@clockworkdog/cogs-client 3.0.0-alpha.8 → 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.
Files changed (42) hide show
  1. package/README.md +20 -9
  2. package/dist/CogsConnection.d.ts +0 -4
  3. package/dist/CogsConnection.js +0 -10
  4. package/dist/browser/index.mjs +1871 -2773
  5. package/dist/browser/index.umd.js +13 -13
  6. package/dist/index.d.ts +1 -7
  7. package/dist/index.js +1 -5
  8. package/dist/state-based/MediaClipManager.d.ts +66 -0
  9. package/dist/state-based/MediaClipManager.js +420 -0
  10. package/dist/state-based/MediaPreloader.d.ts +2 -2
  11. package/dist/state-based/MediaPreloader.js +10 -3
  12. package/dist/state-based/SurfaceManager.d.ts +5 -1
  13. package/dist/state-based/SurfaceManager.js +31 -10
  14. package/dist/types/MediaSchema.d.ts +19 -13
  15. package/dist/types/MediaSchema.js +3 -1
  16. package/dist/utils/device.d.ts +2 -0
  17. package/dist/utils/device.js +4 -0
  18. package/dist/utils/getStateAtTime.d.ts +12 -2
  19. package/dist/utils/getStateAtTime.js +6 -1
  20. package/dist/utils/modulo.d.ts +6 -0
  21. package/dist/utils/modulo.js +17 -0
  22. package/package.json +3 -6
  23. package/dist/AudioPlayer.d.ts +0 -49
  24. package/dist/AudioPlayer.js +0 -474
  25. package/dist/VideoPlayer.d.ts +0 -49
  26. package/dist/VideoPlayer.js +0 -385
  27. package/dist/state-based/AudioManager.d.ts +0 -17
  28. package/dist/state-based/AudioManager.js +0 -114
  29. package/dist/state-based/ClipManager.d.ts +0 -23
  30. package/dist/state-based/ClipManager.js +0 -60
  31. package/dist/state-based/ImageManager.d.ts +0 -9
  32. package/dist/state-based/ImageManager.js +0 -54
  33. package/dist/state-based/VideoManager.d.ts +0 -19
  34. package/dist/state-based/VideoManager.js +0 -215
  35. package/dist/types/AllMediaClipStatesMessage.d.ts +0 -5
  36. package/dist/types/AllMediaClipStatesMessage.js +0 -1
  37. package/dist/types/AudioState.d.ts +0 -39
  38. package/dist/types/AudioState.js +0 -1
  39. package/dist/types/MediaClipStateMessage.d.ts +0 -7
  40. package/dist/types/MediaClipStateMessage.js +0 -1
  41. package/dist/types/VideoState.d.ts +0 -26
  42. package/dist/types/VideoState.js +0 -5
package/dist/index.d.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  export { default as CogsConnection } from './CogsConnection';
2
2
  export * from './CogsConnection';
3
3
  export type { default as CogsClientMessage, MediaClientConfig } from './types/CogsClientMessage';
4
- export type { default as MediaClipStateMessage } from './types/MediaClipStateMessage';
5
4
  export type { default as ShowPhase } from './types/ShowPhase';
6
- export type { default as MediaObjectFit } from './types/MediaObjectFit';
7
5
  export * as MediaSchema from './types/MediaSchema';
8
- export * from './state-based/SurfaceManager';
9
- export * from './state-based/MediaPreloader';
10
- export { default as CogsAudioPlayer } from './AudioPlayer';
11
- export { default as CogsVideoPlayer } from './VideoPlayer';
12
6
  export { SurfaceManager } from './state-based/SurfaceManager';
13
- export * from './types/AudioState';
7
+ export { MediaPreloader } from './state-based/MediaPreloader';
14
8
  export { assetUrl, preloadUrl } from './utils/urls';
15
9
  export { getStateAtTime } from './utils/getStateAtTime';
16
10
  export * from './types/CogsPluginManifest';
package/dist/index.js CHANGED
@@ -1,12 +1,8 @@
1
1
  export { default as CogsConnection } from './CogsConnection';
2
2
  export * from './CogsConnection';
3
3
  export * as MediaSchema from './types/MediaSchema';
4
- export * from './state-based/SurfaceManager';
5
- export * from './state-based/MediaPreloader';
6
- export { default as CogsAudioPlayer } from './AudioPlayer';
7
- export { default as CogsVideoPlayer } from './VideoPlayer';
8
4
  export { SurfaceManager } from './state-based/SurfaceManager';
