From 9af1beeaae652bce0dc1789820a28423cd6c780f Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 22 Oct 2026 07:11:14 +0100 Subject: [PATCH] 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 --- .../.config/quickshell/config/Appearance.qml | 17 + .../quickshell/modules/bar/ActiveWindow.qml | 4 +- .../.config/quickshell/modules/bar/Bar.qml | 1 + .../quickshell/modules/bar/MediaPlayer.qml | 468 ++++++++++++++++++ 4 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 dotfiles/.config/quickshell/modules/bar/MediaPlayer.qml diff --git a/dotfiles/.config/quickshell/config/Appearance.qml b/dotfiles/.config/quickshell/config/Appearance.qml index 324b40f..77711e4 100644 --- a/dotfiles/.config/quickshell/config/Appearance.qml +++ b/dotfiles/.config/quickshell/config/Appearance.qml @@ -8,6 +8,11 @@ QtObject { // a visually distinct icon style from the rest of the bar (currently // just BluetoothIndicator.qml) without switching every icon over. 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 // 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 @@ -26,4 +31,16 @@ QtObject { readonly property int spacing: 10 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("") + } } diff --git a/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml b/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml index beb5d74..943e7be 100644 --- a/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml +++ b/dotfiles/.config/quickshell/modules/bar/ActiveWindow.qml @@ -4,10 +4,8 @@ import Quickshell.Hyprland import "../../config" Text { - Layout.maximumWidth: 320 - elide: Text.ElideRight font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall color: Colors.muted - text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : "" + text: Hyprland.activeToplevel ? Appearance.truncate(Hyprland.activeToplevel.title, Appearance.activeWindowMaxChars) : "" } diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index e367414..dd8c2ed 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -32,6 +32,7 @@ PanelWindow { } spacing: Appearance.spacing Workspaces { screen: bar.screen } + MediaPlayer { barWindow: bar } ActiveWindow {} } diff --git a/dotfiles/.config/quickshell/modules/bar/MediaPlayer.qml b/dotfiles/.config/quickshell/modules/bar/MediaPlayer.qml new file mode 100644 index 0000000..162f493 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/MediaPlayer.qml @@ -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 + } + } + } + } + } + } +}