@livepeer-frameworks/player-core 0.0.3

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 (120) hide show
  1. package/dist/cjs/index.js +19493 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +19398 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/player.css +2140 -0
  6. package/dist/types/core/ABRController.d.ts +164 -0
  7. package/dist/types/core/CodecUtils.d.ts +54 -0
  8. package/dist/types/core/Disposable.d.ts +61 -0
  9. package/dist/types/core/EventEmitter.d.ts +73 -0
  10. package/dist/types/core/GatewayClient.d.ts +144 -0
  11. package/dist/types/core/InteractionController.d.ts +121 -0
  12. package/dist/types/core/LiveDurationProxy.d.ts +102 -0
  13. package/dist/types/core/MetaTrackManager.d.ts +220 -0
  14. package/dist/types/core/MistReporter.d.ts +163 -0
  15. package/dist/types/core/MistSignaling.d.ts +148 -0
  16. package/dist/types/core/PlayerController.d.ts +665 -0
  17. package/dist/types/core/PlayerInterface.d.ts +230 -0
  18. package/dist/types/core/PlayerManager.d.ts +182 -0
  19. package/dist/types/core/PlayerRegistry.d.ts +27 -0
  20. package/dist/types/core/QualityMonitor.d.ts +184 -0
  21. package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
  22. package/dist/types/core/SeekingUtils.d.ts +142 -0
  23. package/dist/types/core/StreamStateClient.d.ts +108 -0
  24. package/dist/types/core/SubtitleManager.d.ts +111 -0
  25. package/dist/types/core/TelemetryReporter.d.ts +79 -0
  26. package/dist/types/core/TimeFormat.d.ts +97 -0
  27. package/dist/types/core/TimerManager.d.ts +83 -0
  28. package/dist/types/core/UrlUtils.d.ts +81 -0
  29. package/dist/types/core/detector.d.ts +149 -0
  30. package/dist/types/core/index.d.ts +49 -0
  31. package/dist/types/core/scorer.d.ts +167 -0
  32. package/dist/types/core/selector.d.ts +9 -0
  33. package/dist/types/index.d.ts +45 -0
  34. package/dist/types/lib/utils.d.ts +2 -0
  35. package/dist/types/players/DashJsPlayer.d.ts +102 -0
  36. package/dist/types/players/HlsJsPlayer.d.ts +70 -0
  37. package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
  38. package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
  39. package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
  40. package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
  41. package/dist/types/players/MistPlayer.d.ts +25 -0
  42. package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
  43. package/dist/types/players/NativePlayer.d.ts +143 -0
  44. package/dist/types/players/VideoJsPlayer.d.ts +59 -0
  45. package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
  46. package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
  47. package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
  48. package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
  49. package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
  50. package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
  51. package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
  52. package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
  53. package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
  54. package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
  55. package/dist/types/players/index.d.ts +14 -0
  56. package/dist/types/styles/index.d.ts +11 -0
  57. package/dist/types/types.d.ts +363 -0
  58. package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
  59. package/dist/types/vanilla/index.d.ts +19 -0
  60. package/dist/workers/decoder.worker.js +989 -0
  61. package/dist/workers/decoder.worker.js.map +1 -0
  62. package/package.json +80 -0
  63. package/src/core/ABRController.ts +550 -0
  64. package/src/core/CodecUtils.ts +257 -0
  65. package/src/core/Disposable.ts +120 -0
  66. package/src/core/EventEmitter.ts +113 -0
  67. package/src/core/GatewayClient.ts +439 -0
  68. package/src/core/InteractionController.ts +712 -0
  69. package/src/core/LiveDurationProxy.ts +270 -0
  70. package/src/core/MetaTrackManager.ts +753 -0
  71. package/src/core/MistReporter.ts +543 -0
  72. package/src/core/MistSignaling.ts +346 -0
  73. package/src/core/PlayerController.ts +2829 -0
  74. package/src/core/PlayerInterface.ts +432 -0
  75. package/src/core/PlayerManager.ts +900 -0
  76. package/src/core/PlayerRegistry.ts +149 -0
  77. package/src/core/QualityMonitor.ts +597 -0
  78. package/src/core/ScreenWakeLockManager.ts +163 -0
  79. package/src/core/SeekingUtils.ts +364 -0
  80. package/src/core/StreamStateClient.ts +457 -0
  81. package/src/core/SubtitleManager.ts +297 -0
  82. package/src/core/TelemetryReporter.ts +308 -0
  83. package/src/core/TimeFormat.ts +205 -0
  84. package/src/core/TimerManager.ts +209 -0
  85. package/src/core/UrlUtils.ts +179 -0
  86. package/src/core/detector.ts +382 -0
  87. package/src/core/index.ts +140 -0
  88. package/src/core/scorer.ts +553 -0
  89. package/src/core/selector.ts +16 -0
  90. package/src/global.d.ts +11 -0
  91. package/src/index.ts +75 -0
  92. package/src/lib/utils.ts +6 -0
  93. package/src/players/DashJsPlayer.ts +642 -0
  94. package/src/players/HlsJsPlayer.ts +483 -0
  95. package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
  96. package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
  97. package/src/players/MewsWsPlayer/index.ts +1065 -0
  98. package/src/players/MewsWsPlayer/types.ts +106 -0
  99. package/src/players/MistPlayer.ts +188 -0
  100. package/src/players/MistWebRTCPlayer/index.ts +703 -0
  101. package/src/players/NativePlayer.ts +820 -0
  102. package/src/players/VideoJsPlayer.ts +643 -0
  103. package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
  104. package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
  105. package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
  106. package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
  107. package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
  108. package/src/players/WebCodecsPlayer/index.ts +1650 -0
  109. package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
  110. package/src/players/WebCodecsPlayer/types.ts +542 -0
  111. package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
  112. package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
  113. package/src/players/index.ts +22 -0
  114. package/src/styles/animations.css +21 -0
  115. package/src/styles/index.ts +52 -0
  116. package/src/styles/player.css +2126 -0
  117. package/src/styles/tailwind.css +1015 -0
  118. package/src/types.ts +421 -0
  119. package/src/vanilla/FrameWorksPlayer.ts +367 -0
  120. package/src/vanilla/index.ts +22 -0
