@clockworkdog/cogs-client 3.0.0-alpha.13 → 3.0.0-alpha.15

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';
@@ -17,15 +17,34 @@ export declare abstract class MediaClipManager<T extends MediaClipState> {
17
17
  protected _state: T;
18
18
  setState(newState: T): void;
19
19
  private timeout;
20
- private loop;
20
+ loop: () => Promise<void>;
21
21
  }
22
- 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
+ */
23
33
  export declare function assertVisualProperties(mediaElement: HTMLMediaElement | HTMLImageElement, properties: VisualProperties, objectFit: ImageMetadata['fit']): void;
24
- 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;
25
39
  interface TemporalSyncState {
26
40
  state: 'idle' | 'seeking' | 'intercepting';
27
41
  }
28
- 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;
29
48
  export declare class ImageManager extends MediaClipManager<ImageState> {
30
49
  private imageElement;
31
50
  protected update(): void;
@@ -34,12 +53,14 @@ export declare class ImageManager extends MediaClipManager<ImageState> {
34
53
  export declare class AudioManager extends MediaClipManager<AudioState> {
35
54
  private syncState;
36
55
  private audioElement;
56
+ volume: number;
37
57
  protected update(): void;
38
58
  destroy(): void;
39
59
  }
40
60
  export declare class VideoManager extends MediaClipManager<VideoState> {
41
61
  private syncState;
42
62
  private videoElement?;
63
+ volume: number;
43
64
  protected update(): void;
44
65
  destroy(): void;
45
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.
@@ -16,8 +26,6 @@ export class MediaClipManager {
16
26
  this.getAudioOutput = getAudioOutput;
17
27
  this.mediaPreloader = mediaPreloader;
18
28
  this._state = state;
19
- // Allow the class to be constructed, then call the loop
20
- setTimeout(this.loop);
21
29
  }
22
30
  isConnected(element) {
23
31
  if (!this.surfaceElement) {
@@ -41,43 +49,49 @@ export class MediaClipManager {
41
49
  }
42
50
  timeout;
43
51
  loop = async () => {
52
+ clearTimeout(this.timeout);
44
53
  if (this.isConnected()) {
45
54
  this.update();
46
- this.timeout = setTimeout(this.loop, 0);
55
+ this.timeout = setTimeout(this.loop, INNER_TARGET_SYNC_THRESHOLD_MS);
47
56
  }
48
57
  else {
49
58
  this.destroy();
50
59
  }
51
60
  };
52
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
+ */
53
68
  export function assertElement(mediaElement, parentElement, clip, constructAssetURL, preloader) {
54
- let element;
69
+ let element = undefined;
55
70
  const assetURL = constructAssetURL(clip.file);
71
+ const assetPath = getPath(assetURL);
56
72
  switch (clip.type) {
57
73
  case 'image':
58
74
  {
59
75
  element = mediaElement instanceof HTMLImageElement ? mediaElement : document.createElement('img');
60
- if (!element.src.includes(assetURL)) {
76
+ const elementPath = getPath(element.src);
77
+ if (elementPath !== assetPath) {
61
78
  element.src = assetURL;
62
79
  }
63
80
  }
64
81
  break;
65
82
  case 'audio':
66
- if (mediaElement instanceof HTMLAudioElement && mediaElement.src.includes(assetURL)) {
67
- element = mediaElement;
68
- }
69
- else {
70
- element = preloader.getElement(clip.file, clip.type);
71
- }
72
- break;
73
- case 'video':
74
- if (mediaElement instanceof HTMLVideoElement && mediaElement.src.includes(assetURL)) {
75
- 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
+ }
76
89
  }
77
- else {
90
+ if (!element) {
78
91
  element = preloader.getElement(clip.file, clip.type);
79
92
  }
80
93
  break;
94
+ }
81
95
  }
82
96
  if (parentElement.children.length !== 1 || parentElement.childNodes[0] !== element) {
83
97
  parentElement.replaceChildren(element);
@@ -87,6 +101,10 @@ export function assertElement(mediaElement, parentElement, clip, constructAssetU
87
101
  element.style.height = '100%';
88
102
  return element;
89
103
  }
104
+ /**
105
+ * Makes sure that the element looks correct.
106
+ * - If the opacity, zIndex or fit are incorrect, we'll set again
107
+ */
90
108
  export function assertVisualProperties(mediaElement, properties, objectFit) {
91
109
  const opacityString = String(properties.opacity);
92
110
  if (mediaElement.style.opacity !== opacityString) {
@@ -100,9 +118,14 @@ export function assertVisualProperties(mediaElement, properties, objectFit) {
100
118
  mediaElement.style.objectFit = objectFit;
101
119
  }
102
120
  }
103
- export function assertAudialProperties(mediaElement, properties, sinkId) {
104
- if (mediaElement.volume !== properties.volume) {
105
- 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;
106
129
  }
107
130
  if (mediaElement.sinkId !== sinkId) {
108
131
  try {
@@ -117,6 +140,7 @@ export function assertAudialProperties(mediaElement, properties, sinkId) {
117
140
  }
118
141
  }
119
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)
120
144
  const INNER_TARGET_SYNC_THRESHOLD_MS = 5; // When attempting to sync playback, we aim for this accuracy
121
145
  const MAX_SYNC_THRESHOLD_MS = 1_000; // If we are further than this, we will seek instead
122
146
  const SEEK_LOOKAHEAD_MS = 5; // If it takes time to seek, we should seek ahead a little
@@ -126,7 +150,12 @@ const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.1;
126
150
  function playbackSmoothing(deltaTime) {
127
151
  return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
128
152
  }
129
- 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) {
130
159
  if (mediaElement.paused && properties.rate > 0) {
131
160
  mediaElement.play().catch(() => {
132
161
  /* Do nothing, will be tried in next loop */
@@ -158,6 +187,19 @@ export function assertTemporalProperties(mediaElement, properties, keyframes, sy
158
187
  }
159
188
  return { state: 'idle' };
160
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
161
203
  properties.rate > 0 &&
162
204
  deltaTimeAbs > OUTER_TARGET_SYNC_THRESHOLD_MS &&
163
205
  deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS:
@@ -181,7 +223,6 @@ export function assertTemporalProperties(mediaElement, properties, keyframes, sy
181
223
  }
182
224
  case syncState.state === 'intercepting' && Math.sign(deltaTime) === Math.sign(mediaElement.playbackRate - properties.rate): {
183
225
  // We have missed our interception. Go back to idle to try again.
184
- console.warn(deltaTime, 'missed intercept');
185
226
  return { state: 'idle' };
186
227
  }
187
228
  case syncState.state === 'intercepting':
@@ -225,6 +266,7 @@ export class ImageManager extends MediaClipManager {
225
266
  export class AudioManager extends MediaClipManager {
226
267
  syncState = { state: 'idle' };
227
268
  audioElement;
269
+ volume = 1;
228
270
  update() {
229
271
  const currentState = getStateAtTime(this._state, Date.now());
230
272
  if (currentState) {
@@ -236,8 +278,8 @@ export class AudioManager extends MediaClipManager {
236
278
  if (!currentState || !this.audioElement)
237
279
  return;
238
280
  const sinkId = this.getAudioOutput(this._state.audioOutput);
239
- assertAudialProperties(this.audioElement, currentState, sinkId);
240
- const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState);
281
+ assertAudialProperties(this.audioElement, currentState, sinkId, this.volume);
282
+ const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState, this._state.disablePlaybackRateAdjustment);
241
283
  if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
242
284
  this.audioElement.addEventListener('seeked', () => {
243
285
  this.syncState = { state: 'idle' };
@@ -246,13 +288,20 @@ export class AudioManager extends MediaClipManager {
246
288
  this.syncState = nextSyncState;
247
289
  }
248
290
  destroy() {
249
- 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
+ }
250
298
  this.audioElement = undefined;
251
299
  }
252
300
  }
253
301
  export class VideoManager extends MediaClipManager {
254
302
  syncState = { state: 'idle' };
255
303
  videoElement;
304
+ volume = 1;
256
305
  update() {
257
306
  const currentState = getStateAtTime(this._state, Date.now());
258
307
  if (currentState) {
@@ -265,8 +314,8 @@ export class VideoManager extends MediaClipManager {
265
314
  return;
266
315
  const sinkId = this.getAudioOutput(this._state.audioOutput);
267
316
  assertVisualProperties(this.videoElement, currentState, this._state.fit);
268
- assertAudialProperties(this.videoElement, currentState, sinkId);
269
- 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);
270
319
  if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
271
320
  this.videoElement.addEventListener('seeked', () => {
272
321
  this.syncState = { state: 'idle' };
@@ -275,7 +324,13 @@ export class VideoManager extends MediaClipManager {
275
324
  this.syncState = nextSyncState;
276
325
  }
277
326
  destroy() {
278
- 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
+ }
279
334
  this.videoElement = undefined;
280
335
  }
281
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;
@@ -69,13 +81,22 @@ export class SurfaceManager {
69
81
  switch (clip.type) {
70
82
  case 'image':
71
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.getAudioOutput, 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.getAudioOutput, 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.13",
6
+ "version": "3.0.0-alpha.15",
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.13",
40
+ "@clockworkdog/timesync": "^3.0.0-alpha.15",
41
41
  "howler": "clockwork-dog/howler.js#fix-looping-clips",
42
42
  "reconnecting-websocket": "^4.4.0",
43
43
  "zod": "^4.1.13"