@mediafox/core 1.2.8 → 1.2.10
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/compositor/compositor.d.ts.map +1 -1
- package/dist/compositor-worker.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +4 -3
- package/src/compositor/audio-manager.ts +411 -0
- package/src/compositor/compositor-worker.ts +158 -0
- package/src/compositor/compositor.ts +931 -0
- package/src/compositor/index.ts +19 -0
- package/src/compositor/source-pool.ts +450 -0
- package/src/compositor/types.ts +103 -0
- package/src/compositor/worker-client.ts +139 -0
- package/src/compositor/worker-types.ts +67 -0
- package/src/core/player-core.ts +273 -0
- package/src/core/state-facade.ts +98 -0
- package/src/core/track-switcher.ts +127 -0
- package/src/events/emitter.ts +137 -0
- package/src/events/types.ts +24 -0
- package/src/index.ts +124 -0
- package/src/mediafox.ts +642 -0
- package/src/playback/audio.ts +361 -0
- package/src/playback/controller.ts +446 -0
- package/src/playback/renderer.ts +1176 -0
- package/src/playback/renderers/canvas2d.ts +128 -0
- package/src/playback/renderers/factory.ts +172 -0
- package/src/playback/renderers/index.ts +5 -0
- package/src/playback/renderers/types.ts +57 -0
- package/src/playback/renderers/webgl.ts +373 -0
- package/src/playback/renderers/webgpu.ts +395 -0
- package/src/playlist/manager.ts +268 -0
- package/src/plugins/context.ts +93 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/manager.ts +482 -0
- package/src/plugins/types.ts +243 -0
- package/src/sources/manager.ts +285 -0
- package/src/sources/source.ts +84 -0
- package/src/sources/types.ts +17 -0
- package/src/state/store.ts +389 -0
- package/src/state/types.ts +18 -0
- package/src/tracks/manager.ts +421 -0
- package/src/tracks/types.ts +30 -0
- package/src/types/jassub.d.ts +1 -0
- package/src/types.ts +235 -0
- package/src/utils/async-lock.ts +26 -0
- package/src/utils/dispose.ts +28 -0
- package/src/utils/equal.ts +33 -0
- package/src/utils/errors.ts +74 -0
- package/src/utils/logger.ts +50 -0
- package/src/utils/time.ts +157 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { InputAudioTrack, InputVideoTrack } from 'mediabunny';
|
|
2
|
+
import type { PluginManager } from '../plugins/manager';
|
|
3
|
+
import { AudioManager } from './audio';
|
|
4
|
+
import { VideoRenderer } from './renderer';
|
|
5
|
+
import type { RendererType, Rotation } from './renderers';
|
|
6
|
+
|
|
7
|
+
// Constants for timing intervals
|
|
8
|
+
const RENDER_INTERVAL_MS = 100; // Interval for background tab rendering
|
|
9
|
+
const SYNC_INTERVAL_MS = 250; // Interval for time update sync
|
|
10
|
+
|
|
11
|
+
export interface PlaybackControllerOptions {
|
|
12
|
+
canvas?: HTMLCanvasElement | OffscreenCanvas;
|
|
13
|
+
audioContext?: AudioContext;
|
|
14
|
+
volume?: number;
|
|
15
|
+
muted?: boolean;
|
|
16
|
+
playbackRate?: number;
|
|
17
|
+
rendererType?: RendererType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PlaybackController {
|
|
21
|
+
private videoRenderer: VideoRenderer;
|
|
22
|
+
private audioManager: AudioManager;
|
|
23
|
+
private playing = false;
|
|
24
|
+
private currentTime = 0;
|
|
25
|
+
private duration = 0;
|
|
26
|
+
private playbackRate = 1;
|
|
27
|
+
private animationFrameId: number | null = null;
|
|
28
|
+
private lastFrameTime = 0;
|
|
29
|
+
private syncIntervalId: number | null = null;
|
|
30
|
+
private renderIntervalId: number | null = null;
|
|
31
|
+
private isWaiting = false;
|
|
32
|
+
private onTimeUpdate?: (time: number) => void;
|
|
33
|
+
private onEnded?: () => void;
|
|
34
|
+
private onWaiting?: () => void;
|
|
35
|
+
private onPlaying?: () => void;
|
|
36
|
+
|
|
37
|
+
constructor(options: PlaybackControllerOptions = {}) {
|
|
38
|
+
this.videoRenderer = new VideoRenderer({
|
|
39
|
+
canvas: options.canvas,
|
|
40
|
+
rendererType: options.rendererType,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.audioManager = new AudioManager({
|
|
44
|
+
audioContext: options.audioContext,
|
|
45
|
+
volume: options.volume,
|
|
46
|
+
muted: options.muted,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.playbackRate = options.playbackRate ?? 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async setVideoTrack(track: InputVideoTrack | null): Promise<void> {
|
|
53
|
+
if (!track) {
|
|
54
|
+
this.videoRenderer.dispose();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await this.videoRenderer.setVideoTrack(track);
|
|
59
|
+
const duration = await track.computeDuration();
|
|
60
|
+
this.duration = Math.max(this.duration, duration);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Attempt to set video track without throwing. Returns true on success.
|
|
65
|
+
*/
|
|
66
|
+
async trySetVideoTrack(track: InputVideoTrack | null): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
await this.setVideoTrack(track);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async setAudioTrack(track: InputAudioTrack | null): Promise<void> {
|
|
76
|
+
const resumePlayback = this.playing;
|
|
77
|
+
const resumeTime = this.getCurrentTime();
|
|
78
|
+
|
|
79
|
+
if (!track) {
|
|
80
|
+
this.audioManager.dispose();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const trackDuration = await track.computeDuration();
|
|
85
|
+
const clampedResumeTime = Math.max(0, Math.min(resumeTime, trackDuration));
|
|
86
|
+
|
|
87
|
+
await this.audioManager.setAudioTrack(track);
|
|
88
|
+
await this.audioManager.seek(clampedResumeTime);
|
|
89
|
+
|
|
90
|
+
if (resumePlayback) {
|
|
91
|
+
await this.audioManager.play(clampedResumeTime);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.currentTime = clampedResumeTime;
|
|
95
|
+
this.duration = Math.max(this.duration, trackDuration);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Attempt to set audio track without throwing. Returns true on success.
|
|
100
|
+
*/
|
|
101
|
+
async trySetAudioTrack(track: InputAudioTrack | null): Promise<boolean> {
|
|
102
|
+
try {
|
|
103
|
+
await this.setAudioTrack(track);
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async setCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<void> {
|
|
111
|
+
await this.videoRenderer.setCanvas(canvas);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async play(): Promise<void> {
|
|
115
|
+
if (this.playing) return;
|
|
116
|
+
|
|
117
|
+
this.playing = true;
|
|
118
|
+
this.lastFrameTime = performance.now();
|
|
119
|
+
|
|
120
|
+
// If we're at the end, restart from beginning
|
|
121
|
+
if (this.currentTime >= this.duration) {
|
|
122
|
+
this.currentTime = 0;
|
|
123
|
+
await this.videoRenderer.seek(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Start audio playback
|
|
127
|
+
await this.audioManager.play(this.currentTime);
|
|
128
|
+
|
|
129
|
+
// Start video rendering loop
|
|
130
|
+
this.startRenderLoop();
|
|
131
|
+
|
|
132
|
+
// Start sync interval for time updates
|
|
133
|
+
this.startSyncInterval();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pause(): void {
|
|
137
|
+
if (!this.playing) return;
|
|
138
|
+
|
|
139
|
+
this.playing = false;
|
|
140
|
+
|
|
141
|
+
// Clear waiting state without emitting playing event (we're pausing, not playing)
|
|
142
|
+
this.isWaiting = false;
|
|
143
|
+
|
|
144
|
+
// Pause audio
|
|
145
|
+
this.audioManager.pause();
|
|
146
|
+
|
|
147
|
+
// Stop render loop
|
|
148
|
+
this.stopRenderLoop();
|
|
149
|
+
|
|
150
|
+
// Stop sync interval
|
|
151
|
+
this.stopSyncInterval();
|
|
152
|
+
|
|
153
|
+
// Update current time to audio time (most accurate)
|
|
154
|
+
if (this.audioManager.isPlaying()) {
|
|
155
|
+
this.currentTime = this.audioManager.getCurrentTime();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async seek(time: number): Promise<void> {
|
|
160
|
+
const clampedTime = Math.max(0, Math.min(time, this.duration));
|
|
161
|
+
this.currentTime = clampedTime;
|
|
162
|
+
|
|
163
|
+
// Seek video - this will start a new iterator
|
|
164
|
+
await this.videoRenderer.seek(clampedTime);
|
|
165
|
+
|
|
166
|
+
// Seek audio
|
|
167
|
+
await this.audioManager.seek(clampedTime);
|
|
168
|
+
|
|
169
|
+
// Notify time update
|
|
170
|
+
if (this.onTimeUpdate) {
|
|
171
|
+
this.onTimeUpdate(this.currentTime);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private startRenderLoop(): void {
|
|
176
|
+
if (this.animationFrameId !== null || this.renderIntervalId !== null) {
|
|
177
|
+
// Already running
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const render = (requestNextFrame = true) => {
|
|
182
|
+
if (!this.playing) return;
|
|
183
|
+
|
|
184
|
+
// Get accurate time from audio manager if available
|
|
185
|
+
if (this.audioManager.isPlaying()) {
|
|
186
|
+
this.currentTime = this.audioManager.getCurrentTime();
|
|
187
|
+
} else {
|
|
188
|
+
// Fallback to manual time tracking
|
|
189
|
+
const now = performance.now();
|
|
190
|
+
const deltaTime = (now - this.lastFrameTime) / 1000;
|
|
191
|
+
this.lastFrameTime = now;
|
|
192
|
+
this.currentTime += deltaTime * this.playbackRate;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check for end of playback
|
|
196
|
+
if (this.currentTime >= this.duration) {
|
|
197
|
+
this.handleEnded();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Update video frame synchronously
|
|
202
|
+
const { isStarving } = this.videoRenderer.updateFrame(this.currentTime);
|
|
203
|
+
|
|
204
|
+
// Handle buffering state changes
|
|
205
|
+
if (isStarving && !this.isWaiting) {
|
|
206
|
+
this.isWaiting = true;
|
|
207
|
+
if (this.onWaiting) {
|
|
208
|
+
this.onWaiting();
|
|
209
|
+
}
|
|
210
|
+
} else if (!isStarving && this.isWaiting) {
|
|
211
|
+
this.isWaiting = false;
|
|
212
|
+
if (this.onPlaying) {
|
|
213
|
+
this.onPlaying();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Schedule next frame
|
|
218
|
+
if (requestNextFrame) {
|
|
219
|
+
this.animationFrameId = requestAnimationFrame(() => render());
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Start the render loop
|
|
224
|
+
this.animationFrameId = requestAnimationFrame(() => render());
|
|
225
|
+
|
|
226
|
+
// Also call render on an interval to ensure updates even when tab is not visible
|
|
227
|
+
if (this.renderIntervalId === null) {
|
|
228
|
+
this.renderIntervalId = window.setInterval(() => render(false), RENDER_INTERVAL_MS);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private stopRenderLoop(): void {
|
|
233
|
+
if (this.animationFrameId !== null) {
|
|
234
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
235
|
+
this.animationFrameId = null;
|
|
236
|
+
}
|
|
237
|
+
if (this.renderIntervalId !== null) {
|
|
238
|
+
clearInterval(this.renderIntervalId);
|
|
239
|
+
this.renderIntervalId = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private startSyncInterval(): void {
|
|
244
|
+
if (this.syncIntervalId !== null) return;
|
|
245
|
+
// Sync for time updates
|
|
246
|
+
this.syncIntervalId = window.setInterval(() => {
|
|
247
|
+
if (this.playing && this.onTimeUpdate) {
|
|
248
|
+
this.onTimeUpdate(this.currentTime);
|
|
249
|
+
}
|
|
250
|
+
}, SYNC_INTERVAL_MS);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private stopSyncInterval(): void {
|
|
254
|
+
if (this.syncIntervalId !== null) {
|
|
255
|
+
clearInterval(this.syncIntervalId);
|
|
256
|
+
this.syncIntervalId = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private handleEnded(): void {
|
|
261
|
+
this.pause();
|
|
262
|
+
this.currentTime = this.duration;
|
|
263
|
+
|
|
264
|
+
if (this.onEnded) {
|
|
265
|
+
this.onEnded();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getCurrentTime(): number {
|
|
270
|
+
if (this.playing && this.audioManager.isPlaying()) {
|
|
271
|
+
return this.audioManager.getCurrentTime();
|
|
272
|
+
}
|
|
273
|
+
return this.currentTime;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getDuration(): number {
|
|
277
|
+
return this.duration;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
setDuration(duration: number): void {
|
|
281
|
+
this.duration = duration;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
isPlaying(): boolean {
|
|
285
|
+
return this.playing;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setVolume(volume: number): void {
|
|
289
|
+
this.audioManager.setVolume(volume);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getVolume(): number {
|
|
293
|
+
return this.audioManager.getVolume();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
setMuted(muted: boolean): void {
|
|
297
|
+
this.audioManager.setMuted(muted);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
isMuted(): boolean {
|
|
301
|
+
return this.audioManager.isMuted();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
setPlaybackRate(rate: number): void {
|
|
305
|
+
const clamped = Math.max(0.25, Math.min(4, rate));
|
|
306
|
+
if (this.playbackRate === clamped) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.playbackRate = clamped;
|
|
311
|
+
this.audioManager.setPlaybackRate(clamped);
|
|
312
|
+
|
|
313
|
+
// Reset manual timer to avoid jumps when running without audio
|
|
314
|
+
this.lastFrameTime = performance.now();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getPlaybackRate(): number {
|
|
318
|
+
return this.playbackRate;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
setTimeUpdateCallback(callback: (time: number) => void): void {
|
|
322
|
+
this.onTimeUpdate = callback;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
setEndedCallback(callback: () => void): void {
|
|
326
|
+
this.onEnded = callback;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
setWaitingCallback(callback: () => void): void {
|
|
330
|
+
this.onWaiting = callback;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
setPlayingCallback(callback: () => void): void {
|
|
334
|
+
this.onPlaying = callback;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
isBuffering(): boolean {
|
|
338
|
+
return this.isWaiting;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async screenshot(options?: { format?: 'png' | 'jpeg' | 'webp'; quality?: number }): Promise<Blob | null> {
|
|
342
|
+
return this.videoRenderer.screenshot(this.currentTime, options);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getVideoRenderer(): VideoRenderer {
|
|
346
|
+
return this.videoRenderer;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
getAudioManager(): AudioManager {
|
|
350
|
+
return this.audioManager;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async switchRenderer(type: RendererType): Promise<void> {
|
|
354
|
+
await this.videoRenderer.switchRenderer(type);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
getRendererType(): RendererType {
|
|
358
|
+
return this.videoRenderer.getRendererType();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
updateCanvasDimensions(): void {
|
|
362
|
+
this.videoRenderer.updateCanvasDimensions();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
setRendererChangeCallback(callback: (type: RendererType) => void): void {
|
|
366
|
+
this.videoRenderer.setRendererChangeCallback(callback);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
setRendererFallbackCallback(callback: (from: RendererType, to: RendererType) => void): void {
|
|
370
|
+
this.videoRenderer.setRendererFallbackCallback(callback);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
setRotationChangeCallback(
|
|
374
|
+
callback: (rotation: Rotation, displaySize: { width: number; height: number }) => void
|
|
375
|
+
): void {
|
|
376
|
+
this.videoRenderer.setRotationChangeCallback(callback);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
setRotation(rotation: Rotation): void {
|
|
380
|
+
this.videoRenderer.setRotation(rotation);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
getRotation(): Rotation {
|
|
384
|
+
return this.videoRenderer.getRotation();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
getDisplaySize(): { width: number; height: number } {
|
|
388
|
+
return this.videoRenderer.getDisplaySize();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
setPluginManager(pluginManager: PluginManager): void {
|
|
392
|
+
this.videoRenderer.setPluginManager(pluginManager);
|
|
393
|
+
this.audioManager.setPluginManager(pluginManager);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
getCanvas(): HTMLCanvasElement | OffscreenCanvas | null {
|
|
397
|
+
return this.videoRenderer.getCanvas();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
refreshOverlays(): void {
|
|
401
|
+
this.videoRenderer.refreshOverlays();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Rebuild the audio graph with current plugin hooks.
|
|
406
|
+
* Call this after installing/uninstalling audio plugins.
|
|
407
|
+
*/
|
|
408
|
+
rebuildAudioGraph(): void {
|
|
409
|
+
this.audioManager.rebuildAudioGraph();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async reset(): Promise<void> {
|
|
413
|
+
// Stop playback completely
|
|
414
|
+
this.pause();
|
|
415
|
+
|
|
416
|
+
// Clear any pending animation frames/intervals first
|
|
417
|
+
this.stopRenderLoop();
|
|
418
|
+
this.stopSyncInterval();
|
|
419
|
+
|
|
420
|
+
// Reset time to beginning
|
|
421
|
+
this.currentTime = 0;
|
|
422
|
+
this.duration = 0;
|
|
423
|
+
|
|
424
|
+
// Clear iterators to stop in-flight async operations before input is disposed
|
|
425
|
+
await Promise.all([this.videoRenderer.clearIterators(), this.audioManager.clearIterators()]);
|
|
426
|
+
|
|
427
|
+
// Reset playback rate to default
|
|
428
|
+
this.playbackRate = 1;
|
|
429
|
+
this.lastFrameTime = 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
dispose(): void {
|
|
433
|
+
this.pause();
|
|
434
|
+
this.videoRenderer.dispose();
|
|
435
|
+
this.audioManager.dispose();
|
|
436
|
+
this.onTimeUpdate = undefined;
|
|
437
|
+
this.onEnded = undefined;
|
|
438
|
+
this.onWaiting = undefined;
|
|
439
|
+
this.onPlaying = undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
destroy(): void {
|
|
443
|
+
this.dispose();
|
|
444
|
+
this.audioManager.destroy();
|
|
445
|
+
}
|
|
446
|
+
}
|