.pragma library // 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. // 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(`${Math.round(s.bitrate / 1000)} kbps`); return parts.join(" · "); } // SonixLink has one playback mode out of five; the panel shows it as a // shuffle toggle and a repeat cycle. There is no shuffle with repeat one, so // with shuffle on repeat only goes off and all, and turning shuffle on from // repeat one drops the repeat. function shuffled(m) { return m === "shuffle" || m === "shuffle_repeat"; } function toggleShuffle(m) { if (m === "shuffle") return "normal"; if (m === "shuffle_repeat") return "repeat_all"; return m === "repeat_all" ? "shuffle_repeat" : "shuffle"; } function nextRepeat(m) { if (shuffled(m)) return m === "shuffle" ? "shuffle_repeat" : "shuffle"; return m === "repeat_all" ? "repeat_one" : m === "repeat_one" ? "normal" : "repeat_all"; } // md-shuffle / md-shuffle_disabled function shuffleGlyph(m) { return shuffled(m) ? 0xF049D : 0xF049E; } // md-repeat / md-repeat_once / md-repeat_off function repeatGlyph(m) { return m === "repeat_all" || m === "shuffle_repeat" ? 0xF0456 : m === "repeat_one" ? 0xF0458 : 0xF0457; } 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: "SD 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 }; }