@clockworkdog/cogs-client 3.0.0-alpha.2 → 3.0.0-alpha.3

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.
@@ -0,0 +1,381 @@
1
+ import { ActiveVideoClipState } from './types/VideoState';
2
+ export default class VideoPlayer {
3
+ constructor(
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ cogsConnection, parentElement = document.body) {
6
+ this.cogsConnection = cogsConnection;
7
+ this.eventTarget = new EventTarget();
8
+ this.globalVolume = 1;
9
+ this.videoClipPlayers = {};
10
+ this.sinkId = '';
11
+ this.parentElement = parentElement;
12
+ // Send the current status of each clip to COGS
13
+ this.addEventListener('videoClipState', ({ detail }) => {
14
+ cogsConnection.sendMediaClipState(detail);
15
+ });
16
+ // Listen for video control messages
17
+ cogsConnection.addEventListener('message', ({ message }) => {
18
+ switch (message.type) {
19
+ case 'media_config_update':
20
+ this.setGlobalVolume(message.globalVolume);
21
+ if (message.audioOutput !== undefined) {
22
+ const sinkId = cogsConnection.getAudioSinkId(message.audioOutput);
23
+ this.setAudioSink(sinkId ?? '');
24
+ }
25
+ this.updateConfig(message.files);
26
+ break;
27
+ case 'video_play':
28
+ this.playVideoClip(message.file, {
29
+ playId: message.playId,
30
+ volume: message.volume,
31
+ loop: Boolean(message.loop),
32
+ fit: message.fit,
33
+ });
34
+ break;
35
+ case 'video_pause':
36
+ this.pauseVideoClip();
37
+ break;
38
+ case 'video_stop':
39
+ this.stopVideoClip();
40
+ break;
41
+ case 'video_set_volume':
42
+ this.setVideoClipVolume({ volume: message.volume });
43
+ break;
44
+ case 'video_set_fit':
45
+ this.setVideoClipFit({ fit: message.fit });
46
+ break;
47
+ }
48
+ });
49
+ // On connection, send the current playing state of all clips
50
+ // (Usually empty unless websocket is reconnecting)
51
+ const sendInitialClipStates = () => {
52
+ const files = Object.entries(this.videoClipPlayers).map(([file, player]) => {
53
+ const status = !player.videoElement.paused
54
+ ? 'playing'
55
+ : player.videoElement.currentTime === 0 || player.videoElement.currentTime === player.videoElement.duration
56
+ ? 'paused'
57
+ : 'stopped';
58
+ return [file, status];
59
+ });
60
+ cogsConnection.sendInitialMediaClipStates({ mediaType: 'video', files });
61
+ };
62
+ cogsConnection.addEventListener('open', sendInitialClipStates);
63
+ sendInitialClipStates();
64
+ }
65
+ setParentElement(parentElement) {
66
+ this.parentElement = parentElement;
67
+ Object.values(this.videoClipPlayers).forEach((clipPlayer) => {
68
+ parentElement.appendChild(clipPlayer.videoElement);
69
+ });
70
+ }
71
+ resetParentElement() {
72
+ this.setParentElement(document.body);
73
+ }
74
+ setGlobalVolume(globalVolume) {
75
+ Object.values(this.videoClipPlayers).forEach((clipPlayer) => {
76
+ setVideoElementVolume(clipPlayer.videoElement, clipPlayer.volume * globalVolume);
77
+ });
78
+ this.globalVolume = globalVolume;
79
+ this.notifyStateListeners();
80
+ }
81
+ playVideoClip(path, { playId, volume, loop, fit }) {
82
+ if (!this.videoClipPlayers[path]) {
83
+ this.videoClipPlayers[path] = this.createClipPlayer(path, { preload: 'none', ephemeral: true, fit });
84
+ }
85
+ // Check if there's already a pending clip, which has now been superseded and abort the play operation
86
+ if (this.pendingClip) {
87
+ this.updateVideoClipPlayer(this.pendingClip.path, (clipPlayer) => {
88
+ clipPlayer.videoElement.load(); // Resets the media element
89
+ return clipPlayer;
90
+ });
91
+ }
92
+ // New pending clip is video being requested
93
+ if (this.activeClip?.path !== path) {
94
+ this.pendingClip = { path, playId, actionOncePlaying: 'play' };
95
+ }
96
+ // Setup and play the pending clip's player
97
+ this.updateVideoClipPlayer(path, (clipPlayer) => {
98
+ clipPlayer.volume = volume;
99
+ setVideoElementVolume(clipPlayer.videoElement, volume * this.globalVolume);
100
+ clipPlayer.videoElement.loop = loop;
101
+ clipPlayer.videoElement.style.objectFit = fit;
102
+ if (clipPlayer.videoElement.currentTime === clipPlayer.videoElement.duration) {
103
+ clipPlayer.videoElement.currentTime = 0;
104
+ }
105
+ clipPlayer.videoElement.play();
106
+ // Display right away if there's currently no active clip
107
+ if (!this.activeClip) {
108
+ clipPlayer.videoElement.style.display = 'block';
109
+ }
110
+ return clipPlayer;
111
+ });
112
+ }
113
+ pauseVideoClip() {
114
+ // Pending clip should be paused when it loads and becomes active
115
+ if (this.pendingClip) {
116
+ this.pendingClip.actionOncePlaying = 'pause';
117
+ }
118
+ // Pause the currently active clip
119
+ if (this.activeClip) {
120
+ const { playId, path } = this.activeClip;
121
+ this.updateVideoClipPlayer(path, (clipPlayer) => {
122
+ clipPlayer.videoElement?.pause();
123
+ return clipPlayer;
124
+ });
125
+ this.notifyClipStateListeners(playId, path, 'paused');
126
+ }
127
+ }
128
+ stopVideoClip() {
129
+ // Pending clip should be stopped when it loads and becomes active
130
+ if (this.pendingClip) {
131
+ this.pendingClip.actionOncePlaying = 'stop';
132
+ }
133
+ // Stop the currently active clip
134
+ if (this.activeClip) {
135
+ this.handleStoppedClip(this.activeClip.path);
136
+ }
137
+ }
138
+ setVideoClipVolume({ volume }) {
139
+ // If there is a pending clip, this is latest to have been played so update its volume
140
+ const clipToUpdate = this.pendingClip ?? this.activeClip ?? undefined;
141
+ if (!clipToUpdate) {
142
+ return;
143
+ }
144
+ if (!(volume >= 0 && volume <= 1)) {
145
+ console.warn('Invalid volume', volume);
146
+ return;
147
+ }
148
+ this.updateVideoClipPlayer(clipToUpdate.path, (clipPlayer) => {
149
+ if (clipPlayer.videoElement) {
150
+ clipPlayer.volume = volume;
151
+ setVideoElementVolume(clipPlayer.videoElement, volume * this.globalVolume);
152
+ }
153
+ return clipPlayer;
154
+ });
155
+ }
156
+ setVideoClipFit({ fit }) {
157
+ // If there is a pending clip, this is latest to have been played so update its fit
158
+ const clipToUpdate = this.pendingClip ?? this.activeClip ?? undefined;
159
+ if (!clipToUpdate) {
160
+ return;
161
+ }
162
+ this.updateVideoClipPlayer(clipToUpdate.path, (clipPlayer) => {
163
+ if (clipPlayer.videoElement) {
164
+ clipPlayer.videoElement.style.objectFit = fit;
165
+ }
166
+ return clipPlayer;
167
+ });
168
+ }
169
+ handleStoppedClip(path) {
170
+ if (!this.activeClip || this.activeClip.path !== path) {
171
+ return;
172
+ }
173
+ const playId = this.activeClip.playId;
174
+ // Once an ephemeral clip stops, cleanup and remove the player
175
+ if (this.videoClipPlayers[this.activeClip.path]?.config.ephemeral) {
176
+ this.unloadClip(path);
177
+ }
178
+ this.activeClip = undefined;
179
+ this.updateVideoClipPlayer(path, (clipPlayer) => {
180
+ clipPlayer.videoElement.pause();
181
+ clipPlayer.videoElement.currentTime = 0;
182
+ clipPlayer.videoElement.style.display = 'none';
183
+ return clipPlayer;
184
+ });
185
+ this.notifyClipStateListeners(playId, path, 'stopped');
186
+ }
187
+ updateVideoClipPlayer(path, update) {
188
+ if (this.videoClipPlayers[path]) {
189
+ const newPlayer = update(this.videoClipPlayers[path]);
190
+ if (newPlayer) {
191
+ this.videoClipPlayers[path] = newPlayer;
192
+ }
193
+ else {
194
+ delete this.videoClipPlayers[path];
195
+ }
196
+ this.notifyStateListeners();
197
+ }
198
+ }
199
+ setAudioSink(sinkId) {
200
+ for (const clipPlayer of Object.values(this.videoClipPlayers)) {
201
+ setPlayerSinkId(clipPlayer, sinkId);
202
+ }
203
+ this.sinkId = sinkId;
204
+ }
205
+ updateConfig(newPaths) {
206
+ const newVideoPaths = Object.fromEntries(Object.entries(newPaths).filter(([, { type }]) => type === 'video' || !type));
207
+ const previousClipPlayers = this.videoClipPlayers;
208
+ this.videoClipPlayers = (() => {
209
+ const clipPlayers = { ...previousClipPlayers };
210
+ const removedClips = Object.keys(previousClipPlayers).filter((previousPath) => !(previousPath in newVideoPaths));
211
+ removedClips.forEach((path) => {
212
+ if (this.activeClip?.path === path && previousClipPlayers[path]?.config.ephemeral === false) {
213
+ this.updateVideoClipPlayer(path, (player) => {
214
+ player.config = { ...player.config, ephemeral: true };
215
+ return player;
216
+ });
217
+ }
218
+ else {
219
+ this.unloadClip(path);
220
+ delete clipPlayers[path];
221
+ }
222
+ });
223
+ const addedClips = Object.entries(newVideoPaths).filter(([newFile]) => !previousClipPlayers[newFile]);
224
+ addedClips.forEach(([path, config]) => {
225
+ clipPlayers[path] = this.createClipPlayer(path, { ...config, preload: preloadString(config.preload), ephemeral: false, fit: 'contain' });
226
+ });
227
+ const updatedClips = Object.entries(previousClipPlayers).filter(([previousPath]) => previousPath in newVideoPaths);
228
+ updatedClips.forEach(([path, previousClipPlayer]) => {
229
+ if (previousClipPlayer.config.preload !== newVideoPaths[path].preload) {
230
+ this.updateVideoClipPlayer(path, (player) => {
231
+ player.config = {
232
+ ...player.config,
233
+ preload: preloadString(newVideoPaths[path].preload),
234
+ ephemeral: false,
235
+ };
236
+ player.videoElement.preload = player.config.preload;
237
+ return player;
238
+ });
239
+ }
240
+ });
241
+ return clipPlayers;
242
+ })();
243
+ this.notifyStateListeners();
244
+ }
245
+ notifyStateListeners() {
246
+ const VideoState = {
247
+ globalVolume: this.globalVolume,
248
+ isPlaying: this.activeClip ? !this.videoClipPlayers[this.activeClip.path].videoElement?.paused : false,
249
+ clips: { ...this.videoClipPlayers },
250
+ activeClip: this.activeClip
251
+ ? {
252
+ path: this.activeClip.path,
253
+ state: !this.videoClipPlayers[this.activeClip.path].videoElement?.paused ? ActiveVideoClipState.Playing : ActiveVideoClipState.Paused,
254
+ loop: this.videoClipPlayers[this.activeClip.path].videoElement?.loop ?? false,
255
+ volume: this.videoClipPlayers[this.activeClip.path].videoElement?.muted
256
+ ? 0
257
+ : (this.videoClipPlayers[this.activeClip.path].videoElement?.volume ?? 0),
258
+ }
259
+ : undefined,
260
+ };
261
+ this.dispatchEvent('state', VideoState);
262
+ }
263
+ notifyClipStateListeners(playId, file, status) {
264
+ this.dispatchEvent('videoClipState', { playId, mediaType: 'video', file, status });
265
+ }
266
+ // Type-safe wrapper around EventTarget
267
+ addEventListener(type, listener, options) {
268
+ this.eventTarget.addEventListener(type, listener, options);
269
+ }
270
+ removeEventListener(type, listener, options) {
271
+ this.eventTarget.removeEventListener(type, listener, options);
272
+ }
273
+ dispatchEvent(type, detail) {
274
+ this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
275
+ }
276
+ createVideoElement(path, config, { volume }) {
277
+ const videoElement = document.createElement('video');
278
+ videoElement.playsInline = true; // Required for iOS
279
+ videoElement.src = this.cogsConnection.getAssetUrl(path);
280
+ videoElement.autoplay = false;
281
+ videoElement.loop = false;
282
+ setVideoElementVolume(videoElement, volume * this.globalVolume);
283
+ videoElement.preload = config.preload;
284
+ videoElement.addEventListener('playing', () => {
285
+ // If the clip is still the pending one when it actually start playing, then ensure it is in the correct state
286
+ if (this.pendingClip?.path === path) {
287
+ switch (this.pendingClip.actionOncePlaying) {
288
+ case 'play': {
289
+ // Continue playing, show the video element, and notify listeners
290
+ videoElement.style.display = 'block';
291
+ this.notifyClipStateListeners(this.pendingClip.playId, path, 'playing');
292
+ break;
293
+ }
294
+ case 'pause': {
295
+ // Pause playback, show the video element, and notify listeners
296
+ videoElement.style.display = 'block';
297
+ videoElement.pause();
298
+ this.notifyClipStateListeners(this.pendingClip.playId, path, 'paused');
299
+ break;
300
+ }
301
+ case 'stop': {
302
+ // Pause playback, leave the video element hidden, and notify listeners
303
+ videoElement.pause();
304
+ this.notifyClipStateListeners(this.pendingClip.playId, path, 'stopped');
305
+ break;
306
+ }
307
+ }
308
+ // If there was a previously active clip, then stop it
309
+ if (this.activeClip) {
310
+ this.handleStoppedClip(this.activeClip.path);
311
+ }
312
+ this.activeClip = this.pendingClip;
313
+ this.pendingClip = undefined;
314
+ }
315
+ else if (this.activeClip?.path === path) {
316
+ // If we were the active clip then just notify listeners that we are now playing
317
+ this.notifyClipStateListeners(this.activeClip.playId, path, 'playing');
318
+ }
319
+ else {
320
+ // Otherwise it shouldn't be playing, like because another clip became pending before we loaded,
321
+ // so we pause and don't show or notify listeners
322
+ videoElement.pause();
323
+ }
324
+ });
325
+ videoElement.addEventListener('ended', () => {
326
+ // Ignore if there's a pending clip, as once that starts playing the active clip will be stopped
327
+ // Also ignore if the video is set to loop
328
+ if (!this.pendingClip && !videoElement.loop) {
329
+ this.handleStoppedClip(path);
330
+ }
331
+ });
332
+ videoElement.style.position = 'absolute';
333
+ videoElement.style.top = '0';
334
+ videoElement.style.left = '0';
335
+ videoElement.style.width = '100%';
336
+ videoElement.style.height = '100%';
337
+ videoElement.style.objectFit = config.fit;
338
+ videoElement.style.display = 'none';
339
+ this.parentElement.appendChild(videoElement);
340
+ return videoElement;
341
+ }
342
+ createClipPlayer(path, config) {
343
+ const volume = 1;
344
+ const player = {
345
+ config,
346
+ videoElement: this.createVideoElement(path, config, { volume }),
347
+ volume,
348
+ };
349
+ setPlayerSinkId(player, this.sinkId);
350
+ return player;
351
+ }
352
+ unloadClip(path) {
353
+ if (this.activeClip?.path === path) {
354
+ const playId = this.activeClip.playId;
355
+ this.activeClip = undefined;
356
+ this.notifyClipStateListeners(playId, path, 'stopped');
357
+ }
358
+ this.videoClipPlayers[path]?.videoElement.remove();
359
+ this.updateVideoClipPlayer(path, () => null);
360
+ }
361
+ }
362
+ function preloadString(preload) {
363
+ return typeof preload === 'string' ? preload : preload ? 'auto' : 'none';
364
+ }
365
+ function setPlayerSinkId(player, sinkId) {
366
+ if (sinkId === undefined) {
367
+ return;
368
+ }
369
+ if (typeof player.videoElement.setSinkId === 'function') {
370
+ player.videoElement.setSinkId(sinkId);
371
+ }
372
+ }
373
+ /**
374
+ * Set video volume
375
+ *
376
+ * This doesn't work on iOS (volume is read-only) so at least mute it if the volume is zero
377
+ */
378
+ function setVideoElementVolume(videoElement, volume) {
379
+ videoElement.volume = volume;
380
+ videoElement.muted = volume === 0;
381
+ }