@tomorrowos/sdk 0.2.5 → 0.3.0

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.
@@ -1,13 +1,20 @@
1
- const PANEL_PLAYLIST_KEY = "tomorrowos.panel.playlistDraft";
2
- const PANEL_SCHEDULE_KEY = "tomorrowos.panel.scheduleDraft";
3
1
  const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
4
2
 
5
- /** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
6
- let playlistItems = [];
3
+ /** @type {Array<Record<string, unknown>>} */
4
+ let playlistsCatalog = [];
7
5
 
8
6
  /** @type {Array<Record<string, unknown>>} */
9
7
  let devicesCache = [];
10
8
 
9
+ /** @type {string|null} */
10
+ let selectedPlaylistId = null;
11
+
12
+ /** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
13
+ let editorItems = [];
14
+
15
+ /** @type {string|null} */
16
+ let publishModalDeviceId = null;
17
+
11
18
  let devicePollTimer = null;
12
19
 
13
20
  function escapeHtml(value) {
@@ -37,17 +44,9 @@ function formatDateTimeSeconds(iso) {
37
44
  if (!iso) return "—";
38
45
  const d = new Date(iso);
39
46
  if (Number.isNaN(d.getTime())) return "—";
40
- return d.toLocaleString(undefined, {
41
- year: "numeric",
42
- month: "short",
43
- day: "numeric",
44
- hour: "2-digit",
45
- minute: "2-digit",
46
- second: "2-digit"
47
- });
47
+ return d.toLocaleString();
48
48
  }
49
49
 
50
- /** TV uptime: now minus last boot time (device.lastBootAt). Not active when app is offline. */
51
50
  function formatDeviceOnlineLabel(device) {
52
51
  if (!device.connected) return "Not active";
53
52
  const bootIso = device.lastBootAt;
@@ -57,142 +56,6 @@ function formatDeviceOnlineLabel(device) {
57
56
  return formatDurationMs(Date.now() - bootMs);
58
57
  }
59
58
 
60
- async function fetchDevices() {
61
- try {
62
- const res = await fetch("/devices");
63
- const data = await res.json();
64
- if (Array.isArray(data.devices)) {
65
- devicesCache = data.devices;
66
- renderDeviceCards();
67
- }
68
- } catch (err) {
69
- showResult({ status: "failed", error: err.message });
70
- }
71
- }
72
-
73
- function renderDeviceCards() {
74
- const grid = document.getElementById("devicesGrid");
75
- if (!grid) return;
76
-
77
- grid.innerHTML = "";
78
-
79
- if (devicesCache.length === 0) {
80
- const empty = document.createElement("p");
81
- empty.className = "devices-empty";
82
- empty.textContent = "No paired devices yet. Enter a code above.";
83
- grid.appendChild(empty);
84
- return;
85
- }
86
-
87
- for (const device of devicesCache) {
88
- const card = document.createElement("article");
89
- card.className = "device-card";
90
- card.dataset.deviceId = device.deviceId;
91
-
92
- const header = document.createElement("div");
93
- header.className = "device-card-header";
94
-
95
- const led = document.createElement("span");
96
- led.className = `status-led ${device.connected ? "status-led--online" : "status-led--offline"}`;
97
- led.title = device.connected ? "Connected" : "Disconnected";
98
-
99
- const title = document.createElement("h3");
100
- title.className = "device-card-title";
101
- title.textContent = device.deviceName || "Screen";
102
-
103
- header.appendChild(led);
104
- header.appendChild(title);
105
-
106
- const meta = document.createElement("dl");
107
- meta.className = "device-meta";
108
-
109
- const rows = [
110
- ["Device ID", device.deviceId],
111
- ["System", device.system || device.platform || "—"],
112
- [
113
- "Device online",
114
- formatDeviceOnlineLabel(device)
115
- ],
116
- ["Last boot", formatDateTimeSeconds(device.lastBootAt)],
117
- ["Latest content push", formatDateTimeSeconds(device.lastPolicyPushAt)]
118
- ];
119
-
120
- for (const [label, value] of rows) {
121
- const row = document.createElement("div");
122
- row.className = "device-meta-row";
123
- const dt = document.createElement("dt");
124
- dt.textContent = label;
125
- const dd = document.createElement("dd");
126
- if (label === "Device online") {
127
- dd.className = "device-online-time";
128
- dd.title = "Current time minus this TV last boot time";
129
- }
130
- dd.textContent = value;
131
- row.appendChild(dt);
132
- row.appendChild(dd);
133
- meta.appendChild(row);
134
- }
135
-
136
- const actions = document.createElement("div");
137
- actions.className = "device-card-actions";
138
-
139
- const publishBtn = document.createElement("button");
140
- publishBtn.type = "button";
141
- publishBtn.className = "primary";
142
- publishBtn.textContent = "Publish";
143
- publishBtn.addEventListener("click", () => publishToDevice(device.deviceId));
144
-
145
- const infoBtn = document.createElement("button");
146
- infoBtn.type = "button";
147
- infoBtn.textContent = "Info";
148
- infoBtn.addEventListener("click", () => deviceAction(device.deviceId, "get-info"));
149
-
150
- const capsBtn = document.createElement("button");
151
- capsBtn.type = "button";
152
- capsBtn.textContent = "Capabilities";
153
- capsBtn.addEventListener("click", () =>
154
- deviceAction(device.deviceId, "get-capabilities")
155
- );
156
-
157
- const rebootBtn = document.createElement("button");
158
- rebootBtn.type = "button";
159
- rebootBtn.textContent = "Reboot";
160
- rebootBtn.addEventListener("click", () => deviceAction(device.deviceId, "reboot"));
161
-
162
- const clearBtn = document.createElement("button");
163
- clearBtn.type = "button";
164
- clearBtn.textContent = "Clear";
165
- clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
166
-
167
- const unpairBtn = document.createElement("button");
168
- unpairBtn.type = "button";
169
- unpairBtn.className = "danger";
170
- unpairBtn.textContent = "Unpair";
171
- unpairBtn.addEventListener("click", () => unpairDevice(device.deviceId));
172
-
173
- actions.appendChild(publishBtn);
174
- actions.appendChild(infoBtn);
175
- actions.appendChild(capsBtn);
176
- actions.appendChild(rebootBtn);
177
- actions.appendChild(clearBtn);
178
- actions.appendChild(unpairBtn);
179
-
180
- card.appendChild(header);
181
- card.appendChild(meta);
182
- card.appendChild(actions);
183
- grid.appendChild(card);
184
- }
185
- }
186
-
187
- function startDevicePolling() {
188
- if (devicePollTimer) clearInterval(devicePollTimer);
189
-
190
- void fetchDevices();
191
- devicePollTimer = setInterval(() => {
192
- void fetchDevices();
193
- }, 8000);
194
- }
195
-
196
59
  function showResult(data) {
197
60
  document.getElementById("result").textContent = JSON.stringify(data, null, 2);
198
61
  }
@@ -205,12 +68,9 @@ function isLocalPanelHost(hostname) {
205
68
  function normalizeMediaBaseUrl(raw) {
206
69
  let s = String(raw || "").trim();
207
70
  if (!s) return "";
208
- if (!/^https?:\/\//i.test(s)) {
209
- s = `http://${s}`;
210
- }
71
+ if (!/^https?:\/\//i.test(s)) s = `http://${s}`;
211
72
  try {
212
- const u = new URL(s);
213
- return u.origin;
73
+ return new URL(s).origin;
214
74
  } catch {
215
75
  return "";
216
76
  }
@@ -223,11 +83,7 @@ function getMediaBaseOrigin() {
223
83
  ""
224
84
  );
225
85
  if (fromInput) return fromInput;
226
-
227
- if (!isLocalPanelHost(window.location.hostname)) {
228
- return window.location.origin;
229
- }
230
-
86
+ if (!isLocalPanelHost(window.location.hostname)) return window.location.origin;
231
87
  return "";
232
88
  }
233
89
 
@@ -248,12 +104,9 @@ function absoluteMediaUrl(path) {
248
104
  const p = String(path || "").trim();
249
105
  if (!p) return "";
250
106
  if (/^https?:\/\//i.test(p)) return p;
251
-
252
107
  const base = getMediaBaseOrigin();
253
108
  if (!base) {
254
- throw new Error(
255
- "Local CMS only: set CMS URL for screens (your PC LAN IP, e.g. http://192.168.1.105:3000 — not localhost)."
256
- );
109
+ throw new Error("Set CMS URL for screens (LAN IP, not localhost).");
257
110
  }
258
111
  return `${base}${p.startsWith("/") ? p : `/${p}`}`;
259
112
  }
@@ -276,94 +129,144 @@ function defaultDurationMs(type) {
276
129
  }
277
130
 
278
131
  function normalizeDurationMs(item) {
279
- const maxMs = 3600 * 1000;
280
132
  const minMs = 1000;
133
+ const maxMs = 3600 * 1000;
281
134
  let ms = Number(item?.durationMs);
282
- if (!Number.isFinite(ms) || ms < minMs) {
283
- return defaultDurationMs(item?.type);
284
- }
285
- if (ms === 1000000) {
286
- return defaultDurationMs(item?.type);
287
- }
135
+ if (!Number.isFinite(ms) || ms < minMs) return defaultDurationMs(item?.type);
136
+ if (ms === 1000000) return defaultDurationMs(item?.type);
288
137
  return Math.min(maxMs, ms);
289
138
  }
290
139
 
291
- function savePlaylistDraft() {
292
- localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
140
+ function buildScheduleFromForm() {
141
+ const schedule = {};
142
+ const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
143
+ const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
144
+ const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
145
+ const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
146
+ const days = [...document.querySelectorAll(".day-checkbox:checked")].map((el) =>
147
+ Number(el.value)
148
+ );
149
+ if (startDate) schedule.startDate = startDate;
150
+ if (endDate) schedule.endDate = endDate;
151
+ if (startTime) schedule.start = startTime;
152
+ if (endTime) schedule.end = endTime;
153
+ if (days.length > 0) schedule.daysOfWeek = days;
154
+ return Object.keys(schedule).length > 0 ? schedule : undefined;
293
155
  }
294
156
 
295
- function saveScheduleDraft() {
296
- const draft = {
297
- startDate: document.getElementById("scheduleStartDate")?.value || "",
298
- endDate: document.getElementById("scheduleEndDate")?.value || "",
299
- startTime: document.getElementById("scheduleStartTime")?.value || "",
300
- endTime: document.getElementById("scheduleEndTime")?.value || "",
301
- days: [...document.querySelectorAll(".day-checkbox:checked")].map((el) => Number(el.value))
302
- };
303
- localStorage.setItem(PANEL_SCHEDULE_KEY, JSON.stringify(draft));
157
+ function loadScheduleIntoForm(schedule) {
158
+ const s = schedule || {};
159
+ document.getElementById("scheduleStartDate").value = s.startDate || "";
160
+ document.getElementById("scheduleEndDate").value = s.endDate || "";
161
+ document.getElementById("scheduleStartTime").value = s.start || "";
162
+ document.getElementById("scheduleEndTime").value = s.end || "";
163
+ document.querySelectorAll(".day-checkbox").forEach((el) => {
164
+ el.checked =
165
+ Array.isArray(s.daysOfWeek) && s.daysOfWeek.includes(Number(el.value));
166
+ });
304
167
  }
305
168
 
306
- function loadScheduleDraft() {
307
- try {
308
- const raw = localStorage.getItem(PANEL_SCHEDULE_KEY);
309
- if (!raw) return;
310
- const draft = JSON.parse(raw);
311
- if (draft.startDate) document.getElementById("scheduleStartDate").value = draft.startDate;
312
- if (draft.endDate) document.getElementById("scheduleEndDate").value = draft.endDate;
313
- if (draft.startTime) document.getElementById("scheduleStartTime").value = draft.startTime;
314
- if (draft.endTime) document.getElementById("scheduleEndTime").value = draft.endTime;
315
- document.querySelectorAll(".day-checkbox").forEach((el) => {
316
- el.checked = Array.isArray(draft.days) && draft.days.includes(Number(el.value));
169
+ function getSelectedPlaylist() {
170
+ return playlistsCatalog.find((p) => p.id === selectedPlaylistId) || null;
171
+ }
172
+
173
+ async function fetchPlaylists() {
174
+ const res = await fetch("/playlists");
175
+ const data = await res.json();
176
+ if (Array.isArray(data.playlists)) {
177
+ playlistsCatalog = data.playlists;
178
+ renderPlaylistCatalog();
179
+ if (selectedPlaylistId && !getSelectedPlaylist()) {
180
+ selectedPlaylistId = playlistsCatalog[0]?.id || null;
181
+ loadEditorFromSelection();
182
+ }
183
+ }
184
+ }
185
+
186
+ function renderPlaylistCatalog() {
187
+ const list = document.getElementById("playlistCatalog");
188
+ if (!list) return;
189
+ list.innerHTML = "";
190
+
191
+ if (playlistsCatalog.length === 0) {
192
+ const li = document.createElement("li");
193
+ li.className = "playlist-catalog-item";
194
+ li.textContent = "No playlists yet. Tap +.";
195
+ list.appendChild(li);
196
+ return;
197
+ }
198
+
199
+ for (const pl of playlistsCatalog) {
200
+ const li = document.createElement("li");
201
+ li.className = "playlist-catalog-item";
202
+ if (pl.id === selectedPlaylistId) li.classList.add("playlist-catalog-item--active");
203
+ li.innerHTML = `<strong>${escapeHtml(pl.name)}</strong><small>v${pl.version} · ${(pl.items || []).length} items</small>`;
204
+ li.addEventListener("click", () => {
205
+ selectedPlaylistId = pl.id;
206
+ loadEditorFromSelection();
207
+ renderPlaylistCatalog();
317
208
  });
318
- } catch {
319
- /* ignore */
209
+ list.appendChild(li);
320
210
  }
321
211
  }
322
212
 
323
- function loadPlaylistDraft() {
324
- try {
325
- const raw = localStorage.getItem(PANEL_PLAYLIST_KEY);
326
- if (!raw) return;
327
- const parsed = JSON.parse(raw);
328
- if (Array.isArray(parsed)) {
329
- playlistItems = parsed.map((item) => ({
330
- ...item,
331
- durationMs: normalizeDurationMs(item)
332
- }));
333
- savePlaylistDraft();
334
- renderPlaylist();
335
- }
336
- } catch {
337
- playlistItems = [];
213
+ function loadEditorFromSelection() {
214
+ const pl = getSelectedPlaylist();
215
+ const nameInput = document.getElementById("playlistName");
216
+ const editorTitle = document.getElementById("editorTitle");
217
+
218
+ if (!pl) {
219
+ if (editorTitle) editorTitle.textContent = "Playlist editor";
220
+ if (nameInput) nameInput.value = "";
221
+ editorItems = [];
222
+ loadScheduleIntoForm(null);
223
+ renderEditorAssets();
224
+ return;
338
225
  }
226
+
227
+ if (editorTitle) editorTitle.textContent = `Edit: ${pl.name}`;
228
+ if (nameInput) nameInput.value = pl.name || "";
229
+ loadScheduleIntoForm(pl.schedule);
230
+ editorItems = (pl.items || []).map((item) => ({
231
+ id: crypto.randomUUID(),
232
+ url: item.url,
233
+ name: item.url?.split("/").pop() || "asset",
234
+ type: item.type || "image",
235
+ durationMs: normalizeDurationMs(item)
236
+ }));
237
+ renderEditorAssets();
339
238
  }
340
239
 
341
- function renderPlaylist() {
240
+ function renderEditorAssets() {
342
241
  const list = document.getElementById("playlistList");
343
242
  const empty = document.getElementById("playlistEmpty");
344
243
  list.querySelectorAll(".playlist-item").forEach((el) => el.remove());
345
244
 
346
- if (playlistItems.length === 0) {
245
+ if (!selectedPlaylistId || editorItems.length === 0) {
347
246
  empty.classList.remove("hidden");
247
+ empty.textContent = selectedPlaylistId
248
+ ? "No assets yet. Tap +."
249
+ : "Select or create a playlist.";
348
250
  return;
349
251
  }
350
252
 
351
253
  empty.classList.add("hidden");
352
254
 
353
- for (const item of playlistItems) {
255
+ for (const item of editorItems) {
354
256
  const li = document.createElement("li");
355
257
  li.className = "playlist-item";
356
- li.dataset.id = item.id;
357
258
 
358
259
  if (item.type === "image" || item.type === "video") {
359
260
  const thumb = document.createElement(item.type === "video" ? "video" : "img");
360
261
  thumb.className = "playlist-item-thumb";
361
- thumb.src = absoluteMediaUrl(item.url);
262
+ try {
263
+ thumb.src = absoluteMediaUrl(item.url);
264
+ } catch {
265
+ thumb.removeAttribute("src");
266
+ }
362
267
  if (item.type === "video") {
363
268
  thumb.muted = true;
364
269
  thumb.playsInline = true;
365
- } else {
366
- thumb.alt = item.name;
367
270
  }
368
271
  li.appendChild(thumb);
369
272
  }
@@ -378,20 +281,14 @@ function renderPlaylist() {
378
281
 
379
282
  const actions = document.createElement("div");
380
283
  actions.className = "playlist-item-actions";
381
-
382
- const durLabel = document.createElement("label");
383
- durLabel.style.fontSize = "0.75rem";
384
- durLabel.textContent = "sec ";
385
284
  const durInput = document.createElement("input");
386
285
  durInput.type = "number";
387
286
  durInput.min = "1";
388
287
  durInput.max = "3600";
389
288
  durInput.value = String(Math.round(item.durationMs / 1000));
390
289
  durInput.addEventListener("change", () => {
391
- const seconds = Math.min(3600, Math.max(1, Number(durInput.value) || 10));
392
- item.durationMs = seconds * 1000;
393
- meta.textContent = `${item.type} · ${seconds}s`;
394
- savePlaylistDraft();
290
+ item.durationMs = Math.min(3600, Math.max(1, Number(durInput.value) || 10)) * 1000;
291
+ meta.textContent = `${item.type} · ${Math.round(item.durationMs / 1000)}s`;
395
292
  });
396
293
 
397
294
  const removeBtn = document.createElement("button");
@@ -399,15 +296,12 @@ function renderPlaylist() {
399
296
  removeBtn.className = "danger";
400
297
  removeBtn.textContent = "Remove";
401
298
  removeBtn.addEventListener("click", () => {
402
- playlistItems = playlistItems.filter((x) => x.id !== item.id);
403
- savePlaylistDraft();
404
- renderPlaylist();
299
+ editorItems = editorItems.filter((x) => x.id !== item.id);
300
+ renderEditorAssets();
405
301
  });
406
302
 
407
- actions.appendChild(durLabel);
408
303
  actions.appendChild(durInput);
409
304
  actions.appendChild(removeBtn);
410
-
411
305
  li.appendChild(name);
412
306
  li.appendChild(meta);
413
307
  li.appendChild(actions);
@@ -415,11 +309,308 @@ function renderPlaylist() {
415
309
  }
416
310
  }
417
311
 
312
+ async function saveCurrentPlaylist() {
313
+ const name = String(document.getElementById("playlistName")?.value || "").trim();
314
+ if (!name) {
315
+ alert("Enter a playlist name.");
316
+ return;
317
+ }
318
+ if (editorItems.length === 0) {
319
+ alert("Add at least one asset before saving.");
320
+ return;
321
+ }
322
+
323
+ let items;
324
+ try {
325
+ items = editorItems.map((item) => ({
326
+ url: absoluteMediaUrl(item.url),
327
+ type: item.type,
328
+ durationMs: item.durationMs
329
+ }));
330
+ } catch (err) {
331
+ alert(err.message);
332
+ return;
333
+ }
334
+
335
+ const body = {
336
+ id: selectedPlaylistId || undefined,
337
+ name,
338
+ schedule: buildScheduleFromForm(),
339
+ items
340
+ };
341
+
342
+ const res = await fetch("/playlists", {
343
+ method: "POST",
344
+ headers: { "Content-Type": "application/json" },
345
+ body: JSON.stringify(body)
346
+ });
347
+ const data = await res.json();
348
+ showResult(data);
349
+
350
+ if (!res.ok) {
351
+ alert(data.error || "Save failed");
352
+ return;
353
+ }
354
+
355
+ selectedPlaylistId = data.playlist?.id || selectedPlaylistId;
356
+ await fetchPlaylists();
357
+ loadEditorFromSelection();
358
+ }
359
+
360
+ async function deleteCurrentPlaylist() {
361
+ if (!selectedPlaylistId) {
362
+ alert("Select a playlist to delete.");
363
+ return;
364
+ }
365
+ const pl = getSelectedPlaylist();
366
+ if (
367
+ !confirm(
368
+ `Delete playlist "${pl?.name}"? Devices already playing it keep their cached copy until Clear or reboot without sync. New devices cannot receive it.`
369
+ )
370
+ ) {
371
+ return;
372
+ }
373
+
374
+ const res = await fetch(`/playlists/${encodeURIComponent(selectedPlaylistId)}`, {
375
+ method: "DELETE"
376
+ });
377
+ const data = await res.json();
378
+ showResult(data);
379
+ if (!res.ok) {
380
+ alert(data.error || "Delete failed");
381
+ return;
382
+ }
383
+
384
+ selectedPlaylistId = null;
385
+ editorItems = [];
386
+ await fetchPlaylists();
387
+ loadEditorFromSelection();
388
+ }
389
+
390
+ function newPlaylistDraft() {
391
+ selectedPlaylistId = null;
392
+ document.getElementById("playlistName").value = "";
393
+ loadScheduleIntoForm(null);
394
+ editorItems = [];
395
+ renderPlaylistCatalog();
396
+ renderEditorAssets();
397
+ document.getElementById("editorTitle").textContent = "New playlist";
398
+ }
399
+
400
+ async function fetchDevices() {
401
+ try {
402
+ const res = await fetch("/devices");
403
+ const data = await res.json();
404
+ if (Array.isArray(data.devices)) {
405
+ devicesCache = data.devices;
406
+ renderDeviceCards();
407
+ }
408
+ } catch (err) {
409
+ showResult({ status: "failed", error: err.message });
410
+ }
411
+ }
412
+
413
+ function renderDeviceCards() {
414
+ const grid = document.getElementById("devicesGrid");
415
+ if (!grid) return;
416
+ grid.innerHTML = "";
417
+
418
+ if (devicesCache.length === 0) {
419
+ const empty = document.createElement("p");
420
+ empty.className = "devices-empty";
421
+ empty.textContent = "No paired devices yet.";
422
+ grid.appendChild(empty);
423
+ return;
424
+ }
425
+
426
+ for (const device of devicesCache) {
427
+ const card = document.createElement("article");
428
+ card.className = "device-card";
429
+
430
+ const header = document.createElement("div");
431
+ header.className = "device-card-header";
432
+ const led = document.createElement("span");
433
+ led.className = `status-led ${device.connected ? "status-led--online" : "status-led--offline"}`;
434
+ const title = document.createElement("h3");
435
+ title.className = "device-card-title";
436
+ title.textContent = device.deviceName || "Screen";
437
+ header.appendChild(led);
438
+ header.appendChild(title);
439
+
440
+ const published = document.createElement("ul");
441
+ published.className = "device-published-list";
442
+ const pubs = Array.isArray(device.publishedPlaylists) ? device.publishedPlaylists : [];
443
+ if (pubs.length === 0) {
444
+ const li = document.createElement("li");
445
+ li.textContent = "No playlists published";
446
+ published.appendChild(li);
447
+ } else {
448
+ for (const p of pubs) {
449
+ const li = document.createElement("li");
450
+ const label = document.createElement("span");
451
+ label.textContent = `${p.name} (v${p.version})`;
452
+ const rm = document.createElement("button");
453
+ rm.type = "button";
454
+ rm.textContent = "Remove";
455
+ rm.addEventListener("click", () => removePlaylistFromDevice(device.deviceId, p.playlistId));
456
+ li.appendChild(label);
457
+ li.appendChild(rm);
458
+ published.appendChild(li);
459
+ }
460
+ }
461
+
462
+ const meta = document.createElement("dl");
463
+ meta.className = "device-meta";
464
+ const rows = [
465
+ ["Device ID", device.deviceId],
466
+ ["System", device.system || device.platform || "—"],
467
+ ["Device online", formatDeviceOnlineLabel(device)],
468
+ ["Last boot", formatDateTimeSeconds(device.lastBootAt)],
469
+ ["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)]
470
+ ];
471
+ for (const [label, value] of rows) {
472
+ const row = document.createElement("div");
473
+ row.className = "device-meta-row";
474
+ const dt = document.createElement("dt");
475
+ dt.textContent = label;
476
+ const dd = document.createElement("dd");
477
+ dd.textContent = value;
478
+ row.appendChild(dt);
479
+ row.appendChild(dd);
480
+ meta.appendChild(row);
481
+ }
482
+
483
+ const actions = document.createElement("div");
484
+ actions.className = "device-card-actions";
485
+
486
+ const publishBtn = document.createElement("button");
487
+ publishBtn.type = "button";
488
+ publishBtn.className = "primary";
489
+ publishBtn.textContent = "Publish";
490
+ publishBtn.addEventListener("click", () => openPublishModal(device.deviceId));
491
+
492
+ const infoBtn = document.createElement("button");
493
+ infoBtn.type = "button";
494
+ infoBtn.textContent = "Info";
495
+ infoBtn.addEventListener("click", () => deviceAction(device.deviceId, "get-info"));
496
+
497
+ const rebootBtn = document.createElement("button");
498
+ rebootBtn.type = "button";
499
+ rebootBtn.textContent = "Reboot";
500
+ rebootBtn.addEventListener("click", () => deviceAction(device.deviceId, "reboot"));
501
+
502
+ const clearBtn = document.createElement("button");
503
+ clearBtn.type = "button";
504
+ clearBtn.textContent = "Clear";
505
+ clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
506
+
507
+ const unpairBtn = document.createElement("button");
508
+ unpairBtn.type = "button";
509
+ unpairBtn.className = "danger";
510
+ unpairBtn.textContent = "Unpair";
511
+ unpairBtn.addEventListener("click", () => unpairDevice(device.deviceId));
512
+
513
+ actions.appendChild(publishBtn);
514
+ actions.appendChild(infoBtn);
515
+ actions.appendChild(rebootBtn);
516
+ actions.appendChild(clearBtn);
517
+ actions.appendChild(unpairBtn);
518
+
519
+ card.appendChild(header);
520
+ card.appendChild(published);
521
+ card.appendChild(meta);
522
+ card.appendChild(actions);
523
+ grid.appendChild(card);
524
+ }
525
+ }
526
+
527
+ function openPublishModal(deviceId) {
528
+ publishModalDeviceId = deviceId;
529
+ const modal = document.getElementById("publishModal");
530
+ const checklist = document.getElementById("publishChecklist");
531
+ const hint = document.getElementById("publishModalHint");
532
+ if (!modal || !checklist) return;
533
+
534
+ if (playlistsCatalog.length === 0) {
535
+ alert("Create and save at least one playlist first.");
536
+ return;
537
+ }
538
+
539
+ hint.textContent = `Device ${deviceId} — select playlists to publish (snapshot at publish time).`;
540
+ checklist.innerHTML = "";
541
+
542
+ for (const pl of playlistsCatalog) {
543
+ const label = document.createElement("label");
544
+ const cb = document.createElement("input");
545
+ cb.type = "checkbox";
546
+ cb.value = pl.id;
547
+ cb.dataset.name = pl.name;
548
+ const pubs = devicesCache.find((d) => d.deviceId === deviceId)?.publishedPlaylists || [];
549
+ if (pubs.some((p) => p.playlistId === pl.id)) cb.checked = true;
550
+ label.appendChild(cb);
551
+ label.appendChild(document.createTextNode(` ${pl.name} (v${pl.version})`));
552
+ checklist.appendChild(label);
553
+ }
554
+
555
+ modal.classList.remove("hidden");
556
+ }
557
+
558
+ function closePublishModal() {
559
+ publishModalDeviceId = null;
560
+ document.getElementById("publishModal")?.classList.add("hidden");
561
+ }
562
+
563
+ async function confirmPublishModal() {
564
+ if (!publishModalDeviceId) return;
565
+ const ids = [
566
+ ...document.querySelectorAll("#publishChecklist input[type=checkbox]:checked")
567
+ ].map((el) => el.value);
568
+
569
+ if (ids.length === 0) {
570
+ alert("Select at least one playlist.");
571
+ return;
572
+ }
573
+
574
+ const res = await fetch(
575
+ `/device/${encodeURIComponent(publishModalDeviceId)}/assignments`,
576
+ {
577
+ method: "POST",
578
+ headers: { "Content-Type": "application/json" },
579
+ body: JSON.stringify({ playlistIds: ids })
580
+ }
581
+ );
582
+ const data = await res.json();
583
+ showResult({ deviceId: publishModalDeviceId, publish: data });
584
+ if (!res.ok) {
585
+ alert(data.error || "Publish failed");
586
+ return;
587
+ }
588
+ closePublishModal();
589
+ await fetchDevices();
590
+ }
591
+
592
+ async function removePlaylistFromDevice(deviceId, playlistId) {
593
+ if (!confirm("Remove this playlist from the device? Currently playing content may continue until Clear or failed reboot sync.")) {
594
+ return;
595
+ }
596
+ const res = await fetch(
597
+ `/device/${encodeURIComponent(deviceId)}/assignments/${encodeURIComponent(playlistId)}`,
598
+ { method: "DELETE" }
599
+ );
600
+ const data = await res.json();
601
+ showResult({ deviceId, remove: data });
602
+ if (!res.ok) {
603
+ alert(data.error || "Remove failed");
604
+ return;
605
+ }
606
+ await fetchDevices();
607
+ }
608
+
418
609
  async function uploadFile(file) {
419
610
  const q = new URLSearchParams({ filename: file.name });
420
611
  const res = await fetch(`/media/upload?${q.toString()}`, {
421
612
  method: "POST",
422
- headers: { "Content-Type": file.type || "application/octet-stream" },
613
+ headers: { "Content-Type": "application/octet-stream" },
423
614
  body: file
424
615
  });
425
616
  const data = await res.json();
@@ -430,219 +621,107 @@ async function uploadFile(file) {
430
621
  }
431
622
 
432
623
  async function addAssetFromFile(file) {
433
- showResult({ status: "uploading", filename: file.name });
624
+ if (!selectedPlaylistId) {
625
+ alert("Select or create a playlist first, then Save.");
626
+ return;
627
+ }
434
628
  const data = await uploadFile(file);
435
629
  const type = inferMediaType(file.name, file.type);
436
- playlistItems.push({
630
+ editorItems.push({
437
631
  id: crypto.randomUUID(),
438
632
  url: data.url,
439
633
  name: file.name,
440
634
  type,
441
635
  durationMs: defaultDurationMs(type)
442
636
  });
443
- savePlaylistDraft();
444
- renderPlaylist();
637
+ renderEditorAssets();
445
638
  showResult({ status: "uploaded", ...data });
446
639
  }
447
640
 
448
- function buildSchedule() {
449
- const schedule = {};
450
- const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
451
- const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
452
- const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
453
- const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
454
- const days = [...document.querySelectorAll(".day-checkbox:checked")].map((el) =>
455
- Number(el.value)
456
- );
457
-
458
- if (startDate) schedule.startDate = startDate;
459
- if (endDate) schedule.endDate = endDate;
460
- if (startTime) schedule.start = startTime;
461
- if (endTime) schedule.end = endTime;
462
- if (days.length > 0) schedule.daysOfWeek = days;
463
-
464
- return Object.keys(schedule).length > 0 ? schedule : undefined;
465
- }
466
-
467
- function buildPolicyPayload() {
468
- const schedule = buildSchedule();
469
- const playlist = {
470
- id: "main",
471
- name: "Playlist",
472
- items: playlistItems.map((item) => ({
473
- url: absoluteMediaUrl(item.url),
474
- type: item.type,
475
- durationMs: item.durationMs
476
- }))
477
- };
478
- if (schedule) playlist.schedule = schedule;
479
-
480
- return {
481
- policy: {
482
- playlists: [playlist],
483
- fallback: { type: "brand" }
484
- }
485
- };
486
- }
487
-
488
641
  async function verify() {
489
642
  const code = String(document.getElementById("code").value || "")
490
643
  .trim()
491
644
  .toUpperCase()
492
645
  .replace(/[^0-9A-Z]/g, "");
493
-
494
646
  if (code.length !== 6) {
495
647
  showResult({ status: "failed", error: "Enter the 6-character code from the screen." });
496
648
  return;
497
649
  }
498
-
499
650
  const res = await fetch("/pairing/verify", {
500
651
  method: "POST",
501
652
  headers: { "Content-Type": "application/json" },
502
653
  body: JSON.stringify({ code })
503
654
  });
504
-
505
655
  const data = await res.json();
506
656
  showResult(data);
507
-
508
657
  if (data.deviceId) {
509
- const codeInput = document.getElementById("code");
510
- if (codeInput) codeInput.value = "";
658
+ document.getElementById("code").value = "";
511
659
  await fetchDevices();
512
660
  }
513
661
  }
514
662
 
515
663
  async function unpairDevice(deviceId) {
516
- if (
517
- !confirm(
518
- "Unpair this device? Its card will be removed; the screen keeps the same permanent pairing code."
519
- )
520
- ) {
521
- return;
522
- }
523
-
664
+ if (!confirm("Unpair this device?")) return;
524
665
  const res = await fetch("/pairing/unpair", {
525
666
  method: "POST",
526
667
  headers: { "Content-Type": "application/json" },
527
668
  body: JSON.stringify({ deviceId })
528
669
  });
529
-
530
670
  const data = await res.json();
531
671
  showResult(data);
532
-
533
- if (res.ok) {
534
- await fetchDevices();
535
- } else {
536
- alert(data.error || "Unpair failed");
537
- }
538
- }
539
-
540
- async function publishToDevice(deviceId) {
541
- if (!deviceId) {
542
- alert("Unknown device.");
543
- return;
544
- }
545
-
546
- if (playlistItems.length === 0) {
547
- alert("Add at least one asset to the playlist.");
548
- return;
549
- }
550
-
551
- const mediaBase = getMediaBaseOrigin();
552
- if (!mediaBase) {
553
- alert(
554
- "Local CMS only: set CMS URL for screens first (e.g. http://192.168.1.105:3000 — your PC LAN IP, not localhost)."
555
- );
556
- return;
557
- }
558
- if (isLocalPanelHost(new URL(mediaBase).hostname)) {
559
- const ok = confirm(
560
- "Media URLs use localhost. TVs cannot download from localhost unless the player runs on this PC. Continue anyway?"
561
- );
562
- if (!ok) return;
563
- }
564
-
565
- saveScheduleDraft();
566
- let payload;
567
- try {
568
- payload = buildPolicyPayload();
569
- } catch (err) {
570
- alert(err.message);
571
- return;
572
- }
573
-
574
- const res = await fetch(`/device/${deviceId}/content/set-policy`, {
575
- method: "POST",
576
- headers: { "Content-Type": "application/json" },
577
- body: JSON.stringify(payload)
578
- });
579
-
580
- const data = await res.json();
581
- showResult({ deviceId, publish: payload, response: data });
582
-
583
- if (res.ok) {
584
- await fetchDevices();
585
- }
672
+ if (res.ok) await fetchDevices();
586
673
  }
587
674
 
588
675
  async function deviceAction(deviceId, action) {
589
676
  if (!deviceId) return;
590
-
591
677
  if (action === "reboot" && !confirm("Reboot this device?")) return;
592
678
  if (action === "content/clear" && !confirm("Clear content on this device?")) return;
593
-
594
679
  const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
595
680
  method: "POST"
596
681
  });
597
682
  const data = await res.json();
598
683
  showResult({ deviceId, action, ...data });
684
+ if (action === "reboot" && res.ok) await fetchDevices();
685
+ }
599
686
 
600
- if (action === "reboot" && res.ok) {
601
- await fetchDevices();
602
- }
687
+ function startDevicePolling() {
688
+ if (devicePollTimer) clearInterval(devicePollTimer);
689
+ void fetchDevices();
690
+ devicePollTimer = setInterval(() => void fetchDevices(), 8000);
603
691
  }
604
692
 
605
693
  document.addEventListener("DOMContentLoaded", () => {
606
694
  const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
607
695
  const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
608
- if (savedMediaBase && cmsBaseInput) {
609
- cmsBaseInput.value = savedMediaBase;
610
- } else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
696
+ if (savedMediaBase && cmsBaseInput) cmsBaseInput.value = savedMediaBase;
697
+ else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
611
698
  cmsBaseInput.value = window.location.origin;
612
699
  }
613
700
 
614
- loadPlaylistDraft();
615
- loadScheduleDraft();
701
+ void fetchPlaylists();
616
702
  startDevicePolling();
617
703
 
618
- document.getElementById("addAssetBtn").addEventListener("click", () => {
704
+ document.getElementById("newPlaylistBtn")?.addEventListener("click", newPlaylistDraft);
705
+ document.getElementById("savePlaylistBtn")?.addEventListener("click", () => void saveCurrentPlaylist());
706
+ document.getElementById("deletePlaylistBtn")?.addEventListener("click", () => void deleteCurrentPlaylist());
707
+ document.getElementById("publishConfirmBtn")?.addEventListener("click", () => void confirmPublishModal());
708
+
709
+ document.querySelectorAll("[data-close-modal]").forEach((el) => {
710
+ el.addEventListener("click", closePublishModal);
711
+ });
712
+
713
+ document.getElementById("addAssetBtn")?.addEventListener("click", () => {
619
714
  document.getElementById("fileInput").click();
620
715
  });
621
716
 
622
- document.getElementById("fileInput").addEventListener("change", async (ev) => {
717
+ document.getElementById("fileInput")?.addEventListener("change", async (ev) => {
623
718
  const files = ev.target.files;
624
719
  if (!files?.length) return;
625
720
  try {
626
- for (const file of files) {
627
- await addAssetFromFile(file);
628
- }
721
+ for (const file of files) await addAssetFromFile(file);
629
722
  } catch (err) {
630
723
  alert(err.message);
631
- showResult({ status: "failed", error: err.message });
632
724
  }
633
725
  ev.target.value = "";
634
726
  });
635
-
636
- [
637
- "scheduleStartDate",
638
- "scheduleEndDate",
639
- "scheduleStartTime",
640
- "scheduleEndTime"
641
- ].forEach((id) => {
642
- document.getElementById(id)?.addEventListener("change", saveScheduleDraft);
643
- });
644
- document.querySelectorAll(".day-checkbox").forEach((el) => {
645
- el.addEventListener("change", saveScheduleDraft);
646
- });
647
-
648
727
  });