Feature: Add now-playing media player to bar

Show the current MPRIS track after the workspaces, with a per-source
brand icon (YouTube, Spotify, Plexamp, Twitch...) that greys out while
paused. Clicking it opens a panel with album art, a seek bar, transport
controls and shuffle/repeat.

Also cap the window title and track text by character count rather
than pixel width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 07:11:14 +01:00
parent 16a0d12b8f
commit 9af1beeaae
4 changed files with 487 additions and 3 deletions

View File

@@ -8,6 +8,11 @@ QtObject {
// a visually distinct icon style from the rest of the bar (currently // a visually distinct icon style from the rest of the bar (currently
// just BluetoothIndicator.qml) without switching every icon over. // just BluetoothIndicator.qml) without switching every icon over.
readonly property string iconFontFamilyRounded: "Material Symbols Rounded" readonly property string iconFontFamilyRounded: "Material Symbols Rounded"
// Brand logos (YouTube, Spotify, Plex...), which Material Symbols doesn't
// have — used by MediaPlayer.qml's per-source icon. The "Propo" build
// draws Nerd Font icons at full size; the Mono one squeezes them into a
// single character cell, which renders them noticeably smaller.
readonly property string brandIconFontFamily: "CaskaydiaCove Nerd Font Propo"
// Material Symbols is a variable font. Left unset, glyphs fall back to the // Material Symbols is a variable font. Left unset, glyphs fall back to the
// font's default optical size (24px), which is tuned for larger rendering // font's default optical size (24px), which is tuned for larger rendering
// and makes some icons (e.g. "lock") look heavier/bigger than others when // and makes some icons (e.g. "lock") look heavier/bigger than others when
@@ -26,4 +31,16 @@ QtObject {
readonly property int spacing: 10 readonly property int spacing: 10
readonly property int animationDuration: 150 readonly property int animationDuration: 150
// Max characters for the bar's free-text labels before they're cut off
// with "…" (ActiveWindow.qml and MediaPlayer.qml).
readonly property int activeWindowMaxChars: 50
readonly property int mediaMaxChars: 40
// Counts code points rather than UTF-16 units so emoji / some CJK
// aren't split in half at the cut.
function truncate(str, maxChars) {
const chars = Array.from(str || "")
return chars.length > maxChars ? chars.slice(0, maxChars - 1).join("").replace(/\s+$/, "") + "…" : chars.join("")
}
} }

View File

@@ -4,10 +4,8 @@ import Quickshell.Hyprland
import "../../config" import "../../config"
Text { Text {
Layout.maximumWidth: 320
elide: Text.ElideRight
font.family: Appearance.fontFamily font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted color: Colors.muted
text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : "" text: Hyprland.activeToplevel ? Appearance.truncate(Hyprland.activeToplevel.title, Appearance.activeWindowMaxChars) : ""
} }

View File

@@ -32,6 +32,7 @@ PanelWindow {
} }
spacing: Appearance.spacing spacing: Appearance.spacing
Workspaces { screen: bar.screen } Workspaces { screen: bar.screen }
MediaPlayer { barWindow: bar }
ActiveWindow {} ActiveWindow {}
} }

View File