9
- export * from './types/AudioState';
5
+ export { MediaPreloader } from './state-based/MediaPreloader';
10
6
  export { assetUrl, preloadUrl } from './utils/urls';
11
7
  export { getStateAtTime } from './utils/getStateAtTime';
12
8
  export * from './types/CogsPluginManifest';
@@ -0,0 +1,66 @@
1
+ import { AudialProperties, AudioState, ImageMetadata, ImageState, MediaClipState, TemporalProperties, VideoState, VisualProperties } from '../types/MediaSchema';
2
+ import { MediaPreloader } from './MediaPreloader';
3
+ /**
4
+ * Each instance of a MediaClipManager is responsible for displaying
5
+ * an image/audio/video clip in the correct state.
6
+ */
7
+ export declare abstract class MediaClipManager<T extends MediaClipState> {
8
+ private surfaceElement;
9
+ protected clipElement: HTMLElement;
10
+ protected constructAssetURL: (file: string) => string;
11
+ protected getAudioOutput: (outputLabel: string) => string;
12
+ protected mediaPreloader: MediaPreloader;
13
+ constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T, constructAssetURL: (file: string) => string, getAudioOutput: (outputLabel: string) => string, mediaPreloader: MediaPreloader);
14
+ protected abstract update(): void;
15
+ abstract destroy(): void;
16
+ isConnected(element?: HTMLElement): boolean;
17
+ protected _state: T;
18
+ setState(newState: T): void;
19
+ private timeout;
20
+ loop: () => Promise<void>;
21
+ }
22
+ /**
23
+ * Makes sure that the child media element exists and is of the correct type
24
+ * - If it isn't or doesn't exist we'll get a new one
25
+ * - If it is audio or video we'll try and get a preloaded media element
26
+ * - Otherwise we'll directly create and set the src
27
+ */
28
+ export declare function assertElement(mediaElement: HTMLMediaElement | HTMLImageElement | undefined, parentElement: HTMLElement, clip: MediaClipState, constructAssetURL: (file: string) => string, preloader: MediaPreloader): HTMLElement;
29
+ /**
30
+ * Makes sure that the element looks correct.
31
+ * - If the opacity, zIndex or fit are incorrect, we'll set again
32
+ */
33
+ export declare function assertVisualProperties(mediaElement: HTMLMediaElement | HTMLImageElement, properties: VisualProperties, objectFit: ImageMetadata['fit']): void;
34
+ /**
35
+ * Makes sure that the element sounds correct.
36
+ * - It should have the right volume, and play out the correct speaker.
37
+ */
38
+ export declare function assertAudialProperties(mediaElement: HTMLMediaElement, properties: AudialProperties, sinkId: string, surfaceVolume: number): void;
39
+ interface TemporalSyncState {
40
+ state: 'idle' | 'seeking' | 'intercepting' | 'seeking-ahead' | 'seeked-ahead';
41
+ }
42
+ /**
43
+ * Makes sure the media is at the correct time and speed.
44
+ * - Algorithms and constants defined above
45
+ */
46
+ export declare function assertTemporalProperties(mediaElement: HTMLMediaElement, properties: TemporalProperties, keyframes: VideoState['keyframes'], syncState: TemporalSyncState, enablePlaybackRateAdjustment: boolean): TemporalSyncState;
47
+ export declare class ImageManager extends MediaClipManager<ImageState> {
48
+ private imageElement;
49
+ protected update(): void;
50
+ destroy(): void;
51
+ }
52
+ export declare class AudioManager extends MediaClipManager<AudioState> {
53
+ private syncState;
54
+ private audioElement;
55
+ volume: number;
56
+ protected update(): void;
57
+ destroy(): void;
58
+ }
59
+ export declare class VideoManager extends MediaClipManager<VideoState> {
60
+ private syncState;
61
+ private videoElement?;
62
+ volume: number;
63
+ protected update(): void;
64
+ destroy(): void;
65
+ }
66
+ export {};
@@ -0,0 +1,420 @@
1
+ import { getStateAtTime } from '../utils/getStateAtTime';
2
+ import { IS_IOS, IS_WEBKIT } from '../utils/device';
3
+ import { modulo, moduloDiff } from '../utils/modulo';
4
+ const getPath = (url) => {
5
+ try {
6
+ const { pathname } = new URL(url, window.location.href);
7
+ return pathname;
8
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
9
+ }
10
+ catch (_) {
11
+ return undefined;
12
+ }
13
+ };
14
+ /**
15
+ * Each instance of a MediaClipManager is responsible for displaying
16
+ * an image/audio/video clip in the correct state.
17
+ */
18
+ export class MediaClipManager {
19
+ surfaceElement;
20
+ clipElement;
21
+ constructAssetURL;
22
+ getAudioOutput;
23
+ mediaPreloader;
24
+ constructor(surfaceElement, clipElement, state, constructAssetURL, getAudioOutput, mediaPreloader) {
25
+ this.surfaceElement = surfaceElement;
26
+ this.clipElement = clipElement;
27
+ this.constructAssetURL = constructAssetURL;
28
+ this.getAudioOutput = getAudioOutput;
29
+ this.mediaPreloader = mediaPreloader;
30
+ this._state = state;
31
+ }
32
+ isConnected(element) {
33
+ if (!this.surfaceElement) {
34
+ return false;
35
+ }
36
+ if (!this.clipElement) {
37
+ return false;
38
+ }
39
+ if (!this.surfaceElement.contains(this.clipElement)) {
40
+ return false;
41
+ }
42
+ if (element) {
43
+ if (!this.clipElement.contains(element))
44
+ return false;
45
+ }
46
+ return true;
47
+ }
48
+ _state;
49
+ setState(newState) {
50
+ this._state = newState;
51
+ }
52
+ timeout;
53
+ loop = async () => {
54
+ clearTimeout(this.timeout);
55
+ if (this.isConnected()) {
56
+ this.update();
57
+ this.timeout = setTimeout(this.loop, SYNC_INNER_TARGET_THRESHOLD_MS);
58
+ }
59
+ else {
60
+ this.destroy();
61
+ }
62
+ };
63
+ }
64
+ /**
65
+ * Makes sure that the child media element exists and is of the correct type
66
+ * - If it isn't or doesn't exist we'll get a new one
67
+ * - If it is audio or video we'll try and get a preloaded media element
68
+ * - Otherwise we'll directly create and set the src
69
+ */
70
+ export function assertElement(mediaElement, parentElement, clip, constructAssetURL, preloader) {
71
+ let element = undefined;
72
+ const assetURL = constructAssetURL(clip.file);
73
+ const assetPath = getPath(assetURL);
74
+ switch (clip.type) {
75
+ case 'image':
76
+ {
77
+ element = mediaElement instanceof HTMLImageElement ? mediaElement : document.createElement('img');
78
+ const elementPath = getPath(element.src);
79
+ if (elementPath !== assetPath) {
80
+ element.src = assetURL;
81
+ }
82
+ }
83
+ break;
84
+ case 'audio':
85
+ case 'video': {
86
+ if (mediaElement !== undefined) {
87
+ const path = getPath(mediaElement.src);
88
+ if (mediaElement.tagName.toLowerCase() === clip.type && path !== undefined && path === assetPath) {
89
+ element = mediaElement;
90
+ }
91
+ }
92
+ if (!element) {
93
+ element = preloader.getElement(clip.file, clip.type);
94
+ }
95
+ // Required for iOS
96
+ if (element instanceof HTMLVideoElement && !element.playsInline) {
97
+ element.playsInline = true;
98
+ }
99
+ break;
100
+ }
101
+ }
102
+ if (parentElement.children.length !== 1 || parentElement.childNodes[0] !== element) {
103
+ parentElement.replaceChildren(element);
104
+ }
105
+ element.style.position = 'absolute';
106
+ element.style.width = '100%';
107
+ element.style.height = '100%';
108
+ return element;
109
+ }
110
+ /**
111
+ * Makes sure that the element looks correct.
112
+ * - If the opacity, zIndex or fit are incorrect, we'll set again
113
+ */
114
+ export function assertVisualProperties(mediaElement, properties, objectFit) {
115
+ const opacityString = String(properties.opacity);
116
+ if (mediaElement.style.opacity !== opacityString) {
117
+ mediaElement.style.opacity = opacityString;
118
+ }
119
+ const zIndex = Math.round(properties.zIndex ?? 0);
120
+ if (parseInt(mediaElement.style.zIndex) !== zIndex) {
121
+ mediaElement.style.zIndex = String(zIndex);
122
+ }
123
+ if (mediaElement.style.objectFit !== objectFit) {
124
+ mediaElement.style.objectFit = objectFit;
125
+ }
126
+ }
127
+ /**
128
+ * Makes sure that the element sounds correct.
129
+ * - It should have the right volume, and play out the correct speaker.
130
+ */
131
+ export function assertAudialProperties(mediaElement, properties, sinkId, surfaceVolume) {
132
+ const clipVolume = properties.volume * surfaceVolume;
133
+ if (IS_IOS) {
134
+ // For iOS devices HTMLMediaElement.volume is readonly
135
+ // The best we can do is mute if the volume should be 0
136
+ if (clipVolume === 0 && !mediaElement.muted) {
137
+ mediaElement.muted = true;
138
+ }
139
+ else if (clipVolume > 0 && mediaElement.muted) {
140
+ mediaElement.muted = false;
141
+ }
142
+ }
143
+ else {
144
+ if (mediaElement.muted) {
145
+ mediaElement.muted = false;
146
+ }
147
+ if (mediaElement.volume !== clipVolume) {
148
+ mediaElement.volume = clipVolume;
149
+ }
150
+ if (mediaElement.sinkId !== sinkId) {
151
+ try {
152
+ mediaElement.setSinkId(sinkId).catch(() => {
153
+ /* Do nothing, will be tried in next loop */
154
+ });
155
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
156
+ }
157
+ catch (_) {
158
+ /* Do nothing, will be tried in next loop */
159
+ }
160
+ }
161
+ }
162
+ }
163
+ /*
164
+ * When playbackRate adjustment is disabled (no-sync) we will attempt to seek-ahead, then wait to play.
165
+ * - This is a recovery situation, so we only do it when 2s out of sync. (outer threshold)
166
+ * - We seek 1s into the future to allow a lot of buffering time (lookahead)
167
+ * - We press play 0.1s early (inner threshold)
168
+ */
169
+ const NO_SYNC_SEEK_AHEAD_OUTER_THRESHOLD_MS = 2_000;
170
+ const NO_SYNC_SEEK_LOOKAHEAD_MS = 1_000;
171
+ const NO_SYNC_SEEK_AHEAD_INNER_THRESHOLD_MS = 100;
172
+ /**
173
+ * When playbackRate adjustment is enabled (sync) we will attempt to speed ramp to get closer to the correct time.
174
+ * - Whenever we are out of sync by an amount we think we can improve (outer threshold)
175
+ * - We adjust the playbackRate until we are close enough (inner threshold)
176
+ * - If we're too far away that it would take too long to sync (max threshold), then we seek instead.
177
+ * - If we seek ahead we may as well attempt to add a little time for buffering (lookahead)
178
+ */
179
+ const SYNC_OUTER_TARGET_THRESHOLD_MS = 50;
180
+ const SYNC_INNER_TARGET_THRESHOLD_MS = 5;
181
+ const SYNC_MAX_THRESHOLD_MS = 1_000;
182
+ const SYNC_SEEK_LOOKAHEAD_MS = 10;
183
+ // If the media is scheduled to go back to the start close in time to the end of the video, we'll use the loop attribute.
184
+ const LOOPING_EPSILON_MS = 5;
185
+ const PLAYBACK_ADJUSTMENT_SMOOTHING = 0.3;
186
+ const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.1;
187
+ function playbackSmoothing(deltaTime) {
188
+ return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / SYNC_MAX_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
189
+ }
190
+ function assertPlaybackRate(mediaElement, playbackRate) {
191
+ if (mediaElement.playbackRate !== playbackRate) {
192
+ mediaElement.playbackRate = playbackRate;
193
+ }
194
+ // It's more responsive on chromium to set playbackRate to 0 instead of pausing.
195
+ // It also makes it more responsive to start again.
196
+ // On iOS it doesn't make a difference, so we may as well.
197
+ if (mediaElement.paused) {
198
+ mediaElement.play().catch(() => {
199
+ /* do nothing*/
200
+ });
201
+ }
202
+ }
203
+ /**
204
+ * Makes sure the media is at the correct time and speed.
205
+ * - Algorithms and constants defined above
206
+ */
207
+ export function assertTemporalProperties(mediaElement, properties, keyframes, syncState, enablePlaybackRateAdjustment) {
208
+ // On Webkit (using the simulator on safari and COGS mobile app on iOS) changes to currentTime and playbackRate are much less responsive.
209
+ // We make sure we only do lower frequency updates, and don't change playbackRate.
210
+ const playbackRateSync = enablePlaybackRateAdjustment && !IS_WEBKIT;
211
+ // At the end of the media, is it set back to the start?
212
+ // Sounds like looping to me!
213
+ let isLooping = false;
214
+ if (mediaElement.duration) {
215
+ const nextTemporalKeyframe = keyframes.filter(([t, kf]) => t > properties.t && (kf?.set?.t !== undefined || kf?.set?.rate !== undefined))[0];
216
+ if (nextTemporalKeyframe?.[1]?.set?.t === 0) {
217
+ const timeRemaining = (mediaElement.duration - properties.t) / properties.rate;
218
+ const timeUntilKeyframe = nextTemporalKeyframe[0] - properties.t;
219
+ isLooping = Math.abs(timeRemaining - timeUntilKeyframe) <= LOOPING_EPSILON_MS;
220
+ if (mediaElement.loop !== isLooping) {
221
+ mediaElement.loop = isLooping;
222
+ }
223
+ }
224
+ }
225
+ const currentTime = mediaElement.currentTime * 1000;
226
+ const deltaTime = isLooping && mediaElement.duration !== undefined
227
+ ? moduloDiff(currentTime, properties.t, mediaElement.duration * 1000)
228
+ : currentTime - properties.t;
229
+ const deltaTimeAbs = Math.abs(deltaTime);
230
+ switch (true) {
231
+ /**
232
+ * Seek ahead behavior
233
+ * When playbackRate adjustment is not enabled we will seek ahead and try to prepare to play.
234
+ * We'll make sure everything is buffered and ready, then wait until we're on time.
235
+ * We'll try to press play once and leave it to continue.
236
+ */
237
+ case !playbackRateSync && syncState.state === 'idle' && properties.rate > 0 && deltaTimeAbs > NO_SYNC_SEEK_AHEAD_OUTER_THRESHOLD_MS: {
238
+ const target = (properties.t + properties.rate * NO_SYNC_SEEK_LOOKAHEAD_MS) / 1000;
239
+ if (mediaElement.duration !== undefined && target > mediaElement.duration && !isLooping) {
240
+ // We're not looping, and this is past the end of the video
241
+ return { state: 'idle' };
242
+ }
243
+ assertPlaybackRate(mediaElement, 0);
244
+ mediaElement.currentTime = isLooping ? modulo(target, mediaElement.duration * 1000) : target;
245
+ return { state: 'seeking-ahead' };
246
+ }
247
+ case syncState.state === 'seeking-ahead' && mediaElement.seeking === true:
248
+ return { state: 'seeking-ahead' };
249
+ case syncState.state === 'seeking-ahead' && mediaElement.seeking === false: {
250
+ assertPlaybackRate(mediaElement, 0);
251
+ return { state: 'seeked-ahead' };
252
+ }
253
+ case syncState.state === 'seeked-ahead' && deltaTime < -NO_SYNC_SEEK_AHEAD_INNER_THRESHOLD_MS: {
254
+ assertPlaybackRate(mediaElement, properties.rate);
255
+ console.warn('Failed to seek ahead in time');
256
+ return { state: 'idle' };
257
+ }
258
+ case syncState.state === 'seeked-ahead' && deltaTimeAbs <= NO_SYNC_SEEK_AHEAD_INNER_THRESHOLD_MS: {
259
+ assertPlaybackRate(mediaElement, properties.rate);
260
+ return { state: 'idle' };
261
+ }
262
+ case syncState.state === 'seeked-ahead' && deltaTimeAbs > NO_SYNC_SEEK_AHEAD_OUTER_THRESHOLD_MS * 1.5: {
263
+ // This is an escape mechanism for this behavior. This may happen if the state changes after we've seeked ahead.
264
+ console.warn('Failed to seek ahead');
265
+ return { state: 'idle' };
266
+ }
267
+ case syncState.state === 'seeked-ahead':
268
+ return { state: 'seeked-ahead' };
269
+ /**
270
+ * Time synchronization behavior
271
+ * When playbackRate adjustment is enabled we will address small deviations in time by ramping speed up and down.
272
+ * We address larger deviations with a seek, hoping to land close enough so we can finely adjust with playbackRate.
273
+ */
274
+ // Start intercept
275
+ case playbackRateSync &&
276
+ syncState.state === 'idle' &&
277
+ properties.rate > 0 &&
278
+ deltaTimeAbs > SYNC_OUTER_TARGET_THRESHOLD_MS &&
279
+ deltaTimeAbs <= SYNC_MAX_THRESHOLD_MS:
280
+ {
281
+ const playbackRateAdjustment = playbackSmoothing(deltaTime);
282
+ const adjustedPlaybackRate = Math.max(0, properties.rate - playbackRateAdjustment);
283
+ assertPlaybackRate(mediaElement, adjustedPlaybackRate);
284
+ return { state: 'intercepting' };
285
+ }
286
+ // Perfectly intercepted
287
+ case syncState.state === 'intercepting' && deltaTimeAbs <= SYNC_INNER_TARGET_THRESHOLD_MS: {
288
+ assertPlaybackRate(mediaElement, properties.rate);
289
+ return { state: 'idle' };
290
+ }
291
+ // Intercept went too far
292
+ case syncState.state === 'intercepting' && Math.sign(deltaTime) === Math.sign(mediaElement.playbackRate - properties.rate): {
293
+ assertPlaybackRate(mediaElement, properties.rate);
294
+ return { state: 'idle' };
295
+ }
296
+ // We're still on course
297
+ case syncState.state === 'intercepting' && deltaTimeAbs < SYNC_MAX_THRESHOLD_MS * 2:
298
+ return { state: 'intercepting' };
299
+ // We're way off track
300
+ case syncState.state === 'intercepting':
301
+ assertPlaybackRate(mediaElement, properties.rate);
302
+ return { state: 'idle' };
303
+ /**
304
+ * Time synchronization behavior
305
+ * When playbackRate adjustment is enabled we will address small deviations in time by ramping speed up and down.
306
+ * We address larger deviations with a seek, hoping to land close enough so we can finely adjust with playbackRate.
307
+ */
308
+ case playbackRateSync && syncState.state === 'idle' && deltaTimeAbs > SYNC_MAX_THRESHOLD_MS: {
309
+ const seekTarget = (properties.t + properties.rate * SYNC_SEEK_LOOKAHEAD_MS) / 1000;
310
+ mediaElement.currentTime = isLooping ? modulo(seekTarget, mediaElement.duration * 1000) : seekTarget;
311
+ assertPlaybackRate(mediaElement, properties.rate);
312
+ return { state: 'seeking' };
313
+ }
314
+ case syncState.state === 'seeking' && mediaElement.seeking: {
315
+ return { state: 'seeking' };
316
+ }
317
+ case syncState.state === 'seeking' && !mediaElement.seeking: {
318
+ return { state: 'idle' };
319
+ }
320
+ /**
321
+ * Idle behavior
322
+ */
323
+ case syncState.state === 'idle':
324
+ assertPlaybackRate(mediaElement, properties.rate);
325
+ if (properties.rate === 0 && deltaTimeAbs > SYNC_OUTER_TARGET_THRESHOLD_MS) {
326
+ mediaElement.currentTime = properties.t / 1000;
327
+ }
328
+ return { state: 'idle' };
329
+ /**
330
+ * If none of the above conditions are met, we should exit the behavior.
331
+ * For example: we are intercepting but the media has now been paused
332
+ */
333
+ default: {
334
+ return { state: 'idle' };
335
+ }
336
+ }
337
+ }
338
+ export class ImageManager extends MediaClipManager {
339
+ imageElement;
340
+ update() {
341
+ const currentState = getStateAtTime(this._state, Date.now());
342
+ if (currentState) {
343
+ this.imageElement = assertElement(this.imageElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
344
+ }
345
+ else if (this.imageElement) {
346
+ this.destroy();
347
+ }
348
+ if (!currentState || !this.imageElement)
349
+ return;
350
+ assertVisualProperties(this.imageElement, currentState, this._state.fit);
351
+ }
352
+ destroy() {
353
+ if (this.imageElement) {
354
+ this.imageElement.remove();
355
+ this.imageElement.src = '';
356
+ this.imageElement = undefined;
357
+ }
358
+ }
359
+ }
360
+ export class AudioManager extends MediaClipManager {
361
+ syncState = { state: 'idle' };
362
+ audioElement;
363
+ volume = 1;
364
+ update() {
365
+ const currentState = getStateAtTime(this._state, Date.now());
366
+ if (currentState) {
367
+ this.audioElement = assertElement(this.audioElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
368
+ }
369
+ else {
370
+ this.destroy();
371
+ }
372
+ if (!currentState || !this.audioElement)
373
+ return;
374
+ const sinkId = this.getAudioOutput(this._state.audioOutput);
375
+ assertAudialProperties(this.audioElement, currentState, sinkId, this.volume);
376
+ const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState, this._state.enablePlaybackRateAdjustment);
377
+ this.syncState = nextSyncState;
378
+ }
379
+ destroy() {
380
+ if (this.audioElement) {
381
+ this.audioElement.pause();
382
+ this.audioElement.remove();
383
+ this.audioElement.volume = 0;
384
+ this.audioElement.currentTime = 0;
385
+ this.mediaPreloader.releaseElement(this.audioElement);
386
+ }
387
+ this.audioElement = undefined;
388
+ }
389
+ }
390
+ export class VideoManager extends MediaClipManager {
391
+ syncState = { state: 'idle' };
392
+ videoElement;
393
+ volume = 1;
394
+ update() {
395
+ const currentState = getStateAtTime(this._state, Date.now());
396
+ if (currentState) {
397
+ this.videoElement = assertElement(this.videoElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
398
+ }
399
+ else {
400
+ this.destroy();
401
+ }
402
+ if (!currentState || !this.videoElement)
403
+ return;
404
+ const sinkId = this.getAudioOutput(this._state.audioOutput);
405
+ assertVisualProperties(this.videoElement, currentState, this._state.fit);
406
+ assertAudialProperties(this.videoElement, currentState, sinkId, this.volume);
407
+ const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, this.syncState, this._state.enablePlaybackRateAdjustment);
408
+ this.syncState = nextSyncState;
409
+ }
410
+ destroy() {
411
+ if (this.videoElement) {
412
+ this.videoElement.pause();
413
+ this.videoElement.remove();
414
+ this.videoElement.volume = 0;
415
+ this.videoElement.currentTime = 0;
416
+ this.mediaPreloader.releaseElement(this.videoElement);
417
+ }
418
+ this.videoElement = undefined;
419
+ }
420
+ }
@@ -9,6 +9,6 @@ export declare class MediaPreloader {
9
9
  };
10
10
  setState(newState: MediaClientConfig['files']): void;
11
11
  private update;
12
- getElement(file: string, type: 'audio' | 'video'): HTMLAudioElement;
13
- releaseElement(resource: string | HTMLMediaElement): void;
12
+ getElement(file: string, type: 'audio' | 'video'): HTMLMediaElement;
13
+ releaseElement(resource: string | HTMLElement): void;
14
14
  }
