pragma ComponentBehavior: Bound import QtQuick import "Util.js" as Util Flickable { id: root required property Theme theme required property Media mediaService readonly property var panelState: mediaService ? Util.parseJson(mediaService.stateJson(), {}) : ({}) readonly property var player: panelState.activePlayer || ({}) readonly property var players: panelState.players || [] readonly property bool hasMedia: players.length > 0 readonly property var panel: panelState.mediaPanelContract || ({}) // The existing service snapshot omits transport capabilities. Read them from // its native-player lookup, without introducing another command route. readonly property var nativePlayer: mediaService && hasMedia ? mediaService.nativePlayerById(player.id) : null readonly property bool canSeek: !!(nativePlayer && nativePlayer.canControl && player.canSeek && Number(player.length) > 0) readonly property var transportActions: ["shuffle", "previous", "playPause", "next", "repeat"] function mmss(seconds) { const total = Math.max(0, Math.round(Number(seconds || 0))); const minutes = Math.floor(total / 60); const rest = String(total % 60).padStart(2, "0"); return `${minutes}:${rest}`; } function actionEnabled(kind) { if (!nativePlayer || !nativePlayer.canControl) return false; if (kind === "shuffle") return !!player.canShuffle; if (kind === "repeat") return !!player.canRepeat; if (kind === "previous") return !!nativePlayer.canGoPrevious; if (kind === "next") return !!nativePlayer.canGoNext; if (kind === "playPause") return !!nativePlayer.canTogglePlaying; return false; } function runAction(kind) { if (mediaService && actionEnabled(kind)) mediaService.action(kind); } function seekByRatio(ratio) { const length = Math.max(0, Number(player.length || 0)); if (mediaService && canSeek) mediaService.seek(Math.round(Math.max(0, Math.min(1, ratio)) * length)); } function selectPlayer(id) { if (mediaService) mediaService.select(id); } function actionIcon(kind) { if (kind === "shuffle") return "\uF074"; if (kind === "previous") return "󰒮"; if (kind === "playPause") return root.player.playbackState === "playing" ? "\uF04C" : "\uF04B"; if (kind === "next") return "󰒭"; if (kind === "repeat") return "\uF01E"; return "\uF001"; } function actionActive(kind) { return kind === "shuffle" && root.player.shuffle || kind === "repeat" && root.player.repeat !== "none"; } function actionLabel(kind) { if (kind === "playPause") return root.player.playbackState === "playing" ? "pause" : "play"; if (kind === "previous") return "previous track"; if (kind === "next") return "next track"; return kind; } width: parent ? parent.width : 0 contentWidth: width contentHeight: mediaCol.height clip: true interactive: contentHeight > height boundsBehavior: Flickable.StopAtBounds flickableDirection: Flickable.VerticalFlick StableListModel { id: playerModel rows: root.players } Column { id: mediaCol width: root.width spacing: 12 PopupCard { id: nowPlayingCard theme: root.theme padding: 0 objectName: "coverDetailsProgressTransport" visible: root.hasMedia width: parent.width height: 252 Rectangle { id: coverCard anchors.left: parent.left anchors.top: parent.top anchors.margins: 12 width: 104 height: 104 radius: 12 color: root.theme.ui.backdrop clip: true Image { id: coverArt anchors.fill: parent visible: !!root.player.artAvailable && status === Image.Ready source: root.player.cover || "" fillMode: Image.PreserveAspectCrop asynchronous: true } Text { anchors.centerIn: parent visible: !coverArt.visible font.family: "Symbols Nerd Font Mono" font.pixelSize: 36 color: root.theme.ui.accent text: "\uF001" } } Column { anchors.left: coverCard.right anchors.leftMargin: 16 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: coverCard.verticalCenter spacing: 6 NuguText { theme: root.theme variant: "title" text: root.player.title || root.player.identity || "untitled" width: parent.width elide: Text.ElideRight } NuguText { theme: root.theme text: root.player.artist || "unknown artist" width: parent.width elide: Text.ElideRight } NuguText { theme: root.theme variant: "meta" text: root.player.album || "" visible: text.length > 0 width: parent.width elide: Text.ElideRight } Row { width: parent.width spacing: 8 PopupBadge { id: playbackBadge theme: root.theme text: root.player.playbackState || "stopped" role: root.player.playbackState === "playing" ? "active" : "normal" } NuguText { theme: root.theme variant: "meta" text: root.player.identity || "mpris" width: parent.width - playbackBadge.width - 8 anchors.verticalCenter: parent.verticalCenter elide: Text.ElideRight } } } Item { id: progressBlock anchors.left: parent.left anchors.right: parent.right anchors.margins: 12 anchors.bottom: transportRow.top anchors.bottomMargin: 12 height: 44 NuguText { anchors.left: parent.left anchors.top: parent.top theme: root.theme variant: "mono" text: root.mmss(root.player.position) } NuguText { anchors.right: parent.right anchors.top: parent.top theme: root.theme variant: "mono" text: root.mmss(root.player.length) } PopupSlider { objectName: "mpris-progress" anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom theme: root.theme enabled: root.canSeek Accessible.name: `playback position for ${root.player.title || root.player.identity || "media"}` value: Number(root.player.progress || 0) onMoved: value => root.seekByRatio(value) } } Row { id: transportRow objectName: "transportActions" anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 12 spacing: 8 Repeater { model: root.transportActions delegate: PopupButton { required property string modelData readonly property bool isPrimary: modelData === "playPause" theme: root.theme width: 40 height: 34 icon: root.actionIcon(modelData) accessibleName: root.actionLabel(modelData) tooltip: enabled ? accessibleName : `${accessibleName} unavailable for this player` enabled: root.actionEnabled(modelData) role: isPrimary ? "primary" : "normal" checked: root.actionActive(modelData) onClicked: root.runAction(modelData) } } } } Flow { objectName: "playerSelectorRail" width: parent.width spacing: 6 visible: root.players.length > 1 Repeater { model: playerModel delegate: PopupButton { required property var rowData theme: root.theme width: Math.min(root.width, implicitWidth) text: rowData.identity || rowData.id accessibleName: `select ${text}` checked: rowData.id === root.player.id onClicked: root.selectPlayer(rowData.id) } } } Item { objectName: "mediaEmptyState" visible: !root.hasMedia width: parent.width height: 56 Text { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter font.family: "Symbols Nerd Font Mono" font.pixelSize: 26 color: root.theme.ui.minor text: "\uF001" Accessible.ignored: true } Column { anchors.left: parent.left anchors.leftMargin: 40 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 3 NuguText { theme: root.theme variant: "title" text: "nothing playing" } NuguText { width: parent.width theme: root.theme variant: "meta" text: "play media in an app to control it here" color: root.theme.ui.minor wrapMode: Text.WordWrap } } } } }