@glivion/square-screen-js-sdk 0.1.0 → 1.0.1

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.
Files changed (49) hide show
  1. package/dist/index.cjs +874 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +522 -0
  4. package/dist/index.d.mts +522 -0
  5. package/dist/index.mjs +870 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +8 -1
  8. package/.github/workflows/build-js-sdk.yml +0 -70
  9. package/eslint.config.js +0 -3
  10. package/examples/react-app/README.md +0 -73
  11. package/examples/react-app/eslint.config.js +0 -22
  12. package/examples/react-app/index.html +0 -13
  13. package/examples/react-app/package-lock.json +0 -2239
  14. package/examples/react-app/package.json +0 -31
  15. package/examples/react-app/public/favicon.svg +0 -1
  16. package/examples/react-app/public/icons.svg +0 -24
  17. package/examples/react-app/src/App.css +0 -184
  18. package/examples/react-app/src/App.tsx +0 -157
  19. package/examples/react-app/src/EmergencyTicker.tsx +0 -25
  20. package/examples/react-app/src/HeadlessExample.tsx +0 -66
  21. package/examples/react-app/src/RendererExample.tsx +0 -70
  22. package/examples/react-app/src/assets/hero.png +0 -0
  23. package/examples/react-app/src/assets/react.svg +0 -1
  24. package/examples/react-app/src/assets/vite.svg +0 -1
  25. package/examples/react-app/src/index.css +0 -183
  26. package/examples/react-app/src/main.tsx +0 -10
  27. package/examples/react-app/src/mockNetworkDataSource.ts +0 -116
  28. package/examples/react-app/src/usePlayer.ts +0 -71
  29. package/examples/react-app/tsconfig.app.json +0 -25
  30. package/examples/react-app/tsconfig.json +0 -7
  31. package/examples/react-app/tsconfig.node.json +0 -24
  32. package/examples/react-app/vite.config.ts +0 -7
  33. package/examples/react-app/yarn.lock +0 -1089
  34. package/src/__tests__/cache/SquareScreenCache.test.ts +0 -375
  35. package/src/__tests__/network/NetworkClient.test.ts +0 -217
  36. package/src/__tests__/network/mappers.test.ts +0 -163
  37. package/src/__tests__/player/SquareScreenPlayer.test.ts +0 -840
  38. package/src/cache/SquareScreenCache.ts +0 -154
  39. package/src/constants.ts +0 -9
  40. package/src/core/types.ts +0 -251
  41. package/src/env.d.ts +0 -4
  42. package/src/index.ts +0 -34
  43. package/src/network/NetworkClient.ts +0 -234
  44. package/src/network/apiTypes.ts +0 -89
  45. package/src/network/mappers.ts +0 -106
  46. package/src/player/SquareScreenPlayer.ts +0 -414
  47. package/src/renderer/SquareScreenRenderer.ts +0 -282
  48. package/tsconfig.json +0 -12
  49. package/tsdown.config.ts +0 -23
