@tomorrowos/sdk 0.1.6 → 0.1.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.
@@ -139,20 +139,22 @@ When the user enters a URL to deploy, the SDK call must wrap it as a content pol
139
139
  ```typescript
140
140
  await device.sendCommand('device.content.setPolicy', {
141
141
  policy: {
142
- revision: Date.now(), // monotonic
143
142
  playlists: [{
144
- playlistId: crypto.randomUUID(),
145
- priority: 10,
143
+ id: 'default',
144
+ schedule: {
145
+ startDate: '2026-05-01', // YYYY-MM-DD, optional
146
+ endDate: '2026-05-31',
147
+ daysOfWeek: [1, 2, 3, 4, 5], // 0=Sun … 6=Sat, optional
148
+ start: '09:00', // HH:MM daily window, optional
149
+ end: '17:00',
150
+ },
146
151
  items: [{
147
- contentId: crypto.randomUUID(),
148
152
  url: userProvidedUrl,
149
- durationSec: 30,
150
- transition: 'cut',
153
+ type: 'image',
154
+ durationMs: 30000,
151
155
  }],
152
- loop: true,
153
- schedule: { startDate: null, endDate: null },
154
156
  }],
155
- priorityResolution: 'highest_wins',
157
+ fallback: { type: 'brand' },
156
158
  },
157
159
  });
158
160
  ```
package/README.md CHANGED
@@ -63,6 +63,16 @@ TomorrowOS has no required cloud service. Your CMS talks to your screens over yo
63
63
 
64
64
  **Player branding:** `GET /brand.json` returns the `brand` object passed to `new TomorrowOS({ brand })` (same host/port as `listen`). TomorrowOS players can fetch it to apply `backgroundColor`, logos, etc., without a separate static file server.
65
65
 
66
+ **Content policy (`device.content.setPolicy`):** POST `/device/{deviceId}/content/set-policy` with `{ "policy": { "playlists": [...], "fallback": { "type": "brand" } } }`. Each playlist may include optional `schedule` (device local time):
67
+
68
+ | Field | Format | Notes |
69
+ |-------|--------|--------|
70
+ | `startDate` / `endDate` | `YYYY-MM-DD` | Inclusive calendar range; omit either for open-ended |
71
+ | `daysOfWeek` | `[0–6]` | `0` = Sunday … `6` = Saturday |
72
+ | `start` / `end` | `HH:MM` | Daily window; supports overnight (e.g. `22:00`–`06:00`) |
73
+
74
+ All provided constraints must match for the playlist to play. See `templates/cms-starter/policy.example.json`.
75
+
66
76
  ---
67
77
 
68
78
  ## Supported platforms (roadmap)
@@ -87,6 +97,7 @@ See `PLAYER_INSTALL.md` for installation notes.
87
97
  | `brand.schema.json` | JSON Schema for `brand.json` |
88
98
  | `brand.example.json` | Full example `brand.json` |
89
99
  | `templates/cms-starter/` | Minimal Node + TypeScript server seed |
100
+ | `templates/cms-starter/policy.example.json` | Example `setPolicy` payload with date schedule |
90
101
  | `templates/style-tokens/` | CSS tokens and UI pattern notes |
91
102
 
92
103
  ---
@@ -55,6 +55,7 @@ export declare class TomorrowOS extends EventEmitter {
55
55
  };
56
56
  private sendCommandToSocket;
57
57
  listen(options: ListenOptions): http.Server;
58
+ private handleMediaUpload;
58
59
  private tryServeStatic;
59
60
  private handleHttp;
60
61
  private handleDeviceHttp;
@@ -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;AAmDD,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;YAgD7B,cAAc;YAmCd,UAAU;YAgDV,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;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"}
@@ -8,12 +8,10 @@ import { MemoryStore } from "./store/memory-store.js";
8
8
  function createSixDigitCode() {
9
9
  return Math.floor(100000 + Math.random() * 900000).toString();
10
10
  }
