# 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. // // 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://: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 ". 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 ", 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 " 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 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 ` 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=&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=' "$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 ` 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-.png` and `bootlogo--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.