@tomorrowos/sdk 0.9.28 → 0.9.29
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.
- package/README.md +6 -1
- package/VERCEL_SETUP.md +1 -1
- package/dist/cli.js +42 -11
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/server.ts +2 -2
- package/templates/cms-starter-v0/.env.example +19 -0
- package/templates/cms-starter-v0/README.md +60 -0
- package/templates/cms-starter-v0/assets/logo.svg +4 -0
- package/templates/cms-starter-v0/brand.json +23 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/android.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/brightsign.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/lg-webos.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/samsung.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/toolbox.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/index.html +290 -0
- package/templates/cms-starter-v0/cms-panel/methods.js +2361 -0
- package/templates/cms-starter-v0/cms-panel/panel.css +1127 -0
- package/templates/cms-starter-v0/cms-panel/uploads/.gitkeep +1 -0
- package/templates/cms-starter-v0/package.json +35 -0
- package/templates/cms-starter-v0/policy.example.json +30 -0
- package/templates/cms-starter-v0/preview/app/layout.tsx +11 -0
- package/templates/cms-starter-v0/preview/app/page.tsx +3 -0
- package/templates/cms-starter-v0/preview/next-env.d.ts +2 -0
- package/templates/cms-starter-v0/preview/next.config.mjs +18 -0
- package/templates/cms-starter-v0/preview/tsconfig.json +20 -0
- package/templates/cms-starter-v0/server.ts +75 -0
- package/templates/cms-starter-v0/tsconfig.json +13 -0
- package/templates/cms-starter-v0/vercel.json +7 -0
|
@@ -0,0 +1,2361 @@
|
|
|
1
|
+
const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
|
|
2
|
+
|
|
3
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
4
|
+
let playlistsCatalog = [];
|
|
5
|
+
|
|
6
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
7
|
+
let devicesCache = [];
|
|
8
|
+
|
|
9
|
+
/** @type {string|null} */
|
|
10
|
+
let selectedPlaylistId = null;
|
|
11
|
+
|
|
12
|
+
/** True while creating a new playlist locally (assets allowed before Save). */
|
|
13
|
+
let playlistDraftActive = false;
|
|
14
|
+
|
|
15
|
+
/** @type {{ id: string, assetId?: string, url: string, name: string, type: string, durationMs: number }[]} */
|
|
16
|
+
let editorItems = [];
|
|
17
|
+
|
|
18
|
+
/** Item id currently being dragged in the asset list (reorder). */
|
|
19
|
+
let draggingEditorItemId = null;
|
|
20
|
+
|
|
21
|
+
/** @type {string|null} */
|
|
22
|
+
let publishModalDeviceId = null;
|
|
23
|
+
let publishInProgress = false;
|
|
24
|
+
/** @type {string|null} */
|
|
25
|
+
let editingDeviceNameId = null;
|
|
26
|
+
/** @type {string} */
|
|
27
|
+
let editingDeviceNameValue = "";
|
|
28
|
+
|
|
29
|
+
let devicePollTimer = null;
|
|
30
|
+
let serverStatusTimer = null;
|
|
31
|
+
/** @type {string} Latest known `@tomorrowos/sdk` version from GET /status. */
|
|
32
|
+
let cachedSdkVersion = "";
|
|
33
|
+
/** @type {number|null} CMS server boot time (ms) from GET /devices. */
|
|
34
|
+
let serverStartedAtMs = null;
|
|
35
|
+
|
|
36
|
+
const UPDATE_SDK_PROMPT =
|
|
37
|
+
"Follow @tomorrowos/sdk REPLIT_UPGRADE.md to upgrade my CMS with the latest SDK.";
|
|
38
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
39
|
+
let reconnectGraceTimer = null;
|
|
40
|
+
let uploadQueue = [];
|
|
41
|
+
let uploadInProgress = false;
|
|
42
|
+
|
|
43
|
+
const UPLOAD_MAX_RETRIES = 3;
|
|
44
|
+
const DEVICE_RECONNECT_GRACE_MS = 60000;
|
|
45
|
+
const UPLOAD_TIMEOUT_MS = 120000;
|
|
46
|
+
|
|
47
|
+
function escapeHtml(value) {
|
|
48
|
+
return String(value ?? "")
|
|
49
|
+
.replace(/&/g, "&")
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/"/g, """);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDurationMs(ms) {
|
|
56
|
+
if (!Number.isFinite(ms) || ms < 0) return "0s";
|
|
57
|
+
const totalSec = Math.floor(ms / 1000);
|
|
58
|
+
const days = Math.floor(totalSec / 86400);
|
|
59
|
+
const hours = Math.floor((totalSec % 86400) / 3600);
|
|
60
|
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
|
61
|
+
const seconds = totalSec % 60;
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (days > 0) parts.push(`${days}d`);
|
|
64
|
+
if (days > 0 || hours > 0) parts.push(`${hours}h`);
|
|
65
|
+
if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`);
|
|
66
|
+
parts.push(`${seconds}s`);
|
|
67
|
+
return parts.join(" ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatDateTimeSeconds(iso) {
|
|
71
|
+
if (!iso) return "—";
|
|
72
|
+
const d = new Date(iso);
|
|
73
|
+
if (Number.isNaN(d.getTime())) return "—";
|
|
74
|
+
return d.toLocaleString(undefined, {
|
|
75
|
+
year: "numeric",
|
|
76
|
+
month: "short",
|
|
77
|
+
day: "numeric",
|
|
78
|
+
hour: "2-digit",
|
|
79
|
+
minute: "2-digit",
|
|
80
|
+
second: "2-digit",
|
|
81
|
+
hour12: false
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveDeviceConnectionState(device) {
|
|
86
|
+
if (device.connected) return "online";
|
|
87
|
+
if (serverStartedAtMs != null) {
|
|
88
|
+
const elapsed = Date.now() - serverStartedAtMs;
|
|
89
|
+
if (elapsed >= 0 && elapsed < DEVICE_RECONNECT_GRACE_MS) return "loading";
|
|
90
|
+
}
|
|
91
|
+
return "offline";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function statusLedClass(connectionState) {
|
|
95
|
+
if (connectionState === "online") return "status-led status-led--online";
|
|
96
|
+
if (connectionState === "loading") return "status-led status-led--loading";
|
|
97
|
+
return "status-led status-led--offline";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function statusLedTitle(connectionState) {
|
|
101
|
+
if (connectionState === "online") return "Device connected";
|
|
102
|
+
if (connectionState === "loading") return "Waiting for device to reconnect";
|
|
103
|
+
return "Device not connected";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatDeviceOnlineLabel(device, connectionState) {
|
|
107
|
+
if (connectionState === "loading") return "Reconnecting…";
|
|
108
|
+
if (connectionState !== "online") return "Not active";
|
|
109
|
+
const bootIso = device.lastBootAt;
|
|
110
|
+
if (!bootIso) return "Not active";
|
|
111
|
+
const bootMs = new Date(bootIso).getTime();
|
|
112
|
+
if (Number.isNaN(bootMs)) return "Not active";
|
|
113
|
+
return formatDurationMs(Date.now() - bootMs);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scheduleReconnectGraceRerender() {
|
|
117
|
+
if (reconnectGraceTimer) {
|
|
118
|
+
clearTimeout(reconnectGraceTimer);
|
|
119
|
+
reconnectGraceTimer = null;
|
|
120
|
+
}
|
|
121
|
+
if (serverStartedAtMs == null) return;
|
|
122
|
+
|
|
123
|
+
const hasLoadingDevice = devicesCache.some(
|
|
124
|
+
(device) => resolveDeviceConnectionState(device) === "loading"
|
|
125
|
+
);
|
|
126
|
+
if (!hasLoadingDevice) return;
|
|
127
|
+
|
|
128
|
+
const remaining = DEVICE_RECONNECT_GRACE_MS - (Date.now() - serverStartedAtMs);
|
|
129
|
+
if (remaining <= 0) {
|
|
130
|
+
renderDeviceCards();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
reconnectGraceTimer = setTimeout(() => {
|
|
135
|
+
reconnectGraceTimer = null;
|
|
136
|
+
renderDeviceCards();
|
|
137
|
+
}, remaining + 50);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function showResult(data) {
|
|
141
|
+
document.getElementById("result").textContent = JSON.stringify(data, null, 2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setAssetUploadBusy(isBusy) {
|
|
145
|
+
const addBtn = document.getElementById("addAssetBtn");
|
|
146
|
+
if (addBtn) {
|
|
147
|
+
addBtn.disabled = isBusy;
|
|
148
|
+
addBtn.textContent = isBusy ? "Uploading..." : "+";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function updateUploadStatusUi(status) {
|
|
153
|
+
const shell = document.getElementById("uploadStatusShell");
|
|
154
|
+
const text = document.getElementById("uploadStatusText");
|
|
155
|
+
const bar = document.getElementById("uploadProgressBar");
|
|
156
|
+
const queue = document.getElementById("uploadQueueText");
|
|
157
|
+
if (!shell || !text || !bar || !queue) return;
|
|
158
|
+
|
|
159
|
+
if (!status || status.hidden) {
|
|
160
|
+
shell.classList.add("hidden");
|
|
161
|
+
bar.style.width = "0%";
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
shell.classList.remove("hidden");
|
|
166
|
+
text.textContent = status.text || "Uploading...";
|
|
167
|
+
queue.textContent = status.queueText || "";
|
|
168
|
+
const percent = Math.max(0, Math.min(100, Number(status.percent) || 0));
|
|
169
|
+
bar.style.width = `${percent}%`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isLocalPanelHost(hostname) {
|
|
173
|
+
const h = String(hostname || "").toLowerCase();
|
|
174
|
+
return h === "localhost" || h === "127.0.0.1" || h === "[::1]";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeMediaBaseUrl(raw) {
|
|
178
|
+
let s = String(raw || "").trim();
|
|
179
|
+
if (!s) return "";
|
|
180
|
+
if (!/^https?:\/\//i.test(s)) s = `http://${s}`;
|
|
181
|
+
try {
|
|
182
|
+
return new URL(s).origin;
|
|
183
|
+
} catch {
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** User-saved LAN override (local dev only). Not used on hosted CMS unless explicitly set. */
|
|
189
|
+
function getExplicitLanMediaBase() {
|
|
190
|
+
return normalizeMediaBaseUrl(localStorage.getItem(PANEL_MEDIA_BASE_KEY) || "");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Prefill CMS URL for screens from server-detected LAN IP:port when running on localhost
|
|
195
|
+
* and the operator has not saved a value yet. Auto-persists so thumbs/publish work without Save.
|
|
196
|
+
*/
|
|
197
|
+
function applyDefaultCmsUrlForScreens(suggested) {
|
|
198
|
+
if (!isLocalPanelHost(window.location.hostname)) return;
|
|
199
|
+
const normalized = normalizeMediaBaseUrl(suggested);
|
|
200
|
+
if (!normalized) return;
|
|
201
|
+
|
|
202
|
+
const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
|
|
203
|
+
const saved = getExplicitLanMediaBase();
|
|
204
|
+
if (saved) {
|
|
205
|
+
if (cmsBaseInput && !String(cmsBaseInput.value || "").trim()) {
|
|
206
|
+
cmsBaseInput.value = saved;
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (cmsBaseInput) cmsBaseInput.value = normalized;
|
|
212
|
+
localStorage.setItem(PANEL_MEDIA_BASE_KEY, normalized);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function playlistHasRelativeMediaUrls(playlist) {
|
|
216
|
+
return (playlist?.items || []).some((item) => {
|
|
217
|
+
const url = String(item?.url || "").trim();
|
|
218
|
+
return url && !/^https?:\/\//i.test(url);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Base URL sent on publish only when needed.
|
|
224
|
+
* - Hosted (e.g. Replit): use public origin when items are relative (/uploads/...).
|
|
225
|
+
* - Local: prefer saved LAN override; otherwise fall back to current origin for dev.
|
|
226
|
+
* - Returns null to omit mediaBaseUrl (playlist already has absolute https URLs).
|
|
227
|
+
*/
|
|
228
|
+
function getPublishMediaBaseUrl(selectedPlaylists) {
|
|
229
|
+
const explicitLan = getExplicitLanMediaBase();
|
|
230
|
+
if (explicitLan) return explicitLan;
|
|
231
|
+
|
|
232
|
+
const needsRewrite = selectedPlaylists.some(playlistHasRelativeMediaUrls);
|
|
233
|
+
if (!needsRewrite) return null;
|
|
234
|
+
|
|
235
|
+
if (!isLocalPanelHost(window.location.hostname)) {
|
|
236
|
+
return window.location.origin;
|
|
237
|
+
}
|
|
238
|
+
// Local dev: same-origin fallback so panel publish/verify works without LAN override.
|
|
239
|
+
return window.location.origin;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Browser-side check URL — always same-origin so localhost panel can reach /uploads. */
|
|
243
|
+
function resolveVerificationMediaUrl(url) {
|
|
244
|
+
const p = String(url || "").trim();
|
|
245
|
+
if (!p) return "";
|
|
246
|
+
if (p.startsWith("/")) return p;
|
|
247
|
+
try {
|
|
248
|
+
const parsed = new URL(p);
|
|
249
|
+
const path = parsed.pathname + parsed.search;
|
|
250
|
+
if (path.startsWith("/uploads/") || path.startsWith("/screenshots/")) {
|
|
251
|
+
return path;
|
|
252
|
+
}
|
|
253
|
+
return p;
|
|
254
|
+
} catch {
|
|
255
|
+
return p;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function resolvePublishMediaUrl(url, mediaBaseUrl) {
|
|
260
|
+
const p = String(url || "").trim();
|
|
261
|
+
if (!p) return "";
|
|
262
|
+
if (/^https?:\/\//i.test(p)) return p;
|
|
263
|
+
const base =
|
|
264
|
+
normalizeMediaBaseUrl(mediaBaseUrl) ||
|
|
265
|
+
getExplicitLanMediaBase() ||
|
|
266
|
+
window.location.origin;
|
|
267
|
+
if (!base) return "";
|
|
268
|
+
return `${base}${p.startsWith("/") ? p : `/${p}`}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function verifyMediaAssetReachable(url) {
|
|
272
|
+
if (!url) return { ok: false, reason: "missing URL" };
|
|
273
|
+
|
|
274
|
+
const candidates = [];
|
|
275
|
+
const sameOriginPath = resolveVerificationMediaUrl(url);
|
|
276
|
+
if (sameOriginPath) candidates.push(sameOriginPath);
|
|
277
|
+
if (url && url !== sameOriginPath) candidates.push(url);
|
|
278
|
+
|
|
279
|
+
let lastReason = "unreachable";
|
|
280
|
+
for (const target of candidates) {
|
|
281
|
+
try {
|
|
282
|
+
// CMS static files are served on GET only (no HEAD / Range support).
|
|
283
|
+
const res = await fetch(target, { method: "GET", cache: "no-store" });
|
|
284
|
+
if (res.ok) return { ok: true };
|
|
285
|
+
lastReason = `HTTP ${res.status}`;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
lastReason = err?.message || "network error";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { ok: false, reason: lastReason };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function verifyPlaylistsAssetsReady(playlists, mediaBaseUrl, onProgress) {
|
|
294
|
+
const tasks = [];
|
|
295
|
+
for (const pl of playlists) {
|
|
296
|
+
for (const item of pl.items || []) {
|
|
297
|
+
const name = String(item?.name || item?.url || "asset").split("/").pop() || "asset";
|
|
298
|
+
const resolvedUrl = resolveVerificationMediaUrl(item?.url);
|
|
299
|
+
tasks.push({
|
|
300
|
+
playlist: pl.name || pl.id,
|
|
301
|
+
name,
|
|
302
|
+
url: item?.url,
|
|
303
|
+
resolvedUrl
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (tasks.length === 0) {
|
|
309
|
+
return { ok: false, failures: [{ playlist: "—", name: "—", reason: "no assets in playlist" }] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const failures = [];
|
|
313
|
+
let done = 0;
|
|
314
|
+
for (const task of tasks) {
|
|
315
|
+
onProgress?.(done, tasks.length, task.name);
|
|
316
|
+
if (!task.resolvedUrl) {
|
|
317
|
+
failures.push({
|
|
318
|
+
playlist: task.playlist,
|
|
319
|
+
name: task.name,
|
|
320
|
+
reason: "could not resolve media URL"
|
|
321
|
+
});
|
|
322
|
+
} else {
|
|
323
|
+
const result = await verifyMediaAssetReachable(task.resolvedUrl);
|
|
324
|
+
if (!result.ok) {
|
|
325
|
+
failures.push({
|
|
326
|
+
playlist: task.playlist,
|
|
327
|
+
name: task.name,
|
|
328
|
+
reason: result.reason || "unreachable"
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
done += 1;
|
|
333
|
+
onProgress?.(done, tasks.length, task.name);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { ok: failures.length === 0, failures, total: tasks.length };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function refreshPlaylistsForPublish() {
|
|
340
|
+
try {
|
|
341
|
+
const res = await fetch("/playlists");
|
|
342
|
+
let data = {};
|
|
343
|
+
try {
|
|
344
|
+
data = await res.json();
|
|
345
|
+
} catch {
|
|
346
|
+
data = {};
|
|
347
|
+
}
|
|
348
|
+
if (!res.ok || !Array.isArray(data.playlists)) return false;
|
|
349
|
+
playlistsCatalog = data.playlists;
|
|
350
|
+
renderPlaylistCatalog();
|
|
351
|
+
return true;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function resetPublishModalStatus() {
|
|
358
|
+
publishInProgress = false;
|
|
359
|
+
updatePublishStatusUi({ hidden: true });
|
|
360
|
+
const confirmBtn = document.getElementById("publishConfirmBtn");
|
|
361
|
+
if (confirmBtn) {
|
|
362
|
+
confirmBtn.disabled = false;
|
|
363
|
+
confirmBtn.textContent = "Publish selected";
|
|
364
|
+
}
|
|
365
|
+
const modal = document.getElementById("publishModal");
|
|
366
|
+
modal?.querySelectorAll("[data-close-modal]").forEach((el) => {
|
|
367
|
+
if (el instanceof HTMLButtonElement) el.disabled = false;
|
|
368
|
+
});
|
|
369
|
+
document
|
|
370
|
+
.querySelectorAll("#publishChecklist input[type=checkbox]")
|
|
371
|
+
.forEach((el) => {
|
|
372
|
+
if (el instanceof HTMLInputElement) el.disabled = false;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function setPublishModalBusy(busy) {
|
|
377
|
+
publishInProgress = busy;
|
|
378
|
+
const confirmBtn = document.getElementById("publishConfirmBtn");
|
|
379
|
+
if (confirmBtn) {
|
|
380
|
+
confirmBtn.disabled = busy;
|
|
381
|
+
confirmBtn.textContent = busy ? "Publishing..." : "Publish selected";
|
|
382
|
+
}
|
|
383
|
+
document
|
|
384
|
+
.querySelectorAll("#publishChecklist input[type=checkbox]")
|
|
385
|
+
.forEach((el) => {
|
|
386
|
+
if (el instanceof HTMLInputElement) el.disabled = busy;
|
|
387
|
+
});
|
|
388
|
+
document.querySelectorAll("#publishModal [data-close-modal]").forEach((el) => {
|
|
389
|
+
if (el instanceof HTMLButtonElement) el.disabled = busy;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function updatePublishStatusUi(status) {
|
|
394
|
+
const shell = document.getElementById("publishStatusShell");
|
|
395
|
+
const text = document.getElementById("publishStatusText");
|
|
396
|
+
const bar = document.getElementById("publishProgressBar");
|
|
397
|
+
if (!shell || !text || !bar) return;
|
|
398
|
+
|
|
399
|
+
if (!status || status.hidden) {
|
|
400
|
+
shell.classList.add("hidden");
|
|
401
|
+
shell.classList.remove("publish-status--error", "publish-status--success");
|
|
402
|
+
bar.style.width = "0%";
|
|
403
|
+
text.textContent = "";
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
shell.classList.remove("hidden");
|
|
408
|
+
shell.classList.toggle("publish-status--error", !!status.isError);
|
|
409
|
+
shell.classList.toggle("publish-status--success", !!status.isSuccess);
|
|
410
|
+
text.textContent = status.text || "Working...";
|
|
411
|
+
const percent = Math.max(0, Math.min(100, Number(status.percent) || 0));
|
|
412
|
+
bar.style.width = `${percent}%`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Resolve media URLs in the editor (save / thumbnails). */
|
|
416
|
+
function getMediaBaseOrigin() {
|
|
417
|
+
const explicitLan = getExplicitLanMediaBase();
|
|
418
|
+
if (explicitLan) return explicitLan;
|
|
419
|
+
if (!isLocalPanelHost(window.location.hostname)) return window.location.origin;
|
|
420
|
+
const draft = normalizeMediaBaseUrl(document.getElementById("cmsDeviceBaseUrl")?.value);
|
|
421
|
+
return draft;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function saveCmsDeviceBaseUrl() {
|
|
425
|
+
const normalized = normalizeMediaBaseUrl(
|
|
426
|
+
document.getElementById("cmsDeviceBaseUrl")?.value
|
|
427
|
+
);
|
|
428
|
+
if (!normalized) {
|
|
429
|
+
alert("Enter a valid URL, e.g. http://192.168.1.105:3000");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
document.getElementById("cmsDeviceBaseUrl").value = normalized;
|
|
433
|
+
localStorage.setItem(PANEL_MEDIA_BASE_KEY, normalized);
|
|
434
|
+
showResult({ status: "saved", mediaBaseUrl: normalized });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function absoluteMediaUrl(path) {
|
|
438
|
+
const p = String(path || "").trim();
|
|
439
|
+
if (!p) return "";
|
|
440
|
+
if (/^https?:\/\//i.test(p)) return p;
|
|
441
|
+
const base = getMediaBaseOrigin();
|
|
442
|
+
if (!base) {
|
|
443
|
+
throw new Error("Set CMS URL for screens (LAN IP, not localhost).");
|
|
444
|
+
}
|
|
445
|
+
return `${base}${p.startsWith("/") ? p : `/${p}`}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function inferMediaType(filename, mime) {
|
|
449
|
+
const lower = String(filename || "").toLowerCase();
|
|
450
|
+
const m = String(mime || "").toLowerCase();
|
|
451
|
+
if (m.startsWith("video/")) return "video";
|
|
452
|
+
if (m.startsWith("image/")) return "image";
|
|
453
|
+
if (lower.endsWith(".wgt") || lower.endsWith(".zip")) return "widget";
|
|
454
|
+
if (/\.(mp4|webm|mov|m4v)$/.test(lower)) return "video";
|
|
455
|
+
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(lower)) return "image";
|
|
456
|
+
return "image";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function defaultDurationMs(type) {
|
|
460
|
+
if (type === "video") return 30000;
|
|
461
|
+
if (type === "widget") return 20000;
|
|
462
|
+
return 10000;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function clampVideoDurationMs(ms) {
|
|
466
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
467
|
+
return Math.min(3600 * 1000, Math.max(1000, Math.round(ms)));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Probe duration from a local File or an absolute media URL. */
|
|
471
|
+
function probeVideoDurationInBrowser(fileOrUrl) {
|
|
472
|
+
if (!fileOrUrl) return Promise.resolve(null);
|
|
473
|
+
|
|
474
|
+
let objectUrl = null;
|
|
475
|
+
const src = typeof fileOrUrl === "string" ? fileOrUrl : null;
|
|
476
|
+
if (!src && fileOrUrl instanceof File) {
|
|
477
|
+
objectUrl = URL.createObjectURL(fileOrUrl);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
const video = document.createElement("video");
|
|
482
|
+
let settled = false;
|
|
483
|
+
const timer = setTimeout(() => done(null), 20000);
|
|
484
|
+
|
|
485
|
+
const cleanup = () => {
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
video.removeAttribute("src");
|
|
488
|
+
try {
|
|
489
|
+
video.load();
|
|
490
|
+
} catch (_) {}
|
|
491
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const done = (value) => {
|
|
495
|
+
if (settled) return;
|
|
496
|
+
settled = true;
|
|
497
|
+
cleanup();
|
|
498
|
+
resolve(clampVideoDurationMs(value));
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const readDuration = () => {
|
|
502
|
+
const seconds = Number(video.duration);
|
|
503
|
+
if (Number.isFinite(seconds) && seconds > 0 && seconds !== Infinity) {
|
|
504
|
+
done(seconds * 1000);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
video.preload = "auto";
|
|
509
|
+
video.muted = true;
|
|
510
|
+
video.playsInline = true;
|
|
511
|
+
video.setAttribute("playsinline", "");
|
|
512
|
+
video.addEventListener("loadedmetadata", readDuration);
|
|
513
|
+
video.addEventListener("durationchange", readDuration);
|
|
514
|
+
video.addEventListener("loadeddata", readDuration);
|
|
515
|
+
video.addEventListener("canplay", readDuration);
|
|
516
|
+
video.addEventListener("error", () => done(null));
|
|
517
|
+
|
|
518
|
+
if (src) {
|
|
519
|
+
video.src = src;
|
|
520
|
+
} else if (objectUrl) {
|
|
521
|
+
video.src = objectUrl;
|
|
522
|
+
} else {
|
|
523
|
+
done(null);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
video.load();
|
|
528
|
+
} catch (_) {
|
|
529
|
+
done(null);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function resolveVideoDurationMs(file, type, uploadData) {
|
|
535
|
+
if (type !== "video") return defaultDurationMs(type);
|
|
536
|
+
|
|
537
|
+
// Local File metadata is the most reliable source during upload.
|
|
538
|
+
const fromFile = await probeVideoDurationInBrowser(file);
|
|
539
|
+
if (fromFile) return fromFile;
|
|
540
|
+
|
|
541
|
+
const fromServerRaw = Number(uploadData?.durationMs);
|
|
542
|
+
const fromServer =
|
|
543
|
+
Number.isFinite(fromServerRaw) && fromServerRaw > 0
|
|
544
|
+
? clampVideoDurationMs(fromServerRaw)
|
|
545
|
+
: null;
|
|
546
|
+
if (fromServer) return fromServer;
|
|
547
|
+
|
|
548
|
+
if (uploadData?.url) {
|
|
549
|
+
try {
|
|
550
|
+
const fromUrl = await probeVideoDurationInBrowser(absoluteMediaUrl(uploadData.url));
|
|
551
|
+
if (fromUrl) return fromUrl;
|
|
552
|
+
} catch (_) {}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return defaultDurationMs(type);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function normalizeDurationMs(item) {
|
|
559
|
+
const minMs = 1000;
|
|
560
|
+
const maxMs = 3600 * 1000;
|
|
561
|
+
let ms = Number(item?.durationMs);
|
|
562
|
+
if (!Number.isFinite(ms) || ms < minMs) return defaultDurationMs(item?.type);
|
|
563
|
+
if (ms === 1000000) return defaultDurationMs(item?.type);
|
|
564
|
+
return Math.min(maxMs, ms);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function buildScheduleFromForm() {
|
|
568
|
+
const schedule = {};
|
|
569
|
+
const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
|
|
570
|
+
const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
|
|
571
|
+
const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
|
|
572
|
+
const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
|
|
573
|
+
if (startDate) schedule.startDate = startDate;
|
|
574
|
+
if (endDate) schedule.endDate = endDate;
|
|
575
|
+
if (startTime) schedule.start = startTime;
|
|
576
|
+
if (endTime) schedule.end = endTime;
|
|
577
|
+
return Object.keys(schedule).length > 0 ? schedule : undefined;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function loadScheduleIntoForm(schedule) {
|
|
581
|
+
const s = schedule || {};
|
|
582
|
+
document.getElementById("scheduleStartDate").value = s.startDate || "";
|
|
583
|
+
document.getElementById("scheduleEndDate").value = s.endDate || "";
|
|
584
|
+
document.getElementById("scheduleStartTime").value = s.start || "";
|
|
585
|
+
document.getElementById("scheduleEndTime").value = s.end || "";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function parseScheduleDateTimeMs(dateStr, timeStr, defaultTime) {
|
|
589
|
+
const date = String(dateStr || "").trim();
|
|
590
|
+
if (!date) return null;
|
|
591
|
+
const time = String(timeStr || "").trim() || defaultTime;
|
|
592
|
+
const value = new Date(`${date}T${time}:00`);
|
|
593
|
+
const ms = value.getTime();
|
|
594
|
+
return Number.isNaN(ms) ? null : ms;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** True when the playlist has no start/end date or time (always-on / default content). */
|
|
598
|
+
function isUnscheduledPublishedPlaylist(playlist) {
|
|
599
|
+
const schedule = playlist?.schedule;
|
|
600
|
+
if (!schedule) return true;
|
|
601
|
+
return !(
|
|
602
|
+
schedule.startDate ||
|
|
603
|
+
schedule.endDate ||
|
|
604
|
+
schedule.start ||
|
|
605
|
+
schedule.end
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function getPublishedPlaylistStartMs(playlist) {
|
|
610
|
+
const schedule = playlist?.schedule;
|
|
611
|
+
if (isUnscheduledPublishedPlaylist(playlist) || !schedule) return null;
|
|
612
|
+
return parseScheduleDateTimeMs(schedule.startDate, schedule.start, "00:00");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function getPublishedPlaylistEndMs(playlist) {
|
|
616
|
+
const schedule = playlist?.schedule;
|
|
617
|
+
if (isUnscheduledPublishedPlaylist(playlist) || !schedule) return null;
|
|
618
|
+
return parseScheduleDateTimeMs(schedule.endDate, schedule.end, "23:59");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function isPublishedPlaylistActiveNow(playlist, now = new Date()) {
|
|
622
|
+
if (isUnscheduledPublishedPlaylist(playlist)) return true;
|
|
623
|
+
const nowMs = now.getTime();
|
|
624
|
+
const startMs = getPublishedPlaylistStartMs(playlist);
|
|
625
|
+
const endMs = getPublishedPlaylistEndMs(playlist);
|
|
626
|
+
if (startMs !== null && nowMs < startMs) return false;
|
|
627
|
+
if (endMs !== null && nowMs >= endMs) return false;
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Green-light target for the device card.
|
|
633
|
+
* - Prefer an in-window scheduled playlist (latest start wins).
|
|
634
|
+
* - Else among always-on (no schedule) playlists, prefer the latest publishedAt
|
|
635
|
+
* so a later publish overrides earlier ones in the activity indicator.
|
|
636
|
+
*/
|
|
637
|
+
function pickScheduledPlaylistForIndicator(playlists, now = new Date()) {
|
|
638
|
+
const list = Array.isArray(playlists) ? playlists : [];
|
|
639
|
+
const active = list
|
|
640
|
+
.map((playlist, index) => ({ playlist, index }))
|
|
641
|
+
.filter(({ playlist }) => isPublishedPlaylistActiveNow(playlist, now));
|
|
642
|
+
if (!active.length) return null;
|
|
643
|
+
|
|
644
|
+
const scheduled = active.filter(
|
|
645
|
+
({ playlist }) => !isUnscheduledPublishedPlaylist(playlist)
|
|
646
|
+
);
|
|
647
|
+
// Match player: scheduled takeovers beat always-on; always-on uses latest publish.
|
|
648
|
+
const pool = scheduled.length ? scheduled : active;
|
|
649
|
+
|
|
650
|
+
pool.sort((a, b) => {
|
|
651
|
+
if (scheduled.length) {
|
|
652
|
+
const aStart = getPublishedPlaylistStartMs(a.playlist) ?? 0;
|
|
653
|
+
const bStart = getPublishedPlaylistStartMs(b.playlist) ?? 0;
|
|
654
|
+
if (aStart !== bStart) return bStart - aStart;
|
|
655
|
+
}
|
|
656
|
+
const aPublished = new Date(a.playlist?.publishedAt || 0).getTime() || 0;
|
|
657
|
+
const bPublished = new Date(b.playlist?.publishedAt || 0).getTime() || 0;
|
|
658
|
+
if (aPublished !== bPublished) return bPublished - aPublished;
|
|
659
|
+
// Same publish time → later entry in the assignments list (last published) wins.
|
|
660
|
+
return b.index - a.index;
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return pool[0]?.playlist || null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function getSelectedPlaylist() {
|
|
667
|
+
return playlistsCatalog.find((p) => p.id === selectedPlaylistId) || null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function fetchPlaylists() {
|
|
671
|
+
try {
|
|
672
|
+
const res = await fetch("/playlists");
|
|
673
|
+
let data = {};
|
|
674
|
+
try {
|
|
675
|
+
data = await res.json();
|
|
676
|
+
} catch {
|
|
677
|
+
data = {};
|
|
678
|
+
}
|
|
679
|
+
if (!res.ok) {
|
|
680
|
+
console.warn("[CMS] GET /playlists failed", res.status, data);
|
|
681
|
+
if (res.status === 404) {
|
|
682
|
+
showResult({
|
|
683
|
+
status: "failed",
|
|
684
|
+
error:
|
|
685
|
+
"CMS server is missing /playlists. Restart CMS with @tomorrowos/sdk 0.3.10 or newer."
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (Array.isArray(data.playlists)) {
|
|
691
|
+
playlistsCatalog = data.playlists;
|
|
692
|
+
renderPlaylistCatalog();
|
|
693
|
+
if (selectedPlaylistId && !getSelectedPlaylist()) {
|
|
694
|
+
selectedPlaylistId = null;
|
|
695
|
+
playlistDraftActive = false;
|
|
696
|
+
loadEditorFromSelection();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.error("[CMS] fetchPlaylists:", err);
|
|
701
|
+
showResult({ status: "failed", error: err.message });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function renderPlaylistCatalog() {
|
|
706
|
+
const list = document.getElementById("playlistCatalog");
|
|
707
|
+
if (!list) return;
|
|
708
|
+
list.innerHTML = "";
|
|
709
|
+
|
|
710
|
+
if (playlistsCatalog.length === 0) {
|
|
711
|
+
const li = document.createElement("li");
|
|
712
|
+
li.className = "playlist-catalog-item";
|
|
713
|
+
li.textContent = "No playlists yet. Tap +.";
|
|
714
|
+
list.appendChild(li);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const pl of playlistsCatalog) {
|
|
719
|
+
const li = document.createElement("li");
|
|
720
|
+
li.className = "playlist-catalog-item";
|
|
721
|
+
if (pl.id === selectedPlaylistId) li.classList.add("playlist-catalog-item--active");
|
|
722
|
+
li.innerHTML = `<strong>${escapeHtml(pl.name)}</strong><small>${(pl.items || []).length} items</small>`;
|
|
723
|
+
li.addEventListener("click", () => {
|
|
724
|
+
playlistDraftActive = false;
|
|
725
|
+
selectedPlaylistId = pl.id;
|
|
726
|
+
loadEditorFromSelection();
|
|
727
|
+
renderPlaylistCatalog();
|
|
728
|
+
});
|
|
729
|
+
list.appendChild(li);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function isPlaylistEditorOpen() {
|
|
734
|
+
return playlistDraftActive || !!selectedPlaylistId;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function updatePlaylistEditorVisibility() {
|
|
738
|
+
const section = document.getElementById("playlistEditorSection");
|
|
739
|
+
if (!section) return;
|
|
740
|
+
section.classList.toggle("hidden", !isPlaylistEditorOpen());
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function loadEditorFromSelection() {
|
|
744
|
+
const pl = getSelectedPlaylist();
|
|
745
|
+
const nameInput = document.getElementById("playlistName");
|
|
746
|
+
const editorTitle = document.getElementById("editorTitle");
|
|
747
|
+
|
|
748
|
+
if (!pl) {
|
|
749
|
+
if (playlistDraftActive) {
|
|
750
|
+
if (editorTitle) editorTitle.textContent = "New playlist";
|
|
751
|
+
updatePlaylistEditorVisibility();
|
|
752
|
+
renderEditorAssets();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
playlistDraftActive = false;
|
|
756
|
+
if (editorTitle) editorTitle.textContent = "Playlist editor";
|
|
757
|
+
if (nameInput) nameInput.value = "";
|
|
758
|
+
editorItems = [];
|
|
759
|
+
loadScheduleIntoForm(null);
|
|
760
|
+
updatePlaylistEditorVisibility();
|
|
761
|
+
renderEditorAssets();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
playlistDraftActive = false;
|
|
766
|
+
if (editorTitle) editorTitle.textContent = `Edit: ${pl.name}`;
|
|
767
|
+
if (nameInput) nameInput.value = pl.name || "";
|
|
768
|
+
loadScheduleIntoForm(pl.schedule);
|
|
769
|
+
editorItems = (pl.items || []).map((item) => ({
|
|
770
|
+
id: crypto.randomUUID(),
|
|
771
|
+
assetId: item.assetId,
|
|
772
|
+
url: item.url,
|
|
773
|
+
name: item.url?.split("/").pop() || "asset",
|
|
774
|
+
type: item.type || "image",
|
|
775
|
+
durationMs: normalizeDurationMs(item)
|
|
776
|
+
}));
|
|
777
|
+
renderEditorAssets();
|
|
778
|
+
updatePlaylistEditorVisibility();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function clearPlaylistItemDropTargets() {
|
|
782
|
+
document
|
|
783
|
+
.querySelectorAll(".playlist-item--drop-target, .playlist-item--dragging")
|
|
784
|
+
.forEach((el) => {
|
|
785
|
+
el.classList.remove("playlist-item--drop-target", "playlist-item--dragging");
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function reorderEditorItems(fromId, toId) {
|
|
790
|
+
const fromIdx = editorItems.findIndex((x) => x.id === fromId);
|
|
791
|
+
const toIdx = editorItems.findIndex((x) => x.id === toId);
|
|
792
|
+
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
|
|
793
|
+
const [moved] = editorItems.splice(fromIdx, 1);
|
|
794
|
+
editorItems.splice(toIdx, 0, moved);
|
|
795
|
+
renderEditorAssets();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function attachPlaylistItemDragDrop(li, item) {
|
|
799
|
+
li.dataset.itemId = item.id;
|
|
800
|
+
|
|
801
|
+
const handle = document.createElement("button");
|
|
802
|
+
handle.type = "button";
|
|
803
|
+
handle.className = "playlist-item-drag-handle";
|
|
804
|
+
handle.setAttribute("aria-label", "Drag to reorder");
|
|
805
|
+
handle.title = "Drag to reorder";
|
|
806
|
+
handle.textContent = "⋮⋮";
|
|
807
|
+
handle.draggable = true;
|
|
808
|
+
|
|
809
|
+
handle.addEventListener("dragstart", (e) => {
|
|
810
|
+
e.dataTransfer.setData("text/plain", item.id);
|
|
811
|
+
e.dataTransfer.effectAllowed = "move";
|
|
812
|
+
draggingEditorItemId = item.id;
|
|
813
|
+
li.classList.add("playlist-item--dragging");
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
handle.addEventListener("dragend", () => {
|
|
817
|
+
draggingEditorItemId = null;
|
|
818
|
+
clearPlaylistItemDropTargets();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
li.addEventListener("dragover", (e) => {
|
|
822
|
+
e.preventDefault();
|
|
823
|
+
e.dataTransfer.dropEffect = "move";
|
|
824
|
+
if (draggingEditorItemId && draggingEditorItemId !== item.id) {
|
|
825
|
+
li.classList.add("playlist-item--drop-target");
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
li.addEventListener("dragleave", (e) => {
|
|
830
|
+
if (!li.contains(e.relatedTarget)) {
|
|
831
|
+
li.classList.remove("playlist-item--drop-target");
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
li.addEventListener("drop", (e) => {
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
li.classList.remove("playlist-item--drop-target");
|
|
838
|
+
const fromId = e.dataTransfer.getData("text/plain") || draggingEditorItemId;
|
|
839
|
+
if (!fromId || fromId === item.id) return;
|
|
840
|
+
reorderEditorItems(fromId, item.id);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
li.insertBefore(handle, li.firstChild);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function renderEditorAssets() {
|
|
847
|
+
const list = document.getElementById("playlistList");
|
|
848
|
+
const empty = document.getElementById("playlistEmpty");
|
|
849
|
+
draggingEditorItemId = null;
|
|
850
|
+
list.querySelectorAll(".playlist-item").forEach((el) => el.remove());
|
|
851
|
+
|
|
852
|
+
if (!isPlaylistEditorOpen()) {
|
|
853
|
+
empty.classList.remove("hidden");
|
|
854
|
+
empty.textContent = "Select or create a playlist (Playlists +).";
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (editorItems.length === 0) {
|
|
859
|
+
empty.classList.remove("hidden");
|
|
860
|
+
empty.textContent = "No assets yet. Tap + to upload.";
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
empty.classList.add("hidden");
|
|
865
|
+
|
|
866
|
+
for (const item of editorItems) {
|
|
867
|
+
const li = document.createElement("li");
|
|
868
|
+
li.className = "playlist-item";
|
|
869
|
+
|
|
870
|
+
if (item.type === "image" || item.type === "video") {
|
|
871
|
+
const thumb = document.createElement(item.type === "video" ? "video" : "img");
|
|
872
|
+
thumb.className = "playlist-item-thumb";
|
|
873
|
+
thumb.draggable = false;
|
|
874
|
+
try {
|
|
875
|
+
thumb.src = absoluteMediaUrl(item.url);
|
|
876
|
+
} catch {
|
|
877
|
+
thumb.removeAttribute("src");
|
|
878
|
+
}
|
|
879
|
+
if (item.type === "video") {
|
|
880
|
+
thumb.muted = true;
|
|
881
|
+
thumb.playsInline = true;
|
|
882
|
+
}
|
|
883
|
+
li.appendChild(thumb);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const name = document.createElement("div");
|
|
887
|
+
name.className = "playlist-item-name";
|
|
888
|
+
name.textContent = item.name;
|
|
889
|
+
|
|
890
|
+
const meta = document.createElement("div");
|
|
891
|
+
meta.className = "playlist-item-meta";
|
|
892
|
+
meta.textContent = `${item.type} · ${(item.durationMs / 1000).toFixed(0)}s`;
|
|
893
|
+
|
|
894
|
+
const actions = document.createElement("div");
|
|
895
|
+
actions.className = "playlist-item-actions";
|
|
896
|
+
const durInput = document.createElement("input");
|
|
897
|
+
const isVideo = item.type === "video";
|
|
898
|
+
durInput.type = "number";
|
|
899
|
+
durInput.min = "1";
|
|
900
|
+
durInput.max = "3600";
|
|
901
|
+
durInput.value = String(Math.round(item.durationMs / 1000));
|
|
902
|
+
if (isVideo) {
|
|
903
|
+
durInput.readOnly = true;
|
|
904
|
+
durInput.disabled = true;
|
|
905
|
+
durInput.title = "Video duration is auto-detected and cannot be edited.";
|
|
906
|
+
}
|
|
907
|
+
durInput.addEventListener("change", () => {
|
|
908
|
+
if (isVideo) {
|
|
909
|
+
durInput.value = String(Math.round(item.durationMs / 1000));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
item.durationMs = Math.min(3600, Math.max(1, Number(durInput.value) || 10)) * 1000;
|
|
913
|
+
meta.textContent = `${item.type} · ${Math.round(item.durationMs / 1000)}s`;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const downloadBtn = document.createElement("button");
|
|
917
|
+
downloadBtn.type = "button";
|
|
918
|
+
downloadBtn.className = "playlist-item-btn";
|
|
919
|
+
downloadBtn.textContent = "Download";
|
|
920
|
+
downloadBtn.title = "Download asset";
|
|
921
|
+
downloadBtn.addEventListener("click", () => void downloadMediaAsset(item));
|
|
922
|
+
|
|
923
|
+
const removeBtn = document.createElement("button");
|
|
924
|
+
removeBtn.type = "button";
|
|
925
|
+
removeBtn.className = "danger playlist-item-btn";
|
|
926
|
+
removeBtn.textContent = "Remove";
|
|
927
|
+
removeBtn.addEventListener("click", () => {
|
|
928
|
+
editorItems = editorItems.filter((x) => x.id !== item.id);
|
|
929
|
+
renderEditorAssets();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
actions.appendChild(durInput);
|
|
933
|
+
actions.appendChild(downloadBtn);
|
|
934
|
+
actions.appendChild(removeBtn);
|
|
935
|
+
li.appendChild(name);
|
|
936
|
+
li.appendChild(meta);
|
|
937
|
+
li.appendChild(actions);
|
|
938
|
+
attachPlaylistItemDragDrop(li, item);
|
|
939
|
+
list.appendChild(li);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function saveCurrentPlaylist() {
|
|
944
|
+
const name = String(document.getElementById("playlistName")?.value || "").trim();
|
|
945
|
+
if (!name) {
|
|
946
|
+
alert("Enter a playlist name.");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (editorItems.length === 0) {
|
|
950
|
+
alert("Add at least one asset before saving.");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let items;
|
|
955
|
+
try {
|
|
956
|
+
items = editorItems.map((item) => ({
|
|
957
|
+
url: absoluteMediaUrl(item.url),
|
|
958
|
+
assetId: item.assetId,
|
|
959
|
+
type: item.type,
|
|
960
|
+
durationMs: item.durationMs
|
|
961
|
+
}));
|
|
962
|
+
} catch (err) {
|
|
963
|
+
alert(err.message);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const body = {
|
|
968
|
+
id: selectedPlaylistId || undefined,
|
|
969
|
+
name,
|
|
970
|
+
schedule: buildScheduleFromForm(),
|
|
971
|
+
items
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const res = await fetch("/playlists", {
|
|
975
|
+
method: "POST",
|
|
976
|
+
headers: { "Content-Type": "application/json" },
|
|
977
|
+
body: JSON.stringify(body)
|
|
978
|
+
});
|
|
979
|
+
const data = await res.json();
|
|
980
|
+
showResult(data);
|
|
981
|
+
|
|
982
|
+
if (!res.ok) {
|
|
983
|
+
alert(data.error || "Save failed");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
playlistDraftActive = false;
|
|
988
|
+
selectedPlaylistId = data.playlist?.id || selectedPlaylistId;
|
|
989
|
+
await fetchPlaylists();
|
|
990
|
+
loadEditorFromSelection();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function deleteCurrentPlaylist() {
|
|
994
|
+
if (!selectedPlaylistId) {
|
|
995
|
+
alert("Select a playlist to delete.");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const pl = getSelectedPlaylist();
|
|
999
|
+
if (
|
|
1000
|
+
!confirm(
|
|
1001
|
+
`Delete playlist "${pl?.name}"? Devices already playing it keep their cached copy until Clear or reboot without sync. New devices cannot receive it.`
|
|
1002
|
+
)
|
|
1003
|
+
) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const res = await fetch(`/playlists/${encodeURIComponent(selectedPlaylistId)}`, {
|
|
1008
|
+
method: "DELETE"
|
|
1009
|
+
});
|
|
1010
|
+
const data = await res.json();
|
|
1011
|
+
showResult(data);
|
|
1012
|
+
if (!res.ok) {
|
|
1013
|
+
alert(data.error || "Delete failed");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
selectedPlaylistId = null;
|
|
1018
|
+
playlistDraftActive = false;
|
|
1019
|
+
editorItems = [];
|
|
1020
|
+
await fetchPlaylists();
|
|
1021
|
+
loadEditorFromSelection();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function newPlaylistDraft() {
|
|
1025
|
+
selectedPlaylistId = null;
|
|
1026
|
+
playlistDraftActive = true;
|
|
1027
|
+
const nameInput = document.getElementById("playlistName");
|
|
1028
|
+
if (nameInput) {
|
|
1029
|
+
nameInput.value = "";
|
|
1030
|
+
nameInput.focus();
|
|
1031
|
+
}
|
|
1032
|
+
loadScheduleIntoForm(null);
|
|
1033
|
+
editorItems = [];
|
|
1034
|
+
renderPlaylistCatalog();
|
|
1035
|
+
renderEditorAssets();
|
|
1036
|
+
updatePlaylistEditorVisibility();
|
|
1037
|
+
const editorTitle = document.getElementById("editorTitle");
|
|
1038
|
+
if (editorTitle) editorTitle.textContent = "New playlist";
|
|
1039
|
+
document.getElementById("playlistEditorSection")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1040
|
+
showResult({
|
|
1041
|
+
status: "draft",
|
|
1042
|
+
message: "New playlist — enter a name, add assets with + (right), then Save playlist."
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async function fetchDevices() {
|
|
1047
|
+
try {
|
|
1048
|
+
const res = await fetch("/devices");
|
|
1049
|
+
const data = await res.json();
|
|
1050
|
+
if (typeof data.serverStartedAt === "string") {
|
|
1051
|
+
const parsed = new Date(data.serverStartedAt).getTime();
|
|
1052
|
+
if (!Number.isNaN(parsed)) {
|
|
1053
|
+
const prev = serverStartedAtMs;
|
|
1054
|
+
serverStartedAtMs = parsed;
|
|
1055
|
+
if (prev !== parsed) scheduleReconnectGraceRerender();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (Array.isArray(data.devices)) {
|
|
1059
|
+
devicesCache = data.devices;
|
|
1060
|
+
renderDeviceCards();
|
|
1061
|
+
scheduleReconnectGraceRerender();
|
|
1062
|
+
}
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
showResult({ status: "failed", error: err.message });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function screenshotThumbUrl(screenshot) {
|
|
1069
|
+
if (!screenshot?.url) return "";
|
|
1070
|
+
const cacheBust = encodeURIComponent(screenshot.capturedAt || Date.now());
|
|
1071
|
+
const joiner = screenshot.url.includes("?") ? "&" : "?";
|
|
1072
|
+
return `${screenshot.url}${joiner}v=${cacheBust}`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function appendDeviceMetaRow(meta, label, value) {
|
|
1076
|
+
const row = document.createElement("div");
|
|
1077
|
+
row.className = "device-meta-row";
|
|
1078
|
+
const dt = document.createElement("dt");
|
|
1079
|
+
dt.textContent = label;
|
|
1080
|
+
const dd = document.createElement("dd");
|
|
1081
|
+
dd.textContent = value;
|
|
1082
|
+
row.appendChild(dt);
|
|
1083
|
+
row.appendChild(dd);
|
|
1084
|
+
meta.appendChild(row);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function hasConfiguredOnOffTimer(timer) {
|
|
1088
|
+
return !!(
|
|
1089
|
+
timer &&
|
|
1090
|
+
typeof timer.turnOnAt === "string" &&
|
|
1091
|
+
typeof timer.turnOffAt === "string" &&
|
|
1092
|
+
timer.turnOnAt &&
|
|
1093
|
+
timer.turnOffAt
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function appendHardwareTimerStatusRow(meta, device) {
|
|
1098
|
+
const timer = device.onOffTimer || null;
|
|
1099
|
+
const configured = hasConfiguredOnOffTimer(timer);
|
|
1100
|
+
|
|
1101
|
+
const row = document.createElement("div");
|
|
1102
|
+
row.className = "device-meta-row device-meta-row--timer-status";
|
|
1103
|
+
|
|
1104
|
+
const dt = document.createElement("dt");
|
|
1105
|
+
dt.textContent = "Hardware timer status";
|
|
1106
|
+
|
|
1107
|
+
const dd = document.createElement("dd");
|
|
1108
|
+
const statusWrap = document.createElement("span");
|
|
1109
|
+
statusWrap.className = "hardware-timer-status";
|
|
1110
|
+
statusWrap.tabIndex = 0;
|
|
1111
|
+
|
|
1112
|
+
const statusValue = document.createElement("span");
|
|
1113
|
+
statusValue.className = configured
|
|
1114
|
+
? "hardware-timer-status__value hardware-timer-status__value--on"
|
|
1115
|
+
: "hardware-timer-status__value hardware-timer-status__value--off";
|
|
1116
|
+
statusValue.textContent = configured ? "On" : "Off";
|
|
1117
|
+
|
|
1118
|
+
const popup = document.createElement("span");
|
|
1119
|
+
popup.className = "hardware-timer-status__popup";
|
|
1120
|
+
popup.setAttribute("role", "tooltip");
|
|
1121
|
+
if (configured) {
|
|
1122
|
+
const onLine = document.createElement("div");
|
|
1123
|
+
const onLabel = document.createElement("strong");
|
|
1124
|
+
onLabel.textContent = "Turn on";
|
|
1125
|
+
onLine.appendChild(onLabel);
|
|
1126
|
+
onLine.appendChild(document.createTextNode(` ${timer.turnOnAt}`));
|
|
1127
|
+
|
|
1128
|
+
const offLine = document.createElement("div");
|
|
1129
|
+
const offLabel = document.createElement("strong");
|
|
1130
|
+
offLabel.textContent = "Turn off";
|
|
1131
|
+
offLine.appendChild(offLabel);
|
|
1132
|
+
offLine.appendChild(document.createTextNode(` ${timer.turnOffAt}`));
|
|
1133
|
+
|
|
1134
|
+
popup.appendChild(onLine);
|
|
1135
|
+
popup.appendChild(offLine);
|
|
1136
|
+
} else {
|
|
1137
|
+
popup.textContent = "No timer configured";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
statusWrap.appendChild(statusValue);
|
|
1141
|
+
statusWrap.appendChild(popup);
|
|
1142
|
+
dd.appendChild(statusWrap);
|
|
1143
|
+
row.appendChild(dt);
|
|
1144
|
+
row.appendChild(dd);
|
|
1145
|
+
meta.appendChild(row);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function createDeviceScreenshotThumb(device) {
|
|
1149
|
+
const slot = document.createElement("div");
|
|
1150
|
+
slot.className = "device-screenshot-slot";
|
|
1151
|
+
const screenshot = device.latestScreenshot;
|
|
1152
|
+
if (screenshot?.url) {
|
|
1153
|
+
const btn = document.createElement("button");
|
|
1154
|
+
btn.type = "button";
|
|
1155
|
+
btn.className = "device-screenshot-thumb";
|
|
1156
|
+
btn.title = `Last screenshot — ${formatDateTimeSeconds(screenshot.capturedAt)}`;
|
|
1157
|
+
const img = document.createElement("img");
|
|
1158
|
+
img.alt = `Last screenshot for ${device.deviceId}`;
|
|
1159
|
+
img.src = screenshotThumbUrl(screenshot);
|
|
1160
|
+
btn.appendChild(img);
|
|
1161
|
+
btn.addEventListener("click", () => openScreenshotModal(device.deviceId, screenshot));
|
|
1162
|
+
slot.appendChild(btn);
|
|
1163
|
+
}
|
|
1164
|
+
return slot;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function renderDeviceCards() {
|
|
1168
|
+
const grid = document.getElementById("devicesGrid");
|
|
1169
|
+
if (!grid) return;
|
|
1170
|
+
grid.innerHTML = "";
|
|
1171
|
+
|
|
1172
|
+
if (devicesCache.length === 0) {
|
|
1173
|
+
const empty = document.createElement("p");
|
|
1174
|
+
empty.className = "devices-empty";
|
|
1175
|
+
empty.textContent = "No paired devices yet.";
|
|
1176
|
+
grid.appendChild(empty);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
for (const device of devicesCache) {
|
|
1181
|
+
const card = document.createElement("article");
|
|
1182
|
+
card.className = "device-card";
|
|
1183
|
+
|
|
1184
|
+
const header = document.createElement("div");
|
|
1185
|
+
header.className = "device-card-header";
|
|
1186
|
+
const headerMain = document.createElement("div");
|
|
1187
|
+
headerMain.className = "device-card-header-main";
|
|
1188
|
+
const connectionState = resolveDeviceConnectionState(device);
|
|
1189
|
+
const led = document.createElement("span");
|
|
1190
|
+
led.className = statusLedClass(connectionState);
|
|
1191
|
+
led.title = statusLedTitle(connectionState);
|
|
1192
|
+
led.setAttribute("role", "status");
|
|
1193
|
+
led.setAttribute("aria-label", statusLedTitle(connectionState));
|
|
1194
|
+
const title = document.createElement("h3");
|
|
1195
|
+
title.className = "device-card-title";
|
|
1196
|
+
const isEditingName = editingDeviceNameId === device.deviceId;
|
|
1197
|
+
if (isEditingName) {
|
|
1198
|
+
const input = document.createElement("input");
|
|
1199
|
+
input.type = "text";
|
|
1200
|
+
input.className = "device-card-title-input";
|
|
1201
|
+
input.value = editingDeviceNameValue || device.deviceName || "Screen";
|
|
1202
|
+
input.placeholder = "Device name";
|
|
1203
|
+
input.addEventListener("input", (ev) => {
|
|
1204
|
+
editingDeviceNameValue = String(ev.target.value || "");
|
|
1205
|
+
});
|
|
1206
|
+
input.addEventListener("keydown", (ev) => {
|
|
1207
|
+
if (ev.key === "Enter") {
|
|
1208
|
+
ev.preventDefault();
|
|
1209
|
+
void submitInlineDeviceRename(device.deviceId);
|
|
1210
|
+
} else if (ev.key === "Escape") {
|
|
1211
|
+
ev.preventDefault();
|
|
1212
|
+
cancelInlineDeviceRename();
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
title.appendChild(input);
|
|
1216
|
+
} else {
|
|
1217
|
+
title.textContent = device.deviceName || "Screen";
|
|
1218
|
+
}
|
|
1219
|
+
headerMain.appendChild(led);
|
|
1220
|
+
headerMain.appendChild(title);
|
|
1221
|
+
|
|
1222
|
+
const headerActions = document.createElement("div");
|
|
1223
|
+
headerActions.className = "device-card-header-actions";
|
|
1224
|
+
if (isEditingName) {
|
|
1225
|
+
const saveBtn = document.createElement("button");
|
|
1226
|
+
saveBtn.type = "button";
|
|
1227
|
+
saveBtn.className = "primary";
|
|
1228
|
+
saveBtn.textContent = "Save";
|
|
1229
|
+
saveBtn.addEventListener("click", () =>
|
|
1230
|
+
void submitInlineDeviceRename(device.deviceId)
|
|
1231
|
+
);
|
|
1232
|
+
const cancelBtn = document.createElement("button");
|
|
1233
|
+
cancelBtn.type = "button";
|
|
1234
|
+
cancelBtn.textContent = "Cancel";
|
|
1235
|
+
cancelBtn.addEventListener("click", cancelInlineDeviceRename);
|
|
1236
|
+
headerActions.appendChild(saveBtn);
|
|
1237
|
+
headerActions.appendChild(cancelBtn);
|
|
1238
|
+
} else {
|
|
1239
|
+
const renameBtn = document.createElement("button");
|
|
1240
|
+
renameBtn.type = "button";
|
|
1241
|
+
renameBtn.textContent = "Rename";
|
|
1242
|
+
renameBtn.addEventListener("click", () =>
|
|
1243
|
+
startInlineDeviceRename(device.deviceId, device.deviceName)
|
|
1244
|
+
);
|
|
1245
|
+
headerActions.appendChild(renameBtn);
|
|
1246
|
+
}
|
|
1247
|
+
header.appendChild(headerMain);
|
|
1248
|
+
header.appendChild(headerActions);
|
|
1249
|
+
|
|
1250
|
+
const published = document.createElement("ul");
|
|
1251
|
+
published.className = "device-published-list";
|
|
1252
|
+
const pubs = Array.isArray(device.publishedPlaylists) ? device.publishedPlaylists : [];
|
|
1253
|
+
const indicatorPlaylist = pickScheduledPlaylistForIndicator(pubs);
|
|
1254
|
+
const indicatorPlaylistId = indicatorPlaylist?.playlistId || "";
|
|
1255
|
+
if (pubs.length === 0) {
|
|
1256
|
+
const li = document.createElement("li");
|
|
1257
|
+
li.textContent = "No playlists published";
|
|
1258
|
+
published.appendChild(li);
|
|
1259
|
+
} else {
|
|
1260
|
+
for (const p of pubs) {
|
|
1261
|
+
const li = document.createElement("li");
|
|
1262
|
+
const label = document.createElement("span");
|
|
1263
|
+
label.className = "device-published-label";
|
|
1264
|
+
label.textContent = p.name;
|
|
1265
|
+
const isPlaying = !!indicatorPlaylistId && p.playlistId === indicatorPlaylistId;
|
|
1266
|
+
if (isPlaying) {
|
|
1267
|
+
const playingLight = document.createElement("span");
|
|
1268
|
+
playingLight.className = "playlist-playing-light";
|
|
1269
|
+
playingLight.title = "Active now";
|
|
1270
|
+
label.appendChild(playingLight);
|
|
1271
|
+
}
|
|
1272
|
+
const rm = document.createElement("button");
|
|
1273
|
+
rm.type = "button";
|
|
1274
|
+
rm.textContent = "Remove";
|
|
1275
|
+
rm.addEventListener("click", () => removePlaylistFromDevice(device.deviceId, p.playlistId));
|
|
1276
|
+
li.appendChild(label);
|
|
1277
|
+
li.appendChild(rm);
|
|
1278
|
+
published.appendChild(li);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const metaBlock = document.createElement("div");
|
|
1283
|
+
metaBlock.className = "device-meta-block";
|
|
1284
|
+
|
|
1285
|
+
const metaTop = document.createElement("div");
|
|
1286
|
+
metaTop.className = "device-meta-top";
|
|
1287
|
+
|
|
1288
|
+
const metaPrimary = document.createElement("dl");
|
|
1289
|
+
metaPrimary.className = "device-meta device-meta--primary";
|
|
1290
|
+
const primaryRows = [
|
|
1291
|
+
["Device ID", device.deviceId],
|
|
1292
|
+
["System", device.system || device.platform || "—"],
|
|
1293
|
+
["Player version", device.playerVersion || "—"]
|
|
1294
|
+
];
|
|
1295
|
+
for (const [label, value] of primaryRows) {
|
|
1296
|
+
appendDeviceMetaRow(metaPrimary, label, value);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
metaTop.appendChild(metaPrimary);
|
|
1300
|
+
metaTop.appendChild(createDeviceScreenshotThumb(device));
|
|
1301
|
+
|
|
1302
|
+
const metaSecondary = document.createElement("dl");
|
|
1303
|
+
metaSecondary.className = "device-meta device-meta--secondary";
|
|
1304
|
+
const secondaryRows = [
|
|
1305
|
+
["Device online", formatDeviceOnlineLabel(device, connectionState)],
|
|
1306
|
+
["Last boot", formatDateTimeSeconds(device.lastBootAt)],
|
|
1307
|
+
["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)],
|
|
1308
|
+
["Latest error", device.latestErrorMessage || "—"],
|
|
1309
|
+
["Error at", formatDateTimeSeconds(device.latestErrorAt)]
|
|
1310
|
+
];
|
|
1311
|
+
for (const [label, value] of secondaryRows) {
|
|
1312
|
+
appendDeviceMetaRow(metaSecondary, label, value);
|
|
1313
|
+
}
|
|
1314
|
+
appendHardwareTimerStatusRow(metaSecondary, device);
|
|
1315
|
+
|
|
1316
|
+
metaBlock.appendChild(metaTop);
|
|
1317
|
+
metaBlock.appendChild(metaSecondary);
|
|
1318
|
+
|
|
1319
|
+
const actions = document.createElement("div");
|
|
1320
|
+
actions.className = "device-card-actions";
|
|
1321
|
+
|
|
1322
|
+
const publishBtn = document.createElement("button");
|
|
1323
|
+
publishBtn.type = "button";
|
|
1324
|
+
publishBtn.className = "primary";
|
|
1325
|
+
publishBtn.textContent = "Publish";
|
|
1326
|
+
publishBtn.addEventListener("click", () => openPublishModal(device.deviceId));
|
|
1327
|
+
|
|
1328
|
+
const infoBtn = document.createElement("button");
|
|
1329
|
+
infoBtn.type = "button";
|
|
1330
|
+
infoBtn.textContent = "Info";
|
|
1331
|
+
infoBtn.addEventListener("click", () => deviceAction(device.deviceId, "get-info"));
|
|
1332
|
+
|
|
1333
|
+
const capBtn = document.createElement("button");
|
|
1334
|
+
capBtn.type = "button";
|
|
1335
|
+
capBtn.textContent = "Get capabilities";
|
|
1336
|
+
capBtn.addEventListener("click", () =>
|
|
1337
|
+
deviceAction(device.deviceId, "get-capabilities")
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
const rebootBtn = document.createElement("button");
|
|
1341
|
+
rebootBtn.type = "button";
|
|
1342
|
+
rebootBtn.textContent = "Reboot";
|
|
1343
|
+
rebootBtn.addEventListener("click", () => deviceAction(device.deviceId, "reboot"));
|
|
1344
|
+
|
|
1345
|
+
const clearBtn = document.createElement("button");
|
|
1346
|
+
clearBtn.type = "button";
|
|
1347
|
+
clearBtn.textContent = "Clear";
|
|
1348
|
+
clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
|
|
1349
|
+
|
|
1350
|
+
const logsBtn = document.createElement("button");
|
|
1351
|
+
logsBtn.type = "button";
|
|
1352
|
+
logsBtn.textContent = "Logs";
|
|
1353
|
+
logsBtn.addEventListener("click", () => viewDeviceLogs(device.deviceId));
|
|
1354
|
+
|
|
1355
|
+
const screenshotBtn = document.createElement("button");
|
|
1356
|
+
screenshotBtn.type = "button";
|
|
1357
|
+
screenshotBtn.textContent = "Screenshot";
|
|
1358
|
+
screenshotBtn.addEventListener("click", () => captureDeviceScreenshot(device.deviceId));
|
|
1359
|
+
|
|
1360
|
+
const latestScreenshotBtn = document.createElement("button");
|
|
1361
|
+
latestScreenshotBtn.type = "button";
|
|
1362
|
+
latestScreenshotBtn.textContent = "Last screen";
|
|
1363
|
+
latestScreenshotBtn.addEventListener("click", () => viewLatestScreenshot(device.deviceId));
|
|
1364
|
+
|
|
1365
|
+
const timerBtn = document.createElement("button");
|
|
1366
|
+
timerBtn.type = "button";
|
|
1367
|
+
timerBtn.textContent = "Timer";
|
|
1368
|
+
timerBtn.title = "Daily screen on/off timer";
|
|
1369
|
+
timerBtn.addEventListener("click", () => openOnOffTimerModal(device.deviceId));
|
|
1370
|
+
|
|
1371
|
+
const unpairBtn = document.createElement("button");
|
|
1372
|
+
unpairBtn.type = "button";
|
|
1373
|
+
unpairBtn.className = "danger";
|
|
1374
|
+
unpairBtn.textContent = "Unpair";
|
|
1375
|
+
unpairBtn.addEventListener("click", () => unpairDevice(device.deviceId));
|
|
1376
|
+
|
|
1377
|
+
actions.appendChild(publishBtn);
|
|
1378
|
+
actions.appendChild(infoBtn);
|
|
1379
|
+
actions.appendChild(capBtn);
|
|
1380
|
+
actions.appendChild(rebootBtn);
|
|
1381
|
+
actions.appendChild(clearBtn);
|
|
1382
|
+
actions.appendChild(logsBtn);
|
|
1383
|
+
actions.appendChild(screenshotBtn);
|
|
1384
|
+
actions.appendChild(latestScreenshotBtn);
|
|
1385
|
+
actions.appendChild(timerBtn);
|
|
1386
|
+
actions.appendChild(unpairBtn);
|
|
1387
|
+
|
|
1388
|
+
card.appendChild(header);
|
|
1389
|
+
card.appendChild(published);
|
|
1390
|
+
card.appendChild(metaBlock);
|
|
1391
|
+
card.appendChild(actions);
|
|
1392
|
+
grid.appendChild(card);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function openPublishModal(deviceId) {
|
|
1397
|
+
publishModalDeviceId = deviceId;
|
|
1398
|
+
const modal = document.getElementById("publishModal");
|
|
1399
|
+
const checklist = document.getElementById("publishChecklist");
|
|
1400
|
+
const hint = document.getElementById("publishModalHint");
|
|
1401
|
+
if (!modal || !checklist) return;
|
|
1402
|
+
|
|
1403
|
+
if (playlistsCatalog.length === 0) {
|
|
1404
|
+
alert("Create and save at least one playlist first.");
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const pubs = devicesCache.find((d) => d.deviceId === deviceId)?.publishedPlaylists || [];
|
|
1409
|
+
const publishedIds = new Set(pubs.map((p) => p.playlistId));
|
|
1410
|
+
const unpublished = playlistsCatalog.filter((pl) => !publishedIds.has(pl.id));
|
|
1411
|
+
|
|
1412
|
+
if (unpublished.length === 0) {
|
|
1413
|
+
alert("All playlists are already published to this device. Use Remove on the card to unpublish one first.");
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
hint.textContent =
|
|
1418
|
+
`Device ${deviceId} — add playlists not yet on this device (snapshot at publish time). ` +
|
|
1419
|
+
`Publishing a playlist with no start/end date/time replaces any previously published unscheduled playlist on this device.`;
|
|
1420
|
+
checklist.innerHTML = "";
|
|
1421
|
+
|
|
1422
|
+
for (const pl of unpublished) {
|
|
1423
|
+
const label = document.createElement("label");
|
|
1424
|
+
const cb = document.createElement("input");
|
|
1425
|
+
cb.type = "checkbox";
|
|
1426
|
+
cb.value = pl.id;
|
|
1427
|
+
cb.dataset.name = pl.name;
|
|
1428
|
+
label.appendChild(cb);
|
|
1429
|
+
label.appendChild(document.createTextNode(` ${pl.name}`));
|
|
1430
|
+
checklist.appendChild(label);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
modal.classList.remove("hidden");
|
|
1434
|
+
resetPublishModalStatus();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function closePublishModal() {
|
|
1438
|
+
if (publishInProgress) return;
|
|
1439
|
+
publishModalDeviceId = null;
|
|
1440
|
+
resetPublishModalStatus();
|
|
1441
|
+
document.getElementById("publishModal")?.classList.add("hidden");
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function confirmPublishModal() {
|
|
1445
|
+
if (!publishModalDeviceId || publishInProgress) return;
|
|
1446
|
+
|
|
1447
|
+
if (uploadInProgress || uploadQueue.length > 0) {
|
|
1448
|
+
alert("Wait for asset uploads to finish before publishing.");
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const ids = [
|
|
1453
|
+
...document.querySelectorAll("#publishChecklist input[type=checkbox]:checked")
|
|
1454
|
+
].map((el) => el.value);
|
|
1455
|
+
|
|
1456
|
+
if (ids.length === 0) {
|
|
1457
|
+
alert("Select at least one playlist.");
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const deviceId = publishModalDeviceId;
|
|
1462
|
+
setPublishModalBusy(true);
|
|
1463
|
+
updatePublishStatusUi({ hidden: false, text: "Refreshing playlists...", percent: 8 });
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const refreshed = await refreshPlaylistsForPublish();
|
|
1467
|
+
if (!refreshed) {
|
|
1468
|
+
updatePublishStatusUi({
|
|
1469
|
+
text: "Could not load playlists from the server.",
|
|
1470
|
+
percent: 0,
|
|
1471
|
+
isError: true
|
|
1472
|
+
});
|
|
1473
|
+
setPublishModalBusy(false);
|
|
1474
|
+
alert("Could not load playlists from the server. Try again.");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const selectedPlaylists = ids
|
|
1479
|
+
.map((id) => playlistsCatalog.find((p) => p.id === id))
|
|
1480
|
+
.filter(Boolean);
|
|
1481
|
+
|
|
1482
|
+
if (selectedPlaylists.length !== ids.length) {
|
|
1483
|
+
updatePublishStatusUi({
|
|
1484
|
+
text: "A selected playlist is no longer available.",
|
|
1485
|
+
percent: 0,
|
|
1486
|
+
isError: true
|
|
1487
|
+
});
|
|
1488
|
+
setPublishModalBusy(false);
|
|
1489
|
+
alert("A selected playlist was removed or changed. Close this dialog and open Publish again.");
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
for (const pl of selectedPlaylists) {
|
|
1494
|
+
if (!(pl.items || []).length) {
|
|
1495
|
+
updatePublishStatusUi({
|
|
1496
|
+
text: `Playlist "${pl.name || pl.id}" has no assets.`,
|
|
1497
|
+
percent: 0,
|
|
1498
|
+
isError: true
|
|
1499
|
+
});
|
|
1500
|
+
setPublishModalBusy(false);
|
|
1501
|
+
alert(`Playlist "${pl.name || pl.id}" has no assets. Save the playlist first.`);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const mediaBaseUrl = getPublishMediaBaseUrl(selectedPlaylists);
|
|
1507
|
+
|
|
1508
|
+
const verification = await verifyPlaylistsAssetsReady(
|
|
1509
|
+
selectedPlaylists,
|
|
1510
|
+
mediaBaseUrl,
|
|
1511
|
+
(done, total, assetName) => {
|
|
1512
|
+
const pct = 12 + Math.round((done / Math.max(total, 1)) * 68);
|
|
1513
|
+
updatePublishStatusUi({
|
|
1514
|
+
text: `Verifying assets (${done}/${total}): ${assetName}`,
|
|
1515
|
+
percent: pct
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
);
|
|
1519
|
+
|
|
1520
|
+
if (!verification.ok) {
|
|
1521
|
+
const lines = verification.failures
|
|
1522
|
+
.map((f) => `• ${f.playlist} — ${f.name}: ${f.reason}`)
|
|
1523
|
+
.join("\n");
|
|
1524
|
+
updatePublishStatusUi({
|
|
1525
|
+
text: `Could not load ${verification.failures.length} asset(s). Publishing blocked.`,
|
|
1526
|
+
percent: 0,
|
|
1527
|
+
isError: true
|
|
1528
|
+
});
|
|
1529
|
+
setPublishModalBusy(false);
|
|
1530
|
+
alert(`Cannot publish until all playlist assets are reachable:\n\n${lines}`);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
updatePublishStatusUi({ text: "Publishing to device...", percent: 88 });
|
|
1535
|
+
|
|
1536
|
+
const device = devicesCache.find((d) => d.deviceId === deviceId);
|
|
1537
|
+
const alreadyOnDevice = (device?.publishedPlaylists || []).map((p) => p.playlistId);
|
|
1538
|
+
const playlistIds = [...new Set([...alreadyOnDevice, ...ids])];
|
|
1539
|
+
|
|
1540
|
+
const publishBody = { playlistIds };
|
|
1541
|
+
if (mediaBaseUrl) publishBody.mediaBaseUrl = mediaBaseUrl;
|
|
1542
|
+
|
|
1543
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/assignments`, {
|
|
1544
|
+
method: "POST",
|
|
1545
|
+
headers: { "Content-Type": "application/json" },
|
|
1546
|
+
body: JSON.stringify(publishBody)
|
|
1547
|
+
});
|
|
1548
|
+
const data = await res.json();
|
|
1549
|
+
showResult({ deviceId, publish: data });
|
|
1550
|
+
|
|
1551
|
+
if (!res.ok) {
|
|
1552
|
+
updatePublishStatusUi({
|
|
1553
|
+
text: data.error || "Publish failed",
|
|
1554
|
+
percent: 0,
|
|
1555
|
+
isError: true
|
|
1556
|
+
});
|
|
1557
|
+
setPublishModalBusy(false);
|
|
1558
|
+
alert(data.error || "Publish failed");
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
updatePublishStatusUi({ text: "Published successfully!", percent: 100, isSuccess: true });
|
|
1563
|
+
await fetchDevices();
|
|
1564
|
+
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
1565
|
+
publishInProgress = false;
|
|
1566
|
+
closePublishModal();
|
|
1567
|
+
} catch (err) {
|
|
1568
|
+
updatePublishStatusUi({
|
|
1569
|
+
text: err?.message || "Publish failed",
|
|
1570
|
+
percent: 0,
|
|
1571
|
+
isError: true
|
|
1572
|
+
});
|
|
1573
|
+
setPublishModalBusy(false);
|
|
1574
|
+
alert(err?.message || "Publish failed");
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async function removePlaylistFromDevice(deviceId, playlistId) {
|
|
1579
|
+
if (!confirm("Remove this playlist from the device? Currently playing content may continue until Clear or failed reboot sync.")) {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const res = await fetch(
|
|
1583
|
+
`/device/${encodeURIComponent(deviceId)}/assignments/${encodeURIComponent(playlistId)}`,
|
|
1584
|
+
{ method: "DELETE" }
|
|
1585
|
+
);
|
|
1586
|
+
const data = await res.json();
|
|
1587
|
+
showResult({ deviceId, remove: data });
|
|
1588
|
+
if (!res.ok) {
|
|
1589
|
+
alert(data.error || "Remove failed");
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
await fetchDevices();
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function uploadFileWithProgress(file, { onProgress, attempt }) {
|
|
1596
|
+
return new Promise((resolve, reject) => {
|
|
1597
|
+
const xhr = new XMLHttpRequest();
|
|
1598
|
+
const q = new URLSearchParams({ filename: file.name });
|
|
1599
|
+
xhr.open("POST", `/media/upload?${q.toString()}`, true);
|
|
1600
|
+
xhr.timeout = UPLOAD_TIMEOUT_MS;
|
|
1601
|
+
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
|
1602
|
+
|
|
1603
|
+
xhr.upload.onprogress = (event) => {
|
|
1604
|
+
if (!event.lengthComputable) return;
|
|
1605
|
+
const percent = Math.round((event.loaded / event.total) * 100);
|
|
1606
|
+
if (typeof onProgress === "function") onProgress(percent);
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
xhr.onerror = () => reject(new Error(`Network error on attempt ${attempt}`));
|
|
1610
|
+
xhr.ontimeout = () => reject(new Error(`Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s`));
|
|
1611
|
+
|
|
1612
|
+
xhr.onload = () => {
|
|
1613
|
+
let payload = {};
|
|
1614
|
+
try {
|
|
1615
|
+
payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
|
|
1616
|
+
} catch {
|
|
1617
|
+
payload = {};
|
|
1618
|
+
}
|
|
1619
|
+
if (xhr.status >= 200 && xhr.status < 300 && payload.status !== "failed") {
|
|
1620
|
+
resolve(payload);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
reject(new Error(payload.error || `Upload failed (${xhr.status})`));
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
xhr.send(file);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
async function uploadFile(file, onProgress) {
|
|
1631
|
+
let lastErr = null;
|
|
1632
|
+
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt += 1) {
|
|
1633
|
+
try {
|
|
1634
|
+
const payload = await uploadFileWithProgress(file, { onProgress, attempt });
|
|
1635
|
+
return payload;
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
lastErr = err;
|
|
1638
|
+
if (attempt >= UPLOAD_MAX_RETRIES) break;
|
|
1639
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
throw lastErr || new Error("Upload failed");
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
async function addAssetFromFile(file, queueIndex, queueTotal) {
|
|
1646
|
+
if (!isPlaylistEditorOpen()) {
|
|
1647
|
+
newPlaylistDraft();
|
|
1648
|
+
}
|
|
1649
|
+
const type = inferMediaType(file.name, file.type);
|
|
1650
|
+
const data = await uploadFile(file, (percent) => {
|
|
1651
|
+
updateUploadStatusUi({
|
|
1652
|
+
text: `Uploading ${file.name} (${percent}%)`,
|
|
1653
|
+
percent,
|
|
1654
|
+
queueText: `File ${queueIndex}/${queueTotal}`
|
|
1655
|
+
});
|
|
1656
|
+
});
|
|
1657
|
+
const durationMs = await resolveVideoDurationMs(file, type, data);
|
|
1658
|
+
editorItems.push({
|
|
1659
|
+
id: crypto.randomUUID(),
|
|
1660
|
+
assetId: data.assetId,
|
|
1661
|
+
url: data.url,
|
|
1662
|
+
name: file.name,
|
|
1663
|
+
type,
|
|
1664
|
+
durationMs
|
|
1665
|
+
});
|
|
1666
|
+
renderEditorAssets();
|
|
1667
|
+
showResult({
|
|
1668
|
+
status: "uploaded",
|
|
1669
|
+
...data,
|
|
1670
|
+
fileName: file.name,
|
|
1671
|
+
detectedDurationMs: durationMs,
|
|
1672
|
+
detectedDurationSec: Math.round(durationMs / 1000)
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function processUploadQueue() {
|
|
1677
|
+
if (uploadInProgress) return;
|
|
1678
|
+
if (uploadQueue.length === 0) {
|
|
1679
|
+
updateUploadStatusUi({ hidden: true });
|
|
1680
|
+
setAssetUploadBusy(false);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
uploadInProgress = true;
|
|
1685
|
+
setAssetUploadBusy(true);
|
|
1686
|
+
const total = uploadQueue.length;
|
|
1687
|
+
const failures = [];
|
|
1688
|
+
|
|
1689
|
+
try {
|
|
1690
|
+
for (let i = 0; i < total; i += 1) {
|
|
1691
|
+
const file = uploadQueue[i];
|
|
1692
|
+
try {
|
|
1693
|
+
await addAssetFromFile(file, i + 1, total);
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
failures.push({ file: file.name, error: err?.message || String(err) });
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
} finally {
|
|
1699
|
+
uploadQueue = [];
|
|
1700
|
+
uploadInProgress = false;
|
|
1701
|
+
setAssetUploadBusy(false);
|
|
1702
|
+
updateUploadStatusUi({ hidden: true });
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (failures.length > 0) {
|
|
1706
|
+
showResult({ status: "upload_completed_with_failures", failures });
|
|
1707
|
+
alert(
|
|
1708
|
+
`Uploaded with ${failures.length} failure(s). Check result panel for details.`
|
|
1709
|
+
);
|
|
1710
|
+
} else {
|
|
1711
|
+
showResult({ status: "upload_completed", total });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
async function verify() {
|
|
1716
|
+
const code = String(document.getElementById("code").value || "")
|
|
1717
|
+
.trim()
|
|
1718
|
+
.toUpperCase()
|
|
1719
|
+
.replace(/[^0-9A-Z]/g, "");
|
|
1720
|
+
if (code.length !== 6) {
|
|
1721
|
+
showResult({ status: "failed", error: "Enter the 6-character code from the screen." });
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const res = await fetch("/pairing/verify", {
|
|
1725
|
+
method: "POST",
|
|
1726
|
+
headers: { "Content-Type": "application/json" },
|
|
1727
|
+
body: JSON.stringify({ code })
|
|
1728
|
+
});
|
|
1729
|
+
const data = await res.json();
|
|
1730
|
+
showResult(data);
|
|
1731
|
+
if (data.deviceId) {
|
|
1732
|
+
document.getElementById("code").value = "";
|
|
1733
|
+
await fetchDevices();
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
async function unpairDevice(deviceId) {
|
|
1738
|
+
if (!confirm("Unpair this device?")) return;
|
|
1739
|
+
const res = await fetch("/pairing/unpair", {
|
|
1740
|
+
method: "POST",
|
|
1741
|
+
headers: { "Content-Type": "application/json" },
|
|
1742
|
+
body: JSON.stringify({ deviceId })
|
|
1743
|
+
});
|
|
1744
|
+
const data = await res.json();
|
|
1745
|
+
showResult(data);
|
|
1746
|
+
if (res.ok) await fetchDevices();
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
async function renameDevice(deviceId, currentName) {
|
|
1750
|
+
if (!deviceId) return false;
|
|
1751
|
+
const trimmed = String(currentName || "").trim();
|
|
1752
|
+
if (!trimmed) {
|
|
1753
|
+
alert("Device name cannot be empty.");
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/name`, {
|
|
1758
|
+
method: "PATCH",
|
|
1759
|
+
headers: { "Content-Type": "application/json" },
|
|
1760
|
+
body: JSON.stringify({ deviceName: trimmed })
|
|
1761
|
+
});
|
|
1762
|
+
const data = await res.json();
|
|
1763
|
+
showResult({ deviceId, rename: data });
|
|
1764
|
+
if (!res.ok) {
|
|
1765
|
+
alert(data.error || "Rename failed");
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
return true;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function startInlineDeviceRename(deviceId, currentName) {
|
|
1772
|
+
editingDeviceNameId = deviceId;
|
|
1773
|
+
editingDeviceNameValue = String(currentName || "").trim() || "Screen";
|
|
1774
|
+
renderDeviceCards();
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function cancelInlineDeviceRename() {
|
|
1778
|
+
editingDeviceNameId = null;
|
|
1779
|
+
editingDeviceNameValue = "";
|
|
1780
|
+
renderDeviceCards();
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async function submitInlineDeviceRename(deviceId) {
|
|
1784
|
+
const ok = await renameDevice(deviceId, editingDeviceNameValue);
|
|
1785
|
+
if (!ok) return;
|
|
1786
|
+
editingDeviceNameId = null;
|
|
1787
|
+
editingDeviceNameValue = "";
|
|
1788
|
+
await fetchDevices();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
async function deviceAction(deviceId, action) {
|
|
1792
|
+
if (!deviceId) return;
|
|
1793
|
+
if (action === "reboot" && !confirm("Reboot this device?")) return;
|
|
1794
|
+
if (
|
|
1795
|
+
action === "content/clear" &&
|
|
1796
|
+
!confirm("Clear content on this device and remove all published playlists?")
|
|
1797
|
+
) {
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
|
|
1801
|
+
method: "POST"
|
|
1802
|
+
});
|
|
1803
|
+
const data = await res.json();
|
|
1804
|
+
showResult({ deviceId, action, ...data });
|
|
1805
|
+
if (!res.ok) {
|
|
1806
|
+
alert(data.error || `${action} failed`);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
if (action === "content/clear" && data.assignmentsCleared !== true) {
|
|
1810
|
+
alert("Content cleared on device, but playlist assignments were not removed. Restart CMS with latest SDK.");
|
|
1811
|
+
}
|
|
1812
|
+
if (action === "reboot" || action === "content/clear") await fetchDevices();
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
async function viewDeviceLogs(deviceId) {
|
|
1816
|
+
if (!deviceId) return;
|
|
1817
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/logs`);
|
|
1818
|
+
const data = await res.json();
|
|
1819
|
+
showResult({ deviceId, logs: data.logs || [] });
|
|
1820
|
+
if (!res.ok) {
|
|
1821
|
+
alert(data.error || "Failed to load logs");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
async function captureDeviceScreenshot(deviceId) {
|
|
1827
|
+
if (!deviceId) return;
|
|
1828
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/screenshot`, {
|
|
1829
|
+
method: "POST"
|
|
1830
|
+
});
|
|
1831
|
+
const data = await res.json();
|
|
1832
|
+
showResult({ deviceId, screenshot: data });
|
|
1833
|
+
if (!res.ok) {
|
|
1834
|
+
alert(data.error || "Screenshot failed");
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const capturedAt = formatDateTimeSeconds(data.screenshot?.capturedAt);
|
|
1838
|
+
alert(`Screenshot captured successfully${capturedAt ? ` at ${capturedAt}` : ""}.`);
|
|
1839
|
+
void fetchDevices();
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async function viewLatestScreenshot(deviceId) {
|
|
1843
|
+
if (!deviceId) return;
|
|
1844
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/screenshot/latest`);
|
|
1845
|
+
const data = await res.json();
|
|
1846
|
+
showResult({ deviceId, latestScreenshot: data });
|
|
1847
|
+
if (!res.ok) {
|
|
1848
|
+
alert(data.error || "No screenshot available");
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
openScreenshotModal(deviceId, data.screenshot);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function openScreenshotModal(deviceId, screenshot) {
|
|
1855
|
+
const modal = document.getElementById("screenshotModal");
|
|
1856
|
+
const hint = document.getElementById("screenshotModalHint");
|
|
1857
|
+
const img = document.getElementById("screenshotModalImage");
|
|
1858
|
+
if (!modal || !hint || !img || !screenshot) return;
|
|
1859
|
+
|
|
1860
|
+
const capturedAt = formatDateTimeSeconds(screenshot.capturedAt);
|
|
1861
|
+
hint.textContent = `Device ${deviceId} — captured at ${capturedAt}`;
|
|
1862
|
+
const cacheBust = encodeURIComponent(screenshot.capturedAt || Date.now());
|
|
1863
|
+
img.src = `${screenshot.url}${screenshot.url.includes("?") ? "&" : "?"}v=${cacheBust}`;
|
|
1864
|
+
img.classList.remove("hidden");
|
|
1865
|
+
modal.classList.remove("hidden");
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function closeScreenshotModal() {
|
|
1869
|
+
const modal = document.getElementById("screenshotModal");
|
|
1870
|
+
const img = document.getElementById("screenshotModalImage");
|
|
1871
|
+
if (img) {
|
|
1872
|
+
img.removeAttribute("src");
|
|
1873
|
+
img.classList.add("hidden");
|
|
1874
|
+
}
|
|
1875
|
+
modal?.classList.add("hidden");
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function normalizeTimeInputValue(value, fallback) {
|
|
1879
|
+
const raw = String(value || "").trim();
|
|
1880
|
+
const match = /^(\d{1,2}):(\d{2})/.exec(raw);
|
|
1881
|
+
if (!match) return fallback;
|
|
1882
|
+
const hh = Math.min(23, Math.max(0, Number(match[1])));
|
|
1883
|
+
const mm = Math.min(59, Math.max(0, Number(match[2])));
|
|
1884
|
+
if (Number.isNaN(hh) || Number.isNaN(mm)) return fallback;
|
|
1885
|
+
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
let onOffTimerModalDeviceId = null;
|
|
1889
|
+
|
|
1890
|
+
function openOnOffTimerModal(deviceId) {
|
|
1891
|
+
const device = devicesCache.find((d) => d.deviceId === deviceId);
|
|
1892
|
+
if (!device) return;
|
|
1893
|
+
|
|
1894
|
+
onOffTimerModalDeviceId = deviceId;
|
|
1895
|
+
const modal = document.getElementById("onOffTimerModal");
|
|
1896
|
+
const onEl = document.getElementById("onOffTimerTurnOnAt");
|
|
1897
|
+
const offEl = document.getElementById("onOffTimerTurnOffAt");
|
|
1898
|
+
const removeBtn = document.getElementById("onOffTimerRemoveBtn");
|
|
1899
|
+
if (!modal || !onEl || !offEl) return;
|
|
1900
|
+
|
|
1901
|
+
const timer = device.onOffTimer || {};
|
|
1902
|
+
const configured = hasConfiguredOnOffTimer(device.onOffTimer);
|
|
1903
|
+
onEl.value = normalizeTimeInputValue(timer.turnOnAt, "06:00");
|
|
1904
|
+
offEl.value = normalizeTimeInputValue(timer.turnOffAt, "18:00");
|
|
1905
|
+
if (removeBtn) {
|
|
1906
|
+
removeBtn.classList.toggle("hidden", !configured);
|
|
1907
|
+
removeBtn.disabled = false;
|
|
1908
|
+
}
|
|
1909
|
+
modal.classList.remove("hidden");
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function closeOnOffTimerModal() {
|
|
1913
|
+
const modal = document.getElementById("onOffTimerModal");
|
|
1914
|
+
if (modal) modal.classList.add("hidden");
|
|
1915
|
+
onOffTimerModalDeviceId = null;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
async function saveOnOffTimerModal() {
|
|
1919
|
+
const deviceId = onOffTimerModalDeviceId;
|
|
1920
|
+
if (!deviceId) return;
|
|
1921
|
+
|
|
1922
|
+
const onEl = document.getElementById("onOffTimerTurnOnAt");
|
|
1923
|
+
const offEl = document.getElementById("onOffTimerTurnOffAt");
|
|
1924
|
+
const saveBtn = document.getElementById("onOffTimerSaveBtn");
|
|
1925
|
+
const removeBtn = document.getElementById("onOffTimerRemoveBtn");
|
|
1926
|
+
if (!onEl || !offEl) return;
|
|
1927
|
+
|
|
1928
|
+
const onOffTimer = {
|
|
1929
|
+
turnOnAt: normalizeTimeInputValue(onEl.value, "06:00"),
|
|
1930
|
+
turnOffAt: normalizeTimeInputValue(offEl.value, "18:00")
|
|
1931
|
+
};
|
|
1932
|
+
|
|
1933
|
+
if (onOffTimer.turnOnAt === onOffTimer.turnOffAt) {
|
|
1934
|
+
alert("Turn on and turn off times must be different.");
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
if (saveBtn) saveBtn.disabled = true;
|
|
1939
|
+
if (removeBtn) removeBtn.disabled = true;
|
|
1940
|
+
try {
|
|
1941
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
|
|
1942
|
+
method: "POST",
|
|
1943
|
+
headers: { "Content-Type": "application/json" },
|
|
1944
|
+
body: JSON.stringify({ onOffTimer })
|
|
1945
|
+
});
|
|
1946
|
+
const data = await res.json();
|
|
1947
|
+
if (!res.ok || data.status === "failed") {
|
|
1948
|
+
alert(data.error || "Could not save on/off timer");
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
showResult({ deviceId, onOffTimer: data.onOffTimer, pushed: data.pushed });
|
|
1952
|
+
closeOnOffTimerModal();
|
|
1953
|
+
await fetchDevices();
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
alert(err?.message || "Could not save on/off timer");
|
|
1956
|
+
} finally {
|
|
1957
|
+
if (saveBtn) saveBtn.disabled = false;
|
|
1958
|
+
if (removeBtn) removeBtn.disabled = false;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
async function removeOnOffTimerModal() {
|
|
1963
|
+
const deviceId = onOffTimerModalDeviceId;
|
|
1964
|
+
if (!deviceId) return;
|
|
1965
|
+
|
|
1966
|
+
if (
|
|
1967
|
+
!confirm(
|
|
1968
|
+
"Remove the on/off timer? The screen will stay in its current on or off state until you set a timer again."
|
|
1969
|
+
)
|
|
1970
|
+
) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const saveBtn = document.getElementById("onOffTimerSaveBtn");
|
|
1975
|
+
const removeBtn = document.getElementById("onOffTimerRemoveBtn");
|
|
1976
|
+
if (saveBtn) saveBtn.disabled = true;
|
|
1977
|
+
if (removeBtn) removeBtn.disabled = true;
|
|
1978
|
+
try {
|
|
1979
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
|
|
1980
|
+
method: "DELETE"
|
|
1981
|
+
});
|
|
1982
|
+
const data = await res.json();
|
|
1983
|
+
if (!res.ok || data.status === "failed") {
|
|
1984
|
+
alert(data.error || "Could not remove on/off timer");
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
showResult({ deviceId, onOffTimer: null, pushed: data.pushed });
|
|
1988
|
+
closeOnOffTimerModal();
|
|
1989
|
+
await fetchDevices();
|
|
1990
|
+
} catch (err) {
|
|
1991
|
+
alert(err?.message || "Could not remove on/off timer");
|
|
1992
|
+
} finally {
|
|
1993
|
+
if (saveBtn) saveBtn.disabled = false;
|
|
1994
|
+
if (removeBtn) removeBtn.disabled = false;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function openDownloadFailedModal(detail) {
|
|
1999
|
+
const modal = document.getElementById("downloadFailedModal");
|
|
2000
|
+
const message = document.getElementById("downloadFailedModalMessage");
|
|
2001
|
+
if (!modal) return;
|
|
2002
|
+
if (message) {
|
|
2003
|
+
message.textContent = detail
|
|
2004
|
+
? `Could not download this asset: ${detail}`
|
|
2005
|
+
: "Could not download this asset.";
|
|
2006
|
+
}
|
|
2007
|
+
modal.classList.remove("hidden");
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function closeDownloadFailedModal() {
|
|
2011
|
+
document.getElementById("downloadFailedModal")?.classList.add("hidden");
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function openDownloadPlayersModal() {
|
|
2015
|
+
document.getElementById("downloadPlayersModal")?.classList.remove("hidden");
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function closeDownloadPlayersModal() {
|
|
2019
|
+
document.getElementById("downloadPlayersModal")?.classList.add("hidden");
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function setUpdateSdkVersionLabel(version) {
|
|
2023
|
+
const label = document.getElementById("updateSdkVersionLabel");
|
|
2024
|
+
if (!label) return;
|
|
2025
|
+
label.textContent = version || "unknown";
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function openUpdateSdkModal() {
|
|
2029
|
+
const modal = document.getElementById("updateSdkModal");
|
|
2030
|
+
const promptEl = document.getElementById("updateSdkPromptText");
|
|
2031
|
+
if (promptEl) promptEl.textContent = UPDATE_SDK_PROMPT;
|
|
2032
|
+
setUpdateSdkVersionLabel(cachedSdkVersion || "Loading…");
|
|
2033
|
+
modal?.classList.remove("hidden");
|
|
2034
|
+
|
|
2035
|
+
if (!cachedSdkVersion) {
|
|
2036
|
+
await fetchServerStatus();
|
|
2037
|
+
setUpdateSdkVersionLabel(cachedSdkVersion || "unknown");
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function closeUpdateSdkModal() {
|
|
2042
|
+
document.getElementById("updateSdkModal")?.classList.add("hidden");
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
async function copyUpdateSdkPrompt() {
|
|
2046
|
+
const text =
|
|
2047
|
+
document.getElementById("updateSdkPromptText")?.textContent?.trim() ||
|
|
2048
|
+
UPDATE_SDK_PROMPT;
|
|
2049
|
+
try {
|
|
2050
|
+
await navigator.clipboard.writeText(text);
|
|
2051
|
+
const btn = document.getElementById("copyUpdateSdkPromptBtn");
|
|
2052
|
+
if (btn) {
|
|
2053
|
+
const prev = btn.textContent;
|
|
2054
|
+
btn.textContent = "Copied";
|
|
2055
|
+
setTimeout(() => {
|
|
2056
|
+
btn.textContent = prev || "Copy prompt";
|
|
2057
|
+
}, 1200);
|
|
2058
|
+
}
|
|
2059
|
+
} catch {
|
|
2060
|
+
alert("Could not copy automatically. Select the prompt text and copy it manually.");
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function handlePlayerDownloadLinkClick(ev) {
|
|
2065
|
+
ev.preventDefault();
|
|
2066
|
+
const platform = ev.currentTarget?.dataset?.playerDownload || "unknown";
|
|
2067
|
+
console.info(`[CMS] Player download link placeholder (${platform}) — URL not configured yet.`);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function sanitizeDownloadFilename(name) {
|
|
2071
|
+
const base = String(name || "asset").trim() || "asset";
|
|
2072
|
+
return base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
async function downloadMediaAsset(item) {
|
|
2076
|
+
const candidates = [];
|
|
2077
|
+
const sameOriginPath = resolveVerificationMediaUrl(item.url);
|
|
2078
|
+
if (sameOriginPath) candidates.push(sameOriginPath);
|
|
2079
|
+
try {
|
|
2080
|
+
const absolute = absoluteMediaUrl(item.url);
|
|
2081
|
+
if (absolute && !candidates.includes(absolute)) candidates.push(absolute);
|
|
2082
|
+
} catch (_) {}
|
|
2083
|
+
|
|
2084
|
+
if (candidates.length === 0) {
|
|
2085
|
+
openDownloadFailedModal("Media URL is not available.");
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
const filename = sanitizeDownloadFilename(item.name || item.url.split("/").pop() || "asset");
|
|
2090
|
+
|
|
2091
|
+
let lastError = "Network or browser error.";
|
|
2092
|
+
for (const url of candidates) {
|
|
2093
|
+
try {
|
|
2094
|
+
const res = await fetch(url);
|
|
2095
|
+
if (!res.ok) {
|
|
2096
|
+
lastError = `HTTP ${res.status}`;
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
const blob = await res.blob();
|
|
2100
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
2101
|
+
const anchor = document.createElement("a");
|
|
2102
|
+
anchor.href = objectUrl;
|
|
2103
|
+
anchor.download = filename;
|
|
2104
|
+
anchor.style.display = "none";
|
|
2105
|
+
document.body.appendChild(anchor);
|
|
2106
|
+
anchor.click();
|
|
2107
|
+
anchor.remove();
|
|
2108
|
+
URL.revokeObjectURL(objectUrl);
|
|
2109
|
+
return;
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
lastError = err?.message || lastError;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
openDownloadFailedModal(lastError);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function connectorStateLabel(state) {
|
|
2119
|
+
if (state === "ok") return "OK";
|
|
2120
|
+
if (state === "warn") return "Warning";
|
|
2121
|
+
if (state === "missing") return "Not set";
|
|
2122
|
+
return "Error";
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
function overallStatusCopy(overall) {
|
|
2126
|
+
if (overall === "ok") return "All connectors look healthy.";
|
|
2127
|
+
if (overall === "degraded") {
|
|
2128
|
+
return "CMS is running with warnings — review media or database durability below.";
|
|
2129
|
+
}
|
|
2130
|
+
if (overall === "blocked") {
|
|
2131
|
+
return "Setup incomplete — fix the blockers below so pairing and media work reliably.";
|
|
2132
|
+
}
|
|
2133
|
+
return "Checking connectors…";
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function renderServerStatus(report) {
|
|
2137
|
+
const section = document.getElementById("serverStatusSection");
|
|
2138
|
+
const list = document.getElementById("serverStatusList");
|
|
2139
|
+
const overallEl = document.getElementById("serverStatusOverall");
|
|
2140
|
+
const blockersEl = document.getElementById("serverStatusBlockers");
|
|
2141
|
+
if (!section || !list || !overallEl || !blockersEl) return;
|
|
2142
|
+
|
|
2143
|
+
const overall = report?.overall || "blocked";
|
|
2144
|
+
section.classList.remove(
|
|
2145
|
+
"server-status-card--ok",
|
|
2146
|
+
"server-status-card--degraded",
|
|
2147
|
+
"server-status-card--blocked"
|
|
2148
|
+
);
|
|
2149
|
+
section.classList.add(`server-status-card--${overall}`);
|
|
2150
|
+
overallEl.textContent = overallStatusCopy(overall);
|
|
2151
|
+
|
|
2152
|
+
list.innerHTML = "";
|
|
2153
|
+
const connectors = Array.isArray(report?.connectors) ? report.connectors : [];
|
|
2154
|
+
for (const connector of connectors) {
|
|
2155
|
+
const li = document.createElement("li");
|
|
2156
|
+
li.className = "server-status-row";
|
|
2157
|
+
|
|
2158
|
+
const main = document.createElement("div");
|
|
2159
|
+
main.className = "server-status-row-main";
|
|
2160
|
+
|
|
2161
|
+
const label = document.createElement("span");
|
|
2162
|
+
label.className = "server-status-label";
|
|
2163
|
+
label.textContent = connector.label || connector.id || "Connector";
|
|
2164
|
+
main.appendChild(label);
|
|
2165
|
+
|
|
2166
|
+
if (connector.provider) {
|
|
2167
|
+
const provider = document.createElement("span");
|
|
2168
|
+
provider.className = "server-status-provider";
|
|
2169
|
+
provider.textContent = connector.provider;
|
|
2170
|
+
main.appendChild(provider);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const badge = document.createElement("span");
|
|
2174
|
+
const state = connector.state || "error";
|
|
2175
|
+
badge.className = `server-status-badge server-status-badge--${state}`;
|
|
2176
|
+
badge.textContent = connectorStateLabel(state);
|
|
2177
|
+
|
|
2178
|
+
const detail = document.createElement("p");
|
|
2179
|
+
detail.className = "server-status-detail";
|
|
2180
|
+
detail.textContent = connector.detail || "";
|
|
2181
|
+
|
|
2182
|
+
li.appendChild(main);
|
|
2183
|
+
li.appendChild(badge);
|
|
2184
|
+
li.appendChild(detail);
|
|
2185
|
+
list.appendChild(li);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const blockers = Array.isArray(report?.blockers) ? report.blockers : [];
|
|
2189
|
+
blockersEl.innerHTML = "";
|
|
2190
|
+
if (blockers.length === 0) {
|
|
2191
|
+
blockersEl.classList.add("hidden");
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
blockersEl.classList.remove("hidden");
|
|
2196
|
+
for (const blocker of blockers) {
|
|
2197
|
+
const card = document.createElement("div");
|
|
2198
|
+
card.className = "server-status-blocker";
|
|
2199
|
+
|
|
2200
|
+
const title = document.createElement("h3");
|
|
2201
|
+
title.textContent = blocker.title || "Connector issue";
|
|
2202
|
+
|
|
2203
|
+
const message = document.createElement("p");
|
|
2204
|
+
message.textContent = blocker.message || "";
|
|
2205
|
+
|
|
2206
|
+
const fix = document.createElement("p");
|
|
2207
|
+
fix.className = "server-status-blocker-fix";
|
|
2208
|
+
fix.textContent = blocker.fixHint
|
|
2209
|
+
? `How to fix: ${blocker.fixHint}`
|
|
2210
|
+
: "How to fix: update Secrets / env and restart the CMS.";
|
|
2211
|
+
|
|
2212
|
+
card.appendChild(title);
|
|
2213
|
+
card.appendChild(message);
|
|
2214
|
+
card.appendChild(fix);
|
|
2215
|
+
blockersEl.appendChild(card);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
async function fetchServerStatus() {
|
|
2220
|
+
try {
|
|
2221
|
+
const res = await fetch("/status", { cache: "no-store" });
|
|
2222
|
+
const data = await res.json();
|
|
2223
|
+
if (!res.ok) {
|
|
2224
|
+
renderServerStatus({
|
|
2225
|
+
overall: "blocked",
|
|
2226
|
+
connectors: [
|
|
2227
|
+
{
|
|
2228
|
+
id: "server",
|
|
2229
|
+
label: "Server",
|
|
2230
|
+
state: "error",
|
|
2231
|
+
detail: data?.error || `HTTP ${res.status}`
|
|
2232
|
+
}
|
|
2233
|
+
],
|
|
2234
|
+
blockers: [
|
|
2235
|
+
{
|
|
2236
|
+
connectorId: "server",
|
|
2237
|
+
title: "Could not load server status",
|
|
2238
|
+
message: data?.error || `HTTP ${res.status}`,
|
|
2239
|
+
fixHint: "Confirm the CMS is running with @tomorrowos/sdk that includes GET /status."
|
|
2240
|
+
}
|
|
2241
|
+
]
|
|
2242
|
+
});
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (typeof data.sdkVersion === "string" && data.sdkVersion.trim()) {
|
|
2246
|
+
cachedSdkVersion = data.sdkVersion.trim();
|
|
2247
|
+
}
|
|
2248
|
+
if (typeof data.suggestedCmsUrl === "string") {
|
|
2249
|
+
applyDefaultCmsUrlForScreens(data.suggestedCmsUrl);
|
|
2250
|
+
}
|
|
2251
|
+
renderServerStatus(data);
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
renderServerStatus({
|
|
2254
|
+
overall: "blocked",
|
|
2255
|
+
connectors: [
|
|
2256
|
+
{
|
|
2257
|
+
id: "server",
|
|
2258
|
+
label: "Server",
|
|
2259
|
+
state: "error",
|
|
2260
|
+
detail: err?.message || "Network error"
|
|
2261
|
+
}
|
|
2262
|
+
],
|
|
2263
|
+
blockers: [
|
|
2264
|
+
{
|
|
2265
|
+
connectorId: "server",
|
|
2266
|
+
title: "CMS unreachable",
|
|
2267
|
+
message: err?.message || "Network error",
|
|
2268
|
+
fixHint: "Start the CMS (`npm run start`) and reload this page."
|
|
2269
|
+
}
|
|
2270
|
+
]
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function startDevicePolling() {
|
|
2276
|
+
if (devicePollTimer) clearInterval(devicePollTimer);
|
|
2277
|
+
void fetchDevices();
|
|
2278
|
+
devicePollTimer = setInterval(() => void fetchDevices(), 8000);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function startServerStatusPolling() {
|
|
2282
|
+
if (serverStatusTimer) clearInterval(serverStatusTimer);
|
|
2283
|
+
void fetchServerStatus();
|
|
2284
|
+
serverStatusTimer = setInterval(() => void fetchServerStatus(), 30000);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
2288
|
+
const cmsUrlSection = document.getElementById("cmsUrlSection");
|
|
2289
|
+
if (cmsUrlSection && !isLocalPanelHost(window.location.hostname)) {
|
|
2290
|
+
cmsUrlSection.classList.add("hidden");
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
|
|
2294
|
+
const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
|
|
2295
|
+
if (savedMediaBase && cmsBaseInput) cmsBaseInput.value = savedMediaBase;
|
|
2296
|
+
|
|
2297
|
+
updatePlaylistEditorVisibility();
|
|
2298
|
+
void fetchPlaylists();
|
|
2299
|
+
startDevicePolling();
|
|
2300
|
+
startServerStatusPolling();
|
|
2301
|
+
|
|
2302
|
+
document
|
|
2303
|
+
.getElementById("serverStatusRefreshBtn")
|
|
2304
|
+
?.addEventListener("click", () => void fetchServerStatus());
|
|
2305
|
+
|
|
2306
|
+
document.getElementById("newPlaylistBtn")?.addEventListener("click", newPlaylistDraft);
|
|
2307
|
+
document.getElementById("savePlaylistBtn")?.addEventListener("click", () => void saveCurrentPlaylist());
|
|
2308
|
+
document.getElementById("deletePlaylistBtn")?.addEventListener("click", () => void deleteCurrentPlaylist());
|
|
2309
|
+
document.getElementById("publishConfirmBtn")?.addEventListener("click", () => void confirmPublishModal());
|
|
2310
|
+
|
|
2311
|
+
document.querySelectorAll("[data-close-modal]").forEach((el) => {
|
|
2312
|
+
el.addEventListener("click", closePublishModal);
|
|
2313
|
+
});
|
|
2314
|
+
document.querySelectorAll("[data-close-screenshot-modal]").forEach((el) => {
|
|
2315
|
+
el.addEventListener("click", closeScreenshotModal);
|
|
2316
|
+
});
|
|
2317
|
+
document.querySelectorAll("[data-close-on-off-timer-modal]").forEach((el) => {
|
|
2318
|
+
el.addEventListener("click", closeOnOffTimerModal);
|
|
2319
|
+
});
|
|
2320
|
+
document
|
|
2321
|
+
.getElementById("onOffTimerSaveBtn")
|
|
2322
|
+
?.addEventListener("click", () => void saveOnOffTimerModal());
|
|
2323
|
+
document
|
|
2324
|
+
.getElementById("onOffTimerRemoveBtn")
|
|
2325
|
+
?.addEventListener("click", () => void removeOnOffTimerModal());
|
|
2326
|
+
document.querySelectorAll("[data-close-download-failed-modal]").forEach((el) => {
|
|
2327
|
+
el.addEventListener("click", closeDownloadFailedModal);
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
document.getElementById("downloadPlayersBtn")?.addEventListener("click", openDownloadPlayersModal);
|
|
2331
|
+
document.querySelectorAll("[data-close-download-players-modal]").forEach((el) => {
|
|
2332
|
+
el.addEventListener("click", closeDownloadPlayersModal);
|
|
2333
|
+
});
|
|
2334
|
+
document.querySelectorAll("[data-player-download]").forEach((el) => {
|
|
2335
|
+
el.addEventListener("click", handlePlayerDownloadLinkClick);
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
document.getElementById("updateSdkBtn")?.addEventListener("click", () => void openUpdateSdkModal());
|
|
2339
|
+
document.querySelectorAll("[data-close-update-sdk-modal]").forEach((el) => {
|
|
2340
|
+
el.addEventListener("click", closeUpdateSdkModal);
|
|
2341
|
+
});
|
|
2342
|
+
document
|
|
2343
|
+
.getElementById("copyUpdateSdkPromptBtn")
|
|
2344
|
+
?.addEventListener("click", () => void copyUpdateSdkPrompt());
|
|
2345
|
+
|
|
2346
|
+
document.getElementById("addAssetBtn")?.addEventListener("click", () => {
|
|
2347
|
+
if (uploadInProgress) return;
|
|
2348
|
+
if (!isPlaylistEditorOpen()) {
|
|
2349
|
+
newPlaylistDraft();
|
|
2350
|
+
}
|
|
2351
|
+
document.getElementById("fileInput")?.click();
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
document.getElementById("fileInput")?.addEventListener("change", async (ev) => {
|
|
2355
|
+
const files = ev.target.files;
|
|
2356
|
+
if (!files?.length) return;
|
|
2357
|
+
uploadQueue.push(...Array.from(files));
|
|
2358
|
+
void processUploadQueue();
|
|
2359
|
+
ev.target.value = "";
|
|
2360
|
+
});
|
|
2361
|
+
});
|