@tomorrowos/sdk 0.6.4 → 0.6.9
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/tomorrowos.d.ts +6 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +3 -1
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +6 -0
- package/templates/cms-starter/public/methods.js +352 -33
- package/templates/cms-starter/public/panel.css +57 -0
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface DeviceListItem {
|
|
|
52
52
|
name: string;
|
|
53
53
|
version: number;
|
|
54
54
|
publishedAt: string;
|
|
55
|
+
schedule?: {
|
|
56
|
+
startDate?: string;
|
|
57
|
+
endDate?: string;
|
|
58
|
+
start?: string;
|
|
59
|
+
end?: string;
|
|
60
|
+
};
|
|
55
61
|
}>;
|
|
56
62
|
latestErrorAt: string | null;
|
|
57
63
|
latestErrorMessage: string | null;
|
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAYxB,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAKV,eAAe,EAGhB,MAAM,kBAAkB,CAAC;AAc1B,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAkED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,kBAAkB,EAAE,KAAK,CAAC;QACxB,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -331,7 +331,8 @@ export class TomorrowOS extends EventEmitter {
|
|
|
331
331
|
playlistId: a.playlistId,
|
|
332
332
|
name: a.snapshot.name,
|
|
333
333
|
version: a.publishedVersion,
|
|
334
|
-
publishedAt: a.publishedAt
|
|
334
|
+
publishedAt: a.publishedAt,
|
|
335
|
+
schedule: a.snapshot.schedule
|
|
335
336
|
})),
|
|
336
337
|
connected,
|
|
337
338
|
deviceName: record.deviceName ?? null,
|
|
@@ -1389,6 +1390,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1389
1390
|
if (level === "error") {
|
|
1390
1391
|
console.error(`[TomorrowOS][device-log][${deviceId}] ${message}`);
|
|
1391
1392
|
}
|
|
1393
|
+
return;
|
|
1392
1394
|
}
|
|
1393
1395
|
});
|
|
1394
1396
|
ws.on("close", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "TomorrowOS CMS server SDK - WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build-player": "tomorrowos build --platform tizen"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@tomorrowos/sdk": "^0.6.
|
|
13
|
+
"@tomorrowos/sdk": "^0.6.9",
|
|
14
14
|
"dotenv": "^17.2.3"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
@@ -129,6 +129,12 @@
|
|
|
129
129
|
<h2>Publish to device</h2>
|
|
130
130
|
<p class="hint" id="publishModalHint">Select playlists to add to this device. Already-published playlists are not listed here.</p>
|
|
131
131
|
<div id="publishChecklist" class="publish-checklist"></div>
|
|
132
|
+
<div id="publishStatusShell" class="publish-status hidden" aria-live="polite">
|
|
133
|
+
<div id="publishStatusText" class="publish-status-text">Working...</div>
|
|
134
|
+
<div class="upload-progress-track">
|
|
135
|
+
<div id="publishProgressBar" class="upload-progress-bar"></div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
132
138
|
<div class="modal-actions">
|
|
133
139
|
<button type="button" id="publishConfirmBtn" class="primary">Publish selected</button>
|
|
134
140
|
<button type="button" data-close-modal="1">Cancel</button>
|
|
@@ -17,6 +17,7 @@ let editorItems = [];
|
|
|
17
17
|
|
|
18
18
|
/** @type {string|null} */
|
|
19
19
|
let publishModalDeviceId = null;
|
|
20
|
+
let publishInProgress = false;
|
|
20
21
|
/** @type {string|null} */
|
|
21
22
|
let editingDeviceNameId = null;
|
|
22
23
|
/** @type {string} */
|
|
@@ -138,8 +139,8 @@ function playlistHasRelativeMediaUrls(playlist) {
|
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
141
|
* Base URL sent on publish only when needed.
|
|
141
|
-
* - Hosted (e.g. Replit): use public origin
|
|
142
|
-
* - Local:
|
|
142
|
+
* - Hosted (e.g. Replit): use public origin when items are relative (/uploads/...).
|
|
143
|
+
* - Local: prefer saved LAN override; otherwise fall back to current origin for dev.
|
|
143
144
|
* - Returns null to omit mediaBaseUrl (playlist already has absolute https URLs).
|
|
144
145
|
*/
|
|
145
146
|
function getPublishMediaBaseUrl(selectedPlaylists) {
|
|
@@ -152,7 +153,181 @@ function getPublishMediaBaseUrl(selectedPlaylists) {
|
|
|
152
153
|
if (!isLocalPanelHost(window.location.hostname)) {
|
|
153
154
|
return window.location.origin;
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
+
// Local dev: same-origin fallback so panel publish/verify works without LAN override.
|
|
157
|
+
return window.location.origin;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Browser-side check URL — always same-origin so localhost panel can reach /uploads. */
|
|
161
|
+
function resolveVerificationMediaUrl(url) {
|
|
162
|
+
const p = String(url || "").trim();
|
|
163
|
+
if (!p) return "";
|
|
164
|
+
if (p.startsWith("/")) return p;
|
|
165
|
+
try {
|
|
166
|
+
const parsed = new URL(p);
|
|
167
|
+
const path = parsed.pathname + parsed.search;
|
|
168
|
+
if (path.startsWith("/uploads/") || path.startsWith("/screenshots/")) {
|
|
169
|
+
return path;
|
|
170
|
+
}
|
|
171
|
+
return p;
|
|
172
|
+
} catch {
|
|
173
|
+
return p;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function resolvePublishMediaUrl(url, mediaBaseUrl) {
|
|
178
|
+
const p = String(url || "").trim();
|
|
179
|
+
if (!p) return "";
|
|
180
|
+
if (/^https?:\/\//i.test(p)) return p;
|
|
181
|
+
const base =
|
|
182
|
+
normalizeMediaBaseUrl(mediaBaseUrl) ||
|
|
183
|
+
getExplicitLanMediaBase() ||
|
|
184
|
+
window.location.origin;
|
|
185
|
+
if (!base) return "";
|
|
186
|
+
return `${base}${p.startsWith("/") ? p : `/${p}`}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function verifyMediaAssetReachable(url) {
|
|
190
|
+
if (!url) return { ok: false, reason: "missing URL" };
|
|
191
|
+
|
|
192
|
+
const candidates = [];
|
|
193
|
+
const sameOriginPath = resolveVerificationMediaUrl(url);
|
|
194
|
+
if (sameOriginPath) candidates.push(sameOriginPath);
|
|
195
|
+
if (url && url !== sameOriginPath) candidates.push(url);
|
|
196
|
+
|
|
197
|
+
let lastReason = "unreachable";
|
|
198
|
+
for (const target of candidates) {
|
|
199
|
+
try {
|
|
200
|
+
// CMS static files are served on GET only (no HEAD / Range support).
|
|
201
|
+
const res = await fetch(target, { method: "GET", cache: "no-store" });
|
|
202
|
+
if (res.ok) return { ok: true };
|
|
203
|
+
lastReason = `HTTP ${res.status}`;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
lastReason = err?.message || "network error";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { ok: false, reason: lastReason };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function verifyPlaylistsAssetsReady(playlists, mediaBaseUrl, onProgress) {
|
|
212
|
+
const tasks = [];
|
|
213
|
+
for (const pl of playlists) {
|
|
214
|
+
for (const item of pl.items || []) {
|
|
215
|
+
const name = String(item?.name || item?.url || "asset").split("/").pop() || "asset";
|
|
216
|
+
const resolvedUrl = resolveVerificationMediaUrl(item?.url);
|
|
217
|
+
tasks.push({
|
|
218
|
+
playlist: pl.name || pl.id,
|
|
219
|
+
name,
|
|
220
|
+
url: item?.url,
|
|
221
|
+
resolvedUrl
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (tasks.length === 0) {
|
|
227
|
+
return { ok: false, failures: [{ playlist: "—", name: "—", reason: "no assets in playlist" }] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const failures = [];
|
|
231
|
+
let done = 0;
|
|
232
|
+
for (const task of tasks) {
|
|
233
|
+
onProgress?.(done, tasks.length, task.name);
|
|
234
|
+
if (!task.resolvedUrl) {
|
|
235
|
+
failures.push({
|
|
236
|
+
playlist: task.playlist,
|
|
237
|
+
name: task.name,
|
|
238
|
+
reason: "could not resolve media URL"
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
const result = await verifyMediaAssetReachable(task.resolvedUrl);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
failures.push({
|
|
244
|
+
playlist: task.playlist,
|
|
245
|
+
name: task.name,
|
|
246
|
+
reason: result.reason || "unreachable"
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
done += 1;
|
|
251
|
+
onProgress?.(done, tasks.length, task.name);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { ok: failures.length === 0, failures, total: tasks.length };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function refreshPlaylistsForPublish() {
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch("/playlists");
|
|
260
|
+
let data = {};
|
|
261
|
+
try {
|
|
262
|
+
data = await res.json();
|
|
263
|
+
} catch {
|
|
264
|
+
data = {};
|
|
265
|
+
}
|
|
266
|
+
if (!res.ok || !Array.isArray(data.playlists)) return false;
|
|
267
|
+
playlistsCatalog = data.playlists;
|
|
268
|
+
renderPlaylistCatalog();
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resetPublishModalStatus() {
|
|
276
|
+
publishInProgress = false;
|
|
277
|
+
updatePublishStatusUi({ hidden: true });
|
|
278
|
+
const confirmBtn = document.getElementById("publishConfirmBtn");
|
|
279
|
+
if (confirmBtn) {
|
|
280
|
+
confirmBtn.disabled = false;
|
|
281
|
+
confirmBtn.textContent = "Publish selected";
|
|
282
|
+
}
|
|
283
|
+
const modal = document.getElementById("publishModal");
|
|
284
|
+
modal?.querySelectorAll("[data-close-modal]").forEach((el) => {
|
|
285
|
+
if (el instanceof HTMLButtonElement) el.disabled = false;
|
|
286
|
+
});
|
|
287
|
+
document
|
|
288
|
+
.querySelectorAll("#publishChecklist input[type=checkbox]")
|
|
289
|
+
.forEach((el) => {
|
|
290
|
+
if (el instanceof HTMLInputElement) el.disabled = false;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function setPublishModalBusy(busy) {
|
|
295
|
+
publishInProgress = busy;
|
|
296
|
+
const confirmBtn = document.getElementById("publishConfirmBtn");
|
|
297
|
+
if (confirmBtn) {
|
|
298
|
+
confirmBtn.disabled = busy;
|
|
299
|
+
confirmBtn.textContent = busy ? "Publishing..." : "Publish selected";
|
|
300
|
+
}
|
|
301
|
+
document
|
|
302
|
+
.querySelectorAll("#publishChecklist input[type=checkbox]")
|
|
303
|
+
.forEach((el) => {
|
|
304
|
+
if (el instanceof HTMLInputElement) el.disabled = busy;
|
|
305
|
+
});
|
|
306
|
+
document.querySelectorAll("#publishModal [data-close-modal]").forEach((el) => {
|
|
307
|
+
if (el instanceof HTMLButtonElement) el.disabled = busy;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function updatePublishStatusUi(status) {
|
|
312
|
+
const shell = document.getElementById("publishStatusShell");
|
|
313
|
+
const text = document.getElementById("publishStatusText");
|
|
314
|
+
const bar = document.getElementById("publishProgressBar");
|
|
315
|
+
if (!shell || !text || !bar) return;
|
|
316
|
+
|
|
317
|
+
if (!status || status.hidden) {
|
|
318
|
+
shell.classList.add("hidden");
|
|
319
|
+
shell.classList.remove("publish-status--error", "publish-status--success");
|
|
320
|
+
bar.style.width = "0%";
|
|
321
|
+
text.textContent = "";
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
shell.classList.remove("hidden");
|
|
326
|
+
shell.classList.toggle("publish-status--error", !!status.isError);
|
|
327
|
+
shell.classList.toggle("publish-status--success", !!status.isSuccess);
|
|
328
|
+
text.textContent = status.text || "Working...";
|
|
329
|
+
const percent = Math.max(0, Math.min(100, Number(status.percent) || 0));
|
|
330
|
+
bar.style.width = `${percent}%`;
|
|
156
331
|
}
|
|
157
332
|
|
|
158
333
|
/** Resolve media URLs in the editor (save / thumbnails). */
|
|
@@ -328,6 +503,58 @@ function loadScheduleIntoForm(schedule) {
|
|
|
328
503
|
document.getElementById("scheduleEndTime").value = s.end || "";
|
|
329
504
|
}
|
|
330
505
|
|
|
506
|
+
function parseScheduleDateTimeMs(dateStr, timeStr, defaultTime) {
|
|
507
|
+
const date = String(dateStr || "").trim();
|
|
508
|
+
if (!date) return null;
|
|
509
|
+
const time = String(timeStr || "").trim() || defaultTime;
|
|
510
|
+
const value = new Date(`${date}T${time}:00`);
|
|
511
|
+
const ms = value.getTime();
|
|
512
|
+
return Number.isNaN(ms) ? null : ms;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function getPublishedPlaylistStartMs(playlist) {
|
|
516
|
+
const schedule = playlist?.schedule;
|
|
517
|
+
if (!schedule) return null;
|
|
518
|
+
return parseScheduleDateTimeMs(schedule.startDate, schedule.start, "00:00");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function getPublishedPlaylistEndMs(playlist) {
|
|
522
|
+
const schedule = playlist?.schedule;
|
|
523
|
+
if (!schedule) return null;
|
|
524
|
+
return parseScheduleDateTimeMs(schedule.endDate, schedule.end, "23:59");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function isPublishedPlaylistActiveNow(playlist, now = new Date()) {
|
|
528
|
+
const schedule = playlist?.schedule;
|
|
529
|
+
if (!schedule) return true;
|
|
530
|
+
const nowMs = now.getTime();
|
|
531
|
+
const startMs = getPublishedPlaylistStartMs(playlist);
|
|
532
|
+
const endMs = getPublishedPlaylistEndMs(playlist);
|
|
533
|
+
if (startMs !== null && nowMs < startMs) return false;
|
|
534
|
+
if (endMs !== null && nowMs >= endMs) return false;
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function pickScheduledPlaylistForIndicator(playlists, now = new Date()) {
|
|
539
|
+
const active = (Array.isArray(playlists) ? playlists : []).filter((playlist) =>
|
|
540
|
+
isPublishedPlaylistActiveNow(playlist, now)
|
|
541
|
+
);
|
|
542
|
+
if (!active.length) return null;
|
|
543
|
+
|
|
544
|
+
active.sort((a, b) => {
|
|
545
|
+
const aStart = getPublishedPlaylistStartMs(a);
|
|
546
|
+
const bStart = getPublishedPlaylistStartMs(b);
|
|
547
|
+
const aScore = aStart === null ? -Infinity : aStart;
|
|
548
|
+
const bScore = bStart === null ? -Infinity : bStart;
|
|
549
|
+
if (aScore !== bScore) return bScore - aScore;
|
|
550
|
+
const aPublished = new Date(a?.publishedAt || 0).getTime() || 0;
|
|
551
|
+
const bPublished = new Date(b?.publishedAt || 0).getTime() || 0;
|
|
552
|
+
return bPublished - aPublished;
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
return active[0] || null;
|
|
556
|
+
}
|
|
557
|
+
|
|
331
558
|
function getSelectedPlaylist() {
|
|
332
559
|
return playlistsCatalog.find((p) => p.id === selectedPlaylistId) || null;
|
|
333
560
|
}
|
|
@@ -727,6 +954,8 @@ function renderDeviceCards() {
|
|
|
727
954
|
const published = document.createElement("ul");
|
|
728
955
|
published.className = "device-published-list";
|
|
729
956
|
const pubs = Array.isArray(device.publishedPlaylists) ? device.publishedPlaylists : [];
|
|
957
|
+
const indicatorPlaylist = pickScheduledPlaylistForIndicator(pubs);
|
|
958
|
+
const indicatorPlaylistId = indicatorPlaylist?.playlistId || "";
|
|
730
959
|
if (pubs.length === 0) {
|
|
731
960
|
const li = document.createElement("li");
|
|
732
961
|
li.textContent = "No playlists published";
|
|
@@ -735,7 +964,15 @@ function renderDeviceCards() {
|
|
|
735
964
|
for (const p of pubs) {
|
|
736
965
|
const li = document.createElement("li");
|
|
737
966
|
const label = document.createElement("span");
|
|
967
|
+
label.className = "device-published-label";
|
|
738
968
|
label.textContent = p.name;
|
|
969
|
+
const isPlaying = !!indicatorPlaylistId && p.playlistId === indicatorPlaylistId;
|
|
970
|
+
if (isPlaying) {
|
|
971
|
+
const playingLight = document.createElement("span");
|
|
972
|
+
playingLight.className = "playlist-playing-light";
|
|
973
|
+
playingLight.title = "Active now by schedule";
|
|
974
|
+
label.appendChild(playingLight);
|
|
975
|
+
}
|
|
739
976
|
const rm = document.createElement("button");
|
|
740
977
|
rm.type = "button";
|
|
741
978
|
rm.textContent = "Remove";
|
|
@@ -876,15 +1113,24 @@ function openPublishModal(deviceId) {
|
|
|
876
1113
|
}
|
|
877
1114
|
|
|
878
1115
|
modal.classList.remove("hidden");
|
|
1116
|
+
resetPublishModalStatus();
|
|
879
1117
|
}
|
|
880
1118
|
|
|
881
1119
|
function closePublishModal() {
|
|
1120
|
+
if (publishInProgress) return;
|
|
882
1121
|
publishModalDeviceId = null;
|
|
1122
|
+
resetPublishModalStatus();
|
|
883
1123
|
document.getElementById("publishModal")?.classList.add("hidden");
|
|
884
1124
|
}
|
|
885
1125
|
|
|
886
1126
|
async function confirmPublishModal() {
|
|
887
|
-
if (!publishModalDeviceId) return;
|
|
1127
|
+
if (!publishModalDeviceId || publishInProgress) return;
|
|
1128
|
+
|
|
1129
|
+
if (uploadInProgress || uploadQueue.length > 0) {
|
|
1130
|
+
alert("Wait for asset uploads to finish before publishing.");
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
888
1134
|
const ids = [
|
|
889
1135
|
...document.querySelectorAll("#publishChecklist input[type=checkbox]:checked")
|
|
890
1136
|
].map((el) => el.value);
|
|
@@ -894,48 +1140,121 @@ async function confirmPublishModal() {
|
|
|
894
1140
|
return;
|
|
895
1141
|
}
|
|
896
1142
|
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
1143
|
+
const deviceId = publishModalDeviceId;
|
|
1144
|
+
setPublishModalBusy(true);
|
|
1145
|
+
updatePublishStatusUi({ hidden: false, text: "Refreshing playlists...", percent: 8 });
|
|
900
1146
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1147
|
+
try {
|
|
1148
|
+
const refreshed = await refreshPlaylistsForPublish();
|
|
1149
|
+
if (!refreshed) {
|
|
1150
|
+
updatePublishStatusUi({
|
|
1151
|
+
text: "Could not load playlists from the server.",
|
|
1152
|
+
percent: 0,
|
|
1153
|
+
isError: true
|
|
1154
|
+
});
|
|
1155
|
+
setPublishModalBusy(false);
|
|
1156
|
+
alert("Could not load playlists from the server. Try again.");
|
|
904
1157
|
return;
|
|
905
1158
|
}
|
|
906
|
-
}
|
|
907
1159
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1160
|
+
const selectedPlaylists = ids
|
|
1161
|
+
.map((id) => playlistsCatalog.find((p) => p.id === id))
|
|
1162
|
+
.filter(Boolean);
|
|
1163
|
+
|
|
1164
|
+
if (selectedPlaylists.length !== ids.length) {
|
|
1165
|
+
updatePublishStatusUi({
|
|
1166
|
+
text: "A selected playlist is no longer available.",
|
|
1167
|
+
percent: 0,
|
|
1168
|
+
isError: true
|
|
1169
|
+
});
|
|
1170
|
+
setPublishModalBusy(false);
|
|
1171
|
+
alert("A selected playlist was removed or changed. Close this dialog and open Publish again.");
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
for (const pl of selectedPlaylists) {
|
|
1176
|
+
if (!(pl.items || []).length) {
|
|
1177
|
+
updatePublishStatusUi({
|
|
1178
|
+
text: `Playlist "${pl.name || pl.id}" has no assets.`,
|
|
1179
|
+
percent: 0,
|
|
1180
|
+
isError: true
|
|
1181
|
+
});
|
|
1182
|
+
setPublishModalBusy(false);
|
|
1183
|
+
alert(`Playlist "${pl.name || pl.id}" has no assets. Save the playlist first.`);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const mediaBaseUrl = getPublishMediaBaseUrl(selectedPlaylists);
|
|
1189
|
+
|
|
1190
|
+
const verification = await verifyPlaylistsAssetsReady(
|
|
1191
|
+
selectedPlaylists,
|
|
1192
|
+
mediaBaseUrl,
|
|
1193
|
+
(done, total, assetName) => {
|
|
1194
|
+
const pct = 12 + Math.round((done / Math.max(total, 1)) * 68);
|
|
1195
|
+
updatePublishStatusUi({
|
|
1196
|
+
text: `Verifying assets (${done}/${total}): ${assetName}`,
|
|
1197
|
+
percent: pct
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
912
1200
|
);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
1201
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1202
|
+
if (!verification.ok) {
|
|
1203
|
+
const lines = verification.failures
|
|
1204
|
+
.map((f) => `• ${f.playlist} — ${f.name}: ${f.reason}`)
|
|
1205
|
+
.join("\n");
|
|
1206
|
+
updatePublishStatusUi({
|
|
1207
|
+
text: `Could not load ${verification.failures.length} asset(s). Publishing blocked.`,
|
|
1208
|
+
percent: 0,
|
|
1209
|
+
isError: true
|
|
1210
|
+
});
|
|
1211
|
+
setPublishModalBusy(false);
|
|
1212
|
+
alert(`Cannot publish until all playlist assets are reachable:\n\n${lines}`);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
updatePublishStatusUi({ text: "Publishing to device...", percent: 88 });
|
|
919
1217
|
|
|
920
|
-
|
|
921
|
-
|
|
1218
|
+
const device = devicesCache.find((d) => d.deviceId === deviceId);
|
|
1219
|
+
const alreadyOnDevice = (device?.publishedPlaylists || []).map((p) => p.playlistId);
|
|
1220
|
+
const playlistIds = [...new Set([...alreadyOnDevice, ...ids])];
|
|
922
1221
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1222
|
+
const publishBody = { playlistIds };
|
|
1223
|
+
if (mediaBaseUrl) publishBody.mediaBaseUrl = mediaBaseUrl;
|
|
1224
|
+
|
|
1225
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/assignments`, {
|
|
926
1226
|
method: "POST",
|
|
927
1227
|
headers: { "Content-Type": "application/json" },
|
|
928
1228
|
body: JSON.stringify(publishBody)
|
|
1229
|
+
});
|
|
1230
|
+
const data = await res.json();
|
|
1231
|
+
showResult({ deviceId, publish: data });
|
|
1232
|
+
|
|
1233
|
+
if (!res.ok) {
|
|
1234
|
+
updatePublishStatusUi({
|
|
1235
|
+
text: data.error || "Publish failed",
|
|
1236
|
+
percent: 0,
|
|
1237
|
+
isError: true
|
|
1238
|
+
});
|
|
1239
|
+
setPublishModalBusy(false);
|
|
1240
|
+
alert(data.error || "Publish failed");
|
|
1241
|
+
return;
|
|
929
1242
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1243
|
+
|
|
1244
|
+
updatePublishStatusUi({ text: "Published successfully!", percent: 100, isSuccess: true });
|
|
1245
|
+
await fetchDevices();
|
|
1246
|
+
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
1247
|
+
publishInProgress = false;
|
|
1248
|
+
closePublishModal();
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
updatePublishStatusUi({
|
|
1251
|
+
text: err?.message || "Publish failed",
|
|
1252
|
+
percent: 0,
|
|
1253
|
+
isError: true
|
|
1254
|
+
});
|
|
1255
|
+
setPublishModalBusy(false);
|
|
1256
|
+
alert(err?.message || "Publish failed");
|
|
936
1257
|
}
|
|
937
|
-
closePublishModal();
|
|
938
|
-
await fetchDevices();
|
|
939
1258
|
}
|
|
940
1259
|
|
|
941
1260
|
async function removePlaylistFromDevice(deviceId, playlistId) {
|
|
@@ -485,6 +485,21 @@ button.danger {
|
|
|
485
485
|
margin-bottom: 0.25rem;
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
.device-published-label {
|
|
489
|
+
display: inline-flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
gap: 0.35rem;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.playlist-playing-light {
|
|
495
|
+
width: 9px;
|
|
496
|
+
height: 9px;
|
|
497
|
+
border-radius: 50%;
|
|
498
|
+
background: #12b76a;
|
|
499
|
+
box-shadow: 0 0 7px rgba(18, 183, 106, 0.65);
|
|
500
|
+
flex-shrink: 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
488
503
|
.modal {
|
|
489
504
|
position: fixed;
|
|
490
505
|
inset: 0;
|
|
@@ -531,6 +546,48 @@ button.danger {
|
|
|
531
546
|
font-size: 0.9rem;
|
|
532
547
|
}
|
|
533
548
|
|
|
549
|
+
.publish-status {
|
|
550
|
+
margin-top: 0.85rem;
|
|
551
|
+
padding: 0.55rem 0.6rem;
|
|
552
|
+
border: 1px solid #e4e1dc;
|
|
553
|
+
border-radius: 8px;
|
|
554
|
+
background: #fafaf9;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.publish-status-text {
|
|
558
|
+
font-size: 0.82rem;
|
|
559
|
+
font-weight: 600;
|
|
560
|
+
color: #333;
|
|
561
|
+
margin-bottom: 0.35rem;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.publish-status--error {
|
|
565
|
+
border-color: #fecdca;
|
|
566
|
+
background: #fff5f5;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.publish-status--error .publish-status-text {
|
|
570
|
+
color: #b42318;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.publish-status--success {
|
|
574
|
+
border-color: #abefc6;
|
|
575
|
+
background: #f3fcf6;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.publish-status--success .publish-status-text {
|
|
579
|
+
color: #067647;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.publish-status--success .upload-progress-bar {
|
|
583
|
+
background: #12b76a;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.modal-actions button:disabled {
|
|
587
|
+
opacity: 0.55;
|
|
588
|
+
cursor: not-allowed;
|
|
589
|
+
}
|
|
590
|
+
|
|
534
591
|
.modal-actions {
|
|
535
592
|
display: flex;
|
|
536
593
|
gap: 0.5rem;
|