@clockworkdog/cogs-client 3.0.0-alpha.9 → 3.0.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/README.md +20 -9
- package/dist/CogsConnection.d.ts +0 -4
- package/dist/CogsConnection.js +0 -10
- package/dist/browser/index.mjs +1901 -2707
- package/dist/browser/index.umd.js +13 -13
- package/dist/index.d.ts +1 -5
- package/dist/index.js +1 -3
- package/dist/state-based/MediaClipManager.d.ts +66 -0
- package/dist/state-based/MediaClipManager.js +420 -0
- package/dist/state-based/MediaPreloader.d.ts +14 -0
- package/dist/state-based/MediaPreloader.js +93 -0
- package/dist/state-based/SurfaceManager.d.ts +8 -1
- package/dist/state-based/SurfaceManager.js +36 -9
- package/dist/types/MediaSchema.d.ts +6 -0
- package/dist/types/MediaSchema.js +2 -0
- package/dist/utils/device.d.ts +2 -0
- package/dist/utils/device.js +4 -0
- package/dist/utils/getStateAtTime.d.ts +12 -2
- package/dist/utils/getStateAtTime.js +6 -1
- package/dist/utils/modulo.d.ts +6 -0
- package/dist/utils/modulo.js +17 -0
- package/package.json +3 -6
- package/dist/AudioPlayer.d.ts +0 -49
- package/dist/AudioPlayer.js +0 -474
- package/dist/VideoPlayer.d.ts +0 -49
- package/dist/VideoPlayer.js +0 -385
- package/dist/state-based/AudioManager.d.ts +0 -15
- package/dist/state-based/AudioManager.js +0 -116
- package/dist/state-based/ClipManager.d.ts +0 -22
- package/dist/state-based/ClipManager.js +0 -53
- package/dist/state-based/ImageManager.d.ts +0 -9
- package/dist/state-based/ImageManager.js +0 -53
- package/dist/state-based/VideoManager.d.ts +0 -15
- package/dist/state-based/VideoManager.js +0 -129
- package/dist/types/AllMediaClipStatesMessage.d.ts +0 -5
- package/dist/types/AllMediaClipStatesMessage.js +0 -1
- package/dist/types/AudioState.d.ts +0 -39
- package/dist/types/AudioState.js +0 -1
- package/dist/types/MediaClipStateMessage.d.ts +0 -7
- package/dist/types/MediaClipStateMessage.js +0 -1
- package/dist/types/VideoState.d.ts +0 -26
- package/dist/types/VideoState.js +0 -5
package/dist/VideoPlayer.js
DELETED
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
import { ActiveVideoClipState } from './types/VideoState';
|
|
2
|
-
export default class VideoPlayer {
|
|
3
|
-
cogsConnection;
|
|
4
|
-
eventTarget = new EventTarget();
|
|
5
|
-
globalVolume = 1;
|
|
6
|
-
videoClipPlayers = {};
|
|
7
|
-
activeClip;
|
|
8
|
-
pendingClip;
|
|
9
|
-
parentElement;
|
|
10
|
-
sinkId = '';
|
|
11
|
-
constructor(
|
|
12
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
-
cogsConnection, parentElement = document.body) {
|
|
14
|
-
this.cogsConnection = cogsConnection;
|
|
15
|
-
this.parentElement = parentElement;
|
|
16
|
-
// Send the current status of each clip to COGS
|
|
17
|
-
this.addEventListener('videoClipState', ({ detail }) => {
|
|
18
|
-
cogsConnection.sendMediaClipState(detail);
|
|
19
|
-
});
|
|
20
|
-
// Listen for video control messages
|
|
21
|
-
cogsConnection.addEventListener('message', ({ message }) => {
|
|
22
|
-
switch (message.type) {
|
|
23
|
-
case 'media_config_update':
|
|
24
|
-
this.setGlobalVolume(message.globalVolume);
|
|
25
|
-
if (message.audioOutput !== undefined) {
|
|
26
|
-
const sinkId = cogsConnection.getAudioSinkId(message.audioOutput);
|
|
27
|
-
this.setAudioSink(sinkId ?? '');
|
|
28
|
-
}
|
|
29
|
-
this.updateConfig(message.files);
|
|
30
|
-
break;
|
|
31
|
-
case 'video_play':
|
|
32
|
-
this.playVideoClip(message.file, {
|
|
33
|
-
playId: message.playId,
|
|
34
|
-
volume: message.volume,
|
|
35
|
-
loop: Boolean(message.loop),
|
|
36
|
-
fit: message.fit,
|
|
37
|
-
});
|
|
38
|
-
break;
|
|
39
|
-
case 'video_pause':
|
|
40
|
-
this.pauseVideoClip();
|
|
41
|
-
break;
|
|
42
|
-
case 'video_stop':
|
|
43
|
-
this.stopVideoClip();
|
|
44
|
-
break;
|
|
45
|
-
case 'video_set_volume':
|
|
46
|
-
this.setVideoClipVolume({ volume: message.volume });
|
|
47
|
-
break;
|
|
48
|
-
case 'video_set_fit':
|
|
49
|
-
this.setVideoClipFit({ fit: message.fit });
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
// On connection, send the current playing state of all clips
|
|
54
|
-
// (Usually empty unless websocket is reconnecting)
|
|
55
|
-
const sendInitialClipStates = () => {
|
|
56
|
-
const files = Object.entries(this.videoClipPlayers).map(([file, player]) => {
|
|
57
|
-
const status = !player.videoElement.paused
|
|
58
|
-
? 'playing'
|
|
59
|
-
: player.videoElement.currentTime === 0 || player.videoElement.currentTime === player.videoElement.duration
|
|
60
|
-
? 'paused'
|
|
61
|
-
: 'stopped';
|
|
62
|
-
return [file, status];
|
|
63
|
-
});
|
|
64
|
-
cogsConnection.sendInitialMediaClipStates({ mediaType: 'video', files });
|
|
65
|
-
};
|
|
66
|
-
cogsConnection.addEventListener('open', sendInitialClipStates);
|
|
67
|
-
sendInitialClipStates();
|
|
68
|
-
}
|
|
69
|
-
setParentElement(parentElement) {
|
|
70
|
-
this.parentElement = parentElement;
|
|
71
|
-
Object.values(this.videoClipPlayers).forEach((clipPlayer) => {
|
|
72
|
-
parentElement.appendChild(clipPlayer.videoElement);
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
resetParentElement() {
|
|
76
|
-
this.setParentElement(document.body);
|
|
77
|
-
}
|
|
78
|
-
setGlobalVolume(globalVolume) {
|
|
79
|
-
Object.values(this.videoClipPlayers).forEach((clipPlayer) => {
|
|
80
|
-
setVideoElementVolume(clipPlayer.videoElement, clipPlayer.volume * globalVolume);
|
|
81
|
-
});
|
|
82
|
-
this.globalVolume = globalVolume;
|
|
83
|
-
this.notifyStateListeners();
|
|
84
|
-
}
|
|
85
|
-
playVideoClip(path, { playId, volume, loop, fit }) {
|
|
86
|
-
if (!this.videoClipPlayers[path]) {
|
|
87
|
-
this.videoClipPlayers[path] = this.createClipPlayer(path, { preload: 'none', ephemeral: true, fit });
|
|
88
|
-
}
|
|
89
|
-
// Check if there's already a pending clip, which has now been superseded and abort the play operation
|
|
90
|
-
if (this.pendingClip) {
|
|
91
|
-
this.updateVideoClipPlayer(this.pendingClip.path, (clipPlayer) => {
|
|
92
|
-
clipPlayer.videoElement.load(); // Resets the media element
|
|
93
|
-
return clipPlayer;
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
// New pending clip is video being requested
|
|
97
|
-
if (this.activeClip?.path !== path) {
|
|
98
|
-
this.pendingClip = { path, playId, actionOncePlaying: 'play' };
|
|
99
|
-
}
|
|
100
|
-
// Setup and play the pending clip's player
|
|
101
|
-
this.updateVideoClipPlayer(path, (clipPlayer) => {
|
|
102
|
-
clipPlayer.volume = volume;
|
|
103
|
-
setVideoElementVolume(clipPlayer.videoElement, volume * this.globalVolume);
|
|
104
|
-
clipPlayer.videoElement.loop = loop;
|
|
105
|
-
clipPlayer.videoElement.style.objectFit = fit;
|
|
106
|
-
if (clipPlayer.videoElement.currentTime === clipPlayer.videoElement.duration) {
|
|
107
|
-
clipPlayer.videoElement.currentTime = 0;
|
|
108
|
-
}
|
|
109
|
-
clipPlayer.videoElement.play();
|
|
110
|
-
// Display right away if there's currently no active clip
|
|
111
|
-
if (!this.activeClip) {
|
|
112
|
-
clipPlayer.videoElement.style.display = 'block';
|
|
113
|
-
}
|
|
114
|
-
return clipPlayer;
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
pauseVideoClip() {
|
|
118
|
-
// Pending clip should be paused when it loads and becomes active
|
|
119
|
-
if (this.pendingClip) {
|
|
120
|
-
this.pendingClip.actionOncePlaying = 'pause';
|
|
121
|
-
}
|
|
122
|
-
// Pause the currently active clip
|
|
123
|
-
if (this.activeClip) {
|
|
124
|
-
const { playId, path } = this.activeClip;
|
|
125
|
-
this.updateVideoClipPlayer(path, (clipPlayer) => {
|
|
126
|
-
clipPlayer.videoElement?.pause();
|
|
127
|
-
return clipPlayer;
|
|
128
|
-
});
|
|
129
|
-
this.notifyClipStateListeners(playId, path, 'paused');
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
stopVideoClip() {
|
|
133
|
-
// Pending clip should be stopped when it loads and becomes active
|
|
134
|
-
if (this.pendingClip) {
|
|
135
|
-
this.pendingClip.actionOncePlaying = 'stop';
|
|
136
|
-
}
|
|
137
|
-
// Stop the currently active clip
|
|
138
|
-
if (this.activeClip) {
|
|
139
|
-
this.handleStoppedClip(this.activeClip.path);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
setVideoClipVolume({ volume }) {
|
|
143
|
-
// If there is a pending clip, this is latest to have been played so update its volume
|
|
144
|
-
const clipToUpdate = this.pendingClip ?? this.activeClip ?? undefined;
|
|
145
|
-
if (!clipToUpdate) {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
if (!(volume >= 0 && volume <= 1)) {
|
|
149
|
-
console.warn('Invalid volume', volume);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
this.updateVideoClipPlayer(clipToUpdate.path, (clipPlayer) => {
|
|
153
|
-
if (clipPlayer.videoElement) {
|
|
154
|
-
clipPlayer.volume = volume;
|
|
155
|
-
setVideoElementVolume(clipPlayer.videoElement, volume * this.globalVolume);
|
|
156
|
-
}
|
|
157
|
-
return clipPlayer;
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
setVideoClipFit({ fit }) {
|
|
161
|
-
// If there is a pending clip, this is latest to have been played so update its fit
|
|
162
|
-
const clipToUpdate = this.pendingClip ?? this.activeClip ?? undefined;
|
|
163
|
-
if (!clipToUpdate) {
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
this.updateVideoClipPlayer(clipToUpdate.path, (clipPlayer) => {
|
|
167
|
-
if (clipPlayer.videoElement) {
|
|
168
|
-
clipPlayer.videoElement.style.objectFit = fit;
|
|
169
|
-
}
|
|
170
|
-
return clipPlayer;
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
handleStoppedClip(path) {
|
|
174
|
-
if (!this.activeClip || this.activeClip.path !== path) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const playId = this.activeClip.playId;
|
|
178
|
-
// Once an ephemeral clip stops, cleanup and remove the player
|
|
179
|
-
if (this.videoClipPlayers[this.activeClip.path]?.config.ephemeral) {
|
|
180
|
-
this.unloadClip(path);
|
|
181
|
-
}
|
|
182
|
-
this.activeClip = undefined;
|
|
183
|
-
this.updateVideoClipPlayer(path, (clipPlayer) => {
|
|
184
|
-
clipPlayer.videoElement.pause();
|
|
185
|
-
clipPlayer.videoElement.currentTime = 0;
|
|
186
|
-
clipPlayer.videoElement.style.display = 'none';
|
|
187
|
-
return clipPlayer;
|
|
188
|
-
});
|
|
189
|
-
this.notifyClipStateListeners(playId, path, 'stopped');
|
|
190
|
-
}
|
|
191
|
-
updateVideoClipPlayer(path, update) {
|
|
192
|
-
if (this.videoClipPlayers[path]) {
|
|
193
|
-
const newPlayer = update(this.videoClipPlayers[path]);
|
|
194
|
-
if (newPlayer) {
|
|
195
|
-
this.videoClipPlayers[path] = newPlayer;
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
198
|
-
delete this.videoClipPlayers[path];
|
|
199
|
-
}
|
|
200
|
-
this.notifyStateListeners();
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
setAudioSink(sinkId) {
|
|
204
|
-
for (const clipPlayer of Object.values(this.videoClipPlayers)) {
|
|
205
|
-
setPlayerSinkId(clipPlayer, sinkId);
|
|
206
|
-
}
|
|
207
|
-
this.sinkId = sinkId;
|
|
208
|
-
}
|
|
209
|
-
updateConfig(newPaths) {
|
|
210
|
-
const newVideoPaths = Object.fromEntries(Object.entries(newPaths).filter(([, { type }]) => type === 'video' || !type));
|
|
211
|
-
const previousClipPlayers = this.videoClipPlayers;
|
|
212
|
-
this.videoClipPlayers = (() => {
|
|
213
|
-
const clipPlayers = { ...previousClipPlayers };
|
|
214
|
-
const removedClips = Object.keys(previousClipPlayers).filter((previousPath) => !(previousPath in newVideoPaths));
|
|
215
|
-
removedClips.forEach((path) => {
|
|
216
|
-
if (this.activeClip?.path === path && previousClipPlayers[path]?.config.ephemeral === false) {
|
|
217
|
-
this.updateVideoClipPlayer(path, (player) => {
|
|
218
|
-
player.config = { ...player.config, ephemeral: true };
|
|
219
|
-
return player;
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
this.unloadClip(path);
|
|
224
|
-
delete clipPlayers[path];
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
const addedClips = Object.entries(newVideoPaths).filter(([newFile]) => !previousClipPlayers[newFile]);
|
|
228
|
-
addedClips.forEach(([path, config]) => {
|
|
229
|
-
clipPlayers[path] = this.createClipPlayer(path, { ...config, preload: preloadString(config.preload), ephemeral: false, fit: 'contain' });
|
|
230
|
-
});
|
|
231
|
-
const updatedClips = Object.entries(previousClipPlayers).filter(([previousPath]) => previousPath in newVideoPaths);
|
|
232
|
-
updatedClips.forEach(([path, previousClipPlayer]) => {
|
|
233
|
-
if (previousClipPlayer.config.preload !== newVideoPaths[path].preload) {
|
|
234
|
-
this.updateVideoClipPlayer(path, (player) => {
|
|
235
|
-
player.config = {
|
|
236
|
-
...player.config,
|
|
237
|
-
preload: preloadString(newVideoPaths[path].preload),
|
|
238
|
-
ephemeral: false,
|
|
239
|
-
};
|
|
240
|
-
player.videoElement.preload = player.config.preload;
|
|
241
|
-
return player;
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
return clipPlayers;
|
|
246
|
-
})();
|
|
247
|
-
this.notifyStateListeners();
|
|
248
|
-
}
|
|
249
|
-
notifyStateListeners() {
|
|
250
|
-
const VideoState = {
|
|
251
|
-
globalVolume: this.globalVolume,
|
|
252
|
-
isPlaying: this.activeClip ? !this.videoClipPlayers[this.activeClip.path].videoElement?.paused : false,
|
|
253
|
-
clips: { ...this.videoClipPlayers },
|
|
254
|
-
activeClip: this.activeClip
|
|
255
|
-
? {
|
|
256
|
-
path: this.activeClip.path,
|
|
257
|
-
state: !this.videoClipPlayers[this.activeClip.path].videoElement?.paused ? ActiveVideoClipState.Playing : ActiveVideoClipState.Paused,
|
|
258
|
-
loop: this.videoClipPlayers[this.activeClip.path].videoElement?.loop ?? false,
|
|
259
|
-
volume: this.videoClipPlayers[this.activeClip.path].videoElement?.muted
|
|
260
|
-
? 0
|
|
261
|
-
: (this.videoClipPlayers[this.activeClip.path].videoElement?.volume ?? 0),
|
|
262
|
-
}
|
|
263
|
-
: undefined,
|
|
264
|
-
};
|
|
265
|
-
this.dispatchEvent('state', VideoState);
|
|
266
|
-
}
|
|
267
|
-
notifyClipStateListeners(playId, file, status) {
|
|
268
|
-
this.dispatchEvent('videoClipState', { playId, mediaType: 'video', file, status });
|
|
269
|
-
}
|
|
270
|
-
// Type-safe wrapper around EventTarget
|
|
271
|
-
addEventListener(type, listener, options) {
|
|
272
|
-
this.eventTarget.addEventListener(type, listener, options);
|
|
273
|
-
}
|
|
274
|
-
removeEventListener(type, listener, options) {
|
|
275
|
-
this.eventTarget.removeEventListener(type, listener, options);
|
|
276
|
-
}
|
|
277
|
-
dispatchEvent(type, detail) {
|
|
278
|
-
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
|
279
|
-
}
|
|
280
|
-
createVideoElement(path, config, { volume }) {
|
|
281
|
-
const videoElement = document.createElement('video');
|
|
282
|
-
videoElement.playsInline = true; // Required for iOS
|
|
283
|
-
videoElement.src = this.cogsConnection.getAssetUrl(path);
|
|
284
|
-
videoElement.autoplay = false;
|
|
285
|
-
videoElement.loop = false;
|
|
286
|
-
setVideoElementVolume(videoElement, volume * this.globalVolume);
|
|
287
|
-
videoElement.preload = config.preload;
|
|
288
|
-
videoElement.addEventListener('playing', () => {
|
|
289
|
-
// If the clip is still the pending one when it actually start playing, then ensure it is in the correct state
|
|
290
|
-
if (this.pendingClip?.path === path) {
|
|
291
|
-
switch (this.pendingClip.actionOncePlaying) {
|
|
292
|
-
case 'play': {
|
|
293
|
-
// Continue playing, show the video element, and notify listeners
|
|
294
|
-
videoElement.style.display = 'block';
|
|
295
|
-
this.notifyClipStateListeners(this.pendingClip.playId, path, 'playing');
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
case 'pause': {
|
|
299
|
-
// Pause playback, show the video element, and notify listeners
|
|
300
|
-
videoElement.style.display = 'block';
|
|
301
|
-
videoElement.pause();
|
|
302
|
-
this.notifyClipStateListeners(this.pendingClip.playId, path, 'paused');
|
|
303
|
-
break;
|
|
304
|
-
}
|
|
305
|
-
case 'stop': {
|
|
306
|
-
// Pause playback, leave the video element hidden, and notify listeners
|
|
307
|
-
videoElement.pause();
|
|
308
|
-
this.notifyClipStateListeners(this.pendingClip.playId, path, 'stopped');
|
|
309
|
-
break;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
// If there was a previously active clip, then stop it
|
|
313
|
-
if (this.activeClip) {
|
|
314
|
-
this.handleStoppedClip(this.activeClip.path);
|
|
315
|
-
}
|
|
316
|
-
this.activeClip = this.pendingClip;
|
|
317
|
-
this.pendingClip = undefined;
|
|
318
|
-
}
|
|
319
|
-
else if (this.activeClip?.path === path) {
|
|
320
|
-
// If we were the active clip then just notify listeners that we are now playing
|
|
321
|
-
this.notifyClipStateListeners(this.activeClip.playId, path, 'playing');
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
// Otherwise it shouldn't be playing, like because another clip became pending before we loaded,
|
|
325
|
-
// so we pause and don't show or notify listeners
|
|
326
|
-
videoElement.pause();
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
videoElement.addEventListener('ended', () => {
|
|
330
|
-
// Ignore if there's a pending clip, as once that starts playing the active clip will be stopped
|
|
331
|
-
// Also ignore if the video is set to loop
|
|
332
|
-
if (!this.pendingClip && !videoElement.loop) {
|
|
333
|
-
this.handleStoppedClip(path);
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
videoElement.style.position = 'absolute';
|
|
337
|
-
videoElement.style.top = '0';
|
|
338
|
-
videoElement.style.left = '0';
|
|
339
|
-
videoElement.style.width = '100%';
|
|
340
|
-
videoElement.style.height = '100%';
|
|
341
|
-
videoElement.style.objectFit = config.fit;
|
|
342
|
-
videoElement.style.display = 'none';
|
|
343
|
-
this.parentElement.appendChild(videoElement);
|
|
344
|
-
return videoElement;
|
|
345
|
-
}
|
|
346
|
-
createClipPlayer(path, config) {
|
|
347
|
-
const volume = 1;
|
|
348
|
-
const player = {
|
|
349
|
-
config,
|
|
350
|
-
videoElement: this.createVideoElement(path, config, { volume }),
|
|
351
|
-
volume,
|
|
352
|
-
};
|
|
353
|
-
setPlayerSinkId(player, this.sinkId);
|
|
354
|
-
return player;
|
|
355
|
-
}
|
|
356
|
-
unloadClip(path) {
|
|
357
|
-
if (this.activeClip?.path === path) {
|
|
358
|
-
const playId = this.activeClip.playId;
|
|
359
|
-
this.activeClip = undefined;
|
|
360
|
-
this.notifyClipStateListeners(playId, path, 'stopped');
|
|
361
|
-
}
|
|
362
|
-
this.videoClipPlayers[path]?.videoElement.remove();
|
|
363
|
-
this.updateVideoClipPlayer(path, () => null);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
function preloadString(preload) {
|
|
367
|
-
return typeof preload === 'string' ? preload : preload ? 'auto' : 'none';
|
|
368
|
-
}
|
|
369
|
-
function setPlayerSinkId(player, sinkId) {
|
|
370
|
-
if (sinkId === undefined) {
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
if (typeof player.videoElement.setSinkId === 'function') {
|
|
374
|
-
player.videoElement.setSinkId(sinkId);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
/**
|
|
378
|
-
* Set video volume
|
|
379
|
-
*
|
|
380
|
-
* This doesn't work on iOS (volume is read-only) so at least mute it if the volume is zero
|
|
381
|
-
*/
|
|
382
|
-
function setVideoElementVolume(videoElement, volume) {
|
|
383
|
-
videoElement.volume = volume;
|
|
384
|
-
videoElement.muted = volume === 0;
|
|
385
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { AudioState } from '../types/MediaSchema';
|
|
2
|
-
import { ClipManager } from './ClipManager';
|
|
3
|
-
export declare class AudioManager extends ClipManager<AudioState> {
|
|
4
|
-
private audioElement?;
|
|
5
|
-
private isSeeking;
|
|
6
|
-
constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: AudioState);
|
|
7
|
-
private updateAudioElement;
|
|
8
|
-
/**
|
|
9
|
-
* Helper function to seek to a specified time.
|
|
10
|
-
* Works with the update loop to poll until seeked event has fired.
|
|
11
|
-
*/
|
|
12
|
-
private seekTo;
|
|
13
|
-
protected update(): void;
|
|
14
|
-
destroy(): void;
|
|
15
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { defaultAudioOptions } from '../types/MediaSchema';
|
|
2
|
-
import { getStateAtTime } from '../utils/getStateAtTime';
|
|
3
|
-
import { ClipManager } from './ClipManager';
|
|
4
|
-
const NO_AUDIO_POLLING = 1_000;
|
|
5
|
-
const AUDIO_PLAYBACK_POLLING = 100;
|
|
6
|
-
const SEEKING_POLLING = 10;
|
|
7
|
-
const TARGET_SYNC_THRESHOLD_MS = 10; // If we're closer than this we're good enough
|
|
8
|
-
const MAX_SYNC_THRESHOLD_MS = 1_000; // If we're further away than this, we'll seek instead
|
|
9
|
-
const SEEK_LOOKAHEAD_MS = 200; // We won't seek ahead instantly, so lets seek ahead
|
|
10
|
-
const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.2;
|
|
11
|
-
// We smoothly ramp playbackRate up and down
|
|
12
|
-
const PLAYBACK_ADJUSTMENT_SMOOTHING = 0.5;
|
|
13
|
-
function playbackSmoothing(deltaTime) {
|
|
14
|
-
return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
|
|
15
|
-
}
|
|
16
|
-
export class AudioManager extends ClipManager {
|
|
17
|
-
audioElement;
|
|
18
|
-
isSeeking = false;
|
|
19
|
-
constructor(surfaceElement, clipElement, state) {
|
|
20
|
-
super(surfaceElement, clipElement, state);
|
|
21
|
-
this.clipElement = clipElement;
|
|
22
|
-
}
|
|
23
|
-
updateAudioElement() {
|
|
24
|
-
this.destroy();
|
|
25
|
-
this.audioElement = document.createElement('audio');
|
|
26
|
-
this.clipElement.replaceChildren(this.audioElement);
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Helper function to seek to a specified time.
|
|
30
|
-
* Works with the update loop to poll until seeked event has fired.
|
|
31
|
-
*/
|
|
32
|
-
seekTo(time) {
|
|
33
|
-
if (!this.audioElement)
|
|
34
|
-
return;
|
|
35
|
-
this.delay = SEEKING_POLLING;
|
|
36
|
-
this.isSeeking = true;
|
|
37
|
-
this.audioElement.addEventListener('seeked', () => {
|
|
38
|
-
this.isSeeking = false;
|
|
39
|
-
}, { once: true, passive: true });
|
|
40
|
-
this.audioElement.currentTime = time / 1_000;
|
|
41
|
-
}
|
|
42
|
-
update() {
|
|
43
|
-
// Update loop used to poll until seek finished
|
|
44
|
-
if (this.isSeeking)
|
|
45
|
-
return;
|
|
46
|
-
this.delay = NO_AUDIO_POLLING;
|
|
47
|
-
// Does the <audio /> element need adding/removing?
|
|
48
|
-
const currentState = getStateAtTime(this._state, Date.now());
|
|
49
|
-
if (currentState) {
|
|
50
|
-
if (!this.audioElement || !this.isConnected(this.audioElement)) {
|
|
51
|
-
this.updateAudioElement();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
this.destroy();
|
|
56
|
-
}
|
|
57
|
-
if (!currentState || !this.audioElement)
|
|
58
|
-
return;
|
|
59
|
-
const { t, rate, volume } = { ...defaultAudioOptions, ...currentState };
|
|
60
|
-
// this.audioElement.src will be a fully qualified URL
|
|
61
|
-
if (!this.audioElement.src.endsWith(this._state.file)) {
|
|
62
|
-
this.audioElement.src = this._state.file;
|
|
63
|
-
}
|
|
64
|
-
if (this.audioElement.volume !== volume) {
|
|
65
|
-
this.audioElement.volume = volume;
|
|
66
|
-
}
|
|
67
|
-
// Should the element be playing?
|
|
68
|
-
if (this.audioElement.paused && rate > 0) {
|
|
69
|
-
this.audioElement.play().catch(() => {
|
|
70
|
-
// Do nothing - this will be retried in the next loop
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
const currentTime = this.audioElement.currentTime * 1000;
|
|
74
|
-
const deltaTime = currentTime - t;
|
|
75
|
-
const deltaTimeAbs = Math.abs(deltaTime);
|
|
76
|
-
this.delay = AUDIO_PLAYBACK_POLLING;
|
|
77
|
-
switch (true) {
|
|
78
|
-
case deltaTimeAbs <= TARGET_SYNC_THRESHOLD_MS:
|
|
79
|
-
// We are on course:
|
|
80
|
-
// - The audio is within accepted latency of the server time
|
|
81
|
-
// - The playback rate is aligned with the server rate
|
|
82
|
-
if (this.audioElement.playbackRate !== rate) {
|
|
83
|
-
this.audioElement.playbackRate = rate;
|
|
84
|
-
}
|
|
85
|
-
break;
|
|
86
|
-
case rate > 0 && deltaTimeAbs > TARGET_SYNC_THRESHOLD_MS && deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS: {
|
|
87
|
-
// We are close, we can smoothly adjust with playbackRate:
|
|
88
|
-
// - The audio must be playing
|
|
89
|
-
// - We must be close in time to the server time
|
|
90
|
-
const playbackRateAdjustment = playbackSmoothing(deltaTime);
|
|
91
|
-
const adjustedPlaybackRate = Math.max(0, rate - playbackRateAdjustment);
|
|
92
|
-
if (this.audioElement.playbackRate !== adjustedPlaybackRate) {
|
|
93
|
-
this.audioElement.playbackRate = adjustedPlaybackRate;
|
|
94
|
-
}
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
default: {
|
|
98
|
-
// We cannot smoothly recover:
|
|
99
|
-
// - We seek just ahead of server time
|
|
100
|
-
if (this.audioElement.playbackRate !== rate) {
|
|
101
|
-
this.audioElement.playbackRate = rate;
|
|
102
|
-
}
|
|
103
|
-
// delay to poll until seeked
|
|
104
|
-
this.delay = 10;
|
|
105
|
-
this.seekTo(t + rate * (SEEK_LOOKAHEAD_MS / 1000));
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
destroy() {
|
|
111
|
-
if (this.audioElement) {
|
|
112
|
-
this.audioElement.src = '';
|
|
113
|
-
this.audioElement.remove();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { MediaClipState } from '../types/MediaSchema';
|
|
2
|
-
/**
|
|
3
|
-
* Each instance of a ClipManager is responsible for displaying
|
|
4
|
-
* an image/audio/video clip in the correct state.
|
|
5
|
-
*/
|
|
6
|
-
export declare abstract class ClipManager<T extends MediaClipState> {
|
|
7
|
-
private surfaceElement;
|
|
8
|
-
protected clipElement: HTMLElement;
|
|
9
|
-
constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T);
|
|
10
|
-
/**
|
|
11
|
-
* This is the delay to be used in the update loop.
|
|
12
|
-
* It is intended to be dynamic for each loop.
|
|
13
|
-
*/
|
|
14
|
-
protected delay: number;
|
|
15
|
-
protected abstract update(): void;
|
|
16
|
-
abstract destroy(): void;
|
|
17
|
-
isConnected(element?: HTMLElement): boolean;
|
|
18
|
-
protected _state: T;
|
|
19
|
-
setState(newState: T): void;
|
|
20
|
-
private timeout;
|
|
21
|
-
private loop;
|
|
22
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
const DEFAULT_DELAY = 1_000;
|
|
2
|
-
/**
|
|
3
|
-
* Each instance of a ClipManager is responsible for displaying
|
|
4
|
-
* an image/audio/video clip in the correct state.
|
|
5
|
-
*/
|
|
6
|
-
export class ClipManager {
|
|
7
|
-
surfaceElement;
|
|
8
|
-
clipElement;
|
|
9
|
-
constructor(surfaceElement, clipElement, state) {
|
|
10
|
-
this.surfaceElement = surfaceElement;
|
|
11
|
-
this.clipElement = clipElement;
|
|
12
|
-
this._state = state;
|
|
13
|
-
// Allow the class to be constructed, then call the loop
|
|
14
|
-
setTimeout(this.loop);
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* This is the delay to be used in the update loop.
|
|
18
|
-
* It is intended to be dynamic for each loop.
|
|
19
|
-
*/
|
|
20
|
-
delay = DEFAULT_DELAY;
|
|
21
|
-
isConnected(element) {
|
|
22
|
-
if (!this.surfaceElement) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
if (!this.clipElement) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
if (!this.surfaceElement.contains(this.clipElement)) {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
if (element) {
|
|
32
|
-
if (!this.clipElement.contains(element))
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
_state;
|
|
38
|
-
setState(newState) {
|
|
39
|
-
this._state = newState;
|
|
40
|
-
clearTimeout(this.timeout);
|
|
41
|
-
this.loop();
|
|
42
|
-
}
|
|
43
|
-
timeout;
|
|
44
|
-
loop = async () => {
|
|
45
|
-
if (this.isConnected()) {
|
|
46
|
-
this.update();
|
|
47
|
-
this.timeout = setTimeout(this.loop, this.delay);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
this.destroy();
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { ImageState } from '../types/MediaSchema';
|
|
2
|
-
import { ClipManager } from './ClipManager';
|
|
3
|
-
export declare class ImageManager extends ClipManager<ImageState> {
|
|
4
|
-
private imageElement?;
|
|
5
|
-
constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: ImageState);
|
|
6
|
-
private updateImageElement;
|
|
7
|
-
protected update(): void;
|
|
8
|
-
destroy(): void;
|
|
9
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { defaultImageOptions } from '../types/MediaSchema';
|
|
2
|
-
import { getStateAtTime } from '../utils/getStateAtTime';
|
|
3
|
-
import { ClipManager } from './ClipManager';
|
|
4
|
-
export class ImageManager extends ClipManager {
|
|
5
|
-
imageElement;
|
|
6
|
-
constructor(surfaceElement, clipElement, state) {
|
|
7
|
-
super(surfaceElement, clipElement, state);
|
|
8
|
-
this.clipElement = clipElement;
|
|
9
|
-
}
|
|
10
|
-
updateImageElement() {
|
|
11
|
-
this.imageElement = document.createElement('img');
|
|
12
|
-
this.clipElement.replaceChildren(this.imageElement);
|
|
13
|
-
this.imageElement.style.position = 'absolute';
|
|
14
|
-
this.imageElement.style.height = '100%';
|
|
15
|
-
this.imageElement.style.width = '100%';
|
|
16
|
-
}
|
|
17
|
-
update() {
|
|
18
|
-
const currentState = getStateAtTime(this._state, Date.now());
|
|
19
|
-
// Does the <img /> element need adding/removing?
|
|
20
|
-
if (currentState) {
|
|
21
|
-
if (!this.imageElement || !this.isConnected(this.imageElement)) {
|
|
22
|
-
this.updateImageElement();
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
this.imageElement?.remove();
|
|
27
|
-
this.imageElement = undefined;
|
|
28
|
-
}
|
|
29
|
-
if (!this.imageElement || !currentState)
|
|
30
|
-
return;
|
|
31
|
-
// this.imageElement.src will be a fully qualified URL
|
|
32
|
-
if (!this.imageElement.src.endsWith(this._state.file)) {
|
|
33
|
-
this.imageElement.src = this._state.file;
|
|
34
|
-
}
|
|
35
|
-
if (this.imageElement.style.objectFit !== this._state.fit) {
|
|
36
|
-
this.imageElement.style.objectFit = this._state.fit;
|
|
37
|
-
}
|
|
38
|
-
if (parseFloat(this.imageElement.style.opacity) !== currentState.opacity) {
|
|
39
|
-
this.imageElement.style.opacity = String(currentState.opacity ?? defaultImageOptions.opacity);
|
|
40
|
-
}
|
|
41
|
-
const z = Math.round(currentState.zIndex ?? defaultImageOptions.zIndex);
|
|
42
|
-
if (parseInt(this.imageElement.style.zIndex) !== z) {
|
|
43
|
-
this.imageElement.style.zIndex = String(z);
|
|
44
|
-
}
|
|
45
|
-
const opacityString = String(currentState.opacity);
|
|
46
|
-
if (this.imageElement.style.opacity !== opacityString) {
|
|
47
|
-
this.imageElement.style.opacity = opacityString;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
destroy() {
|
|
51
|
-
this.imageElement?.remove();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { VideoState } from '../types/MediaSchema';
|
|
2
|
-
import { ClipManager } from './ClipManager';
|
|
3
|
-
export declare class VideoManager extends ClipManager<VideoState> {
|
|
4
|
-
private videoElement?;
|
|
5
|
-
private isSeeking;
|
|
6
|
-
constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: VideoState);
|
|
7
|
-
private updateVideoElement;
|
|
8
|
-
/**
|
|
9
|
-
* Helper function to seek to a specified time.
|
|
10
|
-
* Works with the update loop to poll until seeked event has fired.
|
|
11
|
-
*/
|
|
12
|
-
private seekTo;
|
|
13
|
-
protected update(): void;
|
|
14
|
-
destroy(): void;
|
|
15
|
-
}
|