aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/modules/sonix/Sonix.js115
-rw-r--r--desktop/modules/sonix/test_sonix.js99
2 files changed, 214 insertions, 0 deletions
diff --git a/desktop/modules/sonix/Sonix.js b/desktop/modules/sonix/Sonix.js
new file mode 100644
index 0000000..76ac1f5
--- /dev/null
+++ b/desktop/modules/sonix/Sonix.js
@@ -0,0 +1,115 @@
+.pragma library
+
+// 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.
+
+// 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 };
+}
diff --git a/desktop/modules/sonix/test_sonix.js b/desktop/modules/sonix/test_sonix.js
new file mode 100644
index 0000000..c19a123
--- /dev/null
+++ b/desktop/modules/sonix/test_sonix.js
@@ -0,0 +1,99 @@
+// 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.
+
+// 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");