@@ -0,0 +1,753 @@
1
+ import type { MetaTrackEvent, MetaTrackEventType } from '../types';
2
+ import { TimerManager } from './TimerManager';
3
+
4
+ export interface MetaTrackSubscription {
5
+ trackId: string;
6
+ callback: (event: MetaTrackEvent) => void;
7
+ }
8
+
9
+ export interface MetaTrackManagerConfig {
10
+ /** MistServer base URL */
11
+ mistBaseUrl: string;
12
+ /** Stream name */
13
+ streamName: string;
14
+ /** Initial subscriptions */
15
+ subscriptions?: MetaTrackSubscription[];
16
+ /** Debug logging */
17
+ debug?: boolean;
18
+ /** Buffer ahead duration in seconds (default: 5) */
19
+ bufferAhead?: number;
20
+ /** Max age for messages in seconds before filtering (default: 5) */
21
+ maxMessageAge?: number;
22
+ /** Fast-forward interval in seconds for catching up (default: 5) */
23
+ fastForwardInterval?: number;
24
+ }
25
+
26
+ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
27
+
28
+ /**
29
+ * MetaTrackManager - Handles real-time metadata subscriptions via MistServer WebSocket
30
+ *
31
+ * Uses native MistServer WebSocket protocol (from embed/player.js):
32
+ * - Connect: ws://{baseUrl}/json_{streamName}.js?rate=1
33
+ * - Set tracks: {type:"tracks", meta:"1,2,3"} (comma-separated indices)
34
+ * - Seek: {type:"seek", seek_time:<ms>, ff_to:<ms>}
35
+ * - Receive: {time:<ms>, track:<index>, data:{...}}
36
+ * - Control: {type:"hold"}, {type:"play"}, {type:"fast_forward", ff_to:<ms>}
37
+ *
38
+ * Features:
39
+ * - Automatic reconnection with exponential backoff
40
+ * - Message buffering during reconnection
41
+ * - Type detection for subtitle/score/event/chapter data
42
+ * - Stay-ahead buffering for smooth playback
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const manager = new MetaTrackManager({
47
+ * mistBaseUrl: 'https://mist.example.com',
48
+ * streamName: 'my-stream',
49
+ * });
50
+ *
51
+ * manager.subscribe('1', (event) => {
52
+ * if (event.type === 'subtitle') {
53
+ * console.log('Subtitle:', event.data);
54
+ * }
55
+ * });
56
+ *
57
+ * manager.connect();
58
+ * ```
59
+ */
60
+ export class MetaTrackManager {
61
+ private config: MetaTrackManagerConfig;
62
+ private ws: WebSocket | null = null;
63
+ private state: ConnectionState = 'disconnected';
64
+ private subscriptions: Map<string, Set<(event: MetaTrackEvent) => void>> = new Map();
65
+ private pendingSubscriptions: Set<string> = new Set();
66
+ private reconnectAttempt = 0;
67
+ private timers = new TimerManager();
68
+ private messageBuffer: MetaTrackEvent[] = [];
69
+ private debug: boolean;
70
+ private connectionId: number = 0; // Track connection attempts to prevent stale callbacks
71
+
72
+ // Debounce time for rapid mount/unmount cycles (ms)
73
+ private static readonly CONNECTION_DEBOUNCE_MS = 100;
74
+
75
+ // Reconnection settings
76
+ private static readonly MAX_RECONNECT_ATTEMPTS = 5;
77
+ private static readonly INITIAL_RECONNECT_DELAY = 1000;
78
+ private static readonly MAX_RECONNECT_DELAY = 30000;
79
+ private static readonly MESSAGE_BUFFER_SIZE = 100;
80
+
81
+ // Buffer management (MistMetaPlayer feature backport)
82
+ private currentPlaybackTime = 0;
83
+ private bufferAhead: number;
84
+ private maxMessageAge: number;
85
+ private fastForwardInterval: number;
86
+ private lastFastForwardTime = 0;
87
+ private timedEventBuffer: Map<string, MetaTrackEvent[]> = new Map(); // trackId -> events sorted by time
88
+
89
+ constructor(config: MetaTrackManagerConfig) {
90
+ this.config = config;
91
+ this.debug = config.debug ?? false;
92
+
93
+ // Buffer management settings (MistMetaPlayer defaults)
94
+ this.bufferAhead = config.bufferAhead ?? 5;
95
+ this.maxMessageAge = config.maxMessageAge ?? 5;
96
+ this.fastForwardInterval = config.fastForwardInterval ?? 5;
97
+
98
+ // Add initial subscriptions
99
+ if (config.subscriptions) {
100
+ for (const sub of config.subscriptions) {
101
+ this.subscribe(sub.trackId, sub.callback);
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Connect to MistServer WebSocket
108
+ * Debounced to prevent orphaned connections during rapid mount/unmount cycles.
109
+ */
110
+ connect(): void {
111
+ if (this.state === 'connecting' || this.state === 'connected') {
112
+ return;
113
+ }
114
+
115
+ this.state = 'connecting';
116
+ this.log('Connecting...');
117
+
118
+ // Increment connection ID to invalidate any pending callbacks
119
+ const currentConnectionId = ++this.connectionId;
120
+
121
+ // Debounce connection
122
+ this.timers.start(() => {
123
+ // Check if this connection attempt is still valid
124
+ if (this.state !== 'connecting' || this.connectionId !== currentConnectionId) {
125
+ return;
126
+ }
127
+
128
+ this.createWebSocket(currentConnectionId);
129
+ }, MetaTrackManager.CONNECTION_DEBOUNCE_MS, 'connect');
130
+ }
131
+
132
+ /**
133
+ * Internal method to create WebSocket after debounce
134
+ */
135
+ private createWebSocket(connectionId: number): void {
136
+ try {
137
+ const wsUrl = this.buildWsUrl();
138
+ this.ws = new WebSocket(wsUrl);
139
+
140
+ this.ws.onopen = () => {
141
+ // Verify still valid
142
+ if (this.connectionId !== connectionId) {
143
+ this.ws?.close();
144
+ return;
145
+ }
146
+
147
+ this.log('Connected');
148
+ this.state = 'connected';
149
+ this.reconnectAttempt = 0;
150
+
151
+ // Merge pending subscriptions into existing
152
+ for (const trackId of this.pendingSubscriptions) {
153
+ if (!this.subscriptions.has(trackId)) {
154
+ this.subscriptions.set(trackId, new Set());
155
+ }
156
+ }
157
+ this.pendingSubscriptions.clear();
158
+
159
+ // Send all subscribed tracks at once (MistServer protocol)
160
+ this.sendTracksUpdate();
161
+
162
+ // Send initial seek to current playback position
163
+ this.sendSeek(this.currentPlaybackTime);
164
+
165
+ // Flush message buffer
166
+ this.flushMessageBuffer();
167
+ };
168
+
169
+ this.ws.onmessage = (event) => {
170
+ this.handleMessage(event.data);
171
+ };
172
+
173
+ this.ws.onerror = (event) => {
174
+ this.log('WebSocket error');
175
+ console.warn('[MetaTrackManager] WebSocket error:', event);
176
+ };
177
+
178
+ this.ws.onclose = () => {
179
+ this.log('Disconnected');
180
+ this.ws = null;
181
+
182
+ if (this.state !== 'disconnected') {
183
+ this.scheduleReconnect();
184
+ }
185
+ };
186
+ } catch (error) {
187
+ this.log(`Connection error: ${error}`);
188
+ this.scheduleReconnect();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Disconnect from MistServer
194
+ */
195
+ disconnect(): void {
196
+ this.state = 'disconnected';
197
+
198
+ // Clear all timers
199
+ this.timers.destroy();
200
+
201
+ if (this.ws) {
202
+ this.ws.close();
203
+ this.ws = null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Subscribe to a meta track
209
+ * @param trackId Track index (number as string) or "all" for all meta tracks
210
+ */
211
+ subscribe(trackId: string, callback: (event: MetaTrackEvent) => void): () => void {
212
+ const isNewTrack = !this.subscriptions.has(trackId);
213
+
214
+ if (isNewTrack) {
215
+ this.subscriptions.set(trackId, new Set());
216
+ }
217
+
218
+ this.subscriptions.get(trackId)!.add(callback);
219
+
220
+ // Send updated track list if connected and this is a new track
221
+ if (this.state === 'connected' && this.ws && isNewTrack) {
222
+ this.sendTracksUpdate();
223
+ } else if (isNewTrack) {
224
+ this.pendingSubscriptions.add(trackId);
225
+ }
226
+
227
+ // Return unsubscribe function
228
+ return () => this.unsubscribe(trackId, callback);
229
+ }
230
+
231
+ /**
232
+ * Unsubscribe from a meta track
233
+ */
234
+ unsubscribe(trackId: string, callback: (event: MetaTrackEvent) => void): void {
235
+ const callbacks = this.subscriptions.get(trackId);
236
+ if (callbacks) {
237
+ callbacks.delete(callback);
238
+
239
+ // If no more callbacks, remove subscription and update MistServer
240
+ if (callbacks.size === 0) {
241
+ this.subscriptions.delete(trackId);
242
+ // Send updated track list (MistServer doesn't have explicit unsubscribe)
243
+ if (this.state === 'connected' && this.ws) {
244
+ this.sendTracksUpdate();
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Get list of subscribed track IDs
252
+ */
253
+ getSubscribedTracks(): string[] {
254
+ return Array.from(this.subscriptions.keys());
255
+ }
256
+
257
+ /**
258
+ * Get connection state
259
+ */
260
+ getState(): ConnectionState {
261
+ return this.state;
262
+ }
263
+
264
+ /**
265
+ * Check if connected
266
+ */
267
+ isConnected(): boolean {
268
+ return this.state === 'connected';
269
+ }
270
+
271
+ // ========================================
272
+ // Buffer Management (MistMetaPlayer backport)
273
+ // ========================================
274
+
275
+ /**
276
+ * Update current playback time
277
+ * Call this on video timeupdate events to keep buffer in sync
278
+ */
279
+ setPlaybackTime(timeInSeconds: number): void {
280
+ this.currentPlaybackTime = timeInSeconds;
281
+
282
+ // Process any buffered events that are now due
283
+ this.processTimedEvents();
284
+ }
285
+
286
+ /**
287
+ * Get current playback time
288
+ */
289
+ getPlaybackTime(): number {
290
+ return this.currentPlaybackTime;
291
+ }
292
+
293
+ /**
294
+ * Handle seek event - clears buffer and sends seek command to MistServer
295
+ * Call this when video seeks to a new position
296
+ */
297
+ onSeek(newTimeInSeconds: number): void {
298
+ this.log(`Seek to ${newTimeInSeconds}s - clearing buffer and notifying server`);
299
+ this.currentPlaybackTime = newTimeInSeconds;
300
+
301
+ // Clear all timed event buffers
302
+ this.timedEventBuffer.clear();
303
+
304
+ // Reset fast-forward tracking
305
+ this.lastFastForwardTime = 0;
306
+
307
+ // Tell MistServer to seek its metadata stream
308
+ this.sendSeek(newTimeInSeconds);
309
+ }
310
+
311
+ /**
312
+ * Process buffered events up to current playback time
313
+ * Dispatches events that are ready to be shown
314
+ */
315
+ private processTimedEvents(): void {
316
+ const now = this.currentPlaybackTime * 1000; // Convert to ms
317
+
318
+ for (const [trackId, events] of this.timedEventBuffer) {
319
+ // Find events that should be dispatched
320
+ const dueEvents: MetaTrackEvent[] = [];
321
+ const remainingEvents: MetaTrackEvent[] = [];
322
+
323
+ for (const event of events) {
324
+ if (event.timestamp <= now) {
325
+ // Check if event is too old (filter stale events)
326
+ const ageSeconds = (now - event.timestamp) / 1000;
327
+ if (ageSeconds <= this.maxMessageAge) {
328
+ dueEvents.push(event);
329
+ } else {
330
+ this.log(`Filtering stale event (${ageSeconds.toFixed(1)}s old)`);
331
+ }
332
+ } else {
333
+ remainingEvents.push(event);
334
+ }
335
+ }
336
+
337
+ // Dispatch due events
338
+ for (const event of dueEvents) {
339
+ this.dispatchEvent(event);
340
+ }
341
+
342
+ // Update buffer with remaining events
343
+ if (remainingEvents.length > 0) {
344
+ this.timedEventBuffer.set(trackId, remainingEvents);
345
+ } else {
346
+ this.timedEventBuffer.delete(trackId);
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Add event to timed buffer (sorted by timestamp)
353
+ * Used for events that should be dispatched at specific playback times
354
+ */
355
+ private addToTimedBuffer(event: MetaTrackEvent): void {
356
+ const trackId = event.trackId;
357
+
358
+ if (!this.timedEventBuffer.has(trackId)) {
359
+ this.timedEventBuffer.set(trackId, []);
360
+ }
361
+
362
+ const buffer = this.timedEventBuffer.get(trackId)!;
363
+
364
+ // Insert in sorted order by timestamp
365
+ let insertIndex = buffer.length;
366
+ for (let i = 0; i < buffer.length; i++) {
367
+ if (buffer[i].timestamp > event.timestamp) {
368
+ insertIndex = i;
369
+ break;
370
+ }
371
+ }
372
+ buffer.splice(insertIndex, 0, event);
373
+
374
+ // Limit buffer size per track
375
+ while (buffer.length > MetaTrackManager.MESSAGE_BUFFER_SIZE) {
376
+ buffer.shift();
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Check if we need to request more data (stay bufferAhead seconds ahead)
382
+ * Returns true if buffer is running low
383
+ */
384
+ needsMoreData(trackId: string): boolean {
385
+ const buffer = this.timedEventBuffer.get(trackId);
386
+ if (!buffer || buffer.length === 0) return true;
387
+
388
+ const lastEventTime = buffer[buffer.length - 1].timestamp / 1000;
389
+ const currentTime = this.currentPlaybackTime;
390
+ const bufferedAhead = lastEventTime - currentTime;
391
+
392
+ return bufferedAhead < this.bufferAhead;
393
+ }
394
+
395
+ /**
396
+ * Fast-forward through buffered events (rate-limited)
397
+ * Used when playback jumps ahead and needs to catch up
398
+ * Also notifies MistServer to fast-forward its metadata stream
399
+ */
400
+ fastForward(): void {
401
+ const now = Date.now();
402
+
403
+ // Rate limit fast-forward (once per fastForwardInterval seconds)
404
+ if (now - this.lastFastForwardTime < this.fastForwardInterval * 1000) {
405
+ return;
406
+ }
407
+
408
+ this.lastFastForwardTime = now;
409
+ this.log('Fast-forwarding through buffered events');
410
+
411
+ // Process all events up to current time + bufferAhead
412
+ const targetTime = (this.currentPlaybackTime + this.bufferAhead) * 1000;
413
+
414
+ for (const [trackId, events] of this.timedEventBuffer) {
415
+ const processEvents: MetaTrackEvent[] = [];
416
+ const remainingEvents: MetaTrackEvent[] = [];
417
+
418
+ for (const event of events) {
419
+ if (event.timestamp <= targetTime) {
420
+ // Only dispatch if not too old
421
+ const ageSeconds = (this.currentPlaybackTime * 1000 - event.timestamp) / 1000;
422
+ if (ageSeconds <= this.maxMessageAge) {
423
+ processEvents.push(event);
424
+ }
425
+ } else {
426
+ remainingEvents.push(event);
427
+ }
428
+ }
429
+
430
+ // Dispatch events
431
+ for (const event of processEvents) {
432
+ this.dispatchEvent(event);
433
+ }
434
+
435
+ // Update buffer
436
+ if (remainingEvents.length > 0) {
437
+ this.timedEventBuffer.set(trackId, remainingEvents);
438
+ } else {
439
+ this.timedEventBuffer.delete(trackId);
440
+ }
441
+ }
442
+
443
+ // Tell MistServer to fast-forward as well
444
+ this.sendFastForward(this.currentPlaybackTime + this.bufferAhead);
445
+ }
446
+
447
+ /**
448
+ * Get buffer status for debugging
449
+ */
450
+ getBufferStatus(): Record<string, { count: number; oldestMs: number; newestMs: number }> {
451
+ const status: Record<string, { count: number; oldestMs: number; newestMs: number }> = {};
452
+
453
+ for (const [trackId, events] of this.timedEventBuffer) {
454
+ if (events.length > 0) {
455
+ status[trackId] = {
456
+ count: events.length,
457
+ oldestMs: events[0].timestamp,
458
+ newestMs: events[events.length - 1].timestamp,
459
+ };
460
+ }
461
+ }
462
+
463
+ return status;
464
+ }
465
+
466
+ /**
467
+ * Build WebSocket URL for MistServer meta track subscription
468
+ * Uses the same endpoint as JSON info polling, just over WebSocket
469
+ */
470
+ private buildWsUrl(): string {
471
+ const baseUrl = this.config.mistBaseUrl
472
+ .replace(/^http:/, 'ws:')
473
+ .replace(/^https:/, 'wss:')
474
+ .replace(/\/$/, '');
475
+
476
+ // MistServer meta track WebSocket uses /json_<streamname>.js endpoint
477
+ // The rate=1 param tells MistServer to stream metadata in real-time
478
+ return `${baseUrl}/json_${this.config.streamName}.js?rate=1`;
479
+ }
480
+
481
+ /**
482
+ * Send tracks update to MistServer
483
+ * MistServer protocol: {type:"tracks", meta:"1,2,3"} (comma-separated track indices)
484
+ */
485
+ private sendTracksUpdate(): void {
486
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
487
+ const trackIds = Array.from(this.subscriptions.keys());
488
+ // Support "all" as special track ID to subscribe to all meta tracks
489
+ const metaValue = trackIds.includes('all') ? 'all' : trackIds.join(',');
490
+
491
+ const message = JSON.stringify({
492
+ type: 'tracks',
493
+ meta: metaValue
494
+ });
495
+ this.ws.send(message);
496
+ this.log(`Set tracks: ${metaValue}`);
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Send seek command to MistServer
502
+ * MistServer protocol: {type:"seek", seek_time:<ms>, ff_to:<ms>}
503
+ */
504
+ private sendSeek(timeInSeconds: number): void {
505
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
506
+ const seekTimeMs = Math.round(timeInSeconds * 1000);
507
+ const ffToMs = Math.round((timeInSeconds + this.bufferAhead) * 1000);
508
+
509
+ const message = JSON.stringify({
510
+ type: 'seek',
511
+ seek_time: seekTimeMs,
512
+ ff_to: ffToMs
513
+ });
514
+ this.ws.send(message);
515
+ this.log(`Seek to ${timeInSeconds}s, buffer ahead to ${timeInSeconds + this.bufferAhead}s`);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Send hold command (pause metadata delivery)
521
+ */
522
+ private sendHold(): void {
523
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
524
+ this.ws.send(JSON.stringify({ type: 'hold' }));
525
+ this.log('Sent hold');
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Send play command (resume metadata delivery)
531
+ */
532
+ private sendPlay(): void {
533
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
534
+ this.ws.send(JSON.stringify({ type: 'play' }));
535
+ this.log('Sent play');
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Send fast-forward command
541
+ */
542
+ private sendFastForward(targetTimeSeconds: number): void {
543
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
544
+ const message = JSON.stringify({
545
+ type: 'fast_forward',
546
+ ff_to: Math.round(targetTimeSeconds * 1000)
547
+ });
548
+ this.ws.send(message);
549
+ this.log(`Fast-forward to ${targetTimeSeconds}s`);
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Handle incoming WebSocket message
555
+ * MistServer format:
556
+ * - Metadata: {time:<ms>, track:<index>, data:{...}}
557
+ * - Status: {type:"on_time", data:{current:<ms>}}
558
+ * - Seek complete: {type:"seek", ...}
559
+ */
560
+ private handleMessage(data: string): void {
561
+ try {
562
+ const parsed = JSON.parse(data);
563
+
564
+ // Handle metadata event: {time, track, data}
565
+ if ('time' in parsed && 'track' in parsed && 'data' in parsed) {
566
+ const event = this.parseMetaTrackEvent(parsed);
567
+
568
+ // Check if we're subscribed to this track (or "all")
569
+ const trackId = String(parsed.track);
570
+ if (this.subscriptions.has(trackId) || this.subscriptions.has('all')) {
571
+ // Subtitles and chapters should be buffered for timed playback
572
+ // Other events (scores, generic events) dispatch immediately
573
+ if (event.type === 'subtitle' || event.type === 'chapter') {
574
+ this.addToTimedBuffer(event);
575
+ // Also process immediately in case we're already past this time
576
+ this.processTimedEvents();
577
+ } else {
578
+ // Dispatch immediately for non-timed events
579
+ this.dispatchEvent(event);
580
+ }
581
+ }
582
+ return;
583
+ }
584
+
585
+ // Handle server status messages: {type:..., ...}
586
+ if ('type' in parsed) {
587
+ switch (parsed.type) {
588
+ case 'on_time':
589
+ // Server time update - can be used for buffer management
590
+ if (parsed.data?.current) {
591
+ const serverTimeMs = parsed.data.current;
592
+ const playerTimeMs = this.currentPlaybackTime * 1000;
593
+ const aheadMs = serverTimeMs - playerTimeMs;
594
+
595
+ // If server is too far ahead, pause and wait
596
+ if (aheadMs > this.bufferAhead * 6 * 1000) {
597
+ this.log(`Server ${aheadMs}ms ahead, sending hold`);
598
+ this.sendHold();
599
+ }
600
+ }
601
+ break;
602
+
603
+ case 'seek':
604
+ // Seek completed - clear buffers
605
+ this.log('Server confirmed seek, clearing buffers');
606
+ this.timedEventBuffer.clear();
607
+ break;
608
+
609
+ default:
610
+ this.log(`Unknown message type: ${parsed.type}`);
611
+ }
612
+ }
613
+ } catch (error) {
614
+ this.log(`Failed to parse message: ${error}`);
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Parse meta track event from MistServer message
620
+ * MistServer format: {time:<ms>, track:<index>, data:{...}}
621
+ */
622
+ private parseMetaTrackEvent(message: {
623
+ track: string | number;
624
+ time: number;
625
+ data?: unknown;
626
+ [key: string]: unknown;
627
+ }): MetaTrackEvent {
628
+ const trackId = String(message.track);
629
+ const timestamp = Number(message.time);
630
+ const data = message.data ?? message;
631
+
632
+ // Detect event type from data shape
633
+ const type = this.detectEventType(data);
634
+
635
+ return {
636
+ type,
637
+ timestamp,
638
+ trackId,
639
+ data,
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Detect event type from data shape
645
+ */
646
+ private detectEventType(data: unknown): MetaTrackEventType {
647
+ if (typeof data !== 'object' || data === null) {
648
+ return 'unknown';
649
+ }
650
+
651
+ const obj = data as Record<string, unknown>;
652
+
653
+ // Subtitle: has text, startTime/endTime
654
+ if ('text' in obj && ('startTime' in obj || 'start' in obj)) {
655
+ return 'subtitle';
656
+ }
657
+
658
+ // Score: has key and value
659
+ if ('key' in obj && 'value' in obj) {
660
+ return 'score';
661
+ }
662
+
663
+ // Chapter: has title and startTime
664
+ if ('title' in obj && 'startTime' in obj) {
665
+ return 'chapter';
666
+ }
667
+
668
+ // Event: has name
669
+ if ('name' in obj) {
670
+ return 'event';
671
+ }
672
+
673
+ return 'unknown';
674
+ }
675
+
676
+ /**
677
+ * Dispatch event to subscribers
678
+ */
679
+ private dispatchEvent(event: MetaTrackEvent): void {
680
+ const callbacks = this.subscriptions.get(event.trackId);
681
+ if (callbacks) {
682
+ for (const callback of callbacks) {
683
+ try {
684
+ callback(event);
685
+ } catch (error) {
686
+ console.error('[MetaTrackManager] Callback error:', error);
687
+ }
688
+ }
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Schedule reconnection attempt
694
+ */
695
+ private scheduleReconnect(): void {
696
+ if (this.state === 'disconnected') return;
697
+
698
+ if (this.reconnectAttempt >= MetaTrackManager.MAX_RECONNECT_ATTEMPTS) {
699
+ this.log('Max reconnect attempts reached');
700
+ this.state = 'disconnected';
701
+ return;
702
+ }
703
+
704
+ this.state = 'reconnecting';
705
+ this.reconnectAttempt++;
706
+
707
+ const delay = Math.min(
708
+ MetaTrackManager.INITIAL_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempt - 1),
709
+ MetaTrackManager.MAX_RECONNECT_DELAY
710
+ );
711
+
712
+ this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
713
+
714
+ this.timers.start(() => {
715
+ this.connect();
716
+ }, delay, 'reconnect');
717
+ }
718
+
719
+ /**
720
+ * Buffer message for later delivery
721
+ */
722
+ private bufferMessage(event: MetaTrackEvent): void {
723
+ this.messageBuffer.push(event);
724
+
725
+ // Limit buffer size
726
+ while (this.messageBuffer.length > MetaTrackManager.MESSAGE_BUFFER_SIZE) {
727
+ this.messageBuffer.shift();
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Flush buffered messages to subscribers
733
+ */
734
+ private flushMessageBuffer(): void {
735
+ const buffered = [...this.messageBuffer];
736
+ this.messageBuffer = [];
737
+
738
+ for (const event of buffered) {
739
+ this.dispatchEvent(event);
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Debug logging
745
+ */
746
+ private log(message: string): void {
747
+ if (this.debug) {
748
+ console.debug(`[MetaTrackManager] ${message}`);
749
+ }
750
+ }
751
+ }
752
+
753
+ export default MetaTrackManager;