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
|
// 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("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) : ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
spacing: Appearance.spacing
|
spacing: Appearance.spacing
|
||||||
Workspaces { screen: bar.screen }
|
Workspaces { screen: bar.screen }
|
||||||
|
MediaPlayer { barWindow: bar }
|
||||||
ActiveWindow {}
|
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