@@ -17,7 +17,12 @@ export class MediaPreloader {
17
17
  // Clean up previous elements
18
18
  for (const [filename, media] of Object.entries(this._elements)) {
19
19
  if (!(filename in this._state)) {
20
+ if (media.inUse) {
21
+ console.warn(`Failed to clean up element ${media.element.src}`);
22
+ continue;
23
+ }
20
24
  media.element.src = '';
25
+ media.element.load();
21
26
  delete this._elements[filename];
22
27
  }
23
28
  media.inUse = media.element.isConnected;
@@ -64,7 +69,9 @@ export class MediaPreloader {
64
69
  else {
65
70
  const element = document.createElement(type);
66
71
  element.src = this._constructAssetURL(file);
67
- this._elements[file] = { element, type, inUse: true };
72
+ if (type === 'video') {
73
+ this._elements[file] = { element, type, inUse: true };
74
+ }
68
75
  return element;
69
76
  }
70
77
  }
@@ -76,9 +83,9 @@ export class MediaPreloader {
76
83
  }
77
84
  }
78
85
  else {
79
- Object.entries(this._elements).forEach(([file, media]) => {
86
+ Object.values(this._elements).forEach((media) => {
80
87
  if (media.element === resource) {
81
- delete this._elements[file];
88
+ media.inUse = false;
82
89
  }
83
90
  });
84
91
  }
@@ -8,12 +8,16 @@ export declare const DATA_CLIP_ID = "data-clip-id";
8
8
  */
9
9
  export declare class SurfaceManager {
10
10
  private constructAssetUrl;
11
+ private getAudioOutput;
11
12
  private mediaPreloader;
12
13
  private _state;
13
14
  setState(newState: MediaSurfaceState): void;
15
+ private _volume;
16
+ get volume(): number;
17
+ set volume(newVolume: number);
14
18
  private _element;
15
19
  get element(): HTMLDivElement;
16
20
  private resources;
17
- constructor(constructAssetUrl: (file: string) => string, initialState?: MediaSurfaceState, mediaPreloader?: MediaPreloader);
21
+ constructor(constructAssetUrl: (file: string) => string, getAudioOutput: (outputLabel: string) => string, testState?: MediaSurfaceState, mediaPreloader?: MediaPreloader);
18
22
  update(): void;
19
23
  }
@@ -1,6 +1,4 @@
1
- import { ImageManager } from './ImageManager';
2
- import { VideoManager } from './VideoManager';
3
- import { AudioManager } from './AudioManager';
1
+ import { AudioManager, ImageManager, VideoManager } from './MediaClipManager';
4
2
  import { MediaPreloader } from './MediaPreloader';
5
3
  export const DATA_CLIP_ID = 'data-clip-id';
6
4
  /**
@@ -10,25 +8,39 @@ export const DATA_CLIP_ID = 'data-clip-id';
10
8
  */
11
9
  export class SurfaceManager {
12
10
  constructAssetUrl;
11
+ getAudioOutput;
13
12
  mediaPreloader;
14
13
  _state = {};
15
14
  setState(newState) {
16
15
  this._state = newState;
17
16
  this.update();
18
17
  }
18
+ _volume = 1;
19
+ get volume() {
20
+ return this._volume;
21
+ }
22
+ set volume(newVolume) {
23
+ this._volume = newVolume;
24
+ Object.values(this.resources).forEach(({ manager }) => {
25
+ if (manager instanceof AudioManager || manager instanceof VideoManager) {
26
+ manager.volume = newVolume;
27
+ }
28
+ });
29
+ }
19
30
  _element;
20
31
  get element() {
21
32
  return this._element;
22
33
  }
23
34
  resources = {};
24
- constructor(constructAssetUrl, initialState = {}, mediaPreloader = new MediaPreloader(constructAssetUrl)) {
35
+ constructor(constructAssetUrl, getAudioOutput, testState, mediaPreloader = new MediaPreloader(constructAssetUrl)) {
25
36
  this.constructAssetUrl = constructAssetUrl;
37
+ this.getAudioOutput = getAudioOutput;
26
38
  this.mediaPreloader = mediaPreloader;
27
39
  this._element = document.createElement('div');
28
40
  this._element.className = 'surface-manager';
29
41
  this._element.style.width = '100%';
30
42
  this._element.style.height = '100%';
31
- this._state = initialState || {};
43
+ this._state = testState || {};
32
44
  this.update();
33
45
  }
34
46
  update() {
@@ -68,14 +80,23 @@ export class SurfaceManager {
68
80
  if (!resource.manager) {
69
81
  switch (clip.type) {
70
82
  case 'image':
71
- resource.manager = new ImageManager(this._element, resource.element, clip, this.constructAssetUrl);
83
+ resource.manager = new ImageManager(this._element, resource.element, clip, this.constructAssetUrl, this.getAudioOutput, this.mediaPreloader);
84
+ resource.manager.loop();
72
85
  break;
73
- case 'audio':
74
- resource.manager = new AudioManager(this._element, resource.element, clip, this.constructAssetUrl, this.mediaPreloader);
86
+ case 'audio': {
87
+ const audioManager = new AudioManager(this._element, resource.element, clip, this.constructAssetUrl, this.getAudioOutput, this.mediaPreloader);
88
+ resource.manager = audioManager;
89
+ audioManager.volume = this._volume;
90
+ audioManager.loop();
75
91
  break;
76
- case 'video':
77
- resource.manager = new VideoManager(this._element, resource.element, clip, this.constructAssetUrl, this.mediaPreloader);
92
+ }
93
+ case 'video': {
94
+ const videoManager = new VideoManager(this._element, resource.element, clip, this.constructAssetUrl, this.getAudioOutput, this.mediaPreloader);
95
+ resource.manager = videoManager;
96
+ videoManager.volume = this._volume;
97
+ videoManager.loop();
78
98
  break;
99
+ }
79
100
  }
80
101
  }
81
102
  else {