@tomorrowos/sdk 0.6.3 → 0.6.7

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.
@@ -53,6 +53,8 @@ export interface DeviceListItem {
53
53
  version: number;
54
54
  publishedAt: string;
55
55
  }>;
56
+ currentPlaylistId: string | null;
57
+ currentPlaylistName: string | null;
56
58
  latestErrorAt: string | null;
57
59
  latestErrorMessage: string | null;
58
60
  }
@@ -78,6 +80,7 @@ export declare class TomorrowOS extends EventEmitter {
78
80
  private readonly devices;
79
81
  private readonly pendingDeviceMeta;
80
82
  private readonly deviceLogs;
83
+ private readonly livePlaybackByDevice;
81
84
  private httpServer;
82
85
  private wss;
83
86
  private staticRoot;
@@ -109,6 +112,7 @@ export declare class TomorrowOS extends EventEmitter {
109
112
  };
110
113
  /** List paired devices with live connection + timing fields for the CMS panel. */
111
114
  listDevices(): Promise<DeviceListItem[]>;
115
+ private refreshLivePlaybackState;
112
116
  setDeviceName(deviceId: string, deviceName: string): Promise<{
113
117
  deviceId: string;
114
118
  deviceName: string;
@@ -1 +1 @@
1
- {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
@@ -186,6 +186,7 @@ export class TomorrowOS extends EventEmitter {
186
186
  devices = new Map();
187
187
  pendingDeviceMeta = new Map();
188
188
  deviceLogs = new Map();
189
+ livePlaybackByDevice = new Map();
189
190
  httpServer = null;
190
191
  wss = null;
191
192
  staticRoot = null;
@@ -312,6 +313,13 @@ export class TomorrowOS extends EventEmitter {
312
313
  const connected = this.isDeviceConnected(deviceId);
313
314
  const reg = await this.store.getDeviceRegistry(deviceId);
314
315
  const assignments = await this.store.getDeviceAssignments(deviceId);
316
+ if (connected) {
317
+ await this.refreshLivePlaybackState(deviceId);
318
+ }
319
+ else {
320
+ this.livePlaybackByDevice.delete(deviceId);
321
+ }
322
+ const playback = this.livePlaybackByDevice.get(deviceId);
315
323
  let screenOnlineActive = false;
316
324
  let screenOnlineLabel = "Not active";
317
325
  if (connected && record.lastBootAt) {
@@ -333,6 +341,8 @@ export class TomorrowOS extends EventEmitter {
333
341
  version: a.publishedVersion,
334
342
  publishedAt: a.publishedAt
335
343
  })),
344
+ currentPlaylistId: playback?.playlistId ?? null,
345
+ currentPlaylistName: playback?.playlistName ?? null,
336
346
  connected,
337
347
  deviceName: record.deviceName ?? null,
338
348
  platform: record.platform ?? null,
@@ -352,6 +362,35 @@ export class TomorrowOS extends EventEmitter {
352
362
  };
353
363
  }));
354
364
  }
