@livepeer-frameworks/player-core 0.1.1 → 0.1.2

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