@tomorrowos/sdk 0.4.1 → 0.4.3

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.
@@ -52,6 +52,15 @@ export interface DeviceListItem {
52
52
  version: number;
53
53
  publishedAt: string;
54
54
  }>;
55
+ latestErrorAt: string | null;
56
+ latestErrorMessage: string | null;
57
+ }
58
+ export interface DeviceLogEntry {
59
+ timestamp: string;
60
+ level: "info" | "warn" | "error";
61
+ message: string;
62
+ source?: string;
63
+ details?: unknown;
55
64
  }
56
65
  export declare class TomorrowOS extends EventEmitter {
57
66
  readonly brand: TomorrowOSBrand;
@@ -59,6 +68,7 @@ export declare class TomorrowOS extends EventEmitter {
59
68
  readonly playlists: PlaylistCatalog;
60
69
  private readonly devices;
61
70
  private readonly pendingDeviceMeta;
71
+ private readonly deviceLogs;
62
72
  private httpServer;
63
73
  private wss;
64
74
  private staticRoot;
@@ -90,6 +100,8 @@ export declare class TomorrowOS extends EventEmitter {
90
100
  };
91
101
  /** List paired devices with live connection + timing fields for the CMS panel. */
92
102
  listDevices(): Promise<DeviceListItem[]>;
103
+ private pushDeviceLog;
104
+ private getDeviceLogs;
93
105
  private isDeviceConnected;
94
106
  private captureHelloMeta;
95
107
  private getOrCreatePermanentPairingCode;
@@ -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;AAUxB,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAIV,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAQ1B,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;AAwBD,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,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;KACrB,CAAC,CAAC;CACJ;AAyID,qBAAa,UAAW,SAAQ,YAAY;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAsC;IACxE,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAOtC,yFAAyF;IACnF,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QACxD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAC;KACtC,CAAC;YAkBY,iBAAiB;IAY/B,6EAA6E;IACvE,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAgEhE,6EAA6E;IACvE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;IA4BvF,OAAO;uBACU,MAAM;sBA9FgC,MAAM;;2BA+FxC,MAAM;sBA9BgC,MAAM;sBAAY,OAAO;;MA+BlF;IAEF,kFAAkF;IAC5E,WAAW,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAkD9C,OAAO,CAAC,iBAAiB;IAKzB,OAAO,CAAC,gBAAgB;YAeV,+BAA+B;YAiC/B,iBAAiB;YASjB,iBAAiB;YA4BjB,kBAAkB;IAUhC,uFAAuF;IACvF,OAAO,CAAC,kBAAkB;YAmBZ,gBAAgB;YAMhB,uBAAuB;IAoCrC,MAAM,CAAC,QAAQ,EAAE,MAAM;oBAGD,CAAC,oBACT,MAAM,WACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;;IAU5E,OAAO,CAAC,mBAAmB;IA6D3B,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC,MAAM;YAmD7B,iBAAiB;YAyCjB,wBAAwB;YAiBxB,cAAc;YAyCd,UAAU;YAyNV,gBAAgB;IAqG9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CAuGzB"}
1
+ {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
@@ -132,6 +132,7 @@ export class TomorrowOS extends EventEmitter {
132
132
  playlists;
133
133
  devices = new Map();
134
134
  pendingDeviceMeta = new Map();
135
+ deviceLogs = new Map();
135
136
  httpServer = null;
136
137
  wss = null;
137
138
  staticRoot = null;
@@ -267,6 +268,8 @@ export class TomorrowOS extends EventEmitter {
267
268
  }
268
269
  }
269
270
  const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
271
+ const logs = this.deviceLogs.get(deviceId) ?? [];
272
+ const latestError = [...logs].reverse().find((entry) => entry.level === "error");
270
273
  return {
271
274
  deviceId,
272
275
  pairingCode: reg?.permanentPairingCode ?? null,
@@ -288,10 +291,28 @@ export class TomorrowOS extends EventEmitter {
288
291
  lastPolicyPushAt: record.lastPolicyPushAt ?? null,
289
292
  screenOnlineActive,
290
293
  screenOnlineLabel,
291
- screenOnlineSince
294
+ screenOnlineSince,
295
+ latestErrorAt: latestError?.timestamp ?? null,
296
+ latestErrorMessage: latestError?.message ?? null
292
297
  };
293
298
  }));
294
299
  }
