diff options
| -rw-r--r-- | desktop/island/Island.qml | 9 | ||||
| -rw-r--r-- | desktop/island/SonixPanel.qml | 292 | ||||
| -rw-r--r-- | desktop/island/StatusPill.qml | 45 |
3 files changed, 341 insertions, 5 deletions
diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml index ee03ca1..f17358d 100644 --- a/desktop/island/Island.qml +++ b/desktop/island/Island.qml @@ -42,7 +42,7 @@ PanelWindow { WlrLayershell.namespace: "keystone-island" WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - // "", "sound", "headphones", "calendar" or "obs". + // "", "sound", "headphones", "sonix", "calendar" or "obs". property string panel: "" // --- reveal and hide ------------------------------------------------- @@ -141,6 +141,12 @@ PanelWindow { function onPhonesChanged() { if (!status.phones && win.panel === "headphones") win.panel = ""; } } + // And the Sonix player leaving the network. + Connections { + target: Sonix + function onPresentChanged() { if (!Sonix.present && win.panel === "sonix") win.panel = ""; } + } + HyprlandFocusGrab { windows: [win] active: win.panel !== "" @@ -324,6 +330,7 @@ PanelWindow { } onOpenSound: win.panel = win.panel === "sound" ? "" : "sound" onOpenHeadphones: win.panel = win.panel === "headphones" ? "" : "headphones" + onOpenSonix: win.panel = win.panel === "sonix" ? "" : "sonix" onClosePanel: win.panel = "" onOpenDrawer: { win.panel = ""; win.drawer.show("sound"); } } diff --git a/desktop/island/SonixPanel.qml b/desktop/island/SonixPanel.qml new file mode 100644 index 0000000..6fff9ce --- /dev/null +++ b/desktop/island/SonixPanel.qml @@ -0,0 +1,292 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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 flips at once; the next poll confirms or reverts it. + property var favOverride: null + Connections { + target: Sonix + function onPlayerChanged() { panel.pos = Sonix.player.position || 0; panel.favOverride = null; } + } + 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; + panel.pendingVol = -1; + if (v >= 0) Sonix.command({ do: "volume", value: v }, null, panel.report); + } + } + + 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}` : "" + onArtUrlChanged: if (artTry === 0) artRetry.stop() + 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 { + 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 { + 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; + Sonix.command({ do: "favourite", path: panel.p.path, value: want ? 1 : 0 }, null, panel.report); + } + } + } + } + Text { + 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 { + 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 { right: parent.right; verticalCenter: parent.verticalCenter } + glyph: panel.glyph(S.modeGlyph(panel.p.mode)) + onClicked: Sonix.command({ do: "mode", value: S.nextMode(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 + } +} diff --git a/desktop/island/StatusPill.qml b/desktop/island/StatusPill.qml index 99f1512..1ad4f77 100644 --- a/desktop/island/StatusPill.qml +++ b/desktop/island/StatusPill.qml @@ -17,8 +17,8 @@ import QtQuick import ".." import "Logic.js" as Logic -// Headphones icon (while Sony headphones are connected), volume icon, tray, -// layout flag. The headphones and volume icons open their panels in this +// Sonix player icon (while it is on the network), headphones icon (while +// Sony headphones are connected), volume icon, tray, layout flag. The headphones and volume icons open their panels in this // capsule; the tray is a plain tray; the flag switches layout on scroll and // ignores clicks. Capsule { @@ -36,6 +36,7 @@ Capsule { signal openSound() signal openHeadphones() + signal openSonix() signal closePanel() signal openDrawer() // A tray menu opening and closing. display() reports no close, so menus @@ -50,10 +51,12 @@ Capsule { onClosed: pill.menuClosed() } - expanded: panel === "sound" || panel === "headphones" + expanded: panel === "sound" || panel === "headphones" || panel === "sonix" restWidth: restRow.implicitWidth + 24 panelWidth: 320 - panelHeight: (panel === "headphones" ? phonesPanel.implicitHeight : soundPanel.implicitHeight) + 28 + panelHeight: (panel === "headphones" ? phonesPanel.implicitHeight + : panel === "sonix" ? sonixPanel.implicitHeight + : soundPanel.implicitHeight) + 28 Row { @@ -64,6 +67,28 @@ Capsule { visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: 120 } } + // The Sonix player, while its beacon is heard. A dot under it while + // it plays. + IconImage { + visible: Sonix.present + anchors.verticalCenter: parent.verticalCenter + implicitSize: 18 + source: Quickshell.iconPath("multimedia-player", true) + + Rectangle { + visible: Sonix.player.state === 1 + anchors { horizontalCenter: parent.horizontalCenter; top: parent.bottom; topMargin: 1 } + width: 4; height: 4; radius: 2 + color: Theme.accent + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: pill.openSonix() + } + } + IconImage { visible: pill.phones !== null anchors.verticalCenter: parent.verticalCenter @@ -170,4 +195,16 @@ Capsule { visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: 160 } } } + + SonixPanel { + id: sonixPanel + x: 14 + y: 14 + width: pill.panelWidth - 28 + open: pill.panel === "sonix" + onClose: pill.closePanel() + opacity: open ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + } } |
