@tomorrowos/sdk 0.1.5 → 0.1.8

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.5",
3
+ "version": "0.1.8",
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.5",
3
+ "version": "0.1.8",
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.5"
13
+ "@tomorrowos/sdk": "^0.1.8"
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,98 @@
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>
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>
10
14
 
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
+ <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>
15
25
 
16
- <h2>Get Device Info</h2>
17
- <button onclick="getInfo()">Get Info</button>
26
+ <section class="card">
27
+ <h2>When this playlist plays</h2>
28
+ <p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
29
+ Leave blank for always on (device local time). All set fields must match.
30
+ </p>
31
+ <div class="schedule-grid">
32
+ <label>
33
+ Start date
34
+ <input type="date" id="scheduleStartDate" />
35
+ </label>
36
+ <label>
37
+ End date
38
+ <input type="date" id="scheduleEndDate" />
39
+ </label>
40
+ <label>
41
+ From
42
+ <input type="time" id="scheduleStartTime" />
43
+ </label>
44
+ <label>
45
+ Until
46
+ <input type="time" id="scheduleEndTime" />
47
+ </label>
48
+ <div class="days-row">
49
+ <span style="width: 100%; color: #444">Days</span>
50
+ <label><input type="checkbox" class="day-checkbox" value="0" /> Sun</label>
51
+ <label><input type="checkbox" class="day-checkbox" value="1" /> Mon</label>
52
+ <label><input type="checkbox" class="day-checkbox" value="2" /> Tue</label>
53
+ <label><input type="checkbox" class="day-checkbox" value="3" /> Wed</label>
54
+ <label><input type="checkbox" class="day-checkbox" value="4" /> Thu</label>
55
+ <label><input type="checkbox" class="day-checkbox" value="5" /> Fri</label>
56
+ <label><input type="checkbox" class="day-checkbox" value="6" /> Sat</label>
57
+ </div>
58
+ </div>
59
+ </section>
18
60
 
19
- <h2>Get Capabilities</h2>
20
- <button onclick="getCapabilities()">Get Capabilities</button>
61
+ <section class="card">
62
+ <h2>Device actions</h2>
63
+ <div class="row">
64
+ <button type="button" onclick="getInfo()">Get info</button>
65
+ <button type="button" onclick="getCapabilities()">Capabilities</button>
66
+ <button type="button" onclick="reboot()">Reboot</button>
67
+ <button type="button" class="danger" onclick="clearContent()">Clear content</button>
68
+ </div>
69
+ </section>
21
70
 
22
- <h2>Reboot Device</h2>
23
- <button onclick="reboot()">Reboot</button>
71
+ <section class="publish-bar">
72
+ <button type="button" class="primary" style="width: 100%; padding: 0.75rem" onclick="publish()">
73
+ Publish playlist
74
+ </button>
75
+ </section>
24
76
 
25
- <h2>Set Content</h2>
26
- <button onclick="setContent()">Set</button>
77
+ <pre id="result" aria-live="polite"></pre>
78
+ </main>
27
79
 
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>
80
+ <aside class="panel-playlist">
81
+ <div class="playlist-header">
82
+ <h2>Playlist</h2>
83
+ <button type="button" class="primary" id="addAssetBtn" title="Add image or video">+</button>
84
+ </div>
85
+ <input
86
+ type="file"
87
+ id="fileInput"
88
+ class="hidden"
89
+ accept="image/*,video/*,.wgt,.zip"
90
+ />
91
+ <ul id="playlistList" class="playlist-list">
92
+ <li class="playlist-empty" id="playlistEmpty">No assets yet. Tap + to upload.</li>
93
+ </ul>
94
+ </aside>
95
+ </div>
55
96
 
56
97
  <script src="./methods.js" defer></script>
57
98
  </body>
