mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-20 02:04:01 -04:00
media: bar lyric line, dbus position re-anchor, keep upcoming lines in view
closes #3652 fixes #3650
This commit is contained in:
@@ -127,6 +127,7 @@ var DEFAULTS = {
|
||||
music: {
|
||||
mediaSize: 1,
|
||||
mediaAdaptiveWidthEnabled: true,
|
||||
mediaShowLyrics: true,
|
||||
audioScrollMode: "volume"
|
||||
},
|
||||
focusedWindow: {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user