@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.
Files changed (48) hide show
  1. package/dist/compositor/compositor.d.ts.map +1 -1
  2. package/dist/compositor-worker.js +1 -1
  3. package/dist/index.js +1 -1
  4. package/package.json +4 -3
  5. package/src/compositor/audio-manager.ts +411 -0
  6. package/src/compositor/compositor-worker.ts +158 -0
  7. package/src/compositor/compositor.ts +931 -0
  8. package/src/compositor/index.ts +19 -0
  9. package/src/compositor/source-pool.ts +450 -0
  10. package/src/compositor/types.ts +103 -0
  11. package/src/compositor/worker-client.ts +139 -0
  12. package/src/compositor/worker-types.ts +67 -0
  13. package/src/core/player-core.ts +273 -0
  14. package/src/core/state-facade.ts +98 -0
  15. package/src/core/track-switcher.ts +127 -0
  16. package/src/events/emitter.ts +137 -0
  17. package/src/events/types.ts +24 -0
  18. package/src/index.ts +124 -0
  19. package/src/mediafox.ts +642 -0
  20. package/src/playback/audio.ts +361 -0
  21. package/src/playback/controller.ts +446 -0
  22. package/src/playback/renderer.ts +1176 -0
  23. package/src/playback/renderers/canvas2d.ts +128 -0
  24. package/src/playback/renderers/factory.ts +172 -0
  25. package/src/playback/renderers/index.ts +5 -0
  26. package/src/playback/renderers/types.ts +57 -0
  27. package/src/playback/renderers/webgl.ts +373 -0
  28. package/src/playback/renderers/webgpu.ts +395 -0
  29. package/src/playlist/manager.ts +268 -0
  30. package/src/plugins/context.ts +93 -0
  31. package/src/plugins/index.ts +15 -0
  32. package/src/plugins/manager.ts +482 -0
  33. package/src/plugins/types.ts +243 -0
  34. package/src/sources/manager.ts +285 -0
  35. package/src/sources/source.ts +84 -0
  36. package/src/sources/types.ts +17 -0
  37. package/src/state/store.ts +389 -0
  38. package/src/state/types.ts +18 -0
  39. package/src/tracks/manager.ts +421 -0
  40. package/src/tracks/types.ts +30 -0
  41. package/src/types/jassub.d.ts +1 -0
  42. package/src/types.ts +235 -0
  43. package/src/utils/async-lock.ts +26 -0
  44. package/src/utils/dispose.ts +28 -0
  45. package/src/utils/equal.ts +33 -0
  46. package/src/utils/errors.ts +74 -0
  47. package/src/utils/logger.ts +50 -0
  48. package/src/utils/time.ts +157 -0
