@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/index.d.ts
CHANGED
|
@@ -1,14 +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 { default as CogsAudioPlayer } from './AudioPlayer';
|
|
9
|
-
export { default as CogsVideoPlayer } from './VideoPlayer';
|
|
10
6
|
export { SurfaceManager } from './state-based/SurfaceManager';
|
|
11
|
-
export
|
|
7
|
+
export { MediaPreloader } from './state-based/MediaPreloader';
|
|
12
8
|
export { assetUrl, preloadUrl } from './utils/urls';
|
|
13
9
|
export { getStateAtTime } from './utils/getStateAtTime';
|
|
14
10
|
export * from './types/CogsPluginManifest';
|
package/dist/index.js
CHANGED
|
@@ -1,10 +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 { default as CogsAudioPlayer } from './AudioPlayer';
|
|
5
|
-
export { default as CogsVideoPlayer } from './VideoPlayer';
|
|
6
4
|
export { SurfaceManager } from './state-based/SurfaceManager';
|
|
7
|
-
export
|
|
5
|
+
export { MediaPreloader } from './state-based/MediaPreloader';
|
|
8
6
|
export { assetUrl, preloadUrl } from './utils/urls';
|
|
9
7
|
export { getStateAtTime } from './utils/getStateAtTime';
|
|
10
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { MediaClientConfig } from '../types/CogsClientMessage';
|
|
2
|
+
export declare class MediaPreloader {
|
|
3
|
+
private _state;
|
|
4
|
+
private _elements;
|
|
5
|
+
private _constructAssetURL;
|
|
6
|
+
constructor(constructAssetURL: (file: string) => string, testState?: MediaClientConfig['files']);
|
|
7
|
+
get state(): {
|
|
8
|
+
[x: string]: import("../types/CogsClientMessage").Media;
|
|
9
|
+
};
|
|
10
|
+
setState(newState: MediaClientConfig['files']): void;
|
|
11
|
+
private update;
|
|
12
|
+
getElement(file: string, type: 'audio' | 'video'): HTMLMediaElement;
|
|
13
|
+
releaseElement(resource: string | HTMLElement): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export class MediaPreloader {
|
|
2
|
+
_state;
|
|
3
|
+
_elements = {};
|
|
4
|
+
_constructAssetURL;
|
|
5
|
+
constructor(constructAssetURL, testState = {}) {
|
|
6
|
+
this._constructAssetURL = constructAssetURL;
|
|
7
|
+
this._state = testState;
|
|
8
|
+
}
|
|
9
|
+
get state() {
|
|
10
|
+
return { ...this._state };
|
|
11
|
+
}
|
|
12
|
+
setState(newState) {
|
|
13
|
+
this._state = newState;
|
|
14
|
+
this.update();
|
|
15
|
+
}
|
|
16
|
+
update() {
|
|
17
|
+
// Clean up previous elements
|
|
18
|
+
for (const [filename, media] of Object.entries(this._elements)) {
|
|
19
|
+
if (!(filename in this._state)) {
|
|
20
|
+
if (media.inUse) {
|
|
21
|
+
console.warn(`Failed to clean up element ${media.element.src}`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
media.element.src = '';
|
|
25
|
+
media.element.load();
|
|
26
|
+
delete this._elements[filename];
|
|
27
|
+
}
|
|
28
|
+
media.inUse = media.element.isConnected;
|
|
29
|
+
}
|
|
30
|
+
for (const [filename, fileConfig] of Object.entries(this._state)) {
|
|
31
|
+
if (filename in this._elements) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Create new elements
|
|
35
|
+
let preloadAttr;
|
|
36
|
+
if (fileConfig.preload === true) {
|
|
37
|
+
preloadAttr = 'auto';
|
|
38
|
+
}
|
|
39
|
+
else if (fileConfig.preload === false) {
|
|
40
|
+
preloadAttr = 'none';
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
preloadAttr = fileConfig.preload;
|
|
44
|
+
}
|
|
45
|
+
switch (fileConfig.type) {
|
|
46
|
+
case 'audio': {
|
|
47
|
+
const element = document.createElement('audio');
|
|
48
|
+
element.src = this._constructAssetURL(filename);
|
|
49
|
+
element.preload = preloadAttr;
|
|
50
|
+
this._elements[filename] = { element, inUse: false, type: 'audio' };
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'video': {
|
|
54
|
+
const element = document.createElement('video');
|
|
55
|
+
element.src = this._constructAssetURL(filename);
|
|
56
|
+
element.preload = preloadAttr;
|
|
57
|
+
this._elements[filename] = { element, inUse: false, type: 'video' };
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
getElement(file, type) {
|
|
64
|
+
const media = this._elements[file];
|
|
65
|
+
if (media && media.inUse === false) {
|
|
66
|
+
media.inUse = true;
|
|
67
|
+
return media.element;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const element = document.createElement(type);
|
|
71
|
+
element.src = this._constructAssetURL(file);
|
|
72
|
+
if (type === 'video') {
|
|
73
|
+
this._elements[file] = { element, type, inUse: true };
|
|
74
|
+
}
|
|
75
|
+
return element;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
releaseElement(resource) {
|
|
79
|
+
if (typeof resource === 'string') {
|
|
80
|
+
const media = this._elements[resource];
|
|
81
|
+
if (media) {
|
|
82
|
+
media.inUse = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
Object.values(this._elements).forEach((media) => {
|
|
87
|
+
if (media.element === resource) {
|
|
88
|
+
media.inUse = false;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MediaSurfaceState } from '../types/MediaSchema';
|
|
2
|
+
import { MediaPreloader } from './MediaPreloader';
|
|
2
3
|
export declare const DATA_CLIP_ID = "data-clip-id";
|
|
3
4
|
/**
|
|
4
5
|
* The SurfaceManager will receive state updates and:
|
|
@@ -6,11 +7,17 @@ export declare const DATA_CLIP_ID = "data-clip-id";
|
|
|
6
7
|
* - Instantiate a ClipManager attached to each respective element
|
|
7
8
|
*/
|
|
8
9
|
export declare class SurfaceManager {
|
|
10
|
+
private constructAssetUrl;
|
|
11
|
+
private getAudioOutput;
|
|
12
|
+
private mediaPreloader;
|
|
9
13
|
private _state;
|
|
10
14
|
setState(newState: MediaSurfaceState): void;
|
|
15
|
+
private _volume;
|
|
16
|
+
get volume(): number;
|
|
17
|
+
set volume(newVolume: number);
|
|
11
18
|
private _element;
|
|
12
19
|
get element(): HTMLDivElement;
|
|
13
20
|
private resources;
|
|
14
|
-
constructor(testState?: MediaSurfaceState);
|
|
21
|
+
constructor(constructAssetUrl: (file: string) => string, getAudioOutput: (outputLabel: string) => string, testState?: MediaSurfaceState, mediaPreloader?: MediaPreloader);
|
|
15
22
|
update(): void;
|
|
16
23
|
}
|