@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.
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/playlist-catalog.d.ts +35 -0
- package/dist/playlist-catalog.d.ts.map +1 -0
- package/dist/playlist-catalog.js +133 -0
- package/dist/store/memory-store.d.ts +9 -1
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +35 -0
- package/dist/store/types.d.ts +41 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/tomorrowos.d.ts +14 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +136 -0
- package/package.json +1 -1
- package/templates/cms-starter/public/index.html +42 -14
- package/templates/cms-starter/public/methods.js +451 -372
- package/templates/cms-starter/public/panel.css +130 -1
- package/templates/cms-starter/server.ts +7 -0
|
@@ -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 {
|
|
6
|
-
let
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
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
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
/* ignore */
|
|
209
|
+
list.appendChild(li);
|
|
320
210
|
}
|
|
321
211
|
}
|
|
322
212
|
|
|
323
|
-
function
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
loadScheduleDraft();
|
|
701
|
+
void fetchPlaylists();
|
|
616
702
|
startDevicePolling();
|
|
617
703
|
|
|
618
|
-
document.getElementById("
|
|
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")
|
|
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
|
});
|