// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Widgets import QtQuick import ".." import "../modules/sound" import "../modules/sonix/Sonix.js" as S // The Sonix player's controls: cover, title, heart, quality, seek, // transport, volume, mode, battery. Polls only while open (Sonix.hold). Column { id: panel property bool open: false signal close() readonly property var p: Sonix.player readonly property bool live: Sonix.present && Sonix.error === "" function glyph(cp) { return String.fromCodePoint(cp); } onOpenChanged: open ? Sonix.hold() : Sonix.release() Component.onDestruction: if (open) Sonix.release() // Position runs locally between polls; each poll resets it. property real pos: 0 // The heart and volume show the local value until a poll agrees, the // command fails, or overrideExpiry fires. property var favOverride: null Connections { target: Sonix function onPlayerChanged() { panel.pos = Sonix.player.position || 0; if (panel.favOverride !== null && !!Sonix.player.favourite === panel.favOverride) panel.favOverride = null; if (panel.pendingVol >= 0 && !volSend.running && Sonix.player.volume === panel.pendingVol) panel.pendingVol = -1; } } Timer { id: overrideExpiry interval: 3000 onTriggered: { panel.favOverride = null; if (!volSend.running) panel.pendingVol = -1; } } Timer { interval: 1000; repeat: true running: panel.open && panel.p.state === 1 onTriggered: panel.pos = Math.min(panel.pos + 1, panel.p.duration || 0) } // Volume is sent once the slider settles. property int pendingVol: -1 readonly property int shownVol: pendingVol >= 0 ? pendingVol : (p.volume || 0) Timer { id: volSend interval: 400 onTriggered: { const v = panel.pendingVol; if (v < 0) return; Sonix.command({ do: "volume", value: v }, null, (ok, d, err) => { panel.report(ok, d, err); if (!ok) panel.pendingVol = -1; }); overrideExpiry.restart(); } } property string error: "" function report(ok, d, err) { panel.error = ok ? "" : err; } // The art is prepared on demand: a 202 first, then the picture. Retried // a few times with a cache-busting counter. property int artTry: 0 readonly property string artUrl: p.path ? `${Sonix.base}/api/art?${S.query({ path: p.path, max: 256 })}&t=${artTry}` : "" readonly property string artPath: p.path || "" onArtPathChanged: { artTry = 0; artRetry.stop(); favOverride = null; } Timer { id: artRetry; interval: 1000; onTriggered: panel.artTry++ } spacing: 14 // --- header ---------------------------------------------------------- Item { width: panel.width height: 26 IconImage { id: headIcon anchors.verticalCenter: parent.verticalCenter implicitSize: 22 source: Quickshell.iconPath("multimedia-player", true) } Text { textFormat: Text.PlainText anchors { left: headIcon.right; leftMargin: 10; right: battery.left; rightMargin: 8 verticalCenter: parent.verticalCenter } elide: Text.ElideRight text: (Sonix.name || "Sonix").toUpperCase() font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; letterSpacing: 2 } color: Theme.text } Text { id: battery anchors { right: closeGlyph.left; rightMargin: 12; verticalCenter: parent.verticalCenter } visible: panel.p.battery !== undefined text: panel.p.battery + "%" + (panel.p.charging ? " " + panel.glyph(0xF0241) : "") // md-flash font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: panel.p.battery <= 15 && !panel.p.charging ? Theme.red : Theme.subtext } Text { id: closeGlyph anchors { right: parent.right; verticalCenter: parent.verticalCenter } text: panel.glyph(0xF0156) // md-close font { family: Theme.iconFamily; pixelSize: 18 } color: closeHit.containsMouse ? Theme.text : Theme.subtext MouseArea { id: closeHit anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: panel.close() } } } Text { visible: !panel.live width: panel.width wrapMode: Text.Wrap text: !Sonix.present ? "The player is not on the network." : "Not answering: " + Sonix.error font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } // --- now playing ------------------------------------------------------ Row { width: panel.width spacing: 12 enabled: panel.live Rectangle { width: 72; height: 72; radius: 8 color: Theme.surface clip: true Image { anchors.fill: parent source: panel.artUrl cache: false asynchronous: true fillMode: Image.PreserveAspectCrop onStatusChanged: if (status === Image.Error && panel.artTry < 3) artRetry.restart() } } Column { width: panel.width - 72 - 12 spacing: 3 Item { width: parent.width height: titleText.implicitHeight Text { textFormat: Text.PlainText id: titleText anchors { left: parent.left; right: heart.left; rightMargin: 6 } elide: Text.ElideRight text: panel.p.title || (panel.p.state ? "" : "Nothing playing") font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } color: Theme.text } Text { id: heart readonly property bool fav: panel.favOverride !== null ? panel.favOverride : !!panel.p.favourite anchors.right: parent.right visible: !!panel.p.path text: panel.glyph(fav ? 0xF02D1 : 0xF02D5) // md-heart / md-heart_outline font { family: Theme.iconFamily; pixelSize: 18 } color: fav ? Theme.accent : Theme.subtext MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { const want = !heart.fav; panel.favOverride = want; overrideExpiry.restart(); Sonix.command({ do: "favourite", path: panel.p.path, value: want ? 1 : 0 }, null, (ok, d, err) => { panel.report(ok, d, err); if (!ok) panel.favOverride = null; }); } } } } Text { textFormat: Text.PlainText width: parent.width elide: Text.ElideRight text: [panel.p.artist, panel.p.album].filter(s => s).join(" ยท ") font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Text { textFormat: Text.PlainText width: parent.width elide: Text.ElideRight text: S.qualityLine(panel.p) + (panel.p.lossless && panel.p.path ? " lossless" : "") font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext } } } // --- seek ------------------------------------------------------------- Item { width: panel.width height: 22 enabled: panel.live && (panel.p.duration || 0) > 0 Rectangle { id: track anchors { left: parent.left; right: parent.right; top: parent.top } height: 6; radius: 3 color: Theme.surface Rectangle { height: parent.height; radius: parent.radius width: parent.width * (panel.p.duration ? Math.min(1, panel.pos / panel.p.duration) : 0) color: Theme.accent } MouseArea { anchors { fill: parent; topMargin: -6; bottomMargin: -6 } cursorShape: Qt.PointingHandCursor onClicked: mouse => { const s = Math.round(mouse.x / width * panel.p.duration); panel.pos = s; Sonix.command({ do: "seek", value: s }, null, panel.report); } } } Text { anchors { left: parent.left; bottom: parent.bottom } text: S.clock(panel.pos) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } color: Theme.subtext } Text { anchors { right: parent.right; bottom: parent.bottom } text: S.clock(panel.p.duration || 0) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } color: Theme.subtext } } // --- transport and mode ----------------------------------------------- Item { width: panel.width height: 30 enabled: panel.live Row { anchors.centerIn: parent spacing: 14 TransportButton { glyph: "\uF048"; onClicked: Sonix.command({ do: "prev" }, null, panel.report) } TransportButton { glyph: panel.p.state === 1 ? "\uF04C" : "\uF04B" onClicked: Sonix.command({ do: "toggle" }, null, panel.report) } TransportButton { glyph: "\uF051"; onClicked: Sonix.command({ do: "next" }, null, panel.report) } } TransportButton { anchors { left: parent.left; verticalCenter: parent.verticalCenter } glyph: panel.glyph(S.shuffleGlyph(panel.p.mode)) onClicked: Sonix.command({ do: "mode", value: S.toggleShuffle(panel.p.mode) }, null, panel.report) } TransportButton { anchors { right: parent.right; verticalCenter: parent.verticalCenter } glyph: panel.glyph(S.repeatGlyph(panel.p.mode)) onClicked: Sonix.command({ do: "mode", value: S.nextRepeat(panel.p.mode) }, null, panel.report) } } PanelSlider { width: panel.width enabled: panel.live icon: "audio-volume-high" value: panel.shownVol / 100 label: panel.shownVol + "%" onMoved: v => { panel.pendingVol = Math.round(v * 100); volSend.restart(); } onStepped: d => { panel.pendingVol = Math.max(0, Math.min(100, panel.shownVol + d * 5)); volSend.restart(); } } Text { visible: panel.error !== "" width: panel.width wrapMode: Text.Wrap text: panel.error font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.red } }