diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-07 11:50:07 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-07 11:50:07 +0100 |
| commit | 4071b681a9b704c1970e4f2c65d7465012a18e62 (patch) | |
| tree | 0b50861ff45f559075f3918107f5a015addbc85a /desktop/modules/sonix/Sonix.js | |
| parent | 4bf73cf6589ca62a51a4eab79a5c93aacfa3195a (diff) | |
| download | quickshell-4071b681a9b704c1970e4f2c65d7465012a18e62.tar.gz quickshell-4071b681a9b704c1970e4f2c65d7465012a18e62.zip | |
sonix: pure helpers for the player, with their test
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/sonix/Sonix.js')
| -rw-r--r-- | desktop/modules/sonix/Sonix.js | 115 |
1 files changed, 115 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 }; +} |