@@ -1,101 +1,278 @@
1
- async function verify() {
2
- const code = document.getElementById("code").value;
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";
3
4
 
4
- const res = await fetch("/pairing/verify", {
5
- method: "POST",
6
- headers: { "Content-Type": "application/json" },
7
- body: JSON.stringify({ code })
8
- });
5
+ /** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
6
+ let playlistItems = [];
9
7
 
10
- const data = await res.json();
8
+ function getPanelDeviceId() {
9
+ return (
10
+ document.getElementById("deviceId")?.value?.trim() ||
11
+ localStorage.getItem(PANEL_DEVICE_ID_KEY) ||
12
+ ""
13
+ );
14
+ }
15
+
16
+ function setPanelDeviceId(deviceId) {
17
+ const id = String(deviceId || "").trim();
18
+ const el = document.getElementById("deviceId");
19
+ if (el) el.value = id;
20
+ if (id) localStorage.setItem(PANEL_DEVICE_ID_KEY, id);
21
+ else localStorage.removeItem(PANEL_DEVICE_ID_KEY);
22
+ }
11
23
 
24
+ function showResult(data) {
12
25
  document.getElementById("result").textContent = JSON.stringify(data, null, 2);
26
+ }
13
27
 
14
- if (data.deviceId) {
15
- document.getElementById("deviceId").value = data.deviceId;
28
+ function absoluteMediaUrl(path) {
29
+ const p = String(path || "").trim();
30
+ if (!p) return "";
31
+ if (/^https?:\/\//i.test(p)) return p;
32
+ return `${window.location.origin}${p.startsWith("/") ? p : `/${p}`}`;
33
+ }
34
+
35
+ function inferMediaType(filename, mime) {
36
+ const lower = String(filename || "").toLowerCase();
37
+ const m = String(mime || "").toLowerCase();
38
+ if (m.startsWith("video/")) return "video";
39
+ if (m.startsWith("image/")) return "image";
40
+ if (lower.endsWith(".wgt") || lower.endsWith(".zip")) return "widget";
41
+ if (/\.(mp4|webm|mov|m4v)$/.test(lower)) return "video";
42
+ if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(lower)) return "image";
43
+ return "image";
44
+ }
45
+
46
+ function defaultDurationMs(type) {
47
+ if (type === "video") return 30000;
48
+ if (type === "widget") return 20000;
49
+ return 10000;
50
+ }
51
+
52
+ function savePlaylistDraft() {
53
+ localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
54
+ }
55
+
56
+ function saveScheduleDraft() {
57
+ const draft = {
58
+ startDate: document.getElementById("scheduleStartDate")?.value || "",
59
+ endDate: document.getElementById("scheduleEndDate")?.value || "",
60
+ startTime: document.getElementById("scheduleStartTime")?.value || "",
61
+ endTime: document.getElementById("scheduleEndTime")?.value || "",
62
+ days: [...document.querySelectorAll(".day-checkbox:checked")].map((el) => Number(el.value))
63
+ };
64
+ localStorage.setItem(PANEL_SCHEDULE_KEY, JSON.stringify(draft));
65
+ }
66
+
67
+ function loadScheduleDraft() {
68
+ try {
69
+ const raw = localStorage.getItem(PANEL_SCHEDULE_KEY);
70
+ if (!raw) return;
71
+ const draft = JSON.parse(raw);
72
+ if (draft.startDate) document.getElementById("scheduleStartDate").value = draft.startDate;
73
+ if (draft.endDate) document.getElementById("scheduleEndDate").value = draft.endDate;
74
+ if (draft.startTime) document.getElementById("scheduleStartTime").value = draft.startTime;
75
+ if (draft.endTime) document.getElementById("scheduleEndTime").value = draft.endTime;
76
+ document.querySelectorAll(".day-checkbox").forEach((el) => {
77
+ el.checked = Array.isArray(draft.days) && draft.days.includes(Number(el.value));
78
+ });
79
+ } catch {
80
+ /* ignore */
16
81
  }
17
82
  }
18
83
 
