@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
@@ -1,840 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import type {
4
- EmergencyAlert,
5
- NetworkDataSource,
6
- Playlist,
7
- SquareScreenCacheProvider,
8
- } from "../../core/types";
9
- import { SquareScreenPlayer } from "../../player/SquareScreenPlayer";
10
-
11
- // ---------------------------------------------------------------------------
12
- // Factories
13
- // ---------------------------------------------------------------------------
14
-
15
- function makeItem(
16
- overrides: Partial<Playlist["items"][number]> = {},
17
- ): Playlist["items"][number] {
18
- return {
19
- uuid: "item-1",
20
- name: "Item 1",
21
- type: "image",
22
- url: "https://cdn.example.com/1.jpg",
23
- duration: 5,
24
- ...overrides,
25
- };
26
- }
27
-
28
- function makePlaylist(overrides: Partial<Playlist> = {}): Playlist {
29
- return {
30
- uuid: "playlist-1",
31
- cachedAt: Date.now(),
32
- strategy: { loop: true, shuffle: false, preloadCount: 0 },
33
- items: [makeItem()],
34
- ...overrides,
35
- };
36
- }
37
-
38
- function makeAlert(overrides: Partial<EmergencyAlert> = {}): EmergencyAlert {
39
- return {
40
- id: 1,
41
- uuid: "alert-1",
42
- companyId: 42,
43
- title: "Emergency",
44
- message: "Stay indoors",
45
- backgroundColor: "#FF0000",
46
- textColor: "#FFFFFF",
47
- targetScope: "all",
48
- isActive: true,
49
- startedAt: new Date().toISOString(),
50
- ...overrides,
51
- };
52
- }
53
-
54
- function makeNetwork(playlist: Playlist = makePlaylist()): NetworkDataSource {
55
- return {
56
- fetchPlaylist: vi.fn().mockResolvedValue({ success: true, data: playlist }),
57
- fetchVideoPlaylist: vi.fn().mockResolvedValue({ success: true, data: playlist }),
58
- fetchImagePlaylist: vi.fn().mockResolvedValue({ success: true, data: playlist }),
59
- checkForEmergencyAlert: vi.fn().mockResolvedValue({ success: true, data: null }),
60
- healthCheck: vi.fn().mockResolvedValue({ success: true, data: { received: true } }),
61
- reportPlaybackEvent: vi
62
- .fn()
63
- .mockResolvedValue({ success: true, data: { recorded: true } }),
64
- };
65
- }
66
-
67
- function makeCache(): SquareScreenCacheProvider {
68
- return {
69
- getPlaylist: vi.fn().mockResolvedValue(null),
70
- savePlaylist: vi.fn().mockResolvedValue(undefined),
71
- getMediaUrl: vi.fn().mockResolvedValue(null),
72
- saveMedia: vi.fn().mockResolvedValue("blob:mock"),
73
- clear: vi.fn().mockResolvedValue(undefined),
74
- };
75
- }
76
-
77
- /** Shared config defaults — override per-test as needed. */
78
- function makeConfig(
79
- network: NetworkDataSource,
80
- cache: SquareScreenCacheProvider,
81
- overrides: Record<string, unknown> = {},
82
- ) {
83
- return {
84
- deviceId: "unused",
85
- deviceToken: "unused",
86
- version: "1.0.0",
87
- pollInterval: 30_000,
88
- emergencyPollInterval: 15_000,
89
- heartbeatInterval: 60_000,
90
- networkDataSource: network,
91
- cacheProvider: cache,
92
- ...overrides,
93
- };
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // Setup / teardown
98
- // ---------------------------------------------------------------------------
99
-
100
- beforeEach(() => {
101
- vi.useFakeTimers();
102
- vi.stubGlobal(
103
- "fetch",
104
- vi.fn().mockResolvedValue({
105
- ok: true,
106
- blob: () => Promise.resolve(new Blob(["data"], { type: "image/jpeg" })),
107
- }),
108
- );
109
- });
110
-
111
- afterEach(() => {
112
- vi.clearAllTimers();
113
- vi.useRealTimers();
114
- vi.unstubAllGlobals();
115
- vi.restoreAllMocks();
116
- localStorage.clear();
117
- });
118
-
119
- // ---------------------------------------------------------------------------
120
- // Initialization guards
121
- // ---------------------------------------------------------------------------
122
-
123
- describe("initialization", () => {
124
- it("throws if deviceId is missing and no networkDataSource is provided", () => {
125
- expect(() => new SquareScreenPlayer({
126
- deviceId: "",
127
- deviceToken: "tok-1",
128
- version: "1.0.0",
129
- })).toThrow("deviceId is required");
130
- });
131
-
132
- it("throws if deviceToken is missing and no networkDataSource is provided", () => {
133
- expect(() => new SquareScreenPlayer({
134
- deviceId: "dev-1",
135
- deviceToken: "",
136
- version: "1.0.0",
137
- })).toThrow("deviceToken is required");
138
- });
139
-
140
- it("does not throw when credentials are missing if networkDataSource is provided", () => {
141
- expect(() => new SquareScreenPlayer({
142
- deviceId: "",
143
- deviceToken: "",
144
- version: "1.0.0",
145
- networkDataSource: makeNetwork(),
146
- })).not.toThrow();
147
- });
148
- });
149
-
150
- // ---------------------------------------------------------------------------
151
- // Startup
152
- // ---------------------------------------------------------------------------
153
-
154
- describe("startup", () => {
155
- it("emits statuschange → online, playlistupdate and itemchange on a successful fetch", async () => {
156
- const network = makeNetwork();
157
- const cache = makeCache();
158
- const player = new SquareScreenPlayer(makeConfig(network, cache));
159
-
160
- const statuschange = vi.fn();
161
- const playlistupdate = vi.fn();
162
- const itemchange = vi.fn();
163
- player.addEventListener("statuschange", statuschange);
164
- player.addEventListener("playlistupdate", playlistupdate);
165
- player.addEventListener("itemchange", itemchange);
166
-
167
- await player.start();
168
-
169
- expect(statuschange).toHaveBeenCalledTimes(1);
170
- expect(statuschange.mock.calls[0][0].detail.status).toBe("online");
171
- expect(playlistupdate).toHaveBeenCalledTimes(1);
172
- expect(itemchange).toHaveBeenCalledTimes(1);
173
- expect(player.currentItem?.uuid).toBe("item-1");
174
-
175
- player.stop();
176
- });
177
-
178
- it("emits statuschange → offline and no itemchange when the network fetch fails and there is no cached fallback", async () => {
179
- const network = makeNetwork();
180
- network.fetchPlaylist = vi
181
- .fn()
182
- .mockResolvedValue({ success: false, error: { kind: "network", code: 0, message: "down" } });
183
-
184
- const cache = makeCache();
185
- const player = new SquareScreenPlayer(makeConfig(network, cache));
186
-
187
- const statuschange = vi.fn();
188
- const itemchange = vi.fn();
189
- player.addEventListener("statuschange", statuschange);
190
- player.addEventListener("itemchange", itemchange);
191
-
192
- await player.start();
193
-
194
- expect(statuschange.mock.calls[0][0].detail.status).toBe("offline");
195
- expect(itemchange).not.toHaveBeenCalled();
196
-
197
- player.stop();
198
- });
199
-
200
- it("does not emit a duplicate statuschange when the status is unchanged between polls", async () => {
201
- const network = makeNetwork();
202
- const cache = makeCache();
203
- const player = new SquareScreenPlayer(
204
- makeConfig(network, cache, { pollInterval: 1_000 }),
205
- );
206
-
207
- const statuschange = vi.fn();
208
- player.addEventListener("statuschange", statuschange);
209
-
210
- await player.start();
211
- expect(statuschange).toHaveBeenCalledTimes(1); // connecting → online
212
-
213
- await vi.advanceTimersByTimeAsync(1_000); // trigger poll — still online
214
- expect(statuschange).toHaveBeenCalledTimes(1); // no second event
215
-
216
- player.stop();
217
- });
218
-
219
- it("saves the playlist to cache with the configured TTL", async () => {
220
- const network = makeNetwork();
221
- const cache = makeCache();
222
- const player = new SquareScreenPlayer(
223
- makeConfig(network, cache, { ttl: 120_000 }),
224
- );
225
-
226
- await player.start();
227
-
228
- expect(cache.savePlaylist).toHaveBeenCalledWith(
229
- expect.objectContaining({ uuid: "playlist-1" }),
230
- 120_000,
231
- );
232
-
233
- player.stop();
234
- });
235
- });
236
-
237
- // ---------------------------------------------------------------------------
238
- // Media resolution
239
- // ---------------------------------------------------------------------------
240
-
241
- describe("media resolution", () => {
242
- it("serves a blob URL from cache without hitting the network", async () => {
243
- const network = makeNetwork();
244
- const cache = makeCache();
245
- vi.mocked(cache.getMediaUrl).mockResolvedValue("blob:cached");
246
-
247
- const player = new SquareScreenPlayer(makeConfig(network, cache));
248
- const itemchange = vi.fn();
249
- player.addEventListener("itemchange", itemchange);
250
-
251
- await player.start();
252
-
253
- expect(fetch).not.toHaveBeenCalled();
254
- expect(itemchange.mock.calls[0][0].detail.item.url).toBe("blob:cached");
255
-
256
- player.stop();
257
- });
258
-
259
- it("dispatches the raw URL immediately on a cache miss and downloads in background", async () => {
260
- const network = makeNetwork();
261
- const cache = makeCache();
262
-
263
- const player = new SquareScreenPlayer(makeConfig(network, cache));
264
- const itemchange = vi.fn();
265
- player.addEventListener("itemchange", itemchange);
266
-
267
- await player.start();
268
-
269
- // itemchange fires immediately with the raw URL — no inline fetch blocks it
270
- expect(itemchange).toHaveBeenCalledTimes(1);
271
- expect(itemchange.mock.calls[0][0].detail.item.url).toBe("https://cdn.example.com/1.jpg");
272
-
273
- // background download is triggered (fire-and-forget)
274
- expect(fetch).toHaveBeenCalledWith("https://cdn.example.com/1.jpg", { mode: "cors" });
275
-
276
- player.stop();
277
- });
278
-
279
- it("falls back to the raw URL when fetch throws", async () => {
280
- vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
281
-
282
- const network = makeNetwork();
283
- const cache = makeCache();
284
- const player = new SquareScreenPlayer(makeConfig(network, cache));
285
- const itemchange = vi.fn();
286
- player.addEventListener("itemchange", itemchange);
287
-
288
- await player.start();
289
-
290
- expect(itemchange.mock.calls[0][0].detail.item.url).toBe(
291
- "https://cdn.example.com/1.jpg",
292
- );
293
-
294
- player.stop();
295
- });
296
-
297
- it("falls back to the raw URL when fetch returns a non-OK status", async () => {
298
- vi.stubGlobal(
299
- "fetch",
300
- vi.fn().mockResolvedValue({ ok: false, status: 403, blob: vi.fn() }),
301
- );
302
-
303
- const network = makeNetwork();
304
- const cache = makeCache();
305
- const player = new SquareScreenPlayer(makeConfig(network, cache));
306
- const itemchange = vi.fn();
307
- player.addEventListener("itemchange", itemchange);
308
-
309
- await player.start();
310
-
311
- expect(itemchange.mock.calls[0][0].detail.item.url).toBe(
312
- "https://cdn.example.com/1.jpg",
313
- );
314
- expect(cache.saveMedia).not.toHaveBeenCalled();
315
-
316
- player.stop();
317
- });
318
- });
319
-
320
- // ---------------------------------------------------------------------------
321
- // Offline fallback
322
- // ---------------------------------------------------------------------------
323
-
324
- describe("offline fallback", () => {
325
- it("loads the stale cached playlist when the network fails", async () => {
326
- const fallback = makePlaylist({
327
- uuid: "cached-playlist",
328
- items: [makeItem({ uuid: "cached-item", url: "https://cdn.example.com/cached.jpg" })],
329
- });
330
-
331
- const network = makeNetwork();
332
- network.fetchPlaylist = vi
333
- .fn()
334
- .mockResolvedValue({ success: false, error: { kind: "network", code: 0, message: "down" } });
335
-
336
- const cache = makeCache();
337
- vi.mocked(cache.getPlaylist).mockResolvedValueOnce(fallback);
338
- localStorage.setItem("square-screen:last-playlist-uuid", "cached-playlist");
339
-
340
- const player = new SquareScreenPlayer(makeConfig(network, cache));
341
- const itemchange = vi.fn();
342
- player.addEventListener("itemchange", itemchange);
343
-
344
- await player.start();
345
-
346
- expect(cache.getPlaylist).toHaveBeenCalledWith("cached-playlist", true);
347
- expect(itemchange).toHaveBeenCalledTimes(1);
348
- expect(player.currentItem?.uuid).toBe("cached-item");
349
-
350
- player.stop();
351
- });
352
-
353
- it("emits no itemchange when the network fails and no last-UUID is stored", async () => {
354
- const network = makeNetwork();
355
- network.fetchPlaylist = vi
356
- .fn()
357
- .mockResolvedValue({ success: false, error: { kind: "network", code: 0, message: "down" } });
358
-
359
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
360
- const itemchange = vi.fn();
361
- player.addEventListener("itemchange", itemchange);
362
-
363
- await player.start();
364
-
365
- expect(itemchange).not.toHaveBeenCalled();
366
-
367
- player.stop();
368
- });
369
- });
370
-
371
- // ---------------------------------------------------------------------------
372
- // Item advancement
373
- // ---------------------------------------------------------------------------
374
-
375
- describe("item advancement", () => {
376
- it("advances to the next item after the duration timer fires", async () => {
377
- const playlist = makePlaylist({
378
- items: [
379
- makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg", duration: 5 }),
380
- makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg", duration: 3 }),
381
- ],
382
- });
383
-
384
- const network = makeNetwork(playlist);
385
- const cache = makeCache();
386
- const player = new SquareScreenPlayer(makeConfig(network, cache));
387
- const itemchange = vi.fn();
388
- player.addEventListener("itemchange", itemchange);
389
-
390
- await player.start();
391
- expect(itemchange).toHaveBeenCalledTimes(1);
392
- expect(itemchange.mock.calls[0][0].detail.item.uuid).toBe("item-1");
393
-
394
- await vi.advanceTimersByTimeAsync(5_000);
395
-
396
- expect(itemchange).toHaveBeenCalledTimes(2);
397
- expect(itemchange.mock.calls[1][0].detail.item.uuid).toBe("item-2");
398
-
399
- player.stop();
400
- });
401
-
402
- it("loops back to item 0 after the last item when loop is true", async () => {
403
- const playlist = makePlaylist({
404
- items: [
405
- makeItem({ uuid: "item-1", duration: 2 }),
406
- makeItem({ uuid: "item-2", duration: 2 }),
407
- ],
408
- strategy: { loop: true, shuffle: false, preloadCount: 0 },
409
- });
410
-
411
- const network = makeNetwork(playlist);
412
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
413
- const itemchange = vi.fn();
414
- player.addEventListener("itemchange", itemchange);
415
-
416
- await player.start();
417
- await vi.advanceTimersByTimeAsync(2_000); // → item-2
418
- await vi.advanceTimersByTimeAsync(2_000); // → item-1 (loop)
419
-
420
- expect(itemchange).toHaveBeenCalledTimes(3);
421
- expect(itemchange.mock.calls[2][0].detail.item.uuid).toBe("item-1");
422
-
423
- player.stop();
424
- });
425
-
426
- it("stops after the last item when loop is false", async () => {
427
- const playlist = makePlaylist({
428
- items: [
429
- makeItem({ uuid: "item-1", duration: 2 }),
430
- makeItem({ uuid: "item-2", duration: 2 }),
431
- ],
432
- strategy: { loop: false, shuffle: false, preloadCount: 0 },
433
- });
434
-
435
- const network = makeNetwork(playlist);
436
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
437
- const itemchange = vi.fn();
438
- player.addEventListener("itemchange", itemchange);
439
-
440
- await player.start();
441
- await vi.advanceTimersByTimeAsync(2_000); // → item-2
442
- await vi.advanceTimersByTimeAsync(2_000); // end — no more itemchange
443
-
444
- expect(itemchange).toHaveBeenCalledTimes(2);
445
- expect(itemchange.mock.calls[1][0].detail.item.uuid).toBe("item-2");
446
-
447
- player.stop();
448
- });
449
-
450
- it("reports a playback event to the network when advancing", async () => {
451
- const playlist = makePlaylist({
452
- items: [
453
- makeItem({ uuid: "item-1", duration: 3 }),
454
- makeItem({ uuid: "item-2", duration: 3 }),
455
- ],
456
- });
457
-
458
- const network = makeNetwork(playlist);
459
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
460
-
461
- await player.start();
462
- await vi.advanceTimersByTimeAsync(3_000);
463
-
464
- expect(network.reportPlaybackEvent).toHaveBeenCalledWith(
465
- expect.objectContaining({
466
- media_uuid: "item-1",
467
- playlist_uuid: "playlist-1",
468
- completed: true,
469
- }),
470
- );
471
-
472
- player.stop();
473
- });
474
-
475
- it("emits itemchange with the correct index and total", async () => {
476
- const playlist = makePlaylist({
477
- items: [
478
- makeItem({ uuid: "item-1", duration: 2 }),
479
- makeItem({ uuid: "item-2", duration: 2 }),
480
- makeItem({ uuid: "item-3", duration: 2 }),
481
- ],
482
- });
483
-
484
- const network = makeNetwork(playlist);
485
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
486
- const itemchange = vi.fn();
487
- player.addEventListener("itemchange", itemchange);
488
-
489
- await player.start();
490
- await vi.advanceTimersByTimeAsync(2_000);
491
-
492
- const detail = itemchange.mock.calls[1][0].detail;
493
- expect(detail.index).toBe(1);
494
- expect(detail.total).toBe(3);
495
-
496
- player.stop();
497
- });
498
- });
499
-
500
- // ---------------------------------------------------------------------------
501
- // Polling
502
- // ---------------------------------------------------------------------------
503
-
504
- describe("polling", () => {
505
- it("calls fetchPlaylist again after the poll interval", async () => {
506
- const network = makeNetwork();
507
- const cache = makeCache();
508
- const player = new SquareScreenPlayer(
509
- makeConfig(network, cache, { pollInterval: 5_000 }),
510
- );
511
-
512
- await player.start();
513
- expect(network.fetchPlaylist).toHaveBeenCalledTimes(1);
514
-
515
- await vi.advanceTimersByTimeAsync(5_000);
516
- expect(network.fetchPlaylist).toHaveBeenCalledTimes(2);
517
-
518
- player.stop();
519
- });
520
-
521
- it("emits playlistupdate but not a new itemchange when the same UUID is re-fetched", async () => {
522
- // Use a long duration so the item-advance timer doesn't fire before the poll.
523
- const playlist = makePlaylist({ items: [makeItem({ duration: 3600 })] });
524
- const network = makeNetwork(playlist);
525
- const cache = makeCache();
526
- const player = new SquareScreenPlayer(
527
- makeConfig(network, cache, { pollInterval: 1_000 }),
528
- );
529
-
530
- const itemchange = vi.fn();
531
- const playlistupdate = vi.fn();
532
- player.addEventListener("itemchange", itemchange);
533
- player.addEventListener("playlistupdate", playlistupdate);
534
-
535
- await player.start();
536
- expect(itemchange).toHaveBeenCalledTimes(1);
537
- expect(playlistupdate).toHaveBeenCalledTimes(1);
538
-
539
- await vi.advanceTimersByTimeAsync(1_000);
540
-
541
- expect(playlistupdate).toHaveBeenCalledTimes(2);
542
- expect(itemchange).toHaveBeenCalledTimes(1); // same UUID → no reset
543
-
544
- player.stop();
545
- });
546
-
547
- it("restarts playback from item 0 when a new playlist UUID is fetched", async () => {
548
- const first = makePlaylist({ uuid: "playlist-1", items: [makeItem({ duration: 3600 })] });
549
- const second = makePlaylist({
550
- uuid: "playlist-2",
551
- items: [makeItem({ uuid: "new-item" })],
552
- });
553
-
554
- const network = makeNetwork(first);
555
- const cache = makeCache();
556
- const player = new SquareScreenPlayer(
557
- makeConfig(network, cache, { pollInterval: 1_000 }),
558
- );
559
-
560
- const itemchange = vi.fn();
561
- player.addEventListener("itemchange", itemchange);
562
-
563
- await player.start();
564
- expect(itemchange).toHaveBeenCalledTimes(1);
565
-
566
- vi.mocked(network.fetchPlaylist).mockResolvedValue({ success: true, data: second });
567
- await vi.advanceTimersByTimeAsync(1_000);
568
-
569
- expect(itemchange).toHaveBeenCalledTimes(2);
570
- expect(itemchange.mock.calls[1][0].detail.item.uuid).toBe("new-item");
571
-
572
- player.stop();
573
- });
574
-
575
- it("refreshPlaylist() triggers an immediate fetch outside the poll interval", async () => {
576
- const network = makeNetwork();
577
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
578
-
579
- await player.start();
580
- expect(network.fetchPlaylist).toHaveBeenCalledTimes(1);
581
-
582
- player.refreshPlaylist();
583
- await Promise.resolve();
584
-
585
- expect(network.fetchPlaylist).toHaveBeenCalledTimes(2);
586
-
587
- player.stop();
588
- });
589
- });
590
-
591
- // ---------------------------------------------------------------------------
592
- // Emergency alerts
593
- // ---------------------------------------------------------------------------
594
-
595
- describe("emergency alerts", () => {
596
- it("does not emit emergencyalert when no alert is active", async () => {
597
- const network = makeNetwork();
598
- const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
599
- const emergencyalert = vi.fn();
600
- player.addEventListener("emergencyalert", emergencyalert);
601
-
602
- await player.start();
603
-
604
- expect(emergencyalert).not.toHaveBeenCalled();
605
-
606
- player.stop();
607
- });
608
-
609
- it("emits emergencyalert when an alert becomes active", async () => {
610
- const alert = makeAlert();
611
- const network = makeNetwork();
612
- network.checkForEmergencyAlert = vi
613
- .fn()
614
- .mockResolvedValueOnce({ success: true, data: null })
615
- .mockResolvedValue({ success: true, data: alert });
616
-
617
- const player = new SquareScreenPlayer(
618
- makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
619
- );
620
- const emergencyalert = vi.fn();
621
- player.addEventListener("emergencyalert", emergencyalert);
622
-
623
- await player.start();
624
- await vi.advanceTimersByTimeAsync(1_000);
625
-
626
- expect(emergencyalert).toHaveBeenCalledTimes(1);
627
- expect(emergencyalert.mock.calls[0][0].detail.alert.uuid).toBe("alert-1");
628
-
629
- player.stop();
630
- });
631
-
632
- it("emits emergencyalert with null when the alert is cleared", async () => {
633
- const alert = makeAlert();
634
- const network = makeNetwork();
635
- network.checkForEmergencyAlert = vi
636
- .fn()
637
- .mockResolvedValueOnce({ success: true, data: alert }) // startup → alert active
638
- .mockResolvedValue({ success: true, data: null }); // poll → cleared
639
-
640
- const player = new SquareScreenPlayer(
641
- makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
642
- );
643
- const emergencyalert = vi.fn();
644
- player.addEventListener("emergencyalert", emergencyalert);
645
-
646
- await player.start(); // fires alert
647
- await vi.advanceTimersByTimeAsync(1_000); // fires null (cleared)
648
-
649
- expect(emergencyalert).toHaveBeenCalledTimes(2);
650
- expect(emergencyalert.mock.calls[1][0].detail.alert).toBeNull();
651
-
652
- player.stop();
653
- });
654
-
655
- it("does not emit emergencyalert again when the same alert persists across polls", async () => {
656
- const alert = makeAlert();
657
- const network = makeNetwork();
658
- network.checkForEmergencyAlert = vi
659
- .fn()
660
- .mockResolvedValue({ success: true, data: alert });
661
-
662
- const player = new SquareScreenPlayer(
663
- makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
664
- );
665
- const emergencyalert = vi.fn();
666
- player.addEventListener("emergencyalert", emergencyalert);
667
-
668
- await player.start();
669
- await vi.advanceTimersByTimeAsync(3_000); // 3 more polls, same alert UUID
670
-
671
- expect(emergencyalert).toHaveBeenCalledTimes(1); // only the initial activation
672
-
673
- player.stop();
674
- });
675
-
676
- it("emits emergencyalert when the alert UUID changes", async () => {
677
- const first = makeAlert({ uuid: "alert-1" });
678
- const second = makeAlert({ uuid: "alert-2" });
679
- const network = makeNetwork();
680
- network.checkForEmergencyAlert = vi
681
- .fn()
682
- .mockResolvedValueOnce({ success: true, data: first })
683
- .mockResolvedValue({ success: true, data: second });
684
-
685
- const player = new SquareScreenPlayer(
686
- makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
687
- );
688
- const emergencyalert = vi.fn();
689
- player.addEventListener("emergencyalert", emergencyalert);
690
-
691
- await player.start();
692
- await vi.advanceTimersByTimeAsync(1_000);
693
-
694
- expect(emergencyalert).toHaveBeenCalledTimes(2);
695
- expect(emergencyalert.mock.calls[1][0].detail.alert.uuid).toBe("alert-2");
696
-
697
- player.stop();
698
- });
699
- });
700
-
701
- // ---------------------------------------------------------------------------
702
- // Preloading
703
- // ---------------------------------------------------------------------------
704
-
705
- describe("preloading", () => {
706
- it("does not trigger any background download when preloadCount is 0", async () => {
707
- const playlist = makePlaylist({
708
- items: [
709
- makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg" }),
710
- makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg" }),
711
- ],
712
- strategy: { loop: true, shuffle: false, preloadCount: 0 },
713
- });
714
-
715
- const network = makeNetwork(playlist);
716
- const cache = makeCache();
717
- const player = new SquareScreenPlayer(makeConfig(network, cache));
718
-
719
- await player.start();
720
-
721
- // Only item-1's URL should have been checked (for the current item).
722
- expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/1.jpg");
723
- expect(cache.getMediaUrl).not.toHaveBeenCalledWith("https://cdn.example.com/2.jpg");
724
-
725
- player.stop();
726
- });
727
-
728
- it("triggers a background download for the next item when preloadCount is 1", async () => {
729
- const playlist = makePlaylist({
730
- items: [
731
- makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg", duration: 5 }),
732
- makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg", duration: 5 }),
733
- ],
734
- strategy: { loop: true, shuffle: false, preloadCount: 1 },
735
- });
736
-
737
- const network = makeNetwork(playlist);
738
- const cache = makeCache();
739
- const player = new SquareScreenPlayer(makeConfig(network, cache));
740
-
741
- await player.start();
742
- // Let the fire-and-forget getMediaUrl call for item-2 resolve.
743
- await Promise.resolve();
744
-
745
- expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/2.jpg");
746
-
747
- player.stop();
748
- });
749
-
750
- it("preloads all items when preloadCount is undefined", async () => {
751
- const playlist = makePlaylist({
752
- items: [
753
- makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg", duration: 5 }),
754
- makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg", duration: 5 }),
755
- makeItem({ uuid: "item-3", url: "https://cdn.example.com/3.jpg", duration: 5 }),
756
- ],
757
- strategy: { loop: true, shuffle: false },
758
- });
759
-
760
- const network = makeNetwork(playlist);
761
- const cache = makeCache();
762
- const player = new SquareScreenPlayer(makeConfig(network, cache));
763
-
764
- await player.start();
765
- await Promise.resolve();
766
-
767
- expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/2.jpg");
768
- expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/3.jpg");
769
-
770
- player.stop();
771
- });
772
- });
773
-
774
- // ---------------------------------------------------------------------------
775
- // Heartbeat
776
- // ---------------------------------------------------------------------------
777
-
778
- describe("heartbeat", () => {
779
- it("calls healthCheck after the heartbeat interval", async () => {
780
- const network = makeNetwork();
781
- const player = new SquareScreenPlayer(
782
- makeConfig(network, makeCache(), { heartbeatInterval: 10_000 }),
783
- );
784
-
785
- await player.start();
786
- expect(network.healthCheck).not.toHaveBeenCalled();
787
-
788
- await vi.advanceTimersByTimeAsync(10_000);
789
-
790
- expect(network.healthCheck).toHaveBeenCalledWith(
791
- expect.objectContaining({ playerVersion: "1.0.0" }),
792
- );
793
-
794
- player.stop();
795
- });
796
- });
797
-
798
- // ---------------------------------------------------------------------------
799
- // stop()
800
- // ---------------------------------------------------------------------------
801
-
802
- describe("stop()", () => {
803
- it("clears all timers so no further fetches or advances occur", async () => {
804
- const network = makeNetwork();
805
- const player = new SquareScreenPlayer(
806
- makeConfig(network, makeCache(), { pollInterval: 5_000, heartbeatInterval: 10_000 }),
807
- );
808
-
809
- await player.start();
810
- player.stop();
811
-
812
- await vi.advanceTimersByTimeAsync(30_000);
813
-
814
- // Only the initial start() call — no subsequent polls or heartbeats.
815
- expect(network.fetchPlaylist).toHaveBeenCalledTimes(1);
816
- expect(network.healthCheck).not.toHaveBeenCalled();
817
- });
818
-
819
- it("prevents itemchange from firing when stop() is called before media resolves", async () => {
820
- const network = makeNetwork();
821
- const cache = makeCache();
822
-
823
- let resolveMedia!: (url: string | null) => void;
824
- vi.mocked(cache.getMediaUrl).mockReturnValue(
825
- new Promise<string | null>((res) => { resolveMedia = res; }),
826
- );
827
-
828
- const player = new SquareScreenPlayer(makeConfig(network, cache));
829
- const itemchange = vi.fn();
830
- player.addEventListener("itemchange", itemchange);
831
-
832
- const started = player.start();
833
- player.stop();
834
- resolveMedia(null);
835
- await started;
836
- await Promise.resolve();
837
-
838
- expect(itemchange).not.toHaveBeenCalled();
839
- });
840
- });