diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 14b348a..160f47c 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -181,6 +181,27 @@ in 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" ''; + + # 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 { "quickshell/qmldir" = { onChange = qsRestart; @@ -478,6 +499,8 @@ in // Recoloured to the theme accent at draw time (ColorOverlay), // 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 cava: "${pkgs.cava}/bin/cava" + readonly property string cavaConfig: "${cavaConfig}" } ''; }; @@ -3796,6 +3819,34 @@ in 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 { id: calRow anchors.horizontalCenter: parent.horizontalCenter @@ -3992,23 +4043,120 @@ in width: parent.width 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 - radius: Theme.radiusSmall anchors.verticalCenter: parent.verticalCenter - color: Theme.base02 - SIcon { - anchors.centerIn: parent - visible: albumArt.status !== Image.Ready - text: "music" - color: Theme.base04 - font.pixelSize: 48 + + readonly property real discSize: 86 + readonly property real ringR: 47 + readonly property bool spinning: mediaCard.modelData.isPlaying + + // Spectrum ring. Each bar sits with + // 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 - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - source: mediaCard.modelData.trackArtUrl + + ClippingRectangle { + id: disc + anchors.centerIn: parent + 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 + } } }