19
- async function getInfo() {
20
- const deviceId = document.getElementById("deviceId").value;
84
+ function loadPlaylistDraft() {
85
+ try {
86
+ const raw = localStorage.getItem(PANEL_PLAYLIST_KEY);
87
+ if (!raw) return;
88
+ const parsed = JSON.parse(raw);
89
+ if (Array.isArray(parsed)) {
90
+ playlistItems = parsed;
91
+ renderPlaylist();
92
+ }
93
+ } catch {
94
+ playlistItems = [];
95
+ }
96
+ }
21
97
 
22
- if (!deviceId) {
23
- alert("Please pair a device first.");
98
+ function renderPlaylist() {
99
+ const list = document.getElementById("playlistList");
100
+ const empty = document.getElementById("playlistEmpty");
101
+ list.querySelectorAll(".playlist-item").forEach((el) => el.remove());
102
+
103
+ if (playlistItems.length === 0) {
104
+ empty.classList.remove("hidden");
24
105
  return;
25
106
  }
26
107
 
27
- const res = await fetch("/device/" + deviceId + "/get-info", {
28
- method: "POST"
29
- });
108
+ empty.classList.add("hidden");
30
109
 
31
- const data = await res.json();
110
+ for (const item of playlistItems) {
111
+ const li = document.createElement("li");
112
+ li.className = "playlist-item";
113
+ li.dataset.id = item.id;
32
114
 
33
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
34
- }
115
+ if (item.type === "image" || item.type === "video") {
116
+ const thumb = document.createElement(item.type === "video" ? "video" : "img");
117
+ thumb.className = "playlist-item-thumb";
118
+ thumb.src = absoluteMediaUrl(item.url);
119
+ if (item.type === "video") {
120
+ thumb.muted = true;
121
+ thumb.playsInline = true;
122
+ } else {
123
+ thumb.alt = item.name;
124
+ }
125
+ li.appendChild(thumb);
126
+ }
35
127
 
36
- async function getCapabilities() {
37
- const deviceId = document.getElementById("deviceId").value;
128
+ const name = document.createElement("div");
129
+ name.className = "playlist-item-name";
130
+ name.textContent = item.name;
38
131
 
39
- if (!deviceId) {
40
- alert("Please pair a device first.");
41
- return;
132
+ const meta = document.createElement("div");
133
+ meta.className = "playlist-item-meta";
134
+ meta.textContent = `${item.type} · ${(item.durationMs / 1000).toFixed(0)}s`;
135
+
136
+ const actions = document.createElement("div");
137
+ actions.className = "playlist-item-actions";
138
+
139
+ const durLabel = document.createElement("label");
140
+ durLabel.style.fontSize = "0.75rem";
141
+ durLabel.textContent = "sec ";
142
+ const durInput = document.createElement("input");
143
+ durInput.type = "number";
144
+ durInput.min = "1";
145
+ durInput.max = "3600";
146
+ durInput.value = String(Math.round(item.durationMs / 1000));
147
+ durInput.addEventListener("change", () => {
148
+ item.durationMs = Math.max(1000, Number(durInput.value) || 10) * 1000;
149
+ savePlaylistDraft();
150
+ renderPlaylist();
151
+ });
152
+
153
+ const removeBtn = document.createElement("button");
154
+ removeBtn.type = "button";
155
+ removeBtn.className = "danger";
156
+ removeBtn.textContent = "Remove";
157
+ removeBtn.addEventListener("click", () => {
158
+ playlistItems = playlistItems.filter((x) => x.id !== item.id);
159
+ savePlaylistDraft();
160
+ renderPlaylist();
161
+ });
162
+
163
+ actions.appendChild(durLabel);
164
+ actions.appendChild(durInput);
165
+ actions.appendChild(removeBtn);
166
+
167
+ li.appendChild(name);
168
+ li.appendChild(meta);
169
+ li.appendChild(actions);
170
+ list.appendChild(li);
42
171
  }
172
+ }
43
173
 
44
- const res = await fetch(`/device/${deviceId}/get-capabilities`, {
45
- method: "POST"
174
+ async function uploadFile(file) {
175
+ const q = new URLSearchParams({ filename: file.name });
176
+ const res = await fetch(`/media/upload?${q.toString()}`, {
177
+ method: "POST",
178
+ headers: { "Content-Type": file.type || "application/octet-stream" },
179
+ body: file
46
180
  });
47
-
48
181
  const data = await res.json();
49
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
182
+ if (!res.ok || data.status === "failed") {
183
+ throw new Error(data.error || `Upload failed (${res.status})`);
184
+ }
185
+ return data;
50
186
  }
51
187
 
52
- async function reboot() {
53
- const deviceId = document.getElementById("deviceId").value;
188
+ async function addAssetFromFile(file) {
189
+ showResult({ status: "uploading", filename: file.name });
190
+ const data = await uploadFile(file);
191
+ const type = inferMediaType(file.name, file.type);
192
+ playlistItems.push({
193
+ id: crypto.randomUUID(),
194
+ url: data.url,
195
+ name: file.name,
196
+ type,
197
+ durationMs: defaultDurationMs(type)
198
+ });
199
+ savePlaylistDraft();
200
+ renderPlaylist();
201
+ showResult({ status: "uploaded", ...data });
202
+ }
54
203
 
55
- if (!deviceId) {
56
- alert("Please pair a device first.");
57
- return;
58
- }
204
+ function buildSchedule() {
205
+ const schedule = {};
206
+ const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
207
+ const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
208
+ const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
209
+ const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
210
+ const days = [...document.querySelectorAll(".day-checkbox:checked")].map((el) =>
211
+ Number(el.value)
212
+ );
59
213
 
60
- if (!confirm("Are you sure you want to reboot this device?")) {
61
- return;
62
- }
214
+ if (startDate) schedule.startDate = startDate;
215
+ if (endDate) schedule.endDate = endDate;
216
+ if (startTime) schedule.start = startTime;
217
+ if (endTime) schedule.end = endTime;
218
+ if (days.length > 0) schedule.daysOfWeek = days;
219
+
220
+ return Object.keys(schedule).length > 0 ? schedule : undefined;
221
+ }
222
+
223
+ function buildPolicyPayload() {
224
+ const schedule = buildSchedule();
225
+ const playlist = {
226
+ id: "main",
227
+ name: "Playlist",
228
+ items: playlistItems.map((item) => ({
229
+ url: absoluteMediaUrl(item.url),
230
+ type: item.type,
231
+ durationMs: item.durationMs
232
+ }))
233
+ };
234
+ if (schedule) playlist.schedule = schedule;
63
235
 
64
- const res = await fetch("/device/" + deviceId + "/reboot", {
65
- method: "POST"
236
+ return {
237
+ policy: {
238
+ playlists: [playlist],
239
+ fallback: { type: "brand" }
240
+ }
241
+ };
242
+ }
243
+
244
+ async function verify() {
245
+ const code = document.getElementById("code").value;
246
+
247
+ const res = await fetch("/pairing/verify", {
248
+ method: "POST",
249
+ headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify({ code })
66
251
  });
67
252
 
68
253
  const data = await res.json();
254
+ showResult(data);
69
255
 
70
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
256
+ if (data.deviceId) {
257
+ setPanelDeviceId(data.deviceId);
258
+ }
71
259
  }
72
260
 
73
- async function setContent() {
74
- const deviceId = document.getElementById("deviceId").value;
75
- const policyJsonText = document.getElementById("policyJson")?.value?.trim();
261
+ async function publish() {
262
+ const deviceId = getPanelDeviceId();
76
263
 
77
264
  if (!deviceId) {
78
- alert("Please pair a device first.");
265
+ alert("Pair a device first (enter code and Verify).");
79
266
  return;
80
267
  }
81
268
 
82
- if (!policyJsonText) {
83
- alert("Please enter policy JSON object.");
269
+ if (playlistItems.length === 0) {
270
+ alert("Add at least one asset to the playlist.");
84
271
  return;
85
272
  }
86
273
 
87
- let payload = null;
88
- try {
89
- payload = JSON.parse(policyJsonText);
90
- } catch (err) {
91
- alert("Policy JSON is invalid: " + err.message);
92
- return;
93
- }
94
-
95
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
96
- alert("Policy payload must be a JSON object.");
97
- return;
98
- }
274
+ saveScheduleDraft();
275
+ const payload = buildPolicyPayload();
99
276
 
100
277
  const res = await fetch(`/device/${deviceId}/content/set-policy`, {
101
278
  method: "POST",
@@ -104,21 +281,86 @@ async function setContent() {
104
281
  });
105
282
 
106
283
  const data = await res.json();
107
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
284
+ showResult({ publish: payload, response: data });
108
285
  }
109
286
 
110
- async function clearContent() {
111
- const deviceId = document.getElementById("deviceId").value;
287
+ async function getInfo() {
288
+ const deviceId = getPanelDeviceId();
289
+ if (!deviceId) {
290
+ alert("Please pair a device first.");
291
+ return;
292
+ }
293
+ const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
294
+ showResult(await res.json());
295
+ }
112
296
 
297
+ async function getCapabilities() {
298
+ const deviceId = getPanelDeviceId();
113
299
  if (!deviceId) {
114
300
  alert("Please pair a device first.");
115
301
  return;
116
302
  }
303
+ const res = await fetch(`/device/${deviceId}/get-capabilities`, { method: "POST" });
304
+ showResult(await res.json());
305
+ }
117
306
 
118
- const res = await fetch(`/device/${deviceId}/content/clear`, {
119
- method: "POST"
120
- });
307
+ async function reboot() {
308
+ const deviceId = getPanelDeviceId();
309
+ if (!deviceId) {
310
+ alert("Please pair a device first.");
311
+ return;
312
+ }
313
+ if (!confirm("Reboot this device?")) return;
314
+ const res = await fetch(`/device/${deviceId}/reboot`, { method: "POST" });
315
+ showResult(await res.json());
316
+ }
121
317
 
122
- const data = await res.json();
123
- document.getElementById("result").textContent = JSON.stringify(data, null, 2);
318
+ async function clearContent() {
319
+ const deviceId = getPanelDeviceId();
320
+ if (!deviceId) {
321
+ alert("Please pair a device first.");
322
+ return;
323
+ }
324
+ if (!confirm("Clear content on this device?")) return;
325
+ const res = await fetch(`/device/${deviceId}/content/clear`, { method: "POST" });
326
+ showResult(await res.json());
124
327
  }
328
+
329
+ document.addEventListener("DOMContentLoaded", () => {
330
+ const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
331
+ if (saved) setPanelDeviceId(saved);
332
+
333
+ loadPlaylistDraft();
334
+ loadScheduleDraft();
335
+
336
+ document.getElementById("addAssetBtn").addEventListener("click", () => {
337
+ document.getElementById("fileInput").click();
338
+ });
339
+
340
+ document.getElementById("fileInput").addEventListener("change", async (ev) => {
341
+ const files = ev.target.files;
342
+ if (!files?.length) return;
343
+ try {
344
+ for (const file of files) {
345
+ await addAssetFromFile(file);
346
+ }
347
+ } catch (err) {
348
+ alert(err.message);
349
+ showResult({ status: "failed", error: err.message });
350
+ }
351
+ ev.target.value = "";
352
+ });
353
+
354
+ [
355
+ "scheduleStartDate",
356
+ "scheduleEndDate",
357
+ "scheduleStartTime",
358
+ "scheduleEndTime"
359
+ ].forEach((id) => {
360
+ document.getElementById(id)?.addEventListener("change", saveScheduleDraft);
361
+ });
362
+ document.querySelectorAll(".day-checkbox").forEach((el) => {
363
+ el.addEventListener("change", saveScheduleDraft);
364
+ });
365
+
366
+ });
@@ -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