package/dist/index.mjs ADDED
@@ -0,0 +1,870 @@
1
+ import { deleteDB, openDB } from "idb";
2
+ //#region src/constants.ts
3
+ /**
4
+ * Production root URL for the SquareScreen REST API.
5
+ *
6
+ * {@link SquareScreenPlayer} uses this for all network requests. Integrators cannot
7
+ * override it via player configuration; supply a custom {@link NetworkDataSource}
8
+ * only if you must talk to a different host (e.g. tests or a private gateway).
9
+ */
10
+ const SQUARESCREEN_API_BASE_URL = "https://square-screen-api-development-f7zuxa.laravel.cloud/api/v1";
11
+ //#endregion
12
+ //#region src/network/mappers.ts
13
+ /** Maps a raw playlist item to the core PlaylistItem type. */
14
+ function mapPlaylistItem(raw) {
15
+ return {
16
+ uuid: raw.uuid,
17
+ name: raw.name,
18
+ type: raw.type,
19
+ url: raw.url,
20
+ duration: raw.duration_seconds,
21
+ ...raw.transition !== void 0 && { transition: raw.transition },
22
+ ...raw.width !== void 0 && { width: raw.width },
23
+ ...raw.height !== void 0 && { height: raw.height },
24
+ ...raw.title !== void 0 && { title: raw.title },
25
+ ...raw.thumbnail !== void 0 && { thumbnail: raw.thumbnail }
26
+ };
27
+ }
28
+ /**
29
+ * Maps the raw strategy field to a PlaybackStrategy.
30
+ * Returns undefined when the API sends an empty array instead of a real object.
31
+ */
32
+ function mapPlaybackStrategy(raw) {
33
+ if (Array.isArray(raw)) return void 0;
34
+ return {
35
+ loop: raw.loop,
36
+ shuffle: raw.shuffle,
37
+ preloadCount: raw.preload_count ?? 0
38
+ };
39
+ }
40
+ /** Maps a raw playlist response to the core Playlist type. */
41
+ function mapPlaylistResponse(raw) {
42
+ return {
43
+ uuid: raw.playlist.uuid,
44
+ items: raw.items.map(mapPlaylistItem),
45
+ strategy: mapPlaybackStrategy(raw.strategy),
46
+ schedule: raw.schedule,
47
+ playlistMeta: raw.playlist,
48
+ cachedAt: Date.now()
49
+ };
50
+ }
51
+ /**
52
+ * Maps a raw emergency response to an EmergencyAlert.
53
+ * Returns null when no emergency is active.
54
+ */
55
+ function mapEmergencyResponse(raw) {
56
+ if (raw.emergency === null) return null;
57
+ return {
58
+ id: raw.emergency.id,
59
+ uuid: raw.emergency.uuid,
60
+ companyId: raw.emergency.company_id,
61
+ title: raw.emergency.title,
62
+ message: raw.emergency.message,
63
+ backgroundColor: raw.emergency.background_color,
64
+ textColor: raw.emergency.text_color,
65
+ targetScope: raw.emergency.target_scope,
66
+ isActive: raw.emergency.is_active,
67
+ startedAt: raw.emergency.started_at,
68
+ ...raw.emergency.ended_at !== void 0 && { endedAt: raw.emergency.ended_at }
69
+ };
70
+ }
71
+ /** Maps a raw heartbeat acknowledgement to the core HeartbeatAck type. */
72
+ function mapHeartbeatAck(raw) {
73
+ return { received: raw.received };
74
+ }
75
+ function mapHeartbeatPayload(raw) {
76
+ return {
77
+ cpu_usage: raw.cpuUsage,
78
+ memory_usage: raw.memoryUsage,
79
+ disk_usage: raw.diskUsage,
80
+ temperature: raw.temperature,
81
+ os_version: raw.osVersion,
82
+ app_version: raw.playerVersion
83
+ };
84
+ }
85
+ //#endregion
86
+ //#region src/network/NetworkClient.ts
87
+ /**
88
+ * Handles all HTTP communication with the SquareScreen API.
89
+ *
90
+ * Implements {@link NetworkDataSource} and is the only class in the SDK
91
+ * that calls `fetch` directly. Auth headers are injected automatically on
92
+ * every request — callers never set them manually.
93
+ *
94
+ * This class is internal to the SDK. Consumers interact with it only through
95
+ * the `NetworkDataSource` interface via the player module.
96
+ */
97
+ var NetworkClient = class {
98
+ /**
99
+ * @param baseUrl - Root URL of the SquareScreen API, e.g. `"https://api.squarescreen.io/api/v1"`.
100
+ * @param deviceId - Unique identifier for this device.
101
+ * @param deviceToken - Secret token used to authenticate this device. Never log or expose this value.
102
+ */
103
+ constructor(baseUrl, deviceId, deviceToken) {
104
+ this.baseUrl = baseUrl;
105
+ this.deviceId = deviceId;
106
+ this.deviceToken = deviceToken;
107
+ }
108
+ /**
109
+ * Central fetch wrapper used by all public methods.
110
+ *
111
+ * Injects auth headers, separates HTTP errors from network failures,
112
+ * and isolates JSON parse errors — so callers always receive a
113
+ * {@link SquareScreenResult} and never an uncaught exception.
114
+ *
115
+ * Error mapping:
116
+ * - 401/403 → `AuthError`
117
+ * - Other non-2xx → `NetworkError`
118
+ * - JSON parse failure → `ParseError`
119
+ * - `fetch` throw (no internet, CORS, timeout) → `NetworkError` with code `0`
120
+ */
121
+ async fetchWrapper(endpoint, options) {
122
+ try {
123
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
124
+ ...options,
125
+ headers: {
126
+ ...options?.headers ?? {},
127
+ "Content-Type": "application/json",
128
+ "X-Device-Id": this.deviceId,
129
+ "X-Device-Token": this.deviceToken
130
+ }
131
+ });
132
+ if (!response.ok) {
133
+ if (response.status === 401) return {
134
+ success: false,
135
+ error: {
136
+ kind: "auth",
137
+ message: "Unauthorized"
138
+ }
139
+ };
140
+ if (response.status === 403) return {
141
+ success: false,
142
+ error: {
143
+ kind: "auth",
144
+ message: "Forbidden"
145
+ }
146
+ };
147
+ return {
148
+ success: false,
149
+ error: {
150
+ kind: "network",
151
+ code: response.status,
152
+ message: response.statusText || `HTTP error ${response.status}`
153
+ }
154
+ };
155
+ }
156
+ try {
157
+ return {
158
+ success: true,
159
+ data: await response.json()
160
+ };
161
+ } catch {
162
+ return {
163
+ success: false,
164
+ error: {
165
+ kind: "parse",
166
+ message: "Response could not be parsed as JSON"
167
+ }
168
+ };
169
+ }
170
+ } catch (err) {
171
+ return {
172
+ success: false,
173
+ error: {
174
+ kind: "network",
175
+ code: 0,
176
+ message: err instanceof Error ? err.message : "Network request failed"
177
+ }
178
+ };
179
+ }
180
+ }
181
+ /** Fetches the full active playlist for this device. */
182
+ async fetchPlaylist() {
183
+ const result = await this.fetchWrapper("/screen/now-playing", { method: "GET" });
184
+ if (!result.success) return result;
185
+ return {
186
+ success: true,
187
+ data: mapPlaylistResponse(result.data)
188
+ };
189
+ }
190
+ /** Fetches a playlist filtered to video items only. */
191
+ async fetchVideoPlaylist(params) {
192
+ const query = new URLSearchParams({
193
+ type: "video",
194
+ ...params.category && { category: params.category },
195
+ ...params.quality && { quality: params.quality },
196
+ ...params.limit && { limit: String(params.limit) },
197
+ ...params.tags && { tags: params.tags.join(",") }
198
+ });
199
+ const result = await this.fetchWrapper(`/screen/now-playing?${query}`, { method: "GET" });
200
+ if (!result.success) return result;
201
+ return {
202
+ success: true,
203
+ data: mapPlaylistResponse(result.data)
204
+ };
205
+ }
206
+ /** Fetches a playlist filtered to image items only. */
207
+ async fetchImagePlaylist(params) {
208
+ const query = new URLSearchParams({
209
+ type: "image",
210
+ ...params.category && { category: params.category },
211
+ ...params.limit && { limit: String(params.limit) },
212
+ ...params.tags && { tags: params.tags.join(",") }
213
+ });
214
+ const result = await this.fetchWrapper(`/screen/now-playing?${query}`, { method: "GET" });
215
+ if (!result.success) return result;
216
+ return {
217
+ success: true,
218
+ data: mapPlaylistResponse(result.data)
219
+ };
220
+ }
221
+ /**
222
+ * Checks whether an emergency broadcast is currently active for this device.
223
+ * Resolves to `null` when no emergency is active.
224
+ */
225
+ async checkForEmergencyAlert() {
226
+ const result = await this.fetchWrapper("/screen/emergency", { method: "GET" });
227
+ if (!result.success) return result;
228
+ return {
229
+ success: true,
230
+ data: mapEmergencyResponse(result.data)
231
+ };
232
+ }
233
+ /**
234
+ * Posts device health metrics to the server.
235
+ * The payload is mapped to snake_case before sending to match the API's expected format.
236
+ *
237
+ * @param payload - Collected device metrics. Hardware fields are optional;
238
+ * `playerVersion` is always required.
239
+ */
240
+ async healthCheck(payload) {
241
+ const result = await this.fetchWrapper("/screen/heartbeat", {
242
+ method: "POST",
243
+ body: JSON.stringify(mapHeartbeatPayload(payload))
244
+ });
245
+ if (!result.success) return result;
246
+ return {
247
+ success: true,
248
+ data: mapHeartbeatAck(result.data)
249
+ };
250
+ }
251
+ /** Reports a playback event to the server.
252
+ * @param event - The playback event to report.
253
+ * @returns A result indicating whether the event was recorded successfully.
254
+ * @example
255
+ * const result = await networkClient.reportPlaybackEvent({
256
+ * media_uuid: "mf-00000001-0000-0000-0000-000000000001",
257
+ * playlist_uuid: "pl-00000001-0000-0000-0000-000000000001",
258
+ * schedule_uuid: "sc-00000001-0000-0000-0000-000000000001",
259
+ * started_at: "2026-04-28T07:05:00Z",
260
+ * ended_at: "2026-04-28T07:05:10Z",
261
+ * });
262
+ */
263
+ async reportPlaybackEvent(event) {
264
+ const result = await this.fetchWrapper("/screen/playback", {
265
+ method: "POST",
266
+ body: JSON.stringify(event)
267
+ });
268
+ if (!result.success) return result;
269
+ return {
270
+ success: true,
271
+ data: { recorded: result.data.recorded }
272
+ };
273
+ }
274
+ };
275
+ //#endregion
276
+ //#region src/cache/SquareScreenCache.ts
277
+ const DEFAULT_DB_NAME = "square-screen-cache";
278
+ const DEFAULT_CACHE_NAME = "square-screen-cache";
279
+ /**
280
+ * Default {@link SquareScreenCacheProvider} backed by IndexedDB (playlist metadata)
281
+ * and the Cache API (media blobs).
282
+ *
283
+ * **IndexedDB** — a single `playlists` object store holds one document per
284
+ * playlist UUID. A private `_ttl` field (milliseconds) is stored alongside the
285
+ * record and stripped before returning data to callers.
286
+ *
287
+ * **Cache API** — media files are stored under their original URL as the cache
288
+ * key, matching the same URL-keyed approach used by the Android SDK's
289
+ * `MediaFileCache`. Once a blob is retrieved from the Cache API it is wrapped
290
+ * in a `URL.createObjectURL` handle that is kept in an in-memory map and reused
291
+ * on subsequent calls to avoid redundant allocations. All handles are revoked
292
+ * when {@link clear} is called.
293
+ *
294
+ * Integrators who need different storage behaviour (a service-worker cache,
295
+ * an in-memory store for tests, a custom database) should implement
296
+ * {@link SquareScreenCacheProvider} and pass it via
297
+ * `SquareScreenPlayerConfig.cacheProvider`.
298
+ */
299
+ var SquareScreenCache = class {
300
+ /**
301
+ * @param dbName Name of the IndexedDB database. Override in tests to isolate state.
302
+ * @param cacheName Name of the Cache API bucket. Override in tests to isolate state.
303
+ */
304
+ constructor({ dbName = DEFAULT_DB_NAME, cacheName = DEFAULT_CACHE_NAME } = {}) {
305
+ this.objectUrls = /* @__PURE__ */ new Map();
306
+ this.dbName = dbName;
307
+ this.cacheName = cacheName;
308
+ }
309
+ /** Opens (or lazily creates) the IndexedDB database with the `playlists` object store. */
310
+ openDB() {
311
+ return openDB(this.dbName, 1, { upgrade(db) {
312
+ if (!db.objectStoreNames.contains("playlists")) db.createObjectStore("playlists", { keyPath: "uuid" });
313
+ } });
314
+ }
315
+ /**
316
+ * Returns the cached playlist for the given UUID, or `null` if:
317
+ * - nothing has been stored for that UUID, or
318
+ * - the record's age exceeds its TTL and `allowStale` is `false`.
319
+ *
320
+ * @param uuid The playlist UUID to look up.
321
+ * @param allowStale When `true`, expired records are returned as-is (useful
322
+ * for serving a fallback when the network is unreachable).
323
+ */
324
+ async getPlaylist(uuid, allowStale = false) {
325
+ const record = await (await this.openDB()).get("playlists", uuid);
326
+ if (!record) return null;
327
+ if (!allowStale && Date.now() - record.cachedAt > record._ttl) return null;
328
+ const { _ttl, ...playlist } = record;
329
+ return playlist;
330
+ }
331
+ /**
332
+ * Persists a playlist to IndexedDB, overwriting any previous record with the
333
+ * same UUID. `cachedAt` is set to the current wall-clock time so that TTL
334
+ * checks in {@link getPlaylist} have an accurate baseline.
335
+ *
336
+ * @param playlist The playlist to store. Items are persisted inline as part
337
+ * of the same document — no separate per-item records.
338
+ * @param ttlMs How long (in milliseconds) the record is considered fresh.
339
+ * Passed through `SquareScreenPlayerConfig.ttl` by the player.
340
+ */
341
+ async savePlaylist(playlist, ttlMs) {
342
+ await (await this.openDB()).put("playlists", {
343
+ ...playlist,
344
+ cachedAt: Date.now(),
345
+ _ttl: ttlMs
346
+ });
347
+ }
348
+ /**
349
+ * Returns a blob URL for the locally cached copy of `url`, or `null` if the
350
+ * media has not been downloaded yet.
351
+ *
352
+ * The blob URL is created once and stored in an in-memory map. Subsequent
353
+ * calls for the same `url` return the same handle without re-reading the
354
+ * Cache API, which avoids both I/O and unnecessary `URL.createObjectURL`
355
+ * allocations on long-running signage devices.
356
+ *
357
+ * @param url The original remote URL used as the Cache API key.
358
+ */
359
+ async getMediaUrl(url) {
360
+ const existing = this.objectUrls.get(url);
361
+ if (existing) return existing;
362
+ const response = await (await caches.open(this.cacheName)).match(url);
363
+ if (!response) return null;
364
+ const blob = await response.blob();
365
+ const objectUrl = URL.createObjectURL(blob);
366
+ this.objectUrls.set(url, objectUrl);
367
+ return objectUrl;
368
+ }
369
+ /**
370
+ * Stores `blob` in the Cache API under `url` and returns a blob URL for
371
+ * immediate use by the renderer.
372
+ *
373
+ * Called by the player after a successful `fetch()` so that subsequent
374
+ * {@link getMediaUrl} calls for the same URL skip the network entirely.
375
+ *
376
+ * @param url The original remote URL — used as the Cache API key so that
377
+ * {@link getMediaUrl} can retrieve the entry with a simple `match`.
378
+ * @param blob The downloaded media blob to persist.
379
+ * @returns A blob URL that the renderer can set as `src` on an
380
+ * `<img>` or `<video>` element.
381
+ */
382
+ async saveMedia(url, blob) {
383
+ await (await caches.open(this.cacheName)).put(url, new Response(blob));
384
+ const objectUrl = URL.createObjectURL(blob);
385
+ this.objectUrls.set(url, objectUrl);
386
+ return objectUrl;
387
+ }
388
+ /**
389
+ * Wipes all cached data:
390
+ * - Revokes every outstanding blob URL to release the underlying Blob references.
391
+ * - Deletes the IndexedDB database entirely (recreated on next access).
392
+ * - Removes all entries from the Cache API bucket.
393
+ *
394
+ * Useful for a "factory reset" flow or in tests to guarantee a clean slate.
395
+ */
396
+ async clear() {
397
+ for (const url of this.objectUrls.values()) URL.revokeObjectURL(url);
398
+ this.objectUrls.clear();
399
+ (await this.openDB()).close();
400
+ await deleteDB(this.dbName);
401
+ const cache = await caches.open(this.cacheName);
402
+ const keys = await cache.keys();
403
+ await Promise.all(keys.map((req) => cache.delete(req)));
404
+ }
405
+ };
406
+ //#endregion
407
+ //#region src/player/SquareScreenPlayer.ts
408
+ const LAST_PLAYLIST_KEY = "square-screen:last-playlist-uuid";
409
+ /**
410
+ * Headless, framework-agnostic player that manages playlist fetching, caching,
411
+ * item advancement, preloading, polling, and emergency alerts.
412
+ *
413
+ * Extends `EventTarget` — use `addEventListener` / `removeEventListener` to
414
+ * react to player events. No DOM or rendering logic lives here.
415
+ *
416
+ * @example
417
+ * const player = new SquareScreenPlayer({ deviceId, deviceToken, version: "1.0.0" });
418
+ * player.addEventListener("itemchange", ({ detail }) => render(detail.item));
419
+ * player.addEventListener("emergencyalert", ({ detail }) => showAlert(detail.alert));
420
+ * await player.start();
421
+ */
422
+ var SquareScreenPlayer = class extends EventTarget {
423
+ constructor(config) {
424
+ super();
425
+ this.playlist = null;
426
+ this.stopped = false;
427
+ this.currentIndex = 0;
428
+ this.status = "connecting";
429
+ this.activeAlert = null;
430
+ this.currentItemStartTime = null;
431
+ this.itemTimer = null;
432
+ this.playlistPollTimer = null;
433
+ this.emergencyPollTimer = null;
434
+ this.heartbeatTimer = null;
435
+ if (!config.networkDataSource) {
436
+ if (!config.deviceId) throw new Error("SquareScreenPlayer: deviceId is required.");
437
+ if (!config.deviceToken) throw new Error("SquareScreenPlayer: deviceToken is required.");
438
+ }
439
+ this.network = config.networkDataSource ?? new NetworkClient("https://square-screen-api-development-f7zuxa.laravel.cloud/api/v1", config.deviceId, config.deviceToken);
440
+ this.cache = config.cacheProvider ?? new SquareScreenCache();
441
+ const { networkDataSource: _n, cacheProvider: _c, ...rest } = config;
442
+ this.config = {
443
+ ttl: 300 * 1e3,
444
+ pollInterval: 3e4,
445
+ emergencyPollInterval: 15e3,
446
+ heartbeatInterval: 6e4,
447
+ ...rest
448
+ };
449
+ }
450
+ /** The playlist item currently being displayed, or null if no playlist is loaded. */
451
+ get currentItem() {
452
+ return this.playlist?.items[this.currentIndex] ?? null;
453
+ }
454
+ /**
455
+ * Starts the player: fetches the playlist, begins playback, and starts polling
456
+ * and heartbeat intervals. Safe to await — resolves once the first playlist load
457
+ * attempt completes (whether from network or cache fallback).
458
+ */
459
+ async start() {
460
+ this.stopped = false;
461
+ this.setStatus("connecting");
462
+ await this.loadPlaylist();
463
+ await this.checkEmergencyAlert();
464
+ this.startPolling();
465
+ this.startHeartbeat();
466
+ }
467
+ /** Stops all timers and clears internal state. Call this when tearing down the player. */
468
+ stop() {
469
+ if (this.itemTimer) clearTimeout(this.itemTimer);
470
+ if (this.playlistPollTimer) clearInterval(this.playlistPollTimer);
471
+ if (this.emergencyPollTimer) clearInterval(this.emergencyPollTimer);
472
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
473
+ this.itemTimer = null;
474
+ this.playlistPollTimer = null;
475
+ this.emergencyPollTimer = null;
476
+ this.heartbeatTimer = null;
477
+ this.stopped = true;
478
+ this.playlist = null;
479
+ }
480
+ /**
481
+ * Network-first playlist load. On success the playlist is saved to cache and
482
+ * applied if the UUID changed. On failure the player falls back to the last
483
+ * known playlist UUID stored in `localStorage`, loading it from cache with
484
+ * `allowStale = true` so it is served even after its TTL has expired.
485
+ */
486
+ async loadPlaylist() {
487
+ const result = await this.network.fetchPlaylist();
488
+ if (result.success) {
489
+ this.setStatus("online");
490
+ const playlist = result.data;
491
+ localStorage.setItem(LAST_PLAYLIST_KEY, playlist.uuid);
492
+ await this.cache.savePlaylist(playlist, this.config.ttl);
493
+ if (!this.playlist || this.playlist.uuid !== playlist.uuid) await this.applyPlaylist(playlist);
494
+ else this.dispatch("playlistupdate", { playlist });
495
+ } else {
496
+ this.setStatus("offline");
497
+ if (!this.playlist) {
498
+ const lastUuid = localStorage.getItem(LAST_PLAYLIST_KEY);
499
+ if (lastUuid) {
500
+ const cached = await this.cache.getPlaylist(lastUuid, true);
501
+ if (cached) await this.applyPlaylist(cached);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ /**
507
+ * Activates a playlist: optionally shuffles the items, resets the index to 0,
508
+ * emits `playlistupdate`, and kicks off the first `scheduleItem` call.
509
+ * Bails out immediately if `stop()` has been called.
510
+ */
511
+ async applyPlaylist(playlist) {
512
+ if (this.stopped) return;
513
+ const items = playlist.strategy?.shuffle ? [...playlist.items].sort(() => Math.random() - .5) : playlist.items;
514
+ this.playlist = {
515
+ ...playlist,
516
+ items
517
+ };
518
+ this.currentIndex = 0;
519
+ this.dispatch("playlistupdate", { playlist: this.playlist });
520
+ this.scheduleItem();
521
+ }
522
+ refreshPlaylist() {
523
+ this.loadPlaylist();
524
+ }
525
+ /**
526
+ * Emits `itemchange` immediately with either the cached blob URL or the raw
527
+ * HTTPS URL, then arms the advancement timer. Never blocks on a network fetch —
528
+ * if the media isn't cached yet the browser loads it directly while
529
+ * `preloadNext` / `downloadInBackground` cache it for the next cycle.
530
+ *
531
+ * Guards against `stop()` being called while awaiting the cache lookup.
532
+ */
533
+ async scheduleItem() {
534
+ if (this.itemTimer) clearTimeout(this.itemTimer);
535
+ const item = this.currentItem;
536
+ if (!item || !this.playlist) return;
537
+ this.currentItemStartTime = Date.now();
538
+ const url = await this.cache.getMediaUrl(item.url) ?? item.url;
539
+ if (!this.playlist) return;
540
+ this.dispatch("itemchange", {
541
+ item: {
542
+ ...item,
543
+ url
544
+ },
545
+ index: this.currentIndex,
546
+ total: this.playlist.items.length
547
+ });
548
+ if (url === item.url) this.downloadInBackground(item.url);
549
+ this.preloadNext();
550
+ const elapsed = Date.now() - this.currentItemStartTime;
551
+ const remaining = Math.max(0, item.duration * 1e3 - elapsed);
552
+ this.itemTimer = setTimeout(() => this.advance(), remaining);
553
+ }
554
+ /**
555
+ * Schedules background downloads for upcoming items while the current one
556
+ * is playing, so subsequent transitions can serve blob URLs immediately.
557
+ *
558
+ * - `preloadCount: 0` — disabled; nothing is downloaded.
559
+ * - `preloadCount: N` — downloads the next N items ahead (wraps at end).
560
+ * - `preloadCount` absent — downloads all items in the playlist up front.
561
+ */
562
+ preloadNext() {
563
+ if (!this.playlist) return;
564
+ const preloadCount = this.playlist.strategy?.preloadCount;
565
+ if (preloadCount === 0) return;
566
+ const count = preloadCount ?? this.playlist.items.length;
567
+ for (let i = 1; i <= count; i++) {
568
+ const idx = (this.currentIndex + i) % this.playlist.items.length;
569
+ this.downloadInBackground(this.playlist.items[idx].url);
570
+ }
571
+ }
572
+ /**
573
+ * Fire-and-forget download. Checks the cache first; if the media is already
574
+ * present the call is a no-op. Errors are silently swallowed — a failed
575
+ * download is not fatal; the raw URL will be used until the next successful download.
576
+ */
577
+ downloadInBackground(url) {
578
+ this.cache.getMediaUrl(url).then((cached) => {
579
+ if (cached || !this.playlist) return;
580
+ return fetch(url, { mode: "cors" }).then((r) => r.ok ? r.blob() : Promise.reject(/* @__PURE__ */ new Error(`HTTP ${r.status}`))).then((blob) => this.cache.saveMedia(url, blob));
581
+ }).catch(() => {});
582
+ }
583
+ /**
584
+ * Moves to the next item. Reports the completed playback event, then either
585
+ * wraps back to index 0 (when `loop` is true or absent) or halts when
586
+ * `loop: false` and the last item has finished.
587
+ */
588
+ advance() {
589
+ if (!this.playlist) return;
590
+ const total = this.playlist.items.length;
591
+ const next = this.currentIndex + 1;
592
+ this.reportPlaybackEvent();
593
+ if (next >= total) if (this.playlist.strategy?.loop ?? true) this.currentIndex = 0;
594
+ else return;
595
+ else this.currentIndex = next;
596
+ this.scheduleItem();
597
+ }
598
+ /** Sends a proof-of-play event to the server. Fire-and-forget; failures are not surfaced. */
599
+ async reportPlaybackEvent() {
600
+ if (!this.currentItemStartTime) return;
601
+ if (!this.currentItem || !this.playlist) return;
602
+ const endedAt = Date.now();
603
+ const event = {
604
+ media_uuid: this.currentItem.uuid,
605
+ playlist_uuid: this.playlist.uuid,
606
+ schedule_uuid: this.playlist.schedule?.uuid ?? "",
607
+ started_at: new Date(this.currentItemStartTime).toISOString(),
608
+ ended_at: new Date(endedAt).toISOString(),
609
+ duration_seconds: Math.round((endedAt - this.currentItemStartTime) / 1e3),
610
+ completed: true
611
+ };
612
+ this.network.reportPlaybackEvent(event);
613
+ }
614
+ /** Arms the playlist-refresh and emergency-alert polling intervals. */
615
+ startPolling() {
616
+ this.playlistPollTimer = setInterval(() => this.loadPlaylist(), this.config.pollInterval);
617
+ this.emergencyPollTimer = setInterval(() => this.checkEmergencyAlert(), this.config.emergencyPollInterval);
618
+ }
619
+ /** Arms the periodic heartbeat that keeps the device registration alive. */
620
+ startHeartbeat() {
621
+ this.heartbeatTimer = setInterval(async () => {
622
+ await this.network.healthCheck({ playerVersion: this.config.version });
623
+ }, this.config.heartbeatInterval);
624
+ }
625
+ /**
626
+ * Polls the server for an active emergency alert. Emits `emergencyalert` only
627
+ * when the state actually changes (alert activated, cleared, or replaced by a
628
+ * different UUID) to avoid redundant re-renders on the consumer side.
629
+ */
630
+ async checkEmergencyAlert() {
631
+ const result = await this.network.checkForEmergencyAlert();
632
+ if (!result.success) return;
633
+ const incoming = result.data;
634
+ const wasActive = this.activeAlert !== null;
635
+ const isActive = incoming !== null;
636
+ const alertChanged = isActive && incoming.uuid !== this.activeAlert?.uuid;
637
+ if (wasActive !== isActive || alertChanged) {
638
+ this.activeAlert = incoming;
639
+ this.dispatch("emergencyalert", { alert: incoming });
640
+ }
641
+ }
642
+ setStatus(status) {
643
+ if (this.status === status) return;
644
+ this.status = status;
645
+ this.dispatch("statuschange", { status });
646
+ }
647
+ dispatch(type, detail) {
648
+ this.dispatchEvent(new CustomEvent(type, { detail }));
649
+ }
650
+ addEventListener(type, listener, options) {
651
+ super.addEventListener(type, listener, options);
652
+ }
653
+ removeEventListener(type, listener, options) {
654
+ super.removeEventListener(type, listener, options);
655
+ }
656
+ };
657
+ //#endregion
658
+ //#region src/renderer/SquareScreenRenderer.ts
659
+ /**
660
+ * Optional vanilla JS renderer that wires a {@link SquareScreenPlayer} to a DOM container.
661
+ * Handles `<img>` / `<video>` element lifecycle, autoplay, transitions, and emergency alerts.
662
+ *
663
+ * When an emergency alert is active it renders a full-screen overlay on top of all content.
664
+ * Normal playback resumes automatically once the alert is cleared.
665
+ *
666
+ * @example
667
+ * const player = new SquareScreenPlayer({ ... });
668
+ * const renderer = new SquareScreenRenderer(document.getElementById("screen"), player);
669
+ * renderer.mount();
670
+ * await player.start();
671
+ *
672
+ * // Tear down
673
+ * player.stop();
674
+ * renderer.unmount();
675
+ */
676
+ var SquareScreenRenderer = class {
677
+ constructor(container, player, config = {}) {
678
+ this.wrapper = null;
679
+ this.slots = null;
680
+ this.activeSlot = 0;
681
+ this.alertOverlay = null;
682
+ this.container = container;
683
+ this.player = player;
684
+ this.config = {
685
+ defaultTransition: "none",
686
+ transitionDuration: 500,
687
+ canPlayTimeout: 3e3,
688
+ ...config
689
+ };
690
+ this.onItemChange = (e) => this.handleItemChange(e.detail.item);
691
+ this.onEmergencyAlert = (e) => this.handleEmergencyAlert(e.detail.alert);
692
+ }
693
+ /** Injects the renderer DOM into the container and begins listening to the player. */
694
+ mount() {
695
+ this.buildDOM();
696
+ this.player.addEventListener("itemchange", this.onItemChange);
697
+ this.player.addEventListener("emergencyalert", this.onEmergencyAlert);
698
+ }
699
+ /** Removes the renderer DOM and stops listening to the player. */
700
+ unmount() {
701
+ this.player.removeEventListener("itemchange", this.onItemChange);
702
+ this.player.removeEventListener("emergencyalert", this.onEmergencyAlert);
703
+ if (this.wrapper && this.container.contains(this.wrapper)) this.container.removeChild(this.wrapper);
704
+ this.wrapper = null;
705
+ this.slots = null;
706
+ }
707
+ buildDOM() {
708
+ this.wrapper = document.createElement("div");
709
+ Object.assign(this.wrapper.style, {
710
+ position: "relative",
711
+ width: "100%",
712
+ height: "100%",
713
+ overflow: "hidden",
714
+ backgroundColor: "#000"
715
+ });
716
+ const slotA = this.createSlot(true);
717
+ const slotB = this.createSlot(false);
718
+ this.wrapper.appendChild(slotA);
719
+ this.wrapper.appendChild(slotB);
720
+ this.slots = [slotA, slotB];
721
+ this.alertOverlay = document.createElement("div");
722
+ Object.assign(this.alertOverlay.style, {
723
+ display: "none",
724
+ position: "absolute",
725
+ top: "0",
726
+ right: "0",
727
+ bottom: "0",
728
+ left: "0",
729
+ zIndex: "9999",
730
+ flexDirection: "column",
731
+ alignItems: "center",
732
+ justifyContent: "center",
733
+ padding: "2rem",
734
+ textAlign: "center",
735
+ boxSizing: "border-box"
736
+ });
737
+ this.wrapper.appendChild(this.alertOverlay);
738
+ this.container.appendChild(this.wrapper);
739
+ }
740
+ createSlot(visible) {
741
+ const slot = document.createElement("div");
742
+ Object.assign(slot.style, {
743
+ position: "absolute",
744
+ top: "0",
745
+ right: "0",
746
+ bottom: "0",
747
+ left: "0",
748
+ display: "flex",
749
+ alignItems: "center",
750
+ justifyContent: "center",
751
+ opacity: visible ? "1" : "0",
752
+ transition: `opacity ${this.config.transitionDuration}ms ease`
753
+ });
754
+ return slot;
755
+ }
756
+ async handleItemChange(item) {
757
+ if (!this.slots) return;
758
+ const nextIndex = (this.activeSlot + 1) % 2;
759
+ const currentSlot = this.slots[this.activeSlot];
760
+ const nextSlot = this.slots[nextIndex];
761
+ nextSlot.innerHTML = "";
762
+ const media = item.type === "video" ? this.createVideo(item.url) : this.createImage(item.url);
763
+ nextSlot.appendChild(media);
764
+ if (item.type === "video") await this.waitForCanPlay(media);
765
+ const transitionType = item.transition ?? this.config.defaultTransition;
766
+ await this.applyTransition(currentSlot, nextSlot, transitionType);
767
+ this.activeSlot = nextIndex;
768
+ }
769
+ handleEmergencyAlert(alert) {
770
+ if (!this.alertOverlay) return;
771
+ if (!alert) {
772
+ this.alertOverlay.style.display = "none";
773
+ this.alertOverlay.innerHTML = "";
774
+ return;
775
+ }
776
+ this.alertOverlay.style.display = "flex";
777
+ this.alertOverlay.style.backgroundColor = alert.backgroundColor;
778
+ this.alertOverlay.style.color = alert.textColor;
779
+ const title = document.createElement("h1");
780
+ title.textContent = alert.title;
781
+ Object.assign(title.style, {
782
+ margin: "0 0 1rem",
783
+ fontSize: "2.5rem",
784
+ fontWeight: "bold"
785
+ });
786
+ const message = document.createElement("p");
787
+ message.textContent = alert.message;
788
+ Object.assign(message.style, {
789
+ margin: "0",
790
+ fontSize: "1.5rem"
791
+ });
792
+ this.alertOverlay.innerHTML = "";
793
+ this.alertOverlay.appendChild(title);
794
+ this.alertOverlay.appendChild(message);
795
+ }
796
+ createImage(src) {
797
+ const img = document.createElement("img");
798
+ Object.assign(img.style, {
799
+ maxWidth: "100%",
800
+ maxHeight: "100%",
801
+ objectFit: "contain"
802
+ });
803
+ img.src = src;
804
+ return img;
805
+ }
806
+ createVideo(src) {
807
+ const video = document.createElement("video");
808
+ Object.assign(video.style, {
809
+ width: "100%",
810
+ height: "100%",
811
+ objectFit: "contain"
812
+ });
813
+ video.src = src;
814
+ video.autoplay = true;
815
+ video.muted = true;
816
+ video.playsInline = true;
817
+ video.loop = true;
818
+ return video;
819
+ }
820
+ waitForCanPlay(video) {
821
+ return new Promise((resolve) => {
822
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
823
+ resolve();
824
+ return;
825
+ }
826
+ const timeout = this.config.canPlayTimeout;
827
+ const timer = timeout > 0 ? setTimeout(resolve, timeout) : null;
828
+ const handler = () => {
829
+ if (timer) clearTimeout(timer);
830
+ video.removeEventListener("canplay", handler);
831
+ resolve();
832
+ };
833
+ video.addEventListener("canplay", handler);
834
+ });
835
+ }
836
+ async applyTransition(from, to, type) {
837
+ const duration = this.config.transitionDuration;
838
+ if (type === "none" || duration === 0) {
839
+ from.style.opacity = "0";
840
+ to.style.opacity = "1";
841
+ } else if (type === "fade") {
842
+ to.style.opacity = "1";
843
+ from.style.opacity = "0";
844
+ await this.wait(duration);
845
+ } else if (type === "slide") {
846
+ to.style.transition = "none";
847
+ to.style.transform = "translateX(100%)";
848
+ to.style.opacity = "1";
849
+ to.offsetWidth;
850
+ to.style.transition = `transform ${duration}ms ease`;
851
+ from.style.transition = `transform ${duration}ms ease`;
852
+ to.style.transform = "translateX(0)";
853
+ from.style.transform = "translateX(-100%)";
854
+ await this.wait(duration);
855
+ }
856
+ from.style.transition = `opacity ${duration}ms ease`;
857
+ from.style.transform = "";
858
+ from.style.opacity = "0";
859
+ to.style.transition = `opacity ${duration}ms ease`;
860
+ to.style.transform = "";
861
+ to.style.opacity = "1";
862
+ }
863
+ wait(ms) {
864
+ return new Promise((resolve) => setTimeout(resolve, ms));
865
+ }
866
+ };
867
+ //#endregion
868
+ export { SQUARESCREEN_API_BASE_URL, SquareScreenCache, SquareScreenPlayer, SquareScreenRenderer };
869
+
870
+ //# sourceMappingURL=index.mjs.map