aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/sonix/Sonix.js
blob: fe5a711d6d47c087488ee1a7368c1cc707fd1a9d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
.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(`${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 };
}