365
+ async refreshLivePlaybackState(deviceId) {
366
+ const ws = this.devices.get(deviceId);
367
+ if (!ws || ws.readyState !== WebSocket.OPEN)
368
+ return;
369
+ try {
370
+ const result = await this.sendCommandToSocket(ws, deviceId, "device.info.get", {}, 5_000);
371
+ if (result.status !== "success" || !result.data || typeof result.data !== "object") {
372
+ return;
373
+ }
374
+ const info = result.data;
375
+ const playback = info.currentPlayback && typeof info.currentPlayback === "object"
376
+ ? info.currentPlayback
377
+ : info;
378
+ const playlistId = typeof playback.activePlaylistId === "string" && playback.activePlaylistId.trim()
379
+ ? playback.activePlaylistId.trim()
380
+ : null;
381
+ const playlistName = typeof playback.activePlaylistName === "string" && playback.activePlaylistName.trim()
382
+ ? playback.activePlaylistName.trim()
383
+ : null;
384
+ this.livePlaybackByDevice.set(deviceId, {
385
+ playlistId,
386
+ playlistName,
387
+ updatedAt: new Date().toISOString()
388
+ });
389
+ }
390
+ catch {
391
+ // Best-effort only; keep previous value on intermittent timeouts.
392
+ }
393
+ }
355
394
  async setDeviceName(deviceId, deviceName) {
356
395
  const id = String(deviceId || "").trim();
357
396
  if (!id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.6.3",
3
+ "version": "0.6.7",
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",
3
+ "version": "0.6.7",
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.3",
13
+ "@tomorrowos/sdk": "^0.6.7",
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 only if items are still relative (/uploads/...).
142
- * - Local: use saved LAN override only when set; never default to rewriting otherwise.
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
- return "";
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). */
@@ -664,6 +839,8 @@ function renderDeviceCards() {
664
839
 
665
840
  const header = document.createElement("div");
666
841
  header.className = "device-card-header";
842
+ const headerMain = document.createElement("div");
843
+ headerMain.className = "device-card-header-main";
667
844
  const led = document.createElement("span");
668
845
  led.className = `status-led ${device.connected ? "status-led--online" : "status-led--offline"}`;
669
846
  const title = document.createElement("h3");
@@ -672,9 +849,9 @@ function renderDeviceCards() {
672
849
  if (isEditingName) {
673
850
  const input = document.createElement("input");
674
851
  input.type = "text";
852
+ input.className = "device-card-title-input";
675
853
  input.value = editingDeviceNameValue || device.deviceName || "Screen";
676
854
  input.placeholder = "Device name";
677
- input.style.minWidth = "12rem";
678
855
  input.addEventListener("input", (ev) => {
679
856
  editingDeviceNameValue = String(ev.target.value || "");
680
857
  });
@@ -691,12 +868,42 @@ function renderDeviceCards() {
691
868
  } else {
692
869
  title.textContent = device.deviceName || "Screen";
693
870
  }
694
- header.appendChild(led);
695
- header.appendChild(title);
871
+ headerMain.appendChild(led);
872
+ headerMain.appendChild(title);
873
+
874
+ const headerActions = document.createElement("div");
875
+ headerActions.className = "device-card-header-actions";
876
+ if (isEditingName) {
877
+ const saveBtn = document.createElement("button");
878
+ saveBtn.type = "button";
879
+ saveBtn.className = "primary";
880
+ saveBtn.textContent = "Save";
881
+ saveBtn.addEventListener("click", () =>
882
+ void submitInlineDeviceRename(device.deviceId)
883
+ );
884
+ const cancelBtn = document.createElement("button");
885
+ cancelBtn.type = "button";
886
+ cancelBtn.textContent = "Cancel";
887
+ cancelBtn.addEventListener("click", cancelInlineDeviceRename);
888
+ headerActions.appendChild(saveBtn);
889
+ headerActions.appendChild(cancelBtn);
890
+ } else {
891
+ const renameBtn = document.createElement("button");
892
+ renameBtn.type = "button";
893
+ renameBtn.textContent = "Rename";
894
+ renameBtn.addEventListener("click", () =>
895
+ startInlineDeviceRename(device.deviceId, device.deviceName)
896
+ );
897
+ headerActions.appendChild(renameBtn);
898
+ }
899
+ header.appendChild(headerMain);
900
+ header.appendChild(headerActions);
696
901
 
697
902
  const published = document.createElement("ul");
698
903
  published.className = "device-published-list";
699
904
  const pubs = Array.isArray(device.publishedPlaylists) ? device.publishedPlaylists : [];
905
+ const activePlaylistId = typeof device.currentPlaylistId === "string" ? device.currentPlaylistId : "";
906
+ const activePlaylistName = typeof device.currentPlaylistName === "string" ? device.currentPlaylistName : "";
700
907
  if (pubs.length === 0) {
701
908
  const li = document.createElement("li");
702
909
  li.textContent = "No playlists published";
@@ -705,7 +912,17 @@ function renderDeviceCards() {
705
912
  for (const p of pubs) {
706
913
  const li = document.createElement("li");
707
914
  const label = document.createElement("span");
915
+ label.className = "device-published-label";
708
916
  label.textContent = p.name;
917
+ const isPlaying =
918
+ (activePlaylistId && p.playlistId === activePlaylistId) ||
919
+ (!activePlaylistId && activePlaylistName && p.name === activePlaylistName);
920
+ if (isPlaying) {
921
+ const playingLight = document.createElement("span");
922
+ playingLight.className = "playlist-playing-light";
923
+ playingLight.title = "Currently playing";
924
+ label.appendChild(playingLight);
925
+ }
709
926
  const rm = document.createElement("button");
710
927
  rm.type = "button";
711
928
  rm.textContent = "Remove";
@@ -786,26 +1003,6 @@ function renderDeviceCards() {
786
1003
  latestScreenshotBtn.textContent = "Last screen";
787
1004
  latestScreenshotBtn.addEventListener("click", () => viewLatestScreenshot(device.deviceId));
788
1005
 
789
- const renameBtn = document.createElement("button");
790
- renameBtn.type = "button";
791
- if (isEditingName) {
792
- renameBtn.className = "primary";
793
- renameBtn.textContent = "Save";
794
- renameBtn.addEventListener("click", () =>
795
- void submitInlineDeviceRename(device.deviceId)
796
- );
797
- } else {
798
- renameBtn.textContent = "Rename";
799
- renameBtn.addEventListener("click", () =>
800
- startInlineDeviceRename(device.deviceId, device.deviceName)
801
- );
802
- }
803
-
804
- const cancelRenameBtn = document.createElement("button");
805
- cancelRenameBtn.type = "button";
806
- cancelRenameBtn.textContent = "Cancel";
807
- cancelRenameBtn.addEventListener("click", cancelInlineDeviceRename);
808
-
809
1006
  const unpairBtn = document.createElement("button");
810
1007
  unpairBtn.type = "button";
811
1008
  unpairBtn.className = "danger";
@@ -820,8 +1017,6 @@ function renderDeviceCards() {
820
1017
  actions.appendChild(logsBtn);
821
1018
  actions.appendChild(screenshotBtn);
822
1019
  actions.appendChild(latestScreenshotBtn);
823
- actions.appendChild(renameBtn);
824
- if (isEditingName) actions.appendChild(cancelRenameBtn);
825
1020
  actions.appendChild(unpairBtn);
826
1021
 
827
1022
  card.appendChild(header);
@@ -868,15 +1063,24 @@ function openPublishModal(deviceId) {
868
1063
  }
869
1064
 
870
1065
  modal.classList.remove("hidden");
1066
+ resetPublishModalStatus();
871
1067
  }
872
1068
 
873
1069
  function closePublishModal() {
1070
+ if (publishInProgress) return;
874
1071
  publishModalDeviceId = null;
1072
+ resetPublishModalStatus();
875
1073
  document.getElementById("publishModal")?.classList.add("hidden");
876
1074
  }
877
1075
 
878
1076
  async function confirmPublishModal() {
879
- if (!publishModalDeviceId) return;
1077
+ if (!publishModalDeviceId || publishInProgress) return;
1078
+
1079
+ if (uploadInProgress || uploadQueue.length > 0) {
1080
+ alert("Wait for asset uploads to finish before publishing.");
1081
+ return;
1082
+ }
1083
+
880
1084
  const ids = [
881
1085
  ...document.querySelectorAll("#publishChecklist input[type=checkbox]:checked")
882
1086
  ].map((el) => el.value);
@@ -886,48 +1090,121 @@ async function confirmPublishModal() {
886
1090
  return;
887
1091
  }
888
1092
 
889
- const selectedPlaylists = ids
890
- .map((id) => playlistsCatalog.find((p) => p.id === id))
891
- .filter(Boolean);
1093
+ const deviceId = publishModalDeviceId;
1094
+ setPublishModalBusy(true);
1095
+ updatePublishStatusUi({ hidden: false, text: "Refreshing playlists...", percent: 8 });
892
1096
 
893
- for (const pl of selectedPlaylists) {
894
- if (!(pl.items || []).length) {
895
- alert(`Playlist "${pl.name || pl.id}" has no assets. Save the playlist first.`);
1097
+ try {
1098
+ const refreshed = await refreshPlaylistsForPublish();
1099
+ if (!refreshed) {
1100
+ updatePublishStatusUi({
1101
+ text: "Could not load playlists from the server.",
1102
+ percent: 0,
1103
+ isError: true
1104
+ });
1105
+ setPublishModalBusy(false);
1106
+ alert("Could not load playlists from the server. Try again.");
896
1107
  return;
897
1108
  }
898
- }
899
1109
 
900
- const mediaBaseUrl = getPublishMediaBaseUrl(selectedPlaylists);
901
- if (mediaBaseUrl === "") {
902
- alert(
903
- "Local CMS: save a LAN URL under “CMS URL for screens” (e.g. http://192.168.1.105:3000) so TVs can load /uploads paths. On Replit/hosted CMS this field is not required."
1110
+ const selectedPlaylists = ids
1111
+ .map((id) => playlistsCatalog.find((p) => p.id === id))
1112
+ .filter(Boolean);
1113
+
1114
+ if (selectedPlaylists.length !== ids.length) {
1115
+ updatePublishStatusUi({
1116
+ text: "A selected playlist is no longer available.",
1117
+ percent: 0,
1118
+ isError: true
1119
+ });
1120
+ setPublishModalBusy(false);
1121
+ alert("A selected playlist was removed or changed. Close this dialog and open Publish again.");
1122
+ return;
1123
+ }
1124
+
1125
+ for (const pl of selectedPlaylists) {
1126
+ if (!(pl.items || []).length) {
1127
+ updatePublishStatusUi({
1128
+ text: `Playlist "${pl.name || pl.id}" has no assets.`,
1129
+ percent: 0,
1130
+ isError: true
1131
+ });
1132
+ setPublishModalBusy(false);
1133
+ alert(`Playlist "${pl.name || pl.id}" has no assets. Save the playlist first.`);
1134
+ return;
1135
+ }
1136
+ }
1137
+
1138
+ const mediaBaseUrl = getPublishMediaBaseUrl(selectedPlaylists);
1139
+
1140
+ const verification = await verifyPlaylistsAssetsReady(
1141
+ selectedPlaylists,
1142
+ mediaBaseUrl,
1143
+ (done, total, assetName) => {
1144
+ const pct = 12 + Math.round((done / Math.max(total, 1)) * 68);
1145
+ updatePublishStatusUi({
1146
+ text: `Verifying assets (${done}/${total}): ${assetName}`,
1147
+ percent: pct
1148
+ });
1149
+ }
904
1150
  );
905
- return;
906
- }
907
1151
 
908
- const device = devicesCache.find((d) => d.deviceId === publishModalDeviceId);
909
- const alreadyOnDevice = (device?.publishedPlaylists || []).map((p) => p.playlistId);
910
- const playlistIds = [...new Set([...alreadyOnDevice, ...ids])];
1152
+ if (!verification.ok) {
1153
+ const lines = verification.failures
1154
+ .map((f) => `• ${f.playlist} ${f.name}: ${f.reason}`)
1155
+ .join("\n");
1156
+ updatePublishStatusUi({
1157
+ text: `Could not load ${verification.failures.length} asset(s). Publishing blocked.`,
1158
+ percent: 0,
1159
+ isError: true
1160
+ });
1161
+ setPublishModalBusy(false);
1162
+ alert(`Cannot publish until all playlist assets are reachable:\n\n${lines}`);
1163
+ return;
1164
+ }
911
1165
 
912
- const publishBody = { playlistIds };
913
- if (mediaBaseUrl) publishBody.mediaBaseUrl = mediaBaseUrl;
1166
+ updatePublishStatusUi({ text: "Publishing to device...", percent: 88 });
914
1167
 
915
- const res = await fetch(
916
- `/device/${encodeURIComponent(publishModalDeviceId)}/assignments`,
917
- {
1168
+ const device = devicesCache.find((d) => d.deviceId === deviceId);
1169
+ const alreadyOnDevice = (device?.publishedPlaylists || []).map((p) => p.playlistId);
1170
+ const playlistIds = [...new Set([...alreadyOnDevice, ...ids])];
1171
+
1172
+ const publishBody = { playlistIds };
1173
+ if (mediaBaseUrl) publishBody.mediaBaseUrl = mediaBaseUrl;
1174
+
1175
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/assignments`, {
918
1176
  method: "POST",
919
1177
  headers: { "Content-Type": "application/json" },
920
1178
  body: JSON.stringify(publishBody)
1179
+ });
1180
+ const data = await res.json();
1181
+ showResult({ deviceId, publish: data });
1182
+
1183
+ if (!res.ok) {
1184
+ updatePublishStatusUi({
1185
+ text: data.error || "Publish failed",
1186
+ percent: 0,
1187
+ isError: true
1188
+ });
1189
+ setPublishModalBusy(false);
1190
+ alert(data.error || "Publish failed");
1191
+ return;
921
1192
  }
922
- );
923
- const data = await res.json();
924
- showResult({ deviceId: publishModalDeviceId, publish: data });
925
- if (!res.ok) {
926
- alert(data.error || "Publish failed");
927
- return;
1193
+
1194
+ updatePublishStatusUi({ text: "Published successfully!", percent: 100, isSuccess: true });
1195
+ await fetchDevices();
1196
+ await new Promise((resolve) => setTimeout(resolve, 900));
1197
+ publishInProgress = false;
1198
+ closePublishModal();
1199
+ } catch (err) {
1200
+ updatePublishStatusUi({
1201
+ text: err?.message || "Publish failed",
1202
+ percent: 0,
1203
+ isError: true
1204
+ });
1205
+ setPublishModalBusy(false);
1206
+ alert(err?.message || "Publish failed");
928
1207
  }
929
- closePublishModal();
930
- await fetchDevices();
931
1208
  }
932
1209
 
933
1210
  async function removePlaylistFromDevice(deviceId, playlistId) {
@@ -141,15 +141,45 @@ body {
141
141
  .device-card-header {
142
142
  display: flex;
143
143
  align-items: center;
144
- gap: 0.5rem;
144
+ justify-content: space-between;
145
+ gap: 0.35rem;
145
146
  margin-bottom: 0.65rem;
146
147
  }
147
148
 
149
+ .device-card-header-main {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 0.5rem;
153
+ min-width: 0;
154
+ flex: 1;
155
+ }
156
+
157
+ .device-card-header-actions {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 0.35rem;
161
+ flex-shrink: 0;
162
+ }
163
+
164
+ .device-card-header-actions button {
165
+ font-size: 0.78rem;
166
+ padding: 0.35rem 0.6rem;
167
+ }
168
+
148
169
  .device-card-title {
149
170
  margin: 0;
150
171
  font-size: 0.95rem;
151
172
  font-weight: 600;
152
173
  line-height: 1.2;
174
+ min-width: 0;
175
+ flex: 1;
176
+ }
177
+
178
+ .device-card-title-input {
179
+ width: 100%;
180
+ min-width: 0;
181
+ font-size: 0.95rem;
182
+ font-weight: 600;
153
183
  }
154
184
 
155
185
  .status-led {
@@ -455,6 +485,21 @@ button.danger {
455
485
  margin-bottom: 0.25rem;
456
486
  }
457
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
+
458
503
  .modal {
459
504
  position: fixed;
460
505
  inset: 0;
@@ -501,6 +546,48 @@ button.danger {
501
546
  font-size: 0.9rem;
502
547
  }
503
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
+
504
591
  .modal-actions {
505
592
  display: flex;
506
593
  gap: 0.5rem;