@tomorrowos/sdk 0.4.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.4.2",
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.2",
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.2"
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]";
@@ -846,26 +879,68 @@ async function removePlaylistFromDevice(deviceId, playlistId) {
846
879
  await fetchDevices();
847
880
  }
848
881
 
849
- async function uploadFile(file) {
850
- const q = new URLSearchParams({ filename: file.name });
851
- const res = await fetch(`/media/upload?${q.toString()}`, {
852
- method: "POST",
853
- headers: { "Content-Type": "application/octet-stream" },
854
- 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);
855
914
  });
856
- const data = await res.json();
857
- if (!res.ok || data.status === "failed") {
858
- 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
+ }
859
928
  }
860
- return data;
929
+ throw lastErr || new Error("Upload failed");
861
930
  }
862
931
 
863
- async function addAssetFromFile(file) {
932
+ async function addAssetFromFile(file, queueIndex, queueTotal) {
864
933
  if (!isPlaylistEditorOpen()) {
865
934
  newPlaylistDraft();
866
935
  }
867
936
  const type = inferMediaType(file.name, file.type);
868
- 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
+ });
869
944
  const durationMs = await resolveVideoDurationMs(file, type, data);
870
945
  editorItems.push({
871
946
  id: crypto.randomUUID(),
@@ -878,11 +953,51 @@ async function addAssetFromFile(file) {
878
953
  showResult({
879
954
  status: "uploaded",
880
955
  ...data,
956
+ fileName: file.name,
881
957
  detectedDurationMs: durationMs,
882
958
  detectedDurationSec: Math.round(durationMs / 1000)
883
959
  });
884
960
  }
885
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
+
886
1001
  async function verify() {
887
1002
  const code = String(document.getElementById("code").value || "")
888
1003
  .trim()
@@ -982,6 +1097,7 @@ document.addEventListener("DOMContentLoaded", () => {
982
1097
  });
983
1098
 
984
1099
  document.getElementById("addAssetBtn")?.addEventListener("click", () => {
1100
+ if (uploadInProgress) return;
985
1101
  if (!isPlaylistEditorOpen()) {
986
1102
  newPlaylistDraft();
987
1103
  }
@@ -991,11 +1107,8 @@ document.addEventListener("DOMContentLoaded", () => {
991
1107
  document.getElementById("fileInput")?.addEventListener("change", async (ev) => {
992
1108
  const files = ev.target.files;
993
1109
  if (!files?.length) return;
994
- try {
995
- for (const file of files) await addAssetFromFile(file);
996
- } catch (err) {
997
- alert(err.message);
998
- }
1110
+ uploadQueue.push(...Array.from(files));
1111
+ void processUploadQueue();
999
1112
  ev.target.value = "";
1000
1113
  });
1001
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;