import QtQuick import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.Mpris import "." as M M.BarSection { id: root spacing: M.Theme.moduleSpacing opacity: M.Modules.mpris.enable && player !== null ? 1 : 0 visible: opacity > 0 tooltip: "" readonly property MprisPlayer player: Mpris.players.values[0] ?? null readonly property bool playing: player?.playbackState === MprisPlaybackState.Playing property string _cachedArt: "" property string _artTrack: "" // Cache art URL at root level so it's captured even when panel is hidden readonly property string _artUrl: player?.trackArtUrl ?? "" readonly property string _currentTrack: player?.trackTitle ?? "" on_ArtUrlChanged: if (_artUrl) _cachedArt = _artUrl on_CurrentTrackChanged: if (_currentTrack !== _artTrack) { _artTrack = _currentTrack; _cachedArt = _artUrl || ""; } // Preload art while panel is hidden — ensures QML image cache has the pixels Image { visible: false source: root._cachedArt asynchronous: true } // Cava visualizer — 16 bars, raw output mode property var _cavaBars: Array(16).fill(0) Process { id: cavaProc running: root.playing command: ["sh", "-c", "cfg=$(mktemp /tmp/nova-cava-XXXXXX.conf);" + "cat > \"$cfg\" << 'CAVAEOF'\n" + "[general]\nbars=16\nframerate=30\n[output]\nmethod=raw\nraw_target=/dev/stdout\ndata_format=ascii\nascii_max_range=100\n" + "CAVAEOF\n" + "trap 'rm -f \"$cfg\"' EXIT;" + "exec cava -p \"$cfg\""] stdout: SplitParser { splitMarker: "\n" onRead: line => { const vals = line.split(";").filter(s => s).map(Number); if (vals.length >= 16) root._cavaBars = vals.map(v => v / 100); } } } required property var bar property bool _panelHovered: false property bool _pinned: false readonly property bool _anyHover: root._hovered || _panelHovered readonly property bool _showPanel: _anyHover || _pinned on_AnyHoverChanged: { if (_anyHover) _unpinTimer.stop(); else if (_pinned) _unpinTimer.start(); } Timer { id: _unpinTimer interval: 500 onTriggered: root._pinned = false } M.BarIcon { icon: root.playing ? "\uF04B" : (root.player?.playbackState === MprisPlaybackState.Paused ? "\uDB80\uDFE4" : "\uDB81\uDCDB") anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root._pinned = !root._pinned } } M.BarLabel { label: root.player?.trackTitle || root.player?.identity || "" elide: Text.ElideRight width: Math.min(implicitWidth, 200) anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root._pinned = !root._pinned } } PanelWindow { id: panel screen: QsWindow.window?.screen ?? null visible: _winVisible color: "transparent" property bool _winVisible: false WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.exclusiveZone: 0 WlrLayershell.namespace: "nova-mpris" anchors.top: true anchors.left: true margins.top: 0 margins.left: Math.max(0, Math.min(Math.round(root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) - implicitWidth / 2), (panel.screen?.width ?? 1920) - implicitWidth)) implicitWidth: panelContent.width implicitHeight: panelContent.height Connections { target: root function on_ShowPanelChanged() { if (root._showPanel) { panel._winVisible = true; hideAnim.stop(); showAnim.start(); } else { showAnim.stop(); hideAnim.start(); } } } ParallelAnimation { id: showAnim NumberAnimation { target: panelContent property: "opacity" to: 1 duration: 120 easing.type: Easing.OutCubic } NumberAnimation { target: panelContent property: "y" to: 0 duration: 150 easing.type: Easing.OutCubic } } ParallelAnimation { id: hideAnim NumberAnimation { target: panelContent property: "opacity" to: 0 duration: 150 easing.type: Easing.InCubic } NumberAnimation { target: panelContent property: "y" to: -panelContent.height duration: 150 easing.type: Easing.InCubic } onFinished: panel._winVisible = false } MouseArea { anchors.fill: parent hoverEnabled: true onContainsMouseChanged: root._panelHovered = containsMouse } Rectangle { x: panelContent.x y: panelContent.y width: panelContent.width height: panelContent.height color: M.Theme.base01 opacity: panelContent.opacity * Math.max(M.Theme.barOpacity, 0.85) topLeftRadius: 0 topRightRadius: 0 bottomLeftRadius: M.Theme.radius bottomRightRadius: M.Theme.radius } Column { id: panelContent width: 280 opacity: 0 y: -height topPadding: 4 bottomPadding: 4 spacing: 2 // Album art Item { width: parent.width height: _artImg._hasArt ? 140 : 60 clip: true Rectangle { anchors.fill: parent color: M.Theme.base02 } Image { id: _artImg anchors.fill: parent fillMode: Image.PreserveAspectCrop visible: _hasArt asynchronous: true source: root._cachedArt property bool _hasArt: false onStatusChanged: if (status === Image.Ready) _hasArt = true Connections { target: root function on_CachedArtChanged() { if (!root._cachedArt) _artImg._hasArt = false; } } } // Visualizer bars Row { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: parent.height * 0.6 spacing: 2 visible: root.playing opacity: 0.5 Repeater { model: 16 Rectangle { required property int index width: (parent.width - 15 * parent.spacing) / 16 height: parent.height * (root._cavaBars[index] ?? 0) anchors.bottom: parent.bottom color: M.Theme.base0E radius: 1 Behavior on height { NumberAnimation { duration: 50 } } } } } Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: 40 visible: _artImg.visible gradient: Gradient { GradientStop { position: 0 color: "transparent" } GradientStop { position: 1 color: M.Theme.base01 } } } Text { anchors.centerIn: parent text: "\uF001" color: M.Theme.base04 font.pixelSize: 28 font.family: M.Theme.iconFontFamily visible: _artImg.status !== Image.Ready } } // Track info Item { width: parent.width height: titleCol.implicitHeight + 8 Column { id: titleCol anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 12 anchors.rightMargin: 12 spacing: 2 Text { width: parent.width text: root.player?.trackTitle || "No track" color: M.Theme.base05 font.pixelSize: M.Theme.fontSize + 1 font.family: M.Theme.fontFamily font.bold: true elide: Text.ElideRight } Text { width: parent.width text: { const p = root.player; if (!p) return ""; const artist = Array.isArray(p.trackArtists) ? p.trackArtists.join(", ") : (p.trackArtists || ""); return [artist, p.trackAlbum].filter(s => s).join(" \u2014 "); } color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily elide: Text.ElideRight visible: text !== "" } } } // Progress Item { width: parent.width height: 20 readonly property real pos: root.player?.position ?? 0 readonly property real dur: root.player?.length ?? 0 readonly property real frac: dur > 0 ? pos / dur : 0 function _fmtTime(ms) { const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); return m + ":" + String(s % 60).padStart(2, "0"); } Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: parent._fmtTime(parent.pos) color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: parent._fmtTime(parent.dur) color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } Item { anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter width: parent.width - 80 height: 4 Rectangle { anchors.fill: parent color: M.Theme.base02 radius: 2 } Rectangle { width: parent.width * Math.min(1, Math.max(0, parent.parent.frac)) height: parent.height color: M.Theme.base0E radius: 2 } } } // Transport controls Item { width: parent.width height: 36 Row { anchors.centerIn: parent spacing: 24 Text { text: "\uF048" color: root.player?.canGoPrevious ? M.Theme.base05 : M.Theme.base03 font.pixelSize: M.Theme.fontSize + 4 font.family: M.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor enabled: root.player?.canGoPrevious ?? false onClicked: root.player.previous() } } Text { text: root.playing ? "\uF04C" : "\uF04B" color: M.Theme.base0E font.pixelSize: M.Theme.fontSize + 8 font.family: M.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.player?.togglePlaying() } } Text { text: "\uF051" color: root.player?.canGoNext ? M.Theme.base05 : M.Theme.base03 font.pixelSize: M.Theme.fontSize + 4 font.family: M.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor enabled: root.player?.canGoNext ?? false onClicked: root.player.next() } } } } // Player name Text { width: parent.width height: 20 horizontalAlignment: Text.AlignHCenter text: root.player?.identity ?? "" color: M.Theme.base03 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } } } }