diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 5cf6a16ec..adbe3a6be 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -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 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index dd4f14e1a..a3e9b4a38 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -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 }, diff --git a/quickshell/Modules/Settings/MediaPlayerTab.qml b/quickshell/Modules/Settings/MediaPlayerTab.qml index 7e7eeb41d..03552dcb1 100644 --- a/quickshell/Modules/Settings/MediaPlayerTab.qml +++ b/quickshell/Modules/Settings/MediaPlayerTab.qml @@ -72,6 +72,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")] diff --git a/quickshell/Services/AppleMusicArtService.qml b/quickshell/Services/AppleMusicArtService.qml new file mode 100644 index 000000000..e85e48637 --- /dev/null +++ b/quickshell/Services/AppleMusicArtService.qml @@ -0,0 +1,242 @@ +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 at all. + function _lookup() { + const key = _cacheKey; + const serial = _serial; + Proc.runCommand(null, ["sh", "-c", 'test -s "$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); + } + + 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; + } + if (!result || !result.collectionId) { + _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; hvc1 is skipped for decoder compatibility. + function _pickVariant(master) { + const lines = master.split("\n"); + let best = null; + let bestBw = -1; + 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 || parseInt(res[1], 10) > 768) + continue; + let j = i + 1; + while (j < lines.length && (lines[j].startsWith("#") || lines[j].trim() === "")) + j++; + if (j >= lines.length) + continue; + const bw = /AVERAGE-BANDWIDTH=(\d+)/.exec(l); + const bwv = bw ? parseInt(bw[1], 10) : 0; + if (bwv > bestBw) { + bestBw = bwv; + best = lines[j].trim(); + } + } + return best; + } + + // 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; + } +} diff --git a/quickshell/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml index 351704484..36a891434 100644 --- a/quickshell/Widgets/DankAlbumArt.qml +++ b/quickshell/Widgets/DankAlbumArt.qml @@ -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 diff --git a/quickshell/Widgets/DankAnimatedAlbumArt.qml b/quickshell/Widgets/DankAnimatedAlbumArt.qml new file mode 100644 index 000000000..efb2bfab1 --- /dev/null +++ b/quickshell/Widgets/DankAnimatedAlbumArt.qml @@ -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(); + } + } +} diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 583435dbd..3c6c95be9 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -5800,6 +5800,34 @@ ], "description": "Play sound when volume is adjusted" }, + { + "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": "mediaExcludePlayers", "label": "Excluded Players", @@ -5825,7 +5853,10 @@ "category": "Media Player", "keywords": [ "animated", + "apple", + "artwork", "bars", + "cover", "general", "media", "mpris", @@ -5834,11 +5865,11 @@ "playback", "player", "progress", - "scroll", "spotify", "statusbar", "taskbar", "topbar", + "video", "wave" ], "icon": "music_note",