300
+ pushDeviceLog(deviceId, entry) {
301
+ const key = String(deviceId || "").trim();
302
+ if (!key)
303
+ return;
304
+ const existing = this.deviceLogs.get(key) ?? [];
305
+ existing.push(entry);
306
+ if (existing.length > 80)
307
+ existing.splice(0, existing.length - 80);
308
+ this.deviceLogs.set(key, existing);
309
+ }
310
+ getDeviceLogs(deviceId) {
311
+ const key = String(deviceId || "").trim();
312
+ if (!key)
313
+ return [];
314
+ return [...(this.deviceLogs.get(key) ?? [])].reverse();
315
+ }
295
316
  isDeviceConnected(deviceId) {
296
317
  const ws = this.devices.get(deviceId);
297
318
  return !!ws && ws.readyState === WebSocket.OPEN;
@@ -710,6 +731,12 @@ export class TomorrowOS extends EventEmitter {
710
731
  sendJson(res, 200, { status: "success", assignments });
711
732
  return;
712
733
  }
734
+ const deviceLogsGet = /^\/device\/([^/]+)\/logs$/.exec(pathname);
735
+ if (req.method === "GET" && deviceLogsGet) {
736
+ const deviceId = decodeURIComponent(deviceLogsGet[1]);
737
+ sendJson(res, 200, { status: "success", logs: this.getDeviceLogs(deviceId) });
738
+ return;
739
+ }
713
740
  if (req.method === "DELETE" && deviceAssignmentsGet) {
714
741
  const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
715
742
  try {
@@ -971,6 +998,26 @@ export class TomorrowOS extends EventEmitter {
971
998
  console.error("[TomorrowOS] pushLatestPolicy on resume failed:", err);
972
999
  });
973
1000
  })();
1001
+ return;
1002
+ }
1003
+ if (type === "device.log") {
1004
+ const deviceId = ws.deviceId;
1005
+ if (!deviceId)
1006
+ return;
1007
+ const rawLevel = String(msg.level ?? "info").toLowerCase();
1008
+ const level = rawLevel === "error" || rawLevel === "warn" ? rawLevel : "info";
1009
+ const message = String(msg.message ?? "").trim();
1010
+ if (!message)
1011
+ return;
1012
+ const source = typeof msg.source === "string" ? msg.source : undefined;
1013
+ const details = msg.details;
1014
+ const timestamp = typeof msg.timestamp === "string" && msg.timestamp.trim()
1015
+ ? msg.timestamp
1016
+ : new Date().toISOString();
1017
+ this.pushDeviceLog(deviceId, { timestamp, level, message, source, details });
1018
+ if (level === "error") {
1019
+ console.error(`[TomorrowOS][device-log][${deviceId}] ${message}`);
1020
+ }
974
1021
  }
975
1022
  });
