From 2f51d2b4f1fe2008af954ef873ded35eeeb58367 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 11:43:45 +0100 Subject: [PATCH] quickshell: split media cards per MPRIS source, add per-stream volume Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 234 ++++++++++++++++++++++++++-------------- 1 file changed, 151 insertions(+), 83 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 5ab90ea..cd48428 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2214,13 +2214,27 @@ in return "thunderstorm"; } - // --- Media: prefer the actively playing MPRIS player --- - property var player: { - let ps = Mpris.players.values; - for (let i = 0; i < ps.length; i++) { - if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i]; + // --- Media: one card per MPRIS player so Spotify, a + // browser tab, etc. stay separate instead of collapsing + // into a single combined player. --- + property var players: Mpris.players.values.filter( + p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing) + + // Best-effort link from an MPRIS player to its Pipewire + // audio stream (matched by app name), so a card can carry a + // volume slider via the same per-app path as the volume + // widget. null when no stream matches. + function streamFor(player) { + if (!player) return null; + let id = (player.identity || "").toLowerCase(); + let ns = Pipewire.nodes.values; + for (let i = 0; i < ns.length; i++) { + let n = ns[i]; + if (!n.isStream || !n.audio) continue; + let an = (n.properties["application.name"] || "").toLowerCase(); + if (an && (an === id || an.includes(id) || id.includes(an))) return n; } - return ps.length > 0 ? ps[0] : null; + return null; } Row { @@ -2405,97 +2419,151 @@ in width: 300 spacing: 8 - // Media player card - Rectangle { - width: parent.width - height: 64 - radius: Theme.radius - color: Theme.cardBg - visible: calPopup.player !== null + // Media player cards — one per active MPRIS source, + // so Spotify and a browser tab stay separate. + Repeater { + model: calPopup.players - Row { - anchors.fill: parent - anchors.margins: 8 - spacing: 10 + Rectangle { + id: mediaCard + required property var modelData + property var pwNode: calPopup.streamFor(modelData) + width: parent.width + height: mediaCol.height + 16 + radius: Theme.radius + color: Theme.cardBg - Rectangle { - width: 48; height: 48 - radius: Theme.radiusSmall - anchors.verticalCenter: parent.verticalCenter - color: Theme.base02 - clip: true - SIcon { - anchors.centerIn: parent - visible: albumArt.status !== Image.Ready - text: "music_note" - color: Theme.base04 - font.pixelSize: 22 - } - Image { - id: albumArt - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - source: calPopup.player ? calPopup.player.trackArtUrl : "" - } - } + PwObjectTracker { objects: mediaCard.pwNode ? [mediaCard.pwNode] : [] } Column { - width: parent.width - 48 - 10 - 88 - 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - SText { - width: parent.width - text: calPopup.player ? calPopup.player.trackTitle : "" - color: Theme.base05 - font.pixelSize: 12 - font.weight: Font.Medium - elide: Text.ElideRight - } - SText { - width: parent.width - text: calPopup.player ? calPopup.player.trackArtist : "" - color: Theme.base04 - font.pixelSize: 11 - elide: Text.ElideRight - } - } + id: mediaCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 8 + + Row { + width: parent.width + height: 48 + spacing: 10 - Row { - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Repeater { - model: [ - { glyph: "skip_previous", act: "prev" }, - { glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, - { glyph: "skip_next", act: "next" } - ] Rectangle { - id: mediaBtn - required property var modelData - width: 28; height: 28; radius: 14 - color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" - Behavior on color { ColorAnimation { duration: Theme.animFade } } + width: 48; height: 48 + radius: Theme.radiusSmall + anchors.verticalCenter: parent.verticalCenter + color: Theme.base02 + clip: true SIcon { anchors.centerIn: parent - text: mediaBtn.modelData.glyph - color: Theme.base05 - font.pixelSize: 18 + visible: albumArt.status !== Image.Ready + text: "music_note" + color: Theme.base04 + font.pixelSize: 22 } - MouseArea { - id: mediaBtnMa + Image { + id: albumArt anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - let p = calPopup.player; - if (!p) return; - if (mediaBtn.modelData.act === "prev") p.previous(); - else if (mediaBtn.modelData.act === "next") p.next(); - else p.togglePlaying(); + fillMode: Image.PreserveAspectCrop + source: mediaCard.modelData.trackArtUrl + } + } + + Column { + width: parent.width - 48 - 10 - 88 - 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + SText { + width: parent.width + text: mediaCard.modelData.trackTitle + color: Theme.base05 + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + } + SText { + width: parent.width + text: mediaCard.modelData.trackArtist + color: Theme.base04 + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Repeater { + model: [ + { glyph: "skip_previous", act: "prev" }, + { glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, + { glyph: "skip_next", act: "next" } + ] + Rectangle { + id: mediaBtn + required property var modelData + width: 28; height: 28; radius: 14 + color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFade } } + SIcon { + anchors.centerIn: parent + text: mediaBtn.modelData.glyph + color: Theme.base05 + font.pixelSize: 18 + } + MouseArea { + id: mediaBtnMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + let p = mediaCard.modelData; + if (!p) return; + if (mediaBtn.modelData.act === "prev") p.previous(); + else if (mediaBtn.modelData.act === "next") p.next(); + else p.togglePlaying(); + } + } } } } } + + // Per-source volume — same per-app path + // as the volume widget. Shown when the + // player's Pipewire stream is matched. + Row { + width: parent.width + spacing: 8 + visible: mediaCard.pwNode !== null && mediaCard.pwNode.audio !== null + + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "volume_up" + color: Theme.base04 + font.pixelSize: 15 + } + + PillSlider { + width: parent.width - 22 - mediaVolLabel.width - 16 + anchors.verticalCenter: parent.verticalCenter + height: 16 + trackH: 4 + value: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.min(1, mediaCard.pwNode.audio.volume) : 0 + fillColor: Theme.base0C + onMoved: (v) => { if (mediaCard.pwNode && mediaCard.pwNode.audio) mediaCard.pwNode.audio.volume = v; } + } + + SText { + id: mediaVolLabel + width: 36 + text: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.round(mediaCard.pwNode.audio.volume * 100) + "%" : "0%" + color: Theme.base04 + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } } } }