@tomorrowos/sdk 0.2.4 → 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;
155
+ }
156
+
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
+ });
293
167
  }
294
168
 
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));
169
+ function getSelectedPlaylist() {
170
+ return playlistsCatalog.find((p) => p.id === selectedPlaylistId) || null;
304
171
  }
305
172
 
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));
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,226 +309,419 @@ function renderPlaylist() {
415
309
  }
416
310
  }
417
311
 
418
- async function uploadFile(file) {
419
- const q = new URLSearchParams({ filename: file.name });
420
- const res = await fetch(`/media/upload?${q.toString()}`, {
421
- method: "POST",
422
- headers: { "Content-Type": file.type || "application/octet-stream" },
423
- body: file
424
- });
425
- const data = await res.json();
426
- if (!res.ok || data.status === "failed") {
427
- throw new Error(data.error || `Upload failed (${res.status})`);
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;
428
321
  }
429
- return data;
430
- }
431
-
432
- async function addAssetFromFile(file) {
433
- showResult({ status: "uploading", filename: file.name });
434
- const data = await uploadFile(file);
435
- const type = inferMediaType(file.name, file.type);
436
- playlistItems.push({
437
- id: crypto.randomUUID(),
438
- url: data.url,
439
- name: file.name,
440
- type,
441
- durationMs: defaultDurationMs(type)
442
- });
443
- savePlaylistDraft();
444
- renderPlaylist();
445
- showResult({ status: "uploaded", ...data });
446
- }
447
-
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
322
 
467
- function buildPolicyPayload() {
468
- const schedule = buildSchedule();
469
- const playlist = {
470
- id: "main",
471
- name: "Playlist",
472
- items: playlistItems.map((item) => ({
323
+ let items;
324
+ try {
325
+ items = editorItems.map((item) => ({
473
326
  url: absoluteMediaUrl(item.url),
474
327
  type: item.type,
475
328
  durationMs: item.durationMs
476
- }))
477
- };
478
- if (schedule) playlist.schedule = schedule;
329
+ }));
330
+ } catch (err) {
331
+ alert(err.message);
332
+ return;
333
+ }
479
334
 
480
- return {
481
- policy: {
482
- playlists: [playlist],
483
- fallback: { type: "brand" }
484
- }
335
+ const body = {
336
+ id: selectedPlaylistId || undefined,
337
+ name,
338
+ schedule: buildScheduleFromForm(),
339
+ items
485
340
  };
486
- }
487
-
488
- async function verify() {
489
- const code = document.getElementById("code").value;
490
341
 
491
- const res = await fetch("/pairing/verify", {
342
+ const res = await fetch("/playlists", {
492
343
  method: "POST",
493
344
  headers: { "Content-Type": "application/json" },
494
- body: JSON.stringify({ code })
345
+ body: JSON.stringify(body)
495
346
  });
496
-
497
347
  const data = await res.json();
498
348
  showResult(data);
499
349
 
500
- if (data.deviceId) {
501
- const codeInput = document.getElementById("code");
502
- if (codeInput) codeInput.value = "";
503
- await fetchDevices();
350
+ if (!res.ok) {
351
+ alert(data.error || "Save failed");
352
+ return;
504
353
  }
354
+
355
+ selectedPlaylistId = data.playlist?.id || selectedPlaylistId;
356
+ await fetchPlaylists();
357
+ loadEditorFromSelection();
505
358
  }
506
359
 
507
- async function unpairDevice(deviceId) {
360
+ async function deleteCurrentPlaylist() {
361
+ if (!selectedPlaylistId) {
362
+ alert("Select a playlist to delete.");
363
+ return;
364
+ }
365
+ const pl = getSelectedPlaylist();
508
366
  if (
509
367
  !confirm(
510
- "Unpair this device? Its card will be removed and the screen will show a new pairing code."
368
+ `Delete playlist "${pl?.name}"? Devices already playing it keep their cached copy until Clear or reboot without sync. New devices cannot receive it.`
511
369
  )
512
370
  ) {
513
371
  return;
514
372
  }
515
373
 
516
- const res = await fetch("/pairing/unpair", {
517
- method: "POST",
518
- headers: { "Content-Type": "application/json" },
519
- body: JSON.stringify({ deviceId })
374
+ const res = await fetch(`/playlists/${encodeURIComponent(selectedPlaylistId)}`, {
375
+ method: "DELETE"
520
376
  });
521
-
522
377
  const data = await res.json();
523
378
  showResult(data);
379
+ if (!res.ok) {
380
+ alert(data.error || "Delete failed");
381
+ return;
382
+ }
524
383
 
525
- if (res.ok) {
526
- await fetchDevices();
527
- } else {
528
- alert(data.error || "Unpair failed");
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 });
529
410
  }
530
411
  }
531
412
 
532
- async function publishToDevice(deviceId) {
533
- if (!deviceId) {
534
- alert("Unknown device.");
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);
535
423
  return;
536
424
  }
537
425
 
538
- if (playlistItems.length === 0) {
539
- alert("Add at least one asset to the playlist.");
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.");
540
536
  return;
541
537
  }
542
538
 
543
- const mediaBase = getMediaBaseOrigin();
544
- if (!mediaBase) {
545
- alert(
546
- "Local CMS only: set CMS URL for screens first (e.g. http://192.168.1.105:3000 your PC LAN IP, not localhost)."
547
- );
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.");
548
571
  return;
549
572
  }
550
- if (isLocalPanelHost(new URL(mediaBase).hostname)) {
551
- const ok = confirm(
552
- "Media URLs use localhost. TVs cannot download from localhost unless the player runs on this PC. Continue anyway?"
553
- );
554
- if (!ok) return;
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;
555
587
  }
588
+ closePublishModal();
589
+ await fetchDevices();
590
+ }
556
591
 
557
- saveScheduleDraft();
558
- let payload;
559
- try {
560
- payload = buildPolicyPayload();
561
- } catch (err) {
562
- alert(err.message);
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.")) {
563
594
  return;
564
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
+ }
565
608
 
566
- const res = await fetch(`/device/${deviceId}/content/set-policy`, {
609
+ async function uploadFile(file) {
610
+ const q = new URLSearchParams({ filename: file.name });
611
+ const res = await fetch(`/media/upload?${q.toString()}`, {
567
612
  method: "POST",
568
- headers: { "Content-Type": "application/json" },
569
- body: JSON.stringify(payload)
613
+ headers: { "Content-Type": "application/octet-stream" },
614
+ body: file
570
615
  });
571
-
572
616
  const data = await res.json();
573
- showResult({ deviceId, publish: payload, response: data });
617
+ if (!res.ok || data.status === "failed") {
618
+ throw new Error(data.error || `Upload failed (${res.status})`);
619
+ }
620
+ return data;
621
+ }
574
622
 
575
- if (res.ok) {
623
+ async function addAssetFromFile(file) {
624
+ if (!selectedPlaylistId) {
625
+ alert("Select or create a playlist first, then Save.");
626
+ return;
627
+ }
628
+ const data = await uploadFile(file);
629
+ const type = inferMediaType(file.name, file.type);
630
+ editorItems.push({
631
+ id: crypto.randomUUID(),
632
+ url: data.url,
633
+ name: file.name,
634
+ type,
635
+ durationMs: defaultDurationMs(type)
636
+ });
637
+ renderEditorAssets();
638
+ showResult({ status: "uploaded", ...data });
639
+ }
640
+
641
+ async function verify() {
642
+ const code = String(document.getElementById("code").value || "")
643
+ .trim()
644
+ .toUpperCase()
645
+ .replace(/[^0-9A-Z]/g, "");
646
+ if (code.length !== 6) {
647
+ showResult({ status: "failed", error: "Enter the 6-character code from the screen." });
648
+ return;
649
+ }
650
+ const res = await fetch("/pairing/verify", {
651
+ method: "POST",
652
+ headers: { "Content-Type": "application/json" },
653
+ body: JSON.stringify({ code })
654
+ });
655
+ const data = await res.json();
656
+ showResult(data);
657
+ if (data.deviceId) {
658
+ document.getElementById("code").value = "";
576
659
  await fetchDevices();
577
660
  }
578
661
  }
579
662
 
663
+ async function unpairDevice(deviceId) {
664
+ if (!confirm("Unpair this device?")) return;
665
+ const res = await fetch("/pairing/unpair", {
666
+ method: "POST",
667
+ headers: { "Content-Type": "application/json" },
668
+ body: JSON.stringify({ deviceId })
669
+ });
670
+ const data = await res.json();
671
+ showResult(data);
672
+ if (res.ok) await fetchDevices();
673
+ }
674
+
580
675
  async function deviceAction(deviceId, action) {
581
676
  if (!deviceId) return;
582
-
583
677
  if (action === "reboot" && !confirm("Reboot this device?")) return;
584
678
  if (action === "content/clear" && !confirm("Clear content on this device?")) return;
585
-
586
679
  const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
587
680
  method: "POST"
588
681
  });
589
682
  const data = await res.json();
590
683
  showResult({ deviceId, action, ...data });
684
+ if (action === "reboot" && res.ok) await fetchDevices();
685
+ }
591
686
 
592
- if (action === "reboot" && res.ok) {
593
- await fetchDevices();
594
- }
687
+ function startDevicePolling() {
688
+ if (devicePollTimer) clearInterval(devicePollTimer);
689
+ void fetchDevices();
690
+ devicePollTimer = setInterval(() => void fetchDevices(), 8000);
595
691
  }
596
692
 
597
693
  document.addEventListener("DOMContentLoaded", () => {
598
694
  const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
599
695
  const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
600
- if (savedMediaBase && cmsBaseInput) {
601
- cmsBaseInput.value = savedMediaBase;
602
- } else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
696
+ if (savedMediaBase && cmsBaseInput) cmsBaseInput.value = savedMediaBase;
697
+ else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
603
698
  cmsBaseInput.value = window.location.origin;
604
699
  }
605
700
 
606
- loadPlaylistDraft();
607
- loadScheduleDraft();
701
+ void fetchPlaylists();
608
702
  startDevicePolling();
609
703
 
610
- 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", () => {
611
714
  document.getElementById("fileInput").click();
612
715
  });
613
716
 
614
- document.getElementById("fileInput").addEventListener("change", async (ev) => {
717
+ document.getElementById("fileInput")?.addEventListener("change", async (ev) => {
615
718
  const files = ev.target.files;
616
719
  if (!files?.length) return;
617
720
  try {
618
- for (const file of files) {
619
- await addAssetFromFile(file);
620
- }
721
+ for (const file of files) await addAssetFromFile(file);
621
722
  } catch (err) {
622
723
  alert(err.message);
623
- showResult({ status: "failed", error: err.message });
624
724
  }
625
725
  ev.target.value = "";
626
726
  });
627
-
628
- [
629
- "scheduleStartDate",
630
- "scheduleEndDate",
631
- "scheduleStartTime",
632
- "scheduleEndTime"
633
- ].forEach((id) => {
634
- document.getElementById(id)?.addEventListener("change", saveScheduleDraft);
635
- });
636
- document.querySelectorAll(".day-checkbox").forEach((el) => {
637
- el.addEventListener("change", saveScheduleDraft);
638
- });
639
-
640
727
  });