pragma ComponentBehavior: Bound import Quickshell import Quickshell.Services.Mpris import QtQuick import QtQuick.Layouts import QtQuick.Shapes import QtQuick.Effects import ZShell.Components import ZShell.Services import ZShell.Config import qs.Modules.Dashboard.Dash.Media import qs.Daemons import qs.Components import qs.Helpers import qs.Services Item { id: root readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 property real playerProgress: { const active = Players.active; return active?.length ? (active.position % active.length) / active.length : 0; } property int rowHeight: Tokens.padding.large + Config.dashboard.sizes.mediaProgressThickness + Tokens.spacing.small anchors.left: parent.left anchors.right: parent.right implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 function lengthStr(length: int): string { if (length < 0) return "-1:-1"; const hours = Math.floor(length / 3600); const mins = Math.floor((length % 3600) / 60); const secs = Math.floor(length % 60).toString().padStart(2, "0"); if (hours > 0) return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; return `${mins}:${secs}`; } Behavior on playerProgress { Anim { duration: Tokens.anim.durations.large } } Timer { running: Players.active?.isPlaying ?? false interval: Config.dashboard.mediaUpdateInterval triggeredOnStart: true repeat: true onTriggered: Players.active?.positionChanged() } ServiceRef { service: Audio.cava } BackgroundShapes { anchors.fill: parent } Shape { id: visualizer readonly property int bars: Config.services.visualizerBars property color color: Qt.alpha(Colors.palette.m3primary, 0.5) property color fillColor: Qt.alpha(color, 0.1) anchors.fill: parent anchors.leftMargin: -(shape.strokeWidth / 2) layer.enabled: true asynchronous: true preferredRendererType: Shape.CurveRenderer ShapePath { id: shape strokeColor: visualizer.color fillColor: visualizer.fillColor strokeWidth: 2 PathSvg { path: shape.curvePath } property string curvePath: { const values = Audio.cava.values; const n = values.length; if (n < 2) return ""; const h = root.height + shape.strokeWidth / 2; const w = root.width + shape.strokeWidth; function x(i) { return i * w / (n - 1); } function y(i) { return h - (values[i] * Config.dashboard.sizes.mediaVisualizerSize) / 2; } let d = `M 0 ${h} `; d += `L ${x(0)} ${y(0)} `; for (let i = 0; i < n - 1; ++i) { const x0 = x(i); const y0 = y(i); const x1 = x(i + 1); const y1 = y(i + 1); const prev = Math.max(0, i - 1); const next = Math.min(n - 1, i + 2); const c1x = x0 + (x1 - x(prev)) / 6; const c1y = y0 + (y1 - y(prev)) / 6; const c2x = x1 - (x(next) - x0) / 6; const c2y = y1 - (y(next) - y0) / 6; d += `C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x1} ${y1} `; } d += `L ${w} ${h} `; d += `L 0 ${h} Z`; return d; } } } CustomRect { anchors.fill: visualizer color: Qt.alpha(Colors.palette.m3shadow, 0.2) layer.enabled: true layer.effect: MultiEffect { maskEnabled: true maskSource: visualizer } } RowLayout { id: layout anchors.fill: parent anchors.margins: Tokens.padding.large anchors.leftMargin: Tokens.padding.extraLarge CoverVisualizer { Layout.fillHeight: true implicitWidth: Config.dashboard.sizes.mediaCoverArtSize } ColumnLayout { Layout.fillWidth: true Layout.leftMargin: Tokens.spacing.largeIncreased Layout.rightMargin: Tokens.spacing.largeIncreased spacing: Tokens.spacing.small MarqueeText { id: title Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3primary font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") implicitWidth: parent.width - Tokens.padding.large * 4 } MarqueeText { id: album Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") implicitWidth: parent.width - Tokens.padding.large * 4 } MarqueeText { id: artist Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter color: Colors.palette.m3secondary horizontalAlignment: Text.AlignHCenter pauseMs: 4000 text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") implicitWidth: parent.width - Tokens.padding.large * 4 } RowLayout { id: positionSliderLayout Layout.topMargin: Tokens.spacing.large Layout.fillWidth: true spacing: Tokens.spacing.small TextMetrics { id: timeMetrics text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" } CustomText { id: positionLabel Layout.preferredWidth: timeMetrics.width text: root.lengthStr(Players.active?.position ?? -1) color: Colors.palette.m3onSurfaceVariant font: timeMetrics.font horizontalAlignment: Text.AlignHCenter } CustomSlider { id: positionSlider Layout.fillWidth: true value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength wavy: true animateWave: Players.active?.isPlaying ?? false waveFrequency: 5 waveDuration: 2000 interactionOnMove: false onInteraction: value => { const active = Players.active; if (active?.canSeek && active?.positionSupported) active.position = value * active.length; } Binding { target: positionLabel property: "text" value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) when: positionSlider.dragging } } CustomText { Layout.preferredWidth: timeMetrics.width text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) color: Colors.palette.m3onSurfaceVariant font: timeMetrics.font horizontalAlignment: Text.AlignHCenter } } ButtonRow { Layout.topMargin: Tokens.spacing.largeIncreased Layout.fillWidth: true spacing: Tokens.spacing.extraSmall Layout.leftMargin: positionSliderLayout.implicitWidth / 4 Layout.rightMargin: positionSliderLayout.implicitWidth / 4 IconButton { type: IconButton.Tonal icon: "shuffle" isRound: true shapeMorph: true checked: Players.active?.shuffle ?? false enabled: Players.active?.shuffleSupported onClicked: Players.active.shuffle = !Players.active?.shuffle } IconButton { id: previousBtn type: IconButton.Tonal icon: "skip_previous" isRound: true shapeMorph: true enabled: Players.active?.canGoPrevious onClicked: Players.active?.previous() font.pointSize: Tokens.font.size.large } IconButton { id: playPauseBtn icon: Players.active?.isPlaying ? "pause" : "play_arrow" isRound: true shapeMorph: true fillWidth: true checked: Players.active?.isPlaying ?? false enabled: Players.active?.canTogglePlaying onClicked: Players.active?.togglePlaying() font.pointSize: Tokens.font.size.large } IconButton { id: nextBtn type: IconButton.Tonal icon: "skip_next" isRound: true shapeMorph: true enabled: Players.active?.canGoNext onClicked: Players.active?.next() font.pointSize: Tokens.font.size.large } IconButton { type: IconButton.Tonal icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" isRound: true shapeMorph: true checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist enabled: Players.active?.loopSupported onClicked: { const state = Players.active.loopState; if (state === MprisLoopState.None) Players.active.loopState = MprisLoopState.Track; else if (state === MprisLoopState.Track) Players.active.loopState = MprisLoopState.Playlist; else Players.active.loopState = MprisLoopState.None; } } } } } }