mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-16 08:14:03 -04:00
530 lines
18 KiB
QML
530 lines
18 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import qs.Common
|
|
import qs.Services
|
|
import qs.Modules.DDash
|
|
|
|
QtObject {
|
|
id: root
|
|
|
|
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 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
|
|
readonly property string busName: player?.dbusName ?? ""
|
|
|
|
property var lines: []
|
|
property var plainLines: []
|
|
property bool synced: false
|
|
property string attributionName: ""
|
|
property string attributionUrl: ""
|
|
property string attributionText: ""
|
|
property int activeIndex: -1
|
|
property real wordTime: -1
|
|
property real wordEnd: -1
|
|
property int wordRevision: 0
|
|
property real sampleTime: 0
|
|
property var cueTimes: []
|
|
property var focusedGroups: []
|
|
property string state: "idle"
|
|
readonly property bool pending: state === "loading" || (state === "idle" && enabled && requestDelay.running)
|
|
property string requestedKey: ""
|
|
property string requestedSong: ""
|
|
property string shownSong: ""
|
|
property string shownResult: ""
|
|
property bool refreshPending: false
|
|
property int requestedDuration: 0
|
|
property var requestId: null
|
|
readonly property bool staleDuration: duration !== requestedDuration && state !== "ready" && state !== "instrumental"
|
|
property int serial: 0
|
|
property real anchorPosition: 0
|
|
property real anchorWall: 0
|
|
property bool anchorPlaying: false
|
|
property real anchorRate: 1
|
|
property real positionWall: 0
|
|
property var probe: null
|
|
property var memo: null
|
|
|
|
onTrackKeyChanged: {
|
|
cancel();
|
|
refreshPending = holdsSong();
|
|
if (!refreshPending)
|
|
clear();
|
|
requestedKey = "";
|
|
if (enabled)
|
|
resume();
|
|
}
|
|
onEnabledChanged: {
|
|
if (!enabled) {
|
|
cancel();
|
|
stopClock();
|
|
if (state === "loading") {
|
|
requestedKey = "";
|
|
state = "idle";
|
|
}
|
|
remember();
|
|
return;
|
|
}
|
|
resume();
|
|
}
|
|
onStaleDurationChanged: {
|
|
if (!enabled || !staleDuration)
|
|
return;
|
|
requestDelay.restart();
|
|
}
|
|
onEmbeddedTextChanged: {
|
|
if (enabled && (state === "none" || state === "error"))
|
|
useEmbeddedText(state);
|
|
}
|
|
onPositionChanged: scheduleAnchor()
|
|
onRateChanged: updatePlaybackClock()
|
|
onPlayingChanged: {
|
|
probe = null;
|
|
updatePlaybackClock();
|
|
}
|
|
Component.onDestruction: {
|
|
cancel();
|
|
stopClock();
|
|
remember();
|
|
}
|
|
|
|
function cancel() {
|
|
serial++;
|
|
if (requestId !== null)
|
|
backend.cancelRequest(requestId);
|
|
requestId = null;
|
|
requestDelay.stop();
|
|
}
|
|
|
|
function stopClock() {
|
|
tick.stop();
|
|
anchorUpdate.stop();
|
|
}
|
|
|
|
function resume() {
|
|
recall();
|
|
if (requestedKey !== trackKey || staleDuration || state === "idle" || state === "error") {
|
|
requestDelay.restart();
|
|
return;
|
|
}
|
|
reanchor();
|
|
}
|
|
|
|
function remember() {
|
|
if (requestedKey === "" || (state !== "ready" && state !== "instrumental" && state !== "none"))
|
|
return;
|
|
memo = {
|
|
requestedKey,
|
|
requestedDuration,
|
|
requestedSong,
|
|
lines,
|
|
plainLines,
|
|
synced,
|
|
cueTimes,
|
|
attributionName,
|
|
attributionUrl,
|
|
attributionText,
|
|
shownSong,
|
|
shownResult,
|
|
state
|
|
};
|
|
}
|
|
|
|
function recall() {
|
|
const last = memo;
|
|
if (!last || last.requestedKey !== trackKey || requestedKey === trackKey)
|
|
return;
|
|
refreshPending = false;
|
|
clearContent();
|
|
for (const name in last)
|
|
root[name] = last[name];
|
|
}
|
|
|
|
function songTitle() {
|
|
return (track?.title ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function holdsSong() {
|
|
const title = songTitle();
|
|
return (state === "ready" || state === "instrumental") && title !== "" && title === shownSong;
|
|
}
|
|
|
|
function clear() {
|
|
clearContent();
|
|
state = "idle";
|
|
}
|
|
|
|
function clearContent() {
|
|
stopClock();
|
|
lines = [];
|
|
plainLines = [];
|
|
synced = false;
|
|
attributionName = "";
|
|
attributionUrl = "";
|
|
attributionText = "";
|
|
activeIndex = -1;
|
|
wordTime = -1;
|
|
wordEnd = -1;
|
|
cueTimes = [];
|
|
focusedGroups = [];
|
|
probe = null;
|
|
wordRevision++;
|
|
shownSong = "";
|
|
shownResult = "";
|
|
}
|
|
|
|
function request() {
|
|
cancel();
|
|
if (!enabled)
|
|
return;
|
|
const refreshing = refreshPending && holdsSong();
|
|
if (settling) {
|
|
if (!refreshing)
|
|
state = "loading";
|
|
requestDelay.restart();
|
|
return;
|
|
}
|
|
refreshPending = false;
|
|
if (!refreshing)
|
|
clear();
|
|
requestedKey = trackKey;
|
|
requestedDuration = duration;
|
|
requestedSong = songTitle();
|
|
const snapshot = track;
|
|
if (!snapshot || ((!snapshot.title || !snapshot.artist) && !fileUrl)) {
|
|
if (!refreshing)
|
|
useEmbeddedText("none");
|
|
return;
|
|
}
|
|
if (!refreshing)
|
|
state = "loading";
|
|
lookup({
|
|
"title": snapshot.title || "",
|
|
"artist": snapshot.artist || "",
|
|
"album": snapshot.album || "",
|
|
"duration": requestedDuration,
|
|
"fileUrl": fileUrl,
|
|
"providers": MediaOptions.enabledLyricsProviders
|
|
}, false, refreshing);
|
|
}
|
|
|
|
function lookup(query, allowNetwork, refreshing) {
|
|
const token = serial;
|
|
const networkQuery = !allowNetwork && query.providers.length > 0 ? query : null;
|
|
const params = Object.assign({
|
|
"allowNetwork": allowNetwork
|
|
}, query);
|
|
requestId = backend.sendRequest("lyrics.get", params, response => root.receive(token, response, refreshing, networkQuery), DashMetrics.mediaLyricsRequestTimeout) ?? null;
|
|
}
|
|
|
|
function receive(token, response, refreshing, networkQuery) {
|
|
if (token !== serial || !enabled)
|
|
return;
|
|
requestId = null;
|
|
const result = response.error ? null : response.result;
|
|
const found = !!result?.found && (result.instrumental || (result.synced ?? []).length > 0 || (result.plain ?? "").trim() !== "");
|
|
if (networkQuery) {
|
|
lookup(networkQuery, true, refreshing || found);
|
|
if (!found)
|
|
return;
|
|
}
|
|
if (refreshing && !found)
|
|
return;
|
|
const resultKey = found ? JSON.stringify([result.instrumental === true, result.synced ?? [], result.voices ?? {}, result.plain ?? "", result.attribution ?? {}]) : "";
|
|
if (refreshing && resultKey === shownResult)
|
|
return;
|
|
if (response.error) {
|
|
useEmbeddedText("error");
|
|
return;
|
|
}
|
|
if (!result?.found) {
|
|
useEmbeddedText("none");
|
|
return;
|
|
}
|
|
shownSong = requestedSong;
|
|
shownResult = resultKey;
|
|
if (result.instrumental) {
|
|
clearContent();
|
|
state = "instrumental";
|
|
return;
|
|
}
|
|
lines = prepareLines(result.synced ?? [], result.voices ?? {});
|
|
synced = lines.length > 0;
|
|
plainLines = (result.plain ?? "").trim().split("\n");
|
|
if (!synced && !plainLines.some(line => line.trim() !== "")) {
|
|
useEmbeddedText("none");
|
|
return;
|
|
}
|
|
attributionName = result.attribution?.name ?? "";
|
|
attributionUrl = result.attribution?.url ?? "";
|
|
attributionText = result.attribution?.text ?? "";
|
|
state = "ready";
|
|
reanchor();
|
|
}
|
|
|
|
function prepareLines(source, voices) {
|
|
const parts = source.filter(line => Number.isFinite(line.t) && line.t >= 0 && typeof line.x === "string").map(line => ({
|
|
t: line.t,
|
|
e: Number.isFinite(line.e) && line.e > line.t ? line.e : 0,
|
|
x: line.x,
|
|
w: wordsFor(line),
|
|
voice: typeof line.voice === "string" ? line.voice : "",
|
|
background: line.background === true,
|
|
group: Number.isInteger(line.group) && line.group > 0 ? line.group : 0
|
|
})).sort((a, b) => a.t - b.t);
|
|
const singers = [...new Set(parts.filter(part => part.voice && !part.background && voices[part.voice]?.type !== "group").map(part => part.voice))];
|
|
const nextStarts = new Map();
|
|
const events = new Set();
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const part = parts[i];
|
|
const lane = part.voice + "\u0000" + part.background;
|
|
const following = nextStarts.get(lane);
|
|
const next = following?.t === part.t ? following.next : following?.t ?? Infinity;
|
|
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 = 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)
|
|
part.e = next > last.t ? next : Infinity;
|
|
if (next > part.t)
|
|
part.e = Math.min(part.e, next);
|
|
nextStarts.set(lane, {
|
|
t: part.t,
|
|
next
|
|
});
|
|
part.cues = cues;
|
|
const voice = voices[part.voice];
|
|
const singerIndex = singers.indexOf(part.voice);
|
|
part.voiceIndex = Math.max(0, singerIndex);
|
|
part.chorus = singers.length > 1 && singerIndex < 0 && voice?.type === "group";
|
|
part.side = singers.length > 1 && singerIndex >= 0 ? (singerIndex % 2 === 0 ? -1 : 1) : 0;
|
|
part.voiceName = typeof voice?.name === "string" ? voice.name : "";
|
|
events.add(part.t);
|
|
if (Number.isFinite(part.e))
|
|
events.add(part.e);
|
|
for (const word of cues)
|
|
events.add(word.t);
|
|
}
|
|
const groups = new Map();
|
|
for (const part of parts) {
|
|
const key = part.group ? "group:" + part.group : "time:" + part.t;
|
|
let group = groups.get(key);
|
|
if (!group) {
|
|
group = {
|
|
t: part.t,
|
|
e: part.e,
|
|
parts: [],
|
|
x: ""
|
|
};
|
|
groups.set(key, group);
|
|
}
|
|
group.t = Math.min(group.t, part.t);
|
|
group.e = Math.max(group.e, part.e);
|
|
group.parts.push(part);
|
|
}
|
|
const prepared = [...groups.values()].sort((a, b) => a.t - b.t);
|
|
for (const group of prepared) {
|
|
group.parts.sort((a, b) => Number(a.background) - Number(b.background) || a.t - b.t);
|
|
group.x = group.parts.map(part => part.x).join("\n");
|
|
}
|
|
cueTimes = [...events].sort((a, b) => a - b).map(t => ({
|
|
t
|
|
}));
|
|
return prepared;
|
|
}
|
|
|
|
function wordsFor(line) {
|
|
if (!Array.isArray(line.w) || !line.w.every(word => Number.isFinite(word.t) && word.t >= line.t && typeof word.x === "string"))
|
|
return [];
|
|
if (line.w.map(word => word.x).join("") !== line.x)
|
|
return [];
|
|
return line.w.map(word => ({
|
|
t: word.t,
|
|
e: Number.isFinite(word.e) && word.e > word.t ? word.e : word.t,
|
|
x: word.x
|
|
}));
|
|
}
|
|
|
|
function useEmbeddedText(fallback) {
|
|
if (!embeddedText.trim()) {
|
|
state = fallback;
|
|
return;
|
|
}
|
|
plainLines = embeddedText.trim().split("\n");
|
|
synced = false;
|
|
shownSong = requestedSong;
|
|
state = "ready";
|
|
}
|
|
|
|
function currentTime() {
|
|
if (!anchorPlaying)
|
|
return anchorPosition;
|
|
return anchorPosition + (Date.now() - anchorWall) * anchorRate / 1000;
|
|
}
|
|
|
|
function updatePlaybackClock() {
|
|
const predicted = currentTime();
|
|
const sampled = position + (anchorPlaying ? (Date.now() - positionWall) * anchorRate / 1000 : 0);
|
|
const pendingSeek = !stopped && anchorUpdate.running && Math.abs(sampled - predicted) >= DashMetrics.mediaLyricsPositionTolerance;
|
|
anchorUpdate.stop();
|
|
anchorPosition = pendingSeek ? sampled : predicted;
|
|
anchorWall = Date.now();
|
|
anchorPlaying = playing;
|
|
anchorRate = rate;
|
|
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)
|
|
anchorUpdate.start();
|
|
}
|
|
|
|
function reanchor(force = true) {
|
|
anchorUpdate.stop();
|
|
const observed = player?.position ?? 0;
|
|
if (!force && anchorPlaying && Math.abs(observed - currentTime()) < DashMetrics.mediaLyricsPositionTolerance)
|
|
return false;
|
|
anchorPosition = observed;
|
|
anchorWall = Date.now();
|
|
anchorPlaying = playing;
|
|
anchorRate = rate;
|
|
resync();
|
|
return true;
|
|
}
|
|
|
|
function resync() {
|
|
tick.stop();
|
|
if (!enabled || !synced)
|
|
return;
|
|
const at = currentTime();
|
|
sampleTime = at;
|
|
const nextIndex = indexFor(at, lines);
|
|
const focused = [];
|
|
for (let i = 0; i <= nextIndex; i++) {
|
|
if (i === nextIndex || lines[i].parts.some(part => part.t <= at && at < part.e))
|
|
focused.push(i);
|
|
}
|
|
if (focused.join() !== focusedGroups.join())
|
|
focusedGroups = focused;
|
|
activeIndex = nextIndex;
|
|
const parts = lines[activeIndex]?.parts ?? [];
|
|
const lead = parts.find(part => !part.background && part.t <= at);
|
|
const timing = wordTiming(lead ?? lines[activeIndex]?.parts[0], at);
|
|
wordTime = timing.t;
|
|
wordEnd = timing.e;
|
|
const next = cueTimes[indexFor(at, cueTimes) + 1]?.t ?? Infinity;
|
|
wordRevision++;
|
|
if (!playing || rate <= 0 || !Number.isFinite(next))
|
|
return;
|
|
tick.interval = Math.max(1, Math.min(2147483647, (next - at) * 1000 / rate));
|
|
tick.start();
|
|
}
|
|
|
|
function wordTiming(part, at) {
|
|
const cues = part?.cues ?? [];
|
|
const index = indexFor(at, cues);
|
|
const word = cues[index];
|
|
if (!word)
|
|
return {
|
|
t: -1,
|
|
e: -1
|
|
};
|
|
return {
|
|
t: word.t,
|
|
e: Math.min(part.e, cues[index + 1]?.t ?? Infinity, word.e)
|
|
};
|
|
}
|
|
|
|
function indexFor(at, timeline) {
|
|
let low = 0;
|
|
let high = timeline.length - 1;
|
|
let found = -1;
|
|
while (low <= high) {
|
|
const mid = (low + high) >> 1;
|
|
if (timeline[mid].t <= at) {
|
|
found = mid;
|
|
low = mid + 1;
|
|
continue;
|
|
}
|
|
high = mid - 1;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
property Timer tick: Timer {
|
|
onTriggered: {
|
|
if (root.anchorUpdate.running && root.reanchor(false))
|
|
return;
|
|
root.resync();
|
|
}
|
|
}
|
|
|
|
property Timer anchorUpdate: Timer {
|
|
interval: 0
|
|
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()
|
|
}
|
|
}
|