media: bar lyric line, dbus position re-anchor, keep upcoming lines in view

closes #3652
fixes #3650
This commit is contained in:
bbedward
2026-10-03 07:07:52 -04:00
parent 48de42b57c
commit 49d7d6b2fe
9 changed files with 340 additions and 6 deletions
@@ -127,6 +127,7 @@ var DEFAULTS = {
music: {
mediaSize: 1,
mediaAdaptiveWidthEnabled: true,
mediaShowLyrics: true,
audioScrollMode: "volume"
},
focusedWindow: {
+26 -1
View File
@@ -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
@@ -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
}
}
@@ -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
@@ -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;
@@ -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"]
+49 -1
View File
@@ -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()
+2
View File
@@ -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
@@ -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": [],