diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-11-07-sonix-player.md | 2648 |
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. |
