@glivion/square-screen-js-sdk 0.1.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.
- package/.github/workflows/build-js-sdk.yml +70 -0
- package/README.md +463 -0
- package/eslint.config.js +3 -0
- package/examples/react-app/README.md +73 -0
- package/examples/react-app/eslint.config.js +22 -0
- package/examples/react-app/index.html +13 -0
- package/examples/react-app/package-lock.json +2239 -0
- package/examples/react-app/package.json +31 -0
- package/examples/react-app/public/favicon.svg +1 -0
- package/examples/react-app/public/icons.svg +24 -0
- package/examples/react-app/src/App.css +184 -0
- package/examples/react-app/src/App.tsx +157 -0
- package/examples/react-app/src/EmergencyTicker.tsx +25 -0
- package/examples/react-app/src/HeadlessExample.tsx +66 -0
- package/examples/react-app/src/RendererExample.tsx +70 -0
- package/examples/react-app/src/assets/hero.png +0 -0
- package/examples/react-app/src/assets/react.svg +1 -0
- package/examples/react-app/src/assets/vite.svg +1 -0
- package/examples/react-app/src/index.css +183 -0
- package/examples/react-app/src/main.tsx +10 -0
- package/examples/react-app/src/mockNetworkDataSource.ts +116 -0
- package/examples/react-app/src/usePlayer.ts +71 -0
- package/examples/react-app/tsconfig.app.json +25 -0
- package/examples/react-app/tsconfig.json +7 -0
- package/examples/react-app/tsconfig.node.json +24 -0
- package/examples/react-app/vite.config.ts +7 -0
- package/examples/react-app/yarn.lock +1089 -0
- package/package.json +49 -0
- package/src/__tests__/cache/SquareScreenCache.test.ts +375 -0
- package/src/__tests__/network/NetworkClient.test.ts +217 -0
- package/src/__tests__/network/mappers.test.ts +163 -0
- package/src/__tests__/player/SquareScreenPlayer.test.ts +840 -0
- package/src/cache/SquareScreenCache.ts +154 -0
- package/src/constants.ts +9 -0
- package/src/core/types.ts +251 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +34 -0
- package/src/network/NetworkClient.ts +234 -0
- package/src/network/apiTypes.ts +89 -0
- package/src/network/mappers.ts +106 -0
- package/src/player/SquareScreenPlayer.ts +414 -0
- package/src/renderer/SquareScreenRenderer.ts +282 -0
- package/tsconfig.json +12 -0
- package/tsdown.config.ts +23 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw API response types — internal to the network layer.
|
|
3
|
+
*
|
|
4
|
+
* These mirror exactly what the server sends, including snake_case field names.
|
|
5
|
+
* Nothing in this file is exported from the SDK's public surface.
|
|
6
|
+
* Use the mapper functions in mappers.ts to convert these to core types.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Raw emergency object as returned inside the emergency response envelope. */
|
|
10
|
+
export interface ApiEmergency {
|
|
11
|
+
id: number;
|
|
12
|
+
uuid: string;
|
|
13
|
+
company_id: number;
|
|
14
|
+
title: string;
|
|
15
|
+
message: string;
|
|
16
|
+
background_color: string;
|
|
17
|
+
text_color: string;
|
|
18
|
+
target_scope: string;
|
|
19
|
+
is_active: boolean;
|
|
20
|
+
started_at: string;
|
|
21
|
+
ended_at?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Top-level shape of the GET /screen/emergency response. */
|
|
25
|
+
export interface ApiEmergencyResponse {
|
|
26
|
+
emergency: ApiEmergency | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Raw playlist item as returned by the API. */
|
|
30
|
+
export interface ApiPlaylistItem {
|
|
31
|
+
uuid: string;
|
|
32
|
+
name: string;
|
|
33
|
+
type: string;
|
|
34
|
+
url: string;
|
|
35
|
+
duration_seconds: number;
|
|
36
|
+
width?: number;
|
|
37
|
+
height?: number;
|
|
38
|
+
transition?: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
thumbnail?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Raw playback strategy as returned by the API when a playlist is active.
|
|
45
|
+
* When no playlist is scheduled the API returns an empty array — use
|
|
46
|
+
* Array.isArray() to detect that case before treating this as a strategy.
|
|
47
|
+
*/
|
|
48
|
+
export interface ApiPlaybackStrategy {
|
|
49
|
+
loop: boolean;
|
|
50
|
+
shuffle: boolean;
|
|
51
|
+
preload_count?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Raw schedule metadata attached to a playlist response. */
|
|
55
|
+
export interface ApiSchedule {
|
|
56
|
+
uuid: string;
|
|
57
|
+
name: string;
|
|
58
|
+
priority: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Raw playlist container metadata attached to a playlist response. */
|
|
62
|
+
export interface ApiPlaylistMeta {
|
|
63
|
+
uuid: string;
|
|
64
|
+
name: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Top-level shape of the GET /screen/now-playing response. */
|
|
68
|
+
export interface ApiPlaylistResponse {
|
|
69
|
+
items: ApiPlaylistItem[];
|
|
70
|
+
/** Empty array when no playlist is scheduled — not a real strategy object. */
|
|
71
|
+
strategy: ApiPlaybackStrategy | [];
|
|
72
|
+
schedule?: ApiSchedule;
|
|
73
|
+
playlist?: ApiPlaylistMeta;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Top-level shape of the POST /screen/heartbeat response. */
|
|
77
|
+
export interface ApiHeartbeatAck {
|
|
78
|
+
received: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Top-level shape of the POST /screen/heartbeat request. */
|
|
82
|
+
export interface ApiHeartbeatPayload {
|
|
83
|
+
cpu_usage?: number;
|
|
84
|
+
memory_usage?: number;
|
|
85
|
+
disk_usage?: number;
|
|
86
|
+
temperature?: number;
|
|
87
|
+
os_version?: string;
|
|
88
|
+
app_version: string;
|
|
89
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EmergencyAlert,
|
|
3
|
+
HeartbeatAck,
|
|
4
|
+
MediaType,
|
|
5
|
+
Playlist,
|
|
6
|
+
PlaylistItem,
|
|
7
|
+
PlaybackStrategy,
|
|
8
|
+
HeartbeatPayload,
|
|
9
|
+
TransitionType,
|
|
10
|
+
} from "../core/types";
|
|
11
|
+
import {
|
|
12
|
+
ApiEmergencyResponse,
|
|
13
|
+
ApiHeartbeatAck,
|
|
14
|
+
ApiHeartbeatPayload,
|
|
15
|
+
ApiPlaybackStrategy,
|
|
16
|
+
ApiPlaylistItem,
|
|
17
|
+
ApiPlaylistResponse,
|
|
18
|
+
} from "./apiTypes";
|
|
19
|
+
|
|
20
|
+
/** Maps a raw playlist item to the core PlaylistItem type. */
|
|
21
|
+
export function mapPlaylistItem(raw: ApiPlaylistItem): PlaylistItem {
|
|
22
|
+
return {
|
|
23
|
+
uuid: raw.uuid,
|
|
24
|
+
name: raw.name,
|
|
25
|
+
type: raw.type as MediaType,
|
|
26
|
+
url: raw.url,
|
|
27
|
+
duration: raw.duration_seconds,
|
|
28
|
+
...(raw.transition !== undefined && {
|
|
29
|
+
transition: raw.transition as TransitionType,
|
|
30
|
+
}),
|
|
31
|
+
...(raw.width !== undefined && { width: raw.width }),
|
|
32
|
+
...(raw.height !== undefined && { height: raw.height }),
|
|
33
|
+
...(raw.title !== undefined && { title: raw.title }),
|
|
34
|
+
...(raw.thumbnail !== undefined && { thumbnail: raw.thumbnail }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Maps the raw strategy field to a PlaybackStrategy.
|
|
40
|
+
* Returns undefined when the API sends an empty array instead of a real object.
|
|
41
|
+
*/
|
|
42
|
+
export function mapPlaybackStrategy(
|
|
43
|
+
raw: ApiPlaybackStrategy | [],
|
|
44
|
+
): PlaybackStrategy | undefined {
|
|
45
|
+
if (Array.isArray(raw)) return undefined;
|
|
46
|
+
return {
|
|
47
|
+
loop: raw.loop,
|
|
48
|
+
shuffle: raw.shuffle,
|
|
49
|
+
preloadCount: raw.preload_count ?? 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Maps a raw playlist response to the core Playlist type. */
|
|
54
|
+
export function mapPlaylistResponse(raw: ApiPlaylistResponse): Playlist {
|
|
55
|
+
return {
|
|
56
|
+
uuid: raw.playlist!.uuid,
|
|
57
|
+
items: raw.items.map(mapPlaylistItem),
|
|
58
|
+
strategy: mapPlaybackStrategy(raw.strategy),
|
|
59
|
+
schedule: raw.schedule,
|
|
60
|
+
playlistMeta: raw.playlist,
|
|
61
|
+
cachedAt: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Maps a raw emergency response to an EmergencyAlert.
|
|
67
|
+
* Returns null when no emergency is active.
|
|
68
|
+
*/
|
|
69
|
+
export function mapEmergencyResponse(
|
|
70
|
+
raw: ApiEmergencyResponse,
|
|
71
|
+
): EmergencyAlert | null {
|
|
72
|
+
if (raw.emergency === null) return null;
|
|
73
|
+
return {
|
|
74
|
+
id: raw.emergency.id,
|
|
75
|
+
uuid: raw.emergency.uuid,
|
|
76
|
+
companyId: raw.emergency.company_id,
|
|
77
|
+
title: raw.emergency.title,
|
|
78
|
+
message: raw.emergency.message,
|
|
79
|
+
backgroundColor: raw.emergency.background_color,
|
|
80
|
+
textColor: raw.emergency.text_color,
|
|
81
|
+
targetScope: raw.emergency.target_scope,
|
|
82
|
+
isActive: raw.emergency.is_active,
|
|
83
|
+
startedAt: raw.emergency.started_at,
|
|
84
|
+
...(raw.emergency.ended_at !== undefined && {
|
|
85
|
+
endedAt: raw.emergency.ended_at,
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Maps a raw heartbeat acknowledgement to the core HeartbeatAck type. */
|
|
91
|
+
export function mapHeartbeatAck(raw: ApiHeartbeatAck): HeartbeatAck {
|
|
92
|
+
return { received: raw.received };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function mapHeartbeatPayload(
|
|
96
|
+
raw: HeartbeatPayload,
|
|
97
|
+
): ApiHeartbeatPayload {
|
|
98
|
+
return {
|
|
99
|
+
cpu_usage: raw.cpuUsage,
|
|
100
|
+
memory_usage: raw.memoryUsage,
|
|
101
|
+
disk_usage: raw.diskUsage,
|
|
102
|
+
temperature: raw.temperature,
|
|
103
|
+
os_version: raw.osVersion,
|
|
104
|
+
app_version: raw.playerVersion,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { SQUARESCREEN_API_BASE_URL } from "../constants";
|
|
2
|
+
import { NetworkClient } from "../network/NetworkClient";
|
|
3
|
+
import {
|
|
4
|
+
DeviceStatus,
|
|
5
|
+
EmergencyAlert,
|
|
6
|
+
NetworkDataSource,
|
|
7
|
+
PlaybackEvent,
|
|
8
|
+
Playlist,
|
|
9
|
+
PlaylistItem,
|
|
10
|
+
SquareScreenCacheProvider,
|
|
11
|
+
} from "../core/types";
|
|
12
|
+
import { SquareScreenCache } from "../cache/SquareScreenCache";
|
|
13
|
+
|
|
14
|
+
const LAST_PLAYLIST_KEY = "square-screen:last-playlist-uuid";
|
|
15
|
+
|
|
16
|
+
export interface SquareScreenPlayerConfig {
|
|
17
|
+
/** Unique identifier for this device. */
|
|
18
|
+
deviceId: string;
|
|
19
|
+
/** Secret token used to authenticate this device. Never log or expose this value. */
|
|
20
|
+
deviceToken: string;
|
|
21
|
+
/** SDK version string sent on every heartbeat. */
|
|
22
|
+
version: string;
|
|
23
|
+
/** How long a cached playlist is considered fresh, in ms. Defaults to 5 minutes. */
|
|
24
|
+
ttl?: number;
|
|
25
|
+
/** How often to poll the API for playlist updates, in ms. Defaults to 30 seconds. */
|
|
26
|
+
pollInterval?: number;
|
|
27
|
+
/** How often to poll the API for emergency alerts, in ms. Defaults to 15 seconds. */
|
|
28
|
+
emergencyPollInterval?: number;
|
|
29
|
+
/** How often to send a heartbeat to the API, in ms. Defaults to 60 seconds. */
|
|
30
|
+
heartbeatInterval?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Override the network layer with a custom implementation — useful for testing
|
|
33
|
+
* or mocking without a real API. When provided, `deviceId` and `deviceToken`
|
|
34
|
+
* are ignored for network calls. Otherwise requests go to the production API
|
|
35
|
+
* URL defined by {@link SQUARESCREEN_API_BASE_URL}.
|
|
36
|
+
*/
|
|
37
|
+
networkDataSource?: NetworkDataSource;
|
|
38
|
+
/**
|
|
39
|
+
* Override the cache layer with a custom implementation. When omitted the default
|
|
40
|
+
* `SquareScreenCache` (IndexedDB + Cache API) is used. Pass your own implementation
|
|
41
|
+
* to integrate with an existing media storage layer.
|
|
42
|
+
*/
|
|
43
|
+
cacheProvider?: SquareScreenCacheProvider;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Typed detail payloads for each player event. */
|
|
47
|
+
export type PlayerEventMap = {
|
|
48
|
+
/** Fires when the current playlist item changes. */
|
|
49
|
+
itemchange: CustomEvent<{ item: PlaylistItem; index: number; total: number }>;
|
|
50
|
+
/** Fires when the device connectivity/sync status changes. */
|
|
51
|
+
statuschange: CustomEvent<{ status: DeviceStatus }>;
|
|
52
|
+
/** Fires when an emergency alert becomes active or is cleared. */
|
|
53
|
+
emergencyalert: CustomEvent<{ alert: EmergencyAlert | null }>;
|
|
54
|
+
/** Fires whenever the playlist is refreshed from the network or cache. */
|
|
55
|
+
playlistupdate: CustomEvent<{ playlist: Playlist }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Headless, framework-agnostic player that manages playlist fetching, caching,
|
|
60
|
+
* item advancement, preloading, polling, and emergency alerts.
|
|
61
|
+
*
|
|
62
|
+
* Extends `EventTarget` — use `addEventListener` / `removeEventListener` to
|
|
63
|
+
* react to player events. No DOM or rendering logic lives here.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const player = new SquareScreenPlayer({ deviceId, deviceToken, version: "1.0.0" });
|
|
67
|
+
* player.addEventListener("itemchange", ({ detail }) => render(detail.item));
|
|
68
|
+
* player.addEventListener("emergencyalert", ({ detail }) => showAlert(detail.alert));
|
|
69
|
+
* await player.start();
|
|
70
|
+
*/
|
|
71
|
+
export class SquareScreenPlayer extends EventTarget {
|
|
72
|
+
private readonly network: NetworkDataSource;
|
|
73
|
+
private readonly cache: SquareScreenCacheProvider;
|
|
74
|
+
private readonly config: Required<
|
|
75
|
+
Omit<SquareScreenPlayerConfig, "networkDataSource" | "cacheProvider">
|
|
76
|
+
>;
|
|
77
|
+
|
|
78
|
+
private playlist: Playlist | null = null;
|
|
79
|
+
private stopped = false;
|
|
80
|
+
private currentIndex = 0;
|
|
81
|
+
private status: DeviceStatus = "connecting";
|
|
82
|
+
private activeAlert: EmergencyAlert | null = null;
|
|
83
|
+
private currentItemStartTime: number | null = null;
|
|
84
|
+
|
|
85
|
+
private itemTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
private playlistPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
87
|
+
private emergencyPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
88
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(config: SquareScreenPlayerConfig) {
|
|
91
|
+
super();
|
|
92
|
+
|
|
93
|
+
if (!config.networkDataSource) {
|
|
94
|
+
if (!config.deviceId) throw new Error("SquareScreenPlayer: deviceId is required.");
|
|
95
|
+
if (!config.deviceToken) throw new Error("SquareScreenPlayer: deviceToken is required.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.network =
|
|
99
|
+
config.networkDataSource ??
|
|
100
|
+
new NetworkClient(SQUARESCREEN_API_BASE_URL, config.deviceId, config.deviceToken);
|
|
101
|
+
|
|
102
|
+
this.cache = config.cacheProvider ?? new SquareScreenCache();
|
|
103
|
+
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- omit injected deps from persisted config snapshot
|
|
105
|
+
const { networkDataSource: _n, cacheProvider: _c, ...rest } = config;
|
|
106
|
+
this.config = {
|
|
107
|
+
ttl: 5 * 60 * 1000,
|
|
108
|
+
pollInterval: 30_000,
|
|
109
|
+
emergencyPollInterval: 15_000,
|
|
110
|
+
heartbeatInterval: 60_000,
|
|
111
|
+
...rest,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The playlist item currently being displayed, or null if no playlist is loaded. */
|
|
116
|
+
get currentItem(): PlaylistItem | null {
|
|
117
|
+
return this.playlist?.items[this.currentIndex] ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Starts the player: fetches the playlist, begins playback, and starts polling
|
|
122
|
+
* and heartbeat intervals. Safe to await — resolves once the first playlist load
|
|
123
|
+
* attempt completes (whether from network or cache fallback).
|
|
124
|
+
*/
|
|
125
|
+
async start(): Promise<void> {
|
|
126
|
+
this.stopped = false;
|
|
127
|
+
this.setStatus("connecting");
|
|
128
|
+
await this.loadPlaylist();
|
|
129
|
+
await this.checkEmergencyAlert();
|
|
130
|
+
this.startPolling();
|
|
131
|
+
this.startHeartbeat();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Stops all timers and clears internal state. Call this when tearing down the player. */
|
|
135
|
+
stop(): void {
|
|
136
|
+
if (this.itemTimer) clearTimeout(this.itemTimer);
|
|
137
|
+
if (this.playlistPollTimer) clearInterval(this.playlistPollTimer);
|
|
138
|
+
if (this.emergencyPollTimer) clearInterval(this.emergencyPollTimer);
|
|
139
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
140
|
+
this.itemTimer = null;
|
|
141
|
+
this.playlistPollTimer = null;
|
|
142
|
+
this.emergencyPollTimer = null;
|
|
143
|
+
this.heartbeatTimer = null;
|
|
144
|
+
// Prevent in-flight loadPlaylist/applyPlaylist/scheduleItem continuations
|
|
145
|
+
// from dispatching events or setting new timers after teardown.
|
|
146
|
+
this.stopped = true;
|
|
147
|
+
this.playlist = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Playlist loading
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Network-first playlist load. On success the playlist is saved to cache and
|
|
156
|
+
* applied if the UUID changed. On failure the player falls back to the last
|
|
157
|
+
* known playlist UUID stored in `localStorage`, loading it from cache with
|
|
158
|
+
* `allowStale = true` so it is served even after its TTL has expired.
|
|
159
|
+
*/
|
|
160
|
+
private async loadPlaylist(): Promise<void> {
|
|
161
|
+
const result = await this.network.fetchPlaylist();
|
|
162
|
+
|
|
163
|
+
if (result.success) {
|
|
164
|
+
this.setStatus("online");
|
|
165
|
+
const playlist = result.data;
|
|
166
|
+
localStorage.setItem(LAST_PLAYLIST_KEY, playlist.uuid);
|
|
167
|
+
await this.cache.savePlaylist(playlist, this.config.ttl);
|
|
168
|
+
|
|
169
|
+
const isNewPlaylist = !this.playlist || this.playlist.uuid !== playlist.uuid;
|
|
170
|
+
if (isNewPlaylist) {
|
|
171
|
+
await this.applyPlaylist(playlist);
|
|
172
|
+
} else {
|
|
173
|
+
this.dispatch("playlistupdate", { playlist });
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
this.setStatus("offline");
|
|
177
|
+
if (!this.playlist) {
|
|
178
|
+
const lastUuid = localStorage.getItem(LAST_PLAYLIST_KEY);
|
|
179
|
+
if (lastUuid) {
|
|
180
|
+
const cached = await this.cache.getPlaylist(lastUuid, true);
|
|
181
|
+
if (cached) await this.applyPlaylist(cached);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Activates a playlist: optionally shuffles the items, resets the index to 0,
|
|
189
|
+
* emits `playlistupdate`, and kicks off the first `scheduleItem` call.
|
|
190
|
+
* Bails out immediately if `stop()` has been called.
|
|
191
|
+
*/
|
|
192
|
+
private async applyPlaylist(playlist: Playlist): Promise<void> {
|
|
193
|
+
if (this.stopped) return;
|
|
194
|
+
const items = playlist.strategy?.shuffle
|
|
195
|
+
? [...playlist.items].sort(() => Math.random() - 0.5)
|
|
196
|
+
: playlist.items;
|
|
197
|
+
|
|
198
|
+
this.playlist = { ...playlist, items };
|
|
199
|
+
this.currentIndex = 0;
|
|
200
|
+
this.dispatch("playlistupdate", { playlist: this.playlist });
|
|
201
|
+
this.scheduleItem();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
refreshPlaylist(): void {
|
|
205
|
+
this.loadPlaylist();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Playback
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Emits `itemchange` immediately with either the cached blob URL or the raw
|
|
214
|
+
* HTTPS URL, then arms the advancement timer. Never blocks on a network fetch —
|
|
215
|
+
* if the media isn't cached yet the browser loads it directly while
|
|
216
|
+
* `preloadNext` / `downloadInBackground` cache it for the next cycle.
|
|
217
|
+
*
|
|
218
|
+
* Guards against `stop()` being called while awaiting the cache lookup.
|
|
219
|
+
*/
|
|
220
|
+
private async scheduleItem(): Promise<void> {
|
|
221
|
+
if (this.itemTimer) clearTimeout(this.itemTimer);
|
|
222
|
+
|
|
223
|
+
const item = this.currentItem;
|
|
224
|
+
if (!item || !this.playlist) return;
|
|
225
|
+
|
|
226
|
+
this.currentItemStartTime = Date.now();
|
|
227
|
+
|
|
228
|
+
// Cache lookup only — never fetch inline so the timer isn't delayed.
|
|
229
|
+
const url = (await this.cache.getMediaUrl(item.url)) ?? item.url;
|
|
230
|
+
if (!this.playlist) return; // stopped while awaiting
|
|
231
|
+
|
|
232
|
+
this.dispatch("itemchange", {
|
|
233
|
+
item: { ...item, url },
|
|
234
|
+
index: this.currentIndex,
|
|
235
|
+
total: this.playlist.items.length,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// If the current item wasn't in cache, download it now for the next cycle.
|
|
239
|
+
if (url === item.url) this.downloadInBackground(item.url);
|
|
240
|
+
this.preloadNext();
|
|
241
|
+
const elapsed = Date.now() - this.currentItemStartTime!;
|
|
242
|
+
const remaining = Math.max(0, item.duration * 1000 - elapsed);
|
|
243
|
+
this.itemTimer = setTimeout(() => this.advance(), remaining);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Schedules background downloads for upcoming items while the current one
|
|
248
|
+
* is playing, so subsequent transitions can serve blob URLs immediately.
|
|
249
|
+
*
|
|
250
|
+
* - `preloadCount: 0` — disabled; nothing is downloaded.
|
|
251
|
+
* - `preloadCount: N` — downloads the next N items ahead (wraps at end).
|
|
252
|
+
* - `preloadCount` absent — downloads all items in the playlist up front.
|
|
253
|
+
*/
|
|
254
|
+
private preloadNext(): void {
|
|
255
|
+
if (!this.playlist) return;
|
|
256
|
+
const preloadCount = this.playlist.strategy?.preloadCount;
|
|
257
|
+
if (preloadCount === 0) return;
|
|
258
|
+
const count = preloadCount ?? this.playlist.items.length;
|
|
259
|
+
|
|
260
|
+
for (let i = 1; i <= count; i++) {
|
|
261
|
+
const idx = (this.currentIndex + i) % this.playlist.items.length;
|
|
262
|
+
this.downloadInBackground(this.playlist.items[idx].url);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Fire-and-forget download. Checks the cache first; if the media is already
|
|
268
|
+
* present the call is a no-op. Errors are silently swallowed — a failed
|
|
269
|
+
* download is not fatal; the raw URL will be used until the next successful download.
|
|
270
|
+
*/
|
|
271
|
+
private downloadInBackground(url: string): void {
|
|
272
|
+
this.cache
|
|
273
|
+
.getMediaUrl(url)
|
|
274
|
+
.then((cached) => {
|
|
275
|
+
if (cached || !this.playlist) return;
|
|
276
|
+
return fetch(url, { mode: "cors" })
|
|
277
|
+
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
278
|
+
.then((blob) => this.cache.saveMedia(url, blob));
|
|
279
|
+
})
|
|
280
|
+
.catch(() => {});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Moves to the next item. Reports the completed playback event, then either
|
|
285
|
+
* wraps back to index 0 (when `loop` is true or absent) or halts when
|
|
286
|
+
* `loop: false` and the last item has finished.
|
|
287
|
+
*/
|
|
288
|
+
private advance(): void {
|
|
289
|
+
if (!this.playlist) return;
|
|
290
|
+
const total = this.playlist.items.length;
|
|
291
|
+
const next = this.currentIndex + 1;
|
|
292
|
+
|
|
293
|
+
this.reportPlaybackEvent();
|
|
294
|
+
|
|
295
|
+
if (next >= total) {
|
|
296
|
+
if (this.playlist.strategy?.loop ?? true) {
|
|
297
|
+
this.currentIndex = 0;
|
|
298
|
+
} else {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
this.currentIndex = next;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.scheduleItem();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Sends a proof-of-play event to the server. Fire-and-forget; failures are not surfaced. */
|
|
309
|
+
private async reportPlaybackEvent(): Promise<void> {
|
|
310
|
+
if (!this.currentItemStartTime) return;
|
|
311
|
+
if (!this.currentItem || !this.playlist) return;
|
|
312
|
+
const endedAt = Date.now();
|
|
313
|
+
const event: PlaybackEvent = {
|
|
314
|
+
media_uuid: this.currentItem.uuid,
|
|
315
|
+
playlist_uuid: this.playlist.uuid,
|
|
316
|
+
schedule_uuid: this.playlist.schedule?.uuid ?? "",
|
|
317
|
+
started_at: new Date(this.currentItemStartTime).toISOString(),
|
|
318
|
+
ended_at: new Date(endedAt).toISOString(),
|
|
319
|
+
duration_seconds: Math.round((endedAt - this.currentItemStartTime) / 1000),
|
|
320
|
+
completed: true,
|
|
321
|
+
};
|
|
322
|
+
this.network.reportPlaybackEvent(event);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Polling and heartbeat
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** Arms the playlist-refresh and emergency-alert polling intervals. */
|
|
330
|
+
private startPolling(): void {
|
|
331
|
+
this.playlistPollTimer = setInterval(
|
|
332
|
+
() => this.loadPlaylist(),
|
|
333
|
+
this.config.pollInterval,
|
|
334
|
+
);
|
|
335
|
+
this.emergencyPollTimer = setInterval(
|
|
336
|
+
() => this.checkEmergencyAlert(),
|
|
337
|
+
this.config.emergencyPollInterval,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Arms the periodic heartbeat that keeps the device registration alive. */
|
|
342
|
+
private startHeartbeat(): void {
|
|
343
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
344
|
+
await this.network.healthCheck({ playerVersion: this.config.version });
|
|
345
|
+
}, this.config.heartbeatInterval);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Polls the server for an active emergency alert. Emits `emergencyalert` only
|
|
350
|
+
* when the state actually changes (alert activated, cleared, or replaced by a
|
|
351
|
+
* different UUID) to avoid redundant re-renders on the consumer side.
|
|
352
|
+
*/
|
|
353
|
+
private async checkEmergencyAlert(): Promise<void> {
|
|
354
|
+
const result = await this.network.checkForEmergencyAlert();
|
|
355
|
+
if (!result.success) return;
|
|
356
|
+
|
|
357
|
+
const incoming = result.data;
|
|
358
|
+
const wasActive = this.activeAlert !== null;
|
|
359
|
+
const isActive = incoming !== null;
|
|
360
|
+
const alertChanged = isActive && incoming!.uuid !== this.activeAlert?.uuid;
|
|
361
|
+
|
|
362
|
+
if (wasActive !== isActive || alertChanged) {
|
|
363
|
+
this.activeAlert = incoming;
|
|
364
|
+
this.dispatch("emergencyalert", { alert: incoming });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Helpers
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
private setStatus(status: DeviceStatus): void {
|
|
373
|
+
if (this.status === status) return;
|
|
374
|
+
this.status = status;
|
|
375
|
+
this.dispatch("statuschange", { status });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private dispatch<K extends keyof PlayerEventMap>(
|
|
379
|
+
type: K,
|
|
380
|
+
detail: PlayerEventMap[K] extends CustomEvent<infer D> ? D : never,
|
|
381
|
+
): void {
|
|
382
|
+
this.dispatchEvent(new CustomEvent(type, { detail }));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
addEventListener<K extends keyof PlayerEventMap>(
|
|
386
|
+
type: K,
|
|
387
|
+
listener: (event: PlayerEventMap[K]) => void,
|
|
388
|
+
options?: boolean | AddEventListenerOptions,
|
|
389
|
+
): void;
|
|
390
|
+
addEventListener(
|
|
391
|
+
type: string,
|
|
392
|
+
listener: EventListenerOrEventListenerObject,
|
|
393
|
+
options?: boolean | AddEventListenerOptions,
|
|
394
|
+
): void;
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
396
|
+
addEventListener(type: string, listener: any, options?: any): void {
|
|
397
|
+
super.addEventListener(type, listener, options);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
removeEventListener<K extends keyof PlayerEventMap>(
|
|
401
|
+
type: K,
|
|
402
|
+
listener: (event: PlayerEventMap[K]) => void,
|
|
403
|
+
options?: boolean | EventListenerOptions,
|
|
404
|
+
): void;
|
|
405
|
+
removeEventListener(
|
|
406
|
+
type: string,
|
|
407
|
+
listener: EventListenerOrEventListenerObject,
|
|
408
|
+
options?: boolean | EventListenerOptions,
|
|
409
|
+
): void;
|
|
410
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
411
|
+
removeEventListener(type: string, listener: any, options?: any): void {
|
|
412
|
+
super.removeEventListener(type, listener, options);
|
|
413
|
+
}
|
|
414
|
+
}
|