tinyjukebox 0.47.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,3050 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
4
|
+
// helpers
|
|
5
|
+
|
|
6
|
+
const $ = (id) => document.getElementById(id);
|
|
7
|
+
|
|
8
|
+
function h(tag, attrs, ...children) {
|
|
9
|
+
const el = document.createElement(tag);
|
|
10
|
+
for (const [key, value] of Object.entries(attrs || {})) {
|
|
11
|
+
if (value === null || value === undefined || value === false) continue;
|
|
12
|
+
if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
|
|
13
|
+
else if (key === "class") el.className = value;
|
|
14
|
+
else if (key in el && key !== "list") el[key] = value;
|
|
15
|
+
else el.setAttribute(key, value === true ? "" : value);
|
|
16
|
+
}
|
|
17
|
+
for (const child of children.flat()) {
|
|
18
|
+
if (child === null || child === undefined || child === false) continue;
|
|
19
|
+
el.append(child instanceof Node ? child : String(child));
|
|
20
|
+
}
|
|
21
|
+
return el;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
25
|
+
// texts: the page has none of its own. They are in texts/<language>.json, one file per language; index.html names
|
|
26
|
+
// the key of a text (data-t, data-t-title, ...) and the code asks for it with t()
|
|
27
|
+
|
|
28
|
+
const LANGUAGES = { de: "Deutsch", en: "English" };
|
|
29
|
+
const LANGUAGE_KEY = "tinyjukebox-language";
|
|
30
|
+
|
|
31
|
+
// the language chosen on this device - the browser keeps it, for this address. English until one is chosen
|
|
32
|
+
function chosenLanguage() {
|
|
33
|
+
try {
|
|
34
|
+
const chosen = localStorage.getItem(LANGUAGE_KEY);
|
|
35
|
+
return Object.keys(LANGUAGES).includes(chosen) ? chosen : "en";
|
|
36
|
+
} catch {
|
|
37
|
+
return "en"; // a browser that keeps nothing
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const language = chosenLanguage();
|
|
41
|
+
let texts = {};
|
|
42
|
+
let pluralRules = null;
|
|
43
|
+
|
|
44
|
+
// the text of a key; {name} stands for the value of that name. A text that depends on a number has one entry per
|
|
45
|
+
// form ("one", "other") and takes the form of the value n
|
|
46
|
+
function t(key, values = {}) {
|
|
47
|
+
let text = texts[key];
|
|
48
|
+
if (text === undefined) return key;
|
|
49
|
+
if (typeof text === "object") text = text[pluralRules.select(values.n)] || text.other;
|
|
50
|
+
return text.replace(/\{(\w+)\}/g, (all, name) => (name in values ? values[name] : all));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// the text of a name within a group of keys (a state, an output) - the name itself if the group has no text for it
|
|
54
|
+
function tOf(group, name, values) {
|
|
55
|
+
const key = `${group}.${name}`;
|
|
56
|
+
return key in texts ? t(key, values) : name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const known = (group, name) => Boolean(name) && `${group}.${name}` in texts;
|
|
60
|
+
|
|
61
|
+
// what the page says about a request that the server refused, or about a result with an error: its own text for the
|
|
62
|
+
// code of the error, otherwise the English text of the server (errors.py)
|
|
63
|
+
function errorText(data, status) {
|
|
64
|
+
if (data && known("error", data.code)) return tOf("error", data.code, data);
|
|
65
|
+
return (data && data.error) || t("error.status", { status });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const percent = (n) => t("common.percent", { n });
|
|
69
|
+
|
|
70
|
+
// a QR code of the library (an svg) as an image: an svg in an img runs nothing and loads nothing - the page never
|
|
71
|
+
// puts markup of the server into the page
|
|
72
|
+
const qrImage = (svg, label) =>
|
|
73
|
+
h("img", { src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`, alt: label });
|
|
74
|
+
|
|
75
|
+
// the nodes of a fixed text: {name} is the element of the page with that name (data-part), and <b>, <code> and
|
|
76
|
+
// <strong> become elements. Nothing is ever put into the page as html
|
|
77
|
+
function textNodes(text, parts) {
|
|
78
|
+
const nodes = [];
|
|
79
|
+
for (const piece of text.split(/(\{\w+\}|<(?:b|code|strong)>.*?<\/(?:b|code|strong)>)/)) {
|
|
80
|
+
const part = /^\{(\w+)\}$/.exec(piece);
|
|
81
|
+
const emphasis = /^<(\w+)>(.*)<\/\w+>$/.exec(piece);
|
|
82
|
+
if (part && parts[part[1]]) nodes.push(parts[part[1]]);
|
|
83
|
+
else if (emphasis) nodes.push(h(emphasis[1], {}, emphasis[2]));
|
|
84
|
+
else if (piece) nodes.push(piece);
|
|
85
|
+
}
|
|
86
|
+
return nodes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const TEXT_ATTRIBUTES = { title: "title", label: "aria-label", placeholder: "placeholder", confirm: "data-confirm" };
|
|
90
|
+
|
|
91
|
+
// gives every element of index.html its text and the attributes that are texts
|
|
92
|
+
function fillTexts() {
|
|
93
|
+
for (const el of document.querySelectorAll("[data-t]")) {
|
|
94
|
+
const parts = {};
|
|
95
|
+
for (const part of el.querySelectorAll(":scope > [data-part]")) parts[part.dataset.part] = part;
|
|
96
|
+
el.replaceChildren(...textNodes(t(el.dataset.t), parts));
|
|
97
|
+
}
|
|
98
|
+
for (const [name, attribute] of Object.entries(TEXT_ATTRIBUTES)) {
|
|
99
|
+
for (const el of document.querySelectorAll(`[data-t-${name}]`)) {
|
|
100
|
+
el.setAttribute(attribute, t(el.getAttribute(`data-t-${name}`)));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// the choice of the language, wherever index.html has one: the browser keeps a change and the page loads again
|
|
106
|
+
function fillLanguages() {
|
|
107
|
+
for (const select of document.querySelectorAll("select.language")) {
|
|
108
|
+
select.replaceChildren(...Object.entries(LANGUAGES).map(([code, name]) =>
|
|
109
|
+
h("option", { value: code, selected: code === language }, name)));
|
|
110
|
+
select.addEventListener("change", () => {
|
|
111
|
+
try {
|
|
112
|
+
localStorage.setItem(LANGUAGE_KEY, select.value);
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
location.reload();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadTexts() {
|
|
122
|
+
texts = await (await fetch(`texts/${language}.json`, { cache: "no-cache" })).json();
|
|
123
|
+
pluralRules = new Intl.PluralRules(language);
|
|
124
|
+
document.documentElement.lang = language;
|
|
125
|
+
fillTexts();
|
|
126
|
+
fillLanguages();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatTime(seconds) {
|
|
130
|
+
if (seconds === null || seconds === undefined || isNaN(seconds)) return "–";
|
|
131
|
+
seconds = Math.max(0, Math.floor(seconds));
|
|
132
|
+
const hours = Math.floor(seconds / 3600);
|
|
133
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
134
|
+
const secs = String(seconds % 60).padStart(2, "0");
|
|
135
|
+
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// how the badge of a state looks - its text is in the group "state"
|
|
139
|
+
const STATE_CLASSES = {
|
|
140
|
+
wait_for_tag: "idle", idle_long: "idle", check_tag: "busy", play_album: "ok", unknown_tag: "warn",
|
|
141
|
+
unknown_album: "warn", broken_album: "error", test_radio: "busy", fetch_album: "busy", blocked: "error",
|
|
142
|
+
spotify_speaker: "ok", end: "idle",
|
|
143
|
+
};
|
|
144
|
+
const PAIRING_RESULTS = ["paired", "not_found", "failed"];
|
|
145
|
+
const pairingResult = (result) => (PAIRING_RESULTS.includes(result) ? tOf("bluetooth.pairing", result) : "");
|
|
146
|
+
|
|
147
|
+
let toastTimer = null;
|
|
148
|
+
function toast(message, isError = false) {
|
|
149
|
+
const el = $("toast");
|
|
150
|
+
el.textContent = message;
|
|
151
|
+
el.classList.toggle("toast-error", isError);
|
|
152
|
+
el.hidden = false;
|
|
153
|
+
clearTimeout(toastTimer);
|
|
154
|
+
toastTimer = setTimeout(() => { el.hidden = true; }, isError ? 6000 : 3000);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
158
|
+
// api
|
|
159
|
+
|
|
160
|
+
class ApiError extends Error {
|
|
161
|
+
constructor(status, data) {
|
|
162
|
+
super(errorText(data, status));
|
|
163
|
+
this.status = status;
|
|
164
|
+
this.data = data;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// when the user did something on the page last - in any tab of this browser, hence the storage. The server ends a
|
|
169
|
+
// session in which nothing was done for too long (rest-api/idle-timeout); the page asks all the time by itself, so
|
|
170
|
+
// every request says how long ago that was
|
|
171
|
+
const ACTIVE_KEY = "tinyjukebox.active";
|
|
172
|
+
let lastInput = Date.now();
|
|
173
|
+
let lastInputStored = 0;
|
|
174
|
+
let uploadsRunning = 0; // while files are sent the user waits for the page: that is no absence
|
|
175
|
+
|
|
176
|
+
function noteInput() {
|
|
177
|
+
lastInput = Date.now();
|
|
178
|
+
if (lastInput - lastInputStored < 5000) return;
|
|
179
|
+
lastInputStored = lastInput;
|
|
180
|
+
try {
|
|
181
|
+
localStorage.setItem(ACTIVE_KEY, String(lastInput));
|
|
182
|
+
} catch { /* a browser without storage: this tab counts for itself */ }
|
|
183
|
+
}
|
|
184
|
+
for (const type of ["pointerdown", "pointermove", "keydown", "wheel", "touchstart"]) {
|
|
185
|
+
document.addEventListener(type, noteInput, { passive: true, capture: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function idleSeconds() {
|
|
189
|
+
if (uploadsRunning) return 0;
|
|
190
|
+
let last = lastInput;
|
|
191
|
+
try {
|
|
192
|
+
last = Math.max(last, Number(localStorage.getItem(ACTIVE_KEY)) || 0);
|
|
193
|
+
} catch { /* see above */ }
|
|
194
|
+
return Math.max(0, Math.round((Date.now() - last) / 1000));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const requestHeaders = () => ({ "X-TinyJukebox-Request": "1", "X-TinyJukebox-Idle": String(idleSeconds()) });
|
|
198
|
+
|
|
199
|
+
// the answer of the server: { status, data }
|
|
200
|
+
async function request(method, path, body) {
|
|
201
|
+
const options = { method, credentials: "same-origin", headers: requestHeaders() };
|
|
202
|
+
if (body !== undefined) {
|
|
203
|
+
options.headers["Content-Type"] = "application/json";
|
|
204
|
+
options.body = JSON.stringify(body);
|
|
205
|
+
}
|
|
206
|
+
const response = await fetch(path, options);
|
|
207
|
+
let data = null;
|
|
208
|
+
if (response.status !== 204) {
|
|
209
|
+
const text = await response.text();
|
|
210
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
|
|
211
|
+
}
|
|
212
|
+
if (response.status === 401 && path !== "/api/login") {
|
|
213
|
+
showLogin();
|
|
214
|
+
}
|
|
215
|
+
if (!response.ok) throw new ApiError(response.status, data);
|
|
216
|
+
return { status: response.status, data };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const api = async (method, path, body) => (await request(method, path, body)).data;
|
|
220
|
+
|
|
221
|
+
// runs an action and reports errors as toast
|
|
222
|
+
async function run(action, successMessage) {
|
|
223
|
+
try {
|
|
224
|
+
const result = await action();
|
|
225
|
+
if (successMessage) toast(successMessage);
|
|
226
|
+
return result;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (!(e instanceof ApiError && e.status === 401)) toast(e.message, true);
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
234
|
+
// identity: the library, or which box - the login page shows it already, and a box has colors of its own (hue from
|
|
235
|
+
// its name or its id, style.css)
|
|
236
|
+
|
|
237
|
+
let identity = null;
|
|
238
|
+
|
|
239
|
+
async function loadIdentity() {
|
|
240
|
+
try {
|
|
241
|
+
identity = await api("GET", "/api/identity");
|
|
242
|
+
} catch {
|
|
243
|
+
return; // the page keeps what it knows
|
|
244
|
+
}
|
|
245
|
+
showIdentity();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function showIdentity() {
|
|
249
|
+
const library = identity ? identity.mode === "library" : isLibrary();
|
|
250
|
+
const name = identity && !library ? identity.name : null;
|
|
251
|
+
const hued = Boolean(identity) && Number.isInteger(identity.hue);
|
|
252
|
+
const root = document.documentElement;
|
|
253
|
+
root.classList.toggle("box-hued", hued);
|
|
254
|
+
if (hued) root.style.setProperty("--box-hue", identity.hue);
|
|
255
|
+
else root.style.removeProperty("--box-hue");
|
|
256
|
+
const place = $("login-place");
|
|
257
|
+
place.textContent = library ? t("login.place.library")
|
|
258
|
+
: name ? t("login.place.box_named", { name }) : t("login.place.box");
|
|
259
|
+
place.hidden = !identity;
|
|
260
|
+
$("place-name").textContent = name || "";
|
|
261
|
+
$("place-name").hidden = !name;
|
|
262
|
+
document.title = library ? t("title.library") : name ? `${name} · Tiny Jukebox` : "Tiny Jukebox";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
266
|
+
// login / session
|
|
267
|
+
|
|
268
|
+
let session = null;
|
|
269
|
+
|
|
270
|
+
// the app for phones shows this page in expert mode and offers it an object of its own: the NFC of the phone for
|
|
271
|
+
// "Scan tag", and the news that the page logged in or out (docs/developer/app.md)
|
|
272
|
+
const APP = window.TinyJukeboxApp || null;
|
|
273
|
+
|
|
274
|
+
// the limit of the server in seconds, 0 for none
|
|
275
|
+
const idleLimit = () => (session && session.idle_timeout) || 0;
|
|
276
|
+
|
|
277
|
+
function showLogin() {
|
|
278
|
+
// the server ended the session, or the page did: say why if nothing was done for too long
|
|
279
|
+
if (APP && session) APP.postMessage("logout"); // the app goes back to simple mode
|
|
280
|
+
const limit = idleLimit();
|
|
281
|
+
$("login-error").textContent = limit && idleSeconds() >= limit - 10
|
|
282
|
+
? t("login.idle", { minutes: Math.round(limit / 60) }) : "";
|
|
283
|
+
session = null;
|
|
284
|
+
stopUpdates();
|
|
285
|
+
stopScan();
|
|
286
|
+
$("app-view").hidden = true;
|
|
287
|
+
$("login-view").hidden = false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const isLibrary = () => Boolean(session && session.mode === "library");
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
293
|
+
// help: the documents of docs/user as pages of the box (help/<language>/, scripts/help/build.py). A numbered heading
|
|
294
|
+
// has an anchor of its number, #s7-2 for 7.2, the same in every language
|
|
295
|
+
|
|
296
|
+
const HELP_OF_TAB = {
|
|
297
|
+
now: "manual#s6-3", tags: "manual#s6-3", statistics: "manual#s6-3",
|
|
298
|
+
"tag-management": "manual#s7-2", albums: "manual#s7", upload: "manual#s7-1",
|
|
299
|
+
radio: "manual#s8", spotify: "manual#s9", bluetooth: "manual#s5",
|
|
300
|
+
system: "manual#s11", boxes: "manual#s13-3", versions: "admin#s4", log: "admin#s7",
|
|
301
|
+
};
|
|
302
|
+
const HELP_HINT_KEY = "tinyjukebox-help-hint";
|
|
303
|
+
const helpAddress = (page) => {
|
|
304
|
+
const [document_, anchor] = page.split("#");
|
|
305
|
+
return `help/${language}/${document_}.html${anchor ? `#${anchor}` : ""}`;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// after a login: the hint at the quick guide, until it is not wanted any more in this browser
|
|
309
|
+
function showHelpHint() {
|
|
310
|
+
let hidden = false;
|
|
311
|
+
try {
|
|
312
|
+
hidden = localStorage.getItem(HELP_HINT_KEY) === "hidden";
|
|
313
|
+
} catch {
|
|
314
|
+
hidden = false; // a browser that keeps nothing shows it every time
|
|
315
|
+
}
|
|
316
|
+
$("help-hint-never").checked = false;
|
|
317
|
+
$("help-hint").hidden = hidden;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
$("help-hint-close").addEventListener("click", () => {
|
|
321
|
+
if ($("help-hint-never").checked) {
|
|
322
|
+
try {
|
|
323
|
+
localStorage.setItem(HELP_HINT_KEY, "hidden");
|
|
324
|
+
} catch {
|
|
325
|
+
// nothing kept: the hint comes again with the next login
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
$("help-hint").hidden = true;
|
|
329
|
+
});
|
|
330
|
+
$("help-hint-open").href = helpAddress("quick-guide");
|
|
331
|
+
$("login-help").href = helpAddress("quick-guide");
|
|
332
|
+
|
|
333
|
+
function showApp(user) {
|
|
334
|
+
session = user;
|
|
335
|
+
if (APP) APP.postMessage(`session:${user.role}`);
|
|
336
|
+
document.body.classList.toggle("is-admin", user.role === "admin");
|
|
337
|
+
document.body.classList.toggle("is-library", user.mode === "library");
|
|
338
|
+
showIdentity();
|
|
339
|
+
$("user-name").textContent = `${user.username} (${user.role})`;
|
|
340
|
+
$("sys-user").textContent = `${user.username} (${user.role})`;
|
|
341
|
+
$("own-password").username.defaultValue = user.username; // for password managers - survives form.reset()
|
|
342
|
+
const canScan = user.mode === "library" && ("NDEFReader" in window || ON_A_PHONE || Boolean(APP));
|
|
343
|
+
// a folder cannot be chosen in the app: files are
|
|
344
|
+
$("upload-folder").closest("label").hidden = Boolean(APP);
|
|
345
|
+
$("scan-toggle").hidden = !canScan;
|
|
346
|
+
$("tag-new-card").hidden = user.role !== "admin" && !canScan;
|
|
347
|
+
$("login-view").hidden = true;
|
|
348
|
+
$("app-view").hidden = false;
|
|
349
|
+
// the library has the statistics next to its boxes; on a box they come after what the box plays and knows
|
|
350
|
+
if (!isLibrary()) $("tab-button-upload").before($("tab-button-statistics"));
|
|
351
|
+
selectTab(location.hash.slice(1) || (isLibrary() ? "boxes" : "now"));
|
|
352
|
+
if (!isLibrary() && user.role === "admin") loadVersion(); // a release that waits for an answer: on every tab
|
|
353
|
+
showHelpHint();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
$("login-form").addEventListener("submit", async (event) => {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
const form = event.target;
|
|
359
|
+
$("login-error").textContent = "";
|
|
360
|
+
try {
|
|
361
|
+
const user = await api("POST", "/api/login", {
|
|
362
|
+
username: form.username.value, password: form.password.value,
|
|
363
|
+
});
|
|
364
|
+
delete user.token; // the cookie carries the session - the page keeps no token (rest-api.md, section 2)
|
|
365
|
+
form.password.value = "";
|
|
366
|
+
showApp(user);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
$("login-error").textContent = e.status === 429
|
|
369
|
+
? t("login.error.too_many") : e.status === 401 ? t("login.error.wrong") : e.message;
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
$("logout").addEventListener("click", async () => {
|
|
374
|
+
await run(() => api("POST", "/api/logout"));
|
|
375
|
+
showLogin();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// nothing was done for too long: the page logs out by itself and does not wait for the next answer of the server
|
|
379
|
+
setInterval(() => {
|
|
380
|
+
const limit = idleLimit();
|
|
381
|
+
if (!limit || idleSeconds() < limit) return;
|
|
382
|
+
fetch("/api/logout", { method: "POST", credentials: "same-origin", headers: { "X-TinyJukebox-Request": "1" } })
|
|
383
|
+
.catch(() => { /* the server ends the session by itself */ });
|
|
384
|
+
showLogin();
|
|
385
|
+
}, 5000);
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
388
|
+
// tabs
|
|
389
|
+
|
|
390
|
+
let currentTab = "now";
|
|
391
|
+
const tabLoaders = {
|
|
392
|
+
now: () => refreshStatus(),
|
|
393
|
+
tags: () => loadDeck(),
|
|
394
|
+
log: async () => { if (isLibrary()) await logSources(); return loadLog(true); },
|
|
395
|
+
"tag-management": () => loadTags(),
|
|
396
|
+
albums: () => loadAlbums(),
|
|
397
|
+
radio: () => loadRadios(),
|
|
398
|
+
spotify: () => loadSpotify(),
|
|
399
|
+
bluetooth: () => refreshStatus(),
|
|
400
|
+
upload: () => loadUpload(),
|
|
401
|
+
system: () => loadSystem(),
|
|
402
|
+
boxes: () => loadBoxes(),
|
|
403
|
+
versions: () => loadVersions(),
|
|
404
|
+
statistics: () => loadStatistics(),
|
|
405
|
+
};
|
|
406
|
+
const BOX_TABS = ["now", "bluetooth"]; // what only the page of a box has
|
|
407
|
+
const LIBRARY_TABS = ["boxes", "versions"]; // ... and only the page of the library
|
|
408
|
+
|
|
409
|
+
function selectTab(name) {
|
|
410
|
+
const home = isLibrary() ? "boxes" : "now";
|
|
411
|
+
if (!Object.hasOwn(tabLoaders, name) || (isLibrary() ? BOX_TABS : LIBRARY_TABS).includes(name)) name = home;
|
|
412
|
+
if (["upload", "log"].includes(name) && session && session.role !== "admin") name = home;
|
|
413
|
+
if (name !== "tag-management") {
|
|
414
|
+
stopScan();
|
|
415
|
+
$("scan-refused").hidden = true;
|
|
416
|
+
}
|
|
417
|
+
currentTab = name;
|
|
418
|
+
for (const button of document.querySelectorAll(".tabs button")) {
|
|
419
|
+
button.classList.toggle("active", button.dataset.tab === name);
|
|
420
|
+
button.setAttribute("aria-selected", button.dataset.tab === name);
|
|
421
|
+
}
|
|
422
|
+
for (const section of document.querySelectorAll(".tab")) section.hidden = section.id !== `tab-${name}`;
|
|
423
|
+
history.replaceState(null, "", `#${name}`);
|
|
424
|
+
$("help-link").href = helpAddress(HELP_OF_TAB[name] || "manual");
|
|
425
|
+
tabLoaders[name]();
|
|
426
|
+
if (!events) startUpdates();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const button of document.querySelectorAll(".tabs button")) {
|
|
430
|
+
button.addEventListener("click", () => selectTab(button.dataset.tab));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
434
|
+
// now playing
|
|
435
|
+
|
|
436
|
+
let events = null;
|
|
437
|
+
let pollTimer = null;
|
|
438
|
+
let lastStatus = null;
|
|
439
|
+
let lastStatusTime = 0;
|
|
440
|
+
let shownAlbumKey = null;
|
|
441
|
+
let volumeDragging = false;
|
|
442
|
+
let albumsCache = [];
|
|
443
|
+
let radiosCache = [];
|
|
444
|
+
let spotifyCache = [];
|
|
445
|
+
|
|
446
|
+
// the server pushes status changes and new log records (server-sent events, /api/events); while the stream is
|
|
447
|
+
// down, the page falls back to polling once per second
|
|
448
|
+
function startUpdates() {
|
|
449
|
+
stopUpdates();
|
|
450
|
+
if (!window.EventSource) {
|
|
451
|
+
startPolling();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const source = new EventSource("/api/events");
|
|
455
|
+
events = source;
|
|
456
|
+
source.addEventListener("open", stopPolling);
|
|
457
|
+
source.addEventListener("error", () => {
|
|
458
|
+
startPolling();
|
|
459
|
+
// closed for good (e.g. 401 after logout) - the polling shows the login; retry the stream later
|
|
460
|
+
if (source.readyState === EventSource.CLOSED && events === source) {
|
|
461
|
+
events = null;
|
|
462
|
+
setTimeout(() => { if (events === null && session) startUpdates(); }, 10000);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
source.addEventListener("status", (event) => applyStatus(JSON.parse(event.data)));
|
|
466
|
+
source.addEventListener("log", () => {
|
|
467
|
+
if (currentTab === "log" && $("log-follow").checked) loadLog(false);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function stopUpdates() {
|
|
472
|
+
if (events) events.close();
|
|
473
|
+
events = null;
|
|
474
|
+
stopPolling();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function startPolling() {
|
|
478
|
+
if (pollTimer) return;
|
|
479
|
+
pollTimer = setInterval(() => {
|
|
480
|
+
if (document.hidden) return;
|
|
481
|
+
refreshStatus();
|
|
482
|
+
if (currentTab === "log" && $("log-follow").checked) loadLog(false);
|
|
483
|
+
}, 1000);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function stopPolling() {
|
|
487
|
+
clearInterval(pollTimer);
|
|
488
|
+
pollTimer = null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function applyStatus(status) {
|
|
492
|
+
lastStatus = status;
|
|
493
|
+
lastStatusTime = performance.now();
|
|
494
|
+
if (!isLibrary()) renderStatus(status); // nothing plays on a library
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function refreshStatus() {
|
|
498
|
+
try {
|
|
499
|
+
applyStatus(await api("GET", "/api/status"));
|
|
500
|
+
} catch (e) {
|
|
501
|
+
if (e.status !== 401) $("now-state").textContent = t("now.connection_lost");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function imageElement(src, placeholderClass = "cover-placeholder", placeholder = "♪") {
|
|
506
|
+
if (!src) return h("span", { class: placeholderClass }, placeholder);
|
|
507
|
+
const img = h("img", { src, alt: "", loading: "lazy" });
|
|
508
|
+
img.addEventListener("error", () => img.replaceWith(h("span", { class: placeholderClass }, placeholder)));
|
|
509
|
+
return img;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// covers changed on this page: the browser keeps a cover for a while, so a new one gets another address
|
|
513
|
+
const coverVersions = {};
|
|
514
|
+
|
|
515
|
+
function coverElement(albumId, placeholderClass = "cover-placeholder") {
|
|
516
|
+
const version = coverVersions[albumId] ? `?v=${coverVersions[albumId]}` : "";
|
|
517
|
+
return imageElement(`/api/albums/${encodeURIComponent(albumId)}/cover${version}`, placeholderClass);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// cover of a radio station (an entry of /api/radios or the station of the status); its address names the image, so
|
|
521
|
+
// a new cover is never the one the browser kept
|
|
522
|
+
function radioCoverElement(radio, placeholderClass = "cover-placeholder") {
|
|
523
|
+
const src = radio && radio.cover
|
|
524
|
+
? `/api/radios/${encodeURIComponent(radio.id)}/cover?v=${encodeURIComponent(radio.cover)}` : null;
|
|
525
|
+
return imageElement(src, placeholderClass, "📻");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// cover of an entry of Spotify (an entry of /api/spotify or of the status), named like the cover of a station
|
|
529
|
+
function spotifyCoverElement(entry, placeholderClass = "cover-placeholder") {
|
|
530
|
+
const src = entry && entry.cover
|
|
531
|
+
? `/api/spotify/${encodeURIComponent(entry.id)}/cover?v=${encodeURIComponent(entry.cover)}` : null;
|
|
532
|
+
return imageElement(src, placeholderClass, "🎵");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// sends the image file as the cover of "/api/albums/<id>", "/api/radios/<id>" or "/api/spotify/<id>" - true if done
|
|
536
|
+
async function sendCover(path, file) {
|
|
537
|
+
let response;
|
|
538
|
+
try {
|
|
539
|
+
response = await fetch(`${path}/cover`, {
|
|
540
|
+
method: "PUT", credentials: "same-origin", body: file,
|
|
541
|
+
headers: { ...requestHeaders(), "Content-Type": file.type || "application/octet-stream" },
|
|
542
|
+
});
|
|
543
|
+
} catch {
|
|
544
|
+
toast(t("cover.send_failed"), true);
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
const data = await response.json().catch(() => ({}));
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
toast(errorText(data, response.status), true);
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// makes the image file the cover of the album - true if done
|
|
556
|
+
async function uploadCover(albumId, file) {
|
|
557
|
+
if (!(await sendCover(`/api/albums/${encodeURIComponent(albumId)}`, file))) return false;
|
|
558
|
+
coverVersions[albumId] = Date.now();
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// the cover of the album, the radio station or the entry of Spotify of the tag
|
|
563
|
+
function tagImage(tag) {
|
|
564
|
+
if (tag.album_id && tag.album_exists) return coverElement(tag.album_id);
|
|
565
|
+
if (tag.radio_id && tag.radio_exists) return radioCoverElement(radiosCache.find((r) => r.id === tag.radio_id));
|
|
566
|
+
if (tag.spotify_id && tag.spotify_exists) return spotifyCoverElement(spotifyCache.find((e) => e.id === tag.spotify_id));
|
|
567
|
+
return imageElement(null);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// the colors of the LED on a screen - the same in the app (Jukebox.kt) and in docs/style-guide, section 3.4
|
|
571
|
+
const LED_COLORS = {
|
|
572
|
+
BLACK: "#222", WHITE: "#f4f1f3", RED: "#e0303a", GREEN: "#2fbf4f", BLUE: "#4f8ff7", YELLOW: "#f6c419",
|
|
573
|
+
AQUA: "#27c4c4", MAGENTA: "#d77aae",
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
let ledSequence = null; // timer of a led that shows more than two colors in turn
|
|
577
|
+
|
|
578
|
+
const ledColorNames = (names) => names.map((name) => tOf("led.color", name.toLowerCase())).join(" / ");
|
|
579
|
+
|
|
580
|
+
function renderLed(led) {
|
|
581
|
+
const el = $("led");
|
|
582
|
+
el.hidden = !led;
|
|
583
|
+
clearInterval(ledSequence);
|
|
584
|
+
ledSequence = null;
|
|
585
|
+
if (!led) return;
|
|
586
|
+
if (led.colors) {
|
|
587
|
+
let step = 0;
|
|
588
|
+
const show = () => {
|
|
589
|
+
const color = LED_COLORS[led.colors[step++ % led.colors.length]] || "var(--muted)";
|
|
590
|
+
el.style.setProperty("--led-a", color);
|
|
591
|
+
el.style.setProperty("--led-b", color);
|
|
592
|
+
};
|
|
593
|
+
el.classList.remove("led-alternate");
|
|
594
|
+
el.title = t("led.title", { colors: ledColorNames(led.colors) });
|
|
595
|
+
show();
|
|
596
|
+
ledSequence = setInterval(show, (led.delay || 0.4) * 1000);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const a = LED_COLORS[led.color || led.color_a] || "var(--muted)";
|
|
600
|
+
const b = LED_COLORS[led.color_b] || a;
|
|
601
|
+
el.style.setProperty("--led-a", a);
|
|
602
|
+
el.style.setProperty("--led-b", b);
|
|
603
|
+
el.style.setProperty("--led-delay", `${(led.delay || 1) * 2}s`);
|
|
604
|
+
el.classList.toggle("led-alternate", Boolean(led.color_b));
|
|
605
|
+
el.title = t("led.title", { colors: ledColorNames(led.color ? [led.color] : [led.color_a, led.color_b]) });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
let lastPresentUid = null;
|
|
609
|
+
|
|
610
|
+
function renderStatus(status) {
|
|
611
|
+
renderLed(status.led);
|
|
612
|
+
const presentUid = status.tag ? status.tag.uid : null;
|
|
613
|
+
if (presentUid !== lastPresentUid) {
|
|
614
|
+
lastPresentUid = presentUid;
|
|
615
|
+
if (currentTab === "tags") loadDeck();
|
|
616
|
+
}
|
|
617
|
+
const radio = status.radio;
|
|
618
|
+
const spotify = status.spotify;
|
|
619
|
+
const spotifyTitle = spotify && (spotify.speaker ? t("now.spotify.speaker")
|
|
620
|
+
: spotify.test ? t("now.spotify.test") : t("now.spotify.active"));
|
|
621
|
+
const [stateText, stateClass] = radio
|
|
622
|
+
? [radio.test ? t("now.radio.test") : t("now.radio.active"), radio.state === "retrying" ? "warn" : "ok"]
|
|
623
|
+
: spotify ? [spotifyTitle, spotify.state === "error" ? "error" : spotify.state === "retrying" ? "warn" : "ok"]
|
|
624
|
+
: [tOf("state", status.state), STATE_CLASSES[status.state] || "idle"];
|
|
625
|
+
const album = status.album;
|
|
626
|
+
const badge = $("now-state");
|
|
627
|
+
const playback = album && album.playback ? tOf("playback", album.playback.state)
|
|
628
|
+
: radio ? tOf("radio.state", radio.state) : spotify ? tOf("spotify.state", spotify.state) : null;
|
|
629
|
+
badge.textContent = playback ? `${stateText} · ${playback}` : stateText;
|
|
630
|
+
badge.title = radio && radio.error ? radio.error : spotify && spotify.error ? tOf("spotify.error", spotify.error) : "";
|
|
631
|
+
badge.className = `badge badge-${stateClass}`;
|
|
632
|
+
$("sys-state").textContent = stateText;
|
|
633
|
+
|
|
634
|
+
const tag = status.tag;
|
|
635
|
+
const tagText = tag ? t("now.tag", { name: tag.name || t("common.unnamed"), uid: tag.uid }) : "";
|
|
636
|
+
$("now-tag").textContent = tagText;
|
|
637
|
+
$("now-unlink").hidden = !(tag && (tag.album_id || tag.radio_id || tag.spotify_id));
|
|
638
|
+
$("sys-tag").textContent = tag ? `${tag.name || t("common.unnamed")} (${tag.uid})` : "–";
|
|
639
|
+
|
|
640
|
+
// album header and track list are only rebuilt if the album changes
|
|
641
|
+
const albumKey = album ? `${album.id}:${album.title}:${coverVersions[album.id] || ""}`
|
|
642
|
+
: radio ? `radio:${radio.id}:${radio.name}:${radio.url}:${radio.cover}`
|
|
643
|
+
: spotify ? `spotify:${spotify.id}:${spotify.name}:${spotify.cover}:${spotify.speaker}` : `state:${status.state}`;
|
|
644
|
+
if (albumKey !== shownAlbumKey) {
|
|
645
|
+
shownAlbumKey = albumKey;
|
|
646
|
+
const cover = $("now-cover");
|
|
647
|
+
cover.replaceChildren(album ? coverElement(album.id) : radio ? radioCoverElement(radio)
|
|
648
|
+
: spotify ? spotifyCoverElement(spotify) : imageElement(null));
|
|
649
|
+
$("now-title").textContent = album ? album.title : radio ? radio.name
|
|
650
|
+
: spotify ? (spotify.name || t("now.spotify.speaker")) : stateText;
|
|
651
|
+
$("now-artist").textContent = album ? [album.artist, album.folder].filter(Boolean).join(" · ")
|
|
652
|
+
: radio ? radio.url : spotify && spotify.kind ? tOf("spotify.kind", spotify.kind) : "";
|
|
653
|
+
const list = $("now-tracks");
|
|
654
|
+
list.replaceChildren(...(album ? album.tracks.map((track) => h("li", { "data-index": track.index },
|
|
655
|
+
h("span", { class: "track-name" }, track.title || track.filename),
|
|
656
|
+
h("span", { class: "muted small" }, formatTime(track.length))
|
|
657
|
+
)) : []));
|
|
658
|
+
$("now-tracks-card").hidden = !album;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const hasTrack = album && album.playback && album.playback.track_id !== null;
|
|
662
|
+
const spotifyTrack = spotify && spotify.track && spotify.track.title;
|
|
663
|
+
$("now-track").hidden = !hasTrack && !spotifyTrack;
|
|
664
|
+
if (spotifyTrack) {
|
|
665
|
+
$("now-track-title").textContent = `${spotify.track.index + 1}. ${spotify.track.title}`;
|
|
666
|
+
renderProgress();
|
|
667
|
+
}
|
|
668
|
+
if (hasTrack) {
|
|
669
|
+
const track = album.tracks[album.playback.track_id];
|
|
670
|
+
$("now-track-title").textContent = track ? `${track.index + 1}. ${track.title || track.filename}` : "";
|
|
671
|
+
for (const li of $("now-tracks").children) {
|
|
672
|
+
li.classList.toggle("current", Number(li.dataset.index) === album.playback.track_id);
|
|
673
|
+
}
|
|
674
|
+
renderProgress();
|
|
675
|
+
}
|
|
676
|
+
for (const id of ["btn-prev", "btn-next", "btn-restart"]) $(id).disabled = !album && !spotify; // radio: no effect
|
|
677
|
+
// play/pause: pauses what plays, continues what is paused - nothing to do for a finished album
|
|
678
|
+
const playState = album && album.playback ? album.playback.state : radio ? radio.state : spotify ? spotify.state : null;
|
|
679
|
+
const paused = playState === "paused" || playState === "elsewhere";
|
|
680
|
+
const playPause = $("btn-playpause");
|
|
681
|
+
playPause.disabled = !playState || playState === "finished" || playState === "error" || (radio && radio.test);
|
|
682
|
+
playPause.textContent = paused ? "▶" : "⏸";
|
|
683
|
+
playPause.title = paused ? t("common.resume") : t("common.pause");
|
|
684
|
+
playPause.setAttribute("aria-label", playPause.title);
|
|
685
|
+
playPause.dataset.action = paused ? "play" : "pause";
|
|
686
|
+
|
|
687
|
+
const volume = status.volume;
|
|
688
|
+
if (!volumeDragging) {
|
|
689
|
+
const slider = $("volume");
|
|
690
|
+
slider.max = volume.max_volume;
|
|
691
|
+
slider.value = volume.volume;
|
|
692
|
+
$("volume-value").textContent = percent(Math.round(volume.volume));
|
|
693
|
+
}
|
|
694
|
+
$("volume-info").textContent = t("now.volume.info", {
|
|
695
|
+
output: tOf("output", volume.output), default: Math.round(volume.default_volume), max: Math.round(volume.max_volume),
|
|
696
|
+
});
|
|
697
|
+
renderBluetooth(status.bluetooth);
|
|
698
|
+
|
|
699
|
+
// radio test (tab radio)
|
|
700
|
+
const testing = radio && radio.test;
|
|
701
|
+
$("radio-test").hidden = !testing;
|
|
702
|
+
if (testing) {
|
|
703
|
+
$("radio-test-text").textContent = t(radio.error ? "radio.test.state_error" : "radio.test.state", {
|
|
704
|
+
station: radio.name === radio.url ? radio.url : `${radio.name} (${radio.url})`,
|
|
705
|
+
state: tOf("radio.state", radio.state), error: radio.error,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// test of an entry of Spotify (tab Spotify)
|
|
710
|
+
const spotifyTesting = spotify && spotify.test;
|
|
711
|
+
$("spotify-test").hidden = !spotifyTesting;
|
|
712
|
+
if (spotifyTesting) {
|
|
713
|
+
$("spotify-test-text").textContent = t("spotify.test.state", { name: spotify.name, state: tOf("spotify.state", spotify.state) });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// unknown tag on the box -> offer to link it right here
|
|
717
|
+
const linkCard = $("now-link");
|
|
718
|
+
const showLink = session && session.role === "admin" && status.state === "unknown_tag" && tag;
|
|
719
|
+
if (showLink && linkCard.dataset.uid !== tag.uid) {
|
|
720
|
+
linkCard.dataset.uid = tag.uid;
|
|
721
|
+
$("now-link-uid").textContent = tag.uid;
|
|
722
|
+
fillTargetSelect($("now-link-album"), null, t("target.choose"), true);
|
|
723
|
+
}
|
|
724
|
+
if (!showLink) delete linkCard.dataset.uid;
|
|
725
|
+
linkCard.hidden = !showLink;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function renderBluetooth(bluetooth) {
|
|
729
|
+
const device = bluetooth.device;
|
|
730
|
+
$("headset-icon").hidden = !bluetooth.connected;
|
|
731
|
+
$("bt-facts").hidden = !device;
|
|
732
|
+
$("bt-unpair").disabled = !device;
|
|
733
|
+
$("bt-pair").disabled = !bluetooth.available || bluetooth.pairing;
|
|
734
|
+
$("bt-message").textContent = !bluetooth.available ? t("bluetooth.unavailable")
|
|
735
|
+
: bluetooth.pairing ? t("bluetooth.searching")
|
|
736
|
+
: !device ? [t("bluetooth.none"), pairingResult(bluetooth.pairing_result)].filter(Boolean).join(" ")
|
|
737
|
+
: bluetooth.pairing_result !== "paired" ? pairingResult(bluetooth.pairing_result) : "";
|
|
738
|
+
if (device) {
|
|
739
|
+
$("bt-name").textContent = device.name;
|
|
740
|
+
$("bt-address").textContent = device.address;
|
|
741
|
+
$("bt-connected").textContent = bluetooth.connected ? t("common.yes") : t("common.no");
|
|
742
|
+
$("bt-battery").textContent = bluetooth.battery === null ? "–" : percent(bluetooth.battery);
|
|
743
|
+
$("bt-volume").textContent = bluetooth.volume === null ? "–" : percent(Math.round(bluetooth.volume));
|
|
744
|
+
}
|
|
745
|
+
$("bt-default").textContent = percent(Math.round(bluetooth.default_volume));
|
|
746
|
+
$("bt-max").textContent = percent(Math.round(bluetooth.max_volume));
|
|
747
|
+
$("bt-virtual").hidden = !bluetooth.virtual;
|
|
748
|
+
$("bt-virtual-on").disabled = !device || bluetooth.connected;
|
|
749
|
+
$("bt-virtual-off").disabled = !bluetooth.connected;
|
|
750
|
+
for (const button of $("bt-virtual-buttons").children) button.disabled = !bluetooth.connected;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// the position is interpolated between two status requests
|
|
754
|
+
function renderProgress() {
|
|
755
|
+
const album = lastStatus && lastStatus.album;
|
|
756
|
+
const spotify = lastStatus && lastStatus.spotify;
|
|
757
|
+
const playback = album && album.playback && album.playback.track_id !== null ? album.playback
|
|
758
|
+
: spotify && spotify.track && spotify.track.title
|
|
759
|
+
? { position: spotify.track.position, length: spotify.track.length, state: spotify.state === "playing" ? "play" : spotify.state }
|
|
760
|
+
: null;
|
|
761
|
+
if (!playback) return;
|
|
762
|
+
let position = playback.position || 0;
|
|
763
|
+
if (playback.state === "play") position += (performance.now() - lastStatusTime) / 1000;
|
|
764
|
+
const length = playback.length || 0;
|
|
765
|
+
position = Math.min(position, length || position);
|
|
766
|
+
$("now-pos").textContent = formatTime(position);
|
|
767
|
+
$("now-len").textContent = formatTime(length);
|
|
768
|
+
$("now-progress").style.width = length ? `${(100 * position) / length}%` : "0";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function animate() {
|
|
772
|
+
if (currentTab === "now" && !document.hidden) renderProgress();
|
|
773
|
+
requestAnimationFrame(animate);
|
|
774
|
+
}
|
|
775
|
+
requestAnimationFrame(animate);
|
|
776
|
+
|
|
777
|
+
// the arrow and volume buttons do exactly what the buttons on the box do
|
|
778
|
+
async function pressButton(button) {
|
|
779
|
+
await run(() => api("POST", `/api/buttons/${button}`, { action: "press" }));
|
|
780
|
+
setTimeout(refreshStatus, 250);
|
|
781
|
+
}
|
|
782
|
+
$("btn-prev").addEventListener("click", () => pressButton("prev"));
|
|
783
|
+
$("btn-next").addEventListener("click", () => pressButton("next"));
|
|
784
|
+
$("btn-vol-up").addEventListener("click", () => pressButton("volume-up"));
|
|
785
|
+
$("btn-vol-down").addEventListener("click", () => pressButton("volume-down"));
|
|
786
|
+
$("btn-restart").addEventListener("click", async () => {
|
|
787
|
+
await run(() => api("POST", "/api/player/restart"));
|
|
788
|
+
setTimeout(refreshStatus, 250);
|
|
789
|
+
});
|
|
790
|
+
$("btn-playpause").addEventListener("click", async (event) => {
|
|
791
|
+
await run(() => api("POST", `/api/player/${event.currentTarget.dataset.action || "pause"}`));
|
|
792
|
+
setTimeout(refreshStatus, 250);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// start value and maximum of the volumes (admins)
|
|
796
|
+
function openLimits() {
|
|
797
|
+
const profiles = lastStatus && lastStatus.volume.profiles;
|
|
798
|
+
if (!profiles) return;
|
|
799
|
+
const form = $("limits-form");
|
|
800
|
+
for (const name of ["speaker", "headphones"]) {
|
|
801
|
+
form[`${name}_default`].value = Math.round(profiles[name].default_volume);
|
|
802
|
+
form[`${name}_max`].value = Math.round(profiles[name].max_volume);
|
|
803
|
+
}
|
|
804
|
+
$("limits-dialog").showModal();
|
|
805
|
+
}
|
|
806
|
+
$("volume-limits").addEventListener("click", openLimits);
|
|
807
|
+
$("bt-limits").addEventListener("click", openLimits);
|
|
808
|
+
$("limits-form").addEventListener("submit", async (event) => {
|
|
809
|
+
event.preventDefault();
|
|
810
|
+
const form = event.target;
|
|
811
|
+
const profiles = lastStatus.volume.profiles;
|
|
812
|
+
let saved = true;
|
|
813
|
+
for (const name of ["speaker", "headphones"]) {
|
|
814
|
+
const limits = { default_volume: Number(form[`${name}_default`].value), max_volume: Number(form[`${name}_max`].value) };
|
|
815
|
+
if (limits.default_volume === profiles[name].default_volume && limits.max_volume === profiles[name].max_volume) continue;
|
|
816
|
+
if (limits.default_volume > limits.max_volume) {
|
|
817
|
+
toast(t("limits.default_over_max", { output: tOf("output", name) }), true);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
saved = await run(() => api("PUT", `/api/volume/limits/${name}`, limits)) !== undefined && saved;
|
|
821
|
+
}
|
|
822
|
+
if (saved) {
|
|
823
|
+
toast(t("limits.saved"));
|
|
824
|
+
$("limits-dialog").close();
|
|
825
|
+
}
|
|
826
|
+
refreshStatus();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// bluetooth headset
|
|
830
|
+
$("bt-pair").addEventListener("click", async () => {
|
|
831
|
+
await run(() => api("POST", "/api/bluetooth/pair"), t("bluetooth.pair.started"));
|
|
832
|
+
refreshStatus();
|
|
833
|
+
});
|
|
834
|
+
$("bt-unpair").addEventListener("click", async () => {
|
|
835
|
+
const device = lastStatus && lastStatus.bluetooth.device;
|
|
836
|
+
if (!device || !confirm(t("bluetooth.unpair.confirm", { name: device.name }))) return;
|
|
837
|
+
await run(() => api("DELETE", "/api/bluetooth/device"), t("bluetooth.unpaired"));
|
|
838
|
+
setTimeout(refreshStatus, 250);
|
|
839
|
+
});
|
|
840
|
+
async function virtualHeadset(body) {
|
|
841
|
+
await run(() => api("POST", "/api/bluetooth/virtual", body));
|
|
842
|
+
setTimeout(refreshStatus, 250);
|
|
843
|
+
}
|
|
844
|
+
$("bt-virtual-on").addEventListener("click", () => virtualHeadset({ connected: true }));
|
|
845
|
+
$("bt-virtual-off").addEventListener("click", () => virtualHeadset({ connected: false }));
|
|
846
|
+
for (const button of $("bt-virtual-buttons").children) {
|
|
847
|
+
button.addEventListener("click", () => virtualHeadset({ button: button.dataset.button }));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const volumeSlider = $("volume");
|
|
851
|
+
volumeSlider.addEventListener("input", () => {
|
|
852
|
+
volumeDragging = true;
|
|
853
|
+
$("volume-value").textContent = percent(volumeSlider.value);
|
|
854
|
+
});
|
|
855
|
+
volumeSlider.addEventListener("change", async () => {
|
|
856
|
+
await run(() => api("PUT", "/api/volume", { volume: Number(volumeSlider.value) }));
|
|
857
|
+
volumeDragging = false;
|
|
858
|
+
refreshStatus();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
$("now-unlink").addEventListener("click", async () => {
|
|
862
|
+
const tag = lastStatus && lastStatus.tag;
|
|
863
|
+
if (tag && await unlinkTag(tag)) refreshStatus();
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
$("now-link-button").addEventListener("click", async () => {
|
|
867
|
+
const target = $("now-link-album").value;
|
|
868
|
+
if (!target) return toast(t("target.required"), true);
|
|
869
|
+
const result = await run(() => linkTag("current", target),
|
|
870
|
+
target.startsWith("radio:") ? t("now.linked.radio")
|
|
871
|
+
: target.startsWith("spotify:") ? t("now.linked.spotify") : t("now.linked.album"));
|
|
872
|
+
if (result) refreshStatus();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// targets of a tag in selects: "album:<id>", "radio:<id>" or "spotify:<id>"
|
|
876
|
+
const targetValue = (tag) => tag.album_id ? `album:${tag.album_id}` : tag.radio_id ? `radio:${tag.radio_id}`
|
|
877
|
+
: tag.spotify_id ? `spotify:${tag.spotify_id}` : "";
|
|
878
|
+
|
|
879
|
+
function linkTag(uid, target) {
|
|
880
|
+
const path = `/api/tags/${encodeURIComponent(uid)}`;
|
|
881
|
+
if (!target) return api("DELETE", `${path}/album`);
|
|
882
|
+
const [type, id] = target.split(/:(.*)/s);
|
|
883
|
+
if (type === "radio") return api("PUT", `${path}/radio`, { radio_id: id });
|
|
884
|
+
if (type === "spotify") return api("PUT", `${path}/spotify`, { spotify_id: id });
|
|
885
|
+
return api("PUT", `${path}/album`, { album_id: id });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function loadTargets() {
|
|
889
|
+
const [albums, radios, entries] = await Promise.all([run(() => api("GET", "/api/albums")),
|
|
890
|
+
run(() => api("GET", "/api/radios")), run(() => api("GET", "/api/spotify"))]);
|
|
891
|
+
if (albums) albumsCache = albums;
|
|
892
|
+
if (radios) radiosCache = radios;
|
|
893
|
+
if (entries) spotifyCache = entries;
|
|
894
|
+
return Boolean(albums && radios && entries);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function targetOptions(selected, unmappedFirst = false) {
|
|
898
|
+
const albums = [...albumsCache];
|
|
899
|
+
if (unmappedFirst) albums.sort((a, b) => (a.tags.length > 0) - (b.tags.length > 0));
|
|
900
|
+
const option = (value, text) => h("option", { value, selected: value === selected }, text);
|
|
901
|
+
const groups = [];
|
|
902
|
+
if (albums.length) groups.push(h("optgroup", { label: t("tabs.albums") }, ...albums.map((a) =>
|
|
903
|
+
option(`album:${a.id}`, unmappedFirst && !a.tags.length ? t("target.album_without_tag", { title: a.title }) : a.title))));
|
|
904
|
+
if (radiosCache.length) groups.push(h("optgroup", { label: t("radio.title") }, ...radiosCache.map((r) =>
|
|
905
|
+
option(`radio:${r.id}`, `📻 ${r.name}`))));
|
|
906
|
+
if (spotifyCache.length) groups.push(h("optgroup", { label: t("spotify.title") }, ...spotifyCache.map((e) =>
|
|
907
|
+
option(`spotify:${e.id}`, `🎵 ${e.name}`))));
|
|
908
|
+
return groups;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function fillTargetSelect(select, selected, placeholder, unmappedFirst = false) {
|
|
912
|
+
await loadTargets();
|
|
913
|
+
select.replaceChildren(h("option", { value: "" }, placeholder), ...targetOptions(selected, unmappedFirst));
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// unlinks the tag from its album or radio station (asks first) - true if done
|
|
917
|
+
async function unlinkTag(tag) {
|
|
918
|
+
if (!confirm(t("tagman.unlink.confirm", { name: tag.name || tag.uid }))) return false;
|
|
919
|
+
const result = await run(() => api("DELETE", `/api/tags/${encodeURIComponent(tag.uid)}/album`), t("tagman.unlinked"));
|
|
920
|
+
return result !== undefined;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// the tags linked to an album or radio station - admins can unlink them (✕)
|
|
924
|
+
function tagChips(tags, onUnlinked) {
|
|
925
|
+
if (!tags.length) return [h("span", { class: "muted small" }, t("common.no_tag"))];
|
|
926
|
+
const isAdmin = session && session.role === "admin";
|
|
927
|
+
return tags.map((tag) => h("span", { class: "tag-chip" }, tag.name || tag.uid,
|
|
928
|
+
isAdmin ? h("button", { class: "link danger-text", title: t("common.unlink"), "aria-label": t("common.unlink"),
|
|
929
|
+
onclick: async () => { if (await unlinkTag(tag)) onUnlinked(); } }, "✕") : ""));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// "Titel des Albums", "📻 Sender" or a hint that the target is missing
|
|
933
|
+
function targetText(tag) {
|
|
934
|
+
if (tag.album_id) {
|
|
935
|
+
const album = albumsCache.find((a) => a.id === tag.album_id);
|
|
936
|
+
return album ? album.title : t("target.album_missing", { id: tag.album_id });
|
|
937
|
+
}
|
|
938
|
+
if (tag.radio_id) {
|
|
939
|
+
const radio = radiosCache.find((r) => r.id === tag.radio_id);
|
|
940
|
+
return radio ? `📻 ${radio.name}` : t("target.radio_missing", { id: tag.radio_id });
|
|
941
|
+
}
|
|
942
|
+
if (tag.spotify_id) {
|
|
943
|
+
const entry = spotifyCache.find((e) => e.id === tag.spotify_id);
|
|
944
|
+
return entry ? `🎵 ${entry.name}` : t("target.spotify_missing", { id: tag.spotify_id });
|
|
945
|
+
}
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// input that renames a tag (admins)
|
|
950
|
+
function tagNameInput(tag, onSaved) {
|
|
951
|
+
const input = h("input", { value: tag.name || "", class: "inline-input", placeholder: t("common.name"), "aria-label": t("common.name") });
|
|
952
|
+
input.addEventListener("change", async () => {
|
|
953
|
+
const saved = await run(() => api("PATCH", `/api/tags/${encodeURIComponent(tag.uid)}`, { name: input.value }),
|
|
954
|
+
t("tagman.name_saved"));
|
|
955
|
+
if (saved && onSaved) onSaved();
|
|
956
|
+
});
|
|
957
|
+
return input;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// select that links a tag with an album or radio station (admins)
|
|
961
|
+
function tagTargetSelect(tag, onLinked) {
|
|
962
|
+
const select = h("select", { "aria-label": t("target.label") },
|
|
963
|
+
h("option", { value: "" }, t("target.none")), ...targetOptions(targetValue(tag)));
|
|
964
|
+
if ((tag.album_id && !tag.album_exists) || (tag.radio_id && !tag.radio_exists)
|
|
965
|
+
|| (tag.spotify_id && !tag.spotify_exists)) {
|
|
966
|
+
select.append(h("option", { value: targetValue(tag), selected: true },
|
|
967
|
+
t("target.missing_option", { id: tag.album_id || tag.radio_id || tag.spotify_id })));
|
|
968
|
+
}
|
|
969
|
+
select.addEventListener("change", async () => {
|
|
970
|
+
await run(() => linkTag(tag.uid, select.value), t("tagman.link_saved"));
|
|
971
|
+
onLinked();
|
|
972
|
+
});
|
|
973
|
+
return select;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
977
|
+
// tags
|
|
978
|
+
|
|
979
|
+
// where a tag stopped: "finished", "track 2, 1:30", or a dash
|
|
980
|
+
function tagProgress(tag) {
|
|
981
|
+
const activity = tag.activity;
|
|
982
|
+
if (!activity) return "–";
|
|
983
|
+
return activity.finished ? t("progress.finished")
|
|
984
|
+
: t("progress.track", { n: activity.track_id + 1, time: formatTime(activity.track_time) });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function loadTags() {
|
|
988
|
+
const unmapped = $("tags-unmapped").checked;
|
|
989
|
+
const [tags, targetsLoaded] = await Promise.all([
|
|
990
|
+
run(() => api("GET", `/api/tags${unmapped ? "?unmapped=1" : ""}`)),
|
|
991
|
+
loadTargets(),
|
|
992
|
+
]);
|
|
993
|
+
if (!tags || !targetsLoaded) return;
|
|
994
|
+
const isAdmin = session.role === "admin";
|
|
995
|
+
const body = $("tags-body");
|
|
996
|
+
body.replaceChildren(...tags.map((tag) => {
|
|
997
|
+
const nameCell = isAdmin ? tagNameInput(tag, refreshScanned) : tag.name || "–";
|
|
998
|
+
const albumCell = isAdmin ? tagTargetSelect(tag, tagsChanged) : targetText(tag) || "–";
|
|
999
|
+
const progress = tagProgress(tag);
|
|
1000
|
+
const remove = isAdmin ? h("button", {
|
|
1001
|
+
class: "link danger-text", title: t("tagman.remove.title"), onclick: async () => {
|
|
1002
|
+
if (!confirm(t("tagman.remove.confirm", { name: tag.name || tag.uid }))) return;
|
|
1003
|
+
await run(() => api("DELETE", `/api/tags/${encodeURIComponent(tag.uid)}`), t("tagman.removed"));
|
|
1004
|
+
if (tag.uid === scannedUid) forgetScanned();
|
|
1005
|
+
loadTags();
|
|
1006
|
+
},
|
|
1007
|
+
}, t("common.remove")) : "";
|
|
1008
|
+
return h("tr", { class: tag.present ? "present" : "" },
|
|
1009
|
+
h("td", { class: "thumb-cell" }, h("div", { class: "thumb" }, tagImage(tag))),
|
|
1010
|
+
h("td", {}, nameCell),
|
|
1011
|
+
h("td", {}, h("code", {}, tag.uid), tag.present ? h("span", { class: "badge badge-ok small" }, t("common.present")) : ""),
|
|
1012
|
+
h("td", {}, albumCell),
|
|
1013
|
+
h("td", { class: "small" }, progress),
|
|
1014
|
+
h("td", {}, remove));
|
|
1015
|
+
}));
|
|
1016
|
+
if (!tags.length) body.append(h("tr", {}, h("td", { colSpan: 6, class: "muted" }, t("tagman.none"))));
|
|
1017
|
+
if (isAdmin) {
|
|
1018
|
+
$("tag-new-album").replaceChildren(h("option", { value: "" }, t("tagman.new.no_target")), ...targetOptions(""));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
$("tag-new").addEventListener("submit", async (event) => {
|
|
1023
|
+
event.preventDefault();
|
|
1024
|
+
const form = event.target;
|
|
1025
|
+
const [type, id] = form.album_id.value.split(/:(.*)/s);
|
|
1026
|
+
const tag = await run(() => api("POST", "/api/tags", {
|
|
1027
|
+
uid: form.uid.value.trim() || undefined, name: form.name.value.trim() || undefined,
|
|
1028
|
+
album_id: type === "album" ? id : undefined, radio_id: type === "radio" ? id : undefined,
|
|
1029
|
+
spotify_id: type === "spotify" ? id : undefined,
|
|
1030
|
+
}));
|
|
1031
|
+
if (tag) {
|
|
1032
|
+
toast(t("tagman.created", { uid: tag.uid }));
|
|
1033
|
+
form.reset();
|
|
1034
|
+
loadTags();
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1039
|
+
// tags (the web page as rfid reader: a click places the tag on the box)
|
|
1040
|
+
|
|
1041
|
+
// what the tab "Tags" shows: all tags or only those of albums or of radio stations, as tiles or as a list. The choice is
|
|
1042
|
+
// kept in this browser
|
|
1043
|
+
const DECK_FILTERS = ["all", "album", "radio", "spotify"];
|
|
1044
|
+
const DECK_VIEWS = ["tiles", "list"];
|
|
1045
|
+
const DECK_KEY = "tinyjukebox.deck";
|
|
1046
|
+
let deckChoice = { filter: "all", view: "tiles" };
|
|
1047
|
+
try {
|
|
1048
|
+
const saved = JSON.parse(localStorage.getItem(DECK_KEY) || "{}");
|
|
1049
|
+
if (DECK_FILTERS.includes(saved.filter)) deckChoice.filter = saved.filter;
|
|
1050
|
+
if (DECK_VIEWS.includes(saved.view)) deckChoice.view = saved.view;
|
|
1051
|
+
} catch { /* a browser without storage: the choice is for this page only */ }
|
|
1052
|
+
let deckTags = [];
|
|
1053
|
+
|
|
1054
|
+
function chooseDeck(part, value) {
|
|
1055
|
+
deckChoice = { ...deckChoice, [part]: value };
|
|
1056
|
+
try {
|
|
1057
|
+
localStorage.setItem(DECK_KEY, JSON.stringify(deckChoice));
|
|
1058
|
+
} catch { /* see above */ }
|
|
1059
|
+
showDeck();
|
|
1060
|
+
}
|
|
1061
|
+
for (const button of $("deck-filter").querySelectorAll("button")) {
|
|
1062
|
+
button.addEventListener("click", () => chooseDeck("filter", button.dataset.filter));
|
|
1063
|
+
}
|
|
1064
|
+
for (const button of $("deck-view").querySelectorAll("button")) {
|
|
1065
|
+
button.addEventListener("click", () => chooseDeck("view", button.dataset.view));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function deckTag(tag) {
|
|
1069
|
+
const press = () => tag.present ? removeTag() : placeTag(tag.uid);
|
|
1070
|
+
const present = tag.present ? h("span", { class: "badge badge-ok deck-badge" }, t("common.present")) : "";
|
|
1071
|
+
if (deckChoice.view === "list") {
|
|
1072
|
+
return h("button", { class: `deck-row${tag.present ? " present" : ""}`, "aria-pressed": tag.present,
|
|
1073
|
+
disabled: isLibrary(), onclick: press },
|
|
1074
|
+
h("div", { class: "thumb" }, tagImage(tag)),
|
|
1075
|
+
h("div", { class: "deck-row-name" }, tag.name || tag.uid),
|
|
1076
|
+
h("div", { class: "muted small deck-row-target" }, targetText(tag) || t("common.not_linked")),
|
|
1077
|
+
h("div", { class: "muted small deck-row-progress" }, tagProgress(tag)),
|
|
1078
|
+
present);
|
|
1079
|
+
}
|
|
1080
|
+
return h("button", { class: `deck-card${tag.present ? " present" : ""}`, "aria-pressed": tag.present,
|
|
1081
|
+
disabled: isLibrary(), // a tag is placed on a box
|
|
1082
|
+
onclick: press },
|
|
1083
|
+
h("div", { class: "cover" }, tagImage(tag)),
|
|
1084
|
+
h("div", { class: "deck-name" }, tag.name || tag.uid),
|
|
1085
|
+
h("div", { class: "muted small" }, targetText(tag) || t("common.not_linked")),
|
|
1086
|
+
present);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function showDeck() {
|
|
1090
|
+
for (const [id, part, attribute] of [["deck-filter", "filter", "filter"], ["deck-view", "view", "view"]]) {
|
|
1091
|
+
for (const button of $(id).querySelectorAll("button")) {
|
|
1092
|
+
button.setAttribute("aria-pressed", button.dataset[attribute] === deckChoice[part]);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const shown = deckTags.filter((tag) => deckChoice.filter === "all"
|
|
1096
|
+
|| tag[`${deckChoice.filter}_id`]);
|
|
1097
|
+
const deck = $("deck");
|
|
1098
|
+
deck.classList.toggle("deck-list", deckChoice.view === "list");
|
|
1099
|
+
deck.replaceChildren(...shown.map(deckTag));
|
|
1100
|
+
if (!shown.length) {
|
|
1101
|
+
deck.append(h("p", { class: "muted" }, deckTags.length ? t("tags.none.filtered")
|
|
1102
|
+
: isLibrary() ? t("tags.none.library") : t("tags.none.box")));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function loadDeck() {
|
|
1107
|
+
const tags = await run(() => api("GET", "/api/tags"));
|
|
1108
|
+
if (!tags) return;
|
|
1109
|
+
if (!albumsCache.length && !radiosCache.length && !spotifyCache.length) await loadTargets();
|
|
1110
|
+
deckTags = tags;
|
|
1111
|
+
showDeck();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function placeTag(uid) {
|
|
1115
|
+
await run(() => api("POST", "/api/rfid", { uid }));
|
|
1116
|
+
setTimeout(() => { refreshStatus(); loadDeck(); }, 400);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function removeTag() {
|
|
1120
|
+
await run(() => api("DELETE", "/api/rfid"));
|
|
1121
|
+
setTimeout(() => { refreshStatus(); loadDeck(); }, 400);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
$("rfid-remove").addEventListener("click", removeTag);
|
|
1125
|
+
$("rfid-form").addEventListener("submit", (event) => {
|
|
1126
|
+
event.preventDefault();
|
|
1127
|
+
placeTag(event.target.uid.value.trim());
|
|
1128
|
+
event.target.reset();
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1132
|
+
// the phone as reader of the library (docs/developer/library.md, section 5.1), in the tab of the tags: the browser
|
|
1133
|
+
// reads the uid of a tag held to the phone (Web NFC, Chrome on Android) and the library adds the tag if it does
|
|
1134
|
+
// not know it. The browser reports the touch only - nothing tells when the tag is taken away. Nothing is ever
|
|
1135
|
+
// written to a tag.
|
|
1136
|
+
|
|
1137
|
+
const SCAN_ERRORS = ["NotSupportedError", "NotReadableError"]; // the errors of a scan that have a text of their own
|
|
1138
|
+
// on a phone the button is always there: where the browser cannot read tags, a tap says why
|
|
1139
|
+
const ON_A_PHONE = /Android|iPhone|iPad/.test(navigator.userAgent);
|
|
1140
|
+
let scanStop = null; // ends the running scan
|
|
1141
|
+
let scannedUid = null; // the tag that is shown
|
|
1142
|
+
|
|
1143
|
+
// why this browser cannot read tags - null if it can
|
|
1144
|
+
function scanObstacle() {
|
|
1145
|
+
if ("NDEFReader" in window || APP) return null;
|
|
1146
|
+
const agent = navigator.userAgent;
|
|
1147
|
+
if (!/Android/.test(agent)) return t("scan.obstacle.ios");
|
|
1148
|
+
const chrome = /Chrome\/(\d+)/.exec(agent);
|
|
1149
|
+
if (!chrome || /Firefox|SamsungBrowser|EdgA|OPR|; wv\)/.test(agent)) {
|
|
1150
|
+
return t("scan.obstacle.browser");
|
|
1151
|
+
}
|
|
1152
|
+
if (!window.isSecureContext) return t("scan.obstacle.https");
|
|
1153
|
+
return t("scan.obstacle.chrome", { version: chrome[1] });
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Chrome did not let the page use NFC: what to do about it. The browser tells whether the page is blocked - by an
|
|
1157
|
+
// answer to its question or for all pages - or whether its question was only closed
|
|
1158
|
+
async function scanRefused() {
|
|
1159
|
+
let state = "denied";
|
|
1160
|
+
try {
|
|
1161
|
+
state = (await navigator.permissions.query({ name: "nfc" })).state;
|
|
1162
|
+
} catch { /* a browser that does not tell */ }
|
|
1163
|
+
return state === "prompt" ? t("scan.refused.closed") : t("scan.refused.blocked");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function startScan() {
|
|
1167
|
+
const obstacle = scanObstacle();
|
|
1168
|
+
if (obstacle) return toast(obstacle, true);
|
|
1169
|
+
const stop = new AbortController();
|
|
1170
|
+
$("scan-refused").hidden = true;
|
|
1171
|
+
if (APP) {
|
|
1172
|
+
// the app reads the tag and gives the page the uid, as NDEFReader gives the serial number
|
|
1173
|
+
APP.onmessage = (event) => scanned(event.data);
|
|
1174
|
+
APP.postMessage("scan-start");
|
|
1175
|
+
stop.signal.addEventListener("abort", () => APP.postMessage("scan-stop"));
|
|
1176
|
+
scanStop = stop;
|
|
1177
|
+
forgetScanned();
|
|
1178
|
+
$("scan").hidden = false;
|
|
1179
|
+
$("scan-toggle").textContent = t("scan.stop");
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
try {
|
|
1183
|
+
const reader = new NDEFReader();
|
|
1184
|
+
reader.addEventListener("reading", (event) => scanned(event.serialNumber));
|
|
1185
|
+
reader.addEventListener("readingerror", () => toast(t("scan.unreadable"), true));
|
|
1186
|
+
await reader.scan({ signal: stop.signal });
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
if (e.name === "NotAllowedError") { // stays on the page: the way out is too long for a message that fades
|
|
1189
|
+
$("scan-refused").textContent = await scanRefused();
|
|
1190
|
+
$("scan-refused").hidden = false;
|
|
1191
|
+
} else {
|
|
1192
|
+
toast(SCAN_ERRORS.includes(e.name) ? tOf("scan.error", e.name) : t("scan.failed", { message: e.message }), true);
|
|
1193
|
+
}
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
scanStop = stop;
|
|
1197
|
+
forgetScanned();
|
|
1198
|
+
$("scan").hidden = false;
|
|
1199
|
+
$("scan-toggle").textContent = t("scan.stop");
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function stopScan() {
|
|
1203
|
+
if (!scanStop) return;
|
|
1204
|
+
scanStop.abort();
|
|
1205
|
+
scanStop = null;
|
|
1206
|
+
$("scan").hidden = true;
|
|
1207
|
+
$("scan-toggle").textContent = t("scan.start");
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// the browser reports the uid as "04:1a:2b:3c:4d:5e:6f", the reader of a box in upper case
|
|
1211
|
+
async function scanned(serialNumber) {
|
|
1212
|
+
if (!serialNumber) return toast(t("scan.unreadable"), true);
|
|
1213
|
+
const uid = serialNumber.toUpperCase();
|
|
1214
|
+
if (uid === scannedUid) return; // some phones report a tag again and again while it stays
|
|
1215
|
+
const answer = await run(() => request("PUT", `/api/tags/${encodeURIComponent(uid)}`));
|
|
1216
|
+
if (!answer || !scanStop) return;
|
|
1217
|
+
scannedUid = uid;
|
|
1218
|
+
await loadTags(); // the tag in the table; also the albums and stations, for the texts below
|
|
1219
|
+
showScanned(answer.data, answer.status === 201);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function forgetScanned() {
|
|
1223
|
+
scannedUid = null;
|
|
1224
|
+
$("scan-result").replaceChildren();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function showScanned(tag, isNew) {
|
|
1228
|
+
const isAdmin = session.role === "admin";
|
|
1229
|
+
$("scan-result").replaceChildren(h("div", { class: "scan-tag" },
|
|
1230
|
+
h("div", { class: "thumb" }, tagImage(tag)),
|
|
1231
|
+
h("div", { class: "scan-fields" },
|
|
1232
|
+
h("div", {}, h("code", {}, tag.uid), isNew ? h("span", { class: "badge badge-ok small" }, t("scan.new")) : ""),
|
|
1233
|
+
isAdmin ? tagNameInput(tag, tagsChanged) : h("strong", {}, tag.name || t("common.unnamed")),
|
|
1234
|
+
isAdmin ? tagTargetSelect(tag, tagsChanged)
|
|
1235
|
+
: h("span", { class: "muted" }, targetText(tag) || t("common.not_linked")))));
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// the tag that is shown, after its name or link was changed on this page
|
|
1239
|
+
async function refreshScanned() {
|
|
1240
|
+
if (!scannedUid || !scanStop) return;
|
|
1241
|
+
const tag = await run(() => api("GET", `/api/tags/${encodeURIComponent(scannedUid)}`));
|
|
1242
|
+
if (tag && scanStop) showScanned(tag, false);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// a tag was changed on this page: the table, and the tag that is shown
|
|
1246
|
+
async function tagsChanged() {
|
|
1247
|
+
await loadTags();
|
|
1248
|
+
await refreshScanned();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
$("scan-toggle").addEventListener("click", () => scanStop ? stopScan() : startScan());
|
|
1252
|
+
|
|
1253
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1254
|
+
// log
|
|
1255
|
+
|
|
1256
|
+
let logLastId = 0;
|
|
1257
|
+
let logBoxes = []; // the page of the library: the boxes whose log can be chosen
|
|
1258
|
+
let logRecords = []; // ... and what is shown of the chosen one
|
|
1259
|
+
|
|
1260
|
+
async function loadLog(reset) {
|
|
1261
|
+
if (isLibrary() && $("log-source").value) return reset ? loadBoxLog() : undefined; // no live view of a box
|
|
1262
|
+
$("log-box").hidden = $("log-startup").hidden = true;
|
|
1263
|
+
$("log-follow-label").hidden = $("log-download").hidden = false;
|
|
1264
|
+
const list = $("log-list");
|
|
1265
|
+
if (reset) {
|
|
1266
|
+
logLastId = 0;
|
|
1267
|
+
list.replaceChildren();
|
|
1268
|
+
}
|
|
1269
|
+
const params = new URLSearchParams({ after: logLastId, level: $("log-level").value, limit: 1000 });
|
|
1270
|
+
const text = $("log-text").value.trim();
|
|
1271
|
+
if (text) params.set("text", text);
|
|
1272
|
+
const result = await run(() => api("GET", `/api/logs?${params}`));
|
|
1273
|
+
if (!result) return;
|
|
1274
|
+
logLastId = result.last_id;
|
|
1275
|
+
if (!result.entries.length) {
|
|
1276
|
+
if (reset) list.append(h("p", { class: "muted" }, t("log.none")));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (list.firstChild && list.firstChild.tagName === "P") list.replaceChildren();
|
|
1280
|
+
const atBottom = list.scrollTop + list.clientHeight >= list.scrollHeight - 30;
|
|
1281
|
+
list.append(...result.entries.map((e) => h("div", { class: `log-entry log-${e.level.toLowerCase()}` },
|
|
1282
|
+
h("span", { class: "log-time" }, new Date(e.time * 1000).toLocaleTimeString()),
|
|
1283
|
+
h("span", { class: "log-level" }, e.level),
|
|
1284
|
+
h("span", { class: "log-logger" }, e.logger.split(".").slice(-2).join(".")),
|
|
1285
|
+
h("span", { class: "log-message" }, e.message))));
|
|
1286
|
+
while (list.children.length > 3000) list.firstChild.remove();
|
|
1287
|
+
if (atBottom || reset) list.scrollTop = list.scrollHeight;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// what a box delivered of its log (docs/developer/library.md, section 4.22) - no live view: it arrives in batches
|
|
1291
|
+
|
|
1292
|
+
async function logSources() {
|
|
1293
|
+
const overview = await run(() => api("GET", "/api/library/overview"));
|
|
1294
|
+
if (!overview) return;
|
|
1295
|
+
logBoxes = overview.boxes.filter((box) => !box.removed);
|
|
1296
|
+
const chosen = $("log-source").value;
|
|
1297
|
+
$("log-source").replaceChildren(h("option", { value: "" }, t("log.source.library")),
|
|
1298
|
+
...logBoxes.map((box) => h("option", { value: box.id }, box.startup ? `${box.name} – ${t("log.cannot_start")}` : box.name)));
|
|
1299
|
+
$("log-source").value = logBoxes.some((box) => box.id === chosen) ? chosen : "";
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function logFilters() {
|
|
1303
|
+
const params = new URLSearchParams({ box: $("log-source").value, level: $("log-level").value });
|
|
1304
|
+
for (const [key, id] of [["component", "log-component"], ["version", "log-version"], ["code", "log-code"],
|
|
1305
|
+
["text", "log-text"]]) {
|
|
1306
|
+
if ($(id).value.trim()) params.set(key, $(id).value.trim());
|
|
1307
|
+
}
|
|
1308
|
+
if ($("log-since").value) params.set("since", new Date($("log-since").value).toISOString().replace(/\.\d+Z$/, "Z"));
|
|
1309
|
+
return params;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function fillChoice(id, values, everything) {
|
|
1313
|
+
const chosen = $(id).value;
|
|
1314
|
+
$(id).replaceChildren(h("option", { value: "" }, everything), ...values.map((value) => h("option", { value }, value)));
|
|
1315
|
+
$(id).value = values.includes(chosen) ? chosen : "";
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
async function loadBoxLog() {
|
|
1319
|
+
$("log-box").hidden = false;
|
|
1320
|
+
$("log-follow-label").hidden = $("log-download").hidden = true;
|
|
1321
|
+
const box = logBoxes.find((entry) => entry.id === $("log-source").value);
|
|
1322
|
+
const startup = box && box.startup;
|
|
1323
|
+
$("log-startup").hidden = !startup;
|
|
1324
|
+
if (startup) {
|
|
1325
|
+
$("log-startup-text").textContent = t("log.startup", { since: when(startup.since || startup.received),
|
|
1326
|
+
n: startup.failures, version: startup.version || "?", slot: startup.slot || "?" })
|
|
1327
|
+
+ (startup.recovery ? ` ${t("log.recovery", { problem: startup.recovery })}` : "");
|
|
1328
|
+
$("log-startup-error").textContent = startup.error || "";
|
|
1329
|
+
}
|
|
1330
|
+
const params = logFilters();
|
|
1331
|
+
params.set("limit", "1000");
|
|
1332
|
+
const result = await run(() => api("GET", `/api/library/diagnostics?${params}`));
|
|
1333
|
+
if (!result) return;
|
|
1334
|
+
fillChoice("log-component", result.choices.component, t("log.component.all"));
|
|
1335
|
+
fillChoice("log-version", result.choices.version, t("log.version.all"));
|
|
1336
|
+
fillChoice("log-code", result.choices.code, t("log.code.all"));
|
|
1337
|
+
logRecords = result.records;
|
|
1338
|
+
const list = $("log-list");
|
|
1339
|
+
list.replaceChildren(...(logRecords.length ? logRecords.map((record) => h("div",
|
|
1340
|
+
{ class: `log-entry log-${record.level.toLowerCase()}`, title: `${record.code} – ${record.version}` },
|
|
1341
|
+
h("span", { class: "log-time" }, new Date(record.time).toLocaleString()),
|
|
1342
|
+
h("span", { class: "log-level" }, record.level),
|
|
1343
|
+
h("span", { class: "log-logger" }, record.component),
|
|
1344
|
+
h("span", { class: "log-message" }, record.message + (record.repeated ? ` (×${record.repeated})` : ""),
|
|
1345
|
+
record.params ? h("span", { class: "muted" }, ` ${JSON.stringify(record.params)}`) : "",
|
|
1346
|
+
record.traceback ? h("details", {}, h("summary", {}, record.exception || t("log.traceback")),
|
|
1347
|
+
h("pre", {}, record.traceback)) : ""))) : [h("p", { class: "muted" }, t("log.none"))]));
|
|
1348
|
+
list.scrollTop = list.scrollHeight;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const logText = (records) => records.map((record) => [record.time, record.level, record.component, record.version,
|
|
1352
|
+
record.code, record.message + (record.traceback ? `\n${record.traceback}` : "")].join("\t")).join("\n");
|
|
1353
|
+
|
|
1354
|
+
$("log-copy").addEventListener("click", async () => {
|
|
1355
|
+
try {
|
|
1356
|
+
await navigator.clipboard.writeText(logText(logRecords));
|
|
1357
|
+
toast(t("log.copied", { n: logRecords.length }));
|
|
1358
|
+
} catch {
|
|
1359
|
+
window.getSelection().selectAllChildren($("log-list")); // copied by hand then
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
$("log-export").addEventListener("click", async () => {
|
|
1363
|
+
const response = await fetch(`/api/library/diagnostics/export?${logFilters()}`,
|
|
1364
|
+
{ credentials: "same-origin", headers: requestHeaders() });
|
|
1365
|
+
if (!response.ok) return toast(t("log.export_failed"), true);
|
|
1366
|
+
const link = h("a", { href: URL.createObjectURL(await response.blob()),
|
|
1367
|
+
download: `diagnostics-${$("log-source").value}.jsonl` });
|
|
1368
|
+
link.click();
|
|
1369
|
+
URL.revokeObjectURL(link.href);
|
|
1370
|
+
});
|
|
1371
|
+
// the log of this box or library as a text file: what it keeps of it (the last 5000 entries), with level and filter
|
|
1372
|
+
$("log-download").addEventListener("click", async () => {
|
|
1373
|
+
const params = new URLSearchParams({ after: 0, level: $("log-level").value, limit: 5000 });
|
|
1374
|
+
const text = $("log-text").value.trim();
|
|
1375
|
+
if (text) params.set("text", text);
|
|
1376
|
+
const result = await run(() => api("GET", `/api/logs?${params}`));
|
|
1377
|
+
if (!result) return;
|
|
1378
|
+
const lines = result.entries.map((e) => [new Date(e.time * 1000).toISOString(), e.level, e.logger, e.message]
|
|
1379
|
+
.join("\t"));
|
|
1380
|
+
const stamp = new Date().toISOString().slice(0, 19).replaceAll(":", "-");
|
|
1381
|
+
const link = h("a", { href: URL.createObjectURL(new Blob([lines.join("\n") + "\n"], { type: "text/plain" })),
|
|
1382
|
+
download: `${isLibrary() ? "library" : "tinyjukebox"}-log-${stamp}.txt` });
|
|
1383
|
+
link.click();
|
|
1384
|
+
URL.revokeObjectURL(link.href);
|
|
1385
|
+
});
|
|
1386
|
+
for (const id of ["log-source", "log-component", "log-version", "log-code", "log-since"]) {
|
|
1387
|
+
$(id).addEventListener("change", () => loadLog(true));
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
$("log-level").addEventListener("change", () => loadLog(true));
|
|
1391
|
+
let logFilterTimer = null;
|
|
1392
|
+
$("log-text").addEventListener("input", () => {
|
|
1393
|
+
clearTimeout(logFilterTimer);
|
|
1394
|
+
logFilterTimer = setTimeout(() => loadLog(true), 300);
|
|
1395
|
+
});
|
|
1396
|
+
$("tags-unmapped").addEventListener("change", loadTags);
|
|
1397
|
+
|
|
1398
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1399
|
+
// albums
|
|
1400
|
+
|
|
1401
|
+
// what the list is sorted by: the value of an album (null: at the end, in both directions). Albums with the same value
|
|
1402
|
+
// stay in the order of their titles. A click on a column heading sorts by it, a second click turns the order round.
|
|
1403
|
+
const collator = new Intl.Collator(undefined, { numeric: true });
|
|
1404
|
+
const ALBUM_SORTS = {
|
|
1405
|
+
title: (a) => a.title,
|
|
1406
|
+
tracks: (a) => a.track_count,
|
|
1407
|
+
length: (a) => a.length,
|
|
1408
|
+
tag: (a) => a.tags.map((tag) => tag.name).filter(Boolean).sort(collator.compare)[0] ?? null,
|
|
1409
|
+
created: (a) => Date.parse(a.created) || null,
|
|
1410
|
+
folder: (a) => a.folder,
|
|
1411
|
+
};
|
|
1412
|
+
const ALBUMS_DESCENDING_FIRST = ["created", "tracks"]; // the first click on these shows the highest value first
|
|
1413
|
+
let albumsShown = [];
|
|
1414
|
+
let albumsSort = { key: "title", descending: false };
|
|
1415
|
+
|
|
1416
|
+
function sortedAlbums() {
|
|
1417
|
+
const value = ALBUM_SORTS[albumsSort.key];
|
|
1418
|
+
const direction = albumsSort.descending ? -1 : 1;
|
|
1419
|
+
return albumsShown.map((album) => [value(album), album]).sort(([x, a], [y, b]) => {
|
|
1420
|
+
if (x === null || y === null) return (x === null) - (y === null) || collator.compare(a.title, b.title);
|
|
1421
|
+
const order = typeof x === "number" ? x - y : collator.compare(x, y);
|
|
1422
|
+
return direction * order || (albumsSort.key === "created" ? 0 : collator.compare(a.title, b.title));
|
|
1423
|
+
}).map(([, album]) => album);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function showAlbums() {
|
|
1427
|
+
for (const head of $("albums-head").querySelectorAll("th[data-sort]")) {
|
|
1428
|
+
if (head.dataset.sort === albumsSort.key) head.setAttribute("aria-sort", albumsSort.descending ? "descending" : "ascending");
|
|
1429
|
+
else head.removeAttribute("aria-sort");
|
|
1430
|
+
}
|
|
1431
|
+
const list = $("albums-list");
|
|
1432
|
+
list.replaceChildren(...sortedAlbums().map((album) => h("tr", {},
|
|
1433
|
+
h("td", { class: "thumb-cell" }, h("div", { class: "thumb" }, coverElement(album.id))),
|
|
1434
|
+
h("td", {}, h("button", { class: "link album-open", onclick: () => openAlbum(album.id) }, album.title),
|
|
1435
|
+
album.broken ? h("span", { class: "badge badge-error small", title: t("albums.broken.title") }, t("albums.broken")) : ""),
|
|
1436
|
+
h("td", {}, String(album.track_count)),
|
|
1437
|
+
h("td", {}, album.length ? duration(Math.round(album.length)) : "–"),
|
|
1438
|
+
h("td", { class: "small" }, album.tags.length ? album.tags.map((tag) => tag.name || tag.uid).join(", ") : t("common.no_tag")),
|
|
1439
|
+
h("td", { class: "small" }, new Date(album.created).toLocaleDateString()),
|
|
1440
|
+
h("td", { class: "small" }, h("code", {}, album.folder)))));
|
|
1441
|
+
if (!albumsShown.length) list.append(h("tr", {}, h("td", { colSpan: 7, class: "muted" }, t("albums.none"))));
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function loadAlbums() {
|
|
1445
|
+
const unmapped = $("albums-unmapped").checked;
|
|
1446
|
+
const albums = await run(() => api("GET", `/api/albums${unmapped ? "?unmapped=1" : ""}`));
|
|
1447
|
+
if (!albums) return;
|
|
1448
|
+
albumsShown = albums;
|
|
1449
|
+
showAlbums();
|
|
1450
|
+
}
|
|
1451
|
+
$("albums-unmapped").addEventListener("change", loadAlbums);
|
|
1452
|
+
|
|
1453
|
+
for (const head of $("albums-head").querySelectorAll("th[data-sort]")) {
|
|
1454
|
+
const sort = () => {
|
|
1455
|
+
const key = head.dataset.sort;
|
|
1456
|
+
albumsSort = { key, descending: albumsSort.key === key ? !albumsSort.descending : ALBUMS_DESCENDING_FIRST.includes(key) };
|
|
1457
|
+
showAlbums();
|
|
1458
|
+
};
|
|
1459
|
+
head.tabIndex = 0;
|
|
1460
|
+
head.addEventListener("click", sort);
|
|
1461
|
+
head.addEventListener("keydown", (event) => {
|
|
1462
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1463
|
+
event.preventDefault();
|
|
1464
|
+
sort();
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
let dialogAlbum = null;
|
|
1469
|
+
async function openAlbum(id) {
|
|
1470
|
+
const album = await run(() => api("GET", `/api/albums/${encodeURIComponent(id)}`));
|
|
1471
|
+
if (!album) return;
|
|
1472
|
+
dialogAlbum = album;
|
|
1473
|
+
$("dlg-cover").replaceChildren(coverElement(album.id));
|
|
1474
|
+
$("dlg-title").replaceChildren(session.role === "admin" ? albumTitleInput(album) : album.title);
|
|
1475
|
+
$("dlg-meta").textContent = [album.artist, t("album.folder", { folder: album.folder }),
|
|
1476
|
+
t("albums.tracks", { n: album.track_count }), formatTime(album.length),
|
|
1477
|
+
t("album.added", { date: new Date(album.created).toLocaleDateString() })].filter(Boolean).join(" · ");
|
|
1478
|
+
$("dlg-tags").replaceChildren(...tagChips(album.tags, () => { openAlbum(album.id); loadAlbums(); }));
|
|
1479
|
+
$("dlg-tracks").replaceChildren(...album.tracks.map((track) => h("li", {},
|
|
1480
|
+
h("span", { class: "track-name" }, track.title || track.filename,
|
|
1481
|
+
track.missing ? h("span", { class: "danger-text" }, ` ${t("album.track_missing")}`) : ""),
|
|
1482
|
+
h("span", { class: "muted small" }, formatTime(track.length)))));
|
|
1483
|
+
$("dlg-delete-files").checked = false;
|
|
1484
|
+
$("dlg-problems").hidden = !album.problems.length;
|
|
1485
|
+
$("dlg-problem-list").replaceChildren(...album.problems.slice(0, 8).map((p) => h("li", {}, p)),
|
|
1486
|
+
album.problems.length > 8 ? h("li", {}, t("album.more_problems", { n: album.problems.length - 8 })) : "");
|
|
1487
|
+
// library: files that boxes found damaged (what the library found itself is among the problems above)
|
|
1488
|
+
const reports = (album.broken || []).flatMap((found) => found.boxes.map((report) => h("li", { title: report.error },
|
|
1489
|
+
t("album.broken.report", { file: found.file, box: report.name, date: new Date(report.when).toLocaleDateString() }))));
|
|
1490
|
+
$("dlg-broken").hidden = !reports.length;
|
|
1491
|
+
$("dlg-broken-list").replaceChildren(...reports);
|
|
1492
|
+
if (!$("album-dialog").open) $("album-dialog").showModal();
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// input that changes the title of an album (admins)
|
|
1496
|
+
function albumTitleInput(album) {
|
|
1497
|
+
const input = h("input", { value: album.title, class: "inline-input title-input", maxLength: 200, "aria-label": t("common.title") });
|
|
1498
|
+
input.addEventListener("change", async () => {
|
|
1499
|
+
const saved = await run(() => api("PATCH", `/api/albums/${encodeURIComponent(album.id)}`, { title: input.value }),
|
|
1500
|
+
t("album.title_saved"));
|
|
1501
|
+
if (saved) {
|
|
1502
|
+
album.title = saved.title;
|
|
1503
|
+
loadTargets();
|
|
1504
|
+
if (currentTab === "albums") loadAlbums();
|
|
1505
|
+
}
|
|
1506
|
+
input.value = album.title;
|
|
1507
|
+
});
|
|
1508
|
+
return input;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
$("dlg-cover-file").addEventListener("change", async (event) => {
|
|
1512
|
+
const file = event.target.files[0];
|
|
1513
|
+
event.target.value = "";
|
|
1514
|
+
if (!file || !(await uploadCover(dialogAlbum.id, file))) return;
|
|
1515
|
+
toast(t("cover.saved"));
|
|
1516
|
+
$("dlg-cover").replaceChildren(coverElement(dialogAlbum.id));
|
|
1517
|
+
if (currentTab === "albums") loadAlbums();
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
$("dlg-playlist").addEventListener("click", async () => {
|
|
1521
|
+
if (!confirm(t("album.playlist.confirm"))) return;
|
|
1522
|
+
const album = await run(() => api("POST", `/api/albums/${encodeURIComponent(dialogAlbum.id)}/playlist`), t("album.playlist.done"));
|
|
1523
|
+
if (album) openAlbum(album.id);
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
$("dlg-broken-forget").addEventListener("click", async () => {
|
|
1527
|
+
const album = await run(() => api("DELETE", `/api/library/albums/${encodeURIComponent(dialogAlbum.id)}/broken`),
|
|
1528
|
+
t("album.broken.forgotten"));
|
|
1529
|
+
if (!album) return;
|
|
1530
|
+
openAlbum(album.id);
|
|
1531
|
+
if (currentTab === "albums") loadAlbums();
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
$("dlg-delete").addEventListener("click", async () => {
|
|
1535
|
+
const album = dialogAlbum;
|
|
1536
|
+
const deleteFiles = $("dlg-delete-files").checked;
|
|
1537
|
+
if (!confirm(t(deleteFiles ? "album.delete.confirm_files" : "album.delete.confirm", { title: album.title }))) return;
|
|
1538
|
+
const result = await run(() => api("DELETE",
|
|
1539
|
+
`/api/albums/${encodeURIComponent(album.id)}${deleteFiles ? "?delete_files=1" : ""}`), t("album.removed"));
|
|
1540
|
+
if (result !== undefined) {
|
|
1541
|
+
$("album-dialog").close();
|
|
1542
|
+
loadAlbums();
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
$("albums-scan").addEventListener("click", async () => {
|
|
1547
|
+
const found = await run(() => api("GET", "/api/albums/new"));
|
|
1548
|
+
if (!found) return;
|
|
1549
|
+
if (!found.length) return toast(t("albums.scan.none"));
|
|
1550
|
+
const lines = found.map((a) => a.title === a.folder ? a.folder : t("albums.scan.line", { folder: a.folder, title: a.title }));
|
|
1551
|
+
if (!confirm(t("albums.scan.confirm", { n: found.length, folders: lines.join("\n") }))) return;
|
|
1552
|
+
const result = await run(() => api("POST", "/api/albums/new",
|
|
1553
|
+
{ titles: Object.fromEntries(found.map((a) => [a.folder, a.title])) }));
|
|
1554
|
+
if (!result) return;
|
|
1555
|
+
toast(t(result.skipped.length ? "albums.scan.added_skipped" : "albums.scan.added",
|
|
1556
|
+
{ n: result.added.length, skipped: result.skipped.length }), result.skipped.length > 0);
|
|
1557
|
+
loadAlbums();
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
$("album-add").addEventListener("submit", async (event) => {
|
|
1561
|
+
event.preventDefault();
|
|
1562
|
+
const form = event.target;
|
|
1563
|
+
const album = await run(() => api("POST", "/api/albums", { folder: form.folder.value, title: form.title.value }),
|
|
1564
|
+
t("albums.add.done"));
|
|
1565
|
+
if (album) {
|
|
1566
|
+
form.reset();
|
|
1567
|
+
loadAlbums();
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1572
|
+
// radio stations
|
|
1573
|
+
|
|
1574
|
+
async function loadRadios() {
|
|
1575
|
+
const radios = await run(() => api("GET", "/api/radios"));
|
|
1576
|
+
if (!radios) return;
|
|
1577
|
+
radiosCache = radios;
|
|
1578
|
+
const isAdmin = session.role === "admin";
|
|
1579
|
+
const tagNames = (radio) => radio.tags.length ? radio.tags.map((tag) => tag.name || tag.uid).join(", ") : t("common.no_tag");
|
|
1580
|
+
$("radios-body").replaceChildren(...radios.map((radio) => {
|
|
1581
|
+
const tagsCell = h("td", {}, h("div", { class: "tag-chips" }, ...tagChips(radio.tags, loadRadios)));
|
|
1582
|
+
if (!isAdmin) {
|
|
1583
|
+
return h("tr", {}, h("td", {}, h("div", { class: "thumb" }, radioCoverElement(radio))),
|
|
1584
|
+
h("td", {}, radio.name), h("td", { class: "radio-url small" }, radio.url), tagsCell, h("td", {}));
|
|
1585
|
+
}
|
|
1586
|
+
const file = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp", hidden: true });
|
|
1587
|
+
file.addEventListener("change", async () => {
|
|
1588
|
+
if (!file.files[0] || !(await sendCover(`/api/radios/${encodeURIComponent(radio.id)}`, file.files[0]))) return;
|
|
1589
|
+
toast(t("cover.saved"));
|
|
1590
|
+
loadRadios();
|
|
1591
|
+
});
|
|
1592
|
+
const cover = h("label", { class: "button thumb", title: t("cover.change") }, radioCoverElement(radio), file);
|
|
1593
|
+
const name = h("input", { value: radio.name, class: "inline-input", maxLength: 100, "aria-label": t("common.name") });
|
|
1594
|
+
const url = h("input", { type: "url", value: radio.url, class: "inline-input", maxLength: 2000, "aria-label": t("radio.column.url") });
|
|
1595
|
+
const save = h("button", { class: "primary", disabled: true, onclick: async () => {
|
|
1596
|
+
const result = await run(() => api("PATCH", `/api/radios/${encodeURIComponent(radio.id)}`,
|
|
1597
|
+
{ name: name.value, url: url.value }), t("radio.saved"));
|
|
1598
|
+
if (result) loadRadios();
|
|
1599
|
+
} }, t("common.save"));
|
|
1600
|
+
const test = h("button", { class: "box-only", onclick: () => testRadio(url.value, name.value) }, t("radio.test"));
|
|
1601
|
+
const changed = () => { save.disabled = name.value === radio.name && url.value === radio.url; };
|
|
1602
|
+
name.addEventListener("input", changed);
|
|
1603
|
+
url.addEventListener("input", changed);
|
|
1604
|
+
const remove = h("button", { class: "link danger-text", onclick: async () => {
|
|
1605
|
+
if (radio.tags.length) {
|
|
1606
|
+
return toast(t("radio.remove.linked", { name: radio.name, tags: tagNames(radio) }), true);
|
|
1607
|
+
}
|
|
1608
|
+
if (!confirm(t("radio.remove.confirm", { name: radio.name }))) return;
|
|
1609
|
+
const result = await run(() => api("DELETE", `/api/radios/${encodeURIComponent(radio.id)}`), t("radio.removed"));
|
|
1610
|
+
if (result !== undefined) loadRadios();
|
|
1611
|
+
} }, t("common.delete"));
|
|
1612
|
+
return h("tr", {}, h("td", {}, cover), h("td", {}, name), h("td", {}, url), tagsCell,
|
|
1613
|
+
h("td", { class: "radio-actions" }, test, save, remove));
|
|
1614
|
+
}));
|
|
1615
|
+
if (!radios.length) $("radios-body").append(h("tr", {}, h("td", { colSpan: 5, class: "muted" }, t("radio.none"))));
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// plays the entered url for a minute (only while no tag is on the box) - the state comes with the status events
|
|
1619
|
+
async function testRadio(url, name) {
|
|
1620
|
+
const result = await run(() => api("POST", "/api/radio-test", { url, name: name || undefined }));
|
|
1621
|
+
if (result) setTimeout(refreshStatus, 300);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
$("radio-new-test").addEventListener("click", () => {
|
|
1625
|
+
const form = $("radio-new");
|
|
1626
|
+
testRadio(form.url.value, form.name.value);
|
|
1627
|
+
});
|
|
1628
|
+
$("radio-test-stop").addEventListener("click", async () => {
|
|
1629
|
+
await run(() => api("DELETE", "/api/radio-test"));
|
|
1630
|
+
setTimeout(refreshStatus, 300);
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
$("radio-new").addEventListener("submit", async (event) => {
|
|
1634
|
+
event.preventDefault();
|
|
1635
|
+
const form = event.target;
|
|
1636
|
+
const radio = await run(() => api("POST", "/api/radios", { name: form.name.value, url: form.url.value }));
|
|
1637
|
+
if (radio) {
|
|
1638
|
+
toast(t("radio.created", { name: radio.name }));
|
|
1639
|
+
form.reset();
|
|
1640
|
+
loadRadios();
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1645
|
+
// entries of Spotify
|
|
1646
|
+
|
|
1647
|
+
async function loadSpotify() {
|
|
1648
|
+
const entries = await run(() => api("GET", "/api/spotify"));
|
|
1649
|
+
if (!entries) return;
|
|
1650
|
+
spotifyCache = entries;
|
|
1651
|
+
const isAdmin = session.role === "admin";
|
|
1652
|
+
const tagNames = (entry) => entry.tags.length ? entry.tags.map((tag) => tag.name || tag.uid).join(", ") : t("common.no_tag");
|
|
1653
|
+
const kind = (entry) => tOf("spotify.kind", entry.kind);
|
|
1654
|
+
const linkElement = (entry) => h("a", { href: entry.link, target: "_blank", rel: "noopener", class: "small" }, entry.link);
|
|
1655
|
+
$("spotify-body").replaceChildren(...entries.map((entry) => {
|
|
1656
|
+
const tagsCell = h("td", {}, h("div", { class: "tag-chips" }, ...tagChips(entry.tags, loadSpotify)));
|
|
1657
|
+
if (!isAdmin) {
|
|
1658
|
+
return h("tr", {}, h("td", {}, h("div", { class: "thumb" }, spotifyCoverElement(entry))),
|
|
1659
|
+
h("td", {}, entry.name), h("td", {}, kind(entry)), h("td", { class: "radio-url" }, linkElement(entry)), tagsCell, h("td", {}));
|
|
1660
|
+
}
|
|
1661
|
+
const path = `/api/spotify/${encodeURIComponent(entry.id)}`;
|
|
1662
|
+
const file = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp", hidden: true });
|
|
1663
|
+
file.addEventListener("change", async () => {
|
|
1664
|
+
if (!file.files[0] || !(await sendCover(path, file.files[0]))) return;
|
|
1665
|
+
toast(t("cover.saved"));
|
|
1666
|
+
loadSpotify();
|
|
1667
|
+
});
|
|
1668
|
+
const cover = h("label", { class: "button thumb", title: t("cover.change") }, spotifyCoverElement(entry), file);
|
|
1669
|
+
const name = h("input", { value: entry.name, class: "inline-input", maxLength: 100, "aria-label": t("common.name") });
|
|
1670
|
+
const link = h("input", { value: entry.link, class: "inline-input", maxLength: 2000, "aria-label": t("spotify.column.link") });
|
|
1671
|
+
const save = h("button", { class: "primary", disabled: true, onclick: async () => {
|
|
1672
|
+
const result = await run(() => api("PATCH", path, { name: name.value, link: link.value }), t("spotify.saved"));
|
|
1673
|
+
if (result) loadSpotify();
|
|
1674
|
+
} }, t("common.save"));
|
|
1675
|
+
const changed = () => { save.disabled = name.value === entry.name && link.value === entry.link; };
|
|
1676
|
+
name.addEventListener("input", changed);
|
|
1677
|
+
link.addEventListener("input", changed);
|
|
1678
|
+
const remove = h("button", { class: "link danger-text", onclick: async () => {
|
|
1679
|
+
if (entry.tags.length) {
|
|
1680
|
+
return toast(t("spotify.remove.linked", { name: entry.name, tags: tagNames(entry) }), true);
|
|
1681
|
+
}
|
|
1682
|
+
if (!confirm(t("spotify.remove.confirm", { name: entry.name }))) return;
|
|
1683
|
+
const result = await run(() => api("DELETE", path), t("spotify.removed"));
|
|
1684
|
+
if (result !== undefined) loadSpotify();
|
|
1685
|
+
} }, t("common.delete"));
|
|
1686
|
+
const test = h("button", { class: "box-only", onclick: () => testSpotify({ id: entry.id }) }, t("radio.test"));
|
|
1687
|
+
return h("tr", {}, h("td", {}, cover), h("td", {}, name), h("td", {}, kind(entry)), h("td", {}, link), tagsCell,
|
|
1688
|
+
h("td", { class: "radio-actions" }, test, save, remove));
|
|
1689
|
+
}));
|
|
1690
|
+
if (!entries.length) $("spotify-body").append(h("tr", {}, h("td", { colSpan: 6, class: "muted" }, t("spotify.none"))));
|
|
1691
|
+
loadSpotifyAccount();
|
|
1692
|
+
loadSpotifyApp();
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// the library: the client id of the app for phones at Spotify, which every phone gets (app.md, section 3)
|
|
1696
|
+
async function loadSpotifyApp() {
|
|
1697
|
+
if (!isLibrary() || !session || session.role !== "admin") return;
|
|
1698
|
+
const overview = await run(() => api("GET", "/api/library/overview"));
|
|
1699
|
+
if (overview) $("spotify-app-form").client.value = (overview.app || {}).spotify_client || "";
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
$("spotify-app-form").addEventListener("submit", async (event) => {
|
|
1703
|
+
event.preventDefault();
|
|
1704
|
+
await run(() => api("PUT", "/api/library/spotify-client", { client: event.target.client.value.trim() }), t("common.saved"));
|
|
1705
|
+
loadSpotifyApp();
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
// the cards "Account" and "Player" (a box, admins): while the box waits to be chosen in the Spotify app or fetches
|
|
1709
|
+
// the player, the page asks every two seconds
|
|
1710
|
+
let spotifyTimer = null;
|
|
1711
|
+
|
|
1712
|
+
async function loadSpotifyAccount() {
|
|
1713
|
+
if (isLibrary() || !session || session.role !== "admin") return;
|
|
1714
|
+
const [account, player] = await Promise.all([run(() => api("GET", "/api/spotify-account")),
|
|
1715
|
+
run(() => api("GET", "/api/spotify-player"))]);
|
|
1716
|
+
if (!account || !player) return;
|
|
1717
|
+
const since = account.connected_since ? new Date(account.connected_since).toLocaleString() : "";
|
|
1718
|
+
const login = account.connecting ? t("spotify.account.state.connecting")
|
|
1719
|
+
: account.connected ? t("spotify.account.state.connected", { since })
|
|
1720
|
+
: account.connect_result && account.connect_result !== "connected" ? tOf("spotify.account.result", account.connect_result)
|
|
1721
|
+
: t("spotify.account.state.none");
|
|
1722
|
+
$("spotify-account-state").textContent = [account.key ? t("spotify.account.key_present") : t("spotify.account.key_missing"),
|
|
1723
|
+
login].join(" ");
|
|
1724
|
+
$("spotify-connect").disabled = !account.key || account.connecting || player.state !== "ready";
|
|
1725
|
+
$("spotify-disconnect").disabled = !account.connected || account.connecting;
|
|
1726
|
+
$("spotify-speaker").checked = account.speaker;
|
|
1727
|
+
const day = (stamp) => stamp ? new Date(stamp).toLocaleDateString() : "–";
|
|
1728
|
+
$("spotify-player-state").textContent = [tOf("spotify.player.state", player.state) + (player.error ? ` (${player.error})` : ""),
|
|
1729
|
+
player.built ? t("spotify.player.built", { built: day(player.built), last_day: day(player.last_day), renew_from: day(player.renew_from) }) : ""]
|
|
1730
|
+
.filter(Boolean).join(" ");
|
|
1731
|
+
$("spotify-player-asked").textContent = player.asked
|
|
1732
|
+
? t("spotify.player.asked", { time: new Date(player.asked).toLocaleString(), result: tOf("spotify.player.result", player.result) })
|
|
1733
|
+
: t("spotify.player.never");
|
|
1734
|
+
$("spotify-player-missing").hidden = !player.missing.length;
|
|
1735
|
+
$("spotify-player-missing").textContent = t("spotify.player.missing", { packages: player.missing.join(", ") });
|
|
1736
|
+
$("spotify-update").disabled = !account.key || player.state === "fetching";
|
|
1737
|
+
clearTimeout(spotifyTimer);
|
|
1738
|
+
spotifyTimer = (account.connecting || player.state === "fetching") && currentTab === "spotify"
|
|
1739
|
+
? setTimeout(loadSpotifyAccount, 2000) : null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
$("spotify-key").addEventListener("submit", async (event) => {
|
|
1743
|
+
event.preventDefault();
|
|
1744
|
+
const form = event.target;
|
|
1745
|
+
if (await run(() => api("PUT", "/api/spotify-account/key", { key: form.key.value }), t("spotify.account.key_saved"))) {
|
|
1746
|
+
form.reset();
|
|
1747
|
+
loadSpotifyAccount();
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
$("spotify-connect").addEventListener("click", async () => {
|
|
1751
|
+
if (await run(() => api("POST", "/api/spotify-account"), t("spotify.account.choose"))) loadSpotifyAccount();
|
|
1752
|
+
});
|
|
1753
|
+
$("spotify-disconnect").addEventListener("click", async () => {
|
|
1754
|
+
if (!confirm(t("spotify.account.disconnect.confirm"))) return;
|
|
1755
|
+
if (await run(() => api("DELETE", "/api/spotify-account"))) loadSpotifyAccount();
|
|
1756
|
+
});
|
|
1757
|
+
$("spotify-speaker").addEventListener("change", async (event) => {
|
|
1758
|
+
await run(() => api("PUT", "/api/spotify-account/speaker", { enabled: event.target.checked }));
|
|
1759
|
+
loadSpotifyAccount();
|
|
1760
|
+
});
|
|
1761
|
+
$("spotify-update").addEventListener("click", async () => {
|
|
1762
|
+
if (await run(() => api("POST", "/api/spotify-player"), t("spotify.player.updating"))) loadSpotifyAccount();
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
// plays an entry for a minute (only while no tag is on the box) - the state comes with the status events
|
|
1766
|
+
async function testSpotify(what) {
|
|
1767
|
+
const result = await run(() => api("POST", "/api/spotify-test", what));
|
|
1768
|
+
if (result) setTimeout(refreshStatus, 300);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
$("spotify-new-test").addEventListener("click", () => testSpotify({ link: $("spotify-new").link.value }));
|
|
1772
|
+
$("spotify-test-stop").addEventListener("click", async () => {
|
|
1773
|
+
await run(() => api("DELETE", "/api/spotify-test"));
|
|
1774
|
+
setTimeout(refreshStatus, 300);
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
// a pasted link: Spotify is asked for the name, which fills the field if nobody typed one
|
|
1778
|
+
$("spotify-new").link.addEventListener("change", async () => {
|
|
1779
|
+
const form = $("spotify-new");
|
|
1780
|
+
if (!form.link.value.trim() || form.name.value.trim()) return;
|
|
1781
|
+
const found = await run(() => api("POST", "/api/spotify-lookup", { link: form.link.value }));
|
|
1782
|
+
if (found && found.name && !form.name.value.trim()) form.name.value = found.name;
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
$("spotify-new").addEventListener("submit", async (event) => {
|
|
1786
|
+
event.preventDefault();
|
|
1787
|
+
const form = event.target;
|
|
1788
|
+
const entry = await run(() => api("POST", "/api/spotify", { link: form.link.value, name: form.name.value || undefined }));
|
|
1789
|
+
if (entry) {
|
|
1790
|
+
toast(t("spotify.created", { name: entry.name }));
|
|
1791
|
+
form.reset();
|
|
1792
|
+
loadSpotify();
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
1797
|
+
// upload
|
|
1798
|
+
|
|
1799
|
+
let upload = null; // {id, folder}
|
|
1800
|
+
let uploadQueue = []; // [{file, name, state, progress, info, li}]
|
|
1801
|
+
let uploading = false;
|
|
1802
|
+
|
|
1803
|
+
async function loadUpload() {
|
|
1804
|
+
loadUploadTags();
|
|
1805
|
+
const pending = (await run(() => api("GET", "/api/uploads"))) || [];
|
|
1806
|
+
const others = pending.filter((p) => !upload || p.id !== upload.id);
|
|
1807
|
+
$("pending-uploads").hidden = others.length === 0;
|
|
1808
|
+
$("pending-list").replaceChildren(...others.map((p) => h("li", {},
|
|
1809
|
+
h("span", { class: "pending-name" }, p.folder),
|
|
1810
|
+
h("span", { class: "muted small" }, t("upload.files", { n: p.files.length })),
|
|
1811
|
+
h("button", { disabled: upload !== null, title: upload ? t("upload.pending.busy") : "",
|
|
1812
|
+
onclick: () => resumeUpload(p) }, t("upload.pending.resume")),
|
|
1813
|
+
h("button", { class: "danger", onclick: async () => {
|
|
1814
|
+
if (!confirm(t("upload.pending.discard.confirm", { folder: p.folder }))) return;
|
|
1815
|
+
await run(() => api("DELETE", `/api/uploads/${p.id}`));
|
|
1816
|
+
loadUpload();
|
|
1817
|
+
} }, t("upload.pending.discard")))));
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function resumeUpload(pending) {
|
|
1821
|
+
upload = { id: pending.id, folder: pending.folder };
|
|
1822
|
+
$("upload-name").value = pending.title || pending.folder;
|
|
1823
|
+
$("upload-name").disabled = true;
|
|
1824
|
+
const position = Object.fromEntries(pending.playlist.map((name, i) => [name, i]));
|
|
1825
|
+
uploadQueue = pending.files.map((info) => ({ file: null, name: info.filename, state: "ok", progress: 1, info }))
|
|
1826
|
+
.sort((a, b) => position[a.name] - position[b.name]);
|
|
1827
|
+
renderUploadList();
|
|
1828
|
+
loadUpload();
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
async function loadUploadTags() {
|
|
1832
|
+
const [tags, status] = await Promise.all([run(() => api("GET", "/api/tags?unmapped=1")), run(() => api("GET", "/api/status"))]);
|
|
1833
|
+
const select = $("upload-tag");
|
|
1834
|
+
const selected = select.value;
|
|
1835
|
+
const options = [h("option", { value: "" }, t("upload.link_later"))];
|
|
1836
|
+
if (status && status.tag) {
|
|
1837
|
+
options.push(h("option", { value: "current" }, t("upload.present_tag", { name: status.tag.name || status.tag.uid })));
|
|
1838
|
+
}
|
|
1839
|
+
for (const tag of tags || []) {
|
|
1840
|
+
options.push(h("option", { value: tag.uid }, `${tag.name || t("common.unnamed")} (${tag.uid})`));
|
|
1841
|
+
}
|
|
1842
|
+
select.replaceChildren(...options);
|
|
1843
|
+
select.value = selected;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function mp3Files(fileList) {
|
|
1847
|
+
return [...fileList].filter((f) => f.name.toLowerCase().endsWith(".mp3"));
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
async function addFiles(files) {
|
|
1851
|
+
files = mp3Files(files);
|
|
1852
|
+
if (!files.length) return toast(t("upload.no_mp3"), true);
|
|
1853
|
+
const nameInput = $("upload-name");
|
|
1854
|
+
if (!nameInput.value.trim()) {
|
|
1855
|
+
// folder upload: use the folder name as album name
|
|
1856
|
+
const path = files[0].webkitRelativePath || "";
|
|
1857
|
+
nameInput.value = path.includes("/") ? path.split("/")[0] : "";
|
|
1858
|
+
}
|
|
1859
|
+
if (!nameInput.value.trim()) {
|
|
1860
|
+
nameInput.focus();
|
|
1861
|
+
return toast(t("upload.title_required"), true);
|
|
1862
|
+
}
|
|
1863
|
+
if (!upload) {
|
|
1864
|
+
upload = await run(() => api("POST", "/api/uploads", { name: nameInput.value }));
|
|
1865
|
+
if (!upload) return;
|
|
1866
|
+
nameInput.value = upload.title;
|
|
1867
|
+
nameInput.disabled = true;
|
|
1868
|
+
}
|
|
1869
|
+
for (const file of files) {
|
|
1870
|
+
const existing = uploadQueue.find((entry) => entry.name === file.name);
|
|
1871
|
+
if (existing) uploadQueue.splice(uploadQueue.indexOf(existing), 1);
|
|
1872
|
+
uploadQueue.push({ file, name: file.name, state: "waiting", progress: 0, info: null });
|
|
1873
|
+
}
|
|
1874
|
+
renderUploadList();
|
|
1875
|
+
processQueue();
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function sendFile(entry) {
|
|
1879
|
+
return new Promise((resolve) => {
|
|
1880
|
+
const xhr = new XMLHttpRequest();
|
|
1881
|
+
xhr.open("PUT", `/api/uploads/${upload.id}/files/${encodeURIComponent(entry.name)}`);
|
|
1882
|
+
uploadsRunning += 1;
|
|
1883
|
+
xhr.addEventListener("loadend", () => { uploadsRunning -= 1; noteInput(); });
|
|
1884
|
+
xhr.setRequestHeader("X-TinyJukebox-Request", "1");
|
|
1885
|
+
xhr.setRequestHeader("X-TinyJukebox-Idle", "0");
|
|
1886
|
+
xhr.setRequestHeader("Content-Type", "audio/mpeg");
|
|
1887
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
1888
|
+
if (e.lengthComputable) {
|
|
1889
|
+
entry.progress = e.loaded / e.total;
|
|
1890
|
+
renderUploadEntry(entry);
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
xhr.addEventListener("load", () => {
|
|
1894
|
+
let data = null;
|
|
1895
|
+
try { data = JSON.parse(xhr.responseText); } catch { /* keep null */ }
|
|
1896
|
+
if (xhr.status === 201) {
|
|
1897
|
+
entry.state = "ok";
|
|
1898
|
+
entry.info = data;
|
|
1899
|
+
entry.name = data.filename;
|
|
1900
|
+
} else {
|
|
1901
|
+
entry.state = "failed";
|
|
1902
|
+
entry.error = errorText(data, xhr.status);
|
|
1903
|
+
if (xhr.status === 401) showLogin();
|
|
1904
|
+
}
|
|
1905
|
+
resolve();
|
|
1906
|
+
});
|
|
1907
|
+
xhr.addEventListener("error", () => {
|
|
1908
|
+
entry.state = "failed";
|
|
1909
|
+
entry.error = t("upload.connection_lost");
|
|
1910
|
+
resolve();
|
|
1911
|
+
});
|
|
1912
|
+
entry.state = "sending";
|
|
1913
|
+
xhr.send(entry.file);
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async function processQueue() {
|
|
1918
|
+
if (uploading) return;
|
|
1919
|
+
uploading = true;
|
|
1920
|
+
updateUploadButtons();
|
|
1921
|
+
let entry;
|
|
1922
|
+
while ((entry = uploadQueue.find((e) => e.state === "waiting"))) {
|
|
1923
|
+
await sendFile(entry);
|
|
1924
|
+
renderUploadEntry(entry);
|
|
1925
|
+
}
|
|
1926
|
+
uploading = false;
|
|
1927
|
+
if (!upload) return; // aborted while the files were sent
|
|
1928
|
+
// order suggested by the server: track numbers, otherwise file names
|
|
1929
|
+
const state = await run(() => api("GET", `/api/uploads/${upload.id}`));
|
|
1930
|
+
if (state) {
|
|
1931
|
+
const position = Object.fromEntries(state.playlist.map((name, i) => [name, i]));
|
|
1932
|
+
uploadQueue.sort((a, b) => (position[a.name] ?? 1e9) - (position[b.name] ?? 1e9));
|
|
1933
|
+
}
|
|
1934
|
+
renderUploadList();
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function renderUploadEntry(entry) {
|
|
1938
|
+
if (!entry.li) return;
|
|
1939
|
+
const info = entry.info;
|
|
1940
|
+
entry.li.querySelector(".upload-name").textContent = info && info.title ? `${info.title} (${entry.name})` : entry.name;
|
|
1941
|
+
entry.li.querySelector(".upload-state").textContent = entry.state === "failed" ? entry.error
|
|
1942
|
+
: entry.state === "ok" ? formatTime(info && info.length) : entry.state === "sending"
|
|
1943
|
+
? percent(Math.round(entry.progress * 100)) : t("upload.waiting");
|
|
1944
|
+
entry.li.querySelector(".progress-bar").style.width = `${Math.round((entry.state === "ok" ? 1 : entry.progress) * 100)}%`;
|
|
1945
|
+
entry.li.classList.toggle("upload-error", entry.state === "failed");
|
|
1946
|
+
entry.li.classList.toggle("upload-ok", entry.state === "ok");
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function moveEntry(entry, delta) {
|
|
1950
|
+
const index = uploadQueue.indexOf(entry);
|
|
1951
|
+
const target = index + delta;
|
|
1952
|
+
if (target < 0 || target >= uploadQueue.length) return;
|
|
1953
|
+
uploadQueue.splice(index, 1);
|
|
1954
|
+
uploadQueue.splice(target, 0, entry);
|
|
1955
|
+
renderUploadList();
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
async function removeEntry(entry) {
|
|
1959
|
+
if (entry.state === "ok") {
|
|
1960
|
+
const ok = await run(() => api("DELETE", `/api/uploads/${upload.id}/files/${encodeURIComponent(entry.name)}`));
|
|
1961
|
+
if (ok === undefined) return;
|
|
1962
|
+
}
|
|
1963
|
+
uploadQueue.splice(uploadQueue.indexOf(entry), 1);
|
|
1964
|
+
renderUploadList();
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function renderUploadList() {
|
|
1968
|
+
$("upload-list").replaceChildren(...uploadQueue.map((entry) => {
|
|
1969
|
+
entry.li = h("li", { class: "upload-entry" },
|
|
1970
|
+
h("div", { class: "upload-row" },
|
|
1971
|
+
h("span", { class: "upload-name" }),
|
|
1972
|
+
h("span", { class: "upload-state muted small" }),
|
|
1973
|
+
h("span", { class: "upload-actions" },
|
|
1974
|
+
h("button", { class: "link", title: t("upload.move_up"), "aria-label": t("upload.move_up"), onclick: () => moveEntry(entry, -1) }, "↑"),
|
|
1975
|
+
h("button", { class: "link", title: t("upload.move_down"), "aria-label": t("upload.move_down"), onclick: () => moveEntry(entry, 1) }, "↓"),
|
|
1976
|
+
h("button", { class: "link danger-text", title: t("upload.remove"), "aria-label": t("upload.remove"), onclick: () => removeEntry(entry) }, "✕"))),
|
|
1977
|
+
h("div", { class: "progress progress-thin" }, h("div", { class: "progress-bar" })));
|
|
1978
|
+
renderUploadEntry(entry);
|
|
1979
|
+
return entry.li;
|
|
1980
|
+
}));
|
|
1981
|
+
updateUploadButtons();
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function updateUploadButtons() {
|
|
1985
|
+
const done = uploadQueue.filter((e) => e.state === "ok").length;
|
|
1986
|
+
$("upload-finish").disabled = uploading || done === 0 || uploadQueue.some((e) => e.state !== "ok");
|
|
1987
|
+
$("upload-abort").disabled = !upload;
|
|
1988
|
+
$("upload-message").textContent = uploadQueue.length ? t("upload.progress", { done, n: uploadQueue.length }) : "";
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function resetUpload() {
|
|
1992
|
+
upload = null;
|
|
1993
|
+
uploadQueue = [];
|
|
1994
|
+
$("upload-name").disabled = false;
|
|
1995
|
+
$("upload-name").value = "";
|
|
1996
|
+
$("upload-cover").value = "";
|
|
1997
|
+
renderUploadList();
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
$("upload-files").addEventListener("change", (e) => { addFiles(e.target.files); e.target.value = ""; });
|
|
2001
|
+
$("upload-folder").addEventListener("change", (e) => { addFiles(e.target.files); e.target.value = ""; });
|
|
2002
|
+
|
|
2003
|
+
const dropzone = $("dropzone");
|
|
2004
|
+
dropzone.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dragging"); });
|
|
2005
|
+
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragging"));
|
|
2006
|
+
dropzone.addEventListener("drop", (e) => {
|
|
2007
|
+
e.preventDefault();
|
|
2008
|
+
dropzone.classList.remove("dragging");
|
|
2009
|
+
addFiles(e.dataTransfer.files);
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
$("upload-finish").addEventListener("click", async () => {
|
|
2013
|
+
const tag = $("upload-tag").value;
|
|
2014
|
+
const result = await run(() => api("POST", `/api/uploads/${upload.id}/finish`, {
|
|
2015
|
+
order: uploadQueue.map((e) => e.name), tag_uid: tag || undefined,
|
|
2016
|
+
}));
|
|
2017
|
+
if (!result) return;
|
|
2018
|
+
const cover = $("upload-cover").files[0];
|
|
2019
|
+
if (cover) await uploadCover(result.album.id, cover); // a refused image is reported; the album is there
|
|
2020
|
+
toast(t(result.tag ? "upload.created_linked" : "upload.created", { title: result.album.title }));
|
|
2021
|
+
resetUpload();
|
|
2022
|
+
loadUpload();
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
$("upload-abort").addEventListener("click", async () => {
|
|
2026
|
+
if (!confirm(t("upload.abort.confirm"))) return;
|
|
2027
|
+
await run(() => api("DELETE", `/api/uploads/${upload.id}`));
|
|
2028
|
+
resetUpload();
|
|
2029
|
+
loadUpload();
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
2033
|
+
// the library as a box sees it (tab "System")
|
|
2034
|
+
|
|
2035
|
+
async function loadLibrary() {
|
|
2036
|
+
let info;
|
|
2037
|
+
try {
|
|
2038
|
+
info = await api("GET", "/api/box");
|
|
2039
|
+
} catch (e) {
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
$("lib-url").textContent = info.library || t("library.none");
|
|
2043
|
+
const state = !info.library ? "–"
|
|
2044
|
+
: !info.attached ? t("library.state.next_start")
|
|
2045
|
+
: info.blocked ? t("library.state.blocked")
|
|
2046
|
+
: info.connected ? t("library.state.connected") : t("library.state.unreachable",
|
|
2047
|
+
{ reason: known("library.alone", info.alone_code) ? tOf("library.alone", info.alone_code) : info.alone || "…" });
|
|
2048
|
+
$("lib-state").textContent = state;
|
|
2049
|
+
$("lib-box").textContent = info.name ? `${info.name} (${info.box})` : info.box;
|
|
2050
|
+
$("lib-outbox").textContent = !info.attached ? "–"
|
|
2051
|
+
: t(info.unsettled.length ? "library.outbox_unsettled" : "library.outbox", { n: info.outbox, unsettled: info.unsettled.length });
|
|
2052
|
+
const form = $("lib-form");
|
|
2053
|
+
if (document.activeElement !== form.url) form.url.value = info.library || "";
|
|
2054
|
+
$("lib-rename").hidden = !info.attached;
|
|
2055
|
+
$("lib-rename").dataset.name = info.name || "";
|
|
2056
|
+
$("lib-unlink-row").hidden = !info.library && !info.attached;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
$("lib-unlink").addEventListener("click", async () => {
|
|
2060
|
+
// asked anew: what waits may have changed since the card was drawn
|
|
2061
|
+
const info = await run(() => api("GET", "/api/box"));
|
|
2062
|
+
if (!info) return;
|
|
2063
|
+
const lines = [t("library.unlink.confirm")];
|
|
2064
|
+
if (info.outbox) lines.push(t("library.unlink.changes", { n: info.outbox }));
|
|
2065
|
+
if (info.absent) lines.push(t("library.unlink.absent", { n: info.absent }));
|
|
2066
|
+
if (info.outbox || info.absent) lines.push(t("library.unlink.wait"));
|
|
2067
|
+
if (!confirm(lines.join("\n\n"))) return;
|
|
2068
|
+
if (!await run(() => api("DELETE", "/api/box/library"), t("library.unlinked"))) return;
|
|
2069
|
+
loadLibrary();
|
|
2070
|
+
if (session.role === "admin") loadVersion();
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
$("lib-form").addEventListener("submit", async (event) => {
|
|
2074
|
+
event.preventDefault();
|
|
2075
|
+
const form = event.target;
|
|
2076
|
+
const body = { url: form.url.value.trim() };
|
|
2077
|
+
if (form.bootstrap.value) body.bootstrap = form.bootstrap.value;
|
|
2078
|
+
const info = await run(() => api("PUT", "/api/box/library", body), t("library.address.saved"));
|
|
2079
|
+
form.bootstrap.value = "";
|
|
2080
|
+
if (info) loadLibrary();
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
// as on the page of the library: the name that is there can be changed, not a new one typed into an empty field
|
|
2084
|
+
$("lib-rename").addEventListener("click", async (event) => {
|
|
2085
|
+
const name = prompt(t("boxes.rename.prompt"), event.target.dataset.name);
|
|
2086
|
+
if (!name) return;
|
|
2087
|
+
const info = await run(() => api("PUT", "/api/box/name", { name }), t("common.name_changed"));
|
|
2088
|
+
if (!info) return;
|
|
2089
|
+
loadLibrary();
|
|
2090
|
+
loadIdentity(); // the new name, and with it the colors of the box
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
2094
|
+
// the version of the box software (tab "System", admins): everything goes through tinyjukebox-slot
|
|
2095
|
+
|
|
2096
|
+
let offered = null; // the release that a box without a library has not installed yet
|
|
2097
|
+
|
|
2098
|
+
async function loadVersion() {
|
|
2099
|
+
let info;
|
|
2100
|
+
try {
|
|
2101
|
+
info = await api("GET", "/api/box/system");
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
// where versions come from: the library as long as the box is linked to one (an older slot script does not say)
|
|
2106
|
+
const origin = info.origin || (info.library ? "library" : "none");
|
|
2107
|
+
// damaged or unknown data this version left out: the box works without it
|
|
2108
|
+
const left = info.degraded || [];
|
|
2109
|
+
$("ver-degraded").hidden = $("ver-degraded-label").hidden = !left.length;
|
|
2110
|
+
$("ver-degraded").firstElementChild.replaceChildren(...left.map((note) => h("li", {}, note)));
|
|
2111
|
+
$("ver-controls").hidden = !info.available;
|
|
2112
|
+
$("ver-help").textContent = info.available ? tOf("version.help", origin) : "";
|
|
2113
|
+
$("ver-source").textContent = origin === "library" ? t("version.source.library", { address: info.library })
|
|
2114
|
+
: origin === "releases" ? t("version.source.releases", { address: info.releases, n: info.wait })
|
|
2115
|
+
: t("version.source.none");
|
|
2116
|
+
const select = $("ver-mode-select");
|
|
2117
|
+
const offer = info.available ? info.offer : null;
|
|
2118
|
+
const asks = Boolean(offer) && info.mode === "manual"; // the box asks: nothing is downloaded before the answer
|
|
2119
|
+
offered = offer;
|
|
2120
|
+
select.hidden = !info.available;
|
|
2121
|
+
$("ver-offer").hidden = $("ver-offer-label").hidden = !offer;
|
|
2122
|
+
$("ver-offer-skip").hidden = $("version-banner").hidden = !asks;
|
|
2123
|
+
if (asks) $("version-banner-text").textContent = t("version.banner", { version: offer.version });
|
|
2124
|
+
if (!info.available) {
|
|
2125
|
+
$("ver-running").textContent = info.version;
|
|
2126
|
+
$("ver-mode").textContent = t("version.no_slots");
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
$("ver-mode").textContent = "";
|
|
2130
|
+
const modes = [...(info.script >= 2 ? ["off"] : []), "manual", "automatic"]; // "off" needs the slot script 2
|
|
2131
|
+
select.replaceChildren(...modes.map((mode) => h("option", { value: mode },
|
|
2132
|
+
mode === "manual" && origin === "releases" ? t("version.mode.ask") : tOf("version.mode", mode))));
|
|
2133
|
+
select.value = info.mode;
|
|
2134
|
+
if (offer) {
|
|
2135
|
+
// a release that a box without a library has not installed yet: it waits for its day, or for a person
|
|
2136
|
+
const values = { version: offer.version, size: formatSize(offer.size), n: info.wait,
|
|
2137
|
+
date: new Date(offer.published).toLocaleDateString(), due: offer.due ? new Date(offer.due).toLocaleDateString() : "" };
|
|
2138
|
+
$("ver-offer-text").textContent = asks ? t("version.offer", values)
|
|
2139
|
+
: t(offer.due ? "version.offer.waiting" : "version.offer.soon", values);
|
|
2140
|
+
$("ver-offer-install").textContent = t(asks ? "version.install" : "version.install_now");
|
|
2141
|
+
$("ver-offer-notes").hidden = !offer.notes;
|
|
2142
|
+
$("ver-offer-notes").querySelector("pre").textContent = offer.notes || "";
|
|
2143
|
+
}
|
|
2144
|
+
const slots = info.slots || {};
|
|
2145
|
+
const running = slots[info.active] || {};
|
|
2146
|
+
$("ver-running").textContent = t("version.running_slot",
|
|
2147
|
+
{ version: info.version, slot: tOf("slot.name", info.active), state: tOf("slot.state", running.state) });
|
|
2148
|
+
$("ver-problem").textContent = info.problem || "";
|
|
2149
|
+
$("ver-problem").hidden = $("ver-problem-label").hidden = !info.problem;
|
|
2150
|
+
$("ver-slots").replaceChildren(...Object.entries(slots).map(([name, slot]) => {
|
|
2151
|
+
const state = name === "next" ? t("slot.installed") : tOf("slot.state", slot.state);
|
|
2152
|
+
return h("tr", { class: name === info.active ? "present" : "" },
|
|
2153
|
+
h("td", {}, tOf("slot.name", name)),
|
|
2154
|
+
h("td", {}, slot.version),
|
|
2155
|
+
h("td", {}, slot.state === "probation" ? t("slot.since", { state, since: when(slot.since), n: slot.starts }) : state));
|
|
2156
|
+
}));
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
document.addEventListener("click", async (event) => {
|
|
2160
|
+
const button = event.target.closest("[data-slot-command]");
|
|
2161
|
+
if (!button) return;
|
|
2162
|
+
if (button.dataset.confirm && !confirm(button.dataset.confirm.replace(/\s+/g, " "))) return;
|
|
2163
|
+
const [command, ...args] = button.dataset.slotCommand.split(" ");
|
|
2164
|
+
button.disabled = true;
|
|
2165
|
+
await slotCommand(command, args);
|
|
2166
|
+
button.disabled = false;
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
async function slotCommand(command, args) {
|
|
2170
|
+
const result = await run(() => api("POST", `/api/box/system/${command}`, { arguments: args }));
|
|
2171
|
+
if (!result) return;
|
|
2172
|
+
$("ver-output").textContent = result.output || (result.status === 0 ? t("version.done") : t("error.status", { status: result.status }));
|
|
2173
|
+
$("ver-output").hidden = false;
|
|
2174
|
+
if (result.status !== 0) toast(t("version.failed"), true);
|
|
2175
|
+
loadVersion();
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
$("ver-mode-select").addEventListener("change", (event) => slotCommand("mode", [event.target.value]));
|
|
2179
|
+
|
|
2180
|
+
// a release of a box without a library (section "Version", and the banner of a box that asks)
|
|
2181
|
+
|
|
2182
|
+
async function installOffer() {
|
|
2183
|
+
if (!offered) return;
|
|
2184
|
+
// in the background, as every update: the version starts the next time the box is switched on
|
|
2185
|
+
$("version-banner").hidden = true;
|
|
2186
|
+
toast(t("version.installing", { version: offered.version }));
|
|
2187
|
+
for (const id of ["ver-offer-install", "version-banner-install"]) $(id).disabled = true;
|
|
2188
|
+
await slotCommand("update", []);
|
|
2189
|
+
for (const id of ["ver-offer-install", "version-banner-install"]) $(id).disabled = false;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
async function skipOffer() {
|
|
2193
|
+
if (!offered) return;
|
|
2194
|
+
const version = offered.version;
|
|
2195
|
+
$("version-banner").hidden = true;
|
|
2196
|
+
await slotCommand("skip", [version]);
|
|
2197
|
+
toast(t("version.skipped", { version }));
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
$("ver-offer-install").addEventListener("click", installOffer);
|
|
2201
|
+
$("version-banner-install").addEventListener("click", installOffer);
|
|
2202
|
+
$("ver-offer-skip").addEventListener("click", skipOffer);
|
|
2203
|
+
$("version-banner-skip").addEventListener("click", skipOffer);
|
|
2204
|
+
$("version-banner-notes").addEventListener("click", () => {
|
|
2205
|
+
selectTab("system");
|
|
2206
|
+
$("ver-offer-notes").open = true;
|
|
2207
|
+
$("ver-offer").scrollIntoView({ block: "center" });
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
2211
|
+
// the page of the library: boxes, versions of the box software
|
|
2212
|
+
|
|
2213
|
+
const when = (stamp) => stamp ? new Date(stamp).toLocaleString() : t("common.never");
|
|
2214
|
+
|
|
2215
|
+
async function loadBoxes() {
|
|
2216
|
+
const overview = await run(() => api("GET", "/api/library/overview"));
|
|
2217
|
+
if (!overview) return;
|
|
2218
|
+
const isAdmin = session.role === "admin";
|
|
2219
|
+
const active = overview.boxes.filter((box) => !box.removed);
|
|
2220
|
+
$("boxes-body").replaceChildren(...overview.boxes.map((box) => {
|
|
2221
|
+
const status = box.status || {};
|
|
2222
|
+
const byHand = box.interface < overview.interface && ["manual", "off"].includes(status.mode);
|
|
2223
|
+
const off = status.mode === "off";
|
|
2224
|
+
const fits = !box.fits ? "" : t(byHand ? "boxes.fits_manual" : "boxes.fits",
|
|
2225
|
+
{ name: box.name, speaks: box.interface, serves: overview.interface });
|
|
2226
|
+
const phone = box.kind === "app";
|
|
2227
|
+
const left = status.degraded || [];
|
|
2228
|
+
const startup = box.startup ? t("boxes.startup", { since: when(box.startup.since || box.startup.received),
|
|
2229
|
+
n: box.startup.failures }) : "";
|
|
2230
|
+
const notes = [startup, fits, status.problem, left.length ? t("boxes.degraded", { n: left.length }) : "",
|
|
2231
|
+
box.waiting ? t("boxes.waiting", { n: box.waiting }) : "",
|
|
2232
|
+
box.removed ? t("boxes.removed") : ""].filter(Boolean);
|
|
2233
|
+
// a phone always shows its progress and its volume limits
|
|
2234
|
+
const facts = phone ? [t("boxes.positions", { n: box.positions }), phoneLimits(box.settings)].filter(Boolean) : [];
|
|
2235
|
+
const rename = h("button", { class: "link", onclick: async () => {
|
|
2236
|
+
const name = prompt(t("boxes.rename.prompt"), box.name);
|
|
2237
|
+
if (!name) return;
|
|
2238
|
+
const done = await run(() => api("PATCH", `/api/library/boxes/${box.id}`, { name }), t("common.name_changed"));
|
|
2239
|
+
if (done !== undefined) loadBoxes();
|
|
2240
|
+
} }, t("boxes.rename"));
|
|
2241
|
+
const takeover = h("button", { class: "link", onclick: async () => {
|
|
2242
|
+
const others = overview.boxes.filter((other) => other.id !== box.id).map((other) => `${other.id} (${other.name})`);
|
|
2243
|
+
const old = prompt(t("boxes.takeover.prompt", { boxes: others.join("\n") }));
|
|
2244
|
+
if (!old) return;
|
|
2245
|
+
const done = await run(() => api("PATCH", `/api/library/boxes/${box.id}`, { takeover: old.split(" ")[0] }),
|
|
2246
|
+
t("boxes.takeover.done"));
|
|
2247
|
+
if (done !== undefined) loadBoxes();
|
|
2248
|
+
} }, t("boxes.takeover"));
|
|
2249
|
+
const volume = h("button", { class: "link", onclick: () => openPhoneLimits(box) }, t("phones.limits"));
|
|
2250
|
+
const remove = h("button", { class: "link danger-text", onclick: async () => {
|
|
2251
|
+
if (!confirm(t("boxes.remove.confirm", { name: box.name }))) return;
|
|
2252
|
+
const done = await run(() => api("DELETE", `/api/library/boxes/${box.id}`), t("boxes.remove.done"));
|
|
2253
|
+
if (done !== undefined) loadBoxes();
|
|
2254
|
+
} }, t("common.remove"));
|
|
2255
|
+
// a phone has no slots and no update mode: it is installed by hand (docs/developer/app.md)
|
|
2256
|
+
return h("tr", {},
|
|
2257
|
+
h("td", {}, box.name, h("div", { class: "muted small" }, phone ? `${t("phones.phone")} · ${box.id}` : box.id)),
|
|
2258
|
+
h("td", {}, box.software || "–", !phone && ["manual", "off"].includes(status.mode)
|
|
2259
|
+
? h("div", { class: "muted small" }, t(off ? "boxes.updates_off" : "boxes.manual_updates")) : ""),
|
|
2260
|
+
h("td", {}, when(box.contact), boxAddress(box)),
|
|
2261
|
+
h("td", { class: "small" }, [...notes, ...facts].join("; ") || t("boxes.positions", { n: box.positions })),
|
|
2262
|
+
h("td", {}, ...(isAdmin && !box.removed
|
|
2263
|
+
? [rename, phone ? volume : "", overview.boxes.length > 1 ? takeover : "", remove] : [])));
|
|
2264
|
+
}));
|
|
2265
|
+
if (!overview.boxes.length) {
|
|
2266
|
+
$("boxes-body").append(h("tr", {}, h("td", { colSpan: 5, class: "muted" },
|
|
2267
|
+
overview.bootstrap ? t("boxes.none") : t("boxes.no_bootstrap"))));
|
|
2268
|
+
}
|
|
2269
|
+
// the line that sets up a new box and links it to this library (docs/developer/deployment.md, section 5)
|
|
2270
|
+
const offer = overview.install || {};
|
|
2271
|
+
const origin = window.location.origin;
|
|
2272
|
+
$("install-row").hidden = !offer.version;
|
|
2273
|
+
$("install-line").textContent = offer.version
|
|
2274
|
+
? `curl -fsSLk --pinnedpubkey '${offer.pin}' ${origin}/install.sh | sudo bash -s -- --library ${origin} --pin '${offer.pin}'`
|
|
2275
|
+
: "";
|
|
2276
|
+
$("install-help").textContent = !offer.version ? t("boxes.add.none")
|
|
2277
|
+
: [t("boxes.add.help", { version: offer.version }),
|
|
2278
|
+
offer.newest !== offer.version ? t("boxes.add.older", { version: offer.version, newest: offer.newest }) : ""]
|
|
2279
|
+
.filter(Boolean).join(" ");
|
|
2280
|
+
// the app for phones: the APK of the version that "Add a box" installs, so that app and library fit
|
|
2281
|
+
const app = overview.app || {};
|
|
2282
|
+
$("phone-app-row").hidden = !app.version;
|
|
2283
|
+
$("phone-app-help").textContent = app.version ? t("phones.app.help", { version: app.version }) : t("phones.app.none");
|
|
2284
|
+
if (app.version) {
|
|
2285
|
+
const link = `${origin}/app.apk`;
|
|
2286
|
+
$("phone-app-link").textContent = link;
|
|
2287
|
+
$("phone-app-link").href = link;
|
|
2288
|
+
if ($("phone-app-qr").dataset.text !== link) {
|
|
2289
|
+
const qr = await run(() => api("GET", `/api/library/qr?text=${encodeURIComponent(link)}`));
|
|
2290
|
+
if (qr) {
|
|
2291
|
+
$("phone-app-qr").replaceChildren(qrImage(qr.svg, t("phones.app.qr")));
|
|
2292
|
+
$("phone-app-qr").dataset.text = link;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
$("uploads-card").hidden = !overview.uploads.length;
|
|
2297
|
+
$("uploads-list").replaceChildren(...overview.uploads.map((upload) => h("li", {},
|
|
2298
|
+
t("boxes.uploads.line", { folder: upload.folder, box: upload.name, transferred: upload.transferred, n: upload.files }))));
|
|
2299
|
+
return active;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// where a box asked from at its last contact: the address, a link to its page if it named the port of its page
|
|
2303
|
+
function boxAddress(box) {
|
|
2304
|
+
if (!box.address) return "";
|
|
2305
|
+
if (!box.page) return h("div", { class: "muted small" }, box.address);
|
|
2306
|
+
const host = box.address.includes(":") ? `[${box.address}]` : box.address; // IPv6
|
|
2307
|
+
const href = box.page === 443 ? `https://${host}/` : `https://${host}:${box.page}/`;
|
|
2308
|
+
return h("div", { class: "small" },
|
|
2309
|
+
h("a", { href, target: "_blank", rel: "noopener", title: t("boxes.page", { name: box.name }) }, box.address));
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// the volume limits of a phone: the most it plays with, of what the phone is set to
|
|
2313
|
+
function phoneLimits(settings) {
|
|
2314
|
+
const most = (output) => ((settings || {})[`volume.${output}`] || {})["max-volume"];
|
|
2315
|
+
const speaker = most("speaker");
|
|
2316
|
+
const headphones = most("headphones");
|
|
2317
|
+
if (speaker === undefined && headphones === undefined) return "";
|
|
2318
|
+
return t("phones.limits.line", { speaker: speaker ?? 100, headphones: headphones ?? 100 });
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
let limitsOf = null; // the phone whose limits the dialog shows
|
|
2322
|
+
function openPhoneLimits(box) {
|
|
2323
|
+
limitsOf = box;
|
|
2324
|
+
const form = $("phone-limits-form");
|
|
2325
|
+
for (const output of ["speaker", "headphones"]) {
|
|
2326
|
+
form[output].value = ((box.settings || {})[`volume.${output}`] || {})["max-volume"] ?? 100;
|
|
2327
|
+
}
|
|
2328
|
+
$("phone-limits-dialog").showModal();
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
$("phone-limits-form").addEventListener("submit", async (event) => {
|
|
2332
|
+
event.preventDefault();
|
|
2333
|
+
const form = event.target;
|
|
2334
|
+
const volume = Object.fromEntries(["speaker", "headphones"].map((output) =>
|
|
2335
|
+
[output, { "max-volume": Number(form[output].value) }]));
|
|
2336
|
+
const done = await run(() => api("PATCH", `/api/library/boxes/${limitsOf.id}`, { volume }), t("limits.saved"));
|
|
2337
|
+
if (done === undefined) return;
|
|
2338
|
+
$("phone-limits-dialog").close();
|
|
2339
|
+
loadBoxes();
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2342
|
+
// pairing a phone: a QR code with the address of the library and a password for one use, valid 10 minutes
|
|
2343
|
+
let pairingTimer = null;
|
|
2344
|
+
$("phone-pair").addEventListener("click", async () => {
|
|
2345
|
+
const answer = await run(() => api("POST", "/api/library/pairings", { url: window.location.origin }));
|
|
2346
|
+
if (!answer) return;
|
|
2347
|
+
$("phone-pair-qr").replaceChildren(qrImage(answer.svg, t("phones.pair.qr")));
|
|
2348
|
+
$("phone-pair-qr").hidden = false;
|
|
2349
|
+
const until = Date.now() + answer.seconds * 1000;
|
|
2350
|
+
const tick = () => {
|
|
2351
|
+
const left = Math.round((until - Date.now()) / 1000);
|
|
2352
|
+
if (left <= 0) {
|
|
2353
|
+
clearInterval(pairingTimer);
|
|
2354
|
+
$("phone-pair-qr").replaceChildren();
|
|
2355
|
+
$("phone-pair-qr").hidden = true;
|
|
2356
|
+
$("phone-pair-state").textContent = t("phones.pair.expired");
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
$("phone-pair-state").textContent = t("phones.pair.valid", { minutes: Math.floor(left / 60), seconds: String(left % 60).padStart(2, "0") });
|
|
2360
|
+
};
|
|
2361
|
+
clearInterval(pairingTimer);
|
|
2362
|
+
pairingTimer = setInterval(tick, 1000);
|
|
2363
|
+
$("phone-pair-state").hidden = false;
|
|
2364
|
+
tick();
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
$("install-copy").addEventListener("click", async () => {
|
|
2368
|
+
try {
|
|
2369
|
+
await navigator.clipboard.writeText($("install-line").textContent);
|
|
2370
|
+
toast(t("boxes.add.copied"));
|
|
2371
|
+
} catch {
|
|
2372
|
+
window.getSelection().selectAllChildren($("install-line")); // copied by hand then
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
$("bootstrap-form").addEventListener("submit", async (event) => {
|
|
2377
|
+
event.preventDefault();
|
|
2378
|
+
const done = await run(() => api("PUT", "/api/library/bootstrap", { password: event.target.password.value }),
|
|
2379
|
+
t("access.bootstrap.saved"));
|
|
2380
|
+
if (done !== undefined) event.target.reset();
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
$("token-new").addEventListener("click", async () => {
|
|
2384
|
+
const result = await run(() => api("POST", "/api/library/tokens"));
|
|
2385
|
+
if (!result) return;
|
|
2386
|
+
$("token-value").textContent = result.token; // shown once: the library keeps only its checksum
|
|
2387
|
+
$("token-value").hidden = false;
|
|
2388
|
+
});
|
|
2389
|
+
|
|
2390
|
+
$("library-verify").addEventListener("click", async () => {
|
|
2391
|
+
if (await run(() => api("POST", "/api/library/verify"), t("access.verified"))) loadBoxes();
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
const VERSION_STATES = ["use", "manual", "dontuse"];
|
|
2395
|
+
|
|
2396
|
+
// the table of the versions leaves out what is of no use any more: a version that is not in the archive (the archive
|
|
2397
|
+
// keeps the newest ones only), that no box runs or has prepared and that the library does not run itself. The choice
|
|
2398
|
+
// is kept in this browser
|
|
2399
|
+
const VERSIONS_KEY = "tinyjukebox.versions.all";
|
|
2400
|
+
try {
|
|
2401
|
+
$("versions-hide-old").checked = localStorage.getItem(VERSIONS_KEY) !== "1";
|
|
2402
|
+
} catch { /* a browser without storage: the old versions are hidden */ }
|
|
2403
|
+
$("versions-hide-old").addEventListener("change", (event) => {
|
|
2404
|
+
try {
|
|
2405
|
+
localStorage.setItem(VERSIONS_KEY, event.target.checked ? "0" : "1");
|
|
2406
|
+
} catch { /* see above */ }
|
|
2407
|
+
loadVersions();
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
async function loadVersions() {
|
|
2411
|
+
const overview = await run(() => api("GET", "/api/library/overview"));
|
|
2412
|
+
if (!overview) return;
|
|
2413
|
+
const isAdmin = session.role === "admin";
|
|
2414
|
+
const names = Object.fromEntries(overview.boxes.map((box) => [box.id, box.name]));
|
|
2415
|
+
const releases = overview.releases;
|
|
2416
|
+
$("releases-state").textContent = [
|
|
2417
|
+
t("releases.library", { interface: overview.interface }),
|
|
2418
|
+
releases.checks && releases.checks.length ? t("releases.checked", {
|
|
2419
|
+
date: new Date(releases.checks[releases.checks.length - 1] * 1000).toLocaleString(), n: Math.round(releases.interval / 60),
|
|
2420
|
+
}) : t("releases.never_checked"),
|
|
2421
|
+
].join(" ");
|
|
2422
|
+
$("releases-token-state").textContent = !releases.repository ? t("releases.no_repository")
|
|
2423
|
+
: [t("releases.repository", { repository: releases.repository }), tOf("releases.token_state", releases.token)].join(" ");
|
|
2424
|
+
$("releases-token-form").hidden = !releases.repository;
|
|
2425
|
+
$("releases-keep-form").versions.value = releases.keep;
|
|
2426
|
+
// the version the library runs itself is named in the table, also when its slot is not in the archive
|
|
2427
|
+
const all = overview.versions.slice().reverse();
|
|
2428
|
+
if (!all.some((version) => version.version === overview.library)) {
|
|
2429
|
+
all.push({ version: overview.library });
|
|
2430
|
+
all.sort((a, b) => collator.compare(b.version, a.version));
|
|
2431
|
+
}
|
|
2432
|
+
const old = (version) => Boolean(version.state) && !version.archived && version.version !== overview.library
|
|
2433
|
+
&& !(version.boxes || []).length && !(version.prepared || []).length;
|
|
2434
|
+
const rows = $("versions-hide-old").checked ? all.filter((version) => !old(version)) : all;
|
|
2435
|
+
$("versions-hidden").textContent = rows.length < all.length ? t("releases.hidden", { n: all.length - rows.length }) : "";
|
|
2436
|
+
$("versions-body").replaceChildren(...rows.map((version) => {
|
|
2437
|
+
const runsOn = (version.boxes || []).map((id) => names[id] || id).join(", ");
|
|
2438
|
+
const library = version.version === overview.library ? h("b", {}, t("library.title")) : "";
|
|
2439
|
+
const runs = h("td", { class: "small" }, library, library && runsOn ? ", " : "", runsOn || (library ? "" : "–"));
|
|
2440
|
+
if (!version.state) {
|
|
2441
|
+
return h("tr", {}, h("td", {}, version.version), h("td", {}, "–"), h("td", {}, "–"), runs, h("td", {}, "–"), h("td"));
|
|
2442
|
+
}
|
|
2443
|
+
const select = h("select", { "aria-label": t("common.state"), disabled: !isAdmin },
|
|
2444
|
+
...VERSION_STATES.map((state) => h("option", { value: state, selected: state === version.state }, state)));
|
|
2445
|
+
select.addEventListener("change", async () => {
|
|
2446
|
+
const why = prompt(t("releases.set.prompt", { version: version.version, state: select.value }), "");
|
|
2447
|
+
if (why === null) { select.value = version.state; return; }
|
|
2448
|
+
const done = await run(() => api("PUT", `/api/library/versions/${version.version}`,
|
|
2449
|
+
{ state: select.value, why }), t("releases.set.done"));
|
|
2450
|
+
loadVersions();
|
|
2451
|
+
return done;
|
|
2452
|
+
});
|
|
2453
|
+
return h("tr", {},
|
|
2454
|
+
h("td", {}, version.version, version.note ? h("div", { class: "muted small" },
|
|
2455
|
+
version.offered === false ? t("releases.needs_newer_library") : version.note) : ""),
|
|
2456
|
+
h("td", { class: `state-${version.state}` }, select),
|
|
2457
|
+
h("td", { class: "small" }, `${version.by}, ${when(version.when)}`, h("div", { class: "muted" }, version.why)),
|
|
2458
|
+
runs,
|
|
2459
|
+
h("td", { class: "small" }, version.prepared.map((id) => names[id] || id).join(", ") || "–"),
|
|
2460
|
+
h("td", { class: "muted small" }, version.archived ? "" : t("releases.not_archived")));
|
|
2461
|
+
}));
|
|
2462
|
+
if (!overview.versions.length) {
|
|
2463
|
+
$("versions-body").append(h("tr", {}, h("td", { colSpan: 6, class: "muted" }, t("releases.none"))));
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
$("releases-check").addEventListener("click", async () => {
|
|
2468
|
+
const result = await run(() => api("POST", "/api/library/releases/check"));
|
|
2469
|
+
if (!result) return;
|
|
2470
|
+
toast(result.found ? t("releases.found", { version: result.found }) : result.error ? errorText(result) : t("releases.nothing_new"),
|
|
2471
|
+
Boolean(result.error));
|
|
2472
|
+
loadVersions();
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
$("releases-keep-form").addEventListener("submit", async (event) => {
|
|
2476
|
+
event.preventDefault();
|
|
2477
|
+
// what is too much leaves the archive at once
|
|
2478
|
+
await run(() => api("PUT", "/api/library/releases/keep", { versions: Number(event.target.versions.value) }), t("common.saved"));
|
|
2479
|
+
loadVersions();
|
|
2480
|
+
});
|
|
2481
|
+
|
|
2482
|
+
$("releases-token-form").addEventListener("submit", async (event) => {
|
|
2483
|
+
event.preventDefault();
|
|
2484
|
+
// the library asks the git server with the token before it keeps it, and looks for a release at once
|
|
2485
|
+
const result = await run(() => api("PUT", "/api/library/releases/token", { token: event.target.token.value }));
|
|
2486
|
+
if (!result) return;
|
|
2487
|
+
event.target.reset();
|
|
2488
|
+
toast([t("releases.token_saved"), result.found ? t("releases.found", { version: result.found }) : result.error
|
|
2489
|
+
? errorText(result) : t("releases.nothing_new")].join(" "), Boolean(result.error));
|
|
2490
|
+
loadVersions();
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
2494
|
+
// play statistics (for every user of the page): a box shows its own, the library those of all boxes or of the box
|
|
2495
|
+
// that is chosen. What belongs to one child - a day, its hours - is never summed over the boxes
|
|
2496
|
+
|
|
2497
|
+
function duration(seconds) {
|
|
2498
|
+
const minutes = Math.round(seconds / 60);
|
|
2499
|
+
return minutes >= 60 ? t("duration.hours", { h: Math.floor(minutes / 60), m: String(minutes % 60).padStart(2, "0") })
|
|
2500
|
+
: minutes ? t("duration.minutes", { n: minutes }) : t("duration.seconds", { n: seconds });
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function emptyRow(columns, text) {
|
|
2504
|
+
return h("tr", {}, h("td", { colSpan: columns, class: "muted" }, text));
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// hours in which something was listened to, as spans: [7, 8, 15] -> [[7, 9], [15, 16]]
|
|
2508
|
+
function spans(hours) {
|
|
2509
|
+
const result = [];
|
|
2510
|
+
for (const hour of hours.sort((a, b) => a - b)) {
|
|
2511
|
+
if (result.length && result[result.length - 1][1] === hour) result[result.length - 1][1] = hour + 1;
|
|
2512
|
+
else result.push([hour, hour + 1]);
|
|
2513
|
+
}
|
|
2514
|
+
return result;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// the table of the tags is sorted by the column that was clicked; a second click turns the order round. A tag
|
|
2518
|
+
// without a value (no known time) stays at the end
|
|
2519
|
+
const STATS_SORTS = { last: (tag) => Date.parse(tag.last) || null };
|
|
2520
|
+
let statsTags = [];
|
|
2521
|
+
let statsBox = ""; // the box chosen on the page of the library - none: all boxes
|
|
2522
|
+
let statsSort = { key: "seconds", descending: true };
|
|
2523
|
+
|
|
2524
|
+
function showStatsTags() {
|
|
2525
|
+
const value = STATS_SORTS[statsSort.key] || ((tag) => tag[statsSort.key] ?? null);
|
|
2526
|
+
const direction = statsSort.descending ? -1 : 1;
|
|
2527
|
+
const sorted = statsTags.map((tag) => [value(tag), tag]).sort(([x, a], [y, b]) => {
|
|
2528
|
+
if (x === null || y === null) return (x === null) - (y === null) || collator.compare(a.name, b.name);
|
|
2529
|
+
return direction * (typeof x === "number" ? x - y : collator.compare(x, y)) || collator.compare(a.name, b.name);
|
|
2530
|
+
}).map(([, tag]) => tag);
|
|
2531
|
+
for (const head of $("stats-tags-head").querySelectorAll("th[data-sort]")) {
|
|
2532
|
+
if (head.dataset.sort === statsSort.key) head.setAttribute("aria-sort", statsSort.descending ? "descending" : "ascending");
|
|
2533
|
+
else head.removeAttribute("aria-sort");
|
|
2534
|
+
}
|
|
2535
|
+
$("stats-tags").replaceChildren(...sorted.map((tag) => h("tr", {},
|
|
2536
|
+
h("td", {}, tag.name),
|
|
2537
|
+
h("td", {}, tag.album || "–"),
|
|
2538
|
+
h("td", {}, `${tag.plays}×`),
|
|
2539
|
+
h("td", {}, duration(tag.seconds)),
|
|
2540
|
+
h("td", {}, tag.finished ? `${tag.finished}×` : "–"),
|
|
2541
|
+
h("td", {}, tag.last ? when(tag.last) : t("statistics.time_unknown")),
|
|
2542
|
+
h("td", { class: "stats-several small" },
|
|
2543
|
+
tag.boxes.map((box) => t("statistics.on_box", { box: box.name, duration: duration(box.seconds) })).join(", ")))));
|
|
2544
|
+
if (!sorted.length) $("stats-tags").append(emptyRow(7, t("statistics.none")));
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
function chooseStatsBox(box) {
|
|
2548
|
+
statsBox = box;
|
|
2549
|
+
loadStatistics();
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
async function loadStatistics() {
|
|
2553
|
+
const stats = await run(() => api("GET", !isLibrary() ? "/api/box/statistics"
|
|
2554
|
+
: `/api/library/statistics${statsBox ? `?box=${encodeURIComponent(statsBox)}` : ""}`));
|
|
2555
|
+
if (!stats) return;
|
|
2556
|
+
$("stats-tags-wrap").hidden = $("stats-rest").hidden = Boolean(stats.missing);
|
|
2557
|
+
if (stats.missing) { // the library counts for a box that has one, and it does not answer
|
|
2558
|
+
$("stats-help").textContent = tOf("statistics.missing", stats.missing);
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
const several = isLibrary() && stats.boxes.length > 1; // only then a box is to be chosen
|
|
2562
|
+
const all = several && !stats.box;
|
|
2563
|
+
$("stats-box").hidden = !several;
|
|
2564
|
+
$("stats-box").replaceChildren(h("option", { value: "" }, t("statistics.box.all")),
|
|
2565
|
+
...stats.boxes.map((box) => h("option", { value: box.id, selected: box.id === stats.box }, box.name)));
|
|
2566
|
+
$("tab-statistics").classList.toggle("several", all);
|
|
2567
|
+
const shown = stats.boxes.filter((box) => !stats.box || box.id === stats.box);
|
|
2568
|
+
$("stats-help").textContent = [
|
|
2569
|
+
tOf("statistics.help", isLibrary() ? "library" : stats.origin === "box" ? "alone" : "box", { limit: stats.limit }),
|
|
2570
|
+
t("statistics.total", { duration: duration(shown.reduce((sum, box) => sum + box.seconds, 0)) })].join(" ");
|
|
2571
|
+
$("stats-boxes-card").hidden = !all;
|
|
2572
|
+
$("stats-boxes").replaceChildren(...stats.boxes.map((box) => h("tr", {},
|
|
2573
|
+
h("td", {}, h("button", { class: "link", onclick: () => chooseStatsBox(box.id) }, box.name)),
|
|
2574
|
+
h("td", {}, box.seconds ? duration(box.seconds) : "–"),
|
|
2575
|
+
h("td", {}, box.plays ? `${box.plays}×` : "–"),
|
|
2576
|
+
h("td", {}, box.last ? when(box.last) : box.plays ? t("statistics.time_unknown") : "–"))));
|
|
2577
|
+
statsTags = stats.tags.filter((tag) => tag.plays);
|
|
2578
|
+
showStatsTags();
|
|
2579
|
+
$("stats-never-card").hidden = !stats.never.length;
|
|
2580
|
+
$("stats-never").textContent = stats.never.join(", ");
|
|
2581
|
+
$("stats-days").replaceChildren(...stats.days.map((day) => h("tr", {},
|
|
2582
|
+
h("td", {}, new Date(`${day.day}T12:00:00`).toLocaleDateString()),
|
|
2583
|
+
h("td", { class: "stats-several" }, day.name),
|
|
2584
|
+
h("td", {}, duration(day.seconds)),
|
|
2585
|
+
h("td", {}, day.speaker ? duration(day.speaker) : "–"),
|
|
2586
|
+
h("td", {}, day.headphones ? duration(day.headphones) : "–"),
|
|
2587
|
+
h("td", { class: "small" }, spans(Object.keys(day.hours).map(Number))
|
|
2588
|
+
.map(([from, to]) => t("statistics.hours", { from, to })).join(", ")))));
|
|
2589
|
+
if (!stats.days.length) $("stats-days").append(emptyRow(6, t("statistics.days.none")));
|
|
2590
|
+
$("stats-unknown").textContent = stats.unknown_time
|
|
2591
|
+
? t("statistics.unknown_time", { duration: duration(stats.unknown_time) }) : "";
|
|
2592
|
+
$("stats-stations").replaceChildren(...stats.stations.map((station) => h("tr", {},
|
|
2593
|
+
h("td", {}, station.name), h("td", {}, duration(station.seconds)))));
|
|
2594
|
+
if (!stats.stations.length) $("stats-stations").append(emptyRow(2, t("statistics.stations.none")));
|
|
2595
|
+
$("stats-skipped").replaceChildren(...stats.skipped.map((track) => h("tr", {},
|
|
2596
|
+
h("td", {}, track.title), h("td", {}, track.track), h("td", {}, `${track.count}×`))));
|
|
2597
|
+
if (!stats.skipped.length) $("stats-skipped").append(emptyRow(3, t("statistics.skipped.none")));
|
|
2598
|
+
const decimal = (value) => value ? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) : "–";
|
|
2599
|
+
$("stats-versions").replaceChildren(...stats.versions.map((version) => h("tr", {},
|
|
2600
|
+
h("td", {}, version.version), h("td", {}, version.starts), h("td", {}, version.errors),
|
|
2601
|
+
h("td", {}, version.fallbacks), h("td", {}, decimal(version.installed / 86400)),
|
|
2602
|
+
h("td", {}, decimal(version.running / 3600)), h("td", {}, decimal(version.active / 3600)))));
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
$("stats-box").addEventListener("change", (event) => chooseStatsBox(event.target.value));
|
|
2606
|
+
|
|
2607
|
+
for (const head of $("stats-tags-head").querySelectorAll("th[data-sort]")) {
|
|
2608
|
+
const sort = () => {
|
|
2609
|
+
const key = head.dataset.sort;
|
|
2610
|
+
statsSort = { key, descending: statsSort.key === key ? !statsSort.descending : !["name", "album"].includes(key) };
|
|
2611
|
+
showStatsTags();
|
|
2612
|
+
};
|
|
2613
|
+
head.tabIndex = 0;
|
|
2614
|
+
head.addEventListener("click", sort);
|
|
2615
|
+
head.addEventListener("keydown", (event) => {
|
|
2616
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
2617
|
+
event.preventDefault();
|
|
2618
|
+
sort();
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
2623
|
+
// hardware: what the drivers say about the buttons, the led and the rfid reader (tab "System", admins) - asked twice
|
|
2624
|
+
// a second for as long as the tab is open
|
|
2625
|
+
|
|
2626
|
+
const LIT_COLORS = {
|
|
2627
|
+
"000": "BLACK", "111": "WHITE", "100": "RED", "010": "GREEN", "001": "BLUE", "110": "YELLOW", "011": "AQUA",
|
|
2628
|
+
"101": "MAGENTA",
|
|
2629
|
+
};
|
|
2630
|
+
const HARDWARE_INTERVAL = 500; // ms
|
|
2631
|
+
let hardwareTimer = null;
|
|
2632
|
+
let hardwareTurn = 0; // a tab opened again starts a new turn - an answer of the one before plans nothing
|
|
2633
|
+
|
|
2634
|
+
function agoText(seconds) {
|
|
2635
|
+
const n = Math.max(0, Math.round(seconds));
|
|
2636
|
+
if (n < 120) return t("hardware.ago.seconds", { n });
|
|
2637
|
+
if (n < 7200) return t("hardware.ago.minutes", { n: Math.round(n / 60) });
|
|
2638
|
+
return t("hardware.ago.hours", { n: Math.round(n / 3600) });
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// a time of the box (seconds since 1970) and how long ago that was - by the clock of the box, not of the browser
|
|
2642
|
+
const hardwareClock = (at) => new Date(at * 1000).toLocaleTimeString();
|
|
2643
|
+
|
|
2644
|
+
const fact = (label, ...value) => [h("dt", {}, label), h("dd", {}, ...value)];
|
|
2645
|
+
|
|
2646
|
+
function renderHardware(info) {
|
|
2647
|
+
$("hw-none").hidden = info.available;
|
|
2648
|
+
$("hw-view").hidden = !info.available;
|
|
2649
|
+
if (!info.available) return;
|
|
2650
|
+
const driver = (d) => `${d.name} · ${t(d.running ? "hardware.driver.running" : "hardware.driver.stopped")}`;
|
|
2651
|
+
const level = (pressed) => pressed === null
|
|
2652
|
+
? h("span", { class: "badge badge-warn" }, tOf("hardware.level", "unknown"))
|
|
2653
|
+
: h("span", { class: `badge badge-${pressed ? "busy" : "idle"}` }, tOf("hardware.level", pressed ? "pressed" : "open"));
|
|
2654
|
+
$("hw-buttons").replaceChildren(...info.buttons.map((b) => h("tr", {},
|
|
2655
|
+
h("td", {}, tOf("hardware.button", b.button)),
|
|
2656
|
+
h("td", {}, h("code", {}, b.pin)),
|
|
2657
|
+
h("td", {}, level(b.pressed)),
|
|
2658
|
+
h("td", {}, b.last_event ? t("hardware.event_at", {
|
|
2659
|
+
event: tOf("hardware.event", b.last_event), time: hardwareClock(b.last_event_at),
|
|
2660
|
+
ago: agoText(info.time - b.last_event_at),
|
|
2661
|
+
}) : t("hardware.event_none")),
|
|
2662
|
+
h("td", {}, b.presses),
|
|
2663
|
+
h("td", {}, driver(b)))));
|
|
2664
|
+
$("hw-leds").replaceChildren(...info.leds.map((led) => {
|
|
2665
|
+
const steps = led.colors.map((color, i) => (led.delays.length
|
|
2666
|
+
? t("hardware.led.step", { color, seconds: led.delays[i] }) : color));
|
|
2667
|
+
const lit = led.lit;
|
|
2668
|
+
const lights = ["red", "green", "blue"];
|
|
2669
|
+
const on = lit ? lights.filter((name) => lit[name]) : [];
|
|
2670
|
+
const color = lit ? LIT_COLORS[lights.map((name) => (lit[name] ? "1" : "0")).join("")] : null;
|
|
2671
|
+
return h("dl", { class: "facts" },
|
|
2672
|
+
fact(t("hardware.column.driver"), driver(led)),
|
|
2673
|
+
fact(t("hardware.led.pattern"), steps.join(" → ")),
|
|
2674
|
+
fact(t("hardware.led.lit"), ...(lit
|
|
2675
|
+
? [h("span", { class: "led hw-dot", style: `--led-a: ${LED_COLORS[color]}` }),
|
|
2676
|
+
on.length ? on.map((name) => tOf("hardware.light", name)).join(" + ") : t("hardware.led.off"),
|
|
2677
|
+
" ", h("code", {}, color)]
|
|
2678
|
+
: [tOf("hardware.level", "unknown")])),
|
|
2679
|
+
fact(t("hardware.pins"), lights.map((name) => t("hardware.pin", {
|
|
2680
|
+
name: tOf("hardware.light", name), pin: led.pins[name],
|
|
2681
|
+
})).join(", ")));
|
|
2682
|
+
}));
|
|
2683
|
+
$("hw-readers").replaceChildren(...info.readers.map((reader) => h("dl", { class: "facts" },
|
|
2684
|
+
fact(t("hardware.column.driver"), driver(reader)),
|
|
2685
|
+
fact(t("hardware.reader.program"), t(reader.reader ? "hardware.driver.running" : "hardware.reader.not_running")),
|
|
2686
|
+
fact(t("hardware.reader.tag"), reader.uid ? h("code", {}, reader.uid) : t("hardware.reader.no_tag")),
|
|
2687
|
+
reader.error ? fact(t("hardware.reader.error"), reader.error) : null,
|
|
2688
|
+
fact(t("hardware.reader.changed"), reader.changed_at ? t("hardware.time", {
|
|
2689
|
+
time: hardwareClock(reader.changed_at), ago: agoText(info.time - reader.changed_at),
|
|
2690
|
+
}) : "–"),
|
|
2691
|
+
fact(t("hardware.pins"), Object.entries(reader.pins).map(([name, pin]) => t("hardware.pin", {
|
|
2692
|
+
name: name.replace("pin_", "").toUpperCase(), pin,
|
|
2693
|
+
})).join(", ")))));
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
async function loadHardware() {
|
|
2697
|
+
const turn = ++hardwareTurn;
|
|
2698
|
+
clearTimeout(hardwareTimer);
|
|
2699
|
+
const wanted = () => turn === hardwareTurn && session && session.role === "admin" && !isLibrary()
|
|
2700
|
+
&& currentTab === "system";
|
|
2701
|
+
if (!wanted()) return;
|
|
2702
|
+
if (!document.hidden) {
|
|
2703
|
+
try {
|
|
2704
|
+
const info = await api("GET", "/api/box/hardware");
|
|
2705
|
+
if (wanted()) renderHardware(info);
|
|
2706
|
+
} catch {
|
|
2707
|
+
// the box restarts, or the session has ended: the next turn tries again, the login ends the asking
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
if (wanted()) hardwareTimer = setTimeout(loadHardware, HARDWARE_INTERVAL);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
2714
|
+
// system
|
|
2715
|
+
|
|
2716
|
+
async function loadSystem() {
|
|
2717
|
+
refreshStatus();
|
|
2718
|
+
if (session.role === "admin") loadUsers();
|
|
2719
|
+
if (session.role === "admin") loadBackups();
|
|
2720
|
+
if (session.role === "admin") loadRestore();
|
|
2721
|
+
if (isLibrary()) return;
|
|
2722
|
+
loadLibrary();
|
|
2723
|
+
if (session.role === "admin") loadVersion();
|
|
2724
|
+
if (session.role === "admin") loadHardware();
|
|
2725
|
+
const [activities, albums, tags] = await Promise.all([
|
|
2726
|
+
run(() => api("GET", "/api/activities")), run(() => api("GET", "/api/albums")), run(() => api("GET", "/api/tags")),
|
|
2727
|
+
]);
|
|
2728
|
+
if (!activities) return;
|
|
2729
|
+
const albumTitle = Object.fromEntries((albums || []).map((a) => [a.id, a.title]));
|
|
2730
|
+
const tagName = Object.fromEntries((tags || []).map((tag) => [tag.uid, tag.name]));
|
|
2731
|
+
$("activities-body").replaceChildren(...activities.map((a) => h("tr", {},
|
|
2732
|
+
h("td", {}, tagName[a.tag_uid] || h("code", {}, a.tag_uid)),
|
|
2733
|
+
h("td", {}, albumTitle[a.album_id] || a.album_id),
|
|
2734
|
+
h("td", {}, a.track_id + 1),
|
|
2735
|
+
h("td", {}, formatTime(a.track_time)),
|
|
2736
|
+
h("td", {}, a.finished ? t("common.yes") : t("common.no")))));
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// the backups that the library, or a box without a library, makes by itself (docs/developer/operations.md,
|
|
2740
|
+
// section 10.2)
|
|
2741
|
+
function formatSize(bytes) {
|
|
2742
|
+
if (!Number.isFinite(bytes)) return t("common.size_unknown"); // e.g. a release without the size of its archive
|
|
2743
|
+
const units = ["Bytes", "kB", "MB", "GB"];
|
|
2744
|
+
let value = bytes, unit = 0;
|
|
2745
|
+
while (value >= 1000 && unit < units.length - 1) { value /= 1000; unit += 1; }
|
|
2746
|
+
const digits = unit && value < 10 ? 1 : 0;
|
|
2747
|
+
return `${value.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits })} ${units[unit]}`;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// "2026-10-10 03:00", the local time of the library
|
|
2751
|
+
const toTheMinute = (text) => new Date(text.replace(" ", "T")).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
|
2752
|
+
let backupTimer = null; // looks again while a backup that was asked for on this page is being made
|
|
2753
|
+
const BACKUP_KINDS = ["plain", "albums"]; // the two schedules: without the albums, with them
|
|
2754
|
+
const backupsPath = () => (isLibrary() ? "/api/library/backups" : "/api/backups");
|
|
2755
|
+
|
|
2756
|
+
async function loadBackups(fill = true) {
|
|
2757
|
+
const status = await run(() => api("GET", backupsPath()));
|
|
2758
|
+
if (status) showBackups(status, fill);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
function showBackups(status, fill = true) {
|
|
2762
|
+
// a box that works with a library leaves the backups to it
|
|
2763
|
+
$("backup-kept").hidden = !status.active;
|
|
2764
|
+
const form = $("backup-form");
|
|
2765
|
+
for (const kind of BACKUP_KINDS) {
|
|
2766
|
+
const schedule = status[kind];
|
|
2767
|
+
const field = (name) => form.elements[`${kind}-${name}`];
|
|
2768
|
+
if (fill) {
|
|
2769
|
+
field("enabled").checked = schedule.enabled;
|
|
2770
|
+
for (const name of ["every", "unit", "time", "keep", "folder"]) field(name).value = schedule[name];
|
|
2771
|
+
field("folder").placeholder = t("backup.folder.placeholder", { folder: schedule.default_folder });
|
|
2772
|
+
}
|
|
2773
|
+
const last = schedule.last;
|
|
2774
|
+
$(`backup-state-${kind}`).textContent = [
|
|
2775
|
+
t("backup.state.folder", { folder: schedule.folder }),
|
|
2776
|
+
last ? (last.ok ? t("backup.state.last", { when: when(last.when) })
|
|
2777
|
+
: t("backup.state.failed", { when: when(last.when), message: last.message })) : "",
|
|
2778
|
+
schedule.next ? t("backup.state.next", { when: toTheMinute(schedule.next) }) : "",
|
|
2779
|
+
].filter(Boolean).join(" ");
|
|
2780
|
+
}
|
|
2781
|
+
$("backup-state").textContent = status.running ? t("backup.state.running") : "";
|
|
2782
|
+
$("backup-now").disabled = $("backup-now-albums").disabled = status.running;
|
|
2783
|
+
$("backups-body").replaceChildren(...status.backups.map((backup) => {
|
|
2784
|
+
const path = `${backupsPath()}/${encodeURIComponent(backup.name)}`;
|
|
2785
|
+
return h("tr", {},
|
|
2786
|
+
h("td", {}, toTheMinute(backup.when)),
|
|
2787
|
+
h("td", {}, backup.albums ? t("backup.with_albums") : t("backup.without_albums")),
|
|
2788
|
+
h("td", {}, formatSize(backup.size)),
|
|
2789
|
+
h("td", {},
|
|
2790
|
+
h("a", { href: path, download: backup.name, title: backup.name }, t("common.download")), " ",
|
|
2791
|
+
h("button", {
|
|
2792
|
+
class: "link", onclick: async () => {
|
|
2793
|
+
const status = await run(() => api("POST", "/api/restore", { name: backup.name }));
|
|
2794
|
+
if (!status) return;
|
|
2795
|
+
restoreAsk = true; // the page asks as soon as the backup is unpacked
|
|
2796
|
+
showRestore(status);
|
|
2797
|
+
},
|
|
2798
|
+
}, t("restore.put_back")), " ",
|
|
2799
|
+
h("button", {
|
|
2800
|
+
class: "link danger-text", onclick: async () => {
|
|
2801
|
+
if (!confirm(t("backup.delete.confirm", { when: toTheMinute(backup.when) }))) return;
|
|
2802
|
+
await run(() => api("DELETE", path), t("backup.deleted"));
|
|
2803
|
+
loadBackups(false);
|
|
2804
|
+
},
|
|
2805
|
+
}, t("common.delete"))));
|
|
2806
|
+
}));
|
|
2807
|
+
if (!status.backups.length) {
|
|
2808
|
+
$("backups-body").append(h("tr", {}, h("td", { colSpan: 4, class: "muted" }, t("backup.none"))));
|
|
2809
|
+
}
|
|
2810
|
+
clearTimeout(backupTimer);
|
|
2811
|
+
if (status.running && backupTimer !== null && currentTab === "system") {
|
|
2812
|
+
backupTimer = setTimeout(() => loadBackups(false), 2000);
|
|
2813
|
+
} else {
|
|
2814
|
+
backupTimer = null;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
$("backup-form").addEventListener("submit", async (event) => {
|
|
2819
|
+
event.preventDefault();
|
|
2820
|
+
const form = event.target;
|
|
2821
|
+
const schedule = (kind) => {
|
|
2822
|
+
const field = (name) => form.elements[`${kind}-${name}`];
|
|
2823
|
+
return {
|
|
2824
|
+
enabled: field("enabled").checked, every: Number(field("every").value), unit: field("unit").value,
|
|
2825
|
+
time: field("time").value, keep: Number(field("keep").value), folder: field("folder").value.trim(),
|
|
2826
|
+
};
|
|
2827
|
+
};
|
|
2828
|
+
const status = await run(() => api("PUT", backupsPath(), {
|
|
2829
|
+
plain: schedule("plain"), albums: schedule("albums"),
|
|
2830
|
+
}), t("common.saved"));
|
|
2831
|
+
if (status) showBackups(status);
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
for (const [id, albums] of [["backup-now", false], ["backup-now-albums", true]]) {
|
|
2835
|
+
$(id).addEventListener("click", async () => {
|
|
2836
|
+
const status = await run(() => api("POST", backupsPath(), { albums }), t("backup.started"));
|
|
2837
|
+
if (!status) return;
|
|
2838
|
+
backupTimer = 0; // from now on the page looks until this backup is made
|
|
2839
|
+
showBackups(status, false);
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// putting a backup back (docs/developer/operations.md, section 10.3): the backup is unpacked beside the data, the page
|
|
2844
|
+
// asks once, and the box or the library starts again with it
|
|
2845
|
+
let restoreTimer = null;
|
|
2846
|
+
let restoreAsk = false; // this page unpacked the backup: it asks at once whether to put it back
|
|
2847
|
+
|
|
2848
|
+
async function loadRestore() {
|
|
2849
|
+
const status = await run(() => api("GET", "/api/restore"));
|
|
2850
|
+
if (status) showRestore(status);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
function showRestore(status) {
|
|
2854
|
+
const busy = status.state === "staging" || status.state === "confirmed";
|
|
2855
|
+
const lines = [];
|
|
2856
|
+
if (status.state === "staging") lines.push(t("restore.state.staging"));
|
|
2857
|
+
if (status.state === "staged") lines.push(t("restore.state.staged", { when: when(status.about.created) }));
|
|
2858
|
+
if (status.state === "idle" && status.error) lines.push(t("restore.state.failed", { message: status.error }));
|
|
2859
|
+
const last = status.last;
|
|
2860
|
+
if (last) {
|
|
2861
|
+
lines.push(t("restore.state.last", { when: when(last.done), created: when(last.backup.created) }));
|
|
2862
|
+
if (last.missing && last.missing.length) {
|
|
2863
|
+
lines.push(t("restore.state.missing", {
|
|
2864
|
+
n: last.missing.length, albums: last.missing.map((album) => `${album.title} (${album.folder})`).join(", "),
|
|
2865
|
+
}));
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
$("restore-state").textContent = lines.join(" ");
|
|
2869
|
+
$("restore-file").disabled = busy;
|
|
2870
|
+
$("restore-confirm").hidden = $("restore-discard").hidden = status.state !== "staged";
|
|
2871
|
+
clearTimeout(restoreTimer);
|
|
2872
|
+
if (status.state === "staging" && currentTab === "system") restoreTimer = setTimeout(loadRestore, 2000);
|
|
2873
|
+
if (status.state === "staged" && restoreAsk) {
|
|
2874
|
+
restoreAsk = false;
|
|
2875
|
+
askRestore(status);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// asks once; a backup that is not put back is discarded
|
|
2880
|
+
async function askRestore(status) {
|
|
2881
|
+
const about = status.about;
|
|
2882
|
+
const text = [
|
|
2883
|
+
t("restore.confirm", { when: when(about.created) }),
|
|
2884
|
+
about.albums ? t("restore.confirm.albums") : "",
|
|
2885
|
+
status.library ? t("restore.confirm.library") : "",
|
|
2886
|
+
t("restore.confirm.backup"),
|
|
2887
|
+
].filter(Boolean).join("\n\n");
|
|
2888
|
+
if (!confirm(text)) {
|
|
2889
|
+
const discarded = await run(() => api("DELETE", "/api/restore"), t("restore.discarded"));
|
|
2890
|
+
if (discarded) showRestore(discarded);
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
const confirmed = await run(() => api("POST", "/api/restore/confirm"));
|
|
2894
|
+
if (!confirmed) return;
|
|
2895
|
+
$("restore-state").textContent = t("restore.state.restarting");
|
|
2896
|
+
$("restore-confirm").hidden = $("restore-discard").hidden = true;
|
|
2897
|
+
waitForRestart();
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// the program stops and starts again: the page waits until it is gone and back, then loads anew
|
|
2901
|
+
function waitForRestart() {
|
|
2902
|
+
let gone = false;
|
|
2903
|
+
const started = Date.now();
|
|
2904
|
+
const look = async () => {
|
|
2905
|
+
try {
|
|
2906
|
+
const response = await fetch("/api/session", { credentials: "same-origin", cache: "no-store" });
|
|
2907
|
+
if ((gone || Date.now() - started > 60000) && response.status < 500) {
|
|
2908
|
+
location.reload();
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
gone = gone || response.status >= 500;
|
|
2912
|
+
} catch {
|
|
2913
|
+
gone = true;
|
|
2914
|
+
}
|
|
2915
|
+
setTimeout(look, 1500);
|
|
2916
|
+
};
|
|
2917
|
+
setTimeout(look, 1500);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
$("restore-file").addEventListener("change", async (event) => {
|
|
2921
|
+
const file = event.target.files[0];
|
|
2922
|
+
event.target.value = "";
|
|
2923
|
+
if (!file) return;
|
|
2924
|
+
$("restore-state").textContent = t("restore.state.uploading", { size: formatSize(file.size) });
|
|
2925
|
+
$("restore-file").disabled = true;
|
|
2926
|
+
let response;
|
|
2927
|
+
uploadsRunning += 1; // a backup with its albums may take longer than the idle timeout: no absence
|
|
2928
|
+
try {
|
|
2929
|
+
response = await fetch("/api/restore", {
|
|
2930
|
+
method: "PUT", credentials: "same-origin", body: file,
|
|
2931
|
+
headers: { ...requestHeaders(), "Content-Type": "application/octet-stream" },
|
|
2932
|
+
});
|
|
2933
|
+
} catch {
|
|
2934
|
+
toast(t("restore.send_failed"), true);
|
|
2935
|
+
loadRestore();
|
|
2936
|
+
return;
|
|
2937
|
+
} finally {
|
|
2938
|
+
uploadsRunning -= 1;
|
|
2939
|
+
noteInput();
|
|
2940
|
+
}
|
|
2941
|
+
const data = await response.json().catch(() => ({}));
|
|
2942
|
+
if (!response.ok) {
|
|
2943
|
+
toast(errorText(data, response.status), true);
|
|
2944
|
+
loadRestore();
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2947
|
+
restoreAsk = true;
|
|
2948
|
+
showRestore(data);
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
$("restore-confirm").addEventListener("click", async () => {
|
|
2952
|
+
const status = await run(() => api("GET", "/api/restore"));
|
|
2953
|
+
if (status && status.state === "staged") askRestore(status);
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
$("restore-discard").addEventListener("click", async () => {
|
|
2957
|
+
const status = await run(() => api("DELETE", "/api/restore"), t("restore.discarded"));
|
|
2958
|
+
if (status) showRestore(status);
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
// the own password (every user)
|
|
2962
|
+
$("own-password").addEventListener("submit", async (event) => {
|
|
2963
|
+
event.preventDefault();
|
|
2964
|
+
const form = event.target;
|
|
2965
|
+
if (form.password.value !== form.repeat.value) {
|
|
2966
|
+
return toast(t("password.mismatch"), true);
|
|
2967
|
+
}
|
|
2968
|
+
try {
|
|
2969
|
+
await api("PUT", "/api/session/password", { current_password: form.current.value, password: form.password.value });
|
|
2970
|
+
} catch (e) {
|
|
2971
|
+
if (e.status === 401) return; // the login is shown
|
|
2972
|
+
return toast(e.status === 403 ? t("password.wrong") : e.status === 429 ? t("login.error.too_many") : e.message, true);
|
|
2973
|
+
}
|
|
2974
|
+
form.reset();
|
|
2975
|
+
toast(t("password.changed"));
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
// users of the web page (admins)
|
|
2979
|
+
async function loadUsers() {
|
|
2980
|
+
const users = await run(() => api("GET", "/api/users"));
|
|
2981
|
+
if (!users) return;
|
|
2982
|
+
$("users-body").replaceChildren(...users.map((user) => {
|
|
2983
|
+
const path = `/api/users/${encodeURIComponent(user.username)}`;
|
|
2984
|
+
const isSelf = user.username === session.username;
|
|
2985
|
+
const password = h("input", {
|
|
2986
|
+
type: "password", class: "inline-input", placeholder: t("users.password.placeholder"), minLength: 8, required: true,
|
|
2987
|
+
autocomplete: "new-password", "aria-label": t("users.password.label", { name: user.username }),
|
|
2988
|
+
});
|
|
2989
|
+
const form = h("form", { class: "user-password", onsubmit: async (event) => {
|
|
2990
|
+
event.preventDefault();
|
|
2991
|
+
const result = await run(() => api("PUT", `${path}/password`, { password: password.value }),
|
|
2992
|
+
t("users.password.done", { name: user.username }));
|
|
2993
|
+
if (result !== undefined) form.reset();
|
|
2994
|
+
} }, password, h("button", { type: "submit" }, t("users.password.submit")));
|
|
2995
|
+
const remove = h("button", {
|
|
2996
|
+
class: "link danger-text", disabled: isSelf, title: isSelf ? t("users.remove.self") : "",
|
|
2997
|
+
onclick: async () => {
|
|
2998
|
+
if (!confirm(t("users.remove.confirm", { name: user.username }))) return;
|
|
2999
|
+
const result = await run(() => api("DELETE", path), t("users.removed"));
|
|
3000
|
+
if (result !== undefined) loadUsers();
|
|
3001
|
+
},
|
|
3002
|
+
}, t("common.delete"));
|
|
3003
|
+
return h("tr", {},
|
|
3004
|
+
h("td", {}, user.username, isSelf ? h("span", { class: "muted small" }, ` ${t("users.self")}`) : ""),
|
|
3005
|
+
h("td", {}, user.role),
|
|
3006
|
+
h("td", {}, form),
|
|
3007
|
+
h("td", {}, remove));
|
|
3008
|
+
}));
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
$("user-new").addEventListener("submit", async (event) => {
|
|
3012
|
+
event.preventDefault();
|
|
3013
|
+
const form = event.target;
|
|
3014
|
+
const user = await run(() => api("POST", "/api/users", {
|
|
3015
|
+
username: form.username.value.trim(), password: form.password.value, role: form.role.value,
|
|
3016
|
+
}));
|
|
3017
|
+
if (user) {
|
|
3018
|
+
toast(t("users.created", { name: user.username }));
|
|
3019
|
+
form.reset();
|
|
3020
|
+
loadUsers();
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
|
|
3024
|
+
$("sys-sanity").addEventListener("click", async () => {
|
|
3025
|
+
const result = await run(() => api("POST", "/api/sanity-check"));
|
|
3026
|
+
if (!result) return;
|
|
3027
|
+
const output = $("sys-output");
|
|
3028
|
+
output.hidden = false;
|
|
3029
|
+
output.textContent = (result.passed ? t("system.sanity.ok") : t("system.sanity.problems")) +
|
|
3030
|
+
(result.messages.length ? `\n\n${result.messages.join("\n")}` : "");
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
$("sys-rebuild").addEventListener("click", async () => {
|
|
3034
|
+
if (!confirm(t("system.rebuild.confirm"))) return;
|
|
3035
|
+
await run(() => api("POST", "/api/activities/rebuild"), t("system.rebuild.done"));
|
|
3036
|
+
loadSystem();
|
|
3037
|
+
});
|
|
3038
|
+
|
|
3039
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
3040
|
+
// start
|
|
3041
|
+
|
|
3042
|
+
(async () => {
|
|
3043
|
+
await loadTexts();
|
|
3044
|
+
await loadIdentity();
|
|
3045
|
+
try {
|
|
3046
|
+
showApp(await api("GET", "/api/session"));
|
|
3047
|
+
} catch {
|
|
3048
|
+
showLogin();
|
|
3049
|
+
}
|
|
3050
|
+
})();
|