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:
@@ -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("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) : ""
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ PanelWindow {
|
||||
}
|
||||
spacing: Appearance.spacing
|
||||
Workspaces { screen: bar.screen }
|
||||
MediaPlayer { barWindow: bar }
|
||||
ActiveWindow {}
|
||||
}
|
||||
|
||||
|
||||
468
dotfiles/.config/quickshell/modules/bar/MediaPlayer.qml
Normal file
468
dotfiles/.config/quickshell/modules/bar/MediaPlayer.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user