@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,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
|
+
}
|