@@ -0,0 +1,468 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.Mpris
import "../../config"
// Now-playing label. A playing player always wins; otherwise the label
// sticks to whichever player was last playing, so pausing keeps the track
// in the bar (dimmed). Hidden once that player stops or quits.
//
// Left-click opens a player panel (art, seek bar, transport, shuffle/repeat);
// middle-click toggles play/pause; right-click skips to the next track.
Item {
id: root
implicitWidth: row.implicitWidth
implicitHeight: row.implicitHeight
// The bar's own PanelWindow, passed in from Bar.qml — the panel anchors
// to the bar so it sits flush under it rather than under this label's
// (shorter) text box.
required property var barWindow
// Last player seen playing. Becomes null by itself if that player's
// process exits and Quickshell destroys the object.
property var lastPlayer: null
readonly property var player: {
const players = Mpris.players.values
for (let i = 0; i < players.length; i++) {
if (players[i].isPlaying)
return players[i]
}
if (lastPlayer && lastPlayer.playbackState === MprisPlaybackState.Paused)
return lastPlayer
// Nothing has played since the shell started (e.g. right after a
// reload) — fall back to any paused player that has a track loaded.
for (let i = 0; i < players.length; i++) {
if (players[i].playbackState === MprisPlaybackState.Paused && players[i].trackTitle)
return players[i]
}
return null
}
readonly property bool paused: player !== null && !player.isPlaying
// --- Per-source bar icon ---
// First match wins. `app` is tested against the player's identity +
// desktop entry; `url` against the track's URL, which is how a browser
// tab gets told apart (YouTube vs Twitch...) since the player itself is
// just "Firefox"/"Zen" either way. `glyph` is a Nerd Font code point —
// look new ones up at https://www.nerdfonts.com/cheat-sheet.
readonly property var sources: [
{ name: "YouTube", url: /youtube\.com|youtu\.be/i, glyph: 0xf16a, color: "#ff0033" },
{ name: "Twitch", url: /twitch\.tv/i, glyph: 0xf0543, color: "#9146ff" },
{ name: "SoundCloud", url: /soundcloud\.com/i, glyph: 0xf1be, color: "#ff5500" },
{ name: "Spotify", app: /spotify/i, url: /open\.spotify\.com/i, glyph: 0xf1bc, color: "#1db954" },
{ name: "Plexamp", app: /plex/i, glyph: 0xf06ba, color: "#e5a00d" },
{ name: "VLC", app: /vlc/i, glyph: 0xf057c, color: "#ff8800" }
]
// Anything unmatched: a plain music note in the theme colour.
readonly property var defaultSource: ({ name: "Audio", glyph: 0xf075a, color: Colors.primary })
readonly property var source: {
if (!player)
return defaultSource
const app = [player.identity, player.desktopEntry].join(" ")
const url = String((player.metadata && player.metadata["xesam:url"]) || "")
return sources.find(s => (s.app && s.app.test(app)) || (s.url && s.url.test(url))) || defaultSource
}
onPlayerChanged: {
if (player && player.isPlaying)
lastPlayer = player
if (!player)
panel.visible = false
}
visible: player !== null
function formatTime(seconds) {
if (!(seconds > 0))
return "0:00"
const s = Math.floor(seconds)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
const ss = String(s % 60).padStart(2, "0")
return h > 0 ? h + ":" + String(m % 60).padStart(2, "0") + ":" + ss : m + ":" + ss
}
RowLayout {
id: row
spacing: 6
// Source logo in its brand colour; greyed out while paused.
Text {
font.family: Appearance.brandIconFontFamily
font.pixelSize: 14
color: root.paused ? Colors.muted : root.source.color
text: String.fromCodePoint(root.source.glyph)
}
// CJK characters pull in a fallback font with a taller line box
// (16px vs 13px, baseline 2.5px lower), and centring that taller box
// dropped Japanese titles ~2px below ActiveWindow's text. So the label
// sits in a box sized from the primary font alone, positioned so its
// baseline lands where a plain-Latin label's would.
FontMetrics { id: labelMetrics; font: label.font }
Item {
implicitWidth: label.implicitWidth
implicitHeight: Math.ceil(labelMetrics.height)
Text {
id: label
y: labelMetrics.ascent - baselineOffset
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: root.paused ? Colors.muted : Colors.foreground
text: {
if (!root.player)
return ""
const title = root.player.trackTitle || root.player.identity
const artist = root.player.trackArtist
return Appearance.truncate(artist ? artist + " — " + title : title, Appearance.mediaMaxChars)
}
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onClicked: (mouse) => {
if (!root.player)
return
if (mouse.button === Qt.LeftButton) {
// Line the panel's left edge up with this label. Read at
// click time since the label's x shifts with the workspace
// count and isn't something the anchor can bind to.
panel.anchor.rect.x = root.mapToItem(null, 0, 0).x
panel.visible = !panel.visible
grab.active = panel.visible
} else if (mouse.button === Qt.MiddleButton) {
if (root.player.canTogglePlaying)
root.player.togglePlaying()
} else if (root.player.canGoNext) {
root.player.next()
}
}
}
// MPRIS doesn't push position updates while playing (only on seeks and
// state changes), so poll it — only while the panel is actually showing.
Timer {
running: panel.visible && root.player !== null && root.player.isPlaying
interval: 1000
repeat: true
onTriggered: root.player.positionChanged()
}
// --- Reusable pieces -----------------------------------------------
// Same look as Volume.qml's VolumeSlider. While dragging it shows the
// drag position rather than `value`, so a seek bar doesn't fight the
// player's own position updates mid-drag; `committed` fires on release.
component MediaSlider: Item {
id: sliderRoot
property real value: 0
property bool enabled: true
property real dragValue: -1
readonly property real shownValue: dragValue >= 0 ? dragValue : value
signal moved(real newValue)
signal committed(real newValue)
implicitHeight: 16
Rectangle {
id: track
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 4
color: Colors.muted
opacity: 0.35
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: track.width * Math.max(0, Math.min(1, sliderRoot.shownValue))
height: 4
color: Colors.primary
opacity: sliderRoot.enabled ? 1 : 0.4
}
Rectangle {
width: 12
height: 12
radius: 6
color: Colors.primary
opacity: sliderRoot.enabled ? 1 : 0.4
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(track.width - width, track.width * sliderRoot.shownValue - width / 2))
}
MouseArea {
anchors.fill: parent
enabled: sliderRoot.enabled
cursorShape: Qt.PointingHandCursor
function valueAt(mx) {
return Math.max(0, Math.min(1, mx / track.width))
}
onPressed: (mouse) => {
sliderRoot.dragValue = valueAt(mouse.x)
sliderRoot.moved(sliderRoot.dragValue)
}
onPositionChanged: (mouse) => {
if (!pressed)
return
sliderRoot.dragValue = valueAt(mouse.x)
sliderRoot.moved(sliderRoot.dragValue)
}
onReleased: {
sliderRoot.committed(sliderRoot.dragValue)
sliderRoot.dragValue = -1
}
}
}
component IconButton: Item {
id: btn
property string icon: ""
property int size: 22
property bool enabled: true
// Filled = the big primary-coloured play/pause button.
property bool filled: false
// Active = a toggle that's switched on (shuffle/repeat), tinted primary.
property bool active: false
signal clicked()
implicitWidth: size + 16
implicitHeight: size + 16
opacity: enabled ? 1 : 0.35
Rectangle {
anchors.fill: parent
radius: btn.filled ? width / 2 : Appearance.radiusSmall
color: btn.filled ? Colors.primary
: btnMouse.containsMouse ? Qt.rgba(Colors.foreground.r, Colors.foreground.g, Colors.foreground.b, 0.12)
: "transparent"
Behavior on color { ColorAnimation { duration: Appearance.animationDuration } }
}
Text {
anchors.centerIn: parent
text: btn.icon
font.family: Appearance.iconFontFamilyRounded
// Filled glyphs read better than outlines at transport-button size.
font.variableAxes: ({ "FILL": 1, "wght": 400, "GRAD": 0, "opsz": 24 })
font.pixelSize: btn.size
color: btn.filled ? Colors.primaryText : btn.active ? Colors.primary : Colors.foreground
}
MouseArea {
id: btnMouse
anchors.fill: parent
enabled: btn.enabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: btn.clicked()
}
}
// --- Panel -----------------------------------------------------------
PopupWindow {
id: panel
visible: false
color: "transparent"
readonly property int artSize: 160
readonly property int contentWidth: 470
readonly property int contentPadding: 16
implicitWidth: contentWidth + contentPadding * 2
implicitHeight: Math.max(artSize, infoColumn.implicitHeight) + contentPadding * 2
anchor {
window: root.barWindow
// rect.x is set on click (see the MouseArea above).
rect.y: 0
rect.width: 1
rect.height: root.barWindow.height
edges: Edges.Bottom | Edges.Left
gravity: Edges.Bottom | Edges.Right
margins.bottom: -4
}
HyprlandFocusGrab {
id: grab
windows: [panel]
onCleared: panel.visible = false
}
Rectangle {
anchors.fill: parent
color: Colors.surface
border.color: Colors.muted
border.width: 3
RowLayout {
anchors.fill: parent
anchors.margins: panel.contentPadding
spacing: 16
// Album art, or a placeholder note when the player has none.
Rectangle {
Layout.preferredWidth: panel.artSize
Layout.preferredHeight: panel.artSize
Layout.alignment: Qt.AlignTop
color: Colors.background
Text {
anchors.centerIn: parent
visible: art.status !== Image.Ready
text: "music_note"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 56
color: Colors.muted
}
Image {
id: art
anchors.fill: parent
source: root.player ? root.player.trackArtUrl : ""
fillMode: Image.PreserveAspectCrop
sourceSize.width: panel.artSize * 2
sourceSize.height: panel.artSize * 2
asynchronous: true
smooth: true
}
}
ColumnLayout {
id: infoColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 8
// Track info
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
visible: text !== ""
text: root.player ? root.player.trackArtist : ""
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: Colors.foreground
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.player ? (root.player.trackTitle || root.player.identity) : ""
font.family: Appearance.fontFamily
font.pixelSize: 15
font.bold: true
color: Colors.foreground
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
visible: text !== ""
text: root.player ? root.player.trackAlbum : ""
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
// Seek bar
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 4
spacing: 8
visible: root.player !== null && root.player.lengthSupported && root.player.length > 0
Text {
Layout.preferredWidth: 36
horizontalAlignment: Text.AlignRight
text: root.player ? root.formatTime(seekSlider.dragValue >= 0
? seekSlider.dragValue * root.player.length : root.player.position) : ""
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
MediaSlider {
id: seekSlider
Layout.fillWidth: true
enabled: root.player !== null && root.player.canSeek && root.player.positionSupported
value: root.player && root.player.length > 0 ? root.player.position / root.player.length : 0
onCommitted: (v) => { if (root.player) root.player.position = v * root.player.length }
}
Text {
Layout.preferredWidth: 36
text: root.player ? root.formatTime(root.player.length) : ""
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
// Transport
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 4
IconButton {
icon: "shuffle"
size: 18
visible: root.player !== null && root.player.shuffleSupported
active: root.player !== null && root.player.shuffle
onClicked: root.player.shuffle = !root.player.shuffle
}
IconButton {
icon: "replay_10"
size: 18
visible: root.player !== null && root.player.canSeek
onClicked: root.player.seek(-10)
}
IconButton {
icon: "skip_previous"
enabled: root.player !== null && root.player.canGoPrevious
onClicked: root.player.previous()
}
IconButton {
icon: root.paused ? "play_arrow" : "pause"
size: 26
filled: true
enabled: root.player !== null && root.player.canTogglePlaying
onClicked: root.player.togglePlaying()
}
IconButton {
icon: "skip_next"
enabled: root.player !== null && root.player.canGoNext
onClicked: root.player.next()
}
IconButton {
icon: "forward_10"
size: 18
visible: root.player !== null && root.player.canSeek
onClicked: root.player.seek(10)
}
// Cycles off -> repeat all -> repeat one -> off.
IconButton {
icon: root.player && root.player.loopState === MprisLoopState.Track ? "repeat_one" : "repeat"
size: 18
visible: root.player !== null && root.player.loopSupported
active: root.player !== null && root.player.loopState !== MprisLoopState.None
onClicked: root.player.loopState =
root.player.loopState === MprisLoopState.None ? MprisLoopState.Playlist
: root.player.loopState === MprisLoopState.Playlist ? MprisLoopState.Track
: MprisLoopState.None
}
}
}
}
}
}
}