@@ -0,0 +1,395 @@
1
+ import type { IRenderer, Rotation } from './types';
2
+
3
+ export interface WebGPURendererOptions {
4
+ canvas: HTMLCanvasElement | OffscreenCanvas;
5
+ powerPreference?: 'high-performance' | 'low-power';
6
+ rotation?: Rotation;
7
+ }
8
+
9
+ export class WebGPURenderer implements IRenderer {
10
+ private canvas: HTMLCanvasElement | OffscreenCanvas;
11
+ private device: GPUDevice | null = null;
12
+ private context: GPUCanvasContext | null = null;
13
+ private pipeline: GPURenderPipeline | null = null;
14
+ private texture: GPUTexture | null = null;
15
+ private sampler: GPUSampler | null = null;
16
+ private bindGroup: GPUBindGroup | null = null;
17
+ private vertexBuffer: GPUBuffer | null = null;
18
+ private isInitialized = false;
19
+ private textureWidth = 0;
20
+ private textureHeight = 0;
21
+ private powerPreference: 'high-performance' | 'low-power';
22
+ private rotation: Rotation = 0;
23
+ // Pre-allocated typed array for quad vertices (4 vertices * 4 floats each: x, y, u, v)
24
+ private quadArray = new Float32Array(16);
25
+
26
+ private readonly vertexShaderSource = `
27
+ struct VSOut {
28
+ @builtin(position) pos : vec4f,
29
+ @location(0) uv : vec2f,
30
+ };
31
+
32
+ @vertex
33
+ fn vs_main(@location(0) in_pos: vec2f, @location(1) in_uv: vec2f) -> VSOut {
34
+ var out: VSOut;
35
+ out.pos = vec4f(in_pos, 0.0, 1.0);
36
+ out.uv = in_uv;
37
+ return out;
38
+ }
39
+ `;
40
+
41
+ private readonly fragmentShaderSource = `
42
+ @group(0) @binding(0) var texture_sampler: sampler;
43
+ @group(0) @binding(1) var texture_view: texture_2d<f32>;
44
+
45
+ @fragment
46
+ fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
47
+ return textureSample(texture_view, texture_sampler, uv);
48
+ }
49
+ `;
50
+
51
+ constructor(options: WebGPURendererOptions) {
52
+ this.canvas = options.canvas;
53
+ this.powerPreference = options.powerPreference || 'high-performance';
54
+ this.rotation = options.rotation ?? 0;
55
+ this.initialize().catch((err) => {
56
+ console.error('WebGPU initialization failed:', err);
57
+ });
58
+ }
59
+
60
+ private async initialize(): Promise<boolean> {
61
+ try {
62
+ const nav = navigator as Navigator & { gpu?: GPU };
63
+ if (!nav.gpu) {
64
+ console.log('WebGPU not available in navigator');
65
+ return false;
66
+ }
67
+
68
+ const adapter = await nav.gpu.requestAdapter({
69
+ powerPreference: this.powerPreference,
70
+ });
71
+
72
+ if (!adapter) {
73
+ console.log('WebGPU adapter not available');
74
+ return false;
75
+ }
76
+
77
+ this.device = await adapter.requestDevice();
78
+ if (!this.device) {
79
+ console.log('WebGPU device not available');
80
+ return false;
81
+ }
82
+
83
+ if ('getContext' in this.canvas) {
84
+ this.context = this.canvas.getContext('webgpu') as GPUCanvasContext | null;
85
+ }
86
+
87
+ if (!this.context) {
88
+ console.log('WebGPU context not available on canvas');
89
+ return false;
90
+ }
91
+
92
+ const canvasFormat = nav.gpu.getPreferredCanvasFormat();
93
+ this.context.configure({
94
+ device: this.device,
95
+ format: canvasFormat,
96
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
97
+ alphaMode: 'opaque',
98
+ });
99
+
100
+ await this.createRenderPipeline();
101
+ this.createVertexBuffer();
102
+
103
+ this.isInitialized = true;
104
+ console.log('WebGPU renderer initialized successfully');
105
+ return true;
106
+ } catch (error) {
107
+ console.error('WebGPU initialization error:', error);
108
+ return false;
109
+ }
110
+ }
111
+
112
+ private async createRenderPipeline(): Promise<void> {
113
+ if (!this.device) return;
114
+
115
+ const nav = navigator as Navigator & { gpu?: GPU };
116
+ if (!nav.gpu) return;
117
+
118
+ const vertexShaderModule = this.device.createShaderModule({
119
+ code: this.vertexShaderSource,
120
+ });
121
+
122
+ const fragmentShaderModule = this.device.createShaderModule({
123
+ code: this.fragmentShaderSource,
124
+ });
125
+
126
+ this.pipeline = this.device.createRenderPipeline({
127
+ layout: 'auto',
128
+ vertex: {
129
+ module: vertexShaderModule,
130
+ entryPoint: 'vs_main',
131
+ buffers: [
132
+ {
133
+ arrayStride: 16,
134
+ attributes: [
135
+ { shaderLocation: 0, offset: 0, format: 'float32x2' },
136
+ { shaderLocation: 1, offset: 8, format: 'float32x2' },
137
+ ],
138
+ },
139
+ ],
140
+ },
141
+ fragment: {
142
+ module: fragmentShaderModule,
143
+ entryPoint: 'fs_main',
144
+ targets: [{ format: nav.gpu.getPreferredCanvasFormat() }],
145
+ },
146
+ primitive: { topology: 'triangle-strip' },
147
+ });
148
+ }
149
+
150
+ private createVertexBuffer(): void {
151
+ if (!this.device) return;
152
+
153
+ this.vertexBuffer = this.device.createBuffer({
154
+ size: 4 * 4 * 4,
155
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
156
+ });
157
+ }
158
+
159
+ private createTexture(width: number, height: number): void {
160
+ if (!this.device) return;
161
+
162
+ if (this.texture) this.texture.destroy();
163
+
164
+ this.texture = this.device.createTexture({
165
+ size: { width, height },
166
+ format: 'rgba8unorm',
167
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
168
+ });
169
+
170
+ if (!this.sampler) {
171
+ this.sampler = this.device.createSampler({
172
+ magFilter: 'linear',
173
+ minFilter: 'linear',
174
+ addressModeU: 'clamp-to-edge',
175
+ addressModeV: 'clamp-to-edge',
176
+ });
177
+ }
178
+
179
+ this.createBindGroup();
180
+ }
181
+
182
+ private createBindGroup(): void {
183
+ if (!this.device || !this.texture || !this.sampler || !this.pipeline) return;
184
+
185
+ this.bindGroup = this.device.createBindGroup({
186
+ layout: this.pipeline.getBindGroupLayout(0),
187
+ entries: [
188
+ { binding: 0, resource: this.sampler },
189
+ { binding: 1, resource: this.texture.createView() },
190
+ ],
191
+ });
192
+ }
193
+
194
+ public isReady(): boolean {
195
+ return this.isInitialized && this.device !== null && this.context !== null && this.pipeline !== null;
196
+ }
197
+
198
+ public render(source: HTMLCanvasElement | OffscreenCanvas): boolean {
199
+ if (!this.isReady() || !this.device || !this.context || !this.pipeline) return false;
200
+
201
+ try {
202
+ const sourceWidth = source.width;
203
+ const sourceHeight = source.height;
204
+
205
+ if (sourceWidth === 0 || sourceHeight === 0) {
206
+ console.warn(`WebGPU: Source canvas has zero dimensions (${sourceWidth}x${sourceHeight})`);
207
+ return false;
208
+ }
209
+
210
+ const canvasWidth = this.canvas.width;
211
+ const canvasHeight = this.canvas.height;
212
+
213
+ if (canvasWidth === 0 || canvasHeight === 0) {
214
+ console.warn(`WebGPU: Output canvas has zero dimensions (${canvasWidth}x${canvasHeight})`);
215
+ return false;
216
+ }
217
+
218
+ if (sourceWidth !== this.textureWidth || sourceHeight !== this.textureHeight) {
219
+ this.createTexture(sourceWidth, sourceHeight);
220
+ this.textureWidth = sourceWidth;
221
+ this.textureHeight = sourceHeight;
222
+ }
223
+
224
+ if (!this.texture) return false;
225
+
226
+ // Use copyExternalImageToTexture for better performance
227
+ try {
228
+ this.device.queue.copyExternalImageToTexture(
229
+ { source },
230
+ { texture: this.texture },
231
+ { width: sourceWidth, height: sourceHeight }
232
+ );
233
+ } catch {
234
+ // Fallback to getImageData if copyExternalImageToTexture fails
235
+ const sourceCtx = source.getContext('2d');
236
+ if (!sourceCtx) return false;
237
+
238
+ const imageData = sourceCtx.getImageData(0, 0, sourceWidth, sourceHeight);
239
+ const data = new Uint8Array(imageData.data.buffer);
240
+
241
+ this.device.queue.writeTexture(
242
+ { texture: this.texture, origin: { x: 0, y: 0, z: 0 } },
243
+ data,
244
+ { bytesPerRow: sourceWidth * 4, rowsPerImage: sourceHeight },
245
+ { width: sourceWidth, height: sourceHeight, depthOrArrayLayers: 1 }
246
+ );
247
+ }
248
+
249
+ const commandEncoder = this.device.createCommandEncoder();
250
+ const textureView = this.context.getCurrentTexture().createView();
251
+
252
+ const renderPass = commandEncoder.beginRenderPass({
253
+ colorAttachments: [
254
+ {
255
+ view: textureView,
256
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
257
+ loadOp: 'clear',
258
+ storeOp: 'store',
259
+ },
260
+ ],
261
+ });
262
+
263
+ renderPass.setPipeline(this.pipeline);
264
+ if (this.bindGroup) renderPass.setBindGroup(0, this.bindGroup);
265
+
266
+ // For 90/270 rotation, swap source dimensions for aspect ratio calculation
267
+ const isRotated90or270 = this.rotation === 90 || this.rotation === 270;
268
+ const effectiveWidth = isRotated90or270 ? this.textureHeight : this.textureWidth;
269
+ const effectiveHeight = isRotated90or270 ? this.textureWidth : this.textureHeight;
270
+
271
+ // Calculate letterbox dimensions to preserve aspect ratio
272
+ const scale = Math.min(canvasWidth / effectiveWidth, canvasHeight / effectiveHeight);
273
+ const drawW = Math.round(effectiveWidth * scale);
274
+ const drawH = Math.round(effectiveHeight * scale);
275
+ const x = Math.round((canvasWidth - drawW) / 2);
276
+ const y = Math.round((canvasHeight - drawH) / 2);
277
+
278
+ // Calculate clip-space coordinates
279
+ const left = (x / canvasWidth) * 2 - 1;
280
+ const right = ((x + drawW) / canvasWidth) * 2 - 1;
281
+ const top = 1 - (y / canvasHeight) * 2;
282
+ const bottom = 1 - ((y + drawH) / canvasHeight) * 2;
283
+
284
+ // Calculate center of quad
285
+ const cx = (left + right) / 2;
286
+ const cy = (top + bottom) / 2;
287
+ const hw = (right - left) / 2;
288
+ const hh = (top - bottom) / 2;
289
+
290
+ // Apply rotation by rotating vertex positions
291
+ const rad = (this.rotation * Math.PI) / 180;
292
+ const cos = Math.cos(rad);
293
+ const sin = Math.sin(rad);
294
+
295
+ // For rotated quads, we need to swap half-width/half-height
296
+ const rhw = isRotated90or270 ? hh : hw;
297
+ const rhh = isRotated90or270 ? hw : hh;
298
+
299
+ // Calculate rotated corner positions with texture coordinates using pre-allocated array
300
+ // Corner order: bottom-left, bottom-right, top-left, top-right
301
+ // Each vertex: x, y, u, v
302
+ const quad = this.quadArray;
303
+
304
+ // bottom-left (-rhw, -rhh, 0.0, 1.0)
305
+ quad[0] = -rhw * cos - -rhh * sin + cx;
306
+ quad[1] = -rhw * sin + -rhh * cos + cy;
307
+ quad[2] = 0.0;
308
+ quad[3] = 1.0;
309
+
310
+ // bottom-right (rhw, -rhh, 1.0, 1.0)
311
+ quad[4] = rhw * cos - -rhh * sin + cx;
312
+ quad[5] = rhw * sin + -rhh * cos + cy;
313
+ quad[6] = 1.0;
314
+ quad[7] = 1.0;
315
+
316
+ // top-left (-rhw, rhh, 0.0, 0.0)
317
+ quad[8] = -rhw * cos - rhh * sin + cx;
318
+ quad[9] = -rhw * sin + rhh * cos + cy;
319
+ quad[10] = 0.0;
320
+ quad[11] = 0.0;
321
+
322
+ // top-right (rhw, rhh, 1.0, 0.0)
323
+ quad[12] = rhw * cos - rhh * sin + cx;
324
+ quad[13] = rhw * sin + rhh * cos + cy;
325
+ quad[14] = 1.0;
326
+ quad[15] = 0.0;
327
+
328
+ if (this.vertexBuffer) {
329
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, quad);
330
+ renderPass.setVertexBuffer(0, this.vertexBuffer);
331
+ }
332
+
333
+ renderPass.draw(4, 1, 0, 0);
334
+ renderPass.end();
335
+
336
+ this.device.queue.submit([commandEncoder.finish()]);
337
+
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ public clear(): void {
345
+ if (!this.isReady() || !this.device || !this.context) return;
346
+
347
+ try {
348
+ const commandEncoder = this.device.createCommandEncoder();
349
+ const textureView = this.context.getCurrentTexture().createView();
350
+
351
+ const renderPass = commandEncoder.beginRenderPass({
352
+ colorAttachments: [
353
+ {
354
+ view: textureView,
355
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
356
+ loadOp: 'clear',
357
+ storeOp: 'store',
358
+ },
359
+ ],
360
+ });
361
+
362
+ renderPass.end();
363
+ this.device.queue.submit([commandEncoder.finish()]);
364
+ } catch {}
365
+ }
366
+
367
+ public setRotation(rotation: Rotation): void {
368
+ this.rotation = rotation;
369
+ }
370
+
371
+ public getRotation(): Rotation {
372
+ return this.rotation;
373
+ }
374
+
375
+ public dispose(): void {
376
+ try {
377
+ if (this.texture) {
378
+ this.texture.destroy();
379
+ this.texture = null;
380
+ }
381
+
382
+ if (this.vertexBuffer) {
383
+ this.vertexBuffer.destroy();
384
+ this.vertexBuffer = null;
385
+ }
386
+
387
+ this.device = null;
388
+ this.context = null;
389
+ this.pipeline = null;
390
+ this.sampler = null;
391
+ this.bindGroup = null;
392
+ this.isInitialized = false;
393
+ } catch {}
394
+ }
395
+ }
@@ -0,0 +1,268 @@
1
+ import type { TypedEventEmitter } from '../events/types';
2
+ import type { SourceManager } from '../sources/manager';
3
+ import type { StateStore } from '../state/types';
4
+ import type { MediaSource, PlayerEventMap, Playlist, PlaylistItem, PlaylistMode } from '../types';
5
+
6
+ // Simple ID generator (no uuid dep for now)
7
+ function generateId(): string {
8
+ return crypto?.randomUUID?.() ?? Date.now().toString(36) + Math.random().toString(36).substr(2);
9
+ }
10
+
11
+ interface PlaylistItemConfig {
12
+ mediaSource: MediaSource;
13
+ title?: string;
14
+ poster?: string;
15
+ }
16
+
17
+ export class PlaylistManager {
18
+ private store: StateStore;
19
+ private emitter: TypedEventEmitter<PlayerEventMap>;
20
+ private switchSource?: (item: PlaylistItem, autoplay: boolean) => Promise<void>;
21
+ private sourceManager?: SourceManager;
22
+
23
+ constructor(
24
+ store: StateStore,
25
+ emitter: TypedEventEmitter<PlayerEventMap>,
26
+ switchSource?: (item: PlaylistItem, autoplay: boolean) => Promise<void>,
27
+ sourceManager?: SourceManager
28
+ ) {
29
+ this.store = store;
30
+ this.emitter = emitter;
31
+ this.switchSource = switchSource;
32
+ this.sourceManager = sourceManager;
33
+ }
34
+
35
+ async loadPlaylist(
36
+ items: Array<MediaSource | PlaylistItemConfig>,
37
+ options: { autoplay?: boolean; startTime?: number } = {}
38
+ ): Promise<void> {
39
+ const playlist: Playlist = items.map((item) => {
40
+ // Check if it's a PlaylistItemConfig (has mediaSource property)
41
+ if (item && typeof item === 'object' && 'mediaSource' in item) {
42
+ // PlaylistItemConfig
43
+ return {
44
+ id: generateId(),
45
+ mediaSource: item.mediaSource,
46
+ title: item.title,
47
+ poster: item.poster,
48
+ savedPosition: null,
49
+ duration: null,
50
+ } as PlaylistItem;
51
+ } else {
52
+ // MediaSource
53
+ return {
54
+ id: generateId(),
55
+ mediaSource: item as MediaSource,
56
+ savedPosition: null,
57
+ duration: null,
58
+ } as PlaylistItem;
59
+ }
60
+ });
61
+
62
+ this.store.updatePlaylist(playlist, playlist.length > 0 ? 0 : null);
63
+ this.emitter.emit('playlistchange', { playlist });
64
+
65
+ if (playlist.length > 0 && this.switchSource) {
66
+ const firstItem = playlist[0];
67
+ // Override savedPosition with startTime if provided
68
+ if (options.startTime !== undefined) {
69
+ firstItem.savedPosition = options.startTime;
70
+ }
71
+ await this.switchSource(firstItem, options.autoplay ?? false);
72
+ }
73
+ }
74
+
75
+ addToPlaylist(itemInput: MediaSource | PlaylistItemConfig, index?: number): void {
76
+ const item: PlaylistItem = this.createPlaylistItem(itemInput);
77
+
78
+ this.store.addToPlaylist(item, index);
79
+ this.emitter.emit('playlistadd', { item, index: index ?? this.store.getState().playlist.length - 1 });
80
+ }
81
+
82
+ async removeFromPlaylist(index: number): Promise<void> {
83
+ const state = this.store.getState();
84
+ const currentIndex = state.currentPlaylistIndex;
85
+ const wasPlaying = state.playing;
86
+
87
+ this.store.removeFromPlaylist(index);
88
+ this.emitter.emit('playlistremove', { index });
89
+
90
+ // Dispose if queued (for removed item)
91
+ this.sourceManager?.disposeQueued(state.playlist[index]?.id || '');
92
+
93
+ const newState = this.store.getState();
94
+ const newCurrentIndex = newState.currentPlaylistIndex;
95
+
96
+ if (newState.playlist.length === 0) {
97
+ this.sourceManager?.disposeAll();
98
+ this.emitter.emit('playlistchange', { playlist: newState.playlist });
99
+ this.emitter.emit('playlistend', undefined);
100
+ }
101
+
102
+ if (currentIndex === index && newCurrentIndex !== null && newCurrentIndex !== currentIndex && this.switchSource) {
103
+ const newItem = newState.playlist[newCurrentIndex];
104
+ try {
105
+ await this.switchSource(newItem, wasPlaying);
106
+ } catch (error) {
107
+ this.emitter.emit('playlistitemerror', { index: newCurrentIndex, error: error as Error });
108
+ }
109
+ }
110
+ }
111
+
112
+ clearPlaylist(): void {
113
+ this.store.clearPlaylist();
114
+ this.emitter.emit('playlistchange', { playlist: [] });
115
+ this.emitter.emit('playlistend', undefined);
116
+ // Dispose all sources
117
+ this.sourceManager?.disposeAll();
118
+ }
119
+
120
+ async next(): Promise<void> {
121
+ const state = this.store.getState();
122
+ const currentIndex = state.currentPlaylistIndex ?? 0;
123
+ const playlist = state.playlist;
124
+ const mode = state.playlistMode;
125
+
126
+ let newIndex: number | null = null;
127
+
128
+ if (mode === 'repeat-one') {
129
+ newIndex = currentIndex; // Stay on current for repeat-one
130
+ } else if (mode === 'sequential' || mode === 'repeat') {
131
+ if (currentIndex < playlist.length - 1) {
132
+ newIndex = currentIndex + 1;
133
+ } else if (mode === 'repeat') {
134
+ newIndex = 0;
135
+ } else {
136
+ this.emitter.emit('playlistend', undefined);
137
+ return;
138
+ }
139
+ } else {
140
+ // manual or null, just next if possible
141
+ if (currentIndex < playlist.length - 1) {
142
+ newIndex = currentIndex + 1;
143
+ }
144
+ }
145
+
146
+ if (newIndex !== null) {
147
+ await this.switchTo(newIndex);
148
+ }
149
+ }
150
+
151
+ async prev(): Promise<void> {
152
+ const state = this.store.getState();
153
+ const currentIndex = state.currentPlaylistIndex ?? 0;
154
+
155
+ if (currentIndex > 0) {
156
+ await this.switchTo(currentIndex - 1);
157
+ }
158
+ }
159
+
160
+ async jumpTo(index: number): Promise<void> {
161
+ const state = this.store.getState();
162
+ const playlist = state.playlist;
163
+
164
+ if (index >= 0 && index < playlist.length) {
165
+ await this.switchTo(index);
166
+ } else if (index >= playlist.length) {
167
+ // end
168
+ this.emitter.emit('playlistend', undefined);
169
+ }
170
+ }
171
+
172
+ setMode(mode: PlaylistMode): void {
173
+ const validModes: PlaylistMode[] = ['sequential', 'manual', 'repeat', 'repeat-one', null];
174
+ if (!validModes.includes(mode)) {
175
+ throw new Error(
176
+ `Invalid playlist mode: ${mode}. Valid modes: ${validModes.filter((m) => m !== null).join(', ')}, null`
177
+ );
178
+ }
179
+ this.store.updatePlaylistMode(mode);
180
+ }
181
+
182
+ private async switchTo(index: number): Promise<void> {
183
+ const state = this.store.getState();
184
+ const previousIndex = state.currentPlaylistIndex;
185
+ const wasPlaying = state.playing;
186
+
187
+ const newPlaylist = [...state.playlist];
188
+ if (previousIndex !== null && previousIndex !== index) {
189
+ const oldItem = newPlaylist[previousIndex];
190
+ newPlaylist[previousIndex] = { ...oldItem, savedPosition: state.currentTime };
191
+ // Dispose old source if queued (but since lazy, only current is loaded)
192
+ // sourceManager.disposeQueued(oldItem.id); // If preloaded
193
+ }
194
+
195
+ this.store.updatePlaylist(newPlaylist, index);
196
+
197
+ const item = newPlaylist[index];
198
+
199
+ this.emitter.emit('playlistitemchange', {
200
+ index,
201
+ item,
202
+ previousIndex: previousIndex ?? undefined,
203
+ });
204
+
205
+ if (this.switchSource) {
206
+ await this.switchSource(item, wasPlaying);
207
+ }
208
+
209
+ // Optional prefetch next if sequential
210
+ const mode = state.playlistMode;
211
+ if (mode === 'sequential' && index < newPlaylist.length - 1) {
212
+ const nextItem = newPlaylist[index + 1];
213
+ // If sourceManager available, preload
214
+ this.sourceManager?.preloadSource(nextItem.mediaSource, nextItem.id);
215
+ }
216
+ }
217
+
218
+ private createPlaylistItem(input: MediaSource | PlaylistItemConfig): PlaylistItem {
219
+ // Check if it's a PlaylistItemConfig (has mediaSource property)
220
+ if (input && typeof input === 'object' && 'mediaSource' in input) {
221
+ // PlaylistItemConfig
222
+ return {
223
+ id: generateId(),
224
+ mediaSource: input.mediaSource,
225
+ title: input.title,
226
+ poster: input.poster,
227
+ savedPosition: null,
228
+ duration: null,
229
+ };
230
+ } else {
231
+ // MediaSource
232
+ return {
233
+ id: generateId(),
234
+ mediaSource: input as MediaSource,
235
+ savedPosition: null,
236
+ duration: null,
237
+ };
238
+ }
239
+ }
240
+
241
+ // Getters
242
+ get playlist(): Playlist {
243
+ return this.store.getState().playlist;
244
+ }
245
+
246
+ get currentIndex(): number | null {
247
+ return this.store.getState().currentPlaylistIndex;
248
+ }
249
+
250
+ get currentItem(): PlaylistItem | null {
251
+ const index = this.currentIndex;
252
+ return index !== null ? this.playlist[index] : null;
253
+ }
254
+
255
+ get mode(): PlaylistMode {
256
+ return this.store.getState().playlistMode;
257
+ }
258
+
259
+ dispose(): void {
260
+ // Clear any queued sources
261
+ if (this.sourceManager) {
262
+ // The sourceManager will be disposed by the main MediaFox class
263
+ // Just clear any playlist-specific references
264
+ this.sourceManager = undefined;
265
+ }
266
+ this.switchSource = undefined;
267
+ }
268
+ }