aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-11-07-sonix-player.md2648
1 files changed, 2648 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-11-07-sonix-player.md b/docs/superpowers/plans/2026-11-07-sonix-player.md
new file mode 100644
index 0000000..f95192f
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-07-sonix-player.md
@@ -0,0 +1,2648 @@
+# Sonix Player Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A HiBy R1 running Sonix Player gets an island control panel, a drawer tile and page (device, playlists, folders), and a boot logo editor in the settings window.
+
+**Architecture:** One root singleton, `desktop/Sonix.qml`, owns presence (a Python stdlib helper relays the player's UDP beacon), state polling while a view holds it, commands over `XMLHttpRequest`, and the boot logo upload over `curl`. Pure logic lives in `modules/sonix/Sonix.js`, tested with node. The boot logo is rendered by a QML scene grabbed with `grabToImage`.
+
+**Tech Stack:** Quickshell 0.3.2 (QML, Qt 6), node for JS tests, `/usr/bin/python3` (stdlib only), curl.
+
+**Spec:** `docs/superpowers/specs/2026-11-07-sonix-player-design.md`
+
+---
+
+## Ground rules for every task
+
+- Every new source file starts with the repo's GPLv2 header, exactly:
+
+```
+// 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.
+```
+
+ (Python files: the same lines with `#` instead of `//`, after the shebang.)
+ The code blocks below omit it for brevity; add it.
+- The running `desktop` shell hot-reloads on save. After each QML task, check
+ the log: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -n 40`.
+ Expect a `Reloading configuration` line newer than your edit and no
+ `ReferenceError`, `TypeError` or `is not a type`. If no reload line follows
+ the edit, `touch desktop/shell.qml` and check again (AGENTS.md, "Hot reload
+ sometimes does not fire").
+- Never `pkill -f`; the process is `qs` (AGENTS.md).
+- QML's JS has no `String.matchAll`, `Object.fromEntries`, `Array.flatMap`.
+- No em dashes in code, comments or docs.
+- Commits are GPG-signed by the global config; never disable signing.
+- Card paths: SonixLink uses `/mnt/sd_0/...`; Wi-Fi transfer uses `/data/mnt/sd_0/...`.
+- Live player checks: the player's address is in the beacon. Get it with
+ `timeout 5 /usr/bin/python3 desktop/modules/sonix/sonix-beacon.py | head -1`
+ (after Task 2). Below, `$R1` means `http://<that ip>:7800`.
+
+## File structure
+
+```
+desktop/Sonix.qml singleton: presence, polling, commands, upload
+desktop/Prefs.qml + bootlogo settings
+desktop/shell.qml + import and two modules
+desktop/island/PanelSlider.qml the slider, extracted from HeadphonesPanel
+desktop/island/HeadphonesPanel.qml uses PanelSlider
+desktop/island/SonixPanel.qml the controls panel
+desktop/island/StatusPill.qml + R1 icon and SonixPanel
+desktop/island/Island.qml + "sonix" panel wiring
+desktop/modules/sonix/Sonix.js pure functions
+desktop/modules/sonix/test_sonix.js
+desktop/modules/sonix/sonix-beacon.py UDP beacon relay
+desktop/modules/sonix/test_sonix_beacon.py
+desktop/modules/sonix/SonixModule.qml drawer tile + page
+desktop/modules/sonix/SonixTile.qml
+desktop/modules/sonix/SonixPage.qml tab bar
+desktop/modules/sonix/SonixRow.qml a list row with trailing buttons
+desktop/modules/sonix/SonixDevice.qml
+desktop/modules/sonix/SonixPlaylists.qml
+desktop/modules/sonix/SonixFolders.qml
+desktop/modules/sonix/BootlogoModule.qml settings page + header bar
+desktop/modules/sonix/BootlogoTab.qml controls
+desktop/modules/sonix/BootlogoCanvas.qml the 480x800 scene
+desktop/modules/sonix/fonts/ 5 TTFs + OFL licences
+desktop/modules/sonix/README.md
+```
+
+---
+
+### Task 1: Sonix.js pure functions
+
+**Files:**
+- Create: `desktop/modules/sonix/Sonix.js`
+- Test: `desktop/modules/sonix/test_sonix.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/sonix/test_sonix.js`:
+
+```js
+// Run: node desktop/modules/sonix/test_sonix.js
+// Sonix.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Sonix.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const S = new Function(src +
+ "\nreturn { parseBeacon, qualityLine, nextMode, modeGlyph, clock, cover, centred, zoomAt," +
+ " stamp, query, crumbs, parentOf, colour, offsetXY };")();
+
+// Beacon: "SONIXLINK1 <ip> <port> <name with spaces>".
+assert.deepStrictEqual(S.parseBeacon("SONIXLINK1 10.0.0.5 7800 HiBy R1\n"),
+ { host: "10.0.0.5", port: 7800, name: "HiBy R1" });
+assert.strictEqual(S.parseBeacon("SONIXLINK2 10.0.0.5 7800 x"), null);
+assert.strictEqual(S.parseBeacon("SONIXLINK1 nope 7800 x"), null);
+assert.strictEqual(S.parseBeacon("SONIXLINK1 10.0.0.5 0 x"), null);
+assert.strictEqual(S.parseBeacon("SONIXLINK1 10.0.0.5 7800"), null);
+
+// Quality line from /api/state fields.
+assert.strictEqual(S.qualityLine({ path: "/mnt/sd_0/a/b.flac", sample_rate: 96000, bits: 24, bitrate: 2304 }),
+ "FLAC · 24/96 · 2304 kbps");
+assert.strictEqual(S.qualityLine({ path: "x.mp3", sample_rate: 44100, bits: 0, bitrate: 320 }),
+ "MP3 · 44.1 kHz · 320 kbps");
+assert.strictEqual(S.qualityLine({ path: "", sample_rate: 0, bits: 0, bitrate: 0 }), "");
+
+// Mode cycle, unknown restarts at normal.
+assert.strictEqual(S.nextMode("normal"), "repeat_all");
+assert.strictEqual(S.nextMode("shuffle_repeat"), "normal");
+assert.strictEqual(S.nextMode("???"), "normal");
+assert.strictEqual(S.modeGlyph("repeat_one"), 0xF0458);
+assert.strictEqual(S.modeGlyph("???"), 0xF0457);
+
+assert.strictEqual(S.clock(0), "0:00");
+assert.strictEqual(S.clock(61.9), "1:01");
+assert.strictEqual(S.clock(-3), "0:00");
+
+// Cover: a 2000x1000 picture in 480x800 scales to height (0.8), 1600 wide.
+let c = S.cover(2000, 1000, 1, 0, 0, 480, 800);
+assert.strictEqual(c.scale, 0.8);
+assert.strictEqual(c.w, 1600);
+assert.strictEqual(c.h, 800);
+// Panning is clamped so the frame stays covered.
+c = S.cover(2000, 1000, 1, 100, -50, 480, 800);
+assert.strictEqual(c.x, 0);
+assert.strictEqual(c.y, 0);
+c = S.cover(2000, 1000, 1, -5000, 0, 480, 800);
+assert.strictEqual(c.x, 480 - 1600);
+// Zoom is kept in 1..4.
+assert.strictEqual(S.cover(2000, 1000, 0.2, 0, 0, 480, 800).zoom, 1);
+assert.strictEqual(S.cover(2000, 1000, 9, 0, 0, 480, 800).zoom, 4);
+// Centred: the crop the player itself makes.
+c = S.centred(2000, 1000, 480, 800);
+assert.strictEqual(c.x, (480 - 1600) / 2);
+assert.strictEqual(c.y, 0);
+// Zooming at a point keeps that point under the cursor.
+c = S.centred(2000, 1000, 480, 800);
+const z = S.zoomAt(2000, 1000, c, 2, 240, 400, 480, 800);
+assert.strictEqual(z.zoom, 2);
+assert.ok(Math.abs((240 - z.x) / z.scale - (240 - c.x) / c.scale) < 1e-9);
+
+assert.strictEqual(S.stamp(new Date(2026, 10, 7, 9, 5, 3)), "bootlogo-20261107-090503");
+
+assert.strictEqual(S.query({ do: "play_path", path: "/mnt/sd_0/a b&c.flac" }),
+ "do=play_path&path=%2Fmnt%2Fsd_0%2Fa%20b%26c.flac");
+
+assert.deepStrictEqual(S.crumbs("/mnt/sd_0", "/mnt/sd_0"), [{ name: "Card", path: "/mnt/sd_0" }]);
+assert.deepStrictEqual(S.crumbs("/mnt/sd_0/A/B", "/mnt/sd_0"), [
+ { name: "Card", path: "/mnt/sd_0" },
+ { name: "A", path: "/mnt/sd_0/A" },
+ { name: "B", path: "/mnt/sd_0/A/B" },
+]);
+assert.deepStrictEqual(S.crumbs("/elsewhere", "/mnt/sd_0"), [{ name: "Card", path: "/mnt/sd_0" }]);
+
+assert.strictEqual(S.parentOf("/mnt/sd_0/A/B", "/mnt/sd_0"), "/mnt/sd_0/A");
+assert.strictEqual(S.parentOf("/mnt/sd_0/A", "/mnt/sd_0"), "/mnt/sd_0");
+assert.strictEqual(S.parentOf("/mnt/sd_0", "/mnt/sd_0"), "/mnt/sd_0");
+
+assert.strictEqual(S.colour("udt", "#123456"), "#123456");
+assert.strictEqual(S.colour("#e5a50a", "#123456"), "#e5a50a");
+
+assert.deepStrictEqual(S.offsetXY("se", 8), { x: 8, y: 8 });
+assert.deepStrictEqual(S.offsetXY("nw", 3), { x: -3, y: -3 });
+assert.deepStrictEqual(S.offsetXY("bogus", 5), { x: 5, y: 5 });
+
+console.log("ok");
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node desktop/modules/sonix/test_sonix.js`
+Expected: FAIL with `ENOENT` (no `Sonix.js`).
+
+- [ ] **Step 3: Write the implementation**
+
+`desktop/modules/sonix/Sonix.js`:
+
+```js
+.pragma library
+
+// Pure helpers for the Sonix player: no QML, so node tests them
+// (test_sonix.js).
+
+// "SONIXLINK1 <ip> <port> <name>", broadcast by the player every 2s.
+function parseBeacon(line) {
+ const f = String(line).trim().split(" ");
+ if (f.length < 4 || f[0] !== "SONIXLINK1") return null;
+ const port = parseInt(f[2], 10);
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(f[1]) || !(port > 0 && port < 65536)) return null;
+ return { host: f[1], port: port, name: f.slice(3).join(" ") };
+}
+
+function rateText(hz) {
+ const k = hz / 1000;
+ return Number.isInteger(k) ? String(k) : k.toFixed(1);
+}
+
+// "FLAC · 24/96 · 2304 kbps" from /api/state.
+function qualityLine(s) {
+ const parts = [];
+ const m = /\.([A-Za-z0-9]+)$/.exec(s.path || "");
+ if (m) parts.push(m[1].toUpperCase());
+ if (s.sample_rate > 0)
+ parts.push(s.bits > 0 ? `${s.bits}/${rateText(s.sample_rate)}` : `${rateText(s.sample_rate)} kHz`);
+ if (s.bitrate > 0) parts.push(`${s.bitrate} kbps`);
+ return parts.join(" · ");
+}
+
+const MODES = ["normal", "repeat_all", "repeat_one", "shuffle", "shuffle_repeat"];
+// md-repeat_off, md-repeat, md-repeat_once, md-shuffle, md-shuffle (with repeat)
+const MODE_GLYPHS = [0xF0457, 0xF0456, 0xF0458, 0xF049D, 0xF049D];
+
+function nextMode(m) { return MODES[(MODES.indexOf(m) + 1) % MODES.length]; }
+function modeGlyph(m) { return MODE_GLYPHS[Math.max(0, MODES.indexOf(m))]; }
+
+function clock(sec) {
+ sec = Math.max(0, Math.floor(sec));
+ return Math.floor(sec / 60) + ":" + String(sec % 60).padStart(2, "0");
+}
+
+// The picture scaled to cover W x H times zoom (1..4), its top-left offset
+// clamped so no edge of the frame is left uncovered.
+function cover(imgW, imgH, zoom, x, y, W, H) {
+ const z = Math.max(1, Math.min(4, zoom));
+ const s = Math.max(W / imgW, H / imgH) * z;
+ const w = imgW * s, h = imgH * s;
+ return { scale: s, zoom: z, w: w, h: h,
+ x: Math.min(0, Math.max(W - w, x)),
+ y: Math.min(0, Math.max(H - h, y)) };
+}
+
+// Centred at minimum zoom: the crop the player makes on its own.
+function centred(imgW, imgH, W, H) {
+ const c = cover(imgW, imgH, 1, 0, 0, W, H);
+ return cover(imgW, imgH, 1, (W - c.w) / 2, (H - c.h) / 2, W, H);
+}
+
+// A new zoom that keeps the picture point under (cx, cy) where it is.
+function zoomAt(imgW, imgH, p, zoom, cx, cy, W, H) {
+ const k = cover(imgW, imgH, zoom, 0, 0, W, H).scale / p.scale;
+ return cover(imgW, imgH, zoom, cx - (cx - p.x) * k, cy - (cy - p.y) * k, W, H);
+}
+
+function stamp(d) {
+ const p = n => String(n).padStart(2, "0");
+ return `bootlogo-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
+ + `-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
+}
+
+function query(o) {
+ const out = [];
+ for (const k in o) out.push(encodeURIComponent(k) + "=" + encodeURIComponent(o[k]));
+ return out.join("&");
+}
+
+// Breadcrumbs from the card root to path; a path outside the card is the root.
+function crumbs(path, root) {
+ const out = [{ name: "Card", path: root }];
+ if (!path || path.indexOf(root + "/") !== 0) return out;
+ let at = root;
+ const parts = path.slice(root.length + 1).split("/");
+ for (let i = 0; i < parts.length; i++) {
+ if (!parts[i]) continue;
+ at += "/" + parts[i];
+ out.push({ name: parts[i], path: at });
+ }
+ return out;
+}
+
+function parentOf(path, root) {
+ if (!path || path === root || path.indexOf(root + "/") !== 0) return root;
+ return path.slice(0, path.lastIndexOf("/"));
+}
+
+// A colour choice is a hex value or "udt", the live udt accent.
+function colour(token, udt) { return token === "udt" ? udt : token; }
+
+const DIRS = { ne: [1, -1], se: [1, 1], sw: [-1, 1], nw: [-1, -1] };
+function offsetXY(dir, n) {
+ const d = DIRS[dir] || DIRS.se;
+ return { x: d[0] * n, y: d[1] * n };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node desktop/modules/sonix/test_sonix.js`
+Expected: `ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/sonix/Sonix.js desktop/modules/sonix/test_sonix.js
+git commit -m "sonix: pure helpers for the player, with their test"
+```
+
+---
+
+### Task 2: Beacon relay
+
+**Files:**
+- Create: `desktop/modules/sonix/sonix-beacon.py`
+- Test: `desktop/modules/sonix/test_sonix_beacon.py`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/sonix/test_sonix_beacon.py`:
+
+```python
+#!/usr/bin/python3
+# Run: /usr/bin/python3 desktop/modules/sonix/test_sonix_beacon.py
+# Starts the relay on a test port, sends a junk datagram and a beacon, and
+# checks that only the beacon comes out.
+import os
+import socket
+import subprocess
+import sys
+
+PORT = 47801
+here = os.path.dirname(os.path.abspath(__file__))
+relay = subprocess.Popen([sys.executable, "-u", os.path.join(here, "sonix-beacon.py"), str(PORT)],
+ stdout=subprocess.PIPE, text=True)
+try:
+ out = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ for _ in range(50): # until the relay is bound
+ out.sendto(b"not a beacon", ("127.0.0.1", PORT))
+ out.sendto(b"SONIXLINK1 10.0.0.5 7800 HiBy R1", ("127.0.0.1", PORT))
+ import select
+ if select.select([relay.stdout], [], [], 0.1)[0]:
+ break
+ line = relay.stdout.readline().strip()
+ assert line == "SONIXLINK1 10.0.0.5 7800 HiBy R1", line
+finally:
+ relay.kill()
+print("ok")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `/usr/bin/python3 desktop/modules/sonix/test_sonix_beacon.py`
+Expected: FAIL (the relay file does not exist; the assertion sees an empty line).
+
+- [ ] **Step 3: Write the relay**
+
+`desktop/modules/sonix/sonix-beacon.py`:
+
+```python
+#!/usr/bin/python3
+# Relays the Sonix player's discovery beacon to stdout, one line per
+# datagram: QML has no UDP socket. SonixLink broadcasts
+# "SONIXLINK1 <ip> <port> <name>" to UDP 7801 every 2s; anything else on
+# the port is dropped. The port is an argument only for the test.
+import socket
+import sys
+
+port = int(sys.argv[1]) if len(sys.argv) > 1 else 7801
+s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+# Several listeners may share the port; each still gets the broadcast.
+s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+s.bind(("", port))
+while True:
+ data, _ = s.recvfrom(512)
+ line = data.decode("utf-8", "replace").strip()
+ if line.startswith("SONIXLINK1 "):
+ print(line, flush=True)
+```
+
+`chmod +x desktop/modules/sonix/sonix-beacon.py`
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `/usr/bin/python3 desktop/modules/sonix/test_sonix_beacon.py`
+Expected: `ok`
+
+Then with the player's SonixLink on:
+Run: `timeout 5 /usr/bin/python3 desktop/modules/sonix/sonix-beacon.py | head -1`
+Expected: `SONIXLINK1 <ip> 7800 HiBy R1`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/sonix/sonix-beacon.py desktop/modules/sonix/test_sonix_beacon.py
+git commit -m "sonix: relay the player's UDP beacon to stdout"
+```
+
+---
+
+### Task 3: Fonts
+
+**Files:**
+- Create: `desktop/modules/sonix/fonts/*`
+
+- [ ] **Step 1: Fetch the TTFs and each family's OFL**
+
+```bash
+cd desktop/modules/sonix && mkdir -p fonts && cd fonts
+R=https://raw.githubusercontent.com/google/fonts/main/ofl
+for p in bungee/Bungee-Regular.ttf bungeeinline/BungeeInline-Regular.ttf \
+ majormonodisplay/MajorMonoDisplay-Regular.ttf michroma/Michroma-Regular.ttf \
+ audiowide/Audiowide-Regular.ttf; do
+ curl -fsS -o "$(basename "$p")" "$R/$p"
+ d=${p%%/*}; curl -fsS -o "$d-OFL.txt" "$R/$d/OFL.txt"
+done
+command ls -1
+```
+
+Expected: 5 `.ttf` and 5 `*-OFL.txt` files; `file *.ttf` reports TrueType for each.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/modules/sonix/fonts
+git commit -m "sonix: the four boot logo faces and Bungee Inline, SIL OFL"
+```
+
+---
+
+### Task 4: Sonix service
+
+**Files:**
+- Create: `desktop/Sonix.qml`
+- Modify: `desktop/shell.qml` (import)
+
+- [ ] **Step 1: Write the singleton**
+
+`desktop/Sonix.qml`:
+
+```qml
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "modules/sonix/Sonix.js" as S
+
+// The HiBy R1 running Sonix Player, over its two HTTP servers: SonixLink
+// (port 7800, state and commands) and Wi-Fi transfer (port 80, only while
+// its page is open on the player; used for the boot logo upload). See
+// modules/sonix/README.md.
+Singleton {
+ id: root
+
+ // Presence comes from the beacon alone: a failed request never clears it.
+ property bool present: false
+ property string host: ""
+ property int port: 7800
+ property string name: ""
+ readonly property string base: `http://${root.host}:${root.port}`
+
+ // The last /api/state answer, and the last request error ("" when fine).
+ property var player: ({})
+ property string error: ""
+
+ // Views that want state (island panel, drawer tile and page). Polled
+ // only while this is above zero.
+ property int holds: 0
+ function hold() { root.holds++; }
+ function release() {
+ root.holds = Math.max(0, root.holds - 1);
+ // The player counts a client as present for 20s after its last
+ // request; saying goodbye ends that at once.
+ if (root.holds === 0) root.request("GET", "/api/bye", null, () => {});
+ }
+
+ Process {
+ id: beacon
+ command: ["/usr/bin/python3", "-u", `${Quickshell.shellDir}/modules/sonix/sonix-beacon.py`]
+ running: true
+ stdout: SplitParser {
+ onRead: line => {
+ const b = S.parseBeacon(line);
+ if (!b) return;
+ root.host = b.host;
+ root.port = b.port;
+ root.name = b.name;
+ if (!root.present) console.log("sonix:", b.name, "at", b.host);
+ root.present = true;
+ lost.restart();
+ }
+ }
+ onExited: code => { console.warn("sonix: beacon relay exited", code); respawn.restart(); }
+ }
+ Timer { id: respawn; interval: 5000; onTriggered: { beacon.running = false; beacon.running = true; } }
+ Timer { id: lost; interval: 6000; onTriggered: { root.present = false; console.log("sonix: gone"); } }
+
+ Timer {
+ interval: 1000
+ repeat: true
+ triggeredOnStart: true
+ running: root.present && root.holds > 0
+ onTriggered: root.refresh()
+ }
+
+ function refresh() {
+ root.request("GET", "/api/state", null, (ok, d, err) => {
+ root.error = ok ? "" : err;
+ if (ok && d && typeof d === "object") root.player = d;
+ });
+ }
+
+ Component { id: timeout; Timer { interval: 3000 } }
+
+ // cb(ok, data, error): data is parsed JSON when the body is JSON.
+ function request(method, path, body, cb) {
+ if (!root.present) { cb(false, null, "not on the network"); return; }
+ const xhr = new XMLHttpRequest();
+ const t = timeout.createObject(root);
+ let done = false;
+ const finish = (ok, data, err) => {
+ if (done) return;
+ done = true;
+ t.destroy();
+ cb(ok, data, err);
+ };
+ t.triggered.connect(() => { xhr.abort(); finish(false, null, "timed out"); });
+ xhr.onreadystatechange = () => {
+ if (xhr.readyState !== XMLHttpRequest.DONE) return;
+ if (xhr.status === 200) {
+ let data = xhr.responseText;
+ try { data = JSON.parse(data); } catch (e) {}
+ finish(true, data, "");
+ } else {
+ finish(false, null, xhr.status ? `${xhr.status}: ${xhr.responseText.trim()}` : "no answer");
+ }
+ };
+ xhr.open(method, root.base + path);
+ if (body !== null) xhr.setRequestHeader("Content-Type", "text/plain");
+ xhr.send(body);
+ t.start();
+ }
+
+ function get(path, cb) { root.request("GET", path, null, cb); }
+
+ // params: { do: ..., ... }. body: track paths, one per line, or null.
+ function command(params, body, cb) {
+ root.request(body === null ? "GET" : "POST", "/api/command?" + S.query(params), body,
+ (ok, d, err) => { if (ok) root.refresh(); if (cb) cb(ok, d, err); });
+ }
+
+ // Upload a file into a card folder through Wi-Fi transfer. cb(code, text):
+ // 0 done, 3 Wi-Fi transfer not answering, 1 anything else (text says why).
+ readonly property string uploadScript: `
+ h=$1 f=$2 d=$3
+ curl -s -m 3 -o /dev/null "http://$h/" || exit 3
+ curl -s -m 10 -o /dev/null --data-urlencode "path=$d" "http://$h/create"
+ code=$(curl -s -m 120 -o /dev/null -w '%{http_code}' -F "path=$d/" -F "files[]=@$f" "http://$h/upload") \
+ || { echo "curl failed"; exit 1; }
+ [ "$code" = 200 ] || { echo "HTTP $code"; exit 1; }
+ `
+ Process {
+ id: up
+ property var cb: null
+ stdout: StdioCollector { id: upOut }
+ onExited: code => { const cb = up.cb; up.cb = null; if (cb) cb(code, upOut.text.trim()); }
+ }
+ function upload(file, cardDir, cb) {
+ if (up.running) { cb(1, "an upload is already running"); return; }
+ up.cb = cb;
+ up.command = ["sh", "-c", root.uploadScript, "sh", root.host, file, cardDir];
+ up.running = false;
+ up.running = true;
+ }
+}
+```
+
+- [ ] **Step 2: Load it**
+
+The singleton is instantiated on first use. To have the beacon running from
+startup, reference it once in `desktop/shell.qml`, inside `ShellRoot` right
+after the `keepalive` window:
+
+```qml
+ // The Sonix player's presence drives an island icon and a drawer tile,
+ // so its beacon is listened for from startup. See modules/sonix/README.md.
+ Component.onCompleted: Sonix.present
+```
+
+`ShellRoot` already has no `Component.onCompleted`; if one exists when you
+read the file, append the line to it instead.
+
+- [ ] **Step 3: Verify**
+
+Run: `qs log -p ~/Programming/GIT/quickshell/desktop --log-times | tail -n 40`
+Expected: a reload line, then `sonix: HiBy R1 at <ip>` with SonixLink on; no errors.
+Switch SonixLink off on the player; within ~8s: `sonix: gone`. Switch it back on.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/Sonix.qml desktop/shell.qml
+git commit -m "sonix: the player service: beacon presence, polling, commands, upload"
+```
+
+---
+
+### Task 5: Extract the island slider
+
+**Files:**
+- Create: `desktop/island/PanelSlider.qml`
+- Modify: `desktop/island/HeadphonesPanel.qml` (two `Slider {` uses, and the `component Slider` block at the end)
+
+- [ ] **Step 1: Create `desktop/island/PanelSlider.qml`**
+
+The inline component, with `panel.width` left to the user and
+`panel.glyph()` replaced:
+
+```qml
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import ".."
+
+// An icon, a track and a label: drag or click sets, the wheel steps one per
+// notch. Shared by the headphones and Sonix panels. The width is the user's.
+Row {
+ id: row
+ property int cp: 0
+ property string icon: ""
+ property real value: 0
+ property bool muted: false
+ property string label: ""
+ signal moved(real v)
+ signal stepped(int d)
+ signal iconClicked()
+
+ spacing: 10
+
+ Item {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 20
+ height: 20
+
+ Text {
+ visible: row.icon === ""
+ anchors.centerIn: parent
+ text: String.fromCodePoint(row.cp)
+ font { family: Theme.iconFamily; pixelSize: 20 }
+ color: Theme.subtext
+ }
+
+ IconImage {
+ visible: row.icon !== ""
+ anchors.centerIn: parent
+ implicitSize: 18
+ source: row.icon !== "" ? Quickshell.iconPath(row.icon, true) : ""
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: row.enabled
+ cursorShape: Qt.PointingHandCursor
+ onClicked: row.iconClicked()
+ }
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: row.width - 20 - 44 - row.spacing * 2
+ height: 8
+ radius: 4
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ width: parent.width * Math.max(0, Math.min(1, row.value))
+ color: row.muted ? Theme.red : Theme.accent
+ opacity: row.muted ? 0.5 : 1
+ }
+
+ // Taller than the track so it is easy to hit. A MouseArea for the
+ // wheel, not a WheelHandler (AGENTS.md); one step per notch (120),
+ // since a touchpad sends many small deltas.
+ MouseArea {
+ anchors { fill: parent; topMargin: -8; bottomMargin: -8 }
+ enabled: row.enabled
+ preventStealing: true
+ cursorShape: Qt.PointingHandCursor
+ property real acc: 0
+ function at(x) { return Math.max(0, Math.min(1, x / width)); }
+ onPressed: mouse => row.moved(at(mouse.x))
+ onPositionChanged: mouse => { if (pressed) row.moved(at(mouse.x)); }
+ onWheel: wheel => {
+ acc += wheel.angleDelta.y;
+ while (Math.abs(acc) >= 120) {
+ row.stepped(acc > 0 ? 1 : -1);
+ acc -= acc > 0 ? 120 : -120;
+ }
+ wheel.accepted = true;
+ }
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 44
+ horizontalAlignment: Text.AlignRight
+ text: row.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: row.muted ? Theme.red : Theme.subtext
+ }
+}
+```
+
+- [ ] **Step 2: Use it in HeadphonesPanel**
+
+In `desktop/island/HeadphonesPanel.qml`:
+- delete the whole `component Slider: Row { ... }` block (from the line
+ ` component Slider: Row {` to its closing ` }`, just before the file's
+ final `}`);
+- replace each of the two ` Slider {` lines with:
+
+```qml
+ PanelSlider {
+ width: panel.width
+```
+
+- [ ] **Step 3: Verify**
+
+Log as in the ground rules: reload, no errors. Then ask the user to open the
+headphones panel (if the headphones are around) and confirm both sliders
+still drag and wheel. If they are not around, say so; the log is the check.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/island/PanelSlider.qml desktop/island/HeadphonesPanel.qml
+git commit -m "island: the panel slider as its own file, for the Sonix panel too"
+```
+
+---
+
+### Task 6: Island icon and controls panel
+
+**Files:**
+- Create: `desktop/island/SonixPanel.qml`
+- Modify: `desktop/island/StatusPill.qml`, `desktop/island/Island.qml`
+
+- [ ] **Step 1: Check the API's units against the live player**
+
+Start something playing on the player, then:
+
+Run: `curl -s $R1/api/state; echo; curl -s $R1/api/bye`
+Expected: `"state":1`, a non-zero `bitrate`. If `bitrate` reads in bits per
+second (e.g. `2304000` for a 24/96 FLAC) change `qualityLine` in `Sonix.js`
+to divide by 1000, update the test's expectations, rerun it, and commit that
+first.
+
+Run: `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' "$R1/api/art?path=<the path from state>&max=256"`
+Expected: `200 image/...`, or `202` once (preparing) then `200` on a repeat.
+
+- [ ] **Step 2: Write `desktop/island/SonixPanel.qml`**
+
+```qml
+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: ""; onClicked: Sonix.command({ do: "prev" }, null, panel.report) }
+ TransportButton {
+ glyph: panel.p.state === 1 ? "" : ""
+ onClicked: Sonix.command({ do: "toggle" }, null, panel.report)
+ }
+ TransportButton { glyph: ""; 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
+ }
+}
+```
+
+- [ ] **Step 3: The icon and panel in `desktop/island/StatusPill.qml`**
+
+Change the header comment's first sentence to:
+
+```qml
+// Sonix player icon (while it is on the network), headphones icon (while
+// Sony headphones are connected), volume icon, tray, layout flag.
+```
+
+Add the signal next to `signal openHeadphones()`:
+
+```qml
+ signal openSonix()
+```
+
+Replace the `expanded` and `panelHeight` lines with:
+
+```qml
+ expanded: panel === "sound" || panel === "headphones" || panel === "sonix"
+ restWidth: restRow.implicitWidth + 24
+ panelWidth: 320
+ panelHeight: (panel === "headphones" ? phonesPanel.implicitHeight
+ : panel === "sonix" ? sonixPanel.implicitHeight
+ : soundPanel.implicitHeight) + 28
+```
+
+(keep the existing `restWidth` and `panelWidth` lines once; the block above
+shows them for position only).
+
+In `restRow`, before the headphones `IconImage`, add:
+
+```qml
+ // 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()
+ }
+ }
+```
+
+After the `HeadphonesPanel { ... }` block, add:
+
+```qml
+ 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 } }
+ }
+```
+
+Note: the dot shows the last polled state; with no view holding the
+service it is not refreshed. That is accepted (the dot is a hint; the panel
+polls while open).
+
+- [ ] **Step 4: Wire it in `desktop/island/Island.qml`**
+
+In the comment listing panels (line ~45), add `"sonix"` to the list.
+
+After the headphones `Connections` block, add:
+
+```qml
+ // And the Sonix player leaving the network.
+ Connections {
+ target: Sonix
+ function onPresentChanged() { if (!Sonix.present && win.panel === "sonix") win.panel = ""; }
+ }
+```
+
+In the `StatusPill { ... }` handlers, after `onOpenHeadphones`, add:
+
+```qml
+ onOpenSonix: win.panel = win.panel === "sonix" ? "" : "sonix"
+```
+
+- [ ] **Step 5: Verify**
+
+Log: reload, no errors. Ask the user to: see the player icon in the status
+pill, open it, and check cover, title, heart toggle (and that the player's
+Favourites list shows the track), seek, transport, mode cycling, volume
+drag and wheel. Then switch SonixLink off: the icon goes within ~8s and an
+open panel closes.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/island/SonixPanel.qml desktop/island/StatusPill.qml desktop/island/Island.qml
+git commit -m "island: the Sonix player's icon and controls panel"
+```
+
+---
+
+### Task 7: Drawer tile and Device tab
+
+**Files:**
+- Create: `desktop/modules/sonix/SonixModule.qml`, `SonixTile.qml`, `SonixPage.qml`, `SonixRow.qml`, `SonixDevice.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: `SonixTile.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+// "95% · playing". Holds the service while the drawer shows the tile.
+Item {
+ width: parent ? parent.width : implicitWidth
+ implicitHeight: line.implicitHeight
+
+ Component.onCompleted: Sonix.hold()
+ Component.onDestruction: Sonix.release()
+
+ Text {
+ id: line
+ width: parent.width
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.text
+ text: {
+ const p = Sonix.player;
+ if (p.battery === undefined) return "…";
+ return `${p.battery}%${p.charging ? " charging" : ""} · `
+ + (p.state === 1 ? "playing" : p.state === 2 ? "paused" : "idle");
+ }
+ }
+}
+```
+
+- [ ] **Step 2: `SonixRow.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+// A list row: a label, an optional second line, trailing buttons (children),
+// and a click on the text.
+Item {
+ id: row
+ property string text: ""
+ property string sub: ""
+ default property alias buttons: trail.data
+ signal clicked()
+
+ width: parent ? parent.width : implicitWidth
+ implicitHeight: Math.max(texts.implicitHeight, trail.implicitHeight) + 10
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: hit.containsMouse ? Qt.alpha(Theme.accent, 0.12) : "transparent"
+ }
+
+ Column {
+ id: texts
+ anchors { left: parent.left; leftMargin: 8; right: trail.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: row.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ Text {
+ visible: row.sub !== ""
+ width: parent.width
+ elide: Text.ElideRight
+ text: row.sub
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+
+ MouseArea {
+ id: hit
+ anchors { left: parent.left; right: trail.left; top: parent.top; bottom: parent.bottom }
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: row.clicked()
+ }
+
+ Row {
+ id: trail
+ anchors { right: parent.right; rightMargin: 4; verticalCenter: parent.verticalCenter }
+ spacing: 2
+ }
+}
+```
+
+- [ ] **Step 3: `SonixDevice.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+Column {
+ id: dev
+ signal go(string name)
+ readonly property var p: Sonix.player
+ spacing: 10
+
+ component Line: Row {
+ id: line
+ property string k: ""
+ property string v: ""
+ spacing: 10
+ Text {
+ width: 110
+ text: line.k
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Text {
+ text: line.v
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ }
+
+ Line { k: "Name"; v: Sonix.name }
+ Line { k: "Address"; v: `${Sonix.host}:${Sonix.port}` }
+ Line {
+ k: "Battery"
+ v: dev.p.battery === undefined ? "…" : dev.p.battery + "%" + (dev.p.charging ? ", charging" : "")
+ }
+ Row {
+ spacing: 10
+ Text {
+ width: 110
+ text: "Accent"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Rectangle {
+ width: 18; height: 18; radius: 4
+ color: dev.p.accent || "transparent"
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.2)
+ }
+ Text {
+ text: dev.p.accent || ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ }
+ Line {
+ k: "Library"
+ v: dev.p.scanning ? `scanning… ${dev.p.scan_count} found`
+ : dev.p.tracks === undefined ? "…" : dev.p.tracks + " tracks"
+ }
+ Line {
+ k: "Now"
+ v: (["stopped", "playing", "paused"][dev.p.state || 0]) + (dev.p.title ? " · " + dev.p.title : "")
+ }
+
+ Row {
+ spacing: 8
+ Button {
+ text: "Rescan"
+ enabled: !dev.p.scanning
+ onClicked: Sonix.command({ do: "scan" }, null, (ok, d, err) => msg.text = ok ? "" : err)
+ }
+ Button {
+ text: "Open Wi-Fi transfer"
+ onClicked: Qt.openUrlExternally(`http://${Sonix.host}/`)
+ }
+ Button {
+ text: "Boot logo…"
+ onClicked: dev.go("bootlogo")
+ }
+ }
+
+ Text {
+ width: dev.width
+ wrapMode: Text.Wrap
+ text: "Wi-Fi transfer answers only while its page is open on the player."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+
+ Text {
+ id: msg
+ visible: text !== ""
+ width: dev.width
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.red
+ }
+}
+```
+
+- [ ] **Step 4: `SonixPage.qml`** (Playlists and Folders come in Tasks 8 and 9; this step creates the tab bar with Device only wired, the other two as empty placeholders that Tasks 8 and 9 replace)
+
+```qml
+import QtQuick
+import "../.."
+
+Column {
+ id: page
+ required property var mod
+ signal go(string name)
+ property string tab: "device"
+ spacing: 14
+
+ Component.onCompleted: Sonix.hold()
+ Component.onDestruction: Sonix.release()
+
+ Text {
+ visible: !Sonix.present
+ text: "The player is not on the network."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+
+ Row {
+ visible: Sonix.present
+ spacing: 6
+ Repeater {
+ model: [["device", "Device"], ["playlists", "Playlists"], ["folders", "Folders"]]
+ delegate: Button {
+ required property var modelData
+ text: modelData[1]
+ border.color: page.tab === modelData[0] ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: page.tab = modelData[0]
+ }
+ }
+ }
+
+ Loader {
+ width: page.width
+ active: Sonix.present
+ sourceComponent: page.tab === "device" ? deviceTab
+ : page.tab === "playlists" ? playlistsTab : foldersTab
+ }
+
+ Component { id: deviceTab; SonixDevice { onGo: name => page.go(name) } }
+ Component { id: playlistsTab; Item {} }
+ Component { id: foldersTab; Item {} }
+}
+```
+
+- [ ] **Step 5: `SonixModule.qml`**
+
+```qml
+import QtQuick
+import "../.."
+
+// The Sonix player in the drawer: a tile while it is on the network, and a
+// page with its device state, playlists and folders. See README.md.
+Module {
+ id: sonixMod
+
+ name: "sonix"
+ label: Sonix.name || "Sonix"
+ icon: String.fromCodePoint(0xF075A) // md-music
+ tiled: Sonix.present
+ active: Sonix.present && Sonix.player.state === 1
+
+ // Names typed under Playlists > New: SonixLink makes a playlist only by
+ // adding a track to it, so a new one lives here until its first track.
+ property var draftPlaylists: []
+
+ tileContent: Component { SonixTile {} }
+
+ page: Component {
+ Page {
+ id: pg
+ title: Sonix.name || "Sonix"
+ // Not `mod: mod`: inside SonixPage that names its own property.
+ SonixPage { width: parent.width; mod: sonixMod; onGo: name => pg.go(name) }
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Register it in `desktop/shell.qml`**
+
+Add `import "modules/sonix"` in alphabetical position among the module
+imports (after `import "modules/sound"`), and `SonixModule {},` after
+`KdeConnectModule {},` in the `modules` list.
+
+- [ ] **Step 7: Verify**
+
+Log: reload, no errors. Ask the user to open the drawer (SUPER): the tile
+shows while the player is on the network with `NN% · state`; its page shows
+the Device tab with real values; Rescan starts a scan (Library line shows
+progress); Open Wi-Fi transfer opens the browser. Boot logo… does nothing
+yet (Task 13 adds the page).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add desktop/modules/sonix/SonixModule.qml desktop/modules/sonix/SonixTile.qml \
+ desktop/modules/sonix/SonixPage.qml desktop/modules/sonix/SonixRow.qml \
+ desktop/modules/sonix/SonixDevice.qml desktop/shell.qml
+git commit -m "sonix: drawer tile and the Device tab"
+```
+
+---
+
+### Task 8: Playlists tab
+
+**Files:**
+- Create: `desktop/modules/sonix/SonixPlaylists.qml`
+- Modify: `desktop/modules/sonix/SonixPage.qml` (the `playlistsTab` component)
+
+- [ ] **Step 1: Check the calls against the live player**
+
+```bash
+curl -s "$R1/api/playlists"; echo
+curl -s -G --data-urlencode 'name=<an existing playlist>' "$R1/api/playlist" | head -c 300; echo
+curl -s "$R1/api/bye"
+```
+
+Expected: `{"revision":N,"playlists":[{"name":...,"count":...}]}` and
+`{"name":...,"tracks":[{"path":...,"title":...,"artist":...}]}`.
+
+- [ ] **Step 2: Write `SonixPlaylists.qml`**
+
+```qml
+import QtQuick
+import "../.."
+import "../appearance"
+import "../sound"
+import "Sonix.js" as S
+
+// Playlists: list, play, open, remove tracks, and new names that become
+// playlists when Folders adds their first track. No rename, delete or
+// reorder: SonixLink has none (README.md).
+Column {
+ id: pl
+ required property var mod
+ property var lists: []
+ property string open: ""
+ property var tracks: []
+ property string error: ""
+ property var rev: undefined
+ spacing: 8
+
+ function glyph(cp) { return String.fromCodePoint(cp); }
+ function report(ok, d, err) { pl.error = ok ? "" : err; }
+
+ function load() {
+ Sonix.get("/api/playlists", (ok, d, err) => {
+ pl.report(ok, d, err);
+ if (!ok) return;
+ pl.lists = d.playlists || [];
+ const names = pl.lists.map(l => l.name);
+ pl.mod.draftPlaylists = pl.mod.draftPlaylists.filter(n => names.indexOf(n) < 0);
+ });
+ if (pl.open !== "") pl.loadTracks();
+ }
+ function loadTracks() {
+ Sonix.get("/api/playlist?" + S.query({ name: pl.open }), (ok, d, err) => {
+ pl.report(ok, d, err);
+ if (ok) pl.tracks = d.tracks || [];
+ });
+ }
+
+ Component.onCompleted: load()
+ // Edits made on the player show up: the state carries a revision.
+ Connections {
+ target: Sonix
+ function onPlayerChanged() {
+ const r = Sonix.player.playlists_revision;
+ if (r !== undefined && r !== pl.rev) { pl.rev = r; pl.load(); }
+ }
+ }
+
+ // --- the list ---------------------------------------------------------
+
+ Row {
+ visible: pl.open === ""
+ spacing: 8
+ Field { id: newName; width: 220; placeholder: "New playlist name" }
+ Button {
+ text: "New"
+ onClicked: {
+ const n = newName.value.trim();
+ if (n === "" || pl.lists.some(l => l.name === n) || pl.mod.draftPlaylists.indexOf(n) >= 0) return;
+ pl.mod.draftPlaylists = pl.mod.draftPlaylists.concat([n]);
+ newName.value = "";
+ }
+ }
+ }
+
+ Repeater {
+ model: pl.open === "" ? pl.lists : []
+ delegate: SonixRow {
+ required property var modelData
+ width: pl.width
+ text: modelData.name
+ sub: modelData.count + " tracks"
+ onClicked: { pl.open = modelData.name; pl.tracks = []; pl.loadTracks(); }
+ TransportButton {
+ glyph: pl.glyph(0xF040A) // md-play
+ onClicked: Sonix.command({ do: "play_all", list: "playlist", value: modelData.name }, null, pl.report)
+ }
+ }
+ }
+
+ Repeater {
+ model: pl.open === "" ? pl.mod.draftPlaylists : []
+ delegate: SonixRow {
+ required property var modelData
+ width: pl.width
+ text: modelData
+ sub: "empty: add its first track from Folders"
+ }
+ }
+
+ // --- one playlist -----------------------------------------------------
+
+ Row {
+ visible: pl.open !== ""
+ spacing: 8
+ Button { text: "Playlists"; icon: ""; onClicked: { pl.open = ""; pl.tracks = []; } }
+ Button {
+ text: "Play all"
+ onClicked: Sonix.command({ do: "play_all", list: "playlist", value: pl.open }, null, pl.report)
+ }
+ }
+
+ Text {
+ visible: pl.open !== ""
+ text: pl.open
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ Repeater {
+ model: pl.open !== "" ? pl.tracks : []
+ delegate: SonixRow {
+ required property var modelData
+ width: pl.width
+ text: modelData.title || modelData.path.split("/").pop()
+ sub: modelData.artist || ""
+ TransportButton {
+ glyph: pl.glyph(0xF040A) // md-play
+ onClicked: Sonix.command({ do: "play_path", path: modelData.path, list: "playlist", value: pl.open },
+ null, pl.report)
+ }
+ TransportButton {
+ glyph: pl.glyph(0xF0156) // md-close
+ onClicked: Sonix.command({ do: "playlist_remove", value: pl.open }, modelData.path,
+ (ok, d, err) => { pl.report(ok, d, err); if (ok) pl.loadTracks(); })
+ }
+ }
+ }
+
+ Text {
+ visible: pl.error !== ""
+ width: pl.width
+ wrapMode: Text.Wrap
+ text: pl.error
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.red
+ }
+}
+```
+
+`Field` (`desktop/modules/appearance/Field.qml`) exposes `value`, a
+writable alias of its text, `placeholder`, and `edited`, fired on Enter or
+focus loss.
+
+- [ ] **Step 3: Wire the tab**
+
+In `SonixPage.qml` replace `Component { id: playlistsTab; Item {} }` with:
+
+```qml
+ Component { id: playlistsTab; SonixPlaylists { mod: page.mod } }
+```
+
+- [ ] **Step 4: Verify**
+
+Log: reload, no errors. Ask the user to: open Playlists, see their
+playlists with counts, play one with ▶, open one, play a track, remove a
+track (and see the count drop on the player), add a New name and see it
+listed as empty.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/sonix/SonixPlaylists.qml desktop/modules/sonix/SonixPage.qml
+git commit -m "sonix: the Playlists tab"
+```
+
+---
+
+### Task 9: Folders tab
+
+**Files:**
+- Create: `desktop/modules/sonix/SonixFolders.qml`
+- Modify: `desktop/modules/sonix/SonixPage.qml` (the `foldersTab` component)
+
+- [ ] **Step 1: Check play_path with a folder list on the live player**
+
+`play_all` refuses `list=folder`, so a folder plays as its first file with
+the folder as the list:
+
+```bash
+curl -s -G --data-urlencode 'path=/' "$R1/api/browse" | head -c 400; echo
+# pick a folder F and a file inside it P from a second browse of F, then:
+curl -s -G --data-urlencode 'do=play_path' --data-urlencode "path=P" \
+ --data-urlencode 'list=folder' --data-urlencode "value=F" "$R1/api/command"; echo
+curl -s "$R1/api/bye"
+```
+
+Expected: `ok`, and the player plays P with the rest of F queued after it.
+If the player rejects it or queues something else, stop and report to the
+user before writing the tab.
+
+- [ ] **Step 2: Write `SonixFolders.qml`**
+
+```qml
+import QtQuick
+import "../.."
+import "../appearance"
+import "../sound"
+import "Sonix.js" as S
+
+// The card's folders: browse, play a folder or a file, add either to a
+// playlist (an existing one, a draft from Playlists, or a new name).
+Column {
+ id: fo
+ required property var mod
+ property string root: ""
+ property string path: ""
+ property var entries: []
+ property string error: ""
+ property string notice: ""
+ // What + was pressed on: { label, folder: bool, path }; null when closed.
+ property var adding: null
+ spacing: 8
+
+ function glyph(cp) { return String.fromCodePoint(cp); }
+ function report(ok, d, err) { fo.error = ok ? "" : err; }
+
+ function go(p) {
+ Sonix.get("/api/browse?" + S.query({ path: p }), (ok, d, err) => {
+ fo.report(ok, d, err);
+ if (!ok) return;
+ fo.root = d.root;
+ fo.path = d.path;
+ fo.entries = d.entries || [];
+ fo.adding = null;
+ });
+ }
+ Component.onCompleted: go("/")
+
+ // The files directly inside a folder, for playing or adding it whole.
+ function filesIn(folder, cb) {
+ Sonix.get("/api/browse?" + S.query({ path: folder }), (ok, d, err) => {
+ if (!ok) { fo.report(ok, d, err); return; }
+ cb((d.entries || []).filter(e => !e.dir).map(e => e.path));
+ });
+ }
+
+ function playFolder(folder) {
+ fo.filesIn(folder, files => {
+ if (files.length === 0) { fo.error = "no tracks directly in this folder"; return; }
+ Sonix.command({ do: "play_path", path: files[0], list: "folder", value: folder }, null, fo.report);
+ });
+ }
+
+ function addTo(name) {
+ const a = fo.adding;
+ if (!a || name === "") return;
+ const send = paths => {
+ if (paths.length === 0) { fo.error = "no tracks directly in this folder"; return; }
+ Sonix.command({ do: "playlist_add", value: name }, paths.join("\n"), (ok, d, err) => {
+ fo.report(ok, d, err);
+ if (ok) fo.notice = `Added ${paths.length} to ${name}`;
+ });
+ };
+ fo.adding = null;
+ if (a.folder) fo.filesIn(a.path, send); else send([a.path]);
+ }
+
+ property var playlists: []
+ function loadPlaylists() {
+ Sonix.get("/api/playlists", (ok, d, err) => { if (ok) fo.playlists = (d.playlists || []).map(l => l.name); });
+ }
+
+ // --- breadcrumbs ------------------------------------------------------
+
+ Flow {
+ width: fo.width
+ spacing: 6
+ Repeater {
+ model: S.crumbs(fo.path, fo.root)
+ delegate: Text {
+ required property var modelData
+ required property int index
+ text: (index > 0 ? "› " : "") + modelData.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: modelData.path === fo.path ? Theme.text : Theme.accent
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: fo.go(modelData.path)
+ }
+ }
+ }
+ }
+
+ Button {
+ text: "Up"
+ icon: ""
+ enabled: fo.path !== fo.root
+ onClicked: fo.go(S.parentOf(fo.path, fo.root))
+ }
+
+ // --- the add-to chooser -----------------------------------------------
+
+ Rectangle {
+ visible: fo.adding !== null
+ width: fo.width
+ height: chooser.implicitHeight + 16
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.accent, 0.5)
+
+ Column {
+ id: chooser
+ x: 8; y: 8
+ width: parent.width - 16
+ spacing: 6
+
+ Text {
+ text: fo.adding ? `Add “${fo.adding.label}” to:` : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ Flow {
+ width: parent.width
+ spacing: 6
+ Repeater {
+ model: fo.playlists.concat(fo.mod.draftPlaylists.filter(n => fo.playlists.indexOf(n) < 0))
+ delegate: Button {
+ required property var modelData
+ text: modelData
+ onClicked: fo.addTo(modelData)
+ }
+ }
+ }
+ Row {
+ spacing: 6
+ Field { id: newName; width: 200; placeholder: "New playlist name" }
+ Button { text: "Add"; onClicked: fo.addTo(newName.value.trim()) }
+ Button { text: "Cancel"; onClicked: fo.adding = null }
+ }
+ }
+ }
+
+ // --- entries ----------------------------------------------------------
+
+ Repeater {
+ model: fo.entries
+ delegate: SonixRow {
+ required property var modelData
+ width: fo.width
+ text: (modelData.dir ? fo.glyph(0xF024B) + " " : "") + modelData.name // md-folder
+ onClicked: if (modelData.dir) fo.go(modelData.path)
+ TransportButton {
+ glyph: fo.glyph(0xF040A) // md-play
+ onClicked: modelData.dir
+ ? fo.playFolder(modelData.path)
+ : Sonix.command({ do: "play_path", path: modelData.path, list: "folder", value: fo.path }, null, fo.report)
+ }
+ TransportButton {
+ glyph: fo.glyph(0xF0415) // md-plus
+ onClicked: {
+ fo.notice = "";
+ fo.loadPlaylists();
+ fo.adding = { label: modelData.name, folder: modelData.dir, path: modelData.path };
+ }
+ }
+ }
+ }
+
+ Text {
+ visible: fo.notice !== "" && fo.error === ""
+ text: fo.notice
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.green
+ }
+ Text {
+ visible: fo.error !== ""
+ width: fo.width
+ wrapMode: Text.Wrap
+ text: fo.error
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.red
+ }
+}
+```
+
+The folder glyph is drawn in `Theme.fontFamily` inside the row text; if it
+shows as a box, move it to a separate `Text` in `Theme.iconFamily` (the
+row's `text` then holds the name only).
+
+- [ ] **Step 3: Wire the tab**
+
+In `SonixPage.qml` replace `Component { id: foldersTab; Item {} }` with:
+
+```qml
+ Component { id: foldersTab; SonixFolders { mod: page.mod } }
+```
+
+- [ ] **Step 4: Verify**
+
+Log: reload, no errors. Ask the user to: browse into a folder and back via
+breadcrumbs and Up; play a folder; play a file; add a file to an existing
+playlist; add a folder to a draft name from Playlists (it appears as a real
+playlist on the Playlists tab and on the player).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/sonix/SonixFolders.qml desktop/modules/sonix/SonixPage.qml
+git commit -m "sonix: the Folders tab, with add to playlist"
+```
+
+---
+
+### Task 10: Boot logo preferences
+
+**Files:**
+- Modify: `desktop/Prefs.qml`
+
+- [ ] **Step 1: Add the property and setter**
+
+After the `wallTransition` block, add:
+
+```qml
+ // The Sonix boot logo editor (modules/sonix/BootlogoTab.qml): text ("" for
+ // $USER), font key, size, centre x/y in the 480x800 frame, fill and second
+ // colour (hex or "udt"), offset and its direction, output folder ("" for
+ // ~/Pictures/sonix-bootlogo). The picture is not kept.
+ readonly property var bootlogoDefaults: ({ text: "", font: "bungee", size: 110, x: 240, y: 690,
+ fill: "#ffffff", second: "#e5a50a", offset: 8, dir: "se", folder: "" })
+ property var bootlogo: bootlogoDefaults
+ function setBootlogo(b) {
+ root.bootlogo = Object.assign({}, root.bootlogoDefaults, b);
+ root.write(Object.assign({}, root.doc, { bootlogo: root.bootlogo }));
+ }
+```
+
+- [ ] **Step 2: Read it back**
+
+At the end of `reparse()`, add:
+
+```qml
+ const bl = parsed.bootlogo && typeof parsed.bootlogo === "object" && !Array.isArray(parsed.bootlogo)
+ ? parsed.bootlogo : {};
+ root.bootlogo = Object.assign({}, root.bootlogoDefaults, bl);
+```
+
+In `onLoadFailed`, append `root.bootlogo = root.bootlogoDefaults;` inside the braces.
+
+- [ ] **Step 3: Verify**
+
+Log: reload, no errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/Prefs.qml
+git commit -m "prefs: the Sonix boot logo editor's settings"
+```
+
+---
+
+### Task 11: Boot logo canvas
+
+**Files:**
+- Create: `desktop/modules/sonix/BootlogoCanvas.qml`
+
+- [ ] **Step 1: Write it**
+
+```qml
+import QtQuick
+import "../.."
+import "Sonix.js" as S
+
+// The 480x800 boot logo scene: the picture, panned and zoomed by the user,
+// and the duotone text. The output is this item, grabbed, so what is seen
+// is what the player gets. Scale it for display through a parent, never
+// through its own `scale`: grabToImage must see it at 480x800.
+Item {
+ id: canvas
+ width: 480
+ height: 800
+ clip: true
+
+ property url source: ""
+ property var cfg: ({}) // Prefs.bootlogo's shape
+ property string text: ""
+ property bool moveText: false
+ property bool maskOnly: false // the text alone, for the mask grab
+ property var place: ({ x: 0, y: 0, w: 480, h: 800, scale: 1, zoom: 1 })
+ readonly property bool ready: photo.status === Image.Ready
+ readonly property string loadError: photo.status === Image.Error ? "Cannot read this picture." : ""
+ signal textMoved(real x, real y)
+
+ FontLoader { id: fBungee; source: Qt.resolvedUrl("fonts/Bungee-Regular.ttf") }
+ FontLoader { id: fInline; source: Qt.resolvedUrl("fonts/BungeeInline-Regular.ttf") }
+ FontLoader { id: fMono; source: Qt.resolvedUrl("fonts/MajorMonoDisplay-Regular.ttf") }
+ FontLoader { id: fMich; source: Qt.resolvedUrl("fonts/Michroma-Regular.ttf") }
+ FontLoader { id: fAudio; source: Qt.resolvedUrl("fonts/Audiowide-Regular.ttf") }
+ readonly property var families: ({ bungee: fBungee.name, mono: fMono.name,
+ michroma: fMich.name, audiowide: fAudio.name })
+
+ function colour(t) { return S.colour(t, Theme.accent); }
+ function recentre() {
+ if (canvas.ready) canvas.place = S.centred(photo.implicitWidth, photo.implicitHeight, canvas.width, canvas.height);
+ }
+
+ Rectangle { anchors.fill: parent; color: "black"; visible: !canvas.maskOnly }
+
+ Image {
+ id: photo
+ source: canvas.source
+ asynchronous: true
+ smooth: true
+ mipmap: true
+ visible: !canvas.maskOnly
+ x: canvas.place.x
+ y: canvas.place.y
+ width: canvas.place.w
+ height: canvas.place.h
+ onStatusChanged: if (status === Image.Ready) canvas.recentre()
+ }
+
+ Item {
+ id: label
+ readonly property string fam: canvas.families[canvas.cfg.font] || canvas.families.bungee
+ readonly property bool layered: canvas.cfg.font === "bungee"
+ readonly property var off: S.offsetXY(canvas.cfg.dir, canvas.cfg.offset)
+ width: fillText.implicitWidth
+ height: fillText.implicitHeight
+ x: canvas.cfg.x - width / 2
+ y: canvas.cfg.y - height / 2
+
+ // The second colour: an offset copy behind, or Bungee Inline on top.
+ Text {
+ visible: !label.layered
+ x: label.off.x
+ y: label.off.y
+ text: canvas.text
+ font { family: label.fam; pixelSize: canvas.cfg.size }
+ color: canvas.colour(canvas.cfg.second)
+ }
+ Text {
+ id: fillText
+ text: canvas.text
+ font { family: label.fam; pixelSize: canvas.cfg.size }
+ color: canvas.colour(canvas.cfg.fill)
+ }
+ Text {
+ visible: label.layered
+ text: canvas.text
+ font { family: fInline.name; pixelSize: canvas.cfg.size }
+ color: canvas.colour(canvas.cfg.second)
+ }
+ }
+
+ // Drag pans the picture or moves the text; the wheel zooms the picture
+ // at the cursor. Coordinates are the canvas's own, unscaled.
+ MouseArea {
+ anchors.fill: parent
+ preventStealing: true
+ cursorShape: Qt.SizeAllCursor
+ property real lx: 0
+ property real ly: 0
+ onPressed: m => { lx = m.x; ly = m.y; }
+ onPositionChanged: m => {
+ const dx = m.x - lx, dy = m.y - ly;
+ lx = m.x; ly = m.y;
+ if (canvas.moveText) canvas.textMoved(canvas.cfg.x + dx, canvas.cfg.y + dy);
+ else if (canvas.ready)
+ canvas.place = S.cover(photo.implicitWidth, photo.implicitHeight, canvas.place.zoom,
+ canvas.place.x + dx, canvas.place.y + dy, canvas.width, canvas.height);
+ }
+ onWheel: w => {
+ w.accepted = true;
+ if (!canvas.ready) return;
+ const z = canvas.place.zoom * (w.angleDelta.y > 0 ? 1.1 : 1 / 1.1);
+ canvas.place = S.zoomAt(photo.implicitWidth, photo.implicitHeight, canvas.place, z,
+ w.x, w.y, canvas.width, canvas.height);
+ }
+ }
+
+ // Writes base + ".png" (the logo) and base + "-mask.png" (the text on
+ // transparency). cb(ok, error).
+ function save(base, cb) {
+ canvas.grabToImage(r => {
+ if (!r.saveToFile(base + ".png")) { cb(false, `Cannot write ${base}.png`); return; }
+ canvas.maskOnly = true;
+ canvas.grabToImage(m => {
+ canvas.maskOnly = false;
+ const ok = m.saveToFile(base + "-mask.png");
+ cb(ok, ok ? "" : `Cannot write ${base}-mask.png`);
+ }, Qt.size(480, 800));
+ }, Qt.size(480, 800));
+ }
+}
+```
+
+- [ ] **Step 2: Commit** (verified with the tab in Task 12)
+
+```bash
+git add desktop/modules/sonix/BootlogoCanvas.qml
+git commit -m "sonix: the boot logo canvas: picture, duotone text, grab"
+```
+
+---
+
+### Task 12: Boot logo controls
+
+**Files:**
+- Create: `desktop/modules/sonix/BootlogoTab.qml`
+
+- [ ] **Step 1: Write it**
+
+```qml
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Dialogs
+import "../.."
+import "../appearance"
+import "Sonix.js" as S
+
+// The boot logo editor: the canvas on the left, its controls on the right;
+// Save and Upload live in the page header (BootlogoModule.qml) and call
+// save() and upload() here.
+Row {
+ id: tab
+ spacing: 24
+
+ // Edited locally, written to Prefs after a pause, not on every drag step.
+ property var cfg: Object.assign({}, Prefs.bootlogo)
+ function set(ch) { tab.cfg = Object.assign({}, tab.cfg, ch); cfgWrite.restart(); }
+ Timer { id: cfgWrite; interval: 600; onTriggered: Prefs.setBootlogo(tab.cfg) }
+
+ readonly property string folder: tab.cfg.folder || Quickshell.env("HOME") + "/Pictures/sonix-bootlogo"
+ readonly property string cardDir: "/data/mnt/sd_0/Bootlogo"
+
+ property bool busy: false
+ property bool waiting: false
+ property string status: ""
+ property bool statusError: false
+ readonly property bool canSave: canvas.ready && !tab.busy
+ function say(t, err) { tab.status = t; tab.statusError = !!err; }
+
+ readonly property var swatches: ["#ffffff", "#000000", "#3584e4", "#ff7800", "#e5a50a",
+ "#2ec27e", "#9141ac", "#d56199", "udt"]
+ readonly property var fontKeys: [["bungee", "Bungee + Inline"], ["mono", "Major Mono"],
+ ["michroma", "Michroma"], ["audiowide", "Audiowide"]]
+
+ // --- save and upload --------------------------------------------------
+
+ // then(file) runs after a successful save.
+ function save(then) {
+ tab.busy = true;
+ mk.then = then || null;
+ mk.command = ["mkdir", "-p", tab.folder];
+ mk.running = false;
+ mk.running = true;
+ }
+ Process {
+ id: mk
+ property var then: null
+ onExited: code => {
+ if (code !== 0) { tab.busy = false; tab.say(`Cannot create ${tab.folder}`, true); return; }
+ const base = tab.folder + "/" + S.stamp(new Date());
+ canvas.save(base, (ok, err) => {
+ tab.busy = false;
+ if (!ok) { tab.say(err, true); return; }
+ tab.say(`Saved ${base}.png`);
+ if (mk.then) mk.then(base + ".png");
+ });
+ }
+ }
+
+ property int tries: 0
+ function upload() { tab.save(file => { tab.tries = 0; tab.send(file); }); }
+ function send(file) {
+ tab.busy = true;
+ Sonix.upload(file, tab.cardDir, (code, out) => {
+ tab.busy = false;
+ if (code === 3 && tab.tries < 30) {
+ tab.waiting = true;
+ tab.tries++;
+ tab.say("Open Wi-Fi transfer on the player (Control centre) and leave it on screen.", true);
+ retry.file = file;
+ retry.restart();
+ return;
+ }
+ tab.waiting = false;
+ if (code === 0)
+ tab.say("Uploaded. Pick it on the player: Settings > Appearance > Boot logo. You can leave Wi-Fi transfer now.");
+ else if (code === 3)
+ tab.say("Wi-Fi transfer did not answer for 60s. The copy is saved locally.", true);
+ else
+ tab.say(`Upload failed: ${out || "exit " + code}. The copy is saved locally.`, true);
+ });
+ }
+ Timer { id: retry; property string file: ""; interval: 2000; onTriggered: tab.send(file) }
+
+ // --- canvas -----------------------------------------------------------
+
+ // 0.75 for display; the canvas itself stays 480x800 for the grab.
+ Rectangle {
+ width: 480 * 0.75 + 8
+ height: 800 * 0.75 + 8
+ radius: 14
+ color: "black"
+ border.width: 2
+ border.color: Qt.alpha(Theme.text, 0.25)
+
+ Item {
+ x: 4; y: 4
+ width: 480 * 0.75
+ height: 800 * 0.75
+ scale: 0.75
+ transformOrigin: Item.TopLeft
+
+ BootlogoCanvas {
+ id: canvas
+ cfg: tab.cfg
+ text: tab.cfg.text || Quickshell.env("USER")
+ moveText: moveTextBtn.chosen
+ onTextMoved: (x, y) => tab.set({ x: Math.round(x), y: Math.round(y) })
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ width: parent.width - 40
+ visible: !canvas.ready
+ wrapMode: Text.Wrap
+ horizontalAlignment: Text.AlignHCenter
+ text: canvas.loadError || "Choose a picture"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: canvas.loadError ? Theme.red : Theme.subtext
+ }
+ }
+
+ // --- controls ---------------------------------------------------------
+
+ component Heading: Text {
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ component Swatches: Row {
+ id: sw
+ property string value: ""
+ signal picked(string v)
+ spacing: 6
+ Repeater {
+ model: tab.swatches
+ delegate: Rectangle {
+ required property string modelData
+ width: 24; height: 24; radius: 6
+ color: S.colour(modelData, Theme.accent)
+ border.width: sw.value === modelData ? 3 : 1
+ border.color: sw.value === modelData ? Theme.text : Qt.alpha(Theme.text, 0.3)
+ Text {
+ visible: modelData === "udt"
+ anchors.centerIn: parent
+ text: "U"
+ font { family: Theme.fontFamily; pixelSize: 11; bold: true }
+ color: Theme.base
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: sw.picked(modelData)
+ }
+ }
+ }
+ }
+
+ Column {
+ width: 380
+ spacing: 12
+
+ Heading { text: "Picture" }
+ Row {
+ spacing: 8
+ Button { text: "Choose picture…"; onClicked: pick.open() }
+ Button { text: "Reset"; enabled: canvas.ready; onClicked: canvas.recentre() }
+ }
+ Row {
+ spacing: 8
+ Button {
+ text: "Move picture"
+ border.color: !moveTextBtn.chosen ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: moveTextBtn.chosen = false
+ }
+ Button {
+ id: moveTextBtn
+ property bool chosen: false
+ text: "Move text"
+ border.color: chosen ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: chosen = true
+ }
+ }
+ Text {
+ text: "Drag on the preview; the wheel zooms the picture."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+
+ Heading { text: "Text" }
+ Field {
+ width: 260
+ placeholder: Quickshell.env("USER")
+ Component.onCompleted: value = tab.cfg.text
+ onEdited: tab.set({ text: value })
+ }
+ Flow {
+ width: parent.width
+ spacing: 6
+ Repeater {
+ model: tab.fontKeys
+ delegate: Button {
+ required property var modelData
+ text: modelData[1]
+ font.family: canvas.families[modelData[0]]
+ border.color: tab.cfg.font === modelData[0] ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: tab.set({ font: modelData[0] })
+ }
+ }
+ }
+ Row {
+ spacing: 8
+ Button { text: "Top"; onClicked: tab.set({ x: 240, y: 120 }) }
+ Button { text: "Centre"; onClicked: tab.set({ x: 240, y: 400 }) }
+ Button { text: "Bottom"; onClicked: tab.set({ x: 240, y: 690 }) }
+ }
+ Row {
+ spacing: 8
+ Text {
+ width: 60
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Size"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+ Slider {
+ width: 260
+ from: 40; to: 240; stepSize: 1
+ value: tab.cfg.size
+ onMoved: tab.set({ size: Math.round(value) })
+ }
+ }
+
+ Heading { text: "Colours" }
+ Row {
+ spacing: 8
+ Text {
+ width: 60
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Fill"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+ Swatches { value: tab.cfg.fill; onPicked: v => tab.set({ fill: v }) }
+ }
+ Row {
+ spacing: 8
+ Text {
+ width: 60
+ anchors.verticalCenter: parent.verticalCenter
+ text: tab.cfg.font === "bungee" ? "Inline" : "Shadow"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+ Swatches { value: tab.cfg.second; onPicked: v => tab.set({ second: v }) }
+ }
+ Button { text: "Swap colours"; onClicked: tab.set({ fill: tab.cfg.second, second: tab.cfg.fill }) }
+ Row {
+ visible: tab.cfg.font !== "bungee"
+ spacing: 8
+ Text {
+ width: 60
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Offset"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.subtext
+ }
+ Slider {
+ width: 160
+ from: 0; to: 30; stepSize: 1
+ value: tab.cfg.offset
+ onMoved: tab.set({ offset: Math.round(value) })
+ }
+ Repeater {
+ model: [["nw", "↖"], ["ne", "↗"], ["sw", "↙"], ["se", "↘"]]
+ delegate: Button {
+ required property var modelData
+ text: modelData[1]
+ border.color: tab.cfg.dir === modelData[0] ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: tab.set({ dir: modelData[0] })
+ }
+ }
+ }
+
+ Heading { text: "Output" }
+ Row {
+ spacing: 8
+ Text {
+ width: 260
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideMiddle
+ text: tab.folder
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.text
+ }
+ Button { text: "Change…"; onClicked: pickFolder.open() }
+ }
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: "Save writes the logo and a transparent mask of the text. Upload also sends the logo to the card's Bootlogo folder; Wi-Fi transfer must be open on the player."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+
+ // A FileDialog hands back a file:// URL.
+ function pathOf(u) { return decodeURIComponent(u.toString().replace(/^file:\/\//, "")); }
+
+ FileDialog {
+ id: pick
+ title: "Choose a picture"
+ fileMode: FileDialog.OpenFile
+ currentFolder: "file://" + Quickshell.env("HOME") + "/Pictures"
+ nameFilters: ["Pictures (*.jpg *.jpeg *.png *.JPG *.JPEG *.PNG)"]
+ onAccepted: canvas.source = selectedFile
+ }
+ FolderDialog {
+ id: pickFolder
+ title: "Output folder"
+ currentFolder: "file://" + tab.folder
+ onAccepted: tab.set({ folder: tab.pathOf(selectedFolder) })
+ }
+}
+```
+
+`Field.edited` fires on Enter or focus loss, so the text reaches the
+preview then, not per keystroke. `QtQuick.Controls` is imported before
+`"../.."`, so `Button` is the repo's and `Slider` is Qt's.
+
+- [ ] **Step 2: Commit** (verified with the module in Task 13)
+
+```bash
+git add desktop/modules/sonix/BootlogoTab.qml
+git commit -m "sonix: the boot logo editor's controls, save and upload"
+```
+
+---
+
+### Task 13: Boot logo settings page
+
+**Files:**
+- Create: `desktop/modules/sonix/BootlogoModule.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: Write it**
+
+```qml
+import QtQuick
+import "../.."
+
+// The Sonix boot logo editor, in the settings window under Appearance.
+// No draft: it edits no file. Save and Upload are in the header.
+Module {
+ name: "bootlogo"
+ label: "Boot logo"
+ icon: String.fromCodePoint(0xF075A) // md-music
+ settings: true
+ group: "Appearance"
+ keywords: ["boot", "logo", "sonix", "hiby", "splash", "player"]
+
+ page: Component {
+ Page {
+ title: "Boot logo"
+ actions: Component {
+ Row {
+ spacing: 8
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: Math.min(implicitWidth, 520)
+ elide: Text.ElideRight
+ text: tab.status
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: tab.statusError ? Theme.yellow : Theme.green
+ }
+ Button {
+ text: tab.busy && !tab.waiting ? "Saving…" : "Save"
+ enabled: tab.canSave
+ onClicked: tab.save(null)
+ }
+ Button {
+ text: tab.waiting ? "Waiting for Wi-Fi transfer…" : "Upload"
+ enabled: tab.canSave && Sonix.present && !tab.waiting
+ onClicked: tab.upload()
+ }
+ }
+ }
+ BootlogoTab { id: tab }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Register it**
+
+In `desktop/shell.qml`, add `BootlogoModule {},` after `FontsModule {},`
+(the `modules/sonix` import exists from Task 7).
+
+- [ ] **Step 3: Verify**
+
+Log: reload, no errors. Then:
+`qs -p ~/Programming/GIT/quickshell/desktop ipc call settings open bootlogo`
+
+Ask the user to: choose a picture; drag to pan; wheel to zoom; switch to
+Move text and drag the text; pick each font; size; colours including udt
+and Swap; offset and direction (hidden for Bungee); Save; check the two
+PNGs in the output folder (480x800; the mask transparent around the text):
+
+```bash
+file ~/Pictures/sonix-bootlogo/*.png | tail -n 2
+```
+
+Expected: `PNG image data, 480 x 800, 8-bit/color RGBA` for both.
+
+Then Upload with Wi-Fi transfer closed on the player (the button waits),
+open it on the player (the upload completes), and pick the logo on the
+player. Restart the settings window: the text settings are kept, the
+picture is not.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/sonix/BootlogoModule.qml desktop/shell.qml
+git commit -m "sonix: the boot logo page in the settings window"
+```
+
+---
+
+### Task 14: Documentation
+
+**Files:**
+- Create: `desktop/modules/sonix/README.md`
+- Modify: `desktop/README.md`, `desktop/island/README.md`, `AGENTS.md`
+
+- [ ] **Step 1: `desktop/modules/sonix/README.md`**
+
+```markdown
+# sonix
+
+A HiBy R1 running [Sonix Player](https://github.com/Jepl4r/sonix-player),
+over the LAN: an icon and controls panel in the island's status pill, a
+drawer tile and page, and a boot logo editor in the settings window.
+
+## The player's two servers
+
+- **SonixLink**, HTTP on port 7800, no authentication: state, commands,
+ covers, the library. Switched on from the player's control centre.
+ Anyone on the network can drive the player while it is on.
+- **Wi-Fi transfer**, HTTP on port 80: the card's file server. It runs only
+ while its page is open on the player; leaving the page stops it. Used
+ only by the boot logo upload.
+
+## Presence
+
+SonixLink broadcasts `SONIXLINK1 <ip> <port> <name>` to UDP 7801 every 2s.
+`sonix-beacon.py` relays it to stdout (QML has no UDP socket) and
+`desktop/Sonix.qml` reads it: present while beacons arrive, gone 6s after
+the last. The address follows the beacon, so DHCP changes need nothing.
+
+## Polling
+
+`/api/state` is read every second while a view holds the service: the
+island panel, the drawer tile, the drawer page. Nothing is polled
+otherwise, and `/api/bye` is sent when the last view lets go, so the player
+stops counting a client.
+
+## Drawer page
+
+Device: name, address, battery, the player's accent, the library and a
+Rescan, what is playing, a link to Wi-Fi transfer and to the boot logo
+page. Playlists: play, open, remove a track, new names. Folders: browse,
+play a folder or a file, add either to a playlist.
+
+SonixLink has no rename, delete or reorder for playlists, and makes a
+playlist only by adding a track to a new name. The player has rename and
+delete internally but does not expose them; the `.m3u` files on the card
+are backups, not the playlists. Do those on the player. `play_all` refuses
+a folder, so a folder plays as its first file with the folder as the list.
+
+## Boot logo
+
+A 480x800 scene: the picture, panned and zoomed, and the text in one of
+four faces (Bungee with its Inline layer, Major Mono Display, Michroma,
+Audiowide; SIL OFL, in `fonts/`) in two colours: the six player accents,
+white, black, or the udt accent. Save writes `bootlogo-<stamp>.png` and
+`bootlogo-<stamp>-mask.png`, the text on transparency, to the output folder.
+Upload saves, then sends the PNG to `Bootlogo/` on the card through Wi-Fi
+transfer, waiting up to 60s for it to be opened on the player. The logo is
+then picked on the player: Settings > Appearance > Boot logo.
+
+## Tests
+
+ node desktop/modules/sonix/test_sonix.js
+ /usr/bin/python3 desktop/modules/sonix/test_sonix_beacon.py
+```
+
+- [ ] **Step 2: `desktop/README.md`**
+
+In the module list under `## The modules`, add after the kdeconnect line:
+
+```
+ modules/sonix/ the Sonix player: device, playlists, folders; boot logo page
+```
+
+and change `Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README.`
+to `Sound, mail, vm, network, bluetooth, kdeconnect, sonix and ai each carry their own README.`
+
+- [ ] **Step 3: `desktop/island/README.md`**
+
+In `## Status pill`, change the first sentence to
+`Left to right: Sonix player icon, headphones icon, volume icon, tray, layout flag.`
+and add before the headphones bullet:
+
+```markdown
+- **Sonix player icon** shows while a HiBy R1 running Sonix Player is on
+ the network (its SonixLink beacon). A dot under it while it plays. Click
+ opens its panel: cover, title, a heart for the track's favourite,
+ quality, seek, transport, mode, volume (sent once it settles), battery.
+ See `desktop/modules/sonix/README.md`.
+```
+
+- [ ] **Step 4: `AGENTS.md`**
+
+In the `desktop/` description at the top, after `performance, clipboard, updates;`
+add ` the Sonix player;`, and after `fonts` in the settings window list add
+`, the Sonix boot logo`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/sonix/README.md desktop/README.md desktop/island/README.md AGENTS.md
+git commit -m "docs: the Sonix player"
+```
+
+---
+
+## Self-review notes
+
+- Spec coverage: presence and helper (Tasks 2, 4), polling with hold and
+ bye (4, 6, 7), commands (4), upload with the transfer wait (4, 12),
+ island icon and panel with heart (6), drawer tile and three tabs (7, 8, 9),
+ boot logo canvas, controls, page, prefs (10 to 13), fonts and licences
+ (3), tests (1, 2), docs (14).
+- The heart uses SonixLink's `favourite` command (`path`, `value` 1 or 0)
+ rather than the spec's `favourites_add` / `favourites_remove`: same
+ effect, one track, no POST body.
+- Live checks the plan cannot settle offline: `bitrate` units (Task 6
+ Step 1), `/api/art` behaviour (Task 6 Step 1), `play_path` with
+ `list=folder` (Task 9 Step 1). Each has a stop-and-report instruction.