quickshell: spinning vinyl media art with cava spectrum ring

This commit is contained in:
rope 2026-07-30 15:42:00 +01:00
parent 7e8275a654
commit 8dbdb42841

View file

@ -181,6 +181,27 @@ in
esac esac
${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset&timezone=auto&forecast_days=7" ${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset&timezone=auto&forecast_days=7"
''; '';
# cava in raw mode: one newline-terminated frame per tick, bars as
# semicolon-separated 0-100 ints — trivially parsed by SplitParser.
# Drives the media card's spectrum ring. Only spawned while the
# calendar popup is open, so nothing captures audio at idle.
cavaConfig = pkgs.writeText "cava-quickshell.conf" ''
[general]
framerate = 30
bars = 24
autosens = 1
[input]
method = pipewire
source = auto
[output]
method = raw
data_format = ascii
ascii_max_range = 100
channels = mono
'';
in { in {
"quickshell/qmldir" = { "quickshell/qmldir" = {
onChange = qsRestart; onChange = qsRestart;
@ -478,6 +499,8 @@ in
// Recoloured to the theme accent at draw time (ColorOverlay), // Recoloured to the theme accent at draw time (ColorOverlay),
// so the stock blue/grey snowflake artwork is fine here. // so the stock blue/grey snowflake artwork is fine here.
readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg" readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"
readonly property string cava: "${pkgs.cava}/bin/cava"
readonly property string cavaConfig: "${cavaConfig}"
} }
''; '';
}; };
@ -3796,6 +3819,34 @@ in
return null; return null;
} }
// Live spectrum for the media cards' rings. cava runs only
// while this popup is open AND something is playing, so it
// isn't capturing audio in the background. levels is a
// barCount-long array of 0-100 ints, one frame per tick.
readonly property int barCount: 24
property var levels: []
Process {
id: cavaProc
running: calPopup.open && calPopup.players.length > 0
command: [Commands.cava, "-p", Commands.cavaConfig]
stdout: SplitParser {
onRead: data => {
let parts = data.split(";");
let out = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "") continue;
out.push(parseInt(parts[i]) || 0);
}
if (out.length > 0) calPopup.levels = out;
}
}
}
// Bars decay to flat when cava stops, so a paused card
// doesn't keep the last frame frozen around the disc.
onOpenChanged: if (!open) levels = []
Row { Row {
id: calRow id: calRow
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
@ -3992,23 +4043,120 @@ in
width: parent.width width: parent.width
spacing: 12 spacing: 12
ClippingRectangle { // Vinyl: cover art is the disc label,
// spinning while playback runs, ringed
// by cava's spectrum. Kept at 128 so
// the row's text column is unaffected.
Item {
id: vinyl
width: 128; height: 128 width: 128; height: 128
radius: Theme.radiusSmall
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: Theme.base02
SIcon { readonly property real discSize: 86
anchors.centerIn: parent readonly property real ringR: 47
visible: albumArt.status !== Image.Ready readonly property bool spinning: mediaCard.modelData.isPlaying
text: "music"
color: Theme.base04 // Spectrum ring. Each bar sits with
font.pixelSize: 48 // its base on the ring and grows
// outward, then is rotated about the
// disc centre into its slot.
Repeater {
model: calPopup.barCount
Rectangle {
id: eqBar
required property int index
readonly property real lvl:
index < calPopup.levels.length
? calPopup.levels[index] / 100 : 0
width: 3
radius: 1.5
height: 3 + lvl * 13
color: Theme.base0C
opacity: 0.35 + lvl * 0.65
x: vinyl.width / 2 - width / 2
y: vinyl.height / 2 - vinyl.ringR - height
transform: Rotation {
origin.x: eqBar.width / 2
origin.y: vinyl.ringR + eqBar.height
angle: eqBar.index * (360 / calPopup.barCount)
}
Behavior on height {
NumberAnimation { duration: 90; easing.type: Easing.OutQuad }
}
}
} }
Image {
id: albumArt ClippingRectangle {
anchors.fill: parent id: disc
fillMode: Image.PreserveAspectCrop anchors.centerIn: parent
source: mediaCard.modelData.trackArtUrl width: vinyl.discSize
height: vinyl.discSize
radius: width / 2
color: Theme.base02
SIcon {
anchors.centerIn: parent
visible: albumArt.status !== Image.Ready
text: "music"
color: Theme.base04
font.pixelSize: 30
}
Image {
id: albumArt
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
asynchronous: true
source: mediaCard.modelData.trackArtUrl
}
// paused (not stopped) so resuming
// picks up at the current angle
// instead of snapping back to 0.
NumberAnimation on rotation {
from: 0; to: 360
duration: 8000
loops: Animation.Infinite
running: true
paused: !vinyl.spinning
}
}
// Spindle hole outside the disc so
// it stays put while the label turns.
Rectangle {
anchors.centerIn: parent
width: 14; height: 14
radius: 7
color: Theme.cardBg
border.width: 1
border.color: Theme.base03
}
// Source badge. MPRIS identity is the
// browser, not the site, so YouTube is
// inferred from the art host instead.
Rectangle {
id: sourceBadge
readonly property string glyph: {
let ident = (mediaCard.modelData.identity || "").toLowerCase();
let art = (mediaCard.modelData.trackArtUrl || "").toLowerCase();
if (art.indexOf("ytimg") >= 0 || ident.indexOf("youtube") >= 0) return "youtube";
if (ident.indexOf("mpv") >= 0 || ident.indexOf("vlc") >= 0) return "video";
return "";
}
visible: sourceBadge.glyph !== ""
width: 22; height: 22
radius: 11
color: Theme.cardBg
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 6
SIcon {
anchors.centerIn: parent
text: sourceBadge.glyph
color: Theme.base05
font.pixelSize: 14
}
} }
} }