@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,2829 @@
1
+ /**
2
+ * PlayerController.ts
3
+ *
4
+ * Main headless orchestrator for the player. This class encapsulates all business logic
5
+ * (gateway resolution, stream state polling, player selection/initialization) in a
6
+ * framework-agnostic manner.
7
+ *
8
+ * Both React and Vanilla wrappers use this class internally.
9
+ */
10
+
11
+ import { TypedEventEmitter } from './EventEmitter';
12
+ import { GatewayClient, GatewayStatus } from './GatewayClient';
13
+ import { StreamStateClient } from './StreamStateClient';
14
+ import { PlayerManager } from './PlayerManager';
15
+ import { globalPlayerManager, ensurePlayersRegistered } from './PlayerRegistry';
16
+ import { ABRController } from './ABRController';
17
+ import { InteractionController, type InteractionControllerConfig } from './InteractionController';
18
+ import { MistReporter } from './MistReporter';
19
+ import { QualityMonitor } from './QualityMonitor';
20
+ import { MetaTrackManager } from './MetaTrackManager';
21
+ import {
22
+ calculateSeekableRange,
23
+ calculateLiveThresholds,
24
+ calculateIsNearLive,
25
+ canSeekStream,
26
+ isMediaStreamSource,
27
+ supportsPlaybackRate,
28
+ isLiveContent,
29
+ getLatencyTier,
30
+ type LatencyTier,
31
+ type LiveThresholds,
32
+ } from './SeekingUtils';
33
+ import type { ABRMode, PlaybackQuality, ContentType } from '../types';
34
+ import type {
35
+ ContentEndpoints,
36
+ ContentMetadata,
37
+ EndpointInfo,
38
+ OutputEndpoint,
39
+ OutputCapabilities,
40
+ PlayerState,
41
+ PlayerStateContext,
42
+ StreamState,
43
+ } from '../types';
44
+ import type { StreamInfo, StreamSource, StreamTrack, IPlayer, PlayerOptions as CorePlayerOptions } from './PlayerInterface';
45
+
46
+ // ============================================================================
47
+ // Types
48
+ // ============================================================================
49
+
50
+ export interface PlayerControllerConfig {
51
+ /** Content identifier (stream name) */
52
+ contentId: string;
53
+ /** Content type */
54
+ contentType: ContentType;
55
+
56
+ /** Pre-resolved endpoints (skip gateway) */
57
+ endpoints?: ContentEndpoints;
58
+
59
+ /** Gateway URL (for FrameWorks Gateway resolution) */
60
+ gatewayUrl?: string;
61
+ /** Direct MistServer base URL (bypasses Gateway, fetches json_{contentId}.js directly) */
62
+ mistUrl?: string;
63
+ /** Auth token for private streams */
64
+ authToken?: string;
65
+
66
+ /** Playback options */
67
+ autoplay?: boolean;
68
+ muted?: boolean;
69
+ controls?: boolean;
70
+ poster?: string;
71
+
72
+ /** Debug logging */
73
+ debug?: boolean;
74
+
75
+ /** Custom PlayerManager instance (optional, uses global by default) */
76
+ playerManager?: PlayerManager;
77
+
78
+ // Dev mode overrides - passed to PlayerManager during player selection
79
+ /** Force a specific player (e.g., 'hlsjs', 'dashjs', 'native') */
80
+ forcePlayer?: string;
81
+ /** Force a specific MIME type (e.g., 'html5/application/vnd.apple.mpegurl') */
82
+ forceType?: string;
83
+ /** Force a specific source index */
84
+ forceSource?: number;
85
+ /** Playback mode preference */
86
+ playbackMode?: 'auto' | 'low-latency' | 'quality' | 'vod';
87
+ }
88
+
89
+ export interface PlayerControllerEvents {
90
+ /** Player state changed */
91
+ stateChange: { state: PlayerState; context?: PlayerStateContext };
92
+ /** Stream state changed (for live streams) */
93
+ streamStateChange: { state: StreamState };
94
+ /** Time update during playback */
95
+ timeUpdate: { currentTime: number; duration: number };
96
+ /** Error occurred */
97
+ error: { error: string; code?: string };
98
+ /** Error was cleared (auto-cleared or manually) */
99
+ errorCleared: void;
100
+ /** Player ready with video element */
101
+ ready: { videoElement: HTMLVideoElement };
102
+ /** Controller destroyed */
103
+ destroyed: void;
104
+
105
+ // ============================================================================
106
+ // Playback Events (Phase A5)
107
+ // ============================================================================
108
+
109
+ /** Player/source was selected */
110
+ playerSelected: { player: string; source: StreamSource; score: number };
111
+ /** Quality level changed (ABR switch) */
112
+ qualityChanged: { fromLevel?: string; toLevel: string };
113
+ /** Volume or mute state changed */
114
+ volumeChange: { volume: number; muted: boolean };
115
+ /** Fullscreen state changed */
116
+ fullscreenChange: { isFullscreen: boolean };
117
+ /** Picture-in-Picture state changed */
118
+ pipChange: { isPiP: boolean };
119
+ /** Loop mode changed */
120
+ loopChange: { isLoopEnabled: boolean };
121
+ /** Playback rate changed */
122
+ speedChange: { rate: number };
123
+ /** User skipped forward */
124
+ skipForward: { seconds: number };
125
+ /** User skipped backward */
126
+ skipBackward: { seconds: number };
127
+ /** Speed hold started (hold-for-2x gesture) */
128
+ holdSpeedStart: { speed: number };
129
+ /** Speed hold ended */
130
+ holdSpeedEnd: void;
131
+ /** Captions/subtitles toggled */
132
+ captionsChange: { enabled: boolean };
133
+
134
+ // ============================================================================
135
+ // Seeking & Live State Events (Centralized from wrappers)
136
+ // ============================================================================
137
+
138
+ /** Seeking/live state changed - emitted on timeupdate when values change */
139
+ seekingStateChange: {
140
+ seekableStart: number;
141
+ liveEdge: number;
142
+ canSeek: boolean;
143
+ isNearLive: boolean;
144
+ isLive: boolean;
145
+ isWebRTC: boolean;
146
+ latencyTier: LatencyTier;
147
+ buffered: TimeRanges | null;
148
+ hasAudio: boolean;
149
+ supportsPlaybackRate: boolean;
150
+ };
151
+
152
+ // ============================================================================
153
+ // Interaction Events (Phase A5)
154
+ // ============================================================================
155
+
156
+ /** User started hovering over player */
157
+ hoverStart: void;
158
+ /** User stopped hovering (after timeout) */
159
+ hoverEnd: void;
160
+ /** User became idle (no interaction for N seconds) */
161
+ interactionIdle: void;
162
+ /** User resumed interaction after being idle */
163
+ interactionActive: void;
164
+
165
+ // ============================================================================
166
+ // Metadata Events (Phase A5)
167
+ // ============================================================================
168
+
169
+ /** Playback metadata updated */
170
+ metadataUpdate: {
171
+ currentTime: number;
172
+ duration: number;
173
+ bufferedAhead: number;
174
+ qualityScore?: number;
175
+ playerInfo?: { name: string; shortname: string };
176
+ sourceInfo?: { url: string; type: string };
177
+ isLive: boolean;
178
+ isBuffering: boolean;
179
+ isPaused: boolean;
180
+ volume: number;
181
+ muted: boolean;
182
+ };
183
+ }
184
+
185
+ // ============================================================================
186
+ // MistServer Source Type Mapping
187
+ // ============================================================================
188
+
189
+ /**
190
+ * Complete MistServer source type mapping
191
+ * Maps MistServer's `source[].type` field to player selection info
192
+ *
193
+ * type field = MIME type used for player selection
194
+ * hrn = human readable name for UI
195
+ * player = recommended player implementation
196
+ * supported = whether we have a working player for it
197
+ */
198
+ export const MIST_SOURCE_TYPES: Record<string, { hrn: string; player: string; supported: boolean }> = {
199
+ // ===== VIDEO STREAMING (Primary) =====
200
+ 'html5/application/vnd.apple.mpegurl': { hrn: 'HLS (TS)', player: 'hlsjs', supported: true },
201
+ 'html5/application/vnd.apple.mpegurl;version=7': { hrn: 'HLS (CMAF)', player: 'hlsjs', supported: true },
202
+ 'dash/video/mp4': { hrn: 'DASH', player: 'dashjs', supported: true },
203
+ 'html5/video/mp4': { hrn: 'MP4 progressive', player: 'native', supported: true },
204
+ 'html5/video/webm': { hrn: 'WebM progressive', player: 'native', supported: true },
205
+
206
+ // ===== WEBSOCKET STREAMING =====
207
+ 'ws/video/mp4': { hrn: 'MP4 WebSocket', player: 'mews', supported: true },
208
+ 'wss/video/mp4': { hrn: 'MP4 WebSocket (SSL)', player: 'mews', supported: true },
209
+ 'ws/video/raw': { hrn: 'Raw WebSocket', player: 'webcodecs', supported: true },
210
+ 'wss/video/raw': { hrn: 'Raw WebSocket (SSL)', player: 'webcodecs', supported: true },
211
+ 'ws/video/h264': { hrn: 'Annex B WebSocket', player: 'webcodecs', supported: true },
212
+ 'wss/video/h264': { hrn: 'Annex B WebSocket (SSL)', player: 'webcodecs', supported: true },
213
+
214
+ // ===== WEBRTC =====
215
+ 'whep': { hrn: 'WebRTC (WHEP)', player: 'native', supported: true },
216
+ 'webrtc': { hrn: 'WebRTC (WebSocket)', player: 'mist-webrtc', supported: true },
217
+
218
+ // ===== AUDIO ONLY =====
219
+ 'html5/audio/aac': { hrn: 'AAC progressive', player: 'native', supported: true },
220
+ 'html5/audio/mp3': { hrn: 'MP3 progressive', player: 'native', supported: true },
221
+ 'html5/audio/flac': { hrn: 'FLAC progressive', player: 'native', supported: true },
222
+ 'html5/audio/wav': { hrn: 'WAV progressive', player: 'native', supported: true },
223
+
224
+ // ===== SUBTITLES/TEXT =====
225
+ 'html5/text/vtt': { hrn: 'WebVTT subtitles', player: 'track', supported: true },
226
+ 'html5/text/plain': { hrn: 'SRT subtitles', player: 'track', supported: true },
227
+
228
+ // ===== IMAGES =====
229
+ 'html5/image/jpeg': { hrn: 'JPEG thumbnail', player: 'image', supported: true },
230
+
231
+ // ===== METADATA =====
232
+ 'html5/text/javascript': { hrn: 'JSON metadata', player: 'fetch', supported: true },
233
+
234
+ // ===== LEGACY/UNSUPPORTED =====
235
+ 'html5/video/mpeg': { hrn: 'TS progressive', player: 'none', supported: false },
236
+ 'html5/video/h264': { hrn: 'Annex B progressive', player: 'none', supported: false },
237
+ 'html5/application/sdp': { hrn: 'SDP', player: 'none', supported: false },
238
+ 'html5/application/vnd.ms-sstr+xml': { hrn: 'Smooth Streaming', player: 'none', supported: false },
239
+ 'flash/7': { hrn: 'FLV', player: 'none', supported: false },
240
+ 'flash/10': { hrn: 'RTMP', player: 'none', supported: false },
241
+ 'flash/11': { hrn: 'HDS', player: 'none', supported: false },
242
+
243
+ // ===== SERVER-SIDE ONLY =====
244
+ 'rtsp': { hrn: 'RTSP', player: 'none', supported: false },
245
+ 'srt': { hrn: 'SRT', player: 'none', supported: false },
246
+ 'dtsc': { hrn: 'DTSC', player: 'none', supported: false },
247
+ };
248
+
249
+ /**
250
+ * Map Gateway protocol names to MistServer MIME types
251
+ * Gateway outputs use simplified protocol names like "HLS", "WHEP"
252
+ * while MistServer uses full MIME types
253
+ */
254
+ export const PROTOCOL_TO_MIME: Record<string, string> = {
255
+ // Standard protocols
256
+ 'HLS': 'html5/application/vnd.apple.mpegurl',
257
+ 'DASH': 'dash/video/mp4',
258
+ 'MP4': 'html5/video/mp4',
259
+ 'WEBM': 'html5/video/webm',
260
+ 'WHEP': 'whep',
261
+ 'WebRTC': 'webrtc',
262
+
263
+ // WebSocket variants
264
+ 'MEWS': 'ws/video/mp4',
265
+ 'MEWS_WS': 'ws/video/mp4',
266
+ 'MEWS_WSS': 'wss/video/mp4',
267
+ 'RAW_WS': 'ws/video/raw',
268
+ 'RAW_WSS': 'wss/video/raw',
269
+
270
+ // Audio
271
+ 'AAC': 'html5/audio/aac',
272
+ 'MP3': 'html5/audio/mp3',
273
+ 'FLAC': 'html5/audio/flac',
274
+ 'WAV': 'html5/audio/wav',
275
+
276
+ // Subtitles
277
+ 'VTT': 'html5/text/vtt',
278
+ 'SRT': 'html5/text/plain',
279
+
280
+ // CMAF variants
281
+ 'CMAF': 'html5/application/vnd.apple.mpegurl;version=7',
282
+ 'HLS_CMAF': 'html5/application/vnd.apple.mpegurl;version=7',
283
+
284
+ // Images
285
+ 'JPEG': 'html5/image/jpeg',
286
+ 'JPG': 'html5/image/jpeg',
287
+
288
+ // MistServer specific
289
+ 'HTTP': 'html5/video/mp4', // Default HTTP is MP4
290
+ 'MIST_HTML': 'mist/html',
291
+ 'PLAYER_JS': 'mist/html',
292
+ };
293
+
294
+ /**
295
+ * Get the MIME type for a Gateway protocol name
296
+ */
297
+ export function getMimeTypeForProtocol(protocol: string): string {
298
+ return PROTOCOL_TO_MIME[protocol] || PROTOCOL_TO_MIME[protocol.toUpperCase()] || protocol;
299
+ }
300
+
301
+ /**
302
+ * Get source type info for a MIME type
303
+ */
304
+ export function getSourceTypeInfo(mimeType: string): { hrn: string; player: string; supported: boolean } | undefined {
305
+ return MIST_SOURCE_TYPES[mimeType];
306
+ }
307
+
308
+ // ============================================================================
309
+ // Helper Functions
310
+ // ============================================================================
311
+
312
+ function mapCodecLabel(codecstr: string): string {
313
+ const c = codecstr.toLowerCase();
314
+ if (c.startsWith('avc1')) return 'H264';
315
+ if (c.startsWith('hev1') || c.startsWith('hvc1')) return 'HEVC';
316
+ if (c.startsWith('av01')) return 'AV1';
317
+ if (c.startsWith('vp09')) return 'VP9';
318
+ if (c.startsWith('vp8')) return 'VP8';
319
+ if (c.startsWith('mp4a')) return 'AAC';
320
+ if (c.includes('opus')) return 'Opus';
321
+ if (c.includes('ec-3') || c.includes('ac3')) return 'AC3';
322
+ return codecstr;
323
+ }
324
+
325
+ // ============================================================================
326
+ // Standalone Stream Info Builder
327
+ // ============================================================================
328
+
329
+ /**
330
+ * Build StreamInfo from Gateway ContentEndpoints.
331
+ *
332
+ * This function extracts playback sources and track information from
333
+ * the Gateway's resolved endpoint data. It handles:
334
+ * - Parsing `outputs` JSON string (GraphQL returns JSON scalar as string)
335
+ * - Converting output protocols to StreamSource format
336
+ * - Deriving track info from capabilities
337
+ *
338
+ * Use this for VOD/clip content where Gateway data is sufficient,
339
+ * without waiting for MistServer to load the stream.
340
+ *
341
+ * @param endpoints - ContentEndpoints from Gateway resolution
342
+ * @param contentId - Stream/content identifier
343
+ * @returns StreamInfo with sources and tracks, or null if no valid data
344
+ */
345
+ export function buildStreamInfoFromEndpoints(
346
+ endpoints: ContentEndpoints,
347
+ contentId: string
348
+ ): StreamInfo | null {
349
+ const primary = endpoints.primary as EndpointInfo | undefined;
350
+ if (!primary) return null;
351
+
352
+ // Parse outputs if it's a JSON string (GraphQL returns JSON scalar as string)
353
+ let outputs: Record<string, OutputEndpoint> = {};
354
+ if (primary.outputs) {
355
+ if (typeof primary.outputs === 'string') {
356
+ try {
357
+ outputs = JSON.parse(primary.outputs);
358
+ } catch (e) {
359
+ console.warn('[buildStreamInfoFromEndpoints] Failed to parse outputs JSON');
360
+ outputs = {};
361
+ }
362
+ } else {
363
+ outputs = primary.outputs as Record<string, OutputEndpoint>;
364
+ }
365
+ }
366
+
367
+ const sources: StreamSource[] = [];
368
+ const oKeys = Object.keys(outputs);
369
+
370
+ // Helper to attach MistServer sources
371
+ const attachMistSource = (html?: string, playerJs?: string) => {
372
+ if (!html && !playerJs) return;
373
+ const src: StreamSource = {
374
+ url: html || playerJs || '',
375
+ type: 'mist/html',
376
+ streamName: contentId,
377
+ } as StreamSource;
378
+ if (playerJs) {
379
+ (src as any).mistPlayerUrl = playerJs;
380
+ }
381
+ sources.push(src);
382
+ };
383
+
384
+ if (oKeys.length) {
385
+ const html = outputs['MIST_HTML']?.url;
386
+ const pjs = outputs['PLAYER_JS']?.url;
387
+ attachMistSource(html, pjs);
388
+
389
+ // Process all outputs using PROTOCOL_TO_MIME mapping
390
+ // Skip MIST_HTML and PLAYER_JS (already handled above)
391
+ const skipProtocols = new Set(['MIST_HTML', 'PLAYER_JS']);
392
+
393
+ for (const protocol of oKeys) {
394
+ if (skipProtocols.has(protocol)) continue;
395
+
396
+ const output = outputs[protocol];
397
+ if (!output?.url) continue;
398
+
399
+ // Convert Gateway protocol name to MistServer MIME type
400
+ const mimeType = getMimeTypeForProtocol(protocol);
401
+
402
+ // Check if this source type is supported
403
+ const sourceInfo = getSourceTypeInfo(mimeType);
404
+ if (sourceInfo && !sourceInfo.supported) {
405
+ // Skip unsupported source types
406
+ continue;
407
+ }
408
+
409
+ sources.push({ url: output.url, type: mimeType });
410
+ }
411
+ } else if (primary) {
412
+ // Fallback: single primary URL
413
+ sources.push({
414
+ url: primary.url,
415
+ type: primary.protocol || 'mist/html',
416
+ streamName: contentId,
417
+ } as StreamSource);
418
+ }
419
+
420
+ // Derive tracks from capabilities
421
+ const tracks: StreamTrack[] = [];
422
+ const pushCodecTracks = (cap?: OutputCapabilities) => {
423
+ if (!cap) return;
424
+ const codecs = cap.codecs || [];
425
+ const addTrack = (type: 'video' | 'audio', codecstr: string) => {
426
+ tracks.push({ type, codec: mapCodecLabel(codecstr), codecstring: codecstr });
427
+ };
428
+ codecs.forEach((c) => {
429
+ const lc = c.toLowerCase();
430
+ if (
431
+ lc.startsWith('avc1') ||
432
+ lc.startsWith('hev1') ||
433
+ lc.startsWith('hvc1') ||
434
+ lc.startsWith('vp') ||
435
+ lc.startsWith('av01')
436
+ ) {
437
+ addTrack('video', c);
438
+ } else if (
439
+ lc.startsWith('mp4a') ||
440
+ lc.includes('opus') ||
441
+ lc.includes('vorbis') ||
442
+ lc.includes('ac3') ||
443
+ lc.includes('ec-3')
444
+ ) {
445
+ addTrack('audio', c);
446
+ }
447
+ });
448
+ if (!codecs.length) {
449
+ // Fallback codecs with valid codecstrings for cold-start playback
450
+ if (cap.hasVideo) tracks.push({ type: 'video', codec: 'H264', codecstring: 'avc1.42E01E' });
451
+ if (cap.hasAudio) tracks.push({ type: 'audio', codec: 'AAC', codecstring: 'mp4a.40.2' });
452
+ }
453
+ };
454
+ Object.values(outputs).forEach((out) => pushCodecTracks(out.capabilities));
455
+ if (!tracks.length) {
456
+ // Fallback with valid codecstring for cold-start playback
457
+ tracks.push({ type: 'video', codec: 'H264', codecstring: 'avc1.42E01E' });
458
+ }
459
+
460
+ // Determine content type from metadata
461
+ const contentType: 'live' | 'vod' = endpoints.metadata?.isLive === false ? 'vod' : 'live';
462
+
463
+ return sources.length ? { source: sources, meta: { tracks }, type: contentType } : null;
464
+ }
465
+
466
+ // ============================================================================
467
+ // PlayerController Class
468
+ // ============================================================================
469
+
470
+ /**
471
+ * Headless player controller that manages the entire player lifecycle.
472
+ *
473
+ * @example
474
+ * ```typescript
475
+ * const controller = new PlayerController({
476
+ * contentId: 'my-stream',
477
+ * contentType: 'live',
478
+ * gatewayUrl: 'https://gateway.example.com/graphql',
479
+ * });
480
+ *
481
+ * controller.on('stateChange', ({ state }) => console.log('State:', state));
482
+ * controller.on('ready', ({ videoElement }) => console.log('Ready!'));
483
+ *
484
+ * const container = document.getElementById('player');
485
+ * await controller.attach(container);
486
+ *
487
+ * // Later...
488
+ * controller.destroy();
489
+ * ```
490
+ */
491
+ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents> {
492
+ private config: PlayerControllerConfig;
493
+ private state: PlayerState = 'booting';
494
+ private lastEmittedState: PlayerState | null = null;
495
+ private suppressPlayPauseEventsUntil = 0;
496
+ private suppressPlayPauseEventsUntil = 0;
497
+
498
+ private gatewayClient: GatewayClient | null = null;
499
+ private streamStateClient: StreamStateClient | null = null;
500
+ private playerManager: PlayerManager;
501
+
502
+ private currentPlayer: IPlayer | null = null;
503
+ private videoElement: HTMLVideoElement | null = null;
504
+ private container: HTMLElement | null = null;
505
+
506
+ private endpoints: ContentEndpoints | null = null;
507
+ private streamInfo: StreamInfo | null = null;
508
+ private streamState: StreamState | null = null;
509
+ /** Tracks parsed from MistServer JSON response (used for direct MistServer mode) */
510
+ private mistTracks: StreamTrack[] | null = null;
511
+
512
+ private cleanupFns: Array<() => void> = [];
513
+ private isDestroyed: boolean = false;
514
+ private isAttached: boolean = false;
515
+
516
+ // ============================================================================
517
+ // Internal State Tracking (Phase A1)
518
+ // ============================================================================
519
+ private _isBuffering: boolean = false;
520
+ private _hasPlaybackStarted: boolean = false;
521
+ private _errorText: string | null = null;
522
+ private _isPassiveError: boolean = false;
523
+ private _isHoldingSpeed: boolean = false;
524
+ private _holdSpeed: number = 2;
525
+ private _isLoopEnabled: boolean = false;
526
+ private _currentPlayerInfo: { name: string; shortname: string } | null = null;
527
+ private _currentSourceInfo: { url: string; type: string } | null = null;
528
+
529
+ // One-shot force options (used once by selectCombo, then cleared)
530
+ private _pendingForceOptions: {
531
+ forcePlayer?: string;
532
+ forceType?: string;
533
+ forceSource?: number;
534
+ } | null = null;
535
+
536
+ // ============================================================================
537
+ // Error Handling State (Phase A3)
538
+ // ============================================================================
539
+ private _errorShownAt: number = 0;
540
+ private _errorCleared: boolean = false;
541
+ private _isTransitioning: boolean = false;
542
+ private _errorCount: number = 0;
543
+ private _lastErrorTime: number = 0;
544
+
545
+ // ============================================================================
546
+ // Stream State Tracking (Phase A4)
547
+ // ============================================================================
548
+ private _prevStreamIsOnline: boolean | undefined = undefined;
549
+
550
+ // ============================================================================
551
+ // Hover/Controls Visibility (Phase A5b)
552
+ // ============================================================================
553
+ private _isHovering: boolean = false;
554
+ private _hoverTimeout: ReturnType<typeof setTimeout> | null = null;
555
+ private static readonly HOVER_HIDE_DELAY_MS = 3000;
556
+ private static readonly HOVER_LEAVE_DELAY_MS = 200;
557
+
558
+ // ============================================================================
559
+ // Subtitles/Captions (Phase A5b audit)
560
+ // ============================================================================
561
+ private _subtitlesEnabled: boolean = false;
562
+
563
+ // ============================================================================
564
+ // Stall Detection (Phase A5b audit)
565
+ // ============================================================================
566
+ private _stallStartTime: number = 0;
567
+ private static readonly HARD_FAILURE_STALL_THRESHOLD_MS = 30000; // 30 seconds sustained stall
568
+
569
+ // ============================================================================
570
+ // Seeking & Live Detection State (Centralized from wrappers)
571
+ // ============================================================================
572
+ private _seekableStart: number = 0;
573
+ private _liveEdge: number = 0;
574
+ private _canSeek: boolean = false;
575
+ private _isNearLive: boolean = true;
576
+ private _latencyTier: LatencyTier = 'medium';
577
+ private _liveThresholds: LiveThresholds = { exitLive: 15, enterLive: 5 };
578
+ private _buffered: TimeRanges | null = null;
579
+ private _hasAudio: boolean = true;
580
+ private _supportsPlaybackRate: boolean = true;
581
+ private _isWebRTC: boolean = false;
582
+
583
+ // Error handling constants
584
+ private static readonly AUTO_CLEAR_ERROR_DELAY_MS = 2000;
585
+ private static readonly HARD_FAILURE_ERROR_THRESHOLD = 5;
586
+ private static readonly HARD_FAILURE_ERROR_WINDOW_MS = 60000;
587
+ private static readonly FATAL_ERROR_KEYWORDS = [
588
+ 'fatal', 'network error', 'media error', 'decode error', 'source not supported'
589
+ ];
590
+
591
+ // ============================================================================
592
+ // Sub-Controllers (Phase A2)
593
+ // ============================================================================
594
+ private abrController: ABRController | null = null;
595
+ private interactionController: InteractionController | null = null;
596
+ private mistReporter: MistReporter | null = null;
597
+ private qualityMonitor: QualityMonitor | null = null;
598
+ private metaTrackManager: MetaTrackManager | null = null;
599
+ private _playbackQuality: PlaybackQuality | null = null;
600
+ private bootMs: number = Date.now();
601
+
602
+ constructor(config: PlayerControllerConfig) {
603
+ super();
604
+ this.config = config;
605
+ this.playerManager = config.playerManager || globalPlayerManager;
606
+
607
+ // Load loop state from localStorage
608
+ try {
609
+ if (typeof localStorage !== 'undefined') {
610
+ this._isLoopEnabled = localStorage.getItem('frameworks-player-loop') === 'true';
611
+ }
612
+ } catch {
613
+ // localStorage not available
614
+ }
615
+ }
616
+
617
+ // ============================================================================
618
+ // Lifecycle Methods
619
+ // ============================================================================
620
+
621
+ /**
622
+ * Attach to a container element and start the player lifecycle.
623
+ * This is the main entry point after construction.
624
+ */
625
+ async attach(container: HTMLElement): Promise<void> {
626
+ if (this.isDestroyed) {
627
+ throw new Error('PlayerController is destroyed and cannot be reused');
628
+ }
629
+ if (this.isAttached) {
630
+ this.log('Already attached, detaching first');
631
+ this.detach();
632
+ }
633
+
634
+ this.container = container;
635
+ this.isAttached = true;
636
+ this.setState('booting');
637
+
638
+ try {
639
+ // Ensure players are registered
640
+ ensurePlayersRegistered();
641
+
642
+ // Step 1: Resolve endpoints
643
+ await this.resolveEndpoints();
644
+
645
+ // Guard against zombie operations (React Strict Mode cleanup)
646
+ if (this.isDestroyed || !this.container) {
647
+ this.log('[attach] Aborted - controller destroyed during endpoint resolution');
648
+ return;
649
+ }
650
+
651
+ if (!this.endpoints?.primary) {
652
+ this.setState('no_endpoint', { gatewayStatus: 'error' });
653
+ return;
654
+ }
655
+
656
+ // Step 2: Start stream state polling (for live content)
657
+ this.startStreamStatePolling();
658
+
659
+ // Step 3: Build StreamInfo and initialize player
660
+ this.streamInfo = this.buildStreamInfo(this.endpoints);
661
+
662
+ if (!this.streamInfo || this.streamInfo.source.length === 0) {
663
+ this.setState('error', { error: 'No playable sources found' });
664
+ this.emit('error', { error: 'No playable sources found' });
665
+ return;
666
+ }
667
+
668
+ // Guard again before player init (async boundary)
669
+ if (this.isDestroyed || !this.container) {
670
+ this.log('[attach] Aborted - controller destroyed before player init');
671
+ return;
672
+ }
673
+
674
+ await this.initializePlayer();
675
+ } catch (error) {
676
+ const message = error instanceof Error ? error.message : 'Unknown error';
677
+ this.setState('error', { error: message });
678
+ this.emit('error', { error: message });
679
+
680
+ // Even if initial resolution failed (e.g., stream offline), start polling
681
+ // so we can detect when the stream comes online and re-initialize
682
+ if (this.config.mistUrl && !this.streamStateClient) {
683
+ this.log('[attach] Starting stream polling despite resolution failure');
684
+ this.startStreamStatePolling();
685
+ }
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Detach from the current container and clean up resources.
691
+ * The controller can be re-attached to a new container.
692
+ */
693
+ detach(): void {
694
+ this.cleanup();
695
+ this.clearHoverTimeout();
696
+ this.isAttached = false;
697
+ this.container = null;
698
+ this.endpoints = null;
699
+ this.streamInfo = null;
700
+ this.streamState = null;
701
+ this.videoElement = null;
702
+ this.currentPlayer = null;
703
+ this.lastEmittedState = null;
704
+ this._isHovering = false;
705
+ }
706
+
707
+ /**
708
+ * Fully destroy the controller. Cannot be reused after this.
709
+ */
710
+ destroy(): void {
711
+ if (this.isDestroyed) return;
712
+
713
+ this.detach();
714
+ this.setState('destroyed');
715
+ this.emit('destroyed', undefined as never);
716
+ this.removeAllListeners();
717
+ this.isDestroyed = true;
718
+ }
719
+
720
+ // ============================================================================
721
+ // State Getters
722
+ // ============================================================================
723
+
724
+ /** Get current player state */
725
+ getState(): PlayerState {
726
+ return this.state;
727
+ }
728
+
729
+ /** Get current stream state (for live streams) */
730
+ getStreamState(): StreamState | null {
731
+ return this.streamState;
732
+ }
733
+
734
+ /** Get resolved endpoints */
735
+ getEndpoints(): ContentEndpoints | null {
736
+ return this.endpoints;
737
+ }
738
+
739
+ /** Get content metadata (title, description, duration, etc.) */
740
+ getMetadata(): ContentMetadata | null {
741
+ return this.endpoints?.metadata ?? null;
742
+ }
743
+
744
+ /** Get stream info (sources + tracks for player selection) */
745
+ getStreamInfo(): StreamInfo | null {
746
+ return this.streamInfo;
747
+ }
748
+
749
+ /** Get video element (null if not ready) */
750
+ getVideoElement(): HTMLVideoElement | null {
751
+ return this.videoElement;
752
+ }
753
+
754
+ /** Get current player instance */
755
+ getPlayer(): IPlayer | null {
756
+ return this.currentPlayer;
757
+ }
758
+
759
+ /** Check if player is ready */
760
+ isReady(): boolean {
761
+ return this.videoElement !== null;
762
+ }
763
+
764
+ // ============================================================================
765
+ // Extended State Getters (Phase A1)
766
+ // ============================================================================
767
+
768
+ /** Check if video is currently playing (not paused) */
769
+ isPlaying(): boolean {
770
+ const paused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
771
+ return !paused;
772
+ }
773
+
774
+ /** Check if currently buffering */
775
+ isBuffering(): boolean {
776
+ return this._isBuffering;
777
+ }
778
+
779
+ /** Get current error message (null if no error) */
780
+ getError(): string | null {
781
+ return this._errorText;
782
+ }
783
+
784
+ /** Check if error is passive (video still playing despite error) */
785
+ isPassiveError(): boolean {
786
+ return this._isPassiveError;
787
+ }
788
+
789
+ /** Check if playback has ever started (for idle screen logic) */
790
+ hasPlaybackStarted(): boolean {
791
+ return this._hasPlaybackStarted;
792
+ }
793
+
794
+ /** Check if currently holding for speed boost */
795
+ isHoldingSpeed(): boolean {
796
+ return this._isHoldingSpeed;
797
+ }
798
+
799
+ /** Get current hold speed value */
800
+ getHoldSpeed(): number {
801
+ return this._holdSpeed;
802
+ }
803
+
804
+ /** Get current player implementation info */
805
+ getCurrentPlayerInfo(): { name: string; shortname: string } | null {
806
+ return this._currentPlayerInfo;
807
+ }
808
+
809
+ /** Get current source info (URL and type) */
810
+ getCurrentSourceInfo(): { url: string; type: string } | null {
811
+ return this._currentSourceInfo;
812
+ }
813
+
814
+ /** Get current volume (0-1) */
815
+ getVolume(): number {
816
+ return this.videoElement?.volume ?? 1;
817
+ }
818
+
819
+ /** Check if loop mode is enabled */
820
+ isLoopEnabled(): boolean {
821
+ return this._isLoopEnabled;
822
+ }
823
+
824
+ /** Check if subtitles/captions are enabled */
825
+ isSubtitlesEnabled(): boolean {
826
+ return this._subtitlesEnabled;
827
+ }
828
+
829
+ /** Set subtitles/captions enabled state */
830
+ setSubtitlesEnabled(enabled: boolean): void {
831
+ if (this._subtitlesEnabled === enabled) return;
832
+ this._subtitlesEnabled = enabled;
833
+ // Apply to video text tracks if available
834
+ if (this.videoElement) {
835
+ const tracks = this.videoElement.textTracks;
836
+ for (let i = 0; i < tracks.length; i++) {
837
+ const track = tracks[i];
838
+ if (track.kind === 'subtitles' || track.kind === 'captions') {
839
+ track.mode = enabled ? 'showing' : 'hidden';
840
+ }
841
+ }
842
+ }
843
+ this.emit('captionsChange', { enabled });
844
+ }
845
+
846
+ /** Toggle subtitles/captions */
847
+ toggleSubtitles(): void {
848
+ this.setSubtitlesEnabled(!this._subtitlesEnabled);
849
+ }
850
+
851
+ // ============================================================================
852
+ // Seeking & Live State Getters (Centralized from wrappers)
853
+ // ============================================================================
854
+
855
+ /** Get start of seekable range (seconds) */
856
+ getSeekableStart(): number {
857
+ return this._seekableStart;
858
+ }
859
+
860
+ /** Get live edge / end of seekable range (seconds) */
861
+ getLiveEdge(): number {
862
+ return this._liveEdge;
863
+ }
864
+
865
+ /** Check if seeking is currently available */
866
+ canSeekStream(): boolean {
867
+ return this._canSeek;
868
+ }
869
+
870
+ /** Check if playback is near the live edge (for live badge display) */
871
+ isNearLive(): boolean {
872
+ return this._isNearLive;
873
+ }
874
+
875
+ /** Get buffered ranges, preferring player override when available */
876
+ getBufferedRanges(): TimeRanges | null {
877
+ if (this.currentPlayer && typeof this.currentPlayer.getBufferedRanges === 'function') {
878
+ return this.currentPlayer.getBufferedRanges();
879
+ }
880
+ return this.videoElement?.buffered ?? null;
881
+ }
882
+
883
+ /** Get current latency tier based on protocol */
884
+ getLatencyTier(): LatencyTier {
885
+ return this._latencyTier;
886
+ }
887
+
888
+ /** Get live thresholds for entering/exiting "LIVE" state */
889
+ getLiveThresholds(): LiveThresholds {
890
+ return this._liveThresholds;
891
+ }
892
+
893
+ /** Get buffered time ranges */
894
+ getBuffered(): TimeRanges | null {
895
+ return this._buffered;
896
+ }
897
+
898
+ /** Check if stream has audio track */
899
+ hasAudioTrack(): boolean {
900
+ return this._hasAudio;
901
+ }
902
+
903
+ /** Check if playback rate adjustment is supported */
904
+ canAdjustPlaybackRate(): boolean {
905
+ return this._supportsPlaybackRate;
906
+ }
907
+
908
+ /** Check if source is WebRTC/MediaStream */
909
+ isWebRTCSource(): boolean {
910
+ return this._isWebRTC;
911
+ }
912
+
913
+ /** Check if currently in fullscreen mode */
914
+ isFullscreen(): boolean {
915
+ if (typeof document === 'undefined') return false;
916
+ return document.fullscreenElement === this.container;
917
+ }
918
+
919
+ /** Check if content is effectively live (live or DVR still recording) */
920
+ isEffectivelyLive(): boolean {
921
+ const { contentType } = this.config;
922
+ const metadata = this.getMetadata();
923
+ return contentType === 'live' || (contentType === 'dvr' && metadata?.dvrStatus === 'recording');
924
+ }
925
+
926
+ /** Check if content is strictly live (not DVR/clip/vod) */
927
+ isLive(): boolean {
928
+ return this.config.contentType === 'live';
929
+ }
930
+
931
+ /**
932
+ * Check if content needs cold start (VOD-like loading).
933
+ * True for: clips, DVR (recording OR completed) - any stored/VOD content
934
+ * False for: live streams only (real-time MistServer stream)
935
+ * DVR-while-recording needs cold start because MistServer may not be serving the VOD yet
936
+ */
937
+ needsColdStart(): boolean {
938
+ return this.config.contentType !== 'live';
939
+ }
940
+
941
+ /**
942
+ * Check if we should show idle/loading screen.
943
+ * Logic:
944
+ * - For cold start content (VOD/DVR): Show loading only while waiting for Gateway sources
945
+ * - For live streams: Show loading while waiting for MistServer to come online
946
+ * - Never show idle after playback has started (unless explicit error)
947
+ */
948
+ shouldShowIdleScreen(): boolean {
949
+ // Never show idle after playback has started
950
+ if (this._hasPlaybackStarted) return false;
951
+
952
+ if (this.needsColdStart()) {
953
+ // VOD content (clips, DVR recording or completed): DON'T wait for MistServer
954
+ // Use Gateway sources immediately - MistServer will cold start when player requests
955
+ // Show loading only while waiting for Gateway sources (not MistServer)
956
+ const sources = this.streamInfo?.source ?? [];
957
+ return sources.length === 0;
958
+ } else {
959
+ // Live streams: Wait for MistServer online status
960
+ if (!this.streamState?.isOnline || this.streamState?.status !== 'ONLINE') {
961
+ return true;
962
+ }
963
+ // Show loading if no stream info or sources
964
+ if (!this.streamInfo || (this.streamInfo.source?.length ?? 0) === 0) {
965
+ return true;
966
+ }
967
+ }
968
+
969
+ return false;
970
+ }
971
+
972
+ /**
973
+ * Get the effective content type for playback mode selection.
974
+ * This ensures WHEP/WebRTC gets penalized for VOD content (no seek support)
975
+ * while HLS/MP4 are preferred for clips and completed DVR recordings.
976
+ */
977
+ getEffectiveContentType(): 'live' | 'vod' {
978
+ return this.isEffectivelyLive() ? 'live' : 'vod';
979
+ }
980
+
981
+ // ============================================================================
982
+ // Hover/Controls Visibility (Phase A5b)
983
+ // ============================================================================
984
+
985
+ /** Check if user is currently hovering over the player */
986
+ isHovering(): boolean {
987
+ return this._isHovering;
988
+ }
989
+
990
+ /**
991
+ * Check if controls should be visible.
992
+ * Controls are visible when:
993
+ * - User is hovering over the player
994
+ * - Video is paused
995
+ * - There's an error
996
+ */
997
+ shouldShowControls(): boolean {
998
+ return this._isHovering || this.isPaused() || this._errorText !== null;
999
+ }
1000
+
1001
+ /**
1002
+ * Handle mouse enter event - show controls immediately.
1003
+ * Call this from your UI wrapper's onMouseEnter handler.
1004
+ */
1005
+ handleMouseEnter(): void {
1006
+ this.clearHoverTimeout();
1007
+ if (!this._isHovering) {
1008
+ this._isHovering = true;
1009
+ this.emit('hoverStart', undefined as never);
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Handle mouse leave event - hide controls after delay.
1015
+ * Call this from your UI wrapper's onMouseLeave handler.
1016
+ */
1017
+ handleMouseLeave(): void {
1018
+ this.clearHoverTimeout();
1019
+ this._hoverTimeout = setTimeout(() => {
1020
+ if (this._isHovering) {
1021
+ this._isHovering = false;
1022
+ this.emit('hoverEnd', undefined as never);
1023
+ }
1024
+ }, PlayerController.HOVER_LEAVE_DELAY_MS);
1025
+ }
1026
+
1027
+ /**
1028
+ * Handle mouse move event - show controls and reset hide timer.
1029
+ * Call this from your UI wrapper's onMouseMove handler.
1030
+ */
1031
+ handleMouseMove(): void {
1032
+ if (!this._isHovering) {
1033
+ this._isHovering = true;
1034
+ this.emit('hoverStart', undefined as never);
1035
+ }
1036
+ // Reset hide timeout on any movement
1037
+ this.clearHoverTimeout();
1038
+ this._hoverTimeout = setTimeout(() => {
1039
+ if (this._isHovering) {
1040
+ this._isHovering = false;
1041
+ this.emit('hoverEnd', undefined as never);
1042
+ }
1043
+ }, PlayerController.HOVER_HIDE_DELAY_MS);
1044
+ }
1045
+
1046
+ /**
1047
+ * Handle touch start event - show controls.
1048
+ * Call this from your UI wrapper's onTouchStart handler.
1049
+ */
1050
+ handleTouchStart(): void {
1051
+ this.handleMouseEnter();
1052
+ // Reset hide timer for touch
1053
+ this.clearHoverTimeout();
1054
+ this._hoverTimeout = setTimeout(() => {
1055
+ if (this._isHovering) {
1056
+ this._isHovering = false;
1057
+ this.emit('hoverEnd', undefined as never);
1058
+ }
1059
+ }, PlayerController.HOVER_HIDE_DELAY_MS);
1060
+ }
1061
+
1062
+ /** Clear hover timeout */
1063
+ private clearHoverTimeout(): void {
1064
+ if (this._hoverTimeout) {
1065
+ clearTimeout(this._hoverTimeout);
1066
+ this._hoverTimeout = null;
1067
+ }
1068
+ }
1069
+
1070
+ /** Get current playback rate */
1071
+ getPlaybackRate(): number {
1072
+ return this.videoElement?.playbackRate ?? 1;
1073
+ }
1074
+
1075
+ /** Get playback quality metrics from QualityMonitor */
1076
+ getPlaybackQuality(): PlaybackQuality | null {
1077
+ return this._playbackQuality;
1078
+ }
1079
+
1080
+ /** Get current ABR mode */
1081
+ getABRMode(): ABRMode {
1082
+ return this.abrController?.getMode() ?? 'auto';
1083
+ }
1084
+
1085
+ /** Set ABR mode at runtime */
1086
+ setABRMode(mode: ABRMode): void {
1087
+ this.abrController?.setMode(mode);
1088
+ }
1089
+
1090
+ // ============================================================================
1091
+ // Playback Control
1092
+ // ============================================================================
1093
+
1094
+ /** Start playback */
1095
+ async play(): Promise<void> {
1096
+ if (this.currentPlayer?.play) {
1097
+ await this.currentPlayer.play();
1098
+ return;
1099
+ }
1100
+ if (this.videoElement) {
1101
+ await this.videoElement.play();
1102
+ }
1103
+ }
1104
+
1105
+ /** Pause playback */
1106
+ pause(): void {
1107
+ if (this.currentPlayer?.pause) {
1108
+ this.currentPlayer.pause();
1109
+ return;
1110
+ }
1111
+ this.videoElement?.pause();
1112
+ }
1113
+
1114
+ /** Seek to time */
1115
+ seek(time: number): void {
1116
+ // Use player-specific seek if available (for WebCodecs, MEWS, etc.)
1117
+ if (this.currentPlayer?.seek) {
1118
+ this.currentPlayer.seek(time);
1119
+ return;
1120
+ }
1121
+ // Fallback to direct video element seek
1122
+ if (this.videoElement) {
1123
+ this.videoElement.currentTime = time;
1124
+ }
1125
+ }
1126
+
1127
+ /** Set volume (0-1) */
1128
+ setVolume(volume: number): void {
1129
+ if (this.videoElement) {
1130
+ const newVolume = Math.max(0, Math.min(1, volume));
1131
+ this.videoElement.volume = newVolume;
1132
+ this.emit('volumeChange', { volume: newVolume, muted: this.videoElement.muted });
1133
+ }
1134
+ }
1135
+
1136
+ /** Set muted state */
1137
+ setMuted(muted: boolean): void {
1138
+ if (this.currentPlayer?.setMuted) {
1139
+ this.currentPlayer.setMuted(muted);
1140
+ } else if (this.videoElement) {
1141
+ this.videoElement.muted = muted;
1142
+ }
1143
+ if (this.videoElement) {
1144
+ this.emit('volumeChange', { volume: this.videoElement.volume, muted });
1145
+ }
1146
+ }
1147
+
1148
+ /** Set playback rate */
1149
+ setPlaybackRate(rate: number): void {
1150
+ if (this.currentPlayer?.setPlaybackRate) {
1151
+ this.currentPlayer.setPlaybackRate(rate);
1152
+ } else if (this.videoElement) {
1153
+ this.videoElement.playbackRate = rate;
1154
+ }
1155
+ this.emit('speedChange', { rate });
1156
+ }
1157
+
1158
+ /** Jump to live edge (for live streams) */
1159
+ jumpToLive(): void {
1160
+ // Try player-specific implementation first (WebCodecs uses server time)
1161
+ if (this.currentPlayer?.jumpToLive) {
1162
+ this.currentPlayer.jumpToLive();
1163
+ const el = this.videoElement;
1164
+ if (el && !isMediaStreamSource(el)) {
1165
+ const target = this._liveEdge;
1166
+ if (Number.isFinite(target) && target > 0) {
1167
+ // Fallback: if player-specific jump doesn't move, seek to computed live edge
1168
+ setTimeout(() => {
1169
+ if (!this.videoElement) return;
1170
+ const current = this.getEffectiveCurrentTime();
1171
+ if (target - current > 1) {
1172
+ try { this.videoElement.currentTime = target; } catch {}
1173
+ }
1174
+ }, 200);
1175
+ }
1176
+ }
1177
+ this._isNearLive = true;
1178
+ this.emitSeekingState();
1179
+ return;
1180
+ }
1181
+
1182
+ const el = this.videoElement;
1183
+ if (!el) return;
1184
+
1185
+ // For WebRTC/MediaStream: we're always at live, nothing to do
1186
+ if (isMediaStreamSource(el)) {
1187
+ this._isNearLive = true;
1188
+ this.emitSeekingState();
1189
+ return;
1190
+ }
1191
+
1192
+ // Try browser's seekable range first (most reliable for HLS/DASH/MEWS)
1193
+ if (el.seekable && el.seekable.length > 0) {
1194
+ const liveEdge = el.seekable.end(el.seekable.length - 1);
1195
+ if (Number.isFinite(liveEdge) && liveEdge > 0) {
1196
+ el.currentTime = liveEdge;
1197
+ this._isNearLive = true;
1198
+ this.emitSeekingState();
1199
+ return;
1200
+ }
1201
+ }
1202
+
1203
+ // Try our computed live edge (from MistServer metadata)
1204
+ if (this._liveEdge > 0 && Number.isFinite(this._liveEdge)) {
1205
+ el.currentTime = this._liveEdge;
1206
+ this._isNearLive = true;
1207
+ this.emitSeekingState();
1208
+ return;
1209
+ }
1210
+
1211
+ // Fallback: seek to duration (for VOD or finite-duration live)
1212
+ if (Number.isFinite(el.duration) && el.duration > 0) {
1213
+ el.currentTime = el.duration;
1214
+ this._isNearLive = true;
1215
+ this.emitSeekingState();
1216
+ }
1217
+ }
1218
+
1219
+ /** Emit current seeking state */
1220
+ private emitSeekingState(): void {
1221
+ this.emit('seekingStateChange', {
1222
+ seekableStart: this._seekableStart,
1223
+ liveEdge: this._liveEdge,
1224
+ canSeek: this._canSeek,
1225
+ isNearLive: this._isNearLive,
1226
+ isLive: this.isEffectivelyLive(),
1227
+ isWebRTC: this._isWebRTC,
1228
+ latencyTier: this._latencyTier,
1229
+ buffered: this._buffered,
1230
+ hasAudio: this._hasAudio,
1231
+ supportsPlaybackRate: this._supportsPlaybackRate,
1232
+ });
1233
+ }
1234
+
1235
+ /** Request fullscreen */
1236
+ async requestFullscreen(): Promise<void> {
1237
+ if (this.container) {
1238
+ await this.container.requestFullscreen();
1239
+ }
1240
+ }
1241
+
1242
+ /** Request Picture-in-Picture */
1243
+ async requestPiP(): Promise<void> {
1244
+ if (this.currentPlayer?.requestPiP) {
1245
+ await this.currentPlayer.requestPiP();
1246
+ } else if (this.videoElement && 'requestPictureInPicture' in this.videoElement) {
1247
+ await (this.videoElement as any).requestPictureInPicture();
1248
+ }
1249
+ }
1250
+
1251
+ /** Get available quality levels */
1252
+ getQualities(): Array<{ id: string; label: string; bitrate?: number; width?: number; height?: number; isAuto?: boolean; active?: boolean }> {
1253
+ return this.currentPlayer?.getQualities?.() ?? [];
1254
+ }
1255
+
1256
+ /** Select a quality level */
1257
+ selectQuality(id: string): void {
1258
+ this.currentPlayer?.selectQuality?.(id);
1259
+ }
1260
+
1261
+ /** Get available text tracks */
1262
+ getTextTracks(): Array<{ id: string; label: string; lang?: string; active: boolean }> {
1263
+ return this.currentPlayer?.getTextTracks?.() ?? [];
1264
+ }
1265
+
1266
+ /** Select a text track */
1267
+ selectTextTrack(id: string | null): void {
1268
+ this.currentPlayer?.selectTextTrack?.(id);
1269
+ }
1270
+
1271
+ private getEffectiveCurrentTime(): number {
1272
+ if (this.currentPlayer && typeof this.currentPlayer.getCurrentTime === 'function') {
1273
+ const t = this.currentPlayer.getCurrentTime();
1274
+ if (Number.isFinite(t)) return t;
1275
+ }
1276
+ return this.videoElement?.currentTime ?? 0;
1277
+ }
1278
+
1279
+ private getEffectiveDuration(): number {
1280
+ if (this.currentPlayer && typeof this.currentPlayer.getDuration === 'function') {
1281
+ const d = this.currentPlayer.getDuration();
1282
+ if (Number.isFinite(d) || d === Infinity) return d;
1283
+ }
1284
+ return this.videoElement?.duration ?? NaN;
1285
+ }
1286
+
1287
+ private getPlayerSeekableRange(): { start: number; end: number } | null {
1288
+ if (this.currentPlayer && typeof this.currentPlayer.getSeekableRange === 'function') {
1289
+ const range = this.currentPlayer.getSeekableRange();
1290
+ if (range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start) {
1291
+ return range;
1292
+ }
1293
+ }
1294
+ return null;
1295
+ }
1296
+
1297
+ private getFrameStepSecondsFromTracks(): number | undefined {
1298
+ const tracks = this.streamInfo?.meta?.tracks;
1299
+ if (!tracks || tracks.length === 0) return undefined;
1300
+ const videoTracks = tracks.filter(t => t.type === 'video' && typeof t.fpks === 'number' && t.fpks > 0);
1301
+ if (videoTracks.length === 0) return undefined;
1302
+ const fpks = Math.max(...videoTracks.map(t => t.fpks as number));
1303
+ if (!Number.isFinite(fpks) || fpks <= 0) return undefined;
1304
+ // fpks = frames per kilosecond => frame duration in seconds = 1000 / fpks
1305
+ return 1000 / fpks;
1306
+ }
1307
+
1308
+ private deriveBufferWindowMsFromTracks(tracks?: Record<string, { firstms?: number; lastms?: number }>): number | undefined {
1309
+ if (!tracks) return undefined;
1310
+ const trackList = Object.values(tracks);
1311
+ if (trackList.length === 0) return undefined;
1312
+ const firstmsValues = trackList.map(t => t.firstms).filter((v): v is number => v !== undefined);
1313
+ const lastmsValues = trackList.map(t => t.lastms).filter((v): v is number => v !== undefined);
1314
+ if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
1315
+ const firstms = Math.max(...firstmsValues);
1316
+ const lastms = Math.min(...lastmsValues);
1317
+ const window = lastms - firstms;
1318
+ if (!Number.isFinite(window) || window <= 0) return undefined;
1319
+ return window;
1320
+ }
1321
+
1322
+ /** Get current time */
1323
+ getCurrentTime(): number {
1324
+ return this.getEffectiveCurrentTime();
1325
+ }
1326
+
1327
+ /** Get duration */
1328
+ getDuration(): number {
1329
+ return this.getEffectiveDuration();
1330
+ }
1331
+
1332
+ /** Check if paused */
1333
+ isPaused(): boolean {
1334
+ return this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
1335
+ }
1336
+
1337
+ /** Suppress play/pause-driven UI updates for a short window */
1338
+ suppressPlayPauseEvents(ms: number = 200): void {
1339
+ this.suppressPlayPauseEventsUntil = Date.now() + ms;
1340
+ }
1341
+
1342
+ /** Check if play/pause UI updates should be suppressed */
1343
+ shouldSuppressVideoEvents(): boolean {
1344
+ return Date.now() < this.suppressPlayPauseEventsUntil;
1345
+ }
1346
+
1347
+ /** Check if muted */
1348
+ isMuted(): boolean {
1349
+ return this.videoElement?.muted ?? true;
1350
+ }
1351
+
1352
+ /** Skip backward by specified seconds (default 10) */
1353
+ skipBack(seconds: number = 10): void {
1354
+ this.seekBy(-seconds);
1355
+ this.emit('skipBackward', { seconds });
1356
+ }
1357
+
1358
+ /** Skip forward by specified seconds (default 10) */
1359
+ skipForward(seconds: number = 10): void {
1360
+ this.seekBy(seconds);
1361
+ this.emit('skipForward', { seconds });
1362
+ }
1363
+
1364
+ /** Toggle play/pause */
1365
+ togglePlay(): void {
1366
+ const isPaused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
1367
+ if (isPaused) {
1368
+ if (this.currentPlayer?.play) {
1369
+ this.currentPlayer.play().catch(() => {});
1370
+ } else {
1371
+ this.videoElement?.play().catch(() => {});
1372
+ }
1373
+ return;
1374
+ }
1375
+ if (this.currentPlayer?.pause) {
1376
+ this.currentPlayer.pause();
1377
+ } else {
1378
+ this.videoElement?.pause();
1379
+ }
1380
+ }
1381
+
1382
+ /** Toggle mute */
1383
+ toggleMute(): void {
1384
+ if (this.videoElement) {
1385
+ this.videoElement.muted = !this.videoElement.muted;
1386
+ }
1387
+ }
1388
+
1389
+ /** Seek relative to current position */
1390
+ seekBy(delta: number): void {
1391
+ const currentTime = this.getEffectiveCurrentTime();
1392
+ const duration = this.getEffectiveDuration();
1393
+ const newTime = currentTime + delta;
1394
+ const maxTime = isFinite(duration) ? duration : currentTime + Math.abs(delta);
1395
+ this.seek(Math.max(0, Math.min(maxTime, newTime)));
1396
+ }
1397
+
1398
+ /** Seek to percentage (0-1) of duration */
1399
+ seekPercent(percent: number): void {
1400
+ const duration = this.getEffectiveDuration();
1401
+ if (isFinite(duration)) {
1402
+ this.seek(duration * Math.max(0, Math.min(1, percent)));
1403
+ }
1404
+ }
1405
+
1406
+ /** Toggle loop mode */
1407
+ toggleLoop(): void {
1408
+ this._isLoopEnabled = !this._isLoopEnabled;
1409
+ if (this.videoElement) {
1410
+ this.videoElement.loop = this._isLoopEnabled;
1411
+ }
1412
+ // Persist to localStorage
1413
+ try {
1414
+ if (typeof localStorage !== 'undefined') {
1415
+ localStorage.setItem('frameworks-player-loop', String(this._isLoopEnabled));
1416
+ }
1417
+ } catch {
1418
+ // localStorage not available
1419
+ }
1420
+ this.emit('loopChange', { isLoopEnabled: this._isLoopEnabled });
1421
+ }
1422
+
1423
+ /** Set loop mode */
1424
+ setLoopEnabled(enabled: boolean): void {
1425
+ if (this._isLoopEnabled === enabled) return;
1426
+ this._isLoopEnabled = enabled;
1427
+ if (this.videoElement) {
1428
+ this.videoElement.loop = enabled;
1429
+ }
1430
+ try {
1431
+ if (typeof localStorage !== 'undefined') {
1432
+ localStorage.setItem('frameworks-player-loop', String(enabled));
1433
+ }
1434
+ } catch {}
1435
+ this.emit('loopChange', { isLoopEnabled: enabled });
1436
+ }
1437
+
1438
+ /** Clear current error */
1439
+ clearError(): void {
1440
+ this._errorText = null;
1441
+ this._isPassiveError = false;
1442
+ this._errorCleared = true;
1443
+ }
1444
+
1445
+ // ============================================================================
1446
+ // Seeking & Live State Update (Centralized from wrappers)
1447
+ // ============================================================================
1448
+
1449
+ /**
1450
+ * Update seeking and live detection state.
1451
+ * Called on timeupdate and progress events.
1452
+ * Emits seekingStateChange event when values change.
1453
+ */
1454
+ private updateSeekingState(): void {
1455
+ const el = this.videoElement;
1456
+ if (!el) return;
1457
+
1458
+ const currentTime = this.getEffectiveCurrentTime();
1459
+ const duration = this.getEffectiveDuration();
1460
+ const isLive = this.isEffectivelyLive();
1461
+ const sourceType = this._currentSourceInfo?.type;
1462
+ const mistStreamInfo = this.streamState?.streamInfo;
1463
+
1464
+ // Update WebRTC detection
1465
+ const wasWebRTC = this._isWebRTC;
1466
+ this._isWebRTC = isMediaStreamSource(el);
1467
+
1468
+ // Update playback rate support
1469
+ this._supportsPlaybackRate = supportsPlaybackRate(el);
1470
+
1471
+ // Update latency tier based on source type
1472
+ this._latencyTier = sourceType ? getLatencyTier(sourceType) : (this._isWebRTC ? 'ultra-low' : 'medium');
1473
+
1474
+ // Update live thresholds (with buffer window scaling)
1475
+ const bufferWindowMs = mistStreamInfo?.meta?.buffer_window
1476
+ ?? this.deriveBufferWindowMsFromTracks(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined);
1477
+ this._liveThresholds = calculateLiveThresholds(sourceType, this._isWebRTC, bufferWindowMs);
1478
+
1479
+ // Calculate seekable range using centralized logic (allow player overrides)
1480
+ const playerRange = this.getPlayerSeekableRange();
1481
+ const allowMediaStreamDvr = isMediaStreamSource(el) &&
1482
+ (bufferWindowMs !== undefined && bufferWindowMs > 0) &&
1483
+ (sourceType !== 'whep' && sourceType !== 'webrtc');
1484
+ const { seekableStart, liveEdge } = playerRange
1485
+ ? { seekableStart: playerRange.start, liveEdge: playerRange.end }
1486
+ : calculateSeekableRange({
1487
+ isLive,
1488
+ video: el,
1489
+ mistStreamInfo,
1490
+ currentTime,
1491
+ duration,
1492
+ allowMediaStreamDvr,
1493
+ });
1494
+
1495
+ // Update can seek - pass player's canSeek if available (e.g., WebCodecs uses server commands)
1496
+ const playerCanSeek = this.currentPlayer && typeof (this.currentPlayer as any).canSeek === 'function'
1497
+ ? () => (this.currentPlayer as any).canSeek()
1498
+ : undefined;
1499
+ this._canSeek = canSeekStream({
1500
+ video: el,
1501
+ isLive,
1502
+ duration,
1503
+ bufferWindowMs,
1504
+ playerCanSeek,
1505
+ });
1506
+
1507
+ // Update buffered ranges
1508
+ this._buffered = el.buffered.length > 0 ? el.buffered : null;
1509
+
1510
+ // Check if values changed
1511
+ const seekableChanged = this._seekableStart !== seekableStart || this._liveEdge !== liveEdge;
1512
+ const canSeekChanged = this._canSeek !== this._canSeek; // Already updated above
1513
+
1514
+ this._seekableStart = seekableStart;
1515
+ this._liveEdge = liveEdge;
1516
+
1517
+ // Update interaction controller live-only state (allow DVR shortcuts when seekable window exists)
1518
+ const hasDvrWindow = isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart;
1519
+ const isLiveOnly = isLive && !hasDvrWindow;
1520
+ this.interactionController?.updateConfig({
1521
+ isLive: isLiveOnly,
1522
+ frameStepSeconds: this.getFrameStepSecondsFromTracks(),
1523
+ });
1524
+
1525
+ // Update isNearLive using hysteresis
1526
+ if (isLive) {
1527
+ const newIsNearLive = calculateIsNearLive(
1528
+ currentTime,
1529
+ liveEdge,
1530
+ this._liveThresholds,
1531
+ this._isNearLive
1532
+ );
1533
+ if (newIsNearLive !== this._isNearLive) {
1534
+ this._isNearLive = newIsNearLive;
1535
+ }
1536
+ } else {
1537
+ this._isNearLive = true; // Always "at live" for VOD
1538
+ }
1539
+
1540
+ // Emit event for wrappers to consume
1541
+ // Only emit if something meaningful changed to avoid spam
1542
+ if (seekableChanged || wasWebRTC !== this._isWebRTC) {
1543
+ this.emit('seekingStateChange', {
1544
+ seekableStart: this._seekableStart,
1545
+ liveEdge: this._liveEdge,
1546
+ canSeek: this._canSeek,
1547
+ isNearLive: this._isNearLive,
1548
+ isLive,
1549
+ isWebRTC: this._isWebRTC,
1550
+ latencyTier: this._latencyTier,
1551
+ buffered: this._buffered,
1552
+ hasAudio: this._hasAudio,
1553
+ supportsPlaybackRate: this._supportsPlaybackRate,
1554
+ });
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Detect audio tracks on the video element.
1560
+ * Called after video metadata is loaded.
1561
+ */
1562
+ private detectAudioTracks(): void {
1563
+ const el = this.videoElement;
1564
+ if (!el) return;
1565
+
1566
+ // Check MediaStream audio tracks
1567
+ if (el.srcObject instanceof MediaStream) {
1568
+ const audioTracks = el.srcObject.getAudioTracks();
1569
+ this._hasAudio = audioTracks.length > 0;
1570
+ return;
1571
+ }
1572
+
1573
+ // Check HTML5 audio tracks (if available)
1574
+ // audioTracks is only available in some browsers (Safari, Edge)
1575
+ const elWithAudio = el as HTMLVideoElement & { audioTracks?: { length: number } };
1576
+ if (elWithAudio.audioTracks && elWithAudio.audioTracks.length !== undefined) {
1577
+ this._hasAudio = elWithAudio.audioTracks.length > 0;
1578
+ return;
1579
+ }
1580
+
1581
+ // Default to true if we can't detect
1582
+ this._hasAudio = true;
1583
+ }
1584
+
1585
+ // ============================================================================
1586
+ // Error Handling (Phase A3)
1587
+ // ============================================================================
1588
+
1589
+ /**
1590
+ * Attempt to clear error automatically if playback is progressing.
1591
+ * Called on timeupdate, playing, and canplay events.
1592
+ */
1593
+ private attemptClearError(): void {
1594
+ if (!this._errorText || this._errorCleared) return;
1595
+
1596
+ const now = Date.now();
1597
+ const elapsed = now - this._errorShownAt;
1598
+
1599
+ if (elapsed >= PlayerController.AUTO_CLEAR_ERROR_DELAY_MS) {
1600
+ this._errorCleared = true;
1601
+ this._errorText = null;
1602
+ this._isPassiveError = false;
1603
+ this.log('Error auto-cleared after playback resumed');
1604
+ this.emit('errorCleared', undefined as never);
1605
+ }
1606
+ }
1607
+
1608
+ /**
1609
+ * Check if we should attempt playback fallback due to hard failure.
1610
+ * Returns true if:
1611
+ * - Error count exceeds threshold (5+) within time window (60s)
1612
+ * - Error contains fatal keywords
1613
+ * - Sustained stall for 30+ seconds
1614
+ */
1615
+ private shouldAttemptFallback(error: string): boolean {
1616
+ const now = Date.now();
1617
+
1618
+ // Track error count within window
1619
+ if (now - this._lastErrorTime > PlayerController.HARD_FAILURE_ERROR_WINDOW_MS) {
1620
+ this._errorCount = 0; // Reset counter if outside window
1621
+ }
1622
+ this._errorCount++;
1623
+ this._lastErrorTime = now;
1624
+
1625
+ // Check for repeated errors (5+ errors within 60s)
1626
+ if (this._errorCount >= PlayerController.HARD_FAILURE_ERROR_THRESHOLD) {
1627
+ this.log(`Hard failure: repeated errors (${this._errorCount})`);
1628
+ return true;
1629
+ }
1630
+
1631
+ // Check for fatal error keywords
1632
+ const lowerError = error.toLowerCase();
1633
+ for (const keyword of PlayerController.FATAL_ERROR_KEYWORDS) {
1634
+ if (lowerError.includes(keyword)) {
1635
+ this.log(`Hard failure: fatal keyword "${keyword}" detected`);
1636
+ return true;
1637
+ }
1638
+ }
1639
+
1640
+ // Check for sustained stall (30+ seconds of continuous buffering)
1641
+ if (this._stallStartTime > 0) {
1642
+ const stallDuration = now - this._stallStartTime;
1643
+ if (stallDuration >= PlayerController.HARD_FAILURE_STALL_THRESHOLD_MS) {
1644
+ this.log(`Hard failure: sustained stall for ${stallDuration}ms`);
1645
+ return true;
1646
+ }
1647
+ }
1648
+
1649
+ return false;
1650
+ }
1651
+
1652
+ /**
1653
+ * Set error with passive mode support.
1654
+ * - Ignores errors during player transitions
1655
+ * - Marks error as passive if video is still playing
1656
+ * - Attempts automatic fallback on hard failures
1657
+ */
1658
+ async setPassiveError(error: string): Promise<void> {
1659
+ // Ignore errors during player switching transitions (old player cleanup can fire errors)
1660
+ if (this._isTransitioning) {
1661
+ this.log(`Ignoring error during player transition: ${error}`);
1662
+ return;
1663
+ }
1664
+
1665
+ // Check if video is still playing (passive error scenario)
1666
+ const video = this.videoElement;
1667
+ const isVideoPlaying = video && !video.paused && video.currentTime > 0;
1668
+
1669
+ // Attempt fallback on hard failures before showing error UI
1670
+ if (this.shouldAttemptFallback(error) && this.playerManager.canAttemptFallback()) {
1671
+ this.log('Attempting playback fallback...');
1672
+ this._isTransitioning = true;
1673
+
1674
+ const fallbackSucceeded = await this.playerManager.tryPlaybackFallback();
1675
+
1676
+ this._isTransitioning = false;
1677
+
1678
+ if (fallbackSucceeded) {
1679
+ // Fallback succeeded - clear error state and reset counters
1680
+ this._errorCount = 0;
1681
+ this._errorText = null;
1682
+ this._isPassiveError = false;
1683
+ this.log('Fallback succeeded');
1684
+ return;
1685
+ }
1686
+ // Fallback failed or exhausted - fall through to show error
1687
+ this.log('Fallback exhausted, showing error UI');
1688
+ }
1689
+
1690
+ // Set error state
1691
+ this._errorShownAt = Date.now();
1692
+ this._errorCleared = false;
1693
+ this._errorText = error;
1694
+ this._isPassiveError = isVideoPlaying ?? false;
1695
+
1696
+ this.setState('error', { error });
1697
+ this.emit('error', { error });
1698
+ }
1699
+
1700
+ /**
1701
+ * Retry playback with fallback to next player/source.
1702
+ * Returns true if a fallback option was available and attempted.
1703
+ */
1704
+ async retryWithFallback(): Promise<boolean> {
1705
+ if (!this.playerManager.canAttemptFallback()) {
1706
+ return false;
1707
+ }
1708
+
1709
+ this._isTransitioning = true;
1710
+ const success = await this.playerManager.tryPlaybackFallback();
1711
+ this._isTransitioning = false;
1712
+
1713
+ if (success) {
1714
+ this._errorCount = 0;
1715
+ this.clearError();
1716
+ }
1717
+
1718
+ return success;
1719
+ }
1720
+
1721
+ /** Toggle fullscreen */
1722
+ async toggleFullscreen(): Promise<void> {
1723
+ if (typeof document === 'undefined') return;
1724
+
1725
+ if (document.fullscreenElement) {
1726
+ await document.exitFullscreen().catch(() => {});
1727
+ } else if (this.container) {
1728
+ await this.container.requestFullscreen().catch(() => {});
1729
+ }
1730
+ }
1731
+
1732
+ /** Toggle Picture-in-Picture */
1733
+ async togglePictureInPicture(): Promise<void> {
1734
+ if (typeof document === 'undefined') return;
1735
+
1736
+ if (document.pictureInPictureElement) {
1737
+ await document.exitPictureInPicture().catch(() => {});
1738
+ } else if (this.videoElement && 'requestPictureInPicture' in this.videoElement) {
1739
+ await (this.videoElement as any).requestPictureInPicture().catch(() => {});
1740
+ }
1741
+ }
1742
+
1743
+ /** Check if Picture-in-Picture is supported */
1744
+ isPiPSupported(): boolean {
1745
+ if (typeof document === 'undefined') return false;
1746
+ return document.pictureInPictureEnabled ?? false;
1747
+ }
1748
+
1749
+ /** Check if currently in Picture-in-Picture mode */
1750
+ isPiPActive(): boolean {
1751
+ if (typeof document === 'undefined') return false;
1752
+ return document.pictureInPictureElement === this.videoElement;
1753
+ }
1754
+
1755
+ // ============================================================================
1756
+ // Advanced Control
1757
+ // ============================================================================
1758
+
1759
+ /** Force a retry of the current playback */
1760
+ async retry(): Promise<void> {
1761
+ if (!this.container || !this.streamInfo) return;
1762
+
1763
+ try {
1764
+ this.playerManager.destroy();
1765
+ } catch {
1766
+ // Ignore cleanup errors
1767
+ }
1768
+
1769
+ this.container.innerHTML = '';
1770
+ this.videoElement = null;
1771
+ this.currentPlayer = null;
1772
+
1773
+ try {
1774
+ await this.initializePlayer();
1775
+ } catch (error) {
1776
+ const message = error instanceof Error ? error.message : 'Retry failed';
1777
+ this.setState('error', { error: message });
1778
+ this.emit('error', { error: message });
1779
+ }
1780
+ }
1781
+
1782
+ /** Get playback statistics */
1783
+ async getStats(): Promise<unknown> {
1784
+ return this.currentPlayer?.getStats?.();
1785
+ }
1786
+
1787
+ /** Get current latency (for live streams) */
1788
+ async getLatency(): Promise<unknown> {
1789
+ return this.currentPlayer?.getLatency?.();
1790
+ }
1791
+
1792
+ // ============================================================================
1793
+ // Runtime Configuration (Phase A5)
1794
+ // ============================================================================
1795
+
1796
+ /**
1797
+ * Update configuration at runtime without full re-initialization.
1798
+ * Only certain options can be updated without re-init.
1799
+ */
1800
+ updateConfig(partialConfig: Partial<Pick<PlayerControllerConfig, 'debug' | 'autoplay' | 'muted'>>): void {
1801
+ if (partialConfig.debug !== undefined) {
1802
+ this.config.debug = partialConfig.debug;
1803
+ }
1804
+ if (partialConfig.autoplay !== undefined) {
1805
+ this.config.autoplay = partialConfig.autoplay;
1806
+ }
1807
+ if (partialConfig.muted !== undefined) {
1808
+ this.config.muted = partialConfig.muted;
1809
+ if (this.videoElement) {
1810
+ this.videoElement.muted = partialConfig.muted;
1811
+ }
1812
+ }
1813
+ }
1814
+
1815
+ /**
1816
+ * Force a complete re-initialization with current config.
1817
+ * Stops and re-initializes the entire player.
1818
+ */
1819
+ async reload(): Promise<void> {
1820
+ if (!this.container || this.isDestroyed) return;
1821
+
1822
+ const container = this.container;
1823
+ this.detach();
1824
+ await this.attach(container);
1825
+ }
1826
+
1827
+ /**
1828
+ * Select a specific player/source combination (one-shot).
1829
+ * Used by DevModePanel to manually pick a combo.
1830
+ *
1831
+ * Note: This is a ONE-SHOT selection. The force settings are used for
1832
+ * the next initialization only. If that player fails, normal fallback
1833
+ * logic proceeds without the force settings.
1834
+ */
1835
+ async selectCombo(options: {
1836
+ forcePlayer?: string;
1837
+ forceType?: string;
1838
+ forceSource?: number;
1839
+ }): Promise<void> {
1840
+ const container = this.container;
1841
+ if (!container) return;
1842
+
1843
+ this.log(`[selectCombo] One-shot selection: player=${options.forcePlayer}, type=${options.forceType}, source=${options.forceSource}`);
1844
+
1845
+ // Store as one-shot options (will be cleared after use)
1846
+ this._pendingForceOptions = {
1847
+ forcePlayer: options.forcePlayer,
1848
+ forceType: options.forceType,
1849
+ forceSource: options.forceSource,
1850
+ };
1851
+
1852
+ // Detach and re-attach - initializePlayer will use pending options once
1853
+ this.detach();
1854
+ await this.attach(container);
1855
+ }
1856
+
1857
+ /**
1858
+ * Set playback mode preference.
1859
+ * Unlike selectCombo, this is a persistent preference that affects scoring.
1860
+ */
1861
+ setPlaybackMode(mode: 'auto' | 'low-latency' | 'quality' | 'vod'): void {
1862
+ this.config.playbackMode = mode;
1863
+ this.log(`[setPlaybackMode] Mode set to: ${mode}`);
1864
+ }
1865
+
1866
+ /**
1867
+ * @deprecated Use selectCombo() for one-shot selection or setPlaybackMode() for mode changes.
1868
+ * This method exists for backwards compatibility but may override fallback behavior.
1869
+ */
1870
+ async setDevModeOptions(options: {
1871
+ forcePlayer?: string;
1872
+ forceType?: string;
1873
+ forceSource?: number;
1874
+ playbackMode?: 'auto' | 'low-latency' | 'quality' | 'vod';
1875
+ }): Promise<void> {
1876
+ // Update playback mode if provided (this is a persistent preference)
1877
+ if (options.playbackMode) {
1878
+ this.setPlaybackMode(options.playbackMode);
1879
+ }
1880
+
1881
+ // Use selectCombo for the force settings (one-shot)
1882
+ if (options.forcePlayer !== undefined || options.forceType !== undefined || options.forceSource !== undefined) {
1883
+ await this.selectCombo({
1884
+ forcePlayer: options.forcePlayer,
1885
+ forceType: options.forceType,
1886
+ forceSource: options.forceSource,
1887
+ });
1888
+ } else if (options.playbackMode) {
1889
+ // Mode-only change, trigger reload
1890
+ const container = this.container;
1891
+ if (container) {
1892
+ this.detach();
1893
+ await this.attach(container);
1894
+ }
1895
+ }
1896
+ }
1897
+
1898
+ /**
1899
+ * Get metadata update payload for external consumers.
1900
+ * Combines current state into a single metadata object.
1901
+ */
1902
+ getMetadataPayload(): PlayerControllerEvents['metadataUpdate'] {
1903
+ const video = this.videoElement;
1904
+ const bufferedAhead = video && video.buffered.length > 0
1905
+ ? video.buffered.end(video.buffered.length - 1) - video.currentTime
1906
+ : 0;
1907
+
1908
+ return {
1909
+ currentTime: video?.currentTime ?? 0,
1910
+ duration: video?.duration ?? NaN,
1911
+ bufferedAhead: Math.max(0, bufferedAhead),
1912
+ qualityScore: this._playbackQuality?.score,
1913
+ playerInfo: this._currentPlayerInfo ?? undefined,
1914
+ sourceInfo: this._currentSourceInfo ?? undefined,
1915
+ isLive: this.isEffectivelyLive(),
1916
+ isBuffering: this._isBuffering,
1917
+ isPaused: video?.paused ?? true,
1918
+ volume: video?.volume ?? 1,
1919
+ muted: video?.muted ?? true,
1920
+ };
1921
+ }
1922
+
1923
+ /**
1924
+ * Emit a metadata update event with current state.
1925
+ * Useful for periodic telemetry/reporting.
1926
+ */
1927
+ emitMetadataUpdate(): void {
1928
+ this.emit('metadataUpdate', this.getMetadataPayload());
1929
+ }
1930
+
1931
+ // ============================================================================
1932
+ // Private Methods
1933
+ // ============================================================================
1934
+
1935
+ private async resolveEndpoints(): Promise<void> {
1936
+ const { endpoints, gatewayUrl, mistUrl, contentType, contentId, authToken } = this.config;
1937
+
1938
+ // Priority 1: Use pre-resolved endpoints if provided
1939
+ if (endpoints?.primary) {
1940
+ this.endpoints = endpoints;
1941
+ this.setState('gateway_ready', { gatewayStatus: 'ready' });
1942
+ return;
1943
+ }
1944
+
1945
+ // Priority 2: Direct MistServer resolution (playground/standalone mode)
1946
+ if (mistUrl) {
1947
+ await this.resolveFromMistServer(mistUrl, contentId);
1948
+ return;
1949
+ }
1950
+
1951
+ // Priority 3: Gateway resolution
1952
+ if (gatewayUrl) {
1953
+ await this.resolveFromGateway(gatewayUrl, contentType, contentId, authToken);
1954
+ return;
1955
+ }
1956
+
1957
+ throw new Error('No endpoints provided and no gatewayUrl or mistUrl configured');
1958
+ }
1959
+
1960
+ /**
1961
+ * Resolve endpoints directly from MistServer (bypasses Gateway)
1962
+ * Fetches json_{contentId}.js and builds ContentEndpoints from source array
1963
+ */
1964
+ private async resolveFromMistServer(mistUrl: string, contentId: string): Promise<void> {
1965
+ this.setState('gateway_loading', { gatewayStatus: 'loading' });
1966
+
1967
+ try {
1968
+ const jsonUrl = `${mistUrl.replace(/\/+$/, '')}/json_${encodeURIComponent(contentId)}.js`;
1969
+ this.log(`[resolveFromMistServer] Fetching ${jsonUrl}`);
1970
+
1971
+ const response = await fetch(jsonUrl, { cache: 'no-store' });
1972
+ if (!response.ok) {
1973
+ throw new Error(`MistServer HTTP ${response.status}`);
1974
+ }
1975
+
1976
+ const data = await response.json();
1977
+
1978
+ if (data.error) {
1979
+ throw new Error(data.error);
1980
+ }
1981
+
1982
+ const sources: Array<{ url: string; type: string }> = Array.isArray(data.source) ? data.source : [];
1983
+ if (sources.length === 0) {
1984
+ throw new Error('No sources available from MistServer');
1985
+ }
1986
+
1987
+ // Build outputs map from all sources
1988
+ const outputs: Record<string, { protocol: string; url: string }> = {};
1989
+ for (const source of sources) {
1990
+ const protocol = this.mapMistTypeToProtocol(source.type);
1991
+ if (!outputs[protocol]) {
1992
+ outputs[protocol] = { protocol, url: source.url };
1993
+ }
1994
+ }
1995
+
1996
+ // Select primary source (prefer HLS/DASH over WebSocket-based)
1997
+ const httpSources = sources.filter(s => !s.url.startsWith('ws://'));
1998
+ const primarySource = httpSources.length > 0
1999
+ ? this.selectBestSource(httpSources)
2000
+ : sources[0];
2001
+
2002
+ const primary = {
2003
+ nodeId: `mist-${contentId}`,
2004
+ protocol: this.mapMistTypeToProtocol(primarySource.type),
2005
+ url: primarySource.url,
2006
+ baseUrl: mistUrl,
2007
+ outputs,
2008
+ };
2009
+
2010
+ this.endpoints = { primary, fallbacks: [] };
2011
+
2012
+ // Parse track metadata from MistServer response
2013
+ if (data.meta?.tracks && typeof data.meta.tracks === 'object') {
2014
+ const tracks = this.parseMistTracks(data.meta.tracks);
2015
+ this.mistTracks = tracks.length > 0 ? tracks : null;
2016
+ this.log(`[resolveFromMistServer] Parsed ${tracks.length} tracks from MistServer`);
2017
+ }
2018
+
2019
+ this.setState('gateway_ready', { gatewayStatus: 'ready' });
2020
+ this.log(`[resolveFromMistServer] Resolved: ${primary.protocol} @ ${primary.url}`);
2021
+
2022
+ } catch (error) {
2023
+ const message = error instanceof Error ? error.message : 'MistServer resolution failed';
2024
+ this.setState('gateway_error', { gatewayStatus: 'error', error: message });
2025
+ throw error;
2026
+ }
2027
+ }
2028
+
2029
+ /**
2030
+ * Map MistServer type to protocol identifier
2031
+ */
2032
+ private mapMistTypeToProtocol(mistType: string): string {
2033
+ // WebCodecs raw streams - check BEFORE generic ws/ catch-all
2034
+ // MistServer rawws.js uses 'ws/video/raw', mews.js uses 'ws/video/mp4' and 'ws/video/webm'
2035
+ if (mistType === 'ws/video/raw') return 'RAW_WS';
2036
+ if (mistType === 'wss/video/raw') return 'RAW_WSS';
2037
+ // MEWS (MP4/WebM over WebSocket) - catches remaining ws/* types
2038
+ if (mistType.startsWith('ws/') || mistType.startsWith('wss/')) return 'MEWS_WS';
2039
+ if (mistType.includes('webrtc')) return 'MIST_WEBRTC';
2040
+ if (mistType.includes('mpegurl') || mistType.includes('m3u8')) return 'HLS';
2041
+ if (mistType.includes('dash') || mistType.includes('mpd')) return 'DASH';
2042
+ if (mistType.includes('whep')) return 'WHEP';
2043
+ if (mistType.includes('mp4')) return 'MP4';
2044
+ if (mistType.includes('webm')) return 'WEBM';
2045
+ return mistType;
2046
+ }
2047
+
2048
+ /**
2049
+ * Select best source based on protocol priority
2050
+ */
2051
+ private selectBestSource(sources: Array<{ url: string; type: string }>): { url: string; type: string } {
2052
+ const priority: Record<string, number> = {
2053
+ HLS: 1, DASH: 2, MP4: 3, WEBM: 4, WHEP: 5, MIST_WEBRTC: 6, MEWS_WS: 99,
2054
+ };
2055
+ return sources.sort((a, b) => {
2056
+ const pa = priority[this.mapMistTypeToProtocol(a.type)] ?? 50;
2057
+ const pb = priority[this.mapMistTypeToProtocol(b.type)] ?? 50;
2058
+ return pa - pb;
2059
+ })[0];
2060
+ }
2061
+
2062
+ /**
2063
+ * Resolve endpoints from Gateway GraphQL API
2064
+ */
2065
+ private async resolveFromGateway(
2066
+ gatewayUrl: string,
2067
+ contentType: ContentType,
2068
+ contentId: string,
2069
+ authToken?: string
2070
+ ): Promise<void> {
2071
+ this.setState('gateway_loading', { gatewayStatus: 'loading' });
2072
+
2073
+ this.gatewayClient = new GatewayClient({
2074
+ gatewayUrl,
2075
+ contentType,
2076
+ contentId,
2077
+ authToken,
2078
+ });
2079
+
2080
+ // Subscribe to status changes
2081
+ const unsub = this.gatewayClient.on('statusChange', ({ status, error }) => {
2082
+ if (status === 'error') {
2083
+ this.setState('gateway_error', { gatewayStatus: status, error });
2084
+ }
2085
+ });
2086
+ this.cleanupFns.push(unsub);
2087
+ this.cleanupFns.push(() => this.gatewayClient?.destroy());
2088
+
2089
+ try {
2090
+ this.endpoints = await this.gatewayClient.resolve();
2091
+ this.setState('gateway_ready', { gatewayStatus: 'ready' });
2092
+ } catch (error) {
2093
+ const message = error instanceof Error ? error.message : 'Gateway resolution failed';
2094
+ this.setState('gateway_error', { gatewayStatus: 'error', error: message });
2095
+ throw error;
2096
+ }
2097
+ }
2098
+
2099
+ private startStreamStatePolling(): void {
2100
+ const { contentType, contentId, mistUrl } = this.config;
2101
+
2102
+ // Only poll for live-like content. DVR should only poll while recording.
2103
+ if (contentType !== 'live' && contentType !== 'dvr') return;
2104
+ if (contentType === 'dvr') {
2105
+ const dvrStatus = this.getMetadata()?.dvrStatus;
2106
+ if (dvrStatus && dvrStatus !== 'recording') return;
2107
+ }
2108
+
2109
+ // Use endpoint baseUrl if available, otherwise fall back to config.mistUrl
2110
+ // This allows polling to start even when initial endpoint resolution failed
2111
+ const mistBaseUrl = this.endpoints?.primary?.baseUrl || mistUrl;
2112
+ if (!mistBaseUrl) return;
2113
+
2114
+ // Use playback ID from metadata if available
2115
+ const metadata = this.getMetadata();
2116
+ const streamName = metadata?.contentId || contentId;
2117
+
2118
+ // For effectively live content, use WebSocket for real-time updates
2119
+ // For completed VOD content, use HTTP polling only
2120
+ const useWebSocket = this.isEffectivelyLive();
2121
+ const pollInterval = this.isEffectivelyLive() ? 3000 : 5000;
2122
+
2123
+ this.streamStateClient = new StreamStateClient({
2124
+ mistBaseUrl,
2125
+ streamName,
2126
+ useWebSocket,
2127
+ pollInterval,
2128
+ });
2129
+
2130
+ // Subscribe to state changes
2131
+ const unsubState = this.streamStateClient.on('stateChange', ({ state }) => {
2132
+ const wasOnline = this._prevStreamIsOnline;
2133
+ const isNowOnline = state.isOnline;
2134
+
2135
+ this.streamState = state;
2136
+ this._prevStreamIsOnline = isNowOnline;
2137
+
2138
+ // Update track metadata if MistServer provides better data
2139
+ // This handles cold-start: Gateway gives fallback codecs, MistServer gives real ones
2140
+ if (state.streamInfo?.meta?.tracks && this.streamInfo) {
2141
+ const mistTracks = this.parseMistTracks(state.streamInfo.meta.tracks);
2142
+ if (mistTracks.length > 0) {
2143
+ this.streamInfo.meta.tracks = mistTracks;
2144
+ this.log(`[stateChange] Updated ${mistTracks.length} tracks from MistServer`);
2145
+ }
2146
+ }
2147
+
2148
+ this.emit('streamStateChange', { state });
2149
+
2150
+ // Auto-play when stream transitions from offline to online
2151
+ // This handles the case where user is watching IdleScreen and stream comes online
2152
+ if (wasOnline === false && isNowOnline === true && this.isEffectivelyLive()) {
2153
+ this.log('Stream came online, triggering auto-play');
2154
+ if (this.videoElement) {
2155
+ // Player already initialized - just play
2156
+ this.videoElement.play().catch(e =>
2157
+ this.log(`Auto-play on online transition failed: ${e}`)
2158
+ );
2159
+ } else if (this.container && !this.endpoints?.primary) {
2160
+ // Player wasn't initialized because stream was offline - re-attempt full initialization
2161
+ this.log('Stream came online, attempting late initialization');
2162
+ this.initializeLateFromStreamState(state.streamInfo);
2163
+ }
2164
+ }
2165
+ });
2166
+ this.cleanupFns.push(unsubState);
2167
+ this.cleanupFns.push(() => this.streamStateClient?.destroy());
2168
+
2169
+ this.streamStateClient.start();
2170
+ }
2171
+
2172
+ /**
2173
+ * Initialize player late when stream comes online after initial attach failed.
2174
+ * Uses MistStreamInfo from stream state polling instead of re-fetching.
2175
+ */
2176
+ private async initializeLateFromStreamState(streamInfo: MistStreamInfo | undefined): Promise<void> {
2177
+ if (!streamInfo?.source || !Array.isArray(streamInfo.source) || streamInfo.source.length === 0) {
2178
+ this.log('[initializeLateFromStreamState] No sources in stream info');
2179
+ return;
2180
+ }
2181
+
2182
+ if (!this.container || !this.config.mistUrl) {
2183
+ this.log('[initializeLateFromStreamState] Missing container or mistUrl');
2184
+ return;
2185
+ }
2186
+
2187
+ try {
2188
+ const sources = streamInfo.source;
2189
+ const mistUrl = this.config.mistUrl;
2190
+ const contentId = this.config.contentId;
2191
+
2192
+ // Build outputs map from all sources
2193
+ const outputs: Record<string, { protocol: string; url: string }> = {};
2194
+ for (const source of sources) {
2195
+ const protocol = this.mapMistTypeToProtocol(source.type);
2196
+ if (!outputs[protocol]) {
2197
+ outputs[protocol] = { protocol, url: source.url };
2198
+ }
2199
+ }
2200
+
2201
+ // Select primary source (prefer HLS/DASH over WebSocket-based)
2202
+ const httpSources = sources.filter(s => !s.url.startsWith('ws://'));
2203
+ const primarySource = httpSources.length > 0
2204
+ ? this.selectBestSource(httpSources)
2205
+ : sources[0];
2206
+
2207
+ const primary = {
2208
+ nodeId: `mist-${contentId}`,
2209
+ protocol: this.mapMistTypeToProtocol(primarySource.type),
2210
+ url: primarySource.url,
2211
+ baseUrl: mistUrl,
2212
+ outputs,
2213
+ };
2214
+
2215
+ this.endpoints = { primary, fallbacks: [] };
2216
+
2217
+ // Parse track metadata from stream info
2218
+ if (streamInfo.meta?.tracks && typeof streamInfo.meta.tracks === 'object') {
2219
+ const tracks = this.parseMistTracks(streamInfo.meta.tracks);
2220
+ this.mistTracks = tracks.length > 0 ? tracks : null;
2221
+ this.log(`[initializeLateFromStreamState] Parsed ${tracks.length} tracks`);
2222
+ }
2223
+
2224
+ this.setState('gateway_ready', { gatewayStatus: 'ready' });
2225
+ this.log(`[initializeLateFromStreamState] Built endpoints from stream state: ${primary.protocol}`);
2226
+
2227
+ // Build StreamInfo and initialize player
2228
+ this.streamInfo = this.buildStreamInfo(this.endpoints);
2229
+
2230
+ if (!this.streamInfo || this.streamInfo.source.length === 0) {
2231
+ this.setState('error', { error: 'No playable sources found' });
2232
+ return;
2233
+ }
2234
+
2235
+ await this.initializePlayer();
2236
+ this.log('[initializeLateFromStreamState] Player initialized successfully');
2237
+
2238
+ } catch (error) {
2239
+ const message = error instanceof Error ? error.message : 'Late initialization failed';
2240
+ this.log(`[initializeLateFromStreamState] Failed: ${message}`);
2241
+ this.setState('error', { error: message });
2242
+ }
2243
+ }
2244
+
2245
+ private buildStreamInfo(endpoints: ContentEndpoints): StreamInfo | null {
2246
+ // Delegate to standalone exported function
2247
+ const info = buildStreamInfoFromEndpoints(endpoints, this.config.contentId);
2248
+
2249
+ // If we have tracks from direct MistServer resolution, use those instead
2250
+ // (they have accurate codecstring and init data for proper codec detection)
2251
+ if (info && this.mistTracks && this.mistTracks.length > 0) {
2252
+ info.meta.tracks = this.mistTracks;
2253
+ this.log(`[buildStreamInfo] Using ${this.mistTracks.length} tracks from MistServer`);
2254
+ }
2255
+
2256
+ return info;
2257
+ }
2258
+
2259
+ /**
2260
+ * Parse MistServer track metadata from the tracks object.
2261
+ * MistServer returns tracks as a Record keyed by track name (e.g., "video_H264_800x600_25fps_1").
2262
+ * This converts to our StreamTrack[] format with codecstring and init data.
2263
+ */
2264
+ private parseMistTracks(tracksObj: Record<string, unknown>): StreamTrack[] {
2265
+ const tracks: StreamTrack[] = [];
2266
+ for (const [, trackData] of Object.entries(tracksObj)) {
2267
+ const t = trackData as Record<string, unknown>;
2268
+ const trackType = t.type as string;
2269
+ if (trackType === 'video' || trackType === 'audio' || trackType === 'meta') {
2270
+ tracks.push({
2271
+ type: trackType,
2272
+ codec: t.codec as string,
2273
+ codecstring: t.codecstring as string | undefined,
2274
+ init: t.init as string | undefined,
2275
+ idx: t.idx as number | undefined,
2276
+ width: t.width as number | undefined,
2277
+ height: t.height as number | undefined,
2278
+ fpks: t.fpks as number | undefined,
2279
+ channels: t.channels as number | undefined,
2280
+ rate: t.rate as number | undefined,
2281
+ size: t.size as number | undefined,
2282
+ });
2283
+ }
2284
+ }
2285
+ return tracks;
2286
+ }
2287
+
2288
+ private async initializePlayer(): Promise<void> {
2289
+ const container = this.container;
2290
+ const streamInfo = this.streamInfo;
2291
+
2292
+ this.log(`[initializePlayer] Starting - container: ${!!container}, streamInfo: ${!!streamInfo}, sources: ${streamInfo?.source?.length ?? 0}`);
2293
+
2294
+ if (!container || !streamInfo) {
2295
+ throw new Error('Container or streamInfo not available');
2296
+ }
2297
+
2298
+ // Log source details for debugging
2299
+ this.log(`[initializePlayer] Sources: ${JSON.stringify(streamInfo.source.map(s => ({ type: s.type, url: s.url.slice(0, 60) + '...' })))}`);
2300
+ this.log(`[initializePlayer] Tracks: ${streamInfo.meta.tracks.map(t => `${t.type}:${t.codec}`).join(', ')}`);
2301
+
2302
+ const { autoplay, muted, controls, poster } = this.config;
2303
+
2304
+ // Clear container
2305
+ container.innerHTML = '';
2306
+
2307
+ // Listen for player selection
2308
+ const onSelected = (e: { player: string; source: StreamSource; score: number }) => {
2309
+ // Track current player info
2310
+ const playerImpl = this.playerManager.getRegisteredPlayers().find(
2311
+ p => p.capability.shortname === e.player
2312
+ );
2313
+ if (playerImpl) {
2314
+ this._currentPlayerInfo = {
2315
+ name: playerImpl.capability.name,
2316
+ shortname: playerImpl.capability.shortname,
2317
+ };
2318
+ }
2319
+
2320
+ // Track current source info
2321
+ if (e.source) {
2322
+ this._currentSourceInfo = {
2323
+ url: e.source.url,
2324
+ type: e.source.type,
2325
+ };
2326
+ }
2327
+
2328
+ this.setState('connecting', {
2329
+ selectedPlayer: e.player,
2330
+ selectedProtocol: (e.source?.type || '').toString(),
2331
+ endpointUrl: e.source?.url,
2332
+ });
2333
+
2334
+ // Bubble up playerSelected event
2335
+ this.emit('playerSelected', { player: e.player, source: e.source, score: e.score });
2336
+ };
2337
+ try {
2338
+ (this.playerManager as any).on?.('playerSelected', onSelected);
2339
+ } catch {}
2340
+ this.cleanupFns.push(() => {
2341
+ try {
2342
+ (this.playerManager as any).off?.('playerSelected', onSelected);
2343
+ } catch {}
2344
+ });
2345
+
2346
+ this.setState('selecting_player');
2347
+
2348
+ const playerOptions: CorePlayerOptions = {
2349
+ autoplay: autoplay !== false,
2350
+ muted: muted !== false,
2351
+ controls: controls !== false,
2352
+ poster: poster,
2353
+ debug: this.config.debug,
2354
+ onReady: (el) => {
2355
+ // Guard against zombie callbacks after destroy
2356
+ if (this.isDestroyed || !this.container) {
2357
+ this.log('[initializePlayer] onReady callback aborted - controller destroyed');
2358
+ return;
2359
+ }
2360
+ // Defensive: some flows (e.g. failed fallback attempt) can temporarily detach
2361
+ // the current video element from the container while playback continues.
2362
+ // Ensure the element is actually attached for rendering.
2363
+ try {
2364
+ if (this.container && !this.container.contains(el)) {
2365
+ this.log('[initializePlayer] Video element was detached; re-attaching to container');
2366
+ this.container.appendChild(el);
2367
+ }
2368
+ } catch {}
2369
+ this.videoElement = el;
2370
+ this.currentPlayer = this.playerManager.getCurrentPlayer();
2371
+ this.setupVideoEventListeners(el);
2372
+ // Initialize sub-controllers after video is ready
2373
+ this.initializeSubControllers();
2374
+ this.emit('ready', { videoElement: el });
2375
+ },
2376
+ onTimeUpdate: (t) => {
2377
+ if (this.isDestroyed) return;
2378
+ // Defensive: keep video element attached even if some other lifecycle cleared the container.
2379
+ // (Playback can continue even when detached, which looks like "audio only".)
2380
+ try {
2381
+ if (this.container && this.videoElement && !this.container.contains(this.videoElement)) {
2382
+ this.log('[initializePlayer] Video element was detached during playback; re-attaching to container');
2383
+ this.container.appendChild(this.videoElement);
2384
+ }
2385
+ } catch {}
2386
+ this.emit('timeUpdate', {
2387
+ currentTime: this.getEffectiveCurrentTime(),
2388
+ duration: this.getEffectiveDuration(),
2389
+ });
2390
+ },
2391
+ onError: (err) => {
2392
+ if (this.isDestroyed) return;
2393
+ const message = typeof err === 'string' ? err : String(err);
2394
+ // Use setPassiveError for smart error handling with fallback support
2395
+ this.setPassiveError(message);
2396
+ },
2397
+ };
2398
+
2399
+ // Manager options for player selection
2400
+ // Use pending force options (one-shot from selectCombo) if available, otherwise use config
2401
+ const pendingForce = this._pendingForceOptions;
2402
+ this._pendingForceOptions = null; // Clear immediately - one-shot only
2403
+
2404
+ const managerOptions = {
2405
+ // One-shot force options take precedence, then fall back to config
2406
+ forcePlayer: pendingForce?.forcePlayer ?? this.config.forcePlayer,
2407
+ forceType: pendingForce?.forceType ?? this.config.forceType,
2408
+ forceSource: pendingForce?.forceSource ?? this.config.forceSource,
2409
+ // Playback mode is a persistent preference
2410
+ playbackMode: this.config.playbackMode,
2411
+ };
2412
+
2413
+ this.log(`[initializePlayer] Calling playerManager.initializePlayer...`);
2414
+ this.log(`[initializePlayer] Manager options: ${JSON.stringify(managerOptions)} (pending force: ${pendingForce ? 'yes' : 'no'})`);
2415
+ try {
2416
+ await this.playerManager.initializePlayer(container, streamInfo, playerOptions, managerOptions);
2417
+ this.log(`[initializePlayer] Player initialized successfully`);
2418
+ } catch (e) {
2419
+ this.log(`[initializePlayer] Player initialization FAILED: ${e}`);
2420
+ throw e;
2421
+ }
2422
+ }
2423
+
2424
+ private setupVideoEventListeners(el: HTMLVideoElement): void {
2425
+ // Apply loop setting
2426
+ el.loop = this._isLoopEnabled;
2427
+
2428
+ const onWaiting = () => {
2429
+ this._isBuffering = true;
2430
+ // Start stall timer if not already started
2431
+ if (this._stallStartTime === 0) {
2432
+ this._stallStartTime = Date.now();
2433
+ this.log('Stall started');
2434
+ }
2435
+ this.setState('buffering');
2436
+ };
2437
+ const onPlaying = () => {
2438
+ if (this.shouldSuppressVideoEvents()) return;
2439
+ this._isBuffering = false;
2440
+ this._hasPlaybackStarted = true;
2441
+ // Clear stall timer on successful playback
2442
+ if (this._stallStartTime > 0) {
2443
+ this.log(`Stall cleared after ${Date.now() - this._stallStartTime}ms`);
2444
+ this._stallStartTime = 0;
2445
+ }
2446
+ this.setState('playing');
2447
+ // Attempt to clear error on playback resume
2448
+ this.attemptClearError();
2449
+ };
2450
+ const onCanPlay = () => {
2451
+ this._isBuffering = false;
2452
+ // Clear stall timer on canplay
2453
+ this._stallStartTime = 0;
2454
+ this.setState('playing');
2455
+ // Attempt to clear error on canplay
2456
+ this.attemptClearError();
2457
+ };
2458
+ const onPause = () => {
2459
+ if (this.shouldSuppressVideoEvents()) return;
2460
+ this.setState('paused');
2461
+ };
2462
+ const onEnded = () => this.setState('ended');
2463
+ const onError = () => {
2464
+ const message = el.error ? el.error.message || 'Playback error' : 'Playback error';
2465
+ // Use setPassiveError for smart error handling with fallback support
2466
+ this.setPassiveError(message);
2467
+ };
2468
+ const onTimeUpdate = () => {
2469
+ this.emit('timeUpdate', {
2470
+ currentTime: this.getEffectiveCurrentTime(),
2471
+ duration: this.getEffectiveDuration(),
2472
+ });
2473
+ // Update seeking state (seekable range, isNearLive, etc.)
2474
+ this.updateSeekingState();
2475
+ // Attempt to clear error when playback is progressing
2476
+ if (this.getEffectiveCurrentTime() > 0) {
2477
+ this.attemptClearError();
2478
+ }
2479
+ };
2480
+ const onDurationChange = () => {
2481
+ this.emit('timeUpdate', {
2482
+ currentTime: this.getEffectiveCurrentTime(),
2483
+ duration: this.getEffectiveDuration(),
2484
+ });
2485
+ // Update seeking state on duration change
2486
+ this.updateSeekingState();
2487
+ };
2488
+ const onProgress = () => {
2489
+ // Update buffered ranges
2490
+ this._buffered = el.buffered;
2491
+ // Recalculate seeking state when buffer updates
2492
+ this.updateSeekingState();
2493
+ };
2494
+ const onLoadedMetadata = () => {
2495
+ // Detect audio tracks and WebRTC source
2496
+ this.detectAudioTracks();
2497
+ this._isWebRTC = isMediaStreamSource(el);
2498
+ this._supportsPlaybackRate = !this._isWebRTC;
2499
+ // Initial seeking state calculation
2500
+ this.updateSeekingState();
2501
+ };
2502
+
2503
+ // Fullscreen change handler
2504
+ const onFullscreenChange = () => {
2505
+ const isFullscreen = document.fullscreenElement === this.container;
2506
+ this.emit('fullscreenChange', { isFullscreen });
2507
+ };
2508
+
2509
+ // PiP change handlers
2510
+ const onEnterPiP = () => this.emit('pipChange', { isPiP: true });
2511
+ const onLeavePiP = () => this.emit('pipChange', { isPiP: false });
2512
+
2513
+ // Volume change handler (for external changes, e.g., via native controls)
2514
+ const onVolumeChange = () => {
2515
+ this.emit('volumeChange', { volume: el.volume, muted: el.muted });
2516
+ };
2517
+
2518
+ el.addEventListener('waiting', onWaiting);
2519
+ el.addEventListener('playing', onPlaying);
2520
+ el.addEventListener('canplay', onCanPlay);
2521
+ el.addEventListener('pause', onPause);
2522
+ el.addEventListener('ended', onEnded);
2523
+ el.addEventListener('error', onError);
2524
+ el.addEventListener('timeupdate', onTimeUpdate);
2525
+ el.addEventListener('durationchange', onDurationChange);
2526
+ el.addEventListener('progress', onProgress);
2527
+ el.addEventListener('loadedmetadata', onLoadedMetadata);
2528
+ el.addEventListener('volumechange', onVolumeChange);
2529
+ el.addEventListener('enterpictureinpicture', onEnterPiP);
2530
+ el.addEventListener('leavepictureinpicture', onLeavePiP);
2531
+ document.addEventListener('fullscreenchange', onFullscreenChange);
2532
+
2533
+ this.cleanupFns.push(() => {
2534
+ el.removeEventListener('waiting', onWaiting);
2535
+ el.removeEventListener('playing', onPlaying);
2536
+ el.removeEventListener('canplay', onCanPlay);
2537
+ el.removeEventListener('pause', onPause);
2538
+ el.removeEventListener('ended', onEnded);
2539
+ el.removeEventListener('error', onError);
2540
+ el.removeEventListener('timeupdate', onTimeUpdate);
2541
+ el.removeEventListener('durationchange', onDurationChange);
2542
+ el.removeEventListener('progress', onProgress);
2543
+ el.removeEventListener('loadedmetadata', onLoadedMetadata);
2544
+ el.removeEventListener('volumechange', onVolumeChange);
2545
+ el.removeEventListener('enterpictureinpicture', onEnterPiP);
2546
+ el.removeEventListener('leavepictureinpicture', onLeavePiP);
2547
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
2548
+ });
2549
+ }
2550
+
2551
+ // ============================================================================
2552
+ // Sub-Controller Initialization (Phase A2)
2553
+ // ============================================================================
2554
+
2555
+ private initializeSubControllers(): void {
2556
+ if (!this.videoElement || !this.container) return;
2557
+
2558
+ // Initialize ABRController
2559
+ this.initializeABRController();
2560
+
2561
+ // Initialize QualityMonitor
2562
+ this.initializeQualityMonitor();
2563
+
2564
+ // Initialize InteractionController
2565
+ this.initializeInteractionController();
2566
+
2567
+ // Initialize MistReporter (needs WebSocket from StreamStateClient)
2568
+ this.initializeMistReporter();
2569
+
2570
+ // Initialize MetaTrackManager
2571
+ this.initializeMetaTrackManager();
2572
+ }
2573
+
2574
+ private initializeABRController(): void {
2575
+ const player = this.currentPlayer;
2576
+ if (!player || !this.videoElement) return;
2577
+
2578
+ this.abrController = new ABRController({
2579
+ options: { mode: 'auto' },
2580
+ getQualities: () => player.getQualities?.() ?? [],
2581
+ selectQuality: (id) => player.selectQuality?.(id),
2582
+ getCurrentQuality: () => {
2583
+ const qualities = player.getQualities?.() ?? [];
2584
+ const currentId = player.getCurrentQuality?.();
2585
+ return qualities.find(q => q.id === currentId) ?? null;
2586
+ },
2587
+ // Wire up bandwidth estimate from player stats
2588
+ getBandwidthEstimate: async () => {
2589
+ if (!this.currentPlayer?.getStats) return 0;
2590
+ try {
2591
+ const stats = await this.currentPlayer.getStats();
2592
+ // HLS.js provides bandwidthEstimate directly
2593
+ if (stats?.bandwidthEstimate) {
2594
+ return stats.bandwidthEstimate;
2595
+ }
2596
+ // DASH.js provides throughput info
2597
+ if (stats?.averageThroughput) {
2598
+ return stats.averageThroughput;
2599
+ }
2600
+ return 0;
2601
+ } catch {
2602
+ return 0;
2603
+ }
2604
+ },
2605
+ debug: this.config.debug,
2606
+ });
2607
+
2608
+ this.abrController.start(this.videoElement);
2609
+ this.cleanupFns.push(() => {
2610
+ this.abrController?.stop();
2611
+ this.abrController = null;
2612
+ });
2613
+ }
2614
+
2615
+ private initializeQualityMonitor(): void {
2616
+ if (!this.videoElement) return;
2617
+
2618
+ this.qualityMonitor = new QualityMonitor({ sampleInterval: 1000 });
2619
+ this.qualityMonitor.start(this.videoElement);
2620
+
2621
+ // Subscribe to quality updates
2622
+ const handleQualityUpdate = () => {
2623
+ if (this.qualityMonitor) {
2624
+ this._playbackQuality = this.qualityMonitor.getCurrentQuality();
2625
+
2626
+ // Feed quality score to MistReporter
2627
+ if (this.mistReporter && this._playbackQuality) {
2628
+ // Convert 0-100 score to MistPlayer-style 0-2.0 scale
2629
+ const mistScore = this._playbackQuality.score / 100;
2630
+ this.mistReporter.setPlaybackScore(mistScore);
2631
+ }
2632
+ }
2633
+ };
2634
+
2635
+ // Sample quality periodically
2636
+ const qualityInterval = setInterval(handleQualityUpdate, 1000);
2637
+ this.cleanupFns.push(() => {
2638
+ clearInterval(qualityInterval);
2639
+ this.qualityMonitor?.stop();
2640
+ this.qualityMonitor = null;
2641
+ });
2642
+ }
2643
+
2644
+ private initializeInteractionController(): void {
2645
+ if (!this.container || !this.videoElement) return;
2646
+
2647
+ const isLive = this.isEffectivelyLive();
2648
+ const hasDvrWindow = isLive && Number.isFinite(this._liveEdge) && Number.isFinite(this._seekableStart) && this._liveEdge > this._seekableStart;
2649
+ const isLiveOnly = isLive && !hasDvrWindow;
2650
+ const interactionContainer = (this.container.closest('[data-player-container="true"]') as HTMLElement | null) ?? this.container;
2651
+
2652
+ this.interactionController = new InteractionController({
2653
+ container: interactionContainer,
2654
+ videoElement: this.videoElement,
2655
+ isLive: isLiveOnly,
2656
+ isPaused: () => this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true,
2657
+ frameStepSeconds: this.getFrameStepSecondsFromTracks(),
2658
+ onFrameStep: (direction, seconds) => {
2659
+ const player = this.currentPlayer ?? this.playerManager.getCurrentPlayer();
2660
+ const playerName = player?.capability?.shortname ?? this._currentPlayerInfo?.shortname ?? 'unknown';
2661
+ const hasFrameStep = typeof player?.frameStep === 'function';
2662
+ this.log(`[interaction] frameStep dir=${direction} player=${playerName} hasFrameStep=${hasFrameStep}`);
2663
+ if (playerName === 'webcodecs') {
2664
+ this.suppressPlayPauseEvents(250);
2665
+ }
2666
+ if (hasFrameStep && player) {
2667
+ player.frameStep(direction, seconds);
2668
+ return true;
2669
+ }
2670
+ return false;
2671
+ },
2672
+ onPlayPause: () => this.togglePlay(),
2673
+ onSeek: (delta) => {
2674
+ // End any speed hold before seeking
2675
+ if (this._isHoldingSpeed) {
2676
+ this._isHoldingSpeed = false;
2677
+ this.emit('holdSpeedEnd', undefined as never);
2678
+ }
2679
+ this.seekBy(delta);
2680
+ // Emit skip events
2681
+ if (delta > 0) {
2682
+ this.emit('skipForward', { seconds: delta });
2683
+ } else {
2684
+ this.emit('skipBackward', { seconds: Math.abs(delta) });
2685
+ }
2686
+ },
2687
+ onVolumeChange: (delta) => {
2688
+ if (this.videoElement) {
2689
+ const newVolume = Math.max(0, Math.min(1, this.videoElement.volume + delta));
2690
+ this.videoElement.volume = newVolume;
2691
+ this.emit('volumeChange', { volume: newVolume, muted: this.videoElement.muted });
2692
+ }
2693
+ },
2694
+ onMuteToggle: () => this.toggleMute(),
2695
+ onFullscreenToggle: () => this.toggleFullscreen(),
2696
+ onCaptionsToggle: () => {
2697
+ this.toggleSubtitles();
2698
+ },
2699
+ onSpeedChange: (speed, isHolding) => {
2700
+ const wasHolding = this._isHoldingSpeed;
2701
+ this._isHoldingSpeed = isHolding;
2702
+ this._holdSpeed = speed;
2703
+ this.setPlaybackRate(speed);
2704
+
2705
+ // Emit holdSpeed events on state transitions
2706
+ if (isHolding && !wasHolding) {
2707
+ this.emit('holdSpeedStart', { speed });
2708
+ } else if (!isHolding && wasHolding) {
2709
+ this.emit('holdSpeedEnd', undefined as never);
2710
+ }
2711
+ },
2712
+ onSeekPercent: (percent) => this.seekPercent(percent),
2713
+ speedHoldValue: this._holdSpeed,
2714
+ });
2715
+
2716
+ this.interactionController.attach();
2717
+ this.cleanupFns.push(() => {
2718
+ this.interactionController?.detach();
2719
+ this.interactionController = null;
2720
+ });
2721
+ }
2722
+
2723
+ private initializeMistReporter(): void {
2724
+ if (!this.streamStateClient) return;
2725
+
2726
+ const socket = this.streamStateClient.getSocket();
2727
+ if (!socket) return;
2728
+
2729
+ this.mistReporter = new MistReporter({
2730
+ socket,
2731
+ bootMs: this.bootMs,
2732
+ reportInterval: 5000,
2733
+ });
2734
+
2735
+ // Initialize with video element
2736
+ if (this.videoElement) {
2737
+ this.mistReporter.init(this.videoElement, this.container ?? undefined);
2738
+ }
2739
+
2740
+ // Send initial report
2741
+ if (this._currentSourceInfo) {
2742
+ this.mistReporter.sendInitialReport({
2743
+ player: this._currentPlayerInfo?.shortname || 'unknown',
2744
+ sourceType: this._currentSourceInfo.type,
2745
+ sourceUrl: this._currentSourceInfo.url,
2746
+ pageUrl: typeof window !== 'undefined' ? window.location.href : '',
2747
+ });
2748
+ }
2749
+
2750
+ this.cleanupFns.push(() => {
2751
+ this.mistReporter?.sendFinalReport('unmount');
2752
+ this.mistReporter?.destroy();
2753
+ this.mistReporter = null;
2754
+ });
2755
+ }
2756
+
2757
+ private initializeMetaTrackManager(): void {
2758
+ const mistUrl = this.endpoints?.primary?.baseUrl;
2759
+ if (!mistUrl) return;
2760
+
2761
+ this.metaTrackManager = new MetaTrackManager({
2762
+ mistBaseUrl: mistUrl,
2763
+ streamName: this.config.contentId,
2764
+ debug: this.config.debug,
2765
+ });
2766
+
2767
+ this.metaTrackManager.connect();
2768
+
2769
+ // Wire video timeupdate to MetaTrackManager
2770
+ if (this.videoElement) {
2771
+ const handleTimeUpdate = () => {
2772
+ if (this.videoElement && this.metaTrackManager) {
2773
+ this.metaTrackManager.setPlaybackTime(this.videoElement.currentTime);
2774
+ }
2775
+ };
2776
+ const handleSeeking = () => {
2777
+ if (this.videoElement && this.metaTrackManager) {
2778
+ this.metaTrackManager.onSeek(this.videoElement.currentTime);
2779
+ }
2780
+ };
2781
+
2782
+ this.videoElement.addEventListener('timeupdate', handleTimeUpdate);
2783
+ this.videoElement.addEventListener('seeking', handleSeeking);
2784
+
2785
+ this.cleanupFns.push(() => {
2786
+ this.videoElement?.removeEventListener('timeupdate', handleTimeUpdate);
2787
+ this.videoElement?.removeEventListener('seeking', handleSeeking);
2788
+ });
2789
+ }
2790
+
2791
+ this.cleanupFns.push(() => {
2792
+ this.metaTrackManager?.disconnect();
2793
+ this.metaTrackManager = null;
2794
+ });
2795
+ }
2796
+
2797
+ private cleanup(): void {
2798
+ // Run all cleanup functions
2799
+ this.cleanupFns.forEach((fn) => {
2800
+ try {
2801
+ fn();
2802
+ } catch {}
2803
+ });
2804
+ this.cleanupFns = [];
2805
+
2806
+ // Destroy player manager's current player
2807
+ try {
2808
+ this.playerManager.destroy();
2809
+ } catch {}
2810
+ }
2811
+
2812
+ private setState(state: PlayerState, context?: PlayerStateContext): void {
2813
+ this.state = state;
2814
+
2815
+ // Only emit if state actually changed
2816
+ if (this.lastEmittedState !== state) {
2817
+ this.lastEmittedState = state;
2818
+ this.emit('stateChange', { state, context });
2819
+ }
2820
+ }
2821
+
2822
+ private log(message: string): void {
2823
+ if (this.config.debug) {
2824
+ console.log(`[PlayerController] ${message}`);
2825
+ }
2826
+ }
2827
+ }
2828
+
2829
+ export default PlayerController;