lyrics: share one mpris controller via LyricsService, accept plain sources

This commit is contained in:
bbedward committed 2026-09-26 14:34:32 -04:00
1 parent 1fc4537894
commit 09e7ea0221
7 files changed
+164 -98

No files matched your search

@@ -1,7 +0,0 @@
pragma Singleton
import Quickshell
Singleton {
property var last: null
}
@@ -9,7 +9,9 @@ import qs.Modules.DankDash
FocusScope {
id: root
required property var player
property var player: null
property var lyrics: null
property bool smoothHighlight: root.player?.smoothLyrics ?? MediaOptions.defaults.smoothLyrics
property real radius: DashMetrics.mediaInnerRadius
property Item blurSource: null
property Item backgroundParent: null
@@ -19,7 +21,7 @@ FocusScope {
property Item followedItem: null
property real followedY: 0
readonly property var controller: root.player.lyrics
readonly property var controller: root.lyrics ?? LyricsService.controller
readonly property bool ready: controller.state === "ready"
readonly property bool unsynced: ready && !controller.synced
readonly property bool showFollow: ready && controller.synced && !following
@@ -81,8 +83,13 @@ FocusScope {
transcript.contentY = Math.max(transcript.originY, Math.min(transcript.maximumContentY, transcript.contentY + shift));
}
LyricsSubscription {
active: root.visible && !root.lyrics
}
Binding {
target: root.player
when: root.player !== null
property: "lyricsFocusTarget"
value: root
restoreMode: Binding.RestoreBindingOrValue
@@ -317,7 +324,7 @@ FocusScope {
leadFontSize: root.leadFontSize
distance: lyric ? Math.abs(lyric.index - root.activeIndex) : 0
animationsEnabled: root.animationsEnabled
smoothHighlight: root.player.smoothLyrics
smoothHighlight: root.smoothHighlight
inViewport: {
if (!lyric)
return false;
@@ -23,10 +23,8 @@ Item {
property bool playerPaneOpen: true
property Item lyricsFocusTarget: null
property Item lyricsOpener: null
readonly property alias lyrics: lyricsController
readonly property var presentation: mediaPresentation.current
readonly property bool idle: !presentation
readonly property bool presentationSettling: mediaPresentation.settling
property bool wallpaperEnabled: MediaOptions.albumArtBackdrop
property string panel: ""
property Item contentViewport: null
@@ -95,12 +93,6 @@ Item {
lyricsFocusTimer.restart();
}
LyricsController {
id: lyricsController
player: root
enabled: root.lyricsOpen && root.live && root.lyricsEnabled && !root.idle
}
Timer {
id: lyricsFocusTimer
interval: 0
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.Mpris
import qs.Common
import qs.Services
import qs.Modules.DankDash
@@ -9,24 +8,22 @@ import qs.Modules.DankDash
QtObject {
id: root
required property var player
property var track: null
property var player: null
property bool playing: false
property bool stopped: false
property real rate: 1
property bool settling: false
property string url: ""
property string embeddedText: ""
property var backend: DMSService
property bool enabled: false
readonly property var presentation: root.player.presentation
readonly property var activePlayer: root.player.activePlayer
readonly property string fileUrl: {
const url = activePlayer?.metadata?.["xesam:url"] ?? "";
return url.startsWith("file://") ? url : "";
}
readonly property string embeddedText: activePlayer?.metadata?.["xesam:asText"] ?? ""
readonly property string trackKey: presentation ? JSON.stringify([presentation.key, presentation.title, presentation.artist, presentation.album, fileUrl, MediaOptions.enabledLyricsProviders]) : ""
readonly property int duration: Math.round(presentation?.length ?? 0)
readonly property real position: activePlayer?.position ?? 0
readonly property int playbackState: activePlayer?.playbackState ?? MprisPlaybackState.Stopped
readonly property bool playing: playbackState === MprisPlaybackState.Playing
readonly property bool stopped: playbackState === MprisPlaybackState.Stopped
readonly property real rate: activePlayer?.rate ?? 1
readonly property bool available: DMSService.capabilities.includes("lyrics")
readonly property string fileUrl: url.startsWith("file://") ? url : ""
readonly property string trackKey: track ? JSON.stringify([track.key, track.title, track.artist, track.album, fileUrl, MediaOptions.enabledLyricsProviders]) : ""
readonly property int duration: Math.round(track?.length ?? 0)
readonly property real position: player?.position ?? 0
property var lines: []
property var plainLines: []
@@ -57,6 +54,7 @@ QtObject {
property bool anchorPlaying: false
property real anchorRate: 1
property real positionWall: 0
property var memo: null
onTrackKeyChanged: {
cancel();
@@ -123,7 +121,7 @@ QtObject {
function remember() {
if (requestedKey === "" || (state !== "ready" && state !== "instrumental" && state !== "none"))
return;
LyricsMemo.last = {
memo = {
requestedKey,
requestedDuration,
requestedSong,
@@ -141,7 +139,7 @@ QtObject {
}
function recall() {
const last = LyricsMemo.last;
const last = memo;
if (!last || last.requestedKey !== trackKey || requestedKey === trackKey)
return;
refreshPending = false;
@@ -151,7 +149,7 @@ QtObject {
}
function songTitle() {
return (presentation?.title ?? "").trim().toLowerCase();
return (track?.title ?? "").trim().toLowerCase();
}
function holdsSong() {
@@ -187,7 +185,7 @@ QtObject {
if (!enabled)
return;
const refreshing = refreshPending && holdsSong();
if (root.player.presentationSettling) {
if (settling) {
if (!refreshing)
state = "loading";
requestDelay.restart();
@@ -199,7 +197,7 @@ QtObject {
requestedKey = trackKey;
requestedDuration = duration;
requestedSong = songTitle();
const snapshot = presentation;
const snapshot = track;
if (!snapshot || ((!snapshot.title || !snapshot.artist) && !fileUrl)) {
if (!refreshing)
useEmbeddedText("none");
@@ -280,7 +278,7 @@ QtObject {
const cues = part.w.slice().sort((a, b) => a.t - b.t);
const last = cues[cues.length - 1];
if (part.e <= part.t) {
const trackEnd = presentation?.length > part.t ? presentation.length : Infinity;
const trackEnd = track?.length > part.t ? track.length : Infinity;
part.e = last?.e > last?.t ? Math.max(...cues.map(word => word.e)) : next > part.t ? Math.min(next, trackEnd) : trackEnd;
}
if (last && part.e <= last.t)
@@ -381,7 +379,7 @@ QtObject {
function reanchor(force = true) {
anchorUpdate.stop();
const observed = activePlayer?.position ?? 0;
const observed = player?.position ?? 0;
if (!force && anchorPlaying && Math.abs(observed - currentTime()) < DashMetrics.mediaLyricsPositionTolerance)
return false;
anchorPosition = observed;
+41
View File
@@ -0,0 +1,41 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Services.Mpris
import qs.Services
import qs.Modules.DankDash.Media
Singleton {
id: root
property int refCount: 0
readonly property bool subscribed: refCount > 0 && controller.available
property var activePlayer: subscribed ? MprisController.activePlayer : null
readonly property real stableLength: subscribed ? MprisController.activePlayerStableLength : 0
readonly property LyricsController controller: LyricsController {
enabled: root.subscribed && !!root.presenter.current
track: root.presenter.current
settling: root.presenter.settling
player: root.activePlayer
playing: root.activePlayer?.playbackState === MprisPlaybackState.Playing
stopped: !root.activePlayer || root.activePlayer.playbackState === MprisPlaybackState.Stopped
rate: root.activePlayer?.rate ?? 1
url: root.activePlayer?.metadata?.["xesam:url"] ?? ""
embeddedText: root.activePlayer?.metadata?.["xesam:asText"] ?? ""
}
readonly property MediaPresentation presenter: MediaPresentation {
player: root
}
function addRef() {
refCount++;
}
function removeRef() {
refCount = Math.max(0, refCount - 1);
}
}
@@ -0,0 +1,27 @@
import QtQuick
import qs.Services
QtObject {
id: root
property bool active: true
property bool held: false
onActiveChanged: sync()
Component.onCompleted: sync()
Component.onDestruction: {
if (held)
LyricsService.removeRef();
}
function sync() {
if (active === held)
return;
held = active;
if (active) {
LyricsService.addRef();
return;
}
LyricsService.removeRef();
}
}
+65 -57
View File
@@ -12,7 +12,7 @@ ShellRoot {
property bool recordSeekIndices: false
property var seekIndices: []
readonly property int lyricIndex: media.lyrics.activeIndex
readonly property int lyricIndex: LyricsService.controller.activeIndex
onLyricIndexChanged: {
if (recordSeekIndices)
seekIndices.push(lyricIndex);
@@ -68,20 +68,16 @@ ShellRoot {
DC.Style.theme = Theme;
DC.Style.settings = SettingsData;
DC.I18n.backend = I18n;
media.lyrics.backend = backend;
media.lyrics.player = lyricsPlayer;
LyricsService.controller.backend = backend;
LyricsService.activePlayer = source;
}
QtObject {
id: lyricsPlayer
property var presentation: media.presentation
property QtObject activePlayer: QtObject {
id: source
property real position: 0
property real rate: 1
property int playbackState: MprisPlaybackState.Playing
property var metadata: ({})
}
id: source
property real position: 0
property real rate: 1
property int playbackState: MprisPlaybackState.Playing
property var metadata: ({})
}
QtObject {
@@ -110,6 +106,21 @@ ShellRoot {
}
}
Component {
id: plainLyrics
LyricsController {
enabled: true
track: ({
title: "Plugin song",
artist: "Plugin artist",
length: 60
})
player: QtObject {
property real position: 12
}
}
}
TestCase {
id: input
when: false
@@ -133,14 +144,6 @@ ShellRoot {
}
}
Component {
id: recreatedTab
MediaPlayerTab {
visible: false
live: false
}
}
function check(value, message) {
if (!value)
throw new Error(message);
@@ -167,7 +170,7 @@ ShellRoot {
function seekPaused(position) {
source.position = position;
waitFor(() => Math.abs(media.lyrics.sampleTime - position) < 0.01, "lyrics resync to " + position);
waitFor(() => Math.abs(LyricsService.controller.sampleTime - position) < 0.01, "lyrics resync to " + position);
}
function vocal(overlay, text) {
@@ -202,10 +205,10 @@ ShellRoot {
root.recordSeekIndices = true;
source.position = 30;
source.position = 10;
waitFor(() => media.lyrics.activeIndex === 1, "seek lands on the second line");
waitFor(() => LyricsService.controller.activeIndex === 1, "seek lands on the second line");
root.recordSeekIndices = false;
check(root.seekIndices.length === 1 && root.seekIndices[0] === 1, "seek updates publish only the settled lyric line");
check(media.lyrics.lines.length === 5 && media.lyrics.lines[2].x.includes("backing vocal"), "same-time lyrics stay together");
check(LyricsService.controller.lines.length === 5 && LyricsService.controller.lines[2].x.includes("backing vocal"), "same-time lyrics stay together");
waitFor(() => media.lyricsFocusTarget?.activeFocus, "lyrics receives keyboard focus");
let overlay = media.lyricsFocusTarget;
const line = find(overlay, item => item.text === root.longLine && item.truncated !== undefined);
@@ -217,24 +220,24 @@ ShellRoot {
const settledCalls = backend.calls;
MprisController.stableAlbum = "Late album";
waitFor(() => backend.calls === settledCalls + 1, "a late album refreshes the lookup");
check(media.lyrics.state === "ready" && media.lyrics.tick.running, "the shown lyrics keep following during the refresh");
check(LyricsService.controller.state === "ready" && LyricsService.controller.tick.running, "the shown lyrics keep following during the refresh");
backend.respond({
result: {
found: true,
synced: root.timed
}
});
check(media.lyrics.tick.running, "an unchanged refresh keeps the timer running");
check(LyricsService.controller.tick.running, "an unchanged refresh keeps the timer running");
source.playbackState = MprisPlaybackState.Stopped;
check(media.lyrics.activeIndex === 1, "stopped playback holds the lyric position");
check(LyricsService.controller.activeIndex === 1, "stopped playback holds the lyric position");
source.playbackState = MprisPlaybackState.Playing;
source.playbackState = MprisPlaybackState.Paused;
check(!media.lyrics.tick.running, "paused playback has no lyric timer");
check(!LyricsService.controller.tick.running, "paused playback has no lyric timer");
source.playbackState = MprisPlaybackState.Playing;
check(media.lyrics.tick.running, "resuming schedules the next line");
check(LyricsService.controller.tick.running, "resuming schedules the next line");
source.position = 9.5;
source.playbackState = MprisPlaybackState.Paused;
check(Math.abs(media.lyrics.currentTime() - 9.5) < 0.05 && media.lyrics.activeIndex === 0 && !media.lyrics.tick.running, "pausing immediately after a seek retains the seek target");
check(Math.abs(LyricsService.controller.currentTime() - 9.5) < 0.05 && LyricsService.controller.activeIndex === 0 && !LyricsService.controller.tick.running, "pausing immediately after a seek retains the seek target");
SettingsData.reduceMotion = false;
seekPaused(30.3);
const wordText = find(overlay, item => item.Accessible.name === root.wordLine && item.textFormat !== undefined);
@@ -248,18 +251,11 @@ ShellRoot {
source.playbackState = MprisPlaybackState.Playing;
media.lyricsOpen = false;
waitFor(() => !media.lyricsFocusTarget, "closing releases the view");
check(!media.lyrics.tick.running, "closing stops scheduling");
check(!LyricsService.controller.tick.running, "closing stops scheduling");
media.lyricsOpen = true;
waitFor(() => !!media.lyricsFocusTarget, "reopened lyrics load");
check(backend.calls === settledCalls + 1 && media.lyrics.state === "ready", "reopening reuses loaded lyrics");
const recreated = recreatedTab.createObject(stage);
recreated.lyrics.backend = backend;
recreated.lyrics.player = lyricsPlayer;
recreated.lyricsOpen = true;
recreated.live = true;
check(recreated.lyrics.state === "ready" && !recreated.lyrics.pending && recreated.lyrics.lines.length === media.lyrics.lines.length && backend.calls === settledCalls + 1, "a recreated media tab shows the loaded lyrics without loading");
recreated.destroy();
media.lyrics.request();
check(backend.calls === settledCalls + 1 && LyricsService.controller.state === "ready", "reopening reuses loaded lyrics");
LyricsService.controller.request();
const stale = backend.pending[backend.nextId];
MprisController.stableTitle = "Next track";
waitFor(() => backend.cancelled > 0, "track change cancels old response handler");
@@ -269,7 +265,7 @@ ShellRoot {
plain: "stale lyrics"
}
});
check(media.lyrics.state !== "ready", "late response cannot populate a new track");
check(LyricsService.controller.state !== "ready", "late response cannot populate a new track");
backend.respond({
result: {
found: true,
@@ -282,16 +278,16 @@ ShellRoot {
input.keyClick(Qt.Key_End);
const list = find(overlay, item => typeof item.positionViewAtEnd === "function");
waitFor(() => list.contentY > 0, "plain lyrics scroll to the end");
check(!media.lyrics.synced, "plain lyrics are not synchronized");
check(!LyricsService.controller.synced, "plain lyrics are not synchronized");
source.metadata = {
"xesam:asText": "Embedded text remains available offline"
};
media.lyrics.request();
LyricsService.controller.request();
backend.respond({
error: "offline"
});
check(media.lyrics.state === "ready" && media.lyrics.plainLines[0] === source.metadata["xesam:asText"], "network errors fall back to embedded lyrics");
media.lyrics.request();
check(LyricsService.controller.state === "ready" && LyricsService.controller.plainLines[0] === source.metadata["xesam:asText"], "network errors fall back to embedded lyrics");
LyricsService.controller.request();
const oldProviderReply = backend.pending[backend.nextId];
const oldCalls = backend.calls;
SettingsData.mediaLyricsProviders = [
@@ -315,7 +311,7 @@ ShellRoot {
}
});
waitFor(() => backend.calls === oldCalls + 1, "provider change requests again");
check(JSON.stringify(backend.lastParams.providers) === '["lrclib","lyricsplus"]' && media.lyrics.state === "loading", "priority changes discard pending results and request enabled providers in order");
check(JSON.stringify(backend.lastParams.providers) === '["lrclib","lyricsplus"]' && LyricsService.controller.state === "loading", "priority changes discard pending results and request enabled providers in order");
SettingsData.mediaLyricsProviders = MediaOptions.lyricsProviders.map(provider => ({
id: provider.id,
enabled: false
@@ -327,10 +323,10 @@ ShellRoot {
found: false
}
});
check(media.lyrics.state === "ready" && !media.lyrics.synced, "embedded lyrics remain available with all providers off");
check(LyricsService.controller.state === "ready" && !LyricsService.controller.synced, "embedded lyrics remain available with all providers off");
source.playbackState = MprisPlaybackState.Paused;
source.position = 13;
media.lyrics.request();
LyricsService.controller.request();
backend.respond({
result: {
found: true,
@@ -436,7 +432,7 @@ ShellRoot {
]
}
});
waitFor(() => !!media.lyricsFocusTarget && media.lyrics.synced, "duet lyrics load");
waitFor(() => !!media.lyricsFocusTarget && LyricsService.controller.synced, "duet lyrics load");
overlay = media.lyricsFocusTarget;
overlay.snapToCurrent();
const leadVocal = vocal(overlay, "Lead held");
@@ -444,9 +440,9 @@ ShellRoot {
const replyVocal = vocal(overlay, "Reply");
check(leadVocal.current && backingVocal.current && replyVocal.current, "both singers and backing vocals can be active together");
check(Math.abs(leadVocal.wordProgress - 0.75) < 0.01 && Math.abs(backingVocal.wordProgress - 1 / 3) < 0.01 && Math.abs(replyVocal.wordProgress - 0.25) < 0.01, "each voice uses its own word duration");
check(media.lyrics.lines[1].parts.length === 2, "backing vocals remain grouped with their lead");
check(LyricsService.controller.lines[1].parts.length === 2, "backing vocals remain grouped with their lead");
seekPaused(17);
check(leadVocal.current && !backingVocal.current && !replyVocal.current && media.lyrics.activeIndex === 2, "parts end independently without scrolling backwards");
check(leadVocal.current && !backingVocal.current && !replyVocal.current && LyricsService.controller.activeIndex === 2, "parts end independently without scrolling backwards");
check(replyVocal.highlighted && replyVocal.wordProgress === 1, "completed words retain their highlight while the line stays in focus");
seekPaused(22.5);
const finishedLine = vocal(overlay, "After the duet");
@@ -467,8 +463,8 @@ ShellRoot {
seekPaused(13);
check(backingVocal.current && replyVocal.current && Math.abs(replyVocal.wordProgress - 0.25) < 0.01, "seeking back restores every overlapping part");
MprisController._syncStableMeta();
media.lyrics.player = media;
media.lyrics.request();
LyricsService.activePlayer = media.activePlayer;
LyricsService.controller.request();
backend.respond({
result: {
found: true,
@@ -478,15 +474,27 @@ ShellRoot {
const seekbar = find(media, item => typeof item.seekTo === "function");
for (const target of [31, 10.3, 20.2, 31.4]) {
seekbar.seekTo(target);
waitFor(() => media.activePlayer.position >= target - 0.05 && media.activePlayer.position < target + 5 && Math.abs(media.lyrics.currentTime() - media.activePlayer.position) < 0.05 && media.lyrics.activeIndex === media.lyrics.indexFor(target, media.lyrics.lines), "DMS seekbar resynchronizes against real MPRIS after seeking to " + target);
waitFor(() => media.activePlayer.position >= target - 0.05 && media.activePlayer.position < target + 5 && Math.abs(LyricsService.controller.currentTime() - media.activePlayer.position) < 0.05 && LyricsService.controller.activeIndex === LyricsService.controller.indexFor(target, LyricsService.controller.lines), "DMS seekbar resynchronizes against real MPRIS after seeking to " + target);
}
media.activePlayer.pause();
waitFor(() => !media.lyrics.playing, "real MPRIS pause reaches lyrics");
waitFor(() => !LyricsService.controller.playing, "real MPRIS pause reaches lyrics");
seekbar.seekTo(30.3);
waitFor(() => Math.abs(media.lyrics.currentTime() - 30.3) < 0.01 && media.lyrics.wordTime === 30.25, "DMS seekbar updates the current word while paused");
waitFor(() => Math.abs(LyricsService.controller.currentTime() - 30.3) < 0.01 && LyricsService.controller.wordTime === 30.25, "DMS seekbar updates the current word while paused");
media.activePlayer.play();
waitFor(() => media.lyrics.currentTime() > 30.35 && Math.abs(media.lyrics.currentTime() - media.activePlayer.position) < 0.05, "resuming continues from the seek target");
console.log("FIXTURE_PASS lyric lookup, seeking, word timing, overlapping vocals, stale replies and provider fallback");
waitFor(() => LyricsService.controller.currentTime() > 30.35 && Math.abs(LyricsService.controller.currentTime() - media.activePlayer.position) < 0.05, "resuming continues from the seek target");
const plain = plainLyrics.createObject(root, {
backend: backend
});
waitFor(() => backend.lastParams.title === "Plugin song", "a plain source requests its own track");
backend.respond({
result: {
found: true,
synced: root.timed
}
});
check(plain.activeIndex === 1, "a plain source follows its own position");
plain.destroy();
console.log("FIXTURE_PASS lyric lookup, seeking, word timing, overlapping vocals, stale replies, provider fallback and plain sources");
Qt.quit();
} catch (error) {
console.error("FIXTURE_FAIL", error);