// 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. 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() { // The player counts a client as present for 20s after its last // request; saying goodbye ends that at once. if (root.holds > 0 && --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; root.player = ({}); root.error = ""; 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(() => { finish(false, null, "timed out"); xhr.abort(); }); 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" err=$(mktemp) || exit 1 code=$(curl -sS -m 120 -o /dev/null -w '%{http_code}' --form-string "path=$d/" -F "files[]=@\"$f\"" "http://$h/upload" 2>"$err") \ || { cat "$err"; rm -f "$err"; exit 1; } rm -f "$err" [ "$code" = 200 ] || { echo "HTTP $code"; exit 1; } ` Process { id: up property var cb: null property int code: -1 property bool streamed: false stdout: StdioCollector { id: upOut; onStreamFinished: { up.streamed = true; up.deliver(); } } onExited: code => { up.code = code; up.deliver(); } function deliver() { if (up.code < 0 || !up.streamed || !up.cb) return; const cb = up.cb; up.cb = null; cb(up.code, upOut.text.trim()); } } function upload(file, cardDir, cb) { if (/["\\]/.test(file)) { cb(1, "the output folder name contains \" or \\"); return; } if (up.running) { cb(1, "an upload is already running"); return; } up.cb = cb; up.code = -1; up.streamed = false; up.command = ["sh", "-c", root.uploadScript, "sh", root.host, file, cardDir]; up.running = false; up.running = true; } }