import QtQuick import "../services" as S Column { id: root required property color accentColor property var cavaBars: Array(16).fill(0) // Album art - always 1:1, crossfades on session switch Item { width: root.width height: width clip: true Rectangle { anchors.fill: parent color: S.Theme.base02 } // Outgoing art - snaps to current opacity, then fades out Image { id: _artImgPrev anchors.fill: parent fillMode: Image.PreserveAspectCrop sourceSize: Qt.size(width, height) asynchronous: true opacity: 0 NumberAnimation { id: _prevFadeOut target: _artImgPrev property: "opacity" to: 0 duration: 300 easing.type: Easing.InOutCubic } } // Incoming art - fades in once loaded Image { id: _artImg anchors.fill: parent fillMode: Image.PreserveAspectCrop sourceSize: Qt.size(width, height) asynchronous: true opacity: 0 property bool _hasArt: false onStatusChanged: { if (status === Image.Ready && source !== "") { _hasArt = true; _artFadeIn.start(); _prevFadeOut.start(); } else if (status === Image.Error) { _hasArt = false; } } NumberAnimation { id: _artFadeIn target: _artImg property: "opacity" to: 1 duration: 300 easing.type: Easing.InOutCubic } Connections { target: S.MprisService function onCachedArtChanged() { const art = S.MprisService.cachedArt; if (!art) { _artFadeIn.stop(); _prevFadeOut.stop(); _artImg._hasArt = false; _artImg.opacity = 0; _artImgPrev.opacity = 0; _artImg.source = ""; } else if (art !== _artImg.source) { _prevFadeOut.stop(); _artFadeIn.stop(); _artImgPrev.source = _artImg.source; _artImgPrev.opacity = _artImg.opacity; _artImg.opacity = 0; _artImg.source = art; } } } } // Visualizer bars Row { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: parent.height * 0.6 spacing: 2 visible: S.MprisService.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: root.accentColor 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._hasArt gradient: Gradient { GradientStop { position: 0 color: "transparent" } GradientStop { position: 1 color: S.Theme.base01 } } } Text { anchors.centerIn: parent text: "\uF001" color: S.Theme.base04 font.pixelSize: 28 font.family: S.Theme.iconFontFamily visible: !_artImg._hasArt } } // Track info Item { width: root.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: S.MprisService.player?.trackTitle || "No track" color: S.Theme.base05 font.pixelSize: S.Theme.fontSize + 1 font.family: S.Theme.fontFamily font.bold: true elide: Text.ElideRight } Text { width: parent.width text: { const p = S.MprisService.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: S.Theme.base04 font.pixelSize: S.Theme.fontSize - 1 font.family: S.Theme.fontFamily elide: Text.ElideRight visible: text !== "" } } } // Progress Item { width: root.width height: 20 readonly property real pos: S.MprisService.player?.position ?? 0 readonly property real dur: S.MprisService.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: S.Theme.base04 font.pixelSize: S.Theme.fontSize - 2 font.family: S.Theme.fontFamily } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: parent._fmtTime(parent.dur) color: S.Theme.base04 font.pixelSize: S.Theme.fontSize - 2 font.family: S.Theme.fontFamily } Item { anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter width: parent.width - 80 height: 4 Rectangle { anchors.fill: parent color: S.Theme.base02 radius: 2 } Rectangle { width: parent.width * Math.min(1, Math.max(0, parent.parent.frac)) height: parent.height color: root.accentColor radius: 2 } } } // Transport controls Item { width: root.width height: 36 Row { anchors.centerIn: parent spacing: 24 Text { text: "\uF048" color: S.MprisService.player?.canGoPrevious ? S.Theme.base05 : S.Theme.base03 font.pixelSize: S.Theme.fontSize + 4 font.family: S.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter HoverHandler { cursorShape: Qt.PointingHandCursor } TapHandler { enabled: S.MprisService.player?.canGoPrevious ?? false onTapped: S.MprisService.player.previous() } } Text { text: S.MprisService.playing ? "\uF04C" : "\uF04B" color: root.accentColor font.pixelSize: S.Theme.fontSize + 8 font.family: S.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter HoverHandler { cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: S.MprisService.player?.togglePlaying() } } Text { text: "\uF051" color: S.MprisService.player?.canGoNext ? S.Theme.base05 : S.Theme.base03 font.pixelSize: S.Theme.fontSize + 4 font.family: S.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter HoverHandler { cursorShape: Qt.PointingHandCursor } TapHandler { enabled: S.MprisService.player?.canGoNext ?? false onTapped: S.MprisService.player.next() } } } } // Player switcher Item { width: root.width height: S.MprisService.players.length > 1 ? _playerFlow.implicitHeight + 6 : 0 visible: S.MprisService.players.length > 1 Flow { id: _playerFlow anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter width: parent.width - 16 spacing: 6 Repeater { model: S.MprisService.players delegate: Rectangle { required property var modelData required property int index readonly property bool _active: index === S.MprisService.playerIdx width: _pLabel.implicitWidth + 12 height: 18 radius: 9 color: _active ? S.Theme.base02 : (pHover.hovered ? S.Theme.base02 : "transparent") border.color: _active ? root.accentColor : S.Theme.base03 border.width: _active ? 1 : 0 Text { id: _pLabel anchors.centerIn: parent text: modelData.identity ?? "Player" color: _active ? root.accentColor : S.Theme.base04 font.pixelSize: S.Theme.fontSize - 2 font.family: S.Theme.fontFamily font.bold: _active } HoverHandler { id: pHover cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: S.MprisService.switchPlayer(index) } } } } } }