11
+ const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
11
12
  async function readJsonBody(req) {
12
- const chunks = [];
13
- for await (const chunk of req) {
14
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
15
- }
16
- const raw = Buffer.concat(chunks).toString("utf8");
13
+ const buf = await readRawBody(req, MAX_MEDIA_UPLOAD_BYTES);
14
+ const raw = buf.toString("utf8");
17
15
  if (!raw.trim())
18
16
  return {};
19
17
  try {
@@ -23,6 +21,23 @@ async function readJsonBody(req) {
23
21
  throw new Error("Invalid JSON body");
24
22
  }
25
23
  }
24
+ async function readRawBody(req, maxBytes) {
25
+ const chunks = [];
26
+ let total = 0;
27
+ for await (const chunk of req) {
28
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
29
+ total += buf.length;
30
+ if (total > maxBytes) {
31
+ throw new Error(`Body too large (max ${maxBytes} bytes)`);
32
+ }
33
+ chunks.push(buf);
34
+ }
35
+ return Buffer.concat(chunks);
36
+ }
37
+ function sanitizeUploadFilename(name) {
38
+ const base = path.basename(String(name || "upload")).replace(/[^\w.\-()+ ]/g, "_");
39
+ return base.slice(0, 180) || "upload";
40
+ }
26
41
  function sendJson(res, status, body) {
27
42
  res.writeHead(status, { "Content-Type": "application/json" });
28
43
  res.end(JSON.stringify(body));
@@ -148,6 +163,8 @@ export class TomorrowOS extends EventEmitter {
148
163
  throw new Error("staticIndex must not contain '..'");
149
164
  }
150
165
  this.staticIndexFile = idx;
166
+ const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
167
+ void fs.mkdir(uploadsDir, { recursive: true });
151
168
  }
152
169
  else {
153
170
  this.staticIndexFile = "index.html";
@@ -182,6 +199,36 @@ export class TomorrowOS extends EventEmitter {
182
199
  this.wss = wss;
183
200
  return server;
184
201
  }
202
+ async handleMediaUpload(req, res, url) {
203
+ if (!this.staticRoot) {
204
+ sendJson(res, 400, { status: "failed", error: "staticRoot is not configured" });
205
+ return;
206
+ }
207
+ try {
208
+ const body = await readRawBody(req, MAX_MEDIA_UPLOAD_BYTES);
209
+ if (body.length === 0) {
210
+ sendJson(res, 400, { status: "failed", error: "Empty upload body" });
211
+ return;
212
+ }
213
+ const rawName = url.searchParams.get("filename") || "upload";
214
+ const safeName = sanitizeUploadFilename(rawName);
215
+ const storedName = `${randomUUID()}-${safeName}`;
216
+ const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
217
+ await fs.mkdir(uploadsDir, { recursive: true });
218
+ const filePath = path.join(uploadsDir, storedName);
219
+ await fs.writeFile(filePath, body);
220
+ sendJson(res, 200, {
221
+ status: "success",
222
+ url: `/uploads/${storedName}`,
223
+ filename: storedName,
224
+ size: body.length
225
+ });
226
+ }
227
+ catch (e) {
228
+ const msg = e instanceof Error ? e.message : "Upload failed";
229
+ sendJson(res, 400, { status: "failed", error: msg });
230
+ }
231
+ }
185
232
  async tryServeStatic(pathname, res) {
186
233
  if (!this.staticRoot)
187
234
  return false;
@@ -222,6 +269,10 @@ export class TomorrowOS extends EventEmitter {
222
269
  res.end(JSON.stringify(this.brand));
223
270
  return;
224
271
  }
272
+ if (req.method === "POST" && pathname === "/media/upload") {
273
+ await this.handleMediaUpload(req, res, url);
274
+ return;
275
+ }
225
276
  if (req.method === "GET" && this.staticRoot) {
226
277
  const served = await this.tryServeStatic(pathname, res);
227
278
  if (served)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.1.6",
3
+ "version": "0.1.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.1.6",
3
+ "version": "0.1.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.1.6"
13
+ "@tomorrowos/sdk": "^0.1.9"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^20.0.0",
@@ -0,0 +1,30 @@
1
+ {
2
+ "policy": {
3
+ "playlists": [
4
+ {
5
+ "id": "weekday-promo",
6
+ "name": "Weekday promo",
7
+ "schedule": {
8
+ "startDate": "2026-05-01",
9
+ "endDate": "2026-05-31",
10
+ "daysOfWeek": [1, 2, 3, 4, 5],
11
+ "start": "09:00",
12
+ "end": "17:00"
13
+ },
14
+ "items": [
15
+ {
16
+ "url": "https://example.com/hero.jpg",
17
+ "type": "image",
18
+ "durationMs": 10000
19
+ },
20
+ {
21
+ "url": "https://example.com/spot.mp4",
22
+ "type": "video",
23
+ "durationMs": 30000
24
+ }
25
+ ]
26
+ }
27
+ ],
28
+ "fallback": { "type": "brand" }
29
+ }
30
+ }
@@ -1,57 +1,115 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <title>TomorrowOS SDK Smoke</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>TomorrowOS Control Panel</title>
7
+ <link rel="stylesheet" href="./panel.css" />
6
8
  </head>
7
- <body style="font-family: sans-serif; padding: 40px;">
8
- <h1>TomorrowOS SDK Smoke Panel</h1>
9
- <p>Same-origin API as this server — open this page after <code>npm start</code>.</p>
10
-
11
- <h2>Pair Device</h2>
12
- <input id="code" maxlength="6" placeholder="Enter 6 digit code" />
13
- <button onclick="verify()">Verify</button>
14
- <input type="hidden" id="deviceId" />
15
-
16
- <h2>Get Device Info</h2>
17
- <button onclick="getInfo()">Get Info</button>
18
-
19
- <h2>Get Capabilities</h2>
20
- <button onclick="getCapabilities()">Get Capabilities</button>
21
-
22
- <h2>Reboot Device</h2>
23
- <button onclick="reboot()">Reboot</button>
24
-
25
- <h2>Set Content</h2>
26
- <button onclick="setContent()">Set</button>
27
-
28
- <h3>Set Policy JSON (required)</h3>
29
- <textarea
30
- id="policyJson"
31
- rows="16"
32
- cols="100"
33
- placeholder='{
34
- "policy": {
35
- "playlists": [
36
- {
37
- "id": "default",
38
- "items": [
39
- { "url": "https://example.com/a.jpg", "type": "image", "durationMs": 8000 },
40
- { "url": "https://example.com/b.mp4", "type": "video", "durationMs": 30000 }
41
- ]
42
- }
43
- ],
44
- "fallback": { "type": "brand" }
45
- }
46
- }'></textarea>
47
-
48
- <h2>Clear Content</h2>
49
- <button onclick="clearContent()">Clear</button>
50
-
51
- <br />
52
- <br />
53
-
54
- <pre id="result"></pre>
9
+ <body>
10
+ <header class="app-header">
11
+ <h1>TomorrowOS Control Panel</h1>
12
+ <p>Build a playlist, set schedule, then publish to paired screens.</p>
13
+ </header>
14
+
15
+ <div class="layout">
16
+ <main class="panel-main">
17
+ <section class="card">
18
+ <h2>Pair device</h2>
19
+ <div class="row">
20
+ <input id="code" maxlength="6" placeholder="6-digit code" />
21
+ <button type="button" onclick="verify()">Verify</button>
22
+ </div>
23
+ <input type="hidden" id="deviceId" />
24
+ </section>
25
+
26
+ <section class="card">
27
+ <h2>CMS URL for screens</h2>
28
+ <p style="margin: 0 0 0.5rem; font-size: 0.8rem; color: #666">
29
+ HTTP(S) base URL your TV uses to download uploads (same host as <code>ws://</code> on the
30
+ device, not <code>localhost</code>).
31
+ </p>
32
+ <div class="row">
33
+ <input
34
+ id="cmsDeviceBaseUrl"
35
+ type="text"
36
+ style="flex: 1; min-width: 16rem"
37
+ placeholder="http://192.168.1.105:3000"
38
+ />
39
+ <button type="button" onclick="saveCmsDeviceBaseUrl()">Save</button>
40
+ </div>
41
+ </section>
42
+
43
+ <section class="card">
44
+ <h2>When this playlist plays</h2>
45
+ <p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
46
+ Leave blank for always on (device local time). All set fields must match.
47
+ </p>
48
+ <div class="schedule-grid">
49
+ <label>
50
+ Start date
51
+ <input type="date" id="scheduleStartDate" />
52
+ </label>
53
+ <label>
54
+ End date
55
+ <input type="date" id="scheduleEndDate" />
56
+ </label>
57
+ <label>
58
+ From
59
+ <input type="time" id="scheduleStartTime" />
60
+ </label>
61
+ <label>
62
+ Until
63
+ <input type="time" id="scheduleEndTime" />
64
+ </label>
65
+ <div class="days-row">
66
+ <span style="width: 100%; color: #444">Days</span>
67
+ <label><input type="checkbox" class="day-checkbox" value="0" /> Sun</label>
68
+ <label><input type="checkbox" class="day-checkbox" value="1" /> Mon</label>
69
+ <label><input type="checkbox" class="day-checkbox" value="2" /> Tue</label>
70
+ <label><input type="checkbox" class="day-checkbox" value="3" /> Wed</label>
71
+ <label><input type="checkbox" class="day-checkbox" value="4" /> Thu</label>
72
+ <label><input type="checkbox" class="day-checkbox" value="5" /> Fri</label>
73
+ <label><input type="checkbox" class="day-checkbox" value="6" /> Sat</label>
74
+ </div>
75
+ </div>
76
+ </section>
77
+
78
+ <section class="card">
79
+ <h2>Device actions</h2>
80
+ <div class="row">
81
+ <button type="button" onclick="getInfo()">Get info</button>
82
+ <button type="button" onclick="getCapabilities()">Capabilities</button>
83
+ <button type="button" onclick="reboot()">Reboot</button>
84
+ <button type="button" class="danger" onclick="clearContent()">Clear content</button>
85
+ </div>
86
+ </section>
87
+
88
+ <section class="publish-bar">
89
+ <button type="button" class="primary" style="width: 100%; padding: 0.75rem" onclick="publish()">
90
+ Publish playlist
91
+ </button>
92
+ </section>
93
+
94
+ <pre id="result" aria-live="polite"></pre>
95
+ </main>
96
+
97
+ <aside class="panel-playlist">
98
+ <div class="playlist-header">
99
+ <h2>Playlist</h2>
100
+ <button type="button" class="primary" id="addAssetBtn" title="Add image or video">+</button>
101
+ </div>
102
+ <input
103
+ type="file"
104
+ id="fileInput"
105
+ class="hidden"
106
+ accept="image/*,video/*,.wgt,.zip"
107
+ />
108
+ <ul id="playlistList" class="playlist-list">
109
+ <li class="playlist-empty" id="playlistEmpty">No assets yet. Tap + to upload.</li>
110
+ </ul>
111
+ </aside>
112
+ </div>
55
113
 
56
114
  <script src="./methods.js" defer></script>
57
115
  </body>
@@ -1,4 +1,10 @@
1
1
  const PANEL_DEVICE_ID_KEY = "tomorrowos.panel.deviceId";
2
+ const PANEL_PLAYLIST_KEY = "tomorrowos.panel.playlistDraft";
3
+ const PANEL_SCHEDULE_KEY = "tomorrowos.panel.scheduleDraft";
4
+ const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
5
+
6
+ /** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
7
+ let playlistItems = [];
2
8
 
3
9
  function getPanelDeviceId() {
4
10
  return (
@@ -16,107 +22,330 @@ function setPanelDeviceId(deviceId) {
16
22
  else localStorage.removeItem(PANEL_DEVICE_ID_KEY);
17
23
  }
18
24
 
19
- document.addEventListener("DOMContentLoaded", () => {
20
- const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
21
- if (saved) setPanelDeviceId(saved);
22
- });
25
+ function showResult(data) {
26
+ document.getElementById("result").textContent = JSON.stringify(data, null, 2);
27
+ }
23
28
 
24
- async function verify() {
25
- const code = document.getElementById("code").value;
29
+ function isLocalPanelHost(hostname) {
30
+ const h = String(hostname || "").toLowerCase();
31
+ return h === "localhost" || h === "127.0.0.1" || h === "[::1]";
32
+ }
26
33
 
27
- const res = await fetch("/pairing/verify", {
28
- method: "POST",
29
- headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify({ code })
31
- });
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
+ }
32
47
 
33
- const data = await res.json();
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;
34
55
 
35
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
56
+ if (!isLocalPanelHost(window.location.hostname)) {
57
+ return window.location.origin;
58
+ }
36
59
 
37
- if (data.deviceId) {
38
- setPanelDeviceId(data.deviceId);
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;
39
70
  }
71
+ document.getElementById("cmsDeviceBaseUrl").value = normalized;
72
+ localStorage.setItem(PANEL_MEDIA_BASE_KEY, normalized);
73
+ showResult({ status: "saved", mediaBaseUrl: normalized });
40
74
  }
41
75
 
42
- async function getInfo() {
43
- const deviceId = getPanelDeviceId();
76
+ function absoluteMediaUrl(path) {
77
+ const p = String(path || "").trim();
78
+ if (!p) return "";
79
+ if (/^https?:\/\//i.test(p)) return p;
44
80
 
45
- if (!deviceId) {
46
- alert("Please pair a device first.");
47
- return;
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
+ );
48
86
  }
87
+ return `${base}${p.startsWith("/") ? p : `/${p}`}`;
88
+ }
49
89
 
50
- const res = await fetch("/device/" + deviceId + "/get-info", {
51
- method: "POST"
52
- });
90
+ function inferMediaType(filename, mime) {
91
+ const lower = String(filename || "").toLowerCase();
92
+ const m = String(mime || "").toLowerCase();
93
+ if (m.startsWith("video/")) return "video";
94
+ if (m.startsWith("image/")) return "image";
95
+ if (lower.endsWith(".wgt") || lower.endsWith(".zip")) return "widget";
96
+ if (/\.(mp4|webm|mov|m4v)$/.test(lower)) return "video";
97
+ if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(lower)) return "image";
98
+ return "image";
99
+ }
53
100
 
54
- const data = await res.json();
101
+ function defaultDurationMs(type) {
102
+ if (type === "video") return 30000;
103
+ if (type === "widget") return 20000;
104
+ return 10000;
105
+ }
55
106
 
56
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
107
+ function savePlaylistDraft() {
108
+ localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
57
109
  }
58
110
 
59
- async function getCapabilities() {
60
- const deviceId = getPanelDeviceId();
111
+ function saveScheduleDraft() {
112
+ const draft = {
113
+ startDate: document.getElementById("scheduleStartDate")?.value || "",
114
+ endDate: document.getElementById("scheduleEndDate")?.value || "",
115
+ startTime: document.getElementById("scheduleStartTime")?.value || "",
116
+ endTime: document.getElementById("scheduleEndTime")?.value || "",
117
+ days: [...document.querySelectorAll(".day-checkbox:checked")].map((el) => Number(el.value))
118
+ };
119
+ localStorage.setItem(PANEL_SCHEDULE_KEY, JSON.stringify(draft));
120
+ }
61
121
 
62
- if (!deviceId) {
63
- alert("Please pair a device first.");
64
- return;
122
+ function loadScheduleDraft() {
123
+ try {
124
+ const raw = localStorage.getItem(PANEL_SCHEDULE_KEY);
125
+ if (!raw) return;
126
+ const draft = JSON.parse(raw);
127
+ if (draft.startDate) document.getElementById("scheduleStartDate").value = draft.startDate;
128
+ if (draft.endDate) document.getElementById("scheduleEndDate").value = draft.endDate;
129
+ if (draft.startTime) document.getElementById("scheduleStartTime").value = draft.startTime;
130
+ if (draft.endTime) document.getElementById("scheduleEndTime").value = draft.endTime;
131
+ document.querySelectorAll(".day-checkbox").forEach((el) => {
132
+ el.checked = Array.isArray(draft.days) && draft.days.includes(Number(el.value));
133
+ });
134
+ } catch {
135
+ /* ignore */
65
136
  }
137
+ }
66
138
 
67
- const res = await fetch(`/device/${deviceId}/get-capabilities`, {
68
- method: "POST"
69
- });
70
-
71
- const data = await res.json();
72
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
139
+ function loadPlaylistDraft() {
140
+ try {
141
+ const raw = localStorage.getItem(PANEL_PLAYLIST_KEY);
142
+ if (!raw) return;
143
+ const parsed = JSON.parse(raw);
144
+ if (Array.isArray(parsed)) {
145
+ playlistItems = parsed;
146
+ renderPlaylist();
147
+ }
148
+ } catch {
149
+ playlistItems = [];
150
+ }
73
151
  }
74
152
 
75
- async function reboot() {
76
- const deviceId = getPanelDeviceId();
153
+ function renderPlaylist() {
154
+ const list = document.getElementById("playlistList");
155
+ const empty = document.getElementById("playlistEmpty");
156
+ list.querySelectorAll(".playlist-item").forEach((el) => el.remove());
77
157
 
78
- if (!deviceId) {
79
- alert("Please pair a device first.");
158
+ if (playlistItems.length === 0) {
159
+ empty.classList.remove("hidden");
80
160
  return;
81
161
  }
82
162
 
83
- if (!confirm("Are you sure you want to reboot this device?")) {
84
- return;
163
+ empty.classList.add("hidden");
164
+
165
+ for (const item of playlistItems) {
166
+ const li = document.createElement("li");
167
+ li.className = "playlist-item";
168
+ li.dataset.id = item.id;
169
+
170
+ if (item.type === "image" || item.type === "video") {
171
+ const thumb = document.createElement(item.type === "video" ? "video" : "img");
172
+ thumb.className = "playlist-item-thumb";
173
+ thumb.src = absoluteMediaUrl(item.url);
174
+ if (item.type === "video") {
175
+ thumb.muted = true;
176
+ thumb.playsInline = true;
177
+ } else {
178
+ thumb.alt = item.name;
179
+ }
180
+ li.appendChild(thumb);
181
+ }
182
+
183
+ const name = document.createElement("div");
184
+ name.className = "playlist-item-name";
185
+ name.textContent = item.name;
186
+
187
+ const meta = document.createElement("div");
188
+ meta.className = "playlist-item-meta";
189
+ meta.textContent = `${item.type} · ${(item.durationMs / 1000).toFixed(0)}s`;
190
+
191
+ const actions = document.createElement("div");
192
+ actions.className = "playlist-item-actions";
193
+
194
+ const durLabel = document.createElement("label");
195
+ durLabel.style.fontSize = "0.75rem";
196
+ durLabel.textContent = "sec ";
197
+ const durInput = document.createElement("input");
198
+ durInput.type = "number";
199
+ durInput.min = "1";
200
+ durInput.max = "3600";
201
+ durInput.value = String(Math.round(item.durationMs / 1000));
202
+ durInput.addEventListener("change", () => {
203
+ item.durationMs = Math.max(1000, Number(durInput.value) || 10) * 1000;
204
+ savePlaylistDraft();
205
+ renderPlaylist();
206
+ });
207
+
208
+ const removeBtn = document.createElement("button");
209
+ removeBtn.type = "button";
210
+ removeBtn.className = "danger";
211
+ removeBtn.textContent = "Remove";
212
+ removeBtn.addEventListener("click", () => {
213
+ playlistItems = playlistItems.filter((x) => x.id !== item.id);
214
+ savePlaylistDraft();
215
+ renderPlaylist();
216
+ });
217
+
218
+ actions.appendChild(durLabel);
219
+ actions.appendChild(durInput);
220
+ actions.appendChild(removeBtn);
221
+
222
+ li.appendChild(name);
223
+ li.appendChild(meta);
224
+ li.appendChild(actions);
225
+ list.appendChild(li);
85
226
  }
227
+ }
228
+
229
+ async function uploadFile(file) {
230
+ const q = new URLSearchParams({ filename: file.name });
231
+ const res = await fetch(`/media/upload?${q.toString()}`, {
232
+ method: "POST",
233
+ headers: { "Content-Type": file.type || "application/octet-stream" },
234
+ body: file
235
+ });
236
+ const data = await res.json();
237
+ if (!res.ok || data.status === "failed") {
238
+ throw new Error(data.error || `Upload failed (${res.status})`);
239
+ }
240
+ return data;
241
+ }
242
+
243
+ async function addAssetFromFile(file) {
244
+ showResult({ status: "uploading", filename: file.name });
245
+ const data = await uploadFile(file);
246
+ const type = inferMediaType(file.name, file.type);
247
+ playlistItems.push({
248
+ id: crypto.randomUUID(),
249
+ url: data.url,
250
+ name: file.name,
251
+ type,
252
+ durationMs: defaultDurationMs(type)
253
+ });
254
+ savePlaylistDraft();
255
+ renderPlaylist();
256
+ showResult({ status: "uploaded", ...data });
257
+ }
258
+
259
+ function buildSchedule() {
260
+ const schedule = {};
261
+ const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
262
+ const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
263
+ const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
264
+ const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
265
+ const days = [...document.querySelectorAll(".day-checkbox:checked")].map((el) =>
266
+ Number(el.value)
267
+ );
86
268
 
87
- const res = await fetch("/device/" + deviceId + "/reboot", {
88
- method: "POST"
269
+ if (startDate) schedule.startDate = startDate;
270
+ if (endDate) schedule.endDate = endDate;
271
+ if (startTime) schedule.start = startTime;
272
+ if (endTime) schedule.end = endTime;
273
+ if (days.length > 0) schedule.daysOfWeek = days;
274
+
275
+ return Object.keys(schedule).length > 0 ? schedule : undefined;
276
+ }
277
+
278
+ function buildPolicyPayload() {
279
+ const schedule = buildSchedule();
280
+ const playlist = {
281
+ id: "main",
282
+ name: "Playlist",
283
+ items: playlistItems.map((item) => ({
284
+ url: absoluteMediaUrl(item.url),
285
+ type: item.type,
286
+ durationMs: item.durationMs
287
+ }))
288
+ };
289
+ if (schedule) playlist.schedule = schedule;
290
+
291
+ return {
292
+ policy: {
293
+ playlists: [playlist],
294
+ fallback: { type: "brand" }
295
+ }
296
+ };
297
+ }
298
+
299
+ async function verify() {
300
+ const code = document.getElementById("code").value;
301
+
302
+ const res = await fetch("/pairing/verify", {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify({ code })
89
306
  });
90
307
 
91
308
  const data = await res.json();
309
+ showResult(data);
92
310
 
93
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
311
+ if (data.deviceId) {
312
+ setPanelDeviceId(data.deviceId);
313
+ }
94
314
  }
95
315
 
96
- async function setContent() {
316
+ async function publish() {
97
317
  const deviceId = getPanelDeviceId();
98
- const policyJsonText = document.getElementById("policyJson")?.value?.trim();
99
318
 
100
319
  if (!deviceId) {
101
- alert("Please pair a device first.");
320
+ alert("Pair a device first (enter code and Verify).");
102
321
  return;
103
322
  }
104
323
 
105
- if (!policyJsonText) {
106
- alert("Please enter policy JSON object.");
324
+ if (playlistItems.length === 0) {
325
+ alert("Add at least one asset to the playlist.");
107
326
  return;
108
327
  }
109
328
 
110
- let payload = null;
111
- try {
112
- payload = JSON.parse(policyJsonText);
113
- } catch (err) {
114
- alert("Policy JSON is invalid: " + err.message);
329
+ const mediaBase = getMediaBaseOrigin();
330
+ if (!mediaBase) {
331
+ alert(
332
+ "Set CMS URL for screens first (e.g. http://192.168.1.105:3000 — same machine as the TV ws:// address)."
333
+ );
115
334
  return;
116
335
  }
336
+ if (isLocalPanelHost(new URL(mediaBase).hostname)) {
337
+ const ok = confirm(
338
+ "Media URLs use localhost. TVs cannot download from localhost unless the player runs on this PC. Continue anyway?"
339
+ );
340
+ if (!ok) return;
341
+ }
117
342
 
118
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
119
- alert("Policy payload must be a JSON object.");
343
+ saveScheduleDraft();
344
+ let payload;
345
+ try {
346
+ payload = buildPolicyPayload();
347
+ } catch (err) {
348
+ alert(err.message);
120
349
  return;
121
350
  }
122
351
 
@@ -127,21 +356,94 @@ async function setContent() {
127
356
  });
128
357
 
129
358
  const data = await res.json();
130
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
359
+ showResult({ publish: payload, response: data });
131
360
  }
132
361
 
133
- async function clearContent() {
362
+ async function getInfo() {
363
+ const deviceId = getPanelDeviceId();
364
+ if (!deviceId) {
365
+ alert("Please pair a device first.");
366
+ return;
367
+ }
368
+ const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
369
+ showResult(await res.json());
370
+ }
371
+
372
+ async function getCapabilities() {
134
373
  const deviceId = getPanelDeviceId();
374
+ if (!deviceId) {
375
+ alert("Please pair a device first.");
376
+ return;
377
+ }
378
+ const res = await fetch(`/device/${deviceId}/get-capabilities`, { method: "POST" });
379
+ showResult(await res.json());
380
+ }
135
381
 
382
+ async function reboot() {
383
+ const deviceId = getPanelDeviceId();
384
+ if (!deviceId) {
385
+ alert("Please pair a device first.");
386
+ return;
387
+ }
388
+ if (!confirm("Reboot this device?")) return;
389
+ const res = await fetch(`/device/${deviceId}/reboot`, { method: "POST" });
390
+ showResult(await res.json());
391
+ }
392
+
393
+ async function clearContent() {
394
+ const deviceId = getPanelDeviceId();
136
395
  if (!deviceId) {
137
396
  alert("Please pair a device first.");
138
397
  return;
139
398
  }
399
+ if (!confirm("Clear content on this device?")) return;
400
+ const res = await fetch(`/device/${deviceId}/content/clear`, { method: "POST" });
401
+ showResult(await res.json());
402
+ }
403
+
404
+ document.addEventListener("DOMContentLoaded", () => {
405
+ const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
406
+ if (saved) setPanelDeviceId(saved);
140
407
 
141
- const res = await fetch(`/device/${deviceId}/content/clear`, {
142
- method: "POST"
408
+ const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
409
+ const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
410
+ if (savedMediaBase && cmsBaseInput) {
411
+ cmsBaseInput.value = savedMediaBase;
412
+ } else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
413
+ cmsBaseInput.value = window.location.origin;
414
+ }
415
+
416
+ loadPlaylistDraft();
417
+ loadScheduleDraft();
418
+
419
+ document.getElementById("addAssetBtn").addEventListener("click", () => {
420
+ document.getElementById("fileInput").click();
143
421
  });
144
422
 
145
- const data = await res.json();
146
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
147
- }
423
+ document.getElementById("fileInput").addEventListener("change", async (ev) => {
424
+ const files = ev.target.files;
425
+ if (!files?.length) return;
426
+ try {
427
+ for (const file of files) {
428
+ await addAssetFromFile(file);
429
+ }
430
+ } catch (err) {
431
+ alert(err.message);
432
+ showResult({ status: "failed", error: err.message });
433
+ }
434
+ ev.target.value = "";
435
+ });
436
+
437
+ [
438
+ "scheduleStartDate",
439
+ "scheduleEndDate",
440
+ "scheduleStartTime",
441
+ "scheduleEndTime"
442
+ ].forEach((id) => {
443
+ document.getElementById(id)?.addEventListener("change", saveScheduleDraft);
444
+ });
445
+ document.querySelectorAll(".day-checkbox").forEach((el) => {
446
+ el.addEventListener("change", saveScheduleDraft);
447
+ });
448
+
449
+ });
@@ -0,0 +1,232 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
8
+ background: #f4f3f0;
9
+ color: #0a0908;
10
+ }
11
+
12
+ .app-header {
13
+ padding: 1rem 1.5rem;
14
+ background: #0a0908;
15
+ color: #fafaf9;
16
+ }
17
+
18
+ .app-header h1 {
19
+ margin: 0;
20
+ font-size: 1.25rem;
21
+ font-weight: 600;
22
+ }
23
+
24
+ .app-header p {
25
+ margin: 0.35rem 0 0;
26
+ font-size: 0.875rem;
27
+ opacity: 0.8;
28
+ }
29
+
30
+ .layout {
31
+ display: flex;
32
+ gap: 0;
33
+ min-height: calc(100vh - 4.5rem);
34
+ }
35
+
36
+ .panel-main {
37
+ flex: 1;
38
+ padding: 1.25rem 1.5rem 2rem;
39
+ overflow: auto;
40
+ }
41
+
42
+ .panel-playlist {
43
+ width: 320px;
44
+ flex-shrink: 0;
45
+ background: #fff;
46
+ border-left: 1px solid #e4e1dc;
47
+ display: flex;
48
+ flex-direction: column;
49
+ }
50
+
51
+ .card {
52
+ background: #fff;
53
+ border: 1px solid #e4e1dc;
54
+ border-radius: 10px;
55
+ padding: 1rem 1.1rem;
56
+ margin-bottom: 1rem;
57
+ }
58
+
59
+ .card h2 {
60
+ margin: 0 0 0.75rem;
61
+ font-size: 0.95rem;
62
+ font-weight: 600;
63
+ }
64
+
65
+ .row {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ gap: 0.5rem;
69
+ align-items: center;
70
+ }
71
+
72
+ input[type="text"],
73
+ input[type="date"],
74
+ input[type="time"],
75
+ select {
76
+ font: inherit;
77
+ padding: 0.45rem 0.6rem;
78
+ border: 1px solid #d6d2cb;
79
+ border-radius: 6px;
80
+ background: #fff;
81
+ }
82
+
83
+ button {
84
+ font: inherit;
85
+ padding: 0.45rem 0.85rem;
86
+ border-radius: 6px;
87
+ border: 1px solid #d6d2cb;
88
+ background: #fff;
89
+ cursor: pointer;
90
+ }
91
+
92
+ button:hover {
93
+ background: #f5f3ef;
94
+ }
95
+
96
+ button.primary {
97
+ background: #ff8a3d;
98
+ border-color: #e67320;
99
+ color: #0a0908;
100
+ font-weight: 600;
101
+ }
102
+
103
+ button.primary:hover {
104
+ background: #ff9a57;
105
+ }
106
+
107
+ button.danger {
108
+ color: #b42318;
109
+ border-color: #fecdca;
110
+ }
111
+
112
+ .schedule-grid {
113
+ display: grid;
114
+ grid-template-columns: 1fr 1fr;
115
+ gap: 0.65rem 1rem;
116
+ }
117
+
118
+ .schedule-grid label {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 0.25rem;
122
+ font-size: 0.8rem;
123
+ color: #444;
124
+ }
125
+
126
+ .days-row {
127
+ grid-column: 1 / -1;
128
+ display: flex;
129
+ flex-wrap: wrap;
130
+ gap: 0.5rem 0.75rem;
131
+ font-size: 0.8rem;
132
+ }
133
+
134
+ .days-row label {
135
+ flex-direction: row;
136
+ align-items: center;
137
+ gap: 0.35rem;
138
+ color: #0a0908;
139
+ }
140
+
141
+ .playlist-header {
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: space-between;
145
+ padding: 1rem 1rem 0.75rem;
146
+ border-bottom: 1px solid #e4e1dc;
147
+ }
148
+
149
+ .playlist-header h2 {
150
+ margin: 0;
151
+ font-size: 0.95rem;
152
+ }
153
+
154
+ .playlist-list {
155
+ list-style: none;
156
+ margin: 0;
157
+ padding: 0.5rem;
158
+ flex: 1;
159
+ overflow-y: auto;
160
+ }
161
+
162
+ .playlist-empty {
163
+ padding: 1.5rem 1rem;
164
+ text-align: center;
165
+ color: #888;
166
+ font-size: 0.875rem;
167
+ }
168
+
169
+ .playlist-item {
170
+ border: 1px solid #e4e1dc;
171
+ border-radius: 8px;
172
+ padding: 0.65rem;
173
+ margin-bottom: 0.5rem;
174
+ background: #fafaf9;
175
+ }
176
+
177
+ .playlist-item-thumb {
178
+ width: 100%;
179
+ aspect-ratio: 16 / 9;
180
+ object-fit: cover;
181
+ border-radius: 4px;
182
+ background: #e4e1dc;
183
+ display: block;
184
+ margin-bottom: 0.5rem;
185
+ }
186
+
187
+ .playlist-item-name {
188
+ font-size: 0.8rem;
189
+ font-weight: 600;
190
+ word-break: break-all;
191
+ margin-bottom: 0.35rem;
192
+ }
193
+
194
+ .playlist-item-meta {
195
+ font-size: 0.75rem;
196
+ color: #666;
197
+ margin-bottom: 0.45rem;
198
+ }
199
+
200
+ .playlist-item-actions {
201
+ display: flex;
202
+ gap: 0.35rem;
203
+ align-items: center;
204
+ }
205
+
206
+ .playlist-item-actions input[type="number"] {
207
+ width: 5rem;
208
+ font: inherit;
209
+ padding: 0.25rem 0.4rem;
210
+ border: 1px solid #d6d2cb;
211
+ border-radius: 4px;
212
+ }
213
+
214
+ .publish-bar {
215
+ padding: 1rem 0 0;
216
+ }
217
+
218
+ #result {
219
+ margin: 0;
220
+ padding: 0.75rem;
221
+ background: #0a0908;
222
+ color: #e8e6e3;
223
+ border-radius: 8px;
224
+ font-size: 0.75rem;
225
+ max-height: 10rem;
226
+ overflow: auto;
227
+ white-space: pre-wrap;
228
+ }
229
+
230
+ .hidden {
231
+ display: none !important;
232
+ }
File without changes