976
1023
  ws.on("close", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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.4.1",
3
+ "version": "0.4.3",
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.4.1"
13
+ "@tomorrowos/sdk": "^0.4.3"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^20.0.0",
@@ -116,7 +116,15 @@
116
116
  id="fileInput"
117
117
  class="hidden"
118
118
  accept="image/*,video/*,.wgt,.zip"
119
+ multiple
119
120
  />
121
+ <div id="uploadStatusShell" class="upload-status hidden" aria-live="polite">
122
+ <div id="uploadStatusText" class="upload-status-text">Uploading...</div>
123
+ <div class="upload-progress-track">
124
+ <div id="uploadProgressBar" class="upload-progress-bar"></div>
125
+ </div>
126
+ <div id="uploadQueueText" class="upload-queue-text"></div>
127
+ </div>
120
128
  <ul id="playlistList" class="playlist-list">
121
129
  <li class="playlist-empty" id="playlistEmpty">Select or create a playlist, then add assets.</li>
122
130
  </ul>
@@ -19,6 +19,11 @@ let editorItems = [];
19
19
  let publishModalDeviceId = null;
20
20
 
21
21
  let devicePollTimer = null;
22
+ let uploadQueue = [];
23
+ let uploadInProgress = false;
24
+
25
+ const UPLOAD_MAX_RETRIES = 3;
26
+ const UPLOAD_TIMEOUT_MS = 120000;
22
27
 
23
28
  function escapeHtml(value) {
24
29
  return String(value ?? "")
@@ -71,6 +76,34 @@ function showResult(data) {
71
76
  document.getElementById("result").textContent = JSON.stringify(data, null, 2);
72
77
  }
73
78
 
79
+ function setAssetUploadBusy(isBusy) {
80
+ const addBtn = document.getElementById("addAssetBtn");
81
+ if (addBtn) {
82
+ addBtn.disabled = isBusy;
83
+ addBtn.textContent = isBusy ? "Uploading..." : "+";
84
+ }
85
+ }
86
+
87
+ function updateUploadStatusUi(status) {
88
+ const shell = document.getElementById("uploadStatusShell");
89
+ const text = document.getElementById("uploadStatusText");
90
+ const bar = document.getElementById("uploadProgressBar");
91
+ const queue = document.getElementById("uploadQueueText");
92
+ if (!shell || !text || !bar || !queue) return;
93
+
94
+ if (!status || status.hidden) {
95
+ shell.classList.add("hidden");
96
+ bar.style.width = "0%";
97
+ return;
98
+ }
99
+
100
+ shell.classList.remove("hidden");
101
+ text.textContent = status.text || "Uploading...";
102
+ queue.textContent = status.queueText || "";
103
+ const percent = Math.max(0, Math.min(100, Number(status.percent) || 0));
104
+ bar.style.width = `${percent}%`;
105
+ }
106
+
74
107
  function isLocalPanelHost(hostname) {
75
108
  const h = String(hostname || "").toLowerCase();
76
109
  return h === "localhost" || h === "127.0.0.1" || h === "[::1]";
@@ -657,7 +690,9 @@ function renderDeviceCards() {
657
690
  ["System", device.system || device.platform || "—"],
658
691
  ["Device online", formatDeviceOnlineLabel(device)],
659
692
  ["Last boot", formatDateTimeSeconds(device.lastBootAt)],
660
- ["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)]
693
+ ["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)],
694
+ ["Latest error", device.latestErrorMessage || "—"],
695
+ ["Error at", formatDateTimeSeconds(device.latestErrorAt)]
661
696
  ];
662
697
  for (const [label, value] of rows) {
663
698
  const row = document.createElement("div");
@@ -702,6 +737,11 @@ function renderDeviceCards() {
702
737
  clearBtn.textContent = "Clear";
703
738
  clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
704
739
 
740
+ const logsBtn = document.createElement("button");
741
+ logsBtn.type = "button";
742
+ logsBtn.textContent = "Logs";
743
+ logsBtn.addEventListener("click", () => viewDeviceLogs(device.deviceId));
744
+
705
745
  const unpairBtn = document.createElement("button");
706
746
  unpairBtn.type = "button";
707
747
  unpairBtn.className = "danger";
@@ -713,6 +753,7 @@ function renderDeviceCards() {
713
753
  actions.appendChild(capBtn);
714
754
  actions.appendChild(rebootBtn);
715
755
  actions.appendChild(clearBtn);
756
+ actions.appendChild(logsBtn);
716
757
  actions.appendChild(unpairBtn);
717
758
 
718
759
  card.appendChild(header);
@@ -838,26 +879,68 @@ async function removePlaylistFromDevice(deviceId, playlistId) {
838
879
  await fetchDevices();
839
880
  }
840
881
 
841
- async function uploadFile(file) {
842
- const q = new URLSearchParams({ filename: file.name });
843
- const res = await fetch(`/media/upload?${q.toString()}`, {
844
- method: "POST",
845
- headers: { "Content-Type": "application/octet-stream" },
846
- body: file
882
+ function uploadFileWithProgress(file, { onProgress, attempt }) {
883
+ return new Promise((resolve, reject) => {
884
+ const xhr = new XMLHttpRequest();
885
+ const q = new URLSearchParams({ filename: file.name });
886
+ xhr.open("POST", `/media/upload?${q.toString()}`, true);
887
+ xhr.timeout = UPLOAD_TIMEOUT_MS;
888
+ xhr.setRequestHeader("Content-Type", "application/octet-stream");
889
+
890
+ xhr.upload.onprogress = (event) => {
891
+ if (!event.lengthComputable) return;
892
+ const percent = Math.round((event.loaded / event.total) * 100);
893
+ if (typeof onProgress === "function") onProgress(percent);
894
+ };
895
+
896
+ xhr.onerror = () => reject(new Error(`Network error on attempt ${attempt}`));
897
+ xhr.ontimeout = () => reject(new Error(`Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s`));
898
+
899
+ xhr.onload = () => {
900
+ let payload = {};
901
+ try {
902
+ payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
903
+ } catch {
904
+ payload = {};
905
+ }
906
+ if (xhr.status >= 200 && xhr.status < 300 && payload.status !== "failed") {
907
+ resolve(payload);
908
+ return;
909
+ }
910
+ reject(new Error(payload.error || `Upload failed (${xhr.status})`));
911
+ };
912
+
913
+ xhr.send(file);
847
914
  });
848
- const data = await res.json();
849
- if (!res.ok || data.status === "failed") {
850
- throw new Error(data.error || `Upload failed (${res.status})`);
915
+ }
916
+
917
+ async function uploadFile(file, onProgress) {
918
+ let lastErr = null;
919
+ for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt += 1) {
920
+ try {
921
+ const payload = await uploadFileWithProgress(file, { onProgress, attempt });
922
+ return payload;
923
+ } catch (err) {
924
+ lastErr = err;
925
+ if (attempt >= UPLOAD_MAX_RETRIES) break;
926
+ await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
927
+ }
851
928
  }
852
- return data;
929
+ throw lastErr || new Error("Upload failed");
853
930
  }
854
931
 
855
- async function addAssetFromFile(file) {
932
+ async function addAssetFromFile(file, queueIndex, queueTotal) {
856
933
  if (!isPlaylistEditorOpen()) {
857
934
  newPlaylistDraft();
858
935
  }
859
936
  const type = inferMediaType(file.name, file.type);
860
- const data = await uploadFile(file);
937
+ const data = await uploadFile(file, (percent) => {
938
+ updateUploadStatusUi({
939
+ text: `Uploading ${file.name} (${percent}%)`,
940
+ percent,
941
+ queueText: `File ${queueIndex}/${queueTotal}`
942
+ });
943
+ });
861
944
  const durationMs = await resolveVideoDurationMs(file, type, data);
862
945
  editorItems.push({
863
946
  id: crypto.randomUUID(),
@@ -870,11 +953,51 @@ async function addAssetFromFile(file) {
870
953
  showResult({
871
954
  status: "uploaded",
872
955
  ...data,
956
+ fileName: file.name,
873
957
  detectedDurationMs: durationMs,
874
958
  detectedDurationSec: Math.round(durationMs / 1000)
875
959
  });
876
960
  }
877
961
 
962
+ async function processUploadQueue() {
963
+ if (uploadInProgress) return;
964
+ if (uploadQueue.length === 0) {
965
+ updateUploadStatusUi({ hidden: true });
966
+ setAssetUploadBusy(false);
967
+ return;
968
+ }
969
+
970
+ uploadInProgress = true;
971
+ setAssetUploadBusy(true);
972
+ const total = uploadQueue.length;
973
+ const failures = [];
974
+
975
+ try {
976
+ for (let i = 0; i < total; i += 1) {
977
+ const file = uploadQueue[i];
978
+ try {
979
+ await addAssetFromFile(file, i + 1, total);
980
+ } catch (err) {
981
+ failures.push({ file: file.name, error: err?.message || String(err) });
982
+ }
983
+ }
984
+ } finally {
985
+ uploadQueue = [];
986
+ uploadInProgress = false;
987
+ setAssetUploadBusy(false);
988
+ updateUploadStatusUi({ hidden: true });
989
+ }
990
+
991
+ if (failures.length > 0) {
992
+ showResult({ status: "upload_completed_with_failures", failures });
993
+ alert(
994
+ `Uploaded with ${failures.length} failure(s). Check result panel for details.`
995
+ );
996
+ } else {
997
+ showResult({ status: "upload_completed", total });
998
+ }
999
+ }
1000
+
878
1001
  async function verify() {
879
1002
  const code = String(document.getElementById("code").value || "")
880
1003
  .trim()
@@ -933,6 +1056,17 @@ async function deviceAction(deviceId, action) {
933
1056
  if (action === "reboot" || action === "content/clear") await fetchDevices();
934
1057
  }
935
1058
 
1059
+ async function viewDeviceLogs(deviceId) {
1060
+ if (!deviceId) return;
1061
+ const res = await fetch(`/device/${encodeURIComponent(deviceId)}/logs`);
1062
+ const data = await res.json();
1063
+ showResult({ deviceId, logs: data.logs || [] });
1064
+ if (!res.ok) {
1065
+ alert(data.error || "Failed to load logs");
1066
+ return;
1067
+ }
1068
+ }
1069
+
936
1070
  function startDevicePolling() {
937
1071
  if (devicePollTimer) clearInterval(devicePollTimer);
938
1072
  void fetchDevices();
@@ -963,6 +1097,7 @@ document.addEventListener("DOMContentLoaded", () => {
963
1097
  });
964
1098
 
965
1099
  document.getElementById("addAssetBtn")?.addEventListener("click", () => {
1100
+ if (uploadInProgress) return;
966
1101
  if (!isPlaylistEditorOpen()) {
967
1102
  newPlaylistDraft();
968
1103
  }
@@ -972,11 +1107,8 @@ document.addEventListener("DOMContentLoaded", () => {
972
1107
  document.getElementById("fileInput")?.addEventListener("change", async (ev) => {
973
1108
  const files = ev.target.files;
974
1109
  if (!files?.length) return;
975
- try {
976
- for (const file of files) await addAssetFromFile(file);
977
- } catch (err) {
978
- alert(err.message);
979
- }
1110
+ uploadQueue.push(...Array.from(files));
1111
+ void processUploadQueue();
980
1112
  ev.target.value = "";
981
1113
  });
982
1114
  });
@@ -306,6 +306,42 @@ button.danger {
306
306
  overflow-y: auto;
307
307
  }
308
308
 
309
+ .upload-status {
310
+ margin: 0.25rem 0.75rem 0.5rem;
311
+ padding: 0.55rem 0.6rem;
312
+ border: 1px solid #e4e1dc;
313
+ border-radius: 8px;
314
+ background: #fafaf9;
315
+ }
316
+
317
+ .upload-status-text {
318
+ font-size: 0.78rem;
319
+ font-weight: 600;
320
+ color: #333;
321
+ margin-bottom: 0.35rem;
322
+ }
323
+
324
+ .upload-progress-track {
325
+ width: 100%;
326
+ height: 8px;
327
+ border-radius: 999px;
328
+ overflow: hidden;
329
+ background: #ece8e2;
330
+ }
331
+
332
+ .upload-progress-bar {
333
+ width: 0%;
334
+ height: 100%;
335
+ background: #ff8a3d;
336
+ transition: width 120ms linear;
337
+ }
338
+
339
+ .upload-queue-text {
340
+ margin-top: 0.35rem;
341
+ font-size: 0.72rem;
342
+ color: #666;
343
+ }
344
+
309
345
  .playlist-empty {
310
346
  padding: 1.5rem 1rem;
311
347
  text-align: center;