import QtQuick import Quickshell.Services.Mpris Item { id: root property Osd osdService property Audio audioService property string manualSelection: "" property var lastAction: ({}) property var trackChange: ({ "direction": "none", "reverse": false, "fromTitle": "", "toTitle": "" }) readonly property bool nativeActionRoute: true // cheap reactive "something playing" signal for the bar chip active dot readonly property bool playing: { const players = Mpris.players.values || []; for (let i = 0; i < players.length; i++) if (players[i].playbackState === MprisPlaybackState.Playing) return true; return false; } function playbackStateText(player) { return String(MprisPlaybackState.toString(player.playbackState || MprisPlaybackState.Stopped)).toLowerCase(); } function loopStateText(player) { const text = String(MprisLoopState.toString(player.loopState || MprisLoopState.None)).toLowerCase(); if (text === "playlist") return "all"; if (text === "track") return "one"; return "none"; } function playerId(player) { return String(player.dbusName || player.uniqueId || player.identity || "mpris-player"); } function nativePlayerById(id) { const players = Mpris.players.values || []; for (let i = 0; i < players.length; i++) if (playerId(players[i]) === id) { return players[i]; } return null; } function clonePlayer(player) { const id = playerId(player); const title = String(player.trackTitle || ""); const artist = String(player.trackArtist || ""); const album = String(player.trackAlbum || ""); const position = Math.round(Number(player.position || 0)); const length = Math.max(0, Math.round(Number(player.length || 0))); const cover = String(player.trackArtUrl || ""); return { "id": id, "identity": String(player.identity || id), "title": title, "artist": artist, "album": album, "playbackState": playbackStateText(player), "position": position, "length": length, "progress": length > 0 ? Math.round(position * 1000 / length) / 1000 : 0, "shuffle": !!player.shuffle, "repeat": loopStateText(player), "canSeek": !!player.canSeek, "canShuffle": !!player.shuffleSupported, "canRepeat": !!player.loopSupported, "cover": cover, "artAvailable": cover.length > 0 }; } function servicePlayers() { return Mpris.players.values || []; } function activeIndex() { const players = servicePlayers(); if (manualSelection.length > 0) { for (let i = 0; i < players.length; i++) if (clonePlayer(players[i]).id === manualSelection) { return i; } } for (let j = 0; j < players.length; j++) if (clonePlayer(players[j]).playbackState === "playing") { return j; } return players.length > 0 ? 0 : -1; } function activePlayerRaw() { const players = servicePlayers(); const index = activeIndex(); return index >= 0 ? players[index] : ({ "dbusName": "", "identity": "", "trackTitle": "", "trackArtist": "", "trackAlbum": "", "playbackState": MprisPlaybackState.Stopped, "position": 0, "length": 1, "shuffle": false, "loopState": MprisLoopState.None, "trackArtUrl": "" }); } function activePlayer() { return clonePlayer(activePlayerRaw()); } function osd(eventText) { if (!osdService) return { "event": "media", "value": eventText, "role": "info" }; try { return JSON.parse(osdService.trigger("media", eventText)); } catch (error) { return { "event": "media", "value": eventText, "role": "info" }; } } function setTrackChange(direction, reverse, fromTitle, toTitle) { trackChange = { "direction": direction, "reverse": reverse, "fromTitle": fromTitle, "toTitle": toTitle }; } function mediaProgress() { return Math.max(0, Math.min(1, Number(activePlayer().progress || 0))); } function mediaPlayingValue() { return activePlayer().playbackState === "playing" ? 1 : 0; } function mediaEnergyValue() { // Amplitude VU removed (no audio capture). Energy now reflects playback state only. return mediaPlayingValue(); } function mediaModeValue() { const active = activePlayer(); if (active.shuffle) return 1; return active.repeat === "none" ? 0 : 0.5; } function mediaPanelContract() { return { "source": "Nuguland dashboard media Details.qml plus Nuguland MprisController.qml", "ownedBy": "PopoutHost.qml inline MediaPanel", "coverDetailsProgressTransport": true, "playerSelectorRail": true, "trackDirectionAnimation": ["previous", "next"], "seekPreviewBinding": "positionLabel follows slider while dragging", "transportActions": ["shuffle", "previous", "playPause", "next", "repeat"], "noExternalVisualizer": true, "serviceActionsOnly": true, "coverShape": "nugu-rounded-card", "progressRail": "mpris-progress" }; } function stateObject(osdState) { const active = activePlayer(); return { "backend": "Media.qml", "usesNativeMpris": true, "nativeActionRoute": nativeActionRoute, "externalVisualizer": false, "manualSelection": manualSelection, "players": servicePlayers().map(player => { return clonePlayer(player); }), "activePlayer": active, "cover": { "source": active.artAvailable ? active.cover : `nugu-media:${active.id}`, "artAvailable": active.artAvailable, "role": active.artAvailable ? "content.normal" : "ui.accent" }, "compact": { "label": "Media", "title": active.title, "artist": active.artist, "playing": active.playbackState === "playing" }, "shader": { "kind": "media-waveform", "clipped": true, "source": "shaders/media-waveform.frag.qsb", "priorArt": "WebAudio/ISF visualizer research: true music visualizers need waveform/FFT textures; current chip uses native PipeWire peak history as a compact amplitude visualizer", "values": [mediaProgress(), mediaPlayingValue(), mediaEnergyValue(), mediaModeValue()], "colors": ["ui.accent", "ui.focus", "ui.minor", "ui.backdrop", "warning", "error"] }, "rich": { "hasTransport": true, "hasProgress": true, "hasPlayerSelection": true, "canSeek": active.canSeek, "shuffle": active.shuffle, "repeat": active.repeat }, "audioHandoff": { "route": "Audio.qml", "reason": "media volume stays owned by Audio.qml", "openTarget": "audio", "serviceBoundary": true }, "mediaPanelContract": mediaPanelContract(), "lineMotif": { "source": "mpris-progress", "role": "ui.accent", "progress": active.progress }, "trackChange": trackChange, "lastAction": lastAction, "osd": osdState || {} }; } function stateJson() { return JSON.stringify(stateObject({})); } function action(kind) { const active = activePlayer(); const playerIdValue = active.id; const name = String(kind); const nativePlayer = nativePlayerById(playerIdValue); if (name === "next") setTrackChange("next", false, active.title, "pending"); else if (name === "previous") setTrackChange("previous", true, active.title, "pending"); if (nativePlayer) { if (name === "playPause") nativePlayer.togglePlaying(); else if (name === "next" && nativePlayer.canGoNext) nativePlayer.next(); else if (name === "previous" && nativePlayer.canGoPrevious) nativePlayer.previous(); else if (name === "stop") nativePlayer.stop(); else if (name === "shuffle" && nativePlayer.shuffleSupported) nativePlayer.shuffle = !nativePlayer.shuffle; else if (name === "repeat" && nativePlayer.loopSupported) nativePlayer.loopState = nativePlayer.loopState === MprisLoopState.Playlist ? MprisLoopState.None : MprisLoopState.Playlist; } lastAction = { "kind": name, "id": playerIdValue, "nativeActionRoute": nativeActionRoute }; return JSON.stringify(stateObject(osd(name))); } function seek(position) { const playerIdValue = activePlayer().id; const nativePlayer = nativePlayerById(playerIdValue); if (nativePlayer && nativePlayer.canSeek) nativePlayer.position = Number(position); lastAction = { "kind": "seek", "id": playerIdValue, "position": position, "nativeActionRoute": nativeActionRoute }; return JSON.stringify(stateObject(osd(`seek ${position}`))); } function select(id) { manualSelection = String(id); lastAction = { "kind": "select", "id": manualSelection, "nativeActionRoute": nativeActionRoute }; return JSON.stringify(stateObject(osd(`select ${manualSelection}`))); } }