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.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. 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
+ })();