@tomorrowos/sdk 0.1.8 → 0.1.10

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.
@@ -11,5 +11,6 @@ export declare class MemoryStore implements TomorrowOSStore {
11
11
  deletePendingCode(code: string): Promise<void>;
12
12
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
13
13
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
14
+ deletePairedDevice(deviceId: string): Promise<void>;
14
15
  }
15
16
  //# sourceMappingURL=memory-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,WAAY,YAAW,eAAe;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAwC;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyC;IAEjE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIpE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,IAAI,CAAC;IAIV,eAAe,CACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;CAG3C"}
1
+ {"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,WAAY,YAAW,eAAe;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAwC;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyC;IAEjE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIpE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,IAAI,CAAC;IAIV,eAAe,CACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAIpC,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAG1D"}
@@ -20,4 +20,7 @@ export class MemoryStore {
20
20
  async getPairedDevice(deviceId) {
21
21
  return this.pairedDevices.get(deviceId);
22
22
  }
23
+ async deletePairedDevice(deviceId) {
24
+ this.pairedDevices.delete(deviceId);
25
+ }
23
26
  }
@@ -20,5 +20,6 @@ export interface TomorrowOSStore {
20
20
  deletePendingCode(code: string): Promise<void>;
21
21
  setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
22
22
  getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
23
+ deletePairedDevice(deviceId: string): Promise<void>;
23
24
  }
