mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-09 23:18:31 -04:00
media: add opt-in Apple Music animated album covers (#2918)
* media: add opt-in Apple Music animated album covers * media: validate itunes match, touch files for cache --------- Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
@@ -479,6 +479,7 @@ Singleton {
|
||||
property bool mediaAdaptiveWidthEnabled: true
|
||||
property bool audioVisualizerEnabled: true
|
||||
property bool mediaUseAlbumArtAccent: false
|
||||
property bool appleMusicAnimatedArtEnabled: false
|
||||
property string audioScrollMode: "volume"
|
||||
property int audioWheelScrollAmount: 5
|
||||
property bool audioDeviceScrollVolumeEnabled: false
|
||||
|
||||
@@ -205,6 +205,7 @@ var SPEC = {
|
||||
mediaAdaptiveWidthEnabled: { def: true },
|
||||
audioVisualizerEnabled: { def: true },
|
||||
mediaUseAlbumArtAccent: { def: false },
|
||||
appleMusicAnimatedArtEnabled: { def: false },
|
||||
audioScrollMode: { def: "volume" },
|
||||
audioWheelScrollAmount: { def: 5 },
|
||||
audioDeviceScrollVolumeEnabled: { def: false },
|
||||
|
||||
@@ -82,6 +82,15 @@ Item {
|
||||
onToggled: checked => SettingsData.set("mediaUseAlbumArtAccent", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "appleMusicAnimatedArtEnabled"
|
||||
tags: ["apple", "animated", "video", "artwork", "cover"]
|
||||
text: I18n.tr("Apple Music animated covers")
|
||||
description: I18n.tr("Show looping video album artwork from Apple Music when available. Sends the playing artist and album name to Apple.")
|
||||
checked: SettingsData.appleMusicAnimatedArtEnabled
|
||||
onToggled: checked => SettingsData.set("appleMusicAnimatedArtEnabled", checked)
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
property var scrollOptsInternal: ["volume", "song", "nothing"]
|
||||
property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")]
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("AppleMusicArt")
|
||||
readonly property bool enabled: SettingsData.appleMusicAnimatedArtEnabled
|
||||
|
||||
// file:// url of the current album's downloaded animated cover, or empty.
|
||||
property string animatedArtUrl: ""
|
||||
|
||||
readonly property var _curlCmd: ["curl", "-sS", "--fail", "--connect-timeout", "5", "--max-time", "15", "--compressed"]
|
||||
readonly property string _artDir: Paths.strip(Paths.cache) + "/applemusic-art"
|
||||
property string _token: ""
|
||||
// artist\nalbum (lowercased) -> file url or "" for a known miss; never refetched this session.
|
||||
property var _cache: ({})
|
||||
property int _serial: 0
|
||||
|
||||
readonly property string _artist: MprisController.activePlayer?.trackArtist || ""
|
||||
readonly property string _album: MprisController.activePlayer?.trackAlbum || ""
|
||||
readonly property string _cacheKey: _artist !== "" && _album !== "" ? (_artist + "\n" + _album).toLowerCase() : ""
|
||||
|
||||
on_CacheKeyChanged: _schedule()
|
||||
onEnabledChanged: _schedule()
|
||||
Component.onCompleted: {
|
||||
_prune();
|
||||
_schedule();
|
||||
}
|
||||
|
||||
// Downloaded covers are a few MB each; drop anything untouched for 30 days so the
|
||||
// cache can't grow without bound.
|
||||
function _prune() {
|
||||
Proc.runCommand(null, ["sh", "-c", 'test -d "$1" && find "$1" -type f -mtime +30 -delete', "sh", _artDir], () => {}, 50, 10000);
|
||||
}
|
||||
|
||||
function _schedule() {
|
||||
_serial++;
|
||||
_debounce.stop();
|
||||
if (!enabled || _cacheKey === "") {
|
||||
animatedArtUrl = "";
|
||||
return;
|
||||
}
|
||||
if (_cacheKey in _cache) {
|
||||
animatedArtUrl = _cache[_cacheKey];
|
||||
return;
|
||||
}
|
||||
animatedArtUrl = "";
|
||||
_debounce.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: _debounce
|
||||
interval: 1500
|
||||
onTriggered: root._lookup()
|
||||
}
|
||||
|
||||
function _artPath(key) {
|
||||
return _artDir + "/" + Qt.md5(key) + ".mp4";
|
||||
}
|
||||
|
||||
// Disk cache first: a hit needs no network; touching keeps _prune from evicting active covers.
|
||||
function _lookup() {
|
||||
const key = _cacheKey;
|
||||
const serial = _serial;
|
||||
Proc.runCommand(null, ["sh", "-c", 'test -s "$1" && touch "$1"', "sh", _artPath(key)], (output, exitCode) => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
if (exitCode === 0) {
|
||||
_store(key, serial, "file://" + _artPath(key));
|
||||
return;
|
||||
}
|
||||
_search(key, serial);
|
||||
}, 50, 5000);
|
||||
}
|
||||
|
||||
// Bidirectional contains, so "Album" still matches "Album (Deluxe Edition)" either way round.
|
||||
function _looseMatch(a, b) {
|
||||
if (a === "" || b === "")
|
||||
return false;
|
||||
const la = a.toLowerCase();
|
||||
const lb = b.toLowerCase();
|
||||
return la.includes(lb) || lb.includes(la);
|
||||
}
|
||||
|
||||
// US storefront only (search default and the catalog paths below); albums absent there become misses.
|
||||
function _search(key, serial) {
|
||||
const term = encodeURIComponent(_artist + " " + _album);
|
||||
Proc.runCommand(null, _curlCmd.concat(["https://itunes.apple.com/search?media=music&entity=album&limit=1&term=" + term]), (output, exitCode) => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
if (exitCode !== 0) {
|
||||
log.warn("itunes search failed");
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(output).results[0] || null;
|
||||
} catch (e) {
|
||||
log.warn("itunes search parse failed");
|
||||
return;
|
||||
}
|
||||
// A fuzzy first hit for a different record would cache the wrong video under this key.
|
||||
if (!result || !result.collectionId || !_looseMatch(result.artistName || "", _artist) || !_looseMatch(result.collectionName || "", _album)) {
|
||||
_store(key, serial, "");
|
||||
return;
|
||||
}
|
||||
if (_token !== "") {
|
||||
_fetchEditorialVideo(key, serial, result.collectionId);
|
||||
return;
|
||||
}
|
||||
_fetchToken("https://music.apple.com/us/album/" + result.collectionId, token => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
_token = token;
|
||||
_fetchEditorialVideo(key, serial, result.collectionId);
|
||||
});
|
||||
}, 50, 20000);
|
||||
}
|
||||
|
||||
// The anonymous web-player JWT sits in the main JS bundle referenced by any album page.
|
||||
function _fetchToken(pageUrl, callback) {
|
||||
const script = "p=$(curl -sSfL --compressed \"$1\" | grep -oE '/assets/index~[a-zA-Z0-9]+\\.js' | head -1) && curl -sSfL --compressed \"https://music.apple.com$p\" | grep -oE '\"eyJ[A-Za-z0-9._-]+\"' | head -1 | tr -d '\"'";
|
||||
Proc.runCommand(null, ["sh", "-c", script, "sh", pageUrl], (output, exitCode) => {
|
||||
const token = (output || "").trim();
|
||||
if (exitCode !== 0 || token === "") {
|
||||
log.warn("failed to obtain web-player token");
|
||||
return;
|
||||
}
|
||||
callback(token);
|
||||
}, 50, 30000);
|
||||
}
|
||||
|
||||
function _fetchEditorialVideo(key, serial, albumId) {
|
||||
const url = "https://amp-api.music.apple.com/v1/catalog/us/albums/" + albumId + "?extend=editorialVideo";
|
||||
Proc.runCommand(null, _curlCmd.concat(["-H", "Authorization: Bearer " + _token, "-H", "Origin: https://music.apple.com", url]), (output, exitCode) => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
if (exitCode !== 0) {
|
||||
// Token may have expired; rescrape on the next lookup.
|
||||
_token = "";
|
||||
log.warn("editorial video lookup failed");
|
||||
return;
|
||||
}
|
||||
let video;
|
||||
try {
|
||||
const ev = JSON.parse(output).data[0].attributes.editorialVideo;
|
||||
video = ev ? (ev.motionDetailSquare?.video || ev.motionSquareVideo1x1?.video || "") : "";
|
||||
} catch (e) {
|
||||
log.warn("editorial video parse failed");
|
||||
return;
|
||||
}
|
||||
if (video === "") {
|
||||
_store(key, serial, "");
|
||||
return;
|
||||
}
|
||||
_fetchMaster(key, serial, video);
|
||||
}, 50, 20000);
|
||||
}
|
||||
|
||||
function _fetchMaster(key, serial, m3u8Url) {
|
||||
Proc.runCommand(null, _curlCmd.concat([m3u8Url]), (output, exitCode) => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
if (exitCode !== 0) {
|
||||
log.warn("master playlist fetch failed");
|
||||
return;
|
||||
}
|
||||
const variant = _pickVariant(output);
|
||||
if (!variant) {
|
||||
_store(key, serial, "");
|
||||
return;
|
||||
}
|
||||
const variantUrl = variant.startsWith("http") ? variant : m3u8Url.slice(0, m3u8Url.lastIndexOf("/") + 1) + variant;
|
||||
_fetchVariant(key, serial, variantUrl);
|
||||
}, 50, 20000);
|
||||
}
|
||||
|
||||
// Highest-bandwidth avc1 rendition at or below 768px, else the smallest one above;
|
||||
// hvc1 is skipped for decoder compatibility.
|
||||
function _pickVariant(master) {
|
||||
const lines = master.split("\n");
|
||||
let best = null;
|
||||
let bestBw = -1;
|
||||
let smallestOver = null;
|
||||
let smallestOverW = Infinity;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (!l.startsWith("#EXT-X-STREAM-INF:") || l.indexOf("avc1") === -1)
|
||||
continue;
|
||||
const res = /RESOLUTION=(\d+)x/.exec(l);
|
||||
if (!res)
|
||||
continue;
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (lines[j].startsWith("#") || lines[j].trim() === ""))
|
||||
j++;
|
||||
if (j >= lines.length)
|
||||
continue;
|
||||
const uri = lines[j].trim();
|
||||
const w = parseInt(res[1], 10);
|
||||
if (w > 768) {
|
||||
if (w < smallestOverW) {
|
||||
smallestOverW = w;
|
||||
smallestOver = uri;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const bw = /AVERAGE-BANDWIDTH=(\d+)/.exec(l);
|
||||
const bwv = bw ? parseInt(bw[1], 10) : 0;
|
||||
if (bwv > bestBw) {
|
||||
bestBw = bwv;
|
||||
best = uri;
|
||||
}
|
||||
}
|
||||
return best || smallestOver;
|
||||
}
|
||||
|
||||
// The rendition playlist is BYTERANGE segments over one progressive mp4 (EXT-X-MAP).
|
||||
function _fetchVariant(key, serial, variantUrl) {
|
||||
Proc.runCommand(null, _curlCmd.concat([variantUrl]), (output, exitCode) => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
if (exitCode !== 0) {
|
||||
log.warn("rendition playlist fetch failed");
|
||||
return;
|
||||
}
|
||||
const m = /#EXT-X-MAP:URI="([^"]+)"/.exec(output);
|
||||
if (!m) {
|
||||
_store(key, serial, "");
|
||||
return;
|
||||
}
|
||||
const mp4 = m[1].startsWith("http") ? m[1] : variantUrl.slice(0, variantUrl.lastIndexOf("/") + 1) + m[1];
|
||||
_download(key, serial, mp4);
|
||||
}, 50, 20000);
|
||||
}
|
||||
|
||||
// Download once and play the local file: streaming the HLS through the ffmpeg
|
||||
// backend truncates on some TLS stacks, and the cache survives restarts.
|
||||
function _download(key, serial, url) {
|
||||
const path = _artPath(key);
|
||||
// Per-process temp then atomic rename, so a concurrent download for the same
|
||||
// album (rapid track flip-flop) can't interleave writes into one file; the
|
||||
// temp is removed on failure rather than stranded as a .part.
|
||||
const script = 'mkdir -p "${1%/*}" && { test -s "$1" || { t="$1.$$.part"; curl -sSf --connect-timeout 5 --max-time 60 -o "$t" "$2" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }; }; }';
|
||||
Proc.runCommand(null, ["sh", "-c", script, "sh", path, url], (output, exitCode) => {
|
||||
if (serial !== _serial)
|
||||
return;
|
||||
if (exitCode !== 0) {
|
||||
log.warn("artwork download failed");
|
||||
return;
|
||||
}
|
||||
_store(key, serial, "file://" + path);
|
||||
}, 50, 90000);
|
||||
}
|
||||
|
||||
function _store(key, serial, url) {
|
||||
_cache[key] = url;
|
||||
if (serial === _serial)
|
||||
animatedArtUrl = url;
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,16 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Music animated cover, layered over the static art which stays as fallback.
|
||||
Loader {
|
||||
width: albumSize
|
||||
height: albumSize
|
||||
anchors.centerIn: parent
|
||||
z: 1
|
||||
active: MultimediaService.available && root.onScreen && activePlayer?.playbackState === MprisPlaybackState.Playing && AppleMusicArtService.animatedArtUrl !== ""
|
||||
source: "DankAnimatedAlbumArt.qml"
|
||||
}
|
||||
|
||||
// Outgoing art, shown on top only while fading out over the new mainArt.
|
||||
DankCircularImage {
|
||||
id: fadeArt
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
import Quickshell.Widgets
|
||||
import qs.Services
|
||||
|
||||
// Loaded by url so a missing QtMultimedia degrades to the static art underneath.
|
||||
ClippingRectangle {
|
||||
id: root
|
||||
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
contentInsideBorder: true
|
||||
border.color: MediaAccentService.accent
|
||||
border.width: 2
|
||||
// Reveal only while frames are flowing; the static art beneath covers load, stall and error.
|
||||
opacity: video.hasVideo && video.playbackState === MediaPlayer.PlayingState && video.error === MediaPlayer.NoError ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Video {
|
||||
id: video
|
||||
anchors.fill: parent
|
||||
source: AppleMusicArtService.animatedArtUrl
|
||||
fillMode: VideoOutput.PreserveAspectCrop
|
||||
loops: MediaPlayer.Infinite
|
||||
muted: true
|
||||
onSourceChanged: {
|
||||
if (source != "")
|
||||
play();
|
||||
}
|
||||
Component.onCompleted: {
|
||||
if (source != "")
|
||||
play();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6300,6 +6300,34 @@
|
||||
],
|
||||
"description": "Shrink the media widget to fit shorter song titles while still respecting the configured maximum size"
|
||||
},
|
||||
{
|
||||
"section": "appleMusicAnimatedArtEnabled",
|
||||
"label": "Apple Music animated covers",
|
||||
"tabIndex": 16,
|
||||
"category": "Media Player",
|
||||
"keywords": [
|
||||
"album",
|
||||
"animated",
|
||||
"apple",
|
||||
"artist",
|
||||
"artwork",
|
||||
"available",
|
||||
"cover",
|
||||
"covers",
|
||||
"looping",
|
||||
"media",
|
||||
"mpris",
|
||||
"music",
|
||||
"name",
|
||||
"player",
|
||||
"playing",
|
||||
"sends",
|
||||
"show",
|
||||
"spotify",
|
||||
"video"
|
||||
],
|
||||
"description": "Show looping video album artwork from Apple Music when available. Sends the playing artist and album name to Apple."
|
||||
},
|
||||
{
|
||||
"section": "mediaVisualizer",
|
||||
"label": "Audio Visualizer",
|
||||
|
||||
Reference in New Issue
Block a user