From 49d7d6b2fefadd506de667d9a517e5cfba7871c2 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sat, 3 Oct 2026 07:07:52 -0400 Subject: [PATCH] media: bar lyric line, dbus position re-anchor, keep upcoming lines in view closes #3652 fixes #3650 --- .../Common/settings/BarWidgetDefaults.js | 1 + quickshell/Modules/DankBar/Widgets/Media.qml | 27 +- .../DankBar/Widgets/MediaLyricLine.qml | 238 ++++++++++++++++++ quickshell/Modules/DankDash/DashMetrics.qml | 3 + .../Modules/DankDash/Media/LyricsOverlay.qml | 4 +- .../BarWidgetOptions/MusicOptions.qml | 9 + quickshell/Services/LyricsController.qml | 50 +++- quickshell/Services/LyricsService.qml | 2 + .../translations/settings_search_index.json | 12 +- 9 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 quickshell/Modules/DankBar/Widgets/MediaLyricLine.qml diff --git a/quickshell/Common/settings/BarWidgetDefaults.js b/quickshell/Common/settings/BarWidgetDefaults.js index 2a342334e..42d7e8fbb 100644 --- a/quickshell/Common/settings/BarWidgetDefaults.js +++ b/quickshell/Common/settings/BarWidgetDefaults.js @@ -127,6 +127,7 @@ var DEFAULTS = { music: { mediaSize: 1, mediaAdaptiveWidthEnabled: true, + mediaShowLyrics: true, audioScrollMode: "volume" }, focusedWindow: { diff --git a/quickshell/Modules/DankBar/Widgets/Media.qml b/quickshell/Modules/DankBar/Widgets/Media.qml index 16e872f4e..3a1e0da53 100644 --- a/quickshell/Modules/DankBar/Widgets/Media.qml +++ b/quickshell/Modules/DankBar/Widgets/Media.qml @@ -26,6 +26,16 @@ BasePill { property bool compactMode: false property var widgetData: null readonly property bool adaptiveWidthEnabled: SettingsData.widgetOption("music", widgetData, "mediaAdaptiveWidthEnabled") + readonly property bool lyricsEnabled: SettingsData.widgetOption("music", widgetData, "mediaShowLyrics") && LyricsService.allowed + readonly property bool lyricActive: { + if (!lyricsEnabled) + return false; + const controller = LyricsService.controller; + if (!controller.enabled || !controller.synced) + return false; + const parts = controller.lines[controller.activeIndex]?.parts ?? []; + return parts.some(part => part.x.trim() !== ""); + } readonly property int maxTextWidth: { const size = SettingsData.widgetOption("music", widgetData, "mediaSize"); switch (size) { @@ -57,6 +67,10 @@ BasePill { property real scrollAccumulatorY: 0 property real touchpadThreshold: 100 + LyricsSubscription { + active: root.lyricsEnabled && root.playerAvailable + } + onWheel: function (wheelEvent) { if (SettingsData.widgetOption("music", widgetData, "audioScrollMode") === "nothing") return; @@ -152,7 +166,7 @@ BasePill { if (!root.playerAvailable || root.maxTextWidth <= 0) return 0; // Preserve the fixed-width text slot even if metadata is briefly empty. - if (!root.adaptiveWidthEnabled) + if (!root.adaptiveWidthEnabled || root.lyricActive) return root.maxTextWidth; if (textContainer.displayText.length === 0) return 0; @@ -311,6 +325,7 @@ BasePill { id: mediaText width: contentRoot.measuredTextWidth height: parent.height + visible: !root.lyricActive text: textContainer.displayText color: root.contentColor font.pixelSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText) @@ -318,6 +333,16 @@ BasePill { animateTextChange: true } + MediaLyricLine { + width: contentRoot.measuredTextWidth + height: parent.height + visible: root.lyricActive + controller: LyricsService.controller + color: root.contentColor + font.pixelSize: mediaText.font.pixelSize + live: root.surfaceLive + } + MouseArea { anchors.fill: parent enabled: root.playerAvailable diff --git a/quickshell/Modules/DankBar/Widgets/MediaLyricLine.qml b/quickshell/Modules/DankBar/Widgets/MediaLyricLine.qml new file mode 100644 index 000000000..e4db0341f --- /dev/null +++ b/quickshell/Modules/DankBar/Widgets/MediaLyricLine.qml @@ -0,0 +1,238 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + LayoutMirroring.enabled: false + LayoutMirroring.childrenInherit: true + + required property var controller + property alias font: measure.font + property color color: Theme.surfaceText + property bool live: true + + readonly property int revision: controller.wordRevision + readonly property string fontKey: measure.font.family + "/" + measure.font.pixelSize + "/" + measure.font.weight + property string pagedKey: "" + property string pageKey: "" + property var pages: [] + property bool snapping: false + property real shift: 0 + + clip: true + + onRevisionChanged: place() + onVisibleChanged: place() + onWidthChanged: place() + onFontKeyChanged: place() + Component.onCompleted: place() + + function flat(value) { + return value.replace(/\s*\n\s*/g, " "); + } + + function widthOf(value) { + measure.text = value.replace(/\s+$/, ""); + return measure.implicitWidth; + } + + function place() { + const parts = visible ? (controller.lines[controller.activeIndex]?.parts ?? []) : []; + const at = controller.currentTime(); + const next = parts.find(candidate => !candidate.background && at >= candidate.t && at < candidate.e) ?? parts.find(candidate => !candidate.background) ?? parts[0] ?? null; + if (!next) { + clearLine(); + return; + } + const key = controller.activeIndex + "/" + next.t + "/" + next.voice; + const paged = key + "/" + width + "/" + fontKey; + if (paged !== pagedKey) { + pagedKey = paged; + pages = paginate(next.cues.length > 0 ? next.cues.map(cue => cue.x) : (flat(next.x).match(/\S+\s*/g) ?? [flat(next.x)])); + } + if (next.cues.length > 0) { + placeWords(key, next, at); + return; + } + placeLine(key, next, at); + } + + function placeWords(key, line, at) { + nextPage.stop(); + const index = controller.indexFor(at, line.cues); + const page = pages.find(candidate => index <= candidate.last) ?? pages[pages.length - 1]; + const sung = index < page.first ? 0 : widthOf(flat(line.cues.slice(page.first, index + 1).map(cue => cue.x).join(""))); + turnTo(key + "/" + page.first, page.text, sung); + } + + function placeLine(key, line, at) { + nextPage.stop(); + const span = line.e - line.t; + const timed = pages.length > 1 && Number.isFinite(span) && span > 0; + const pageIndex = timed ? Math.max(0, Math.min(pages.length - 1, Math.floor((at - line.t) / span * pages.length))) : 0; + const page = pages[pageIndex]; + turnTo(key + "/" + page.first, page.text, widthOf(page.text)); + if (!timed || !live || !controller.playing || controller.rate <= 0 || pageIndex >= pages.length - 1) + return; + const boundary = line.t + span * (pageIndex + 1) / pages.length; + nextPage.interval = Math.max(1, Math.min(2147483647, (boundary - at) * 1000 / controller.rate)); + nextPage.start(); + } + + function paginate(chunks) { + const words = []; + for (let i = 0; i < chunks.length; i++) { + const text = flat(chunks[i]); + const open = words.length > 0 && !words[words.length - 1].closed; + if (open) { + words[words.length - 1].last = i; + words[words.length - 1].text += text; + } else { + words.push({ + first: i, + last: i, + text + }); + } + words[words.length - 1].closed = /\s$/.test(text); + } + const result = []; + let page = null; + for (const word of words) { + if (page && widthOf(page.text + word.text) > width) { + result.push(page); + page = null; + } + if (!page) { + page = { + first: word.first, + last: word.last, + text: word.text + }; + continue; + } + page.last = word.last; + page.text += word.text; + } + if (page) + result.push(page); + return result; + } + + function turnTo(key, text, sung) { + if (key === pageKey) { + current.fill = sung; + return; + } + pageKey = key; + slide.stop(); + const animate = live && !SettingsData.reduceMotion && current.text !== ""; + outgoing.text = animate ? current.text : ""; + outgoing.fill = current.fill; + snap(text, sung); + shift = animate ? 1 : 0; + if (animate) + slide.start(); + } + + function clearLine() { + nextPage.stop(); + slide.stop(); + pagedKey = ""; + pageKey = ""; + pages = []; + outgoing.text = ""; + snap("", 0); + shift = 0; + } + + function snap(text, sung) { + snapping = true; + current.text = text; + current.fill = sung; + snapping = false; + } + + Timer { + id: nextPage + onTriggered: root.place() + } + + NumberAnimation { + id: slide + target: root + property: "shift" + to: 0 + duration: Theme.mediumDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + onFinished: outgoing.text = "" + } + + StyledText { + id: measure + visible: false + wrapMode: Text.NoWrap + elide: Text.ElideNone + } + + component Face: Item { + id: face + property string text: "" + property real fill: 0 + + width: root.width + height: root.height + + Behavior on fill { + enabled: root.live && !root.snapping && !SettingsData.reduceMotion + NumberAnimation { + duration: Theme.shortDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + } + } + + StyledText { + id: dim + anchors.verticalCenter: parent.verticalCenter + text: face.text + font: measure.font + color: Theme.withAlpha(root.color, Theme.pendingOpacity) + wrapMode: Text.NoWrap + elide: Text.ElideNone + Accessible.name: face.text + } + + Item { + width: face.fill + height: parent.height + clip: true + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: face.text + font: measure.font + color: root.color + wrapMode: Text.NoWrap + elide: Text.ElideNone + Accessible.ignored: true + } + } + } + + Face { + id: outgoing + y: (root.shift - 1) * root.height + opacity: root.shift + visible: text !== "" + } + + Face { + id: current + y: root.shift * root.height + opacity: 1 - root.shift + } +} diff --git a/quickshell/Modules/DankDash/DashMetrics.qml b/quickshell/Modules/DankDash/DashMetrics.qml index d26325f89..b25b2dafb 100644 --- a/quickshell/Modules/DankDash/DashMetrics.qml +++ b/quickshell/Modules/DankDash/DashMetrics.qml @@ -215,6 +215,9 @@ Singleton { readonly property int mediaLyricsRequestTimeout: 16000 readonly property int mediaLyricsLoadingDelay: 300 readonly property real mediaLyricsPositionTolerance: 0.05 + readonly property int mediaLyricsPositionProbeInterval: 5000 + readonly property real mediaLyricsPositionProbeDrift: 1.5 + readonly property real mediaLyricsPositionProbeThreshold: 1 readonly property real lyricsNearOpacity: 0.55 readonly property real lyricsFarOpacity: 0.3 readonly property real lyricsLineHeight: 1.25 diff --git a/quickshell/Modules/DankDash/Media/LyricsOverlay.qml b/quickshell/Modules/DankDash/Media/LyricsOverlay.qml index f48b05cb4..c8b29f1a5 100644 --- a/quickshell/Modules/DankDash/Media/LyricsOverlay.qml +++ b/quickshell/Modules/DankDash/Media/LyricsOverlay.qml @@ -121,8 +121,8 @@ FocusScope { let target = transcript.contentY; const first = transcript.itemAtIndex(controller.focusedGroups[0] ?? transcript.currentIndex); const last = transcript.currentItem; - if (first && last && last.y + last.height - first.y <= transcript.height) - target = Math.max(transcript.originY, Math.min(transcript.maximumContentY, (first.y + last.y + last.height - transcript.height) / 2)); + if (first && last && first !== last && first.y < target && last.y + last.height - first.y <= transcript.height) + target = Math.max(transcript.originY, Math.min(transcript.maximumContentY, first.y)); target = Math.round(target); transcript.contentY = target; followedItem = last; diff --git a/quickshell/Modules/Settings/BarWidgetOptions/MusicOptions.qml b/quickshell/Modules/Settings/BarWidgetOptions/MusicOptions.qml index c48527fe7..d419fbb08 100644 --- a/quickshell/Modules/Settings/BarWidgetOptions/MusicOptions.qml +++ b/quickshell/Modules/Settings/BarWidgetOptions/MusicOptions.qml @@ -37,6 +37,15 @@ Column { onToggled: checked => root.page.set("mediaAdaptiveWidthEnabled", checked) } + SettingsToggleRow { + resetStore: root.page + resetKeys: ["mediaShowLyrics"] + text: I18n.tr("Lyrics", "Media player lyrics button") + description: I18n.tr("Shows the line being sung instead of the title and artist while synced lyrics are available", "media widget lyrics toggle description") + checked: root.page.value("mediaShowLyrics") + onToggled: checked => root.page.set("mediaShowLyrics", checked) + } + SettingsDropdownRow { resetStore: root.page resetKeys: ["audioScrollMode"] diff --git a/quickshell/Services/LyricsController.qml b/quickshell/Services/LyricsController.qml index 59b27ec6b..eace29857 100644 --- a/quickshell/Services/LyricsController.qml +++ b/quickshell/Services/LyricsController.qml @@ -24,6 +24,7 @@ QtObject { 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 + readonly property string busName: player?.dbusName ?? "" property var lines: [] property var plainLines: [] @@ -54,6 +55,7 @@ QtObject { property bool anchorPlaying: false property real anchorRate: 1 property real positionWall: 0 + property var probe: null property var memo: null onTrackKeyChanged: { @@ -89,7 +91,10 @@ QtObject { } onPositionChanged: scheduleAnchor() onRateChanged: updatePlaybackClock() - onPlayingChanged: updatePlaybackClock() + onPlayingChanged: { + probe = null; + updatePlaybackClock(); + } Component.onDestruction: { cancel(); stopClock(); @@ -175,6 +180,7 @@ QtObject { wordEnd = -1; cueTimes = []; focusedGroups = []; + probe = null; wordRevision++; shownSong = ""; shownResult = ""; @@ -383,6 +389,40 @@ QtObject { resync(); } + // Quickshell fetches Position only on track, state and seek events and extrapolates from there, + // so a player whose Position lagged at that moment stays off until the next seek (#3650). + function probePosition() { + const token = serial; + const name = busName; + DMSService.dbusGetProperty("session", name, "/org/mpris/MediaPlayer2", "org.mpris.MediaPlayer2.Player", "Position", response => root.receivePosition(token, name, response)); + } + + function receivePosition(token, name, response) { + if (token !== serial || name !== busName || !playing || response.error) + return; + const observed = Number(response.result?.value) / 1e6; + if (!Number.isFinite(observed) || observed < 0) + return; + const wall = Date.now(); + const previous = probe; + probe = { + position: observed, + wall + }; + if (!previous) + return; + const advanced = previous.position + (wall - previous.wall) * rate / 1000; + if (Math.abs(observed - advanced) > DashMetrics.mediaLyricsPositionProbeDrift) + return; + if (Math.abs(observed - currentTime()) < DashMetrics.mediaLyricsPositionProbeThreshold) + return; + anchorPosition = observed; + anchorWall = wall; + anchorPlaying = playing; + anchorRate = rate; + resync(); + } + function scheduleAnchor() { positionWall = Date.now(); if (enabled && synced && !stopped && !anchorUpdate.running) @@ -474,6 +514,14 @@ QtObject { onTriggered: root.reanchor(false) } + property Timer positionProbe: Timer { + interval: DashMetrics.mediaLyricsPositionProbeInterval + repeat: true + triggeredOnStart: true + running: root.enabled && root.synced && root.playing && !root.stopped && root.busName !== "" && DMSService.isConnected + onTriggered: root.probePosition() + } + property Timer requestDelay: Timer { interval: DashMetrics.mediaLyricsLoadingDelay onTriggered: root.request() diff --git a/quickshell/Services/LyricsService.qml b/quickshell/Services/LyricsService.qml index 4db58c41a..778f47540 100644 --- a/quickshell/Services/LyricsService.qml +++ b/quickshell/Services/LyricsService.qml @@ -5,12 +5,14 @@ import QtQuick import Quickshell import Quickshell.Services.Mpris import qs.Services +import qs.Modules.DankDash import qs.Modules.DankDash.Media Singleton { id: root property int refCount: 0 + readonly property bool allowed: DashRegistry.option("media", "lyrics") === true && controller.available readonly property bool subscribed: refCount > 0 && controller.available property var activePlayer: subscribed ? MprisController.activePlayer : null readonly property real stableLength: subscribed ? MprisController.activePlayerStableLength : 0 diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 45d9ab633..201175708 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -10245,6 +10245,14 @@ "runtimeType": "barWidgetOption", "optionFile": "AppsDockOptions.qml" }, + { + "section": "barWidgetOption:MusicOptions.qml:3", + "label": "Lyrics", + "tabIndex": 22, + "keywords": [], + "runtimeType": "barWidgetOption", + "optionFile": "MusicOptions.qml" + }, { "section": "barWidgetOption:WorkspaceSwitcherOptions.qml:6", "label": "Max apps to show", @@ -10286,7 +10294,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:MusicOptions.qml:4", + "section": "barWidgetOption:MusicOptions.qml:5", "label": "Media player", "tabIndex": 22, "keywords": [], @@ -10430,7 +10438,7 @@ "optionFile": "PrivacyOptions.qml" }, { - "section": "barWidgetOption:MusicOptions.qml:3", + "section": "barWidgetOption:MusicOptions.qml:4", "label": "Scroll wheel", "tabIndex": 22, "keywords": [],