quickshell: split media cards per MPRIS source, add per-stream volume

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rope 2026-06-17 11:43:45 +01:00
parent 6977568bf2
commit 2f51d2b4f1

View file

@ -2214,13 +2214,27 @@ in
return "thunderstorm"; return "thunderstorm";
} }
// --- Media: prefer the actively playing MPRIS player --- // --- Media: one card per MPRIS player so Spotify, a
property var player: { // browser tab, etc. stay separate instead of collapsing
let ps = Mpris.players.values; // into a single combined player. ---
for (let i = 0; i < ps.length; i++) { property var players: Mpris.players.values.filter(
if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i]; 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 { Row {
@ -2405,97 +2419,151 @@ in
width: 300 width: 300
spacing: 8 spacing: 8
// Media player card // Media player cards one per active MPRIS source,
Rectangle { // so Spotify and a browser tab stay separate.
width: parent.width Repeater {
height: 64 model: calPopup.players
radius: Theme.radius
color: Theme.cardBg
visible: calPopup.player !== null
Row { Rectangle {
anchors.fill: parent id: mediaCard
anchors.margins: 8 required property var modelData
spacing: 10 property var pwNode: calPopup.streamFor(modelData)
width: parent.width
height: mediaCol.height + 16
radius: Theme.radius
color: Theme.cardBg
Rectangle { PwObjectTracker { objects: mediaCard.pwNode ? [mediaCard.pwNode] : [] }
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 : ""
}
}
Column { Column {
width: parent.width - 48 - 10 - 88 - 10 id: mediaCol
anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left
spacing: 2 anchors.right: parent.right
SText { anchors.top: parent.top
width: parent.width anchors.margins: 8
text: calPopup.player ? calPopup.player.trackTitle : "" spacing: 8
color: Theme.base05
font.pixelSize: 12 Row {
font.weight: Font.Medium width: parent.width
elide: Text.ElideRight height: 48
} spacing: 10
SText {
width: parent.width
text: calPopup.player ? calPopup.player.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: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" },
{ glyph: "skip_next", act: "next" }
]
Rectangle { Rectangle {
id: mediaBtn width: 48; height: 48
required property var modelData radius: Theme.radiusSmall
width: 28; height: 28; radius: 14 anchors.verticalCenter: parent.verticalCenter
color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" color: Theme.base02
Behavior on color { ColorAnimation { duration: Theme.animFade } } clip: true
SIcon { SIcon {
anchors.centerIn: parent anchors.centerIn: parent
text: mediaBtn.modelData.glyph visible: albumArt.status !== Image.Ready
color: Theme.base05 text: "music_note"
font.pixelSize: 18 color: Theme.base04
font.pixelSize: 22
} }
MouseArea { Image {
id: mediaBtnMa id: albumArt
anchors.fill: parent anchors.fill: parent
hoverEnabled: true fillMode: Image.PreserveAspectCrop
cursorShape: Qt.PointingHandCursor source: mediaCard.modelData.trackArtUrl
onClicked: { }
let p = calPopup.player; }
if (!p) return;
if (mediaBtn.modelData.act === "prev") p.previous(); Column {
else if (mediaBtn.modelData.act === "next") p.next(); width: parent.width - 48 - 10 - 88 - 10
else p.togglePlaying(); 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
}
}
} }
} }
} }