24
25
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/store/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACrE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;CAC5E"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/store/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACrE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC3E,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrD"}
@@ -40,10 +40,19 @@ export declare class TomorrowOS extends EventEmitter {
40
40
  pairingVerify(code: string): Promise<{
41
41
  deviceId: string;
42
42
  }>;
43
+ /** Remove pairing for a device and notify it over WebSocket if connected. */
44
+ pairingUnpair(deviceId: string): Promise<{
45
+ deviceId: string;
46
+ notified: boolean;
47
+ }>;
43
48
  pairing: {
44
49
  verify: (code: string) => Promise<{
45
50
  deviceId: string;
46
51
  }>;
52
+ unpair: (deviceId: string) => Promise<{
53
+ deviceId: string;
54
+ notified: boolean;
55
+ }>;
47
56
  };
48
57
  device(deviceId: string): {
49
58
  sendCommand<T = unknown>(method: string, params?: Record<string, unknown>): Promise<{
@@ -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;AAKxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGxD,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;AAwED,qBAAa,UAAW,SAAQ,YAAY;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAMtC,oEAAoE;IAC9D,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAiChE,OAAO;uBACU,MAAM;sBAlCgC,MAAM;;MAmC3D;IAEF,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;YAkD7B,iBAAiB;YAqCjB,cAAc;YAmCd,UAAU;YAqDV,gBAAgB;IAgE9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CAgFzB"}
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;AAKxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGxD,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;AAyED,qBAAa,UAAW,SAAQ,YAAY;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAMtC,oEAAoE;IAC9D,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAiChE,6EAA6E;IACvE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;IA2BvF,OAAO;uBACU,MAAM;sBA9DgC,MAAM;;2BA+DxC,MAAM;sBA7BgC,MAAM;sBAAY,OAAO;;MA8BlF;IAEF,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;YAkD7B,iBAAiB;YAqCjB,cAAc;YAmCd,UAAU;YAkEV,gBAAgB;IAgE9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CAgFzB"}
@@ -90,8 +90,31 @@ export class TomorrowOS extends EventEmitter {
90
90
  this.emit("device.paired", { deviceId: record.deviceId });
91
91
  return { deviceId: record.deviceId };
92
92
  }
93
+ /** Remove pairing for a device and notify it over WebSocket if connected. */
94
+ async pairingUnpair(deviceId) {
95
+ const id = String(deviceId || "").trim();
96
+ if (!id) {
97
+ const err = new Error("deviceId is required");
98
+ err.code = "PAIRING_INVALID";
99
+ throw err;
100
+ }
101
+ await this.store.deletePairedDevice(id);
102
+ const ws = this.devices.get(id);
103
+ let notified = false;
104
+ if (ws && ws.readyState === WebSocket.OPEN) {
105
+ ws.send(JSON.stringify({
106
+ type: "pairing.unpaired",
107
+ method: "tomorrowos.pairing.unpair",
108
+ deviceId: id
109
+ }));
110
+ notified = true;
111
+ }
112
+ this.emit("device.unpaired", { deviceId: id });
113
+ return { deviceId: id, notified };
114
+ }
93
115
  pairing = {
94
- verify: (code) => this.pairingVerify(code)
116
+ verify: (code) => this.pairingVerify(code),
117
+ unpair: (deviceId) => this.pairingUnpair(deviceId)
95
118
  };
96
119
  device(deviceId) {
97
120
  const self = this;
@@ -291,6 +314,19 @@ export class TomorrowOS extends EventEmitter {
291
314
  }
292
315
  return;
293
316
  }
317
+ if (req.method === "POST" && pathname === "/pairing/unpair") {
318
+ const body = (await readJsonBody(req));
319
+ const deviceId = typeof body.deviceId === "string" ? body.deviceId : "";
320
+ try {
321
+ const result = await this.pairingUnpair(deviceId);
322
+ sendJson(res, 200, { status: "success", ...result });
323
+ }
324
+ catch (e) {
325
+ const msg = e instanceof Error ? e.message : "Unpair failed";
326
+ sendJson(res, 400, { status: "failed", error: msg });
327
+ }
328
+ return;
329
+ }
294
330
  if (req.method === "POST") {
295
331
  const parsed = parseDevicePath(pathname);
296
332
  if (parsed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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.1.8",
3
+ "version": "0.1.10",
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.1.8"
13
+ "@tomorrowos/sdk": "^0.1.10"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^20.0.0",
@@ -19,10 +19,28 @@
19
19
  <div class="row">
20
20
  <input id="code" maxlength="6" placeholder="6-digit code" />
21
21
  <button type="button" onclick="verify()">Verify</button>
22
+ <button type="button" class="danger" onclick="unpair()">Unpair</button>
22
23
  </div>
23
24
  <input type="hidden" id="deviceId" />
24
25
  </section>
25
26
 
27
+ <section class="card">
28
+ <h2>CMS URL for screens</h2>
29
+ <p style="margin: 0 0 0.5rem; font-size: 0.8rem; color: #666">
30
+ HTTP(S) base URL your TV uses to download uploads (same host as <code>ws://</code> on the
31
+ device, not <code>localhost</code>).
32
+ </p>
33
+ <div class="row">
34
+ <input
35
+ id="cmsDeviceBaseUrl"
36
+ type="text"
37
+ style="flex: 1; min-width: 16rem"
38
+ placeholder="http://192.168.1.105:3000"
39
+ />
40
+ <button type="button" onclick="saveCmsDeviceBaseUrl()">Save</button>
41
+ </div>
42
+ </section>
43
+
26
44
  <section class="card">
27
45
  <h2>When this playlist plays</h2>
28
46
  <p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
@@ -1,6 +1,7 @@
1
1
  const PANEL_DEVICE_ID_KEY = "tomorrowos.panel.deviceId";
2
2
  const PANEL_PLAYLIST_KEY = "tomorrowos.panel.playlistDraft";
3
3
  const PANEL_SCHEDULE_KEY = "tomorrowos.panel.scheduleDraft";
4
+ const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
4
5
 
5
6
  /** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
6
7
  let playlistItems = [];
@@ -25,11 +26,65 @@ function showResult(data) {
25
26
  document.getElementById("result").textContent = JSON.stringify(data, null, 2);
26
27
  }
27
28
 
29
+ function isLocalPanelHost(hostname) {
30
+ const h = String(hostname || "").toLowerCase();
31
+ return h === "localhost" || h === "127.0.0.1" || h === "[::1]";
32
+ }
33
+
34
+ function normalizeMediaBaseUrl(raw) {
35
+ let s = String(raw || "").trim();
36
+ if (!s) return "";
37
+ if (!/^https?:\/\//i.test(s)) {
38
+ s = `http://${s}`;
39
+ }
40
+ try {
41
+ const u = new URL(s);
42
+ return u.origin;
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+
48
+ function getMediaBaseOrigin() {
49
+ const fromInput = normalizeMediaBaseUrl(
50
+ document.getElementById("cmsDeviceBaseUrl")?.value ||
51
+ localStorage.getItem(PANEL_MEDIA_BASE_KEY) ||
52
+ ""
53
+ );
54
+ if (fromInput) return fromInput;
55
+
56
+ if (!isLocalPanelHost(window.location.hostname)) {
57
+ return window.location.origin;
58
+ }
59
+
60
+ return "";
61
+ }
62
+
63
+ function saveCmsDeviceBaseUrl() {
64
+ const normalized = normalizeMediaBaseUrl(
65
+ document.getElementById("cmsDeviceBaseUrl")?.value
66
+ );
67
+ if (!normalized) {
68
+ alert("Enter a valid URL, e.g. http://192.168.1.105:3000");
69
+ return;
70
+ }
71
+ document.getElementById("cmsDeviceBaseUrl").value = normalized;
72
+ localStorage.setItem(PANEL_MEDIA_BASE_KEY, normalized);
73
+ showResult({ status: "saved", mediaBaseUrl: normalized });
74
+ }
75
+
28
76
  function absoluteMediaUrl(path) {
29
77
  const p = String(path || "").trim();
30
78
  if (!p) return "";
31
79
  if (/^https?:\/\//i.test(p)) return p;
32
- return `${window.location.origin}${p.startsWith("/") ? p : `/${p}`}`;
80
+
81
+ const base = getMediaBaseOrigin();
82
+ if (!base) {
83
+ throw new Error(
84
+ "Set CMS URL for screens (use your PC LAN IP or Replit https URL, not localhost)."
85
+ );
86
+ }
87
+ return `${base}${p.startsWith("/") ? p : `/${p}`}`;
33
88
  }
34
89
 
35
90
  function inferMediaType(filename, mime) {
@@ -49,6 +104,19 @@ function defaultDurationMs(type) {
49
104
  return 10000;
50
105
  }
51
106
 
107
+ function normalizeDurationMs(item) {
108
+ const maxMs = 3600 * 1000;
109
+ const minMs = 1000;
110
+ let ms = Number(item?.durationMs);
111
+ if (!Number.isFinite(ms) || ms < minMs) {
112
+ return defaultDurationMs(item?.type);
113
+ }
114
+ if (ms === 1000000) {
115
+ return defaultDurationMs(item?.type);
116
+ }
117
+ return Math.min(maxMs, ms);
118
+ }
119
+
52
120
  function savePlaylistDraft() {
53
121
  localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
54
122
  }
@@ -87,7 +155,11 @@ function loadPlaylistDraft() {
87
155
  if (!raw) return;
88
156
  const parsed = JSON.parse(raw);
89
157
  if (Array.isArray(parsed)) {
90
- playlistItems = parsed;
158
+ playlistItems = parsed.map((item) => ({
159
+ ...item,
160
+ durationMs: normalizeDurationMs(item)
161
+ }));
162
+ savePlaylistDraft();
91
163
  renderPlaylist();
92
164
  }
93
165
  } catch {
@@ -145,9 +217,10 @@ function renderPlaylist() {
145
217
  durInput.max = "3600";
146
218
  durInput.value = String(Math.round(item.durationMs / 1000));
147
219
  durInput.addEventListener("change", () => {
148
- item.durationMs = Math.max(1000, Number(durInput.value) || 10) * 1000;
220
+ const seconds = Math.min(3600, Math.max(1, Number(durInput.value) || 10));
221
+ item.durationMs = seconds * 1000;
222
+ meta.textContent = `${item.type} · ${seconds}s`;
149
223
  savePlaylistDraft();
150
- renderPlaylist();
151
224
  });
152
225
 
153
226
  const removeBtn = document.createElement("button");
@@ -258,6 +331,39 @@ async function verify() {
258
331
  }
259
332
  }
260
333
 
334
+ async function unpair() {
335
+ const deviceId = getPanelDeviceId();
336
+ if (!deviceId) {
337
+ alert("No paired device in this panel.");
338
+ return;
339
+ }
340
+
341
+ if (
342
+ !confirm(
343
+ "Unpair this device? The screen will show a new 6-digit code and this panel will forget the device."
344
+ )
345
+ ) {
346
+ return;
347
+ }
348
+
349
+ const res = await fetch("/pairing/unpair", {
350
+ method: "POST",
351
+ headers: { "Content-Type": "application/json" },
352
+ body: JSON.stringify({ deviceId })
353
+ });
354
+
355
+ const data = await res.json();
356
+ showResult(data);
357
+
358
+ if (res.ok) {
359
+ setPanelDeviceId("");
360
+ const codeInput = document.getElementById("code");
361
+ if (codeInput) codeInput.value = "";
362
+ } else {
363
+ alert(data.error || "Unpair failed");
364
+ }
365
+ }
366
+
261
367
  async function publish() {
262
368
  const deviceId = getPanelDeviceId();
263
369
 
@@ -271,8 +377,28 @@ async function publish() {
271
377
  return;
272
378
  }
273
379
 
380
+ const mediaBase = getMediaBaseOrigin();
381
+ if (!mediaBase) {
382
+ alert(
383
+ "Set CMS URL for screens first (e.g. http://192.168.1.105:3000 — same machine as the TV ws:// address)."
384
+ );
385
+ return;
386
+ }
387
+ if (isLocalPanelHost(new URL(mediaBase).hostname)) {
388
+ const ok = confirm(
389
+ "Media URLs use localhost. TVs cannot download from localhost unless the player runs on this PC. Continue anyway?"
390
+ );
391
+ if (!ok) return;
392
+ }
393
+
274
394
  saveScheduleDraft();
275
- const payload = buildPolicyPayload();
395
+ let payload;
396
+ try {
397
+ payload = buildPolicyPayload();
398
+ } catch (err) {
399
+ alert(err.message);
400
+ return;
401
+ }
276
402
 
277
403
  const res = await fetch(`/device/${deviceId}/content/set-policy`, {
278
404
  method: "POST",
@@ -330,6 +456,14 @@ document.addEventListener("DOMContentLoaded", () => {
330
456
  const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
331
457
  if (saved) setPanelDeviceId(saved);
332
458
 
459
+ const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
460
+ const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
461
+ if (savedMediaBase && cmsBaseInput) {
462
+ cmsBaseInput.value = savedMediaBase;
463
+ } else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
464
+ cmsBaseInput.value = window.location.origin;
465
+ }
466
+
333
467
  loadPlaylistDraft();
334
468
  loadScheduleDraft();
335
469