@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.
- package/dist/cjs/index.js +19493 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +19398 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/player.css +2140 -0
- package/dist/types/core/ABRController.d.ts +164 -0
- package/dist/types/core/CodecUtils.d.ts +54 -0
- package/dist/types/core/Disposable.d.ts +61 -0
- package/dist/types/core/EventEmitter.d.ts +73 -0
- package/dist/types/core/GatewayClient.d.ts +144 -0
- package/dist/types/core/InteractionController.d.ts +121 -0
- package/dist/types/core/LiveDurationProxy.d.ts +102 -0
- package/dist/types/core/MetaTrackManager.d.ts +220 -0
- package/dist/types/core/MistReporter.d.ts +163 -0
- package/dist/types/core/MistSignaling.d.ts +148 -0
- package/dist/types/core/PlayerController.d.ts +665 -0
- package/dist/types/core/PlayerInterface.d.ts +230 -0
- package/dist/types/core/PlayerManager.d.ts +182 -0
- package/dist/types/core/PlayerRegistry.d.ts +27 -0
- package/dist/types/core/QualityMonitor.d.ts +184 -0
- package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
- package/dist/types/core/SeekingUtils.d.ts +142 -0
- package/dist/types/core/StreamStateClient.d.ts +108 -0
- package/dist/types/core/SubtitleManager.d.ts +111 -0
- package/dist/types/core/TelemetryReporter.d.ts +79 -0
- package/dist/types/core/TimeFormat.d.ts +97 -0
- package/dist/types/core/TimerManager.d.ts +83 -0
- package/dist/types/core/UrlUtils.d.ts +81 -0
- package/dist/types/core/detector.d.ts +149 -0
- package/dist/types/core/index.d.ts +49 -0
- package/dist/types/core/scorer.d.ts +167 -0
- package/dist/types/core/selector.d.ts +9 -0
- package/dist/types/index.d.ts +45 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/players/DashJsPlayer.d.ts +102 -0
- package/dist/types/players/HlsJsPlayer.d.ts +70 -0
- package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
- package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
- package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
- package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
- package/dist/types/players/MistPlayer.d.ts +25 -0
- package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
- package/dist/types/players/NativePlayer.d.ts +143 -0
- package/dist/types/players/VideoJsPlayer.d.ts +59 -0
- package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
- package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
- package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
- package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
- package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
- package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
- package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
- package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
- package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
- package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
- package/dist/types/players/index.d.ts +14 -0
- package/dist/types/styles/index.d.ts +11 -0
- package/dist/types/types.d.ts +363 -0
- package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
- package/dist/types/vanilla/index.d.ts +19 -0
- package/dist/workers/decoder.worker.js +989 -0
- package/dist/workers/decoder.worker.js.map +1 -0
- package/package.json +80 -0
- package/src/core/ABRController.ts +550 -0
- package/src/core/CodecUtils.ts +257 -0
- package/src/core/Disposable.ts +120 -0
- package/src/core/EventEmitter.ts +113 -0
- package/src/core/GatewayClient.ts +439 -0
- package/src/core/InteractionController.ts +712 -0
- package/src/core/LiveDurationProxy.ts +270 -0
- package/src/core/MetaTrackManager.ts +753 -0
- package/src/core/MistReporter.ts +543 -0
- package/src/core/MistSignaling.ts +346 -0
- package/src/core/PlayerController.ts +2829 -0
- package/src/core/PlayerInterface.ts +432 -0
- package/src/core/PlayerManager.ts +900 -0
- package/src/core/PlayerRegistry.ts +149 -0
- package/src/core/QualityMonitor.ts +597 -0
- package/src/core/ScreenWakeLockManager.ts +163 -0
- package/src/core/SeekingUtils.ts +364 -0
- package/src/core/StreamStateClient.ts +457 -0
- package/src/core/SubtitleManager.ts +297 -0
- package/src/core/TelemetryReporter.ts +308 -0
- package/src/core/TimeFormat.ts +205 -0
- package/src/core/TimerManager.ts +209 -0
- package/src/core/UrlUtils.ts +179 -0
- package/src/core/detector.ts +382 -0
- package/src/core/index.ts +140 -0
- package/src/core/scorer.ts +553 -0
- package/src/core/selector.ts +16 -0
- package/src/global.d.ts +11 -0
- package/src/index.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/players/DashJsPlayer.ts +642 -0
- package/src/players/HlsJsPlayer.ts +483 -0
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
- package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
- package/src/players/MewsWsPlayer/index.ts +1065 -0
- package/src/players/MewsWsPlayer/types.ts +106 -0
- package/src/players/MistPlayer.ts +188 -0
- package/src/players/MistWebRTCPlayer/index.ts +703 -0
- package/src/players/NativePlayer.ts +820 -0
- package/src/players/VideoJsPlayer.ts +643 -0
- package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
- package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
- package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
- package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
- package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
- package/src/players/WebCodecsPlayer/index.ts +1650 -0
- package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
- package/src/players/WebCodecsPlayer/types.ts +542 -0
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
- package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
- package/src/players/index.ts +22 -0
- package/src/styles/animations.css +21 -0
- package/src/styles/index.ts +52 -0
- package/src/styles/player.css +2126 -0
- package/src/styles/tailwind.css +1015 -0
- package/src/types.ts +421 -0
- package/src/vanilla/FrameWorksPlayer.ts +367 -0
- 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;
|