Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions quickshell/Common/SettingsData.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions quickshell/Common/settings/SettingsSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions quickshell/Modules/Settings/MediaPlayerTab.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
242 changes: 242 additions & 0 deletions quickshell/Services/AppleMusicArtService.qml
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 10 additions & 0 deletions quickshell/Widgets/DankAlbumArt.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions quickshell/Widgets/DankAnimatedAlbumArt.qml
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
33 changes: 32 additions & 1 deletion quickshell/translations/settings_search_index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -5825,7 +5853,10 @@
"category": "Media Player",
"keywords": [
"animated",
"apple",
"artwork",
"bars",
"cover",
"general",
"media",
"mpris",
Expand All @@ -5834,11 +5865,11 @@
"playback",
"player",
"progress",
"scroll",
"spotify",
"statusbar",
"taskbar",
"topbar",
"video",
"wave"
],
"icon": "music_note",
Expand Down
Loading