@tomorrowos/sdk 0.2.4 → 0.3.0

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.
@@ -4,6 +4,8 @@ import fs from "fs/promises";
4
4
  import http from "http";
5
5
  import path from "path";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
+ import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode } from "./pairing-code.js";
8
+ import { PlaylistCatalog } from "./playlist-catalog.js";
7
9
  import { MemoryStore } from "./store/memory-store.js";
8
10
  function formatDurationMs(ms) {
9
11
  if (!Number.isFinite(ms) || ms < 0)
@@ -23,8 +25,16 @@ function formatDurationMs(ms) {
23
25
  parts.push(`${seconds}s`);
24
26
  return parts.join(" ");
25
27
  }
26
- function createSixDigitCode() {
27
- return Math.floor(100000 + Math.random() * 900000).toString();
28
+ function resolveHelloDeviceId(msg) {
29
+ const serial = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
30
+ ? msg.serialNumber.trim()
31
+ : null;
32
+ if (serial)
33
+ return serial;
34
+ const deviceId = typeof msg.deviceId === "string" && msg.deviceId.trim()
35
+ ? msg.deviceId.trim()
36
+ : null;
37
+ return deviceId;
28
38
  }
29
39
  const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
30
40
  async function readJsonBody(req) {
@@ -71,6 +81,7 @@ function parseDevicePath(pathname) {
71
81
  export class TomorrowOS extends EventEmitter {
72
82
  brand;
73
83
  store;
84
+ playlists;
74
85
  devices = new Map();
75
86
  pendingDeviceMeta = new Map();
76
87
  httpServer = null;
@@ -81,42 +92,81 @@ export class TomorrowOS extends EventEmitter {
81
92
  super();
82
93
  this.brand = options.brand;
83
94
  this.store = options.store ?? new MemoryStore();
95
+ this.playlists = new PlaylistCatalog(this.store);
84
96
  }
85
- /** Verify a 6-digit pairing code (same as POST /pairing/verify). */
97
+ /** Push all device assignments using latest playlist definitions (e.g. after reboot). */
98
+ async pushLatestPolicyToDevice(deviceId) {
99
+ const id = String(deviceId || "").trim();
100
+ const assignments = await this.store.getDeviceAssignments(id);
101
+ if (assignments.length === 0)
102
+ return { pushed: false };
103
+ const built = await this.playlists.buildPolicyForDevice(id, { useLatest: true });
104
+ const ws = this.devices.get(id);
105
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
106
+ return { pushed: false, policy: built.policy };
107
+ }
108
+ await this.sendDeviceCommand(id, "device.content.setPolicy", {
109
+ policy: built.policy
110
+ });
111
+ await this.recordPolicyPush(id);
112
+ return { pushed: true, policy: built.policy };
113
+ }
114
+ async sendDeviceCommand(deviceId, method, params) {
115
+ const ws = this.devices.get(deviceId);
116
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
117
+ throw Object.assign(new Error("Device not connected"), { code: "DEVICE_OFFLINE" });
118
+ }
119
+ return this.sendCommandToSocket(ws, deviceId, method, params);
120
+ }
121
+ /** Verify a 6-character alphanumeric pairing code (POST /pairing/verify). */
86
122
  async pairingVerify(code) {
87
- const record = await this.store.getPendingCode(code);
88
- if (!record) {
89
- const err = new Error("Invalid or expired code");
123
+ const normalized = normalizePairingCode(code);
124
+ if (!isValidPairingCodeFormat(normalized)) {
125
+ const err = new Error("Invalid pairing code format");
126
+ err.code = "PAIRING_INVALID";
127
+ throw err;
128
+ }
129
+ let deviceId;
130
+ const registry = await this.store.getDeviceRegistryByCode(normalized);
131
+ if (registry) {
132
+ deviceId = registry.deviceId;
133
+ }
134
+ else {
135
+ const pending = await this.store.getPendingCode(normalized);
136
+ deviceId = pending?.deviceId;
137
+ }
138
+ if (!deviceId) {
139
+ const err = new Error("Invalid or unknown pairing code");
90
140
  err.code = "PAIRING_INVALID";
91
141
  throw err;
92
142
  }
93
143
  const pairingToken = randomBytes(32).toString("hex");
94
144
  const pairedAt = new Date().toISOString();
95
- const meta = this.pendingDeviceMeta.get(record.deviceId);
145
+ const meta = this.pendingDeviceMeta.get(deviceId);
96
146
  const now = pairedAt;
97
- await this.store.setPairedDevice(record.deviceId, {
147
+ await this.store.setPairedDevice(deviceId, {
98
148
  pairingToken,
99
149
  pairedAt,
100
150
  deviceName: meta?.deviceName,
101
151
  platform: meta?.platform,
102
152
  system: meta?.system,
103
153
  lastBootAt: meta?.bootedAt ?? now,
104
- lastOnlineAt: this.isDeviceConnected(record.deviceId) ? now : undefined,
105
- lastOfflineAt: this.isDeviceConnected(record.deviceId) ? undefined : now
154
+ lastOnlineAt: this.isDeviceConnected(deviceId) ? now : undefined,
155
+ lastOfflineAt: this.isDeviceConnected(deviceId) ? undefined : now
106
156
  });
107
- await this.store.deletePendingCode(code);
108
- const ws = this.devices.get(record.deviceId);
157
+ await this.store.deletePendingCode(normalized);
158
+ const ws = this.devices.get(deviceId);
109
159
  if (ws && ws.readyState === WebSocket.OPEN) {
110
160
  ws.send(JSON.stringify({
111
161
  type: "pairing.verified",
112
162
  method: "tomorrowos.pairing.verify",
113
- deviceId: record.deviceId,
163
+ deviceId,
114
164
  pairingToken
115
165
  }));
116
- void this.refreshPairedDeviceInfo(record.deviceId);
166
+ void this.refreshPairedDeviceInfo(deviceId);
117
167
  }
118
- this.emit("device.paired", { deviceId: record.deviceId });
119
- return { deviceId: record.deviceId };
168
+ this.emit("device.paired", { deviceId });
169
+ return { deviceId };
120
170
  }
121
171
  /** Remove pairing for a device and notify it over WebSocket if connected. */
122
172
  async pairingUnpair(deviceId) {
@@ -149,8 +199,10 @@ export class TomorrowOS extends EventEmitter {
149
199
  async listDevices() {
150
200
  const entries = await this.store.listPairedDevices();
151
201
  const now = Date.now();
152
- return entries.map(({ deviceId, record }) => {
202
+ return Promise.all(entries.map(async ({ deviceId, record }) => {
153
203
  const connected = this.isDeviceConnected(deviceId);
204
+ const reg = await this.store.getDeviceRegistry(deviceId);
205
+ const assignments = await this.store.getDeviceAssignments(deviceId);
154
206
  let screenOnlineActive = false;
155
207
  let screenOnlineLabel = "Not active";
156
208
  if (connected && record.lastBootAt) {
@@ -163,10 +215,18 @@ export class TomorrowOS extends EventEmitter {
163
215
  const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
164
216
  return {
165
217
  deviceId,
218
+ pairingCode: reg?.permanentPairingCode ?? null,
219
+ publishedPlaylists: assignments.map((a) => ({
220
+ playlistId: a.playlistId,
221
+ name: a.snapshot.name,
222
+ version: a.publishedVersion,
223
+ publishedAt: a.publishedAt
224
+ })),
166
225
  connected,
167
226
  deviceName: record.deviceName ?? null,
168
227
  platform: record.platform ?? null,
169
228
  system: record.system ?? null,
229
+ serialNumber: reg?.serialNumber ?? deviceId,
170
230
  pairedAt: record.pairedAt,
171
231
  lastBootAt: record.lastBootAt ?? null,
172
232
  lastOnlineAt: record.lastOnlineAt ?? null,
@@ -176,7 +236,7 @@ export class TomorrowOS extends EventEmitter {
176
236
  screenOnlineLabel,
177
237
  screenOnlineSince
178
238
  };
179
- });
239
+ }));
180
240
  }
181
241
  isDeviceConnected(deviceId) {
182
242
  const ws = this.devices.get(deviceId);
@@ -188,9 +248,37 @@ export class TomorrowOS extends EventEmitter {
188
248
  deviceName: typeof msg.deviceName === "string" ? msg.deviceName : undefined,
189
249
  system: typeof msg.system === "string" ? msg.system : undefined,
190
250
  bootedAt: typeof msg.bootedAt === "string" ? msg.bootedAt : undefined,
191
- playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined
251
+ playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined,
252
+ serialNumber: typeof msg.serialNumber === "string" ? msg.serialNumber : deviceId
192
253
  });
193
254
  }
255
+ async getOrCreatePermanentPairingCode(deviceId, serialNumber) {
256
+ const existing = await this.store.getDeviceRegistry(deviceId);
257
+ if (existing?.permanentPairingCode) {
258
+ await this.store.setDeviceRegistry(deviceId, {
259
+ ...existing,
260
+ serialNumber: serialNumber ?? existing.serialNumber ?? deviceId,
261
+ lastHelloAt: Date.now()
262
+ });
263
+ return existing.permanentPairingCode;
264
+ }
265
+ for (let attempt = 0; attempt < 32; attempt += 1) {
266
+ const code = generateRandomPairingCode();
267
+ const collision = await this.store.getDeviceRegistryByCode(code);
268
+ if (collision && collision.deviceId !== deviceId)
269
+ continue;
270
+ const now = Date.now();
271
+ await this.store.setDeviceRegistry(deviceId, {
272
+ permanentPairingCode: code,
273
+ codeCreatedAt: now,
274
+ serialNumber: serialNumber ?? deviceId,
275
+ firstSeenAt: now,
276
+ lastHelloAt: now
277
+ });
278
+ return code;
279
+ }
280
+ throw new Error("Failed to allocate unique pairing code");
281
+ }
194
282
  async mergePairedRecord(deviceId, patch) {
195
283
  const existing = await this.store.getPairedDevice(deviceId);
196
284
  if (!existing)
@@ -496,6 +584,105 @@ export class TomorrowOS extends EventEmitter {
496
584
  sendJson(res, 200, { status: "success", devices });
497
585
  return;
498
586
  }
587
+ if (req.method === "GET" && pathname === "/playlists") {
588
+ const playlists = await this.playlists.listPlaylists();
589
+ sendJson(res, 200, { status: "success", playlists });
590
+ return;
591
+ }
592
+ if (req.method === "POST" && pathname === "/playlists") {
593
+ const body = (await readJsonBody(req));
594
+ try {
595
+ const saved = await this.playlists.savePlaylist({
596
+ id: typeof body.id === "string" ? body.id : undefined,
597
+ name: String(body.name ?? ""),
598
+ schedule: body.schedule && typeof body.schedule === "object"
599
+ ? body.schedule
600
+ : undefined,
601
+ items: Array.isArray(body.items) ? body.items : []
602
+ });
603
+ sendJson(res, 200, { status: "success", playlist: saved });
604
+ }
605
+ catch (e) {
606
+ const msg = e instanceof Error ? e.message : "Save failed";
607
+ sendJson(res, 400, { status: "failed", error: msg });
608
+ }
609
+ return;
610
+ }
611
+ const playlistDelete = /^\/playlists\/([^/]+)$/.exec(pathname);
612
+ if (req.method === "DELETE" && playlistDelete) {
613
+ const playlistId = decodeURIComponent(playlistDelete[1]);
614
+ try {
615
+ const retired = await this.playlists.retirePlaylist(playlistId);
616
+ sendJson(res, 200, { status: "success", playlist: retired });
617
+ }
618
+ catch (e) {
619
+ const msg = e instanceof Error ? e.message : "Delete failed";
620
+ sendJson(res, 400, { status: "failed", error: msg });
621
+ }
622
+ return;
623
+ }
624
+ const deviceAssignmentsGet = /^\/device\/([^/]+)\/assignments$/.exec(pathname);
625
+ if (req.method === "GET" && deviceAssignmentsGet) {
626
+ const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
627
+ const assignments = await this.playlists.getDeviceAssignments(deviceId);
628
+ sendJson(res, 200, { status: "success", assignments });
629
+ return;
630
+ }
631
+ const deviceAssignmentsPost = /^\/device\/([^/]+)\/assignments$/.exec(pathname);
632
+ if (req.method === "POST" && deviceAssignmentsPost) {
633
+ const deviceId = decodeURIComponent(deviceAssignmentsPost[1]);
634
+ const body = (await readJsonBody(req));
635
+ const ws = this.devices.get(deviceId);
636
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
637
+ sendJson(res, 404, { status: "failed", error: "Device not connected" });
638
+ return;
639
+ }
640
+ try {
641
+ let built;
642
+ if (body.useLatest === true) {
643
+ built = await this.playlists.buildPolicyForDevice(deviceId, {
644
+ useLatest: true
645
+ });
646
+ }
647
+ else {
648
+ const ids = Array.isArray(body.playlistIds) ? body.playlistIds : [];
649
+ built = await this.playlists.publishPlaylistsToDevice(deviceId, ids);
650
+ }
651
+ const result = await this.sendDeviceCommand(deviceId, "device.content.setPolicy", {
652
+ policy: built.policy
653
+ });
654
+ await this.recordPolicyPush(deviceId);
655
+ sendJson(res, 200, { status: "success", policy: built.policy, result });
656
+ }
657
+ catch (e) {
658
+ const msg = e instanceof Error ? e.message : "Publish failed";
659
+ sendJson(res, 400, { status: "failed", error: msg });
660
+ }
661
+ return;
662
+ }
663
+ const deviceAssignmentDelete = /^\/device\/([^/]+)\/assignments\/([^/]+)$/.exec(pathname);
664
+ if (req.method === "DELETE" && deviceAssignmentDelete) {
665
+ const deviceId = decodeURIComponent(deviceAssignmentDelete[1]);
666
+ const playlistId = decodeURIComponent(deviceAssignmentDelete[2]);
667
+ const ws = this.devices.get(deviceId);
668
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
669
+ sendJson(res, 404, { status: "failed", error: "Device not connected" });
670
+ return;
671
+ }
672
+ try {
673
+ const built = await this.playlists.removePlaylistFromDevice(deviceId, playlistId);
674
+ const result = await this.sendDeviceCommand(deviceId, "device.content.setPolicy", {
675
+ policy: built.policy
676
+ });
677
+ await this.recordPolicyPush(deviceId);
678
+ sendJson(res, 200, { status: "success", policy: built.policy, result });
679
+ }
680
+ catch (e) {
681
+ const msg = e instanceof Error ? e.message : "Remove failed";
682
+ sendJson(res, 400, { status: "failed", error: msg });
683
+ }
684
+ return;
685
+ }
499
686
  if (req.method === "POST") {
500
687
  const parsed = parseDevicePath(pathname);
501
688
  if (parsed) {
@@ -593,25 +780,34 @@ export class TomorrowOS extends EventEmitter {
593
780
  }
594
781
  const type = msg.type;
595
782
  if (type === "device.hello") {
596
- const deviceId = typeof msg.deviceId === "string" && msg.deviceId
597
- ? msg.deviceId
598
- : randomUUID();
599
- const code = createSixDigitCode();
600
- this.captureHelloMeta(deviceId, msg);
601
- this.devices.set(deviceId, ws);
602
- void this.store.setPendingCode(code, {
603
- deviceId,
604
- createdAt: Date.now()
605
- });
606
- ws.deviceId = deviceId;
607
- ws.send(JSON.stringify({
608
- type: "pairing.code",
609
- method: "tomorrowos.pairing.createCode",
610
- code,
611
- deviceId
612
- }));
613
- this.sendBrandSnapshot(ws);
614
- this.emit("device.online", { deviceId });
783
+ const deviceId = resolveHelloDeviceId(msg) ?? randomUUID();
784
+ const serialNumber = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
785
+ ? msg.serialNumber.trim()
786
+ : deviceId;
787
+ void (async () => {
788
+ try {
789
+ const code = await this.getOrCreatePermanentPairingCode(deviceId, serialNumber);
790
+ this.captureHelloMeta(deviceId, msg);
791
+ this.devices.set(deviceId, ws);
792
+ void this.store.setPendingCode(code, {
793
+ deviceId,
794
+ createdAt: Date.now()
795
+ });
796
+ ws.deviceId = deviceId;
797
+ ws.send(JSON.stringify({
798
+ type: "pairing.code",
799
+ method: "tomorrowos.pairing.createCode",
800
+ code,
801
+ deviceId,
802
+ serialNumber
803
+ }));
804
+ this.sendBrandSnapshot(ws);
805
+ this.emit("device.online", { deviceId });
806
+ }
807
+ catch (err) {
808
+ console.error("[TomorrowOS] device.hello failed:", err);
809
+ }
810
+ })();
615
811
  return;
616
812
  }
617
813
  if (type === "device.resume") {
@@ -638,6 +834,9 @@ export class TomorrowOS extends EventEmitter {
638
834
  this.sendBrandSnapshot(ws);
639
835
  void this.refreshPairedDeviceInfo(deviceId);
640
836
  this.emit("device.online", { deviceId });
837
+ void this.pushLatestPolicyToDevice(deviceId).catch((err) => {
838
+ console.error("[TomorrowOS] pushLatestPolicy on resume failed:", err);
839
+ });
641
840
  })();
642
841
  }
643
842
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
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,30 +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
+ {
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
+ }
@@ -9,16 +9,24 @@
9
9
  <body>
10
10
  <header class="app-header">
11
11
  <h1>TomorrowOS Control Panel</h1>
12
- <p>Pair multiple screens, build a playlist, then publish per device.</p>
12
+ <p>Create playlists, save with confirm, then publish selected playlists per device.</p>
13
13
  </header>
14
14
 
15
15
  <div class="layout">
16
+ <aside class="panel-playlist-nav">
17
+ <div class="playlist-nav-header">
18
+ <h2>Playlists</h2>
19
+ <button type="button" class="primary" id="newPlaylistBtn" title="New playlist">+</button>
20
+ </div>
21
+ <ul id="playlistCatalog" class="playlist-catalog"></ul>
22
+ </aside>
23
+
16
24
  <main class="panel-main">
17
25
  <section class="card">
18
26
  <h2>Pair device</h2>
19
- <p class="hint">Enter the 6-digit code shown on a screen. Each verified device gets its own card below.</p>
27
+ <p class="hint">Enter the 6-character code on the screen (A–Z or 0–9).</p>
20
28
  <div class="row">
21
- <input id="code" maxlength="6" placeholder="6-digit code" />
29
+ <input id="code" maxlength="6" placeholder="e.g. A3K9Z1" autocapitalize="characters" />
22
30
  <button type="button" onclick="verify()">Verify</button>
23
31
  </div>
24
32
  </section>
@@ -26,17 +34,14 @@
26
34
  <section class="card" id="pairedDevicesSection">
27
35
  <h2>Paired devices</h2>
28
36
  <div id="devicesGrid" class="devices-grid">
29
- <p class="devices-empty" id="devicesEmpty">No paired devices yet. Enter a code above.</p>
37
+ <p class="devices-empty" id="devicesEmpty">No paired devices yet.</p>
30
38
  </div>
31
39
  </section>
32
40
 
33
41
  <section class="card" id="cmsUrlSection">
34
42
  <h2>CMS URL for screens</h2>
35
43
  <p class="hint">
36
- <strong>Local development only.</strong> Fill this in when you run the CMS on your PC and
37
- TVs are on the same LAN (e.g. <code>http://192.168.1.105:3000</code> — same machine as your
38
- <code>ws://</code> address, not <code>localhost</code>). On hosted CMS (Replit, etc.) you
39
- normally do <strong>not</strong> need to change this.
44
+ <strong>Local development only.</strong> LAN URL for TVs (not localhost).
40
45
  </p>
41
46
  <div class="row">
42
47
  <input
@@ -49,11 +54,16 @@
49
54
  </div>
50
55
  </section>
51
56
 
52
- <section class="card">
53
- <h2>When this playlist plays</h2>
54
- <p class="hint">
55
- Leave blank for always on (device local time). All set fields must match.
56
- </p>
57
+ <section class="card" id="playlistEditorSection">
58
+ <h2 id="editorTitle">Playlist editor</h2>
59
+ <p class="hint">Save confirms changes. Names must be unique. Schedule applies to this playlist only.</p>
60
+ <label class="field-label">
61
+ Name
62
+ <input type="text" id="playlistName" placeholder="e.g. Weekday promo" />
63
+ </label>
64
+
65
+ <h3 class="subheading">When this playlist plays</h3>
66
+ <p class="hint">Leave blank for always on (device local time).</p>
57
67
  <div class="schedule-grid">
58
68
  <label>
59
69
  Start date
@@ -82,6 +92,11 @@
82
92
  <label><input type="checkbox" class="day-checkbox" value="6" /> Sat</label>
83
93
  </div>
84
94
  </div>
95
+
96
+ <div class="editor-actions">
97
+ <button type="button" class="primary" id="savePlaylistBtn">Save playlist</button>
98
+ <button type="button" class="danger" id="deletePlaylistBtn">Delete playlist</button>
99
+ </div>
85
100
  </section>
86
101
 
87
102
  <pre id="result" aria-live="polite"></pre>
@@ -89,7 +104,7 @@
89
104
 
90
105
  <aside class="panel-playlist">
91
106
  <div class="playlist-header">
92
- <h2>Playlist</h2>
107
+ <h2>Assets</h2>
93
108
  <button type="button" class="primary" id="addAssetBtn" title="Add image or video">+</button>
94
109
  </div>
95
110
  <input
@@ -99,11 +114,24 @@
99
114
  accept="image/*,video/*,.wgt,.zip"
100
115
  />
101
116
  <ul id="playlistList" class="playlist-list">
102
- <li class="playlist-empty" id="playlistEmpty">No assets yet. Tap + to upload.</li>
117
+ <li class="playlist-empty" id="playlistEmpty">Select or create a playlist, then add assets.</li>
103
118
  </ul>
104
119
  </aside>
105
120
  </div>
106
121
 
122
+ <div id="publishModal" class="modal hidden" role="dialog" aria-modal="true">
123
+ <div class="modal-backdrop" data-close-modal="1"></div>
124
+ <div class="modal-card">
125
+ <h2>Publish to device</h2>
126
+ <p class="hint" id="publishModalHint">Select playlists to deploy. Updates apply on publish; reboot pulls latest saved versions.</p>
127
+ <div id="publishChecklist" class="publish-checklist"></div>
128
+ <div class="modal-actions">
129
+ <button type="button" id="publishConfirmBtn" class="primary">Publish selected</button>
130
+ <button type="button" data-close-modal="1">Cancel</button>
131
+ </div>
132
+ </div>
133
+ </div>
134
+
107
135
  <script src="./methods.js" defer></script>
108
136
  </body>
109
137
  </html>