mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 03:04:01 -04:00
396 lines
14 KiB
QML
396 lines
14 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import Quickshell.Services.Mpris
|
|
import qs.Common
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property string _lastArtUrl: ""
|
|
property var artwork: ({
|
|
url: "",
|
|
key: "",
|
|
source: "",
|
|
colors: []
|
|
})
|
|
readonly property string resolvedArtUrl: artwork.url
|
|
readonly property int resolvedArtSide: _largestArt.url === resolvedArtUrl ? _largestArt.side : 0
|
|
property var _candidateArt: null
|
|
property var _quantizedArt: ({
|
|
url: "",
|
|
colors: []
|
|
})
|
|
property var _largestArt: ({
|
|
key: "",
|
|
url: "",
|
|
side: 0
|
|
})
|
|
property alias _bgArtSource: root.resolvedArtUrl
|
|
property bool loading: false
|
|
// sha1s of placeholder art to reject (Chrome's own logo, shown before real cover).
|
|
readonly property var _artHashDenylist: ["764a730860c5b8a7bbee690ee5a443672ae37dc8"]
|
|
|
|
function djb2Hash(str) {
|
|
if (!str)
|
|
return "";
|
|
let hash = 5381;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
hash = hash & 0x7FFFFFFF;
|
|
}
|
|
return hash.toString(16).padStart(8, '0');
|
|
}
|
|
|
|
function _directArtworkUrl(player) {
|
|
if (!player)
|
|
return "";
|
|
|
|
let artUrl = player.trackArtUrl || "";
|
|
if (artUrl !== "") {
|
|
return artUrl;
|
|
}
|
|
|
|
if (player.metadata && player.metadata["mpris:artUrl"]) {
|
|
artUrl = player.metadata["mpris:artUrl"].toString();
|
|
if (artUrl !== "")
|
|
return artUrl;
|
|
}
|
|
|
|
// YouTube publishes no artUrl; derive the thumbnail from the video id.
|
|
if (player.metadata && player.metadata["xesam:url"]) {
|
|
const url = player.metadata["xesam:url"].toString();
|
|
if (url.includes("youtube.com") || url.includes("youtu.be")) {
|
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
|
const match = url.match(regExp);
|
|
if (match && match[2].length === 11) {
|
|
return "https://img.youtube.com/vi/" + match[2] + "/hqdefault.jpg";
|
|
}
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getArtworkUrl(player) {
|
|
const directUrl = _directArtworkUrl(player);
|
|
if (directUrl !== "")
|
|
return directUrl;
|
|
|
|
const equivalent = MprisController.equivalentPlayers(player).find(candidate => {
|
|
return candidate !== player && _directArtworkUrl(candidate) !== "";
|
|
});
|
|
return _directArtworkUrl(equivalent);
|
|
}
|
|
|
|
function _publish(candidate, colors) {
|
|
if (candidate.url !== "" && (candidate.key !== _trackKey() || candidate.source !== getArtworkUrl(activePlayer)))
|
|
return;
|
|
artwork = {
|
|
url: candidate.url,
|
|
key: candidate.key,
|
|
source: candidate.source,
|
|
colors: colors
|
|
};
|
|
_candidateArt = null;
|
|
loading = false;
|
|
}
|
|
|
|
function _commit(url, artKey, srcUrl) {
|
|
if (url !== "" && url === resolvedArtUrl && artKey !== _committedArtKey && srcUrl === _committedSrcUrl) {
|
|
loading = false;
|
|
return;
|
|
}
|
|
const candidate = {
|
|
url: url,
|
|
key: artKey,
|
|
source: srcUrl,
|
|
serial: _requestSerial
|
|
};
|
|
if (url === "" || url === resolvedArtUrl) {
|
|
_publish(candidate, url === "" ? [] : artwork.colors);
|
|
return;
|
|
}
|
|
if (url === _quantizedArt.url) {
|
|
_publish(candidate, _quantizedArt.colors);
|
|
return;
|
|
}
|
|
_candidateArt = candidate;
|
|
_quantize(url);
|
|
}
|
|
|
|
property bool _quantizing: false
|
|
property string _queuedArtUrl: ""
|
|
|
|
// Replacing a live source makes ColorQuantizer wait for the whole global thread pool on the gui thread.
|
|
function _quantize(url) {
|
|
if (_quantizing) {
|
|
_queuedArtUrl = url;
|
|
return;
|
|
}
|
|
_queuedArtUrl = "";
|
|
const previous = quantizer.source.toString();
|
|
quantizer.source = url;
|
|
_quantizing = quantizer.source.toString() !== previous;
|
|
}
|
|
|
|
function _startQueued() {
|
|
if (_queuedArtUrl !== "" && !_quantizing)
|
|
_quantize(_queuedArtUrl);
|
|
}
|
|
|
|
ColorQuantizer {
|
|
id: quantizer
|
|
depth: 4
|
|
rescaleSize: 64
|
|
onColorsChanged: {
|
|
root._quantizing = false;
|
|
root._quantizedArt = {
|
|
url: source.toString(),
|
|
colors: Array.from(colors)
|
|
};
|
|
const candidate = root._candidateArt;
|
|
if (candidate && candidate.serial === root._requestSerial && candidate.url === source.toString())
|
|
root._publish(candidate, root._quantizedArt.colors);
|
|
// colorsChanged fires twice per run; start the queued source after both have seen this one.
|
|
if (root._queuedArtUrl !== "")
|
|
Qt.callLater(root._startQueued);
|
|
}
|
|
}
|
|
|
|
// Chrome can attach the previous track's late, smaller cover to the new track.
|
|
function _noteLargestArt(artKey, art, current) {
|
|
if (artKey !== _pendingArtKey)
|
|
return;
|
|
const known = _largestArt.key === artKey;
|
|
if (known && (current ? art.side > 0 && art.side < _largestArt.side : art.side <= _largestArt.side))
|
|
return;
|
|
_largestArt = {
|
|
key: artKey,
|
|
url: art.url,
|
|
side: art.side
|
|
};
|
|
}
|
|
|
|
function loadArtwork(url, artKey, requestSerial) {
|
|
if (!url || url === "") {
|
|
// Keep stale art; only blank once the empty url debounce settles.
|
|
_lastArtUrl = "";
|
|
loading = false;
|
|
if (!_clearDebounce.running) {
|
|
_clearDebounce.interval = _emptyArtClearMs;
|
|
_clearDebounce.start();
|
|
}
|
|
return;
|
|
}
|
|
_clearDebounce.stop();
|
|
// Same url must re-issue under a new serial; the bump cancelled the in-flight load.
|
|
if (url === _lastArtUrl && requestSerial === _lastIssuedSerial)
|
|
return;
|
|
_lastArtUrl = url;
|
|
_lastIssuedSerial = requestSerial;
|
|
|
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
loading = true;
|
|
const targetUrl = url;
|
|
const hash = djb2Hash(url);
|
|
const cacheDir = Paths.strip(Paths.imagecache);
|
|
const filePath = cacheDir + "/remote_" + hash;
|
|
const localFileUrl = "file://" + filePath;
|
|
|
|
Proc.runCommand(null, ["test", "-f", filePath], (output, exitCode) => {
|
|
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
|
return;
|
|
|
|
if (exitCode === 0) {
|
|
_commit(localFileUrl, artKey, targetUrl);
|
|
} else {
|
|
const dlCmd = "\"$0\" dl -o \"$1\" \"$2\" >/dev/null && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }";
|
|
|
|
// YouTube: try the 16:9 maxres thumbnail before falling back.
|
|
if (targetUrl.includes("img.youtube.com/vi/")) {
|
|
const videoId = targetUrl.split("/vi/")[1].split("/")[0];
|
|
const maxresUrl = "https://img.youtube.com/vi/" + videoId + "/maxresdefault.jpg";
|
|
const mqUrl = "https://img.youtube.com/vi/" + videoId + "/mqdefault.jpg";
|
|
const tmpPath = filePath + "." + requestSerial + ".tmp";
|
|
|
|
Proc.runCommand(null, ["sh", "-c", dlCmd, Proc.dmsBin, tmpPath, maxresUrl, filePath], (maxOutput, maxExitCode) => {
|
|
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
|
return;
|
|
|
|
if (maxExitCode === 0) {
|
|
_commit(localFileUrl, artKey, targetUrl);
|
|
} else {
|
|
Proc.runCommand(null, ["sh", "-c", dlCmd, Proc.dmsBin, tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => {
|
|
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
|
return;
|
|
|
|
_commit(mqExitCode === 0 ? localFileUrl : targetUrl, artKey, targetUrl);
|
|
}, 50, 15000);
|
|
}
|
|
}, 50, 15000);
|
|
} else {
|
|
const tmpPath = filePath + "." + requestSerial + ".tmp";
|
|
Proc.runCommand(null, ["sh", "-c", dlCmd, Proc.dmsBin, tmpPath, targetUrl, filePath], (dlOutput, dlExitCode) => {
|
|
if (_lastArtUrl !== targetUrl || _requestSerial !== requestSerial)
|
|
return;
|
|
|
|
_commit(dlExitCode === 0 ? localFileUrl : targetUrl, artKey, targetUrl);
|
|
}, 50, 15000);
|
|
}
|
|
}
|
|
}, 50, 5000);
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
const localUrl = url;
|
|
const filePath = url.startsWith("file://") ? url.substring(7) : url;
|
|
const cacheDir = Paths.strip(Paths.imagecache);
|
|
// Cover lands after metadata, so poll; commit a content-addressed copy so identical bytes keep an identical url
|
|
const script = "f=\"$1\"; d=\"$2\"; i=0; while [ ! -f \"$f\" ] && [ \"$i\" -lt 20 ]; do sleep 0.15; i=$((i + 1)); done; mkdir -p \"$d\" && cp \"$f\" \"$d/in.$$\" || exit 1; s=$(sha1sum \"$d/in.$$\" | cut -c1-40); if [ -f \"$d/art_$s\" ]; then rm -f \"$d/in.$$\"; else mv \"$d/in.$$\" \"$d/art_$s\" || exit 1; fi; set -- $(od -An -tu1 -j12 -N12 \"$d/art_$s\"); p=0; if [ \"$1 $2 $3 $4\" = \"73 72 68 82\" ]; then w=$(( $5 << 24 | $6 << 16 | $7 << 8 | $8 )); h=$(( $9 << 24 | ${10} << 16 | ${11} << 8 | ${12} )); p=$(( w > h ? w : h )); fi; echo \"$s $p\"";
|
|
Proc.runCommand(null, ["sh", "-c", script, "sh", filePath, cacheDir], (output, exitCode) => _artProbed(localUrl, artKey, requestSerial, output, exitCode), 0, 5000);
|
|
}
|
|
|
|
function _artProbed(localUrl, artKey, requestSerial, output, exitCode) {
|
|
const [sha, sideText] = (output || "").trim().split(" ");
|
|
const art = {
|
|
url: "file://" + Paths.strip(Paths.imagecache) + "/art_" + sha,
|
|
side: Number(sideText) || 0
|
|
};
|
|
const current = _lastArtUrl === localUrl && _requestSerial === requestSerial;
|
|
const usable = exitCode === 0 && _artHashDenylist.indexOf(sha) === -1;
|
|
if (usable)
|
|
_noteLargestArt(artKey, art, current);
|
|
if (!current) {
|
|
// probes finish in any order, so the larger cover can land after the late one was picked
|
|
if (usable && art.url === _largestArt.url && artKey === _pendingArtKey)
|
|
_commit(art.url, artKey, getArtworkUrl(activePlayer));
|
|
return;
|
|
}
|
|
if (exitCode !== 0) {
|
|
_commit("", "", "");
|
|
return;
|
|
}
|
|
if (!usable) {
|
|
_lastArtUrl = "";
|
|
loading = false;
|
|
_clearDebounce.interval = _placeholderArtClearMs;
|
|
_clearDebounce.restart();
|
|
return;
|
|
}
|
|
_commit(_largestArt.url, artKey, localUrl);
|
|
}
|
|
|
|
readonly property int _emptyArtClearMs: 800
|
|
readonly property int _placeholderArtClearMs: 4000
|
|
|
|
Timer {
|
|
id: _clearDebounce
|
|
interval: root._emptyArtClearMs
|
|
onTriggered: {
|
|
if (root._lastArtUrl === "")
|
|
root._commit("", "", "");
|
|
}
|
|
}
|
|
|
|
property MprisPlayer activePlayer: MprisController.activePlayer
|
|
|
|
readonly property string _committedArtKey: artwork.key
|
|
property string _pendingArtKey: ""
|
|
readonly property string _committedSrcUrl: artwork.source
|
|
property string _pendingSrcUrl: ""
|
|
property int _requestSerial: 0
|
|
property int _lastIssuedSerial: -1
|
|
|
|
onActivePlayerChanged: _updateArtUrl()
|
|
|
|
Connections {
|
|
target: MprisController
|
|
function onAvailablePlayersChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: root.activePlayer
|
|
function onIsPlayingChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
function onTrackTitleChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
function onTrackArtistChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
function onTrackAlbumChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
function onTrackArtUrlChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
function onMetadataChanged() {
|
|
root._updateArtUrl();
|
|
}
|
|
}
|
|
|
|
function _trackKey(player) {
|
|
const p = player ?? activePlayer;
|
|
if (!p)
|
|
return "";
|
|
// dbusName is constant per player; uniqueId is per-track and would churn the key.
|
|
const playerId = p.dbusName || p.identity || "";
|
|
const tid = p.metadata && p.metadata["mpris:trackid"] ? p.metadata["mpris:trackid"].toString() : "";
|
|
return playerId + " " + tid + " " + (p.trackTitle || "") + " " + (p.trackArtist || "");
|
|
}
|
|
|
|
function artReadyFor(player) {
|
|
const url = getArtworkUrl(player);
|
|
return url !== "" && url === _committedSrcUrl && _trackKey(player) === _committedArtKey && !loading && resolvedArtUrl !== "";
|
|
}
|
|
|
|
Timer {
|
|
id: artUpdate
|
|
interval: 0
|
|
onTriggered: root._loadCurrentArtwork()
|
|
}
|
|
|
|
Timer {
|
|
id: reusedSourceDelay
|
|
interval: 150
|
|
onTriggered: root.loadArtwork(root._pendingSrcUrl, root._pendingArtKey, root._requestSerial)
|
|
}
|
|
|
|
function _updateArtUrl() {
|
|
artUpdate.restart();
|
|
}
|
|
|
|
function _loadCurrentArtwork() {
|
|
const key = _trackKey();
|
|
const url = getArtworkUrl(activePlayer);
|
|
if (key !== _pendingArtKey || url !== _pendingSrcUrl) {
|
|
_requestSerial++;
|
|
_candidateArt = null;
|
|
reusedSourceDelay.stop();
|
|
}
|
|
_pendingArtKey = key;
|
|
_pendingSrcUrl = url;
|
|
if (url !== "")
|
|
_clearDebounce.stop();
|
|
if (key !== "" && key === _committedArtKey && url === _committedSrcUrl) {
|
|
_clearDebounce.stop();
|
|
loading = false;
|
|
return;
|
|
}
|
|
if (key !== "" && url !== "" && url === _committedSrcUrl && key !== _committedArtKey) {
|
|
loading = true;
|
|
if (!reusedSourceDelay.running)
|
|
reusedSourceDelay.start();
|
|
return;
|
|
}
|
|
loadArtwork(url, key, _requestSerial);
|
|
}
|
|
}
|