@clockworkdog/cogs-client 3.0.0-alpha.1 → 3.0.0-alpha.11
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/AudioPlayer.js +6 -5
- package/dist/CogsConnection.js +37 -20
- package/dist/DataStore.js +11 -15
- package/dist/VideoPlayer.js +10 -7
- package/dist/browser/index.mjs +1576 -1274
- package/dist/browser/index.umd.js +6 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/state-based/MediaClipManager.d.ts +45 -0
- package/dist/state-based/MediaClipManager.js +275 -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 +20 -0
- package/dist/state-based/SurfaceManager.js +86 -0
- package/dist/types/MediaSchema.d.ts +13 -0
- package/dist/types/MediaSchema.js +3 -0
- package/dist/utils/getStateAtTime.js +8 -3
- package/package.json +12 -4
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type { default as MediaObjectFit } from './types/MediaObjectFit';
|
|
|
7
7
|
export * as MediaSchema from './types/MediaSchema';
|
|
8
8
|
export { default as CogsAudioPlayer } from './AudioPlayer';
|
|
9
9
|
export { default as CogsVideoPlayer } from './VideoPlayer';
|
|
10
|
+
export { SurfaceManager } from './state-based/SurfaceManager';
|
|
10
11
|
export * from './types/AudioState';
|
|
11
12
|
export { assetUrl, preloadUrl } from './utils/urls';
|
|
12
13
|
export { getStateAtTime } from './utils/getStateAtTime';
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export * from './CogsConnection';
|
|
|
3
3
|
export * as MediaSchema from './types/MediaSchema';
|
|
4
4
|
export { default as CogsAudioPlayer } from './AudioPlayer';
|
|
5
5
|
export { default as CogsVideoPlayer } from './VideoPlayer';
|
|
6
|
+
export { SurfaceManager } from './state-based/SurfaceManager';
|
|
6
7
|
export * from './types/AudioState';
|
|
7
8
|
export { assetUrl, preloadUrl } from './utils/urls';
|
|
8
9
|
export { getStateAtTime } from './utils/getStateAtTime';
|
|
@@ -0,0 +1,45 @@
|
|
|
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 mediaPreloader: MediaPreloader;
|
|
12
|
+
constructor(surfaceElement: HTMLElement, clipElement: HTMLElement, state: T, constructAssetURL: (file: string) => string, mediaPreloader: MediaPreloader);
|
|
13
|
+
protected abstract update(): void;
|
|
14
|
+
abstract destroy(): void;
|
|
15
|
+
isConnected(element?: HTMLElement): boolean;
|
|
16
|
+
protected _state: T;
|
|
17
|
+
setState(newState: T): void;
|
|
18
|
+
private timeout;
|
|
19
|
+
private loop;
|
|
20
|
+
}
|
|
21
|
+
export declare function assertElement(mediaElement: HTMLElement | undefined, parentElement: HTMLElement, clip: MediaClipState, constructAssetURL: (file: string) => string, preloader: MediaPreloader): HTMLElement;
|
|
22
|
+
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;
|
|
24
|
+
interface TemporalSyncState {
|
|
25
|
+
state: 'idle' | 'seeking' | 'intercepting';
|
|
26
|
+
}
|
|
27
|
+
export declare function assertTemporalProperties(mediaElement: HTMLMediaElement, properties: TemporalProperties, keyframes: VideoState['keyframes'], syncState: TemporalSyncState): TemporalSyncState;
|
|
28
|
+
export declare class ImageManager extends MediaClipManager<ImageState> {
|
|
29
|
+
private imageElement;
|
|
30
|
+
protected update(): void;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare class AudioManager extends MediaClipManager<AudioState> {
|
|
34
|
+
private syncState;
|
|
35
|
+
private audioElement;
|
|
36
|
+
protected update(): void;
|
|
37
|
+
destroy(): void;
|
|
38
|
+
}
|
|
39
|
+
export declare class VideoManager extends MediaClipManager<VideoState> {
|
|
40
|
+
private syncState;
|
|
41
|
+
private videoElement?;
|
|
42
|
+
protected update(): void;
|
|
43
|
+
destroy(): void;
|
|
44
|
+
}
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { getStateAtTime } from '../utils/getStateAtTime';
|
|
2
|
+
/**
|
|
3
|
+
* Each instance of a MediaClipManager is responsible for displaying
|
|
4
|
+
* an image/audio/video clip in the correct state.
|
|
5
|
+
*/
|
|
6
|
+
export class MediaClipManager {
|
|
7
|
+
surfaceElement;
|
|
8
|
+
clipElement;
|
|
9
|
+
constructAssetURL;
|
|
10
|
+
mediaPreloader;
|
|
11
|
+
constructor(surfaceElement, clipElement, state, constructAssetURL, mediaPreloader) {
|
|
12
|
+
this.surfaceElement = surfaceElement;
|
|
13
|
+
this.clipElement = clipElement;
|
|
14
|
+
this.constructAssetURL = constructAssetURL;
|
|
15
|
+
this.mediaPreloader = mediaPreloader;
|
|
16
|
+
this._state = state;
|
|
17
|
+
// Allow the class to be constructed, then call the loop
|
|
18
|
+
setTimeout(this.loop);
|
|
19
|
+
}
|
|
20
|
+
isConnected(element) {
|
|
21
|
+
if (!this.surfaceElement) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (!this.clipElement) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (!this.surfaceElement.contains(this.clipElement)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (element) {
|
|
31
|
+
if (!this.clipElement.contains(element))
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
_state;
|
|
37
|
+
setState(newState) {
|
|
38
|
+
this._state = newState;
|
|
39
|
+
}
|
|
40
|
+
timeout;
|
|
41
|
+
loop = async () => {
|
|
42
|
+
if (this.isConnected()) {
|
|
43
|
+
this.update();
|
|
44
|
+
this.timeout = setTimeout(this.loop, 0);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
this.destroy();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function assertElement(mediaElement, parentElement, clip, constructAssetURL, preloader) {
|
|
52
|
+
let element;
|
|
53
|
+
const assetURL = constructAssetURL(clip.file);
|
|
54
|
+
switch (clip.type) {
|
|
55
|
+
case 'image':
|
|
56
|
+
{
|
|
57
|
+
element = mediaElement instanceof HTMLImageElement ? mediaElement : document.createElement('img');
|
|
58
|
+
if (!element.src.includes(assetURL)) {
|
|
59
|
+
element.src = assetURL;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
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;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
element = preloader.getElement(clip.file, clip.type);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
parentElement.replaceChildren(element);
|
|
81
|
+
element.style.position = 'absolute';
|
|
82
|
+
element.style.width = '100%';
|
|
83
|
+
element.style.height = '100%';
|
|
84
|
+
return element;
|
|
85
|
+
}
|
|
86
|
+
export function assertVisualProperties(mediaElement, properties, objectFit) {
|
|
87
|
+
const opacityString = String(properties.opacity);
|
|
88
|
+
if (mediaElement.style.opacity !== opacityString) {
|
|
89
|
+
mediaElement.style.opacity = opacityString;
|
|
90
|
+
}
|
|
91
|
+
const zIndex = Math.round(properties.zIndex ?? 0);
|
|
92
|
+
if (parseInt(mediaElement.style.zIndex) !== zIndex) {
|
|
93
|
+
mediaElement.style.zIndex = String(zIndex);
|
|
94
|
+
}
|
|
95
|
+
if (mediaElement.style.objectFit !== objectFit) {
|
|
96
|
+
mediaElement.style.objectFit = objectFit;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function assertAudialProperties(mediaElement, properties, sinkId) {
|
|
100
|
+
if (mediaElement.volume !== properties.volume) {
|
|
101
|
+
mediaElement.volume = properties.volume;
|
|
102
|
+
}
|
|
103
|
+
if (mediaElement.sinkId !== sinkId) {
|
|
104
|
+
try {
|
|
105
|
+
mediaElement.setSinkId(sinkId).catch(() => {
|
|
106
|
+
/* Do nothing, will be tried in next loop */
|
|
107
|
+
});
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
109
|
+
}
|
|
110
|
+
catch (_) {
|
|
111
|
+
/* Do nothing, will be tried in next loop */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const OUTER_TARGET_SYNC_THRESHOLD_MS = 50; // When outside of this range we attempt to sync playback
|
|
116
|
+
const INNER_TARGET_SYNC_THRESHOLD_MS = 5; // When attempting to sync playback, we aim for this accuracy
|
|
117
|
+
const MAX_SYNC_THRESHOLD_MS = 1_000; // If we are further than this, we will seek instead
|
|
118
|
+
const SEEK_LOOKAHEAD_MS = 5; // If it takes time to seek, we should seek ahead a little
|
|
119
|
+
const LOOPING_EPSILON_MS = 5;
|
|
120
|
+
const PLAYBACK_ADJUSTMENT_SMOOTHING = 0.3;
|
|
121
|
+
const MAX_PLAYBACK_RATE_ADJUSTMENT = 0.1;
|
|
122
|
+
function playbackSmoothing(deltaTime) {
|
|
123
|
+
return Math.sign(deltaTime) * Math.pow(Math.abs(deltaTime) / MAX_SYNC_THRESHOLD_MS, PLAYBACK_ADJUSTMENT_SMOOTHING) * MAX_PLAYBACK_RATE_ADJUSTMENT;
|
|
124
|
+
}
|
|
125
|
+
export function assertTemporalProperties(mediaElement, properties, keyframes, syncState) {
|
|
126
|
+
if (mediaElement.paused && properties.rate > 0) {
|
|
127
|
+
mediaElement.play().catch(() => {
|
|
128
|
+
/* Do nothing, will be tried in next loop */
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// At the end of the media, is it set back to the start?
|
|
132
|
+
// Sounds like looping to me!
|
|
133
|
+
if (mediaElement.duration) {
|
|
134
|
+
const nextTemporalKeyframe = keyframes.filter(([t, kf]) => t > properties.t && (kf?.set?.t !== undefined || kf?.set?.rate !== undefined))[0];
|
|
135
|
+
if (nextTemporalKeyframe?.[1]?.set?.t === 0) {
|
|
136
|
+
const timeRemaining = (mediaElement.duration - properties.t) / properties.rate;
|
|
137
|
+
const timeUntilKeyframe = nextTemporalKeyframe[0] - properties.t;
|
|
138
|
+
const isLooping = Math.abs(timeRemaining - timeUntilKeyframe) <= LOOPING_EPSILON_MS;
|
|
139
|
+
if (mediaElement.loop !== isLooping) {
|
|
140
|
+
mediaElement.loop = isLooping;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const currentTime = mediaElement.currentTime * 1000;
|
|
145
|
+
const deltaTime = currentTime - properties.t;
|
|
146
|
+
const deltaTimeAbs = Math.abs(deltaTime);
|
|
147
|
+
switch (true) {
|
|
148
|
+
case syncState.state === 'idle' && properties.rate > 0 && deltaTimeAbs <= OUTER_TARGET_SYNC_THRESHOLD_MS:
|
|
149
|
+
// We are on course:
|
|
150
|
+
// - The video is within accepted latency of the server time
|
|
151
|
+
// - The playback rate is aligned with the server rate
|
|
152
|
+
if (mediaElement.playbackRate !== properties.rate) {
|
|
153
|
+
mediaElement.playbackRate = properties.rate;
|
|
154
|
+
}
|
|
155
|
+
return { state: 'idle' };
|
|
156
|
+
case syncState.state === 'idle' &&
|
|
157
|
+
properties.rate > 0 &&
|
|
158
|
+
deltaTimeAbs > OUTER_TARGET_SYNC_THRESHOLD_MS &&
|
|
159
|
+
deltaTimeAbs <= MAX_SYNC_THRESHOLD_MS:
|
|
160
|
+
{
|
|
161
|
+
// We are close, we can smoothly adjust with playbackRate:
|
|
162
|
+
// - The video must be playing
|
|
163
|
+
// - We must be close in time to the server time
|
|
164
|
+
const playbackRateAdjustment = playbackSmoothing(deltaTime);
|
|
165
|
+
const adjustedPlaybackRate = Math.max(0, properties.rate - playbackRateAdjustment);
|
|
166
|
+
if (mediaElement.playbackRate !== adjustedPlaybackRate) {
|
|
167
|
+
mediaElement.playbackRate = adjustedPlaybackRate;
|
|
168
|
+
}
|
|
169
|
+
return { state: 'intercepting' };
|
|
170
|
+
}
|
|
171
|
+
case syncState.state === 'intercepting' && properties.rate > 0 && deltaTimeAbs <= INNER_TARGET_SYNC_THRESHOLD_MS: {
|
|
172
|
+
// We have intercepted, we can now play normally
|
|
173
|
+
if (mediaElement.playbackRate !== properties.rate) {
|
|
174
|
+
mediaElement.playbackRate = properties.rate;
|
|
175
|
+
}
|
|
176
|
+
return { state: 'idle' };
|
|
177
|
+
}
|
|
178
|
+
case syncState.state === 'intercepting' && Math.sign(deltaTime) === Math.sign(mediaElement.playbackRate - properties.rate): {
|
|
179
|
+
// We have missed our interception. Go back to idle to try again.
|
|
180
|
+
console.warn(deltaTime, 'missed intercept');
|
|
181
|
+
return { state: 'idle' };
|
|
182
|
+
}
|
|
183
|
+
case syncState.state === 'intercepting':
|
|
184
|
+
return { state: 'intercepting' };
|
|
185
|
+
case syncState.state === 'seeking': {
|
|
186
|
+
return { state: 'seeking' };
|
|
187
|
+
}
|
|
188
|
+
default: {
|
|
189
|
+
// We cannot smoothly recover:
|
|
190
|
+
// - We seek just ahead of server time
|
|
191
|
+
if (mediaElement.playbackRate !== properties.rate) {
|
|
192
|
+
mediaElement.playbackRate = properties.rate;
|
|
193
|
+
}
|
|
194
|
+
mediaElement.currentTime = (properties.t + properties.rate * SEEK_LOOKAHEAD_MS) / 1000;
|
|
195
|
+
return { state: 'seeking' };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export class ImageManager extends MediaClipManager {
|
|
200
|
+
imageElement;
|
|
201
|
+
update() {
|
|
202
|
+
const currentState = getStateAtTime(this._state, Date.now());
|
|
203
|
+
if (currentState) {
|
|
204
|
+
this.imageElement = assertElement(this.imageElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
|
|
205
|
+
}
|
|
206
|
+
else if (this.imageElement) {
|
|
207
|
+
this.destroy();
|
|
208
|
+
}
|
|
209
|
+
if (!currentState || !this.imageElement)
|
|
210
|
+
return;
|
|
211
|
+
assertVisualProperties(this.imageElement, currentState, this._state.fit);
|
|
212
|
+
}
|
|
213
|
+
destroy() {
|
|
214
|
+
if (this.imageElement) {
|
|
215
|
+
this.imageElement.remove();
|
|
216
|
+
this.imageElement.src = '';
|
|
217
|
+
this.imageElement = undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export class AudioManager extends MediaClipManager {
|
|
222
|
+
syncState = { state: 'idle' };
|
|
223
|
+
audioElement;
|
|
224
|
+
update() {
|
|
225
|
+
const currentState = getStateAtTime(this._state, Date.now());
|
|
226
|
+
if (currentState) {
|
|
227
|
+
this.audioElement = assertElement(this.audioElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
this.destroy();
|
|
231
|
+
}
|
|
232
|
+
if (!currentState || !this.audioElement)
|
|
233
|
+
return;
|
|
234
|
+
assertAudialProperties(this.audioElement, currentState, this._state.audioOutput);
|
|
235
|
+
const nextSyncState = assertTemporalProperties(this.audioElement, currentState, this._state.keyframes, this.syncState);
|
|
236
|
+
if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
|
|
237
|
+
this.audioElement.addEventListener('seeked', () => {
|
|
238
|
+
this.syncState = { state: 'idle' };
|
|
239
|
+
}, { passive: true, once: true });
|
|
240
|
+
}
|
|
241
|
+
this.syncState = nextSyncState;
|
|
242
|
+
}
|
|
243
|
+
destroy() {
|
|
244
|
+
this.audioElement?.remove();
|
|
245
|
+
this.audioElement = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export class VideoManager extends MediaClipManager {
|
|
249
|
+
syncState = { state: 'idle' };
|
|
250
|
+
videoElement;
|
|
251
|
+
update() {
|
|
252
|
+
const currentState = getStateAtTime(this._state, Date.now());
|
|
253
|
+
if (currentState) {
|
|
254
|
+
this.videoElement = assertElement(this.videoElement, this.clipElement, this._state, this.constructAssetURL, this.mediaPreloader);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
this.destroy();
|
|
258
|
+
}
|
|
259
|
+
if (!currentState || !this.videoElement)
|
|
260
|
+
return;
|
|
261
|
+
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);
|
|
264
|
+
if (this.syncState.state !== 'seeking' && nextSyncState.state === 'seeking') {
|
|
265
|
+
this.videoElement.addEventListener('seeked', () => {
|
|
266
|
+
this.syncState = { state: 'idle' };
|
|
267
|
+
}, { passive: true, once: true });
|
|
268
|
+
}
|
|
269
|
+
this.syncState = nextSyncState;
|
|
270
|
+
}
|
|
271
|
+
destroy() {
|
|
272
|
+
this.videoElement?.remove();
|
|
273
|
+
this.videoElement = undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -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'): HTMLAudioElement;
|
|
13
|
+
releaseElement(resource: string | HTMLMediaElement): 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.entries(this._elements).forEach(([file, media]) => {
|
|
87
|
+
if (media.element === resource) {
|
|
88
|
+
delete this._elements[file];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { MediaSurfaceState } from '../types/MediaSchema';
|
|
2
|
+
import { MediaPreloader } from './MediaPreloader';
|
|
3
|
+
export declare const DATA_CLIP_ID = "data-clip-id";
|
|
4
|
+
/**
|
|
5
|
+
* The SurfaceManager will receive state updates and:
|
|
6
|
+
* - Ensure that each clip has a parent element
|
|
7
|
+
* - Instantiate a ClipManager attached to each respective element
|
|
8
|
+
*/
|
|
9
|
+
export declare class SurfaceManager {
|
|
10
|
+
private constructAssetUrl;
|
|
11
|
+
private getAudioOutput;
|
|
12
|
+
private mediaPreloader;
|
|
13
|
+
private _state;
|
|
14
|
+
setState(newState: MediaSurfaceState): void;
|
|
15
|
+
private _element;
|
|
16
|
+
get element(): HTMLDivElement;
|
|
17
|
+
private resources;
|
|
18
|
+
constructor(constructAssetUrl: (file: string) => string, getAudioOutput: (outputLabel: string) => string, testState?: MediaSurfaceState, mediaPreloader?: MediaPreloader);
|
|
19
|
+
update(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { AudioManager, ImageManager, VideoManager } from './MediaClipManager';
|
|
2
|
+
import { MediaPreloader } from './MediaPreloader';
|
|
3
|
+
export const DATA_CLIP_ID = 'data-clip-id';
|
|
4
|
+
/**
|
|
5
|
+
* The SurfaceManager will receive state updates and:
|
|
6
|
+
* - Ensure that each clip has a parent element
|
|
7
|
+
* - Instantiate a ClipManager attached to each respective element
|
|
8
|
+
*/
|
|
9
|
+
export class SurfaceManager {
|
|
10
|
+
constructAssetUrl;
|
|
11
|
+
getAudioOutput;
|
|
12
|
+
mediaPreloader;
|
|
13
|
+
_state = {};
|
|
14
|
+
setState(newState) {
|
|
15
|
+
this._state = newState;
|
|
16
|
+
this.update();
|
|
17
|
+
}
|
|
18
|
+
_element;
|
|
19
|
+
get element() {
|
|
20
|
+
return this._element;
|
|
21
|
+
}
|
|
22
|
+
resources = {};
|
|
23
|
+
constructor(constructAssetUrl, getAudioOutput, testState, mediaPreloader = new MediaPreloader(constructAssetUrl)) {
|
|
24
|
+
this.constructAssetUrl = constructAssetUrl;
|
|
25
|
+
this.getAudioOutput = getAudioOutput;
|
|
26
|
+
this.mediaPreloader = mediaPreloader;
|
|
27
|
+
this._element = document.createElement('div');
|
|
28
|
+
this._element.className = 'surface-manager';
|
|
29
|
+
this._element.style.width = '100%';
|
|
30
|
+
this._element.style.height = '100%';
|
|
31
|
+
this._state = testState || {};
|
|
32
|
+
this.update();
|
|
33
|
+
}
|
|
34
|
+
update() {
|
|
35
|
+
// Destroy stale managers
|
|
36
|
+
Object.entries(this.resources).forEach(([clipId, { element, manager }]) => {
|
|
37
|
+
if (!(clipId in this._state)) {
|
|
38
|
+
delete this.resources[clipId];
|
|
39
|
+
element.remove();
|
|
40
|
+
manager?.destroy();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// Create and attach new wrapper elements
|
|
44
|
+
const elements = Object.keys(this._state)
|
|
45
|
+
.toSorted()
|
|
46
|
+
.map((clipId) => {
|
|
47
|
+
const resource = this.resources[clipId];
|
|
48
|
+
if (resource) {
|
|
49
|
+
return resource.element;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const element = document.createElement('div');
|
|
53
|
+
element.setAttribute(DATA_CLIP_ID, clipId);
|
|
54
|
+
this.resources[clipId] = { element };
|
|
55
|
+
return element;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
this._element.replaceChildren(...elements);
|
|
59
|
+
// Create new managers
|
|
60
|
+
Object.keys(this._state)
|
|
61
|
+
.toSorted()
|
|
62
|
+
.forEach((clipId) => {
|
|
63
|
+
const clip = this._state[clipId];
|
|
64
|
+
const resource = this.resources[clipId];
|
|
65
|
+
if (!resource) {
|
|
66
|
+
throw new Error('Failed to create resource');
|
|
67
|
+
}
|
|
68
|
+
if (!resource.manager) {
|
|
69
|
+
switch (clip.type) {
|
|
70
|
+
case 'image':
|
|
71
|
+
resource.manager = new ImageManager(this._element, resource.element, clip, this.constructAssetUrl, this.mediaPreloader);
|
|
72
|
+
break;
|
|
73
|
+
case 'audio':
|
|
74
|
+
resource.manager = new AudioManager(this._element, resource.element, clip, this.constructAssetUrl, this.mediaPreloader);
|
|
75
|
+
break;
|
|
76
|
+
case 'video':
|
|
77
|
+
resource.manager = new VideoManager(this._element, resource.element, clip, this.constructAssetUrl, this.mediaPreloader);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
resource.manager.setState(clip);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -7,6 +7,7 @@ declare const TemporalProperties: z.ZodObject<{
|
|
|
7
7
|
export type VisualProperties = z.infer<typeof VisualProperties>;
|
|
8
8
|
declare const VisualProperties: z.ZodObject<{
|
|
9
9
|
opacity: z.ZodNumber;
|
|
10
|
+
zIndex: z.ZodDefault<z.ZodNumber>;
|
|
10
11
|
}, z.core.$strip>;
|
|
11
12
|
export type AudialProperties = z.infer<typeof AudialProperties>;
|
|
12
13
|
declare const AudialProperties: z.ZodObject<{
|
|
@@ -40,6 +41,7 @@ export type InitialImageKeyframe = z.infer<typeof InitialImageKeyframe>;
|
|
|
40
41
|
declare const InitialImageKeyframe: z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
41
42
|
set: z.ZodOptional<z.ZodObject<{
|
|
42
43
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
44
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
43
45
|
}, z.core.$strip>>;
|
|
44
46
|
}, z.core.$strip>], null>;
|
|
45
47
|
/**
|
|
@@ -49,9 +51,11 @@ export type ImageKeyframe = z.infer<typeof ImageKeyframe>;
|
|
|
49
51
|
declare const ImageKeyframe: z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
50
52
|
set: z.ZodOptional<z.ZodObject<{
|
|
51
53
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
54
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
52
55
|
}, z.core.$strip>>;
|
|
53
56
|
lerp: z.ZodOptional<z.ZodObject<{
|
|
54
57
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
58
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
55
59
|
}, z.core.$strip>>;
|
|
56
60
|
}, z.core.$strip>], null>;
|
|
57
61
|
/**
|
|
@@ -86,6 +90,7 @@ export type InitialVideoKeyframe = z.infer<typeof InitialVideoKeyframe>;
|
|
|
86
90
|
declare const InitialVideoKeyframe: z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
87
91
|
set: z.ZodOptional<z.ZodObject<{
|
|
88
92
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
93
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
89
94
|
volume: z.ZodOptional<z.ZodNumber>;
|
|
90
95
|
t: z.ZodOptional<z.ZodNumber>;
|
|
91
96
|
rate: z.ZodOptional<z.ZodNumber>;
|
|
@@ -98,12 +103,14 @@ export type VideoKeyframe = z.infer<typeof VideoKeyframe>;
|
|
|
98
103
|
declare const VideoKeyframe: z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
99
104
|
set: z.ZodOptional<z.ZodObject<{
|
|
100
105
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
106
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
101
107
|
volume: z.ZodOptional<z.ZodNumber>;
|
|
102
108
|
t: z.ZodOptional<z.ZodNumber>;
|
|
103
109
|
rate: z.ZodOptional<z.ZodNumber>;
|
|
104
110
|
}, z.core.$strip>>;
|
|
105
111
|
lerp: z.ZodOptional<z.ZodObject<{
|
|
106
112
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
113
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
107
114
|
volume: z.ZodOptional<z.ZodNumber>;
|
|
108
115
|
}, z.core.$strip>>;
|
|
109
116
|
}, z.core.$strip>], null>;
|
|
@@ -111,13 +118,16 @@ export declare const MediaSurfaceStateSchema: z.ZodRecord<z.ZodString, z.ZodUnio
|
|
|
111
118
|
keyframes: z.ZodTuple<[z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
112
119
|
set: z.ZodOptional<z.ZodObject<{
|
|
113
120
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
121
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
114
122
|
}, z.core.$strip>>;
|
|
115
123
|
lerp: z.ZodOptional<z.ZodObject<{
|
|
116
124
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
125
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
117
126
|
}, z.core.$strip>>;
|
|
118
127
|
}, z.core.$strip>], null>], z.ZodUnion<readonly [z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
119
128
|
set: z.ZodOptional<z.ZodObject<{
|
|
120
129
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
130
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
121
131
|
}, z.core.$strip>>;
|
|
122
132
|
}, z.core.$strip>], null>, z.ZodTuple<[z.ZodNumber, z.ZodNull], null>]>>;
|
|
123
133
|
type: z.ZodLiteral<"image">;
|
|
@@ -147,17 +157,20 @@ export declare const MediaSurfaceStateSchema: z.ZodRecord<z.ZodString, z.ZodUnio
|
|
|
147
157
|
keyframes: z.ZodTuple<[z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
148
158
|
set: z.ZodOptional<z.ZodObject<{
|
|
149
159
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
160
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
150
161
|
volume: z.ZodOptional<z.ZodNumber>;
|
|
151
162
|
t: z.ZodOptional<z.ZodNumber>;
|
|
152
163
|
rate: z.ZodOptional<z.ZodNumber>;
|
|
153
164
|
}, z.core.$strip>>;
|
|
154
165
|
lerp: z.ZodOptional<z.ZodObject<{
|
|
155
166
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
167
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
156
168
|
volume: z.ZodOptional<z.ZodNumber>;
|
|
157
169
|
}, z.core.$strip>>;
|
|
158
170
|
}, z.core.$strip>], null>], z.ZodUnion<readonly [z.ZodTuple<[z.ZodNumber, z.ZodObject<{
|
|
159
171
|
set: z.ZodOptional<z.ZodObject<{
|
|
160
172
|
opacity: z.ZodOptional<z.ZodNumber>;
|
|
173
|
+
zIndex: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
161
174
|
volume: z.ZodOptional<z.ZodNumber>;
|
|
162
175
|
t: z.ZodOptional<z.ZodNumber>;
|
|
163
176
|
rate: z.ZodOptional<z.ZodNumber>;
|
|
@@ -5,6 +5,7 @@ const TemporalProperties = z.object({
|
|
|
5
5
|
});
|
|
6
6
|
const VisualProperties = z.object({
|
|
7
7
|
opacity: z.number().gte(0).lte(1),
|
|
8
|
+
zIndex: z.number().default(0),
|
|
8
9
|
});
|
|
9
10
|
const AudialProperties = z.object({
|
|
10
11
|
volume: z.number().gte(0).lte(1),
|
|
@@ -139,6 +140,7 @@ true;
|
|
|
139
140
|
true;
|
|
140
141
|
export const defaultImageOptions = {
|
|
141
142
|
opacity: 1,
|
|
143
|
+
zIndex: 0,
|
|
142
144
|
};
|
|
143
145
|
export const defaultAudioOptions = {
|
|
144
146
|
t: 0,
|
|
@@ -150,4 +152,5 @@ export const defaultVideoOptions = {
|
|
|
150
152
|
rate: 1,
|
|
151
153
|
volume: 1,
|
|
152
154
|
opacity: 1,
|
|
155
|
+
zIndex: 0,
|
|
153
156
|
};
|