@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,900 @@
1
+ /**
2
+ * PlayerManager
3
+ *
4
+ * Central orchestrator for player selection and lifecycle management.
5
+ * Single source of truth for all scoring logic.
6
+ *
7
+ * Architecture:
8
+ * - `getAllCombinations()` is THE single function that computes player+source scores
9
+ * - Results are cached by content (source types + track codecs), not object identity
10
+ * - Events fire only when selection actually changes (no render spam)
11
+ * - `selectBestPlayer()` returns cached winner without recomputation
12
+ */
13
+
14
+ import { getBrowserInfo, getBrowserCompatibility } from './detector';
15
+ import { IPlayer, StreamSource, StreamInfo, PlayerOptions } from './PlayerInterface';
16
+ import { scorePlayer, isProtocolBlacklisted } from './scorer';
17
+ import type { PlaybackMode } from '../types';
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface PlayerSelection {
24
+ score: number;
25
+ player: string;
26
+ source: StreamSource;
27
+ source_index: number;
28
+ }
29
+
30
+ export interface PlayerManagerOptions {
31
+ /** Force a specific player */
32
+ forcePlayer?: string;
33
+ /** Force a specific source index */
34
+ forceSource?: number;
35
+ /** Force a specific MIME type */
36
+ forceType?: string;
37
+ /** Enable debug logging (logs selection changes only, not every render) */
38
+ debug?: boolean;
39
+ /** Automatic fallback on player failure */
40
+ autoFallback?: boolean;
41
+ /** Maximum fallback attempts */
42
+ maxFallbackAttempts?: number;
43
+ /** Playback mode for protocol selection */
44
+ playbackMode?: PlaybackMode;
45
+ }
46
+
47
+ export interface PlayerManagerEvents {
48
+ playerSelected: { player: string; source: StreamSource; score: number };
49
+ playerInitialized: { player: IPlayer; videoElement: HTMLVideoElement };
50
+ playerFailed: { player: string; error: string };
51
+ fallbackAttempted: { fromPlayer: string; toPlayer: string };
52
+ /** Fires when selection changes (different player+source than before) */
53
+ 'selection-changed': PlayerSelection | null;
54
+ /** Fires when combinations are recomputed (cache miss) */
55
+ 'combinations-updated': PlayerCombination[];
56
+ }
57
+
58
+ /** Full combination info including scoring breakdown */
59
+ export interface PlayerCombination {
60
+ player: string;
61
+ playerName: string;
62
+ source: StreamSource;
63
+ sourceIndex: number;
64
+ sourceType: string;
65
+ score: number;
66
+ compatible: boolean;
67
+ incompatibleReason?: string;
68
+ /** True when player supports MIME but codec is incompatible */
69
+ codecIncompatible?: boolean;
70
+ scoreBreakdown?: {
71
+ trackScore: number;
72
+ trackTypes: string[];
73
+ priorityScore: number;
74
+ sourceScore: number;
75
+ reliabilityScore?: number;
76
+ modeBonus?: number;
77
+ routingBonus?: number;
78
+ weights: {
79
+ tracks: number;
80
+ priority: number;
81
+ source: number;
82
+ reliability?: number;
83
+ mode?: number;
84
+ routing?: number;
85
+ };
86
+ };
87
+ }
88
+
89
+ // ============================================================================
90
+ // PlayerManager Class
91
+ // ============================================================================
92
+
93
+ export class PlayerManager {
94
+ private players: Map<string, IPlayer> = new Map();
95
+ private currentPlayer: IPlayer | null = null;
96
+ private listeners: Map<string, Set<Function>> = new Map();
97
+ private fallbackAttempts = 0;
98
+ private options: PlayerManagerOptions;
99
+
100
+ // Caching: prevents recalculation on every render
101
+ private cachedCombinations: PlayerCombination[] | null = null;
102
+ private cachedSelection: PlayerSelection | null = null;
103
+ private cacheKey: string | null = null;
104
+ private lastLoggedWinner: string | null = null;
105
+
106
+ // Fallback state
107
+ private lastContainer: HTMLElement | null = null;
108
+ private lastStreamInfo: StreamInfo | null = null;
109
+ private lastPlayerOptions: PlayerOptions = {};
110
+ private lastManagerOptions: PlayerManagerOptions = {};
111
+ private excludedPlayers: Set<string> = new Set();
112
+
113
+ // Serializes lifecycle operations to prevent race conditions
114
+ private opQueue: Promise<void> = Promise.resolve();
115
+
116
+ constructor(options: PlayerManagerOptions = {}) {
117
+ this.options = {
118
+ debug: false,
119
+ autoFallback: true,
120
+ maxFallbackAttempts: 3,
121
+ ...options,
122
+ };
123
+ }
124
+
125
+ // ==========================================================================
126
+ // Player Registration
127
+ // ==========================================================================
128
+
129
+ registerPlayer(player: IPlayer): void {
130
+ this.players.set(player.capability.shortname, player);
131
+ this.invalidateCache();
132
+ this.log(`Registered player: ${player.capability.name}`);
133
+ }
134
+
135
+ unregisterPlayer(shortname: string): void {
136
+ const player = this.players.get(shortname);
137
+ if (player) {
138
+ player.destroy();
139
+ this.players.delete(shortname);
140
+ this.invalidateCache();
141
+ this.log(`Unregistered player: ${shortname}`);
142
+ }
143
+ }
144
+
145
+ getRegisteredPlayers(): IPlayer[] {
146
+ return Array.from(this.players.values());
147
+ }
148
+
149
+ // ==========================================================================
150
+ // Caching
151
+ // ==========================================================================
152
+
153
+ /**
154
+ * Compute cache key based on CONTENT, not object identity.
155
+ * Prevents recalculation when streamInfo is a new object with same data.
156
+ */
157
+ private computeCacheKey(streamInfo: StreamInfo, mode: PlaybackMode): string {
158
+ return JSON.stringify({
159
+ sources: streamInfo.source.map((s) => s.type).sort(),
160
+ tracks: streamInfo.meta?.tracks?.map((t) => t.codec).sort() ?? [],
161
+ mode,
162
+ forcePlayer: this.options.forcePlayer,
163
+ forceSource: this.options.forceSource,
164
+ forceType: this.options.forceType,
165
+ });
166
+ }
167
+
168
+ /** Invalidate cache (called when player registrations change) */
169
+ invalidateCache(): void {
170
+ this.cachedCombinations = null;
171
+ this.cachedSelection = null;
172
+ this.cacheKey = null;
173
+ }
174
+
175
+ /** Get cached selection without recomputing */
176
+ getCurrentSelection(): PlayerSelection | null {
177
+ return this.cachedSelection;
178
+ }
179
+
180
+ /** Get cached combinations without recomputing */
181
+ getCachedCombinations(): PlayerCombination[] | null {
182
+ return this.cachedCombinations;
183
+ }
184
+
185
+ // ==========================================================================
186
+ // Selection Logic (Single Source of Truth)
187
+ // ==========================================================================
188
+
189
+ /**
190
+ * THE single source of truth for player+source scoring.
191
+ * Returns ALL combinations (compatible and incompatible) with scores.
192
+ * Results are cached - won't recompute if source types/tracks haven't changed.
193
+ */
194
+ getAllCombinations(
195
+ streamInfo: StreamInfo,
196
+ playbackMode?: PlaybackMode
197
+ ): PlayerCombination[] {
198
+ // Determine effective playback mode
199
+ const explicitMode = playbackMode || this.options.playbackMode;
200
+ const effectiveMode: PlaybackMode =
201
+ explicitMode && explicitMode !== 'auto'
202
+ ? explicitMode
203
+ : streamInfo.type === 'vod'
204
+ ? 'vod'
205
+ : 'auto';
206
+
207
+ // Check cache
208
+ const key = this.computeCacheKey(streamInfo, effectiveMode);
209
+ if (key === this.cacheKey && this.cachedCombinations) {
210
+ return this.cachedCombinations;
211
+ }
212
+
213
+ // Cache miss - compute all combinations
214
+ const combinations = this.computeAllCombinations(streamInfo, effectiveMode);
215
+
216
+ // Update cache
217
+ this.cachedCombinations = combinations;
218
+ this.cacheKey = key;
219
+
220
+ // Update selection and emit events if changed
221
+ const newSelection = this.pickBestFromCombinations(combinations);
222
+ const selectionChanged = this.hasSelectionChanged(newSelection);
223
+
224
+ if (selectionChanged) {
225
+ this.cachedSelection = newSelection;
226
+
227
+ // Log only on actual change
228
+ if (this.options.debug && newSelection) {
229
+ const winnerKey = `${newSelection.player}:${newSelection.source?.type}`;
230
+ if (winnerKey !== this.lastLoggedWinner) {
231
+ console.log(
232
+ `[PlayerManager] Selection: ${newSelection.player} + ${newSelection.source?.type} (score: ${newSelection.score.toFixed(3)})`
233
+ );
234
+ this.lastLoggedWinner = winnerKey;
235
+ }
236
+ }
237
+
238
+ this.emit('selection-changed', newSelection);
239
+ }
240
+
241
+ this.emit('combinations-updated', combinations);
242
+ return combinations;
243
+ }
244
+
245
+ /**
246
+ * Select the best player for given stream info.
247
+ * Uses cached combinations - won't recompute if data hasn't changed.
248
+ */
249
+ selectBestPlayer(
250
+ streamInfo: StreamInfo,
251
+ options?: PlayerManagerOptions
252
+ ): PlayerSelection | false {
253
+ // Merge options
254
+ const mergedOptions = { ...this.options, ...options };
255
+
256
+ // Special handling for Legacy player - bypass normal selection
257
+ if (
258
+ mergedOptions.forcePlayer === 'mist-legacy' ||
259
+ mergedOptions.forceType === 'mist/legacy'
260
+ ) {
261
+ const legacyPlayer = this.players.get('mist-legacy');
262
+ if (legacyPlayer && streamInfo.source.length > 0) {
263
+ const firstSource = streamInfo.source[0];
264
+ const legacySource: StreamSource = {
265
+ url: firstSource.url,
266
+ type: 'mist/legacy',
267
+ streamName: firstSource.streamName,
268
+ mistPlayerUrl: firstSource.mistPlayerUrl,
269
+ };
270
+ const result: PlayerSelection = {
271
+ score: 0.1,
272
+ player: 'mist-legacy',
273
+ source: legacySource,
274
+ source_index: 0,
275
+ };
276
+ this.emit('playerSelected', {
277
+ player: result.player,
278
+ source: result.source,
279
+ score: result.score,
280
+ });
281
+ return result;
282
+ }
283
+ }
284
+
285
+ // Get combinations (will use cache if available)
286
+ const combinations = this.getAllCombinations(
287
+ streamInfo,
288
+ mergedOptions.playbackMode
289
+ );
290
+
291
+ // Apply force filters
292
+ let filtered = combinations.filter((c) => c.compatible);
293
+
294
+ if (mergedOptions.forcePlayer) {
295
+ filtered = filtered.filter((c) => c.player === mergedOptions.forcePlayer);
296
+ }
297
+ if (mergedOptions.forceType) {
298
+ filtered = filtered.filter(
299
+ (c) => c.sourceType === mergedOptions.forceType
300
+ );
301
+ }
302
+ if (mergedOptions.forceSource !== undefined) {
303
+ filtered = filtered.filter(
304
+ (c) => c.sourceIndex === mergedOptions.forceSource
305
+ );
306
+ }
307
+
308
+ if (filtered.length === 0) {
309
+ this.log('No suitable player found');
310
+ return false;
311
+ }
312
+
313
+ const best = filtered[0];
314
+ const result: PlayerSelection = {
315
+ score: best.score,
316
+ player: best.player,
317
+ source: best.source,
318
+ source_index: best.sourceIndex,
319
+ };
320
+
321
+ this.emit('playerSelected', {
322
+ player: result.player,
323
+ source: result.source,
324
+ score: result.score,
325
+ });
326
+
327
+ return result;
328
+ }
329
+
330
+ /**
331
+ * Internal: compute all combinations (no caching)
332
+ */
333
+ private computeAllCombinations(
334
+ streamInfo: StreamInfo,
335
+ effectiveMode: PlaybackMode
336
+ ): PlayerCombination[] {
337
+ const combinations: PlayerCombination[] = [];
338
+ const players = Array.from(this.players.values());
339
+ const maxPriority = Math.max(
340
+ ...players.map((p) => p.capability.priority),
341
+ 1
342
+ );
343
+
344
+ // Filter blacklisted sources for scoring index calculation
345
+ const selectionSources = streamInfo.source.filter(
346
+ (s) => !isProtocolBlacklisted(s.type)
347
+ );
348
+ const selectionIndexBySource = new Map<StreamSource, number>();
349
+ selectionSources.forEach((s, idx) => selectionIndexBySource.set(s, idx));
350
+ const totalSources = selectionSources.length;
351
+
352
+ // Track seen player+sourceType pairs to avoid duplicates
353
+ const seenPairs = new Set<string>();
354
+
355
+ for (const player of players) {
356
+ for (let sourceIndex = 0; sourceIndex < streamInfo.source.length; sourceIndex++) {
357
+ const source = streamInfo.source[sourceIndex];
358
+ const pairKey = `${player.capability.shortname}:${source.type}`;
359
+
360
+ // Skip duplicate player+sourceType combinations
361
+ if (seenPairs.has(pairKey)) continue;
362
+ seenPairs.add(pairKey);
363
+
364
+ // Blacklisted protocols: show as incompatible
365
+ const sourceListIndex = selectionIndexBySource.get(source);
366
+ if (sourceListIndex === undefined) {
367
+ combinations.push({
368
+ player: player.capability.shortname,
369
+ playerName: player.capability.name,
370
+ source,
371
+ sourceIndex,
372
+ sourceType: source.type,
373
+ score: 0,
374
+ compatible: false,
375
+ incompatibleReason: `Protocol "${source.type}" is blacklisted`,
376
+ });
377
+ continue;
378
+ }
379
+
380
+ // Check MIME support
381
+ const mimeSupported = player.isMimeSupported(source.type);
382
+ if (!mimeSupported) {
383
+ combinations.push({
384
+ player: player.capability.shortname,
385
+ playerName: player.capability.name,
386
+ source,
387
+ sourceIndex,
388
+ sourceType: source.type,
389
+ score: 0,
390
+ compatible: false,
391
+ incompatibleReason: `MIME type "${source.type}" not supported`,
392
+ });
393
+ continue;
394
+ }
395
+
396
+ // Check browser/codec compatibility
397
+ const tracktypes = player.isBrowserSupported(
398
+ source.type,
399
+ source,
400
+ streamInfo
401
+ );
402
+ if (!tracktypes) {
403
+ // Codec incompatible - still score for UI display
404
+ const priorityScore =
405
+ 1 - player.capability.priority / Math.max(maxPriority, 1);
406
+ const sourceScore =
407
+ 1 - sourceListIndex / Math.max(totalSources - 1, 1);
408
+ const playerScore = scorePlayer(
409
+ ['video', 'audio'],
410
+ player.capability.priority,
411
+ sourceListIndex,
412
+ {
413
+ maxPriority,
414
+ totalSources,
415
+ playerShortname: player.capability.shortname,
416
+ mimeType: source.type,
417
+ playbackMode: effectiveMode,
418
+ }
419
+ );
420
+
421
+ combinations.push({
422
+ player: player.capability.shortname,
423
+ playerName: player.capability.name,
424
+ source,
425
+ sourceIndex,
426
+ sourceType: source.type,
427
+ score: playerScore.total,
428
+ compatible: false,
429
+ codecIncompatible: true,
430
+ incompatibleReason: 'Codec not supported by browser',
431
+ scoreBreakdown: {
432
+ trackScore: 0,
433
+ trackTypes: [],
434
+ priorityScore,
435
+ sourceScore,
436
+ weights: { tracks: 0.5, priority: 0.1, source: 0.05 },
437
+ },
438
+ });
439
+ continue;
440
+ }
441
+
442
+ // Compatible - calculate full score
443
+ const trackScore = Array.isArray(tracktypes)
444
+ ? tracktypes.reduce(
445
+ (sum, t) =>
446
+ sum + ({ video: 2.0, audio: 1.0, subtitle: 0.5 }[t] || 0),
447
+ 0
448
+ )
449
+ : 1.9;
450
+ const priorityScore =
451
+ 1 - player.capability.priority / Math.max(maxPriority, 1);
452
+ const sourceScore =
453
+ 1 - sourceListIndex / Math.max(totalSources - 1, 1);
454
+
455
+ const playerScore = scorePlayer(
456
+ tracktypes,
457
+ player.capability.priority,
458
+ sourceListIndex,
459
+ {
460
+ maxPriority,
461
+ totalSources,
462
+ playerShortname: player.capability.shortname,
463
+ mimeType: source.type,
464
+ playbackMode: effectiveMode,
465
+ }
466
+ );
467
+
468
+ combinations.push({
469
+ player: player.capability.shortname,
470
+ playerName: player.capability.name,
471
+ source,
472
+ sourceIndex,
473
+ sourceType: source.type,
474
+ score: playerScore.total,
475
+ compatible: true,
476
+ scoreBreakdown: {
477
+ trackScore,
478
+ trackTypes: Array.isArray(tracktypes)
479
+ ? tracktypes
480
+ : ['video', 'audio'],
481
+ priorityScore,
482
+ sourceScore,
483
+ reliabilityScore: playerScore.breakdown?.reliabilityScore ?? 0,
484
+ modeBonus: playerScore.breakdown?.modeBonus ?? 0,
485
+ routingBonus: playerScore.breakdown?.routingBonus ?? 0,
486
+ weights: {
487
+ tracks: 0.5,
488
+ priority: 0.1,
489
+ source: 0.05,
490
+ reliability: 0.1,
491
+ mode: 0.12,
492
+ routing: 0.08,
493
+ },
494
+ },
495
+ });
496
+ }
497
+ }
498
+
499
+ // Add Legacy player option
500
+ const legacyPlayer = this.players.get('mist-legacy');
501
+ if (legacyPlayer && streamInfo.source.length > 0) {
502
+ const firstSource = streamInfo.source[0];
503
+ const legacySource: StreamSource = {
504
+ url: firstSource.url,
505
+ type: 'mist/legacy',
506
+ streamName: firstSource.streamName,
507
+ mistPlayerUrl: firstSource.mistPlayerUrl,
508
+ };
509
+
510
+ combinations.push({
511
+ player: legacyPlayer.capability.shortname,
512
+ playerName: legacyPlayer.capability.name,
513
+ source: legacySource,
514
+ sourceIndex: 0,
515
+ sourceType: 'mist/legacy',
516
+ score: 0.1,
517
+ compatible: true,
518
+ scoreBreakdown: {
519
+ trackScore: 2.0,
520
+ trackTypes: ['video', 'audio'],
521
+ priorityScore: 0,
522
+ sourceScore: 0,
523
+ weights: { tracks: 0.5, priority: 0.1, source: 0.05 },
524
+ },
525
+ });
526
+ }
527
+
528
+ // Sort: compatible first by score descending, then incompatible alphabetically
529
+ return combinations.sort((a, b) => {
530
+ if (a.compatible !== b.compatible) return a.compatible ? -1 : 1;
531
+ if (a.compatible) return b.score - a.score;
532
+ return a.playerName.localeCompare(b.playerName);
533
+ });
534
+ }
535
+
536
+ /**
537
+ * Pick best compatible combination
538
+ */
539
+ private pickBestFromCombinations(
540
+ combinations: PlayerCombination[]
541
+ ): PlayerSelection | null {
542
+ const compatible = combinations.filter((c) => c.compatible);
543
+ if (compatible.length === 0) return null;
544
+
545
+ const best = compatible[0];
546
+ return {
547
+ score: best.score,
548
+ player: best.player,
549
+ source: best.source,
550
+ source_index: best.sourceIndex,
551
+ };
552
+ }
553
+
554
+ /**
555
+ * Check if selection changed
556
+ */
557
+ private hasSelectionChanged(newSelection: PlayerSelection | null): boolean {
558
+ if (!this.cachedSelection && !newSelection) return false;
559
+ if (!this.cachedSelection || !newSelection) return true;
560
+ return (
561
+ this.cachedSelection.player !== newSelection.player ||
562
+ this.cachedSelection.source?.type !== newSelection.source?.type
563
+ );
564
+ }
565
+
566
+ // ==========================================================================
567
+ // Player Initialization
568
+ // ==========================================================================
569
+
570
+ private enqueueOp<T>(op: () => Promise<T>): Promise<T> {
571
+ const run = this.opQueue.then(op, op);
572
+ this.opQueue = run.then(
573
+ () => undefined,
574
+ () => undefined
575
+ );
576
+ return run;
577
+ }
578
+
579
+ async initializePlayer(
580
+ container: HTMLElement,
581
+ streamInfo: StreamInfo,
582
+ playerOptions: PlayerOptions = {},
583
+ managerOptions?: PlayerManagerOptions
584
+ ): Promise<HTMLVideoElement> {
585
+ console.log('[PlayerManager] initializePlayer() called');
586
+ return this.enqueueOp(async () => {
587
+ console.log('[PlayerManager] Inside enqueueOp - starting');
588
+ this.fallbackAttempts = 0;
589
+ this.excludedPlayers.clear();
590
+
591
+ // Save for fallback (strip force settings - they're one-shot, not for fallback)
592
+ this.lastContainer = container;
593
+ this.lastStreamInfo = streamInfo;
594
+ this.lastPlayerOptions = playerOptions;
595
+ // Keep playback mode (persistent preference) but clear force settings
596
+ this.lastManagerOptions = {
597
+ playbackMode: managerOptions?.playbackMode,
598
+ debug: managerOptions?.debug,
599
+ autoFallback: managerOptions?.autoFallback,
600
+ maxFallbackAttempts: managerOptions?.maxFallbackAttempts,
601
+ // forcePlayer, forceType, forceSource are intentionally NOT saved
602
+ // They are one-shot selections that shouldn't persist through fallback
603
+ };
604
+
605
+ return this.tryInitializePlayer(
606
+ container,
607
+ streamInfo,
608
+ playerOptions,
609
+ managerOptions
610
+ );
611
+ });
612
+ }
613
+
614
+ private async tryInitializePlayer(
615
+ container: HTMLElement,
616
+ streamInfo: StreamInfo,
617
+ playerOptions: PlayerOptions,
618
+ managerOptions?: PlayerManagerOptions,
619
+ excludePlayers: Set<string> = new Set()
620
+ ): Promise<HTMLVideoElement> {
621
+ console.log('[PlayerManager] tryInitializePlayer() starting');
622
+
623
+ // Clean up previous player
624
+ if (this.currentPlayer) {
625
+ console.log('[PlayerManager] Cleaning up previous player...');
626
+ await Promise.resolve(this.currentPlayer.destroy());
627
+ this.currentPlayer = null;
628
+ }
629
+ container.innerHTML = '';
630
+
631
+ // Filter excluded players
632
+ const availableSources = streamInfo.source.filter((_, index) => {
633
+ if (excludePlayers.size === 0) return true;
634
+ const selection = this.selectBestPlayer(
635
+ { ...streamInfo, source: [streamInfo.source[index]] },
636
+ managerOptions
637
+ );
638
+ return selection && !excludePlayers.has(selection.player);
639
+ });
640
+
641
+ if (availableSources.length === 0) {
642
+ console.log('[PlayerManager] No available sources after filtering');
643
+ throw new Error('No available players after fallback attempts');
644
+ }
645
+
646
+ console.log(`[PlayerManager] Available sources: ${availableSources.length}`);
647
+ const modifiedStreamInfo = { ...streamInfo, source: availableSources };
648
+ const selection = this.selectBestPlayer(modifiedStreamInfo, managerOptions);
649
+
650
+ if (!selection) {
651
+ console.log('[PlayerManager] No suitable player selected');
652
+ throw new Error('No suitable player found for stream');
653
+ }
654
+
655
+ console.log(`[PlayerManager] Selected: ${selection.player} for ${selection.source.type}`);
656
+ const player = this.players.get(selection.player);
657
+ if (!player) {
658
+ console.log(`[PlayerManager] Player ${selection.player} not registered`);
659
+ throw new Error(`Player ${selection.player} not found`);
660
+ }
661
+
662
+ console.log(`[PlayerManager] Calling ${selection.player}.initialize()...`);
663
+ try {
664
+ const videoElement = await player.initialize(
665
+ container,
666
+ selection.source,
667
+ playerOptions,
668
+ streamInfo
669
+ );
670
+ console.log(`[PlayerManager] ${selection.player}.initialize() completed successfully`);
671
+ this.currentPlayer = player;
672
+ this.emit('playerInitialized', { player, videoElement });
673
+ return videoElement;
674
+ } catch (error: unknown) {
675
+ const errorMessage =
676
+ error instanceof Error ? error.message : String(error);
677
+ this.log(`Player ${selection.player} failed: ${errorMessage}`);
678
+ this.emit('playerFailed', { player: selection.player, error: errorMessage });
679
+
680
+ // Attempt fallback
681
+ if (
682
+ this.options.autoFallback &&
683
+ this.fallbackAttempts < (this.options.maxFallbackAttempts || 3)
684
+ ) {
685
+ this.fallbackAttempts++;
686
+ excludePlayers.add(selection.player);
687
+ this.log(`Attempting fallback (attempt ${this.fallbackAttempts})`);
688
+ this.emit('fallbackAttempted', {
689
+ fromPlayer: selection.player,
690
+ toPlayer: 'auto',
691
+ });
692
+
693
+ return this.tryInitializePlayer(
694
+ container,
695
+ streamInfo,
696
+ playerOptions,
697
+ managerOptions,
698
+ excludePlayers
699
+ );
700
+ }
701
+
702
+ throw error;
703
+ }
704
+ }
705
+
706
+ // ==========================================================================
707
+ // Fallback Management
708
+ // ==========================================================================
709
+
710
+ async tryPlaybackFallback(): Promise<boolean> {
711
+ return this.enqueueOp(async () => {
712
+ if (!this.lastContainer || !this.lastStreamInfo) {
713
+ this.log('Cannot attempt fallback: no previous init params');
714
+ return false;
715
+ }
716
+
717
+ const maxAttempts = this.options.maxFallbackAttempts || 3;
718
+ if (this.fallbackAttempts >= maxAttempts) {
719
+ this.log(`Fallback exhausted (${this.fallbackAttempts}/${maxAttempts})`);
720
+ return false;
721
+ }
722
+
723
+ if (this.currentPlayer) {
724
+ this.excludedPlayers.add(this.currentPlayer.capability.shortname);
725
+ await Promise.resolve(this.currentPlayer.destroy());
726
+ this.currentPlayer = null;
727
+ }
728
+
729
+ this.fallbackAttempts++;
730
+ this.lastContainer.innerHTML = '';
731
+
732
+ try {
733
+ await this.tryInitializePlayer(
734
+ this.lastContainer,
735
+ this.lastStreamInfo,
736
+ this.lastPlayerOptions,
737
+ this.lastManagerOptions,
738
+ this.excludedPlayers
739
+ );
740
+
741
+ const current = this.getCurrentPlayer();
742
+ this.emit('fallbackAttempted', {
743
+ fromPlayer: Array.from(this.excludedPlayers).pop() || 'unknown',
744
+ toPlayer: current?.capability.shortname || 'unknown',
745
+ });
746
+
747
+ return true;
748
+ } catch {
749
+ this.log('Playback fallback failed');
750
+ return false;
751
+ }
752
+ });
753
+ }
754
+
755
+ getRemainingFallbackAttempts(): number {
756
+ return Math.max(
757
+ 0,
758
+ (this.options.maxFallbackAttempts || 3) - this.fallbackAttempts
759
+ );
760
+ }
761
+
762
+ canAttemptFallback(): boolean {
763
+ return this.getRemainingFallbackAttempts() > 0 && this.lastStreamInfo !== null;
764
+ }
765
+
766
+ getCurrentPlayer(): IPlayer | null {
767
+ return this.currentPlayer;
768
+ }
769
+
770
+ // ==========================================================================
771
+ // Browser Capabilities
772
+ // ==========================================================================
773
+
774
+ getBrowserCapabilities() {
775
+ const browser = getBrowserInfo();
776
+ const compatibility = getBrowserCompatibility();
777
+
778
+ return {
779
+ browser,
780
+ compatibility,
781
+ supportedMimeTypes: this.getSupportedMimeTypes(),
782
+ availablePlayers: this.getAvailablePlayerInfo(),
783
+ };
784
+ }
785
+
786
+ private getSupportedMimeTypes(): string[] {
787
+ const mimes = new Set<string>();
788
+ for (const player of this.players.values()) {
789
+ player.capability.mimes.forEach((mime) => mimes.add(mime));
790
+ }
791
+ return Array.from(mimes).sort();
792
+ }
793
+
794
+ private getAvailablePlayerInfo() {
795
+ return Array.from(this.players.values())
796
+ .map((player) => ({
797
+ name: player.capability.name,
798
+ shortname: player.capability.shortname,
799
+ priority: player.capability.priority,
800
+ mimes: player.capability.mimes,
801
+ }))
802
+ .sort((a, b) => a.priority - b.priority);
803
+ }
804
+
805
+ // ==========================================================================
806
+ // Lifecycle
807
+ // ==========================================================================
808
+
809
+ async destroy(): Promise<void> {
810
+ await this.enqueueOp(async () => {
811
+ if (this.currentPlayer) {
812
+ await Promise.resolve(this.currentPlayer.destroy());
813
+ this.currentPlayer = null;
814
+ }
815
+ });
816
+ }
817
+
818
+ removeAllListeners(): void {
819
+ this.listeners.clear();
820
+ }
821
+
822
+ // ==========================================================================
823
+ // Event System
824
+ // ==========================================================================
825
+
826
+ on<K extends keyof PlayerManagerEvents>(
827
+ event: K,
828
+ listener: (data: PlayerManagerEvents[K]) => void
829
+ ): () => void {
830
+ if (!this.listeners.has(event)) {
831
+ this.listeners.set(event, new Set());
832
+ }
833
+ this.listeners.get(event)!.add(listener);
834
+
835
+ // Return unsubscribe function
836
+ return () => this.off(event, listener);
837
+ }
838
+
839
+ off<K extends keyof PlayerManagerEvents>(
840
+ event: K,
841
+ listener: (data: PlayerManagerEvents[K]) => void
842
+ ): void {
843
+ this.listeners.get(event)?.delete(listener);
844
+ }
845
+
846
+ private emit<K extends keyof PlayerManagerEvents>(
847
+ event: K,
848
+ data: PlayerManagerEvents[K]
849
+ ): void {
850
+ this.listeners.get(event)?.forEach((listener) => {
851
+ try {
852
+ listener(data);
853
+ } catch (e) {
854
+ console.error(`Error in PlayerManager ${event} listener:`, e);
855
+ }
856
+ });
857
+ }
858
+
859
+ // ==========================================================================
860
+ // Logging
861
+ // ==========================================================================
862
+
863
+ private log(message: string): void {
864
+ if (this.options.debug) {
865
+ console.log(`[PlayerManager] ${message}`);
866
+ }
867
+ }
868
+
869
+ // ==========================================================================
870
+ // Testing
871
+ // ==========================================================================
872
+
873
+ async testSource(
874
+ source: StreamSource,
875
+ streamInfo: StreamInfo
876
+ ): Promise<{ canPlay: boolean; players: string[] }> {
877
+ const testStreamInfo = { ...streamInfo, source: [source] };
878
+ const selection = this.selectBestPlayer(testStreamInfo);
879
+
880
+ if (!selection) {
881
+ return { canPlay: false, players: [] };
882
+ }
883
+
884
+ const capablePlayers: string[] = [];
885
+ for (const player of this.players.values()) {
886
+ if (player.isMimeSupported(source.type)) {
887
+ const browserSupport = player.isBrowserSupported(
888
+ source.type,
889
+ source,
890
+ streamInfo
891
+ );
892
+ if (browserSupport) {
893
+ capablePlayers.push(player.capability.shortname);
894
+ }
895
+ }
896
+ }
897
+
898
+ return { canPlay: true, players: capablePlayers };
899
+ }
900
+ }