@clockworkdog/cogs-client 3.0.0-alpha.11 → 3.0.0-alpha.14

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/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * as MediaSchema from './types/MediaSchema';
8
8
  export { default as CogsAudioPlayer } from './AudioPlayer';
9
9
  export { default as CogsVideoPlayer } from './VideoPlayer';
10
10
  export { SurfaceManager } from './state-based/SurfaceManager';
11
+ export { MediaPreloader } from './state-based/MediaPreloader';
11
12
  export * from './types/AudioState';
12
13
  export { assetUrl, preloadUrl } from './utils/urls';
13
14
  export { getStateAtTime } from './utils/getStateAtTime';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * as MediaSchema from './types/MediaSchema';
4
4
  export { default as CogsAudioPlayer } from './AudioPlayer';
5
5
  export { default as CogsVideoPlayer } from './VideoPlayer';
6
6
  export { SurfaceManager } from './state-based/SurfaceManager';
7
+ export { MediaPreloader } from './state-based/MediaPreloader';
7
8
  export * from './types/AudioState';
8
9
  export { assetUrl, preloadUrl } from './utils/urls';
9
10
  export { getStateAtTime } from './utils/getStateAtTime';
@@ -8,23 +8,43 @@ export declare abstract class MediaClipManager<T extends MediaClipState> {
8
8
  private surfaceElement;
9
9
  protected clipElement: HTMLElement;
10
10
  protected constructAssetURL: (file: string) => string;
11
+ protected getAudioOutput: (outputLabel: string) => string;
11
12
  protected mediaPreloader: MediaPreloader;
12
- constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T, constructAssetURL: (file: string) => string, mediaPreloader: MediaPreloader);
13
+ constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T, constructAssetURL: (file: string) => string, getAudioOutput: (outputLabel: string) => string, mediaPreloader: MediaPreloader);
13
14
  protected abstract update(): void;
14
15
  abstract destroy(): void;
15
16
  isConnected(element?: HTMLElement): boolean;
16
17
  protected _state: T;
17
18
  setState(newState: T): void;
18
19
  private timeout;
19
- private loop;
20
+ loop: () => Promise<void>;
20
21
  }
21
- export declare function assertElement(mediaElement: HTMLElement | undefined, parentElement: HTMLElement, clip: MediaClipState, constructAssetURL: (file: string) => string, preloader: MediaPreloader): HTMLElement;
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
+ */
22
33
  export declare function assertVisualProperties(mediaElement: HTMLMediaElement | HTMLImageElement, properties: VisualProperties, objectFit: ImageMetadata['fit']): void;
23
- export declare function assertAudialProperties(mediaElement: HTMLMediaElement, properties: AudialProperties, sinkId: string): 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;
24
39
  interface TemporalSyncState {
25
40
  state: 'idle' | 'seeking' | 'intercepting';
26
41
  }
27
- export declare function assertTemporalProperties(mediaElement: HTMLMediaElement, properties: TemporalProperties, keyframes: VideoState['keyframes'], syncState: TemporalSyncState): TemporalSyncState;
42
+ /**
43
+ * Makes sure the media is at the correct time and speed.
44
+ * - If we fall slightly behind, we will lightly adjust the speed to catch up.
45
+ * - If we are too far away to smoothly realign, we will seek to the correct time.
46
+ */
47
+ export declare function assertTemporalProperties(mediaElement: HTMLMediaElement, properties: TemporalProperties, keyframes: VideoState['keyframes'], syncState: TemporalSyncState, disablePlaybackRateAdjustment?: boolean): TemporalSyncState;
28
48
  export declare class ImageManager extends MediaClipManager<ImageState> {
29
49
  private imageElement;
30
50
  protected update(): void;
@@ -33,12 +53,14 @@ export declare class ImageManager extends MediaClipManager<ImageState> {
33
53
  export declare class AudioManager extends MediaClipManager<AudioState> {
34
54
  private syncState;
35
55
  private audioElement;
56
+ volume: number;
36
57
  protected update(): void;
37
58
  destroy(): void;
38
59
  }
39
60
  export declare class VideoManager extends MediaClipManager<VideoState> {
40
61
  private syncState;
41
62
  private videoElement?;
63
+ volume: number;
42
64
  protected update(): void;
43
65
  destroy(): void;
44
66
  }
@@ -1,4 +1,14 @@
1
1
  import { getStateAtTime } from '../utils/getStateAtTime';
2
+ const getPath = (url) => {
3
+ try {
4
+ const { pathname } = new URL(url, window.location.href);
5
+ return pathname;
6
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
7
+ }
8
+ catch (_) {
9
+ return undefined;
10
+ }
11
+ };
2
12
  /**
3
13
  * Each instance of a MediaClipManager is responsible for displaying
4
14
  * an image/audio/video clip in the correct state.
@@ -7,15 +17,15 @@ export class MediaClipManager {
7
17
  surfaceElement;
8
18
  clipElement;
9
19
  constructAssetURL;
20
+ getAudioOutput;
10
21
  mediaPreloader;
11
- constructor(surfaceElement, clipElement, state, constructAssetURL, mediaPreloader) {
22
+ constructor(surfaceElement, clipElement, state, constructAssetURL, getAudioOutput, mediaPreloader) {
12
23
  this.surfaceElement = surfaceElement;
13
24
  this.clipElement = clipElement;
14
25
  this.constructAssetURL = constructAssetURL;
26
+ this.getAudioOutput = getAudioOutput;
15
27
  this.mediaPreloader = mediaPreloader;
16
28
  this._state = state;
17
- // Allow the class to be constructed, then call the loop
18
- setTimeout(this.loop);
19
29
  }
20
30
  isConnected(element) {
21
31
  if (!this.surfaceElement) {
@@ -39,50 +49,62 @@ export class MediaClipManager {
39
49
  }
40
50
  timeout;
41
51
  loop = async () => {
52
+ clearTimeout(this.timeout);
42
53
  if (this.isConnected()) {
43
54
  this.update();
44
- this.timeout = setTimeout(this.loop, 0);
55
+ this.timeout = setTimeout(this.loop, INNER_TARGET_SYNC_THRESHOLD_MS);
45
56
  }
46
57
  else {
47
58
  this.destroy();
48
59
  }
49
60
  };
50
61
  }
62
+ /**
63
+ * Makes sure that the child media element exists and is of the correct type
64
+ * - If it isn't or doesn't exist we'll get a new one
65
+ * - If it is audio or video we'll try and get a preloaded media element
66
+ * - Otherwise we'll directly create and set the src
67
+ */
51
68
  export function assertElement(mediaElement, parentElement, clip, constructAssetURL, preloader) {
52
- let element;
69
+ let element = undefined;
53
70
  const assetURL = constructAssetURL(clip.file);
71
+ const assetPath = getPath(assetURL);
54
72
  switch (clip.type) {
55
73
  case 'image':
56
74
  {
57
75
  element = mediaElement instanceof HTMLImageElement ? mediaElement : document.createElement('img');
58
- if (!element.src.includes(assetURL)) {
76
+ const elementPath = getPath(element.src);
77
+ if (elementPath !== assetPath) {
59
78
  element.src = assetURL;
60
79
  }
61
80
  }
62
81
  break;
63
82
  case 'audio':
64
- if (mediaElement instanceof HTMLAudioElement && mediaElement.src.includes(assetURL)) {
65
- element = mediaElement;
66
- }
67
- else {
68
- element = preloader.getElement(clip.file, clip.type);
69
- }
70
- break;
71
- case 'video':
72
- if (mediaElement instanceof HTMLVideoElement && mediaElement.src.includes(assetURL)) {
73
- element = mediaElement;
83
+ case 'video': {
84
+ if (mediaElement !== undefined) {
85
+ const path = getPath(mediaElement.src);
86
+ if (mediaElement.tagName.toLowerCase() === clip.type && path !== undefined && path === assetPath) {
87
+ element = mediaElement;
88
+ }
74
89
  }
75
- else {
90
+ if (!element) {
76
91
  element = preloader.getElement(clip.file, clip.type);
77
92
  }
78
93
  break;
94
+ }
95
+ }
96
+ if (parentElement.children.length !== 1 || parentElement.childNodes[0] !== element) {
97
+ parentElement.replaceChildren(element);
79
98
  }
80
- parentElement.replaceChildren(element);
81
99
  element.style.position = 'absolute';
82
100
  element.style.width = '100%';
83
101
  element.style.height = '100%';
84
102
  return element;
85
103
  }
104
+ /**
105
+ * Makes sure that the element looks correct.
106
+ * - If the opacity, zIndex or fit are incorrect, we'll set again
107
+ */
86
108
  export function assertVisualProperties(mediaElement, properties, objectFit) {
87
109
  const opacityString = String(properties.opacity);
88
110
  if (mediaElement.style.opacity !== opacityString) {
@@ -96,9 +118,14 @@ export function assertVisualProperties(mediaElement, properties, objectFit) {
96
118
  mediaElement.style.objectFit = objectFit;
97
119
  }
98
120
  }
99
- export function assertAudialProperties(mediaElement, properties, sinkId) {
100
- if (mediaElement.volume !== properties.volume) {
101
- mediaElement.volume = properties.volume;
121
+ /**
122
+ * Makes sure that the element sounds correct.
123
+ * - It should have the right volume, and play out the correct speaker.
124
+ */
125
+ export function assertAudialProperties(mediaElement, properties, sinkId, surfaceVolume) {
126
+ const clipVolume = properties.volume * surfaceVolume;
127
+ if (mediaElement.volume !== clipVolume) {
128
+ mediaElement.volume = clipVolume;
102
129
  }
103
130
  if (mediaElement.sinkId !== sinkId) {
104
131
  try {
@@ -113,6 +140,7 @@ export function assertAudialProperties(mediaElement, properties, sinkId) {
113
140
  }
114
141
  }
115
142
  const OUTER_TARGET_SYNC_THRESHOLD_MS = 50; // When outside of this range we attempt to sync playback
143
+ const OUTER_TARGET_SYNC_NO_PLAYBACK_RATE_ADJUSTMENT_THRESHOLD_MS = 500; // When outside of this range we attempt to sync playback (when playback rate adjustment not allowed)
116
144
  const INNER_TARGET_SYNC_THRESHOLD_MS = 5; // When attempting to sync playback, we aim for this accuracy
117
145
  const MAX_SYNC_THRESHOLD_MS = 1_000; // If we are further than this, we will seek instead
118
146
  const SEEK_LOOKAHEAD_MS = 5; // If it takes time to seek, we should seek ahead a little
@@ -122,7 +150,12 @@ const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.1;
122
150
  function playbackSmoothing(deltaTime) {
123
151
  return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
124
152
  }
125
- export function assertTemporalProperties(mediaElement, properties, keyframes, syncState) {
153
+ /**
154
+ * Makes sure the media is at the correct time and speed.
155
+ * - If we fall slightly behind, we will lightly adjust the speed to catch up.
156
+ * - If we are too far away to smoothly realign, we will seek to the correct time.
157
+ */
158
+ export function assertTemporalProperties(mediaElement, properties, keyframes, syncState, disablePlaybackRateAdjustment) {
126
159
  if (mediaElement.paused && properties.rate > 0) {
127
160
  mediaElement.play().catch(() => {
128
161
  /* Do nothing, will be tried in next loop */
@@ -154,6 +187,19 @@ export function assertTemporalProperties(mediaElement, properties, keyframes, sy
154
187
  }
155
188
  return { state: 'idle' };
156
189
  case syncState.state === 'idle' &&
190
+ properties.rate > 0 &&
191
+ disablePlaybackRateAdjustment === true &&
192
+ deltaTimeAbs <= OUTER_TARGET_SYNC_NO_PLAYBACK_RATE_ADJUSTMENT_THRESHOLD_MS:
193
+ // If we aren't able to adjust playback rate, we are more forgiving
194
+ // in our "synced" check to avoid the clip being seeked forward
195
+ // in normal playback where we expect to be a little out of sync
196
+ // due to network and startup latency
197
+ if (mediaElement.playbackRate !== properties.rate) {
198
+ mediaElement.playbackRate = properties.rate;
199
+ }
200
+ return { state: 'idle' };
201
+ case syncState.state === 'idle' &&
202
+ disablePlaybackRateAdjustment !== true && // Never adjust playback rate if disabled for this clip
157
203
  properties.rate > 0 &&
158
204
  deltaTimeAbs > OUTER_TARGET_SYNC_THRESHOLD_MS &&
159
205
  deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS:
@@ -177,7 +223,6 @@ export function assertTemporalProperties(mediaElement, properties, keyframes, sy
177
223
  }
178
224
  case syncState.state === 'intercepting' && Math.sign(deltaTime) === Math.sign(mediaElement.playbackRate - properties.rate): {
179
225
  // We have missed our interception. Go back to idle to try again.
180
- console.warn(deltaTime, 'missed intercept');
181
226
  return { state: 'idle' };
182
227
  }
183
228
  case syncState.state === 'intercepting':
@@ -221,6 +266,7 @@ export class ImageManager extends MediaClipManager {
221
266
  export class AudioManager extends MediaClipManager {
222
267
  syncState = { state: 'idle' };
223
268
  audioElement;
269
+ volume = 1;
224
270
  update() {
225
271
  const currentState = getStateAtTime(this._state, Date.now());
226
272
  if (currentState) {
@@ -231,8 +277,9 @@ export class AudioManager extends MediaClipManager {
231
277
  }
232
278
  if (!currentState || !this.audioElement)
233
279
  return;
234
- assertAudialProperties(this.audioElement, currentState, this._state.audioOutput);
235
- const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState);
280
+ const sinkId = this.getAudioOutput(this._state.audioOutput);
281
+ assertAudialProperties(this.audioElement, currentState, sinkId, this.volume);
282
+ const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState, this._state.disablePlaybackRateAdjustment);
236
283
  if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
237
284
  this.audioElement.addEventListener('seeked', () => {
238
285
  this.syncState = { state: 'idle' };
@@ -241,13 +288,20 @@ export class AudioManager extends MediaClipManager {
241
288
  this.syncState = nextSyncState;
242
289
  }
243
290
  destroy() {
244
- this.audioElement?.remove();
291
+ if (this.audioElement) {
292
+ this.audioElement.pause();
293
+ this.audioElement.remove();
294
+ this.audioElement.volume = 0;
295
+ this.audioElement.currentTime = 0;
296
+ this.mediaPreloader.releaseElement(this.audioElement);
297
+ }
245
298
  this.audioElement = undefined;
246
299
  }
247
300
  }
248
301
  export class VideoManager extends MediaClipManager {
249
302
  syncState = { state: 'idle' };
250
303
  videoElement;
304
+ volume = 1;
251
305
  update() {
252
306
  const currentState = getStateAtTime(this._state, Date.now());
253
307
  if (currentState) {
@@ -258,9 +312,10 @@ export class VideoManager extends MediaClipManager {
258
312
  }
259
313
  if (!currentState || !this.videoElement)
260
314
  return;
315
+ const sinkId = this.getAudioOutput(this._state.audioOutput);
261
316
  assertVisualProperties(this.videoElement, currentState, this._state.fit);
262
- assertAudialProperties(this.videoElement, currentState, this._state.audioOutput);
263
- const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, this.syncState);
317
+ assertAudialProperties(this.videoElement, currentState, sinkId, this.volume);
318
+ const nextSyncState = assertTemporalProperties(this.videoElement, currentState, this._state.keyframes, this.syncState, this._state.disablePlaybackRateAdjustment);
264
319
  if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
265
320
  this.videoElement.addEventListener('seeked', () => {
266
321
  this.syncState = { state: 'idle' };
@@ -269,7 +324,13 @@ export class VideoManager extends MediaClipManager {
269
324
  this.syncState = nextSyncState;
270
325
  }
271
326
  destroy() {
272
- this.videoElement?.remove();
327
+ if (this.videoElement) {
328
+ this.videoElement.pause();
329
+ this.videoElement.remove();
330
+ this.videoElement.volume = 0;
331
+ this.videoElement.currentTime = 0;
332
+ this.mediaPreloader.releaseElement(this.videoElement);
333
+ }
273
334
  this.videoElement = undefined;
274
335
  }
275
336
  }
@@ -10,5 +10,5 @@ export declare class MediaPreloader {
10
10
  setState(newState: MediaClientConfig['files']): void;
11
11
  private update;
12
12
  getElement(file: string, type: 'audio' | 'video'): HTMLAudioElement;
13
- releaseElement(resource: string | HTMLMediaElement): void;
13
+ releaseElement(resource: string | HTMLElement): void;
14
14
  }
@@ -83,9 +83,9 @@ export class MediaPreloader {
83
83
  }
84
84
  }
85
85
  else {
86
- Object.entries(this._elements).forEach(([file, media]) => {
86
+ Object.values(this._elements).forEach((media) => {
87
87
  if (media.element === resource) {
88
- delete this._elements[file];
88
+ media.inUse = false;
89
89
  }
90
90
  });
91
91
  }
@@ -12,6 +12,9 @@ export declare class SurfaceManager {
12
12
  private mediaPreloader;
13
13
  private _state;
14
14
  setState(newState: MediaSurfaceState): void;
15
+ private _volume;
16
+ get volume(): number;
17
+ set volume(newVolume: number);
15
18
  private _element;
16
19
  get element(): HTMLDivElement;
17
20
  private resources;
@@ -15,6 +15,18 @@ export class SurfaceManager {
15
15
  this._state = newState;
16
16
  this.update();
17
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
+ }
18
30
  _element;
19
31
  get element() {
20
32
  return this._element;
@@ -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, this.mediaPreloader);
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 {
@@ -194,6 +194,7 @@ export type AudioState = {
194
194
  type: 'audio';
195
195
  file: string;
196
196
  audioOutput: string;
197
+ disablePlaybackRateAdjustment?: boolean;
197
198
  keyframes: [InitialAudioKeyframe, ...Array<AudioKeyframe | NullKeyframe>];
198
199
  };
199
200
  export type VideoState = {
@@ -201,6 +202,7 @@ export type VideoState = {
201
202
  file: string;
202
203
  fit: 'cover' | 'contain' | 'none';
203
204
  audioOutput: string;
205
+ disablePlaybackRateAdjustment?: boolean;
204
206
  keyframes: [InitialVideoKeyframe, ...Array<VideoKeyframe | NullKeyframe>];
205
207
  };
206
208
  export type MediaClipState = ImageState | AudioState | VideoState;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Connect to COGS to build a custom Media Master",
4
4
  "author": "Clockwork Dog <info@clockwork.dog>",
5
5
  "homepage": "https://github.com/clockwork-dog/cogs-sdk/tree/main/packages/javascript",
6
- "version": "3.0.0-alpha.11",
6
+ "version": "3.0.0-alpha.14",
7
7
  "keywords": [],
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "cy:generate": "cypress run --e2e"
38
38
  },
39
39
  "dependencies": {
40
- "@clockworkdog/timesync": "^3.0.0-alpha.11",
40
+ "@clockworkdog/timesync": "^3.0.0-alpha.14",
41
41
  "howler": "clockwork-dog/howler.js#fix-looping-clips",
42
42
  "reconnecting-websocket": "^4.4.0",
43
43
  "zod": "^4.1.13"