diff --git a/quickshell/Modules/DankDash/Media/LyricsMemo.qml b/quickshell/Modules/DankDash/Media/LyricsMemo.qml deleted file mode 100644 index 6433922e0..000000000 --- a/quickshell/Modules/DankDash/Media/LyricsMemo.qml +++ /dev/null @@ -1,7 +0,0 @@ -pragma Singleton - -import Quickshell - -Singleton { - property var last: null -} diff --git a/quickshell/Modules/DankDash/Media/LyricsOverlay.qml b/quickshell/Modules/DankDash/Media/LyricsOverlay.qml index 349f6e6e8..600b40330 100644 --- a/quickshell/Modules/DankDash/Media/LyricsOverlay.qml +++ b/quickshell/Modules/DankDash/Media/LyricsOverlay.qml @@ -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; diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 02a0d605a..4fa009bf1 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -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 diff --git a/quickshell/Modules/DankDash/Media/LyricsController.qml b/quickshell/Services/LyricsController.qml similarity index 90% rename from quickshell/Modules/DankDash/Media/LyricsController.qml rename to quickshell/Services/LyricsController.qml index 8d244b452..b64694408 100644 --- a/quickshell/Modules/DankDash/Media/LyricsController.qml +++ b/quickshell/Services/LyricsController.qml @@ -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; diff --git a/quickshell/Services/LyricsService.qml b/quickshell/Services/LyricsService.qml new file mode 100644 index 000000000..4db58c41a --- /dev/null +++ b/quickshell/Services/LyricsService.qml @@ -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); + } +} diff --git a/quickshell/Services/LyricsSubscription.qml b/quickshell/Services/LyricsSubscription.qml new file mode 100644 index 000000000..99fe2e573 --- /dev/null +++ b/quickshell/Services/LyricsSubscription.qml @@ -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(); + } +} diff --git a/quickshell/tests/qml/media-lyrics.qml b/quickshell/tests/qml/media-lyrics.qml index 6dc3a5754..71e5def2b 100644 --- a/quickshell/tests/qml/media-lyrics.qml +++ b/quickshell/tests/qml/media-lyrics.qml @@ -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);