@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,499 @@
1
+ /**
2
+ * ErrorClassifier - Centralized error classification and recovery orchestration
3
+ *
4
+ * Implements a 4-tier error handling system:
5
+ * - Tier 1 (TRANSIENT): Silent retry with exponential backoff
6
+ * - Tier 2 (RECOVERABLE): Protocol/player swap with toast notification
7
+ * - Tier 3 (DEGRADED): Quality drop with informational toast
8
+ * - Tier 4 (FATAL): Blocking error modal
9
+ */
10
+
11
+ import {
12
+ ErrorSeverity,
13
+ ErrorCode,
14
+ type ClassifiedError,
15
+ type ErrorHandlingEvents,
16
+ } from "./PlayerInterface";
17
+
18
+ /** Retry configuration for each error code */
19
+ interface RetryConfig {
20
+ maxAttempts: number;
21
+ baseDelayMs: number;
22
+ maxDelayMs: number;
23
+ jitterPercent: number;
24
+ }
25
+
26
+ /** Default retry configurations by error code */
27
+ const RETRY_CONFIGS: Record<ErrorCode, RetryConfig> = {
28
+ // Tier 1: Silent recovery
29
+ [ErrorCode.NETWORK_TIMEOUT]: {
30
+ maxAttempts: 3,
31
+ baseDelayMs: 500,
32
+ maxDelayMs: 4000,
33
+ jitterPercent: 20,
34
+ },
35
+ [ErrorCode.WEBSOCKET_DISCONNECT]: {
36
+ maxAttempts: 5,
37
+ baseDelayMs: 500,
38
+ maxDelayMs: 5000,
39
+ jitterPercent: 20,
40
+ },
41
+ [ErrorCode.SEGMENT_LOAD_ERROR]: {
42
+ maxAttempts: 3,
43
+ baseDelayMs: 200,
44
+ maxDelayMs: 2000,
45
+ jitterPercent: 10,
46
+ },
47
+ [ErrorCode.ICE_DISCONNECTED]: {
48
+ maxAttempts: 3,
49
+ baseDelayMs: 500,
50
+ maxDelayMs: 3000,
51
+ jitterPercent: 20,
52
+ },
53
+ [ErrorCode.BUFFER_UNDERRUN]: {
54
+ maxAttempts: 1,
55
+ baseDelayMs: 5000,
56
+ maxDelayMs: 5000,
57
+ jitterPercent: 0,
58
+ },
59
+ [ErrorCode.CODEC_DECODE_ERROR]: {
60
+ maxAttempts: 3,
61
+ baseDelayMs: 100,
62
+ maxDelayMs: 1000,
63
+ jitterPercent: 10,
64
+ },
65
+
66
+ // Tier 2: Protocol swap (no internal retry, just count for tracking)
67
+ [ErrorCode.PROTOCOL_UNSUPPORTED]: {
68
+ maxAttempts: 1,
69
+ baseDelayMs: 0,
70
+ maxDelayMs: 0,
71
+ jitterPercent: 0,
72
+ },
73
+ [ErrorCode.CODEC_INCOMPATIBLE]: {
74
+ maxAttempts: 1,
75
+ baseDelayMs: 0,
76
+ maxDelayMs: 0,
77
+ jitterPercent: 0,
78
+ },
79
+ [ErrorCode.ICE_FAILED]: { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
80
+ [ErrorCode.MANIFEST_STALE]: { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
81
+ [ErrorCode.PLAYER_INIT_FAILED]: {
82
+ maxAttempts: 1,
83
+ baseDelayMs: 0,
84
+ maxDelayMs: 0,
85
+ jitterPercent: 0,
86
+ },
87
+
88
+ // Tier 3: Quality (not retried, just tracked)
89
+ [ErrorCode.QUALITY_DROPPED]: { maxAttempts: 0, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
90
+ [ErrorCode.BANDWIDTH_LIMITED]: {
91
+ maxAttempts: 0,
92
+ baseDelayMs: 0,
93
+ maxDelayMs: 0,
94
+ jitterPercent: 0,
95
+ },
96
+
97
+ // Tier 4: Fatal (not retried)
98
+ [ErrorCode.ALL_PROTOCOLS_EXHAUSTED]: {
99
+ maxAttempts: 0,
100
+ baseDelayMs: 0,
101
+ maxDelayMs: 0,
102
+ jitterPercent: 0,
103
+ },
104
+ [ErrorCode.ALL_PROTOCOLS_BLACKLISTED]: {
105
+ maxAttempts: 0,
106
+ baseDelayMs: 0,
107
+ maxDelayMs: 0,
108
+ jitterPercent: 0,
109
+ },
110
+ [ErrorCode.STREAM_OFFLINE]: { maxAttempts: 0, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
111
+ [ErrorCode.AUTH_REQUIRED]: { maxAttempts: 0, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
112
+ [ErrorCode.GEO_BLOCKED]: { maxAttempts: 0, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
113
+ [ErrorCode.DRM_ERROR]: { maxAttempts: 0, baseDelayMs: 0, maxDelayMs: 0, jitterPercent: 0 },
114
+ [ErrorCode.CONTENT_UNAVAILABLE]: {
115
+ maxAttempts: 0,
116
+ baseDelayMs: 0,
117
+ maxDelayMs: 0,
118
+ jitterPercent: 0,
119
+ },
120
+ [ErrorCode.UNKNOWN]: { maxAttempts: 1, baseDelayMs: 1000, maxDelayMs: 1000, jitterPercent: 0 },
121
+ };
122
+
123
+ /** Maps error codes to their default severity */
124
+ const CODE_TO_SEVERITY: Record<ErrorCode, ErrorSeverity> = {
125
+ // Tier 1
126
+ [ErrorCode.NETWORK_TIMEOUT]: ErrorSeverity.TRANSIENT,
127
+ [ErrorCode.WEBSOCKET_DISCONNECT]: ErrorSeverity.TRANSIENT,
128
+ [ErrorCode.SEGMENT_LOAD_ERROR]: ErrorSeverity.TRANSIENT,
129
+ [ErrorCode.ICE_DISCONNECTED]: ErrorSeverity.TRANSIENT,
130
+ [ErrorCode.BUFFER_UNDERRUN]: ErrorSeverity.TRANSIENT,
131
+ [ErrorCode.CODEC_DECODE_ERROR]: ErrorSeverity.TRANSIENT,
132
+
133
+ // Tier 2
134
+ [ErrorCode.PROTOCOL_UNSUPPORTED]: ErrorSeverity.RECOVERABLE,
135
+ [ErrorCode.CODEC_INCOMPATIBLE]: ErrorSeverity.RECOVERABLE,
136
+ [ErrorCode.ICE_FAILED]: ErrorSeverity.RECOVERABLE,
137
+ [ErrorCode.MANIFEST_STALE]: ErrorSeverity.RECOVERABLE,
138
+ [ErrorCode.PLAYER_INIT_FAILED]: ErrorSeverity.RECOVERABLE,
139
+
140
+ // Tier 3
141
+ [ErrorCode.QUALITY_DROPPED]: ErrorSeverity.DEGRADED,
142
+ [ErrorCode.BANDWIDTH_LIMITED]: ErrorSeverity.DEGRADED,
143
+
144
+ // Tier 4
145
+ [ErrorCode.ALL_PROTOCOLS_EXHAUSTED]: ErrorSeverity.FATAL,
146
+ [ErrorCode.ALL_PROTOCOLS_BLACKLISTED]: ErrorSeverity.FATAL,
147
+ [ErrorCode.STREAM_OFFLINE]: ErrorSeverity.FATAL,
148
+ [ErrorCode.AUTH_REQUIRED]: ErrorSeverity.FATAL,
149
+ [ErrorCode.GEO_BLOCKED]: ErrorSeverity.FATAL,
150
+ [ErrorCode.DRM_ERROR]: ErrorSeverity.FATAL,
151
+ [ErrorCode.CONTENT_UNAVAILABLE]: ErrorSeverity.FATAL,
152
+ [ErrorCode.UNKNOWN]: ErrorSeverity.FATAL,
153
+ };
154
+
155
+ /** User-friendly messages for each error code */
156
+ const CODE_TO_MESSAGE: Record<ErrorCode, string> = {
157
+ [ErrorCode.NETWORK_TIMEOUT]: "Network timeout",
158
+ [ErrorCode.WEBSOCKET_DISCONNECT]: "Connection lost",
159
+ [ErrorCode.SEGMENT_LOAD_ERROR]: "Failed to load video segment",
160
+ [ErrorCode.ICE_DISCONNECTED]: "Connection interrupted",
161
+ [ErrorCode.BUFFER_UNDERRUN]: "Buffering",
162
+ [ErrorCode.CODEC_DECODE_ERROR]: "Decode error",
163
+ [ErrorCode.PROTOCOL_UNSUPPORTED]: "Protocol not supported",
164
+ [ErrorCode.CODEC_INCOMPATIBLE]: "Codec not supported",
165
+ [ErrorCode.ICE_FAILED]: "Connection failed",
166
+ [ErrorCode.MANIFEST_STALE]: "Stream manifest outdated",
167
+ [ErrorCode.PLAYER_INIT_FAILED]: "Player initialization failed",
168
+ [ErrorCode.QUALITY_DROPPED]: "Quality reduced",
169
+ [ErrorCode.BANDWIDTH_LIMITED]: "Bandwidth limited",
170
+ [ErrorCode.ALL_PROTOCOLS_EXHAUSTED]: "Unable to play video",
171
+ [ErrorCode.ALL_PROTOCOLS_BLACKLISTED]: "No compatible playback protocols available",
172
+ [ErrorCode.STREAM_OFFLINE]: "Stream is offline",
173
+ [ErrorCode.AUTH_REQUIRED]: "Sign in to watch",
174
+ [ErrorCode.GEO_BLOCKED]: "Not available in your region",
175
+ [ErrorCode.DRM_ERROR]: "Playback not supported",
176
+ [ErrorCode.CONTENT_UNAVAILABLE]: "Content unavailable",
177
+ [ErrorCode.UNKNOWN]: "Playback error",
178
+ };
179
+
180
+ type EventListener<K extends keyof ErrorHandlingEvents> = (data: ErrorHandlingEvents[K]) => void;
181
+
182
+ export type RecoveryAction =
183
+ | { type: "retry"; delayMs: number }
184
+ | { type: "swap"; reason: string }
185
+ | { type: "toast"; message: string }
186
+ | { type: "fatal"; error: ClassifiedError };
187
+
188
+ export interface ErrorClassifierOptions {
189
+ /** Number of alternative player/protocol combos available */
190
+ alternativesCount?: number;
191
+ /** Enable debug logging */
192
+ debug?: boolean;
193
+ }
194
+
195
+ /**
196
+ * Centralized error classifier that tracks retry state and determines recovery actions.
197
+ */
198
+ export class ErrorClassifier {
199
+ private retryCounts: Map<ErrorCode, number> = new Map();
200
+ private lastErrorTime: Map<ErrorCode, number> = new Map();
201
+ private alternativesRemaining: number;
202
+ private listeners: Map<string, Set<Function>> = new Map();
203
+ private debug: boolean;
204
+
205
+ // Debounce tracking for quality toasts
206
+ private lastQualityToastTime = 0;
207
+ private static readonly QUALITY_TOAST_DEBOUNCE_MS = 10000;
208
+
209
+ constructor(options: ErrorClassifierOptions = {}) {
210
+ this.alternativesRemaining = options.alternativesCount ?? 0;
211
+ this.debug = options.debug ?? false;
212
+ }
213
+
214
+ /**
215
+ * Update the count of remaining alternatives (called after a swap attempt)
216
+ */
217
+ setAlternativesRemaining(count: number): void {
218
+ this.alternativesRemaining = count;
219
+ }
220
+
221
+ /**
222
+ * Reset retry counts (call when playback successfully resumes)
223
+ */
224
+ reset(): void {
225
+ this.retryCounts.clear();
226
+ this.lastErrorTime.clear();
227
+ this.lastQualityToastTime = 0;
228
+ this.log("Error state reset");
229
+ }
230
+
231
+ /**
232
+ * Classify a raw error and determine the appropriate recovery action.
233
+ *
234
+ * @param code - Error code identifying the error type
235
+ * @param originalError - Original error object or message
236
+ * @returns The recovery action to take
237
+ */
238
+ classify(code: ErrorCode, originalError?: Error | string): RecoveryAction {
239
+ const config = RETRY_CONFIGS[code];
240
+ const currentAttempt = (this.retryCounts.get(code) ?? 0) + 1;
241
+ const retriesRemaining = Math.max(0, config.maxAttempts - currentAttempt);
242
+
243
+ // Update retry count
244
+ this.retryCounts.set(code, currentAttempt);
245
+ this.lastErrorTime.set(code, Date.now());
246
+
247
+ const classified: ClassifiedError = {
248
+ severity: CODE_TO_SEVERITY[code],
249
+ code,
250
+ message: CODE_TO_MESSAGE[code],
251
+ retriesRemaining,
252
+ alternativesRemaining: this.alternativesRemaining,
253
+ originalError,
254
+ timestamp: Date.now(),
255
+ };
256
+
257
+ this.log(
258
+ `Classified error: ${code}, attempt ${currentAttempt}/${config.maxAttempts}, severity ${classified.severity}`
259
+ );
260
+
261
+ // Tier 1: Silent retry if retries remaining
262
+ if (classified.severity === ErrorSeverity.TRANSIENT && retriesRemaining > 0) {
263
+ const delayMs = this.calculateBackoff(code, currentAttempt);
264
+ this.emit("recoveryAttempted", {
265
+ code,
266
+ attempt: currentAttempt,
267
+ maxAttempts: config.maxAttempts,
268
+ });
269
+ return { type: "retry", delayMs };
270
+ }
271
+
272
+ // Tier 1 exhausted or Tier 2: Try protocol swap if alternatives exist
273
+ if (
274
+ (classified.severity === ErrorSeverity.TRANSIENT && retriesRemaining === 0) ||
275
+ classified.severity === ErrorSeverity.RECOVERABLE
276
+ ) {
277
+ if (this.alternativesRemaining > 0) {
278
+ return { type: "swap", reason: classified.message };
279
+ }
280
+ // No alternatives: escalate to fatal
281
+ const originalCode = classified.code;
282
+ const originalMessage = classified.message;
283
+ classified.severity = ErrorSeverity.FATAL;
284
+ classified.code = ErrorCode.ALL_PROTOCOLS_EXHAUSTED;
285
+ classified.message = `${originalMessage} (no alternatives remaining)`;
286
+ classified.details = {
287
+ ...classified.details,
288
+ originalCode,
289
+ originalMessage,
290
+ };
291
+ }
292
+
293
+ // Tier 3: Quality degradation toast (debounced)
294
+ if (classified.severity === ErrorSeverity.DEGRADED) {
295
+ const now = Date.now();
296
+ if (now - this.lastQualityToastTime >= ErrorClassifier.QUALITY_TOAST_DEBOUNCE_MS) {
297
+ this.lastQualityToastTime = now;
298
+ this.emit("qualityChanged", {
299
+ direction: "down",
300
+ reason: classified.message,
301
+ });
302
+ return { type: "toast", message: classified.message };
303
+ }
304
+ // Debounced: no action needed
305
+ return { type: "toast", message: "" };
306
+ }
307
+
308
+ // Tier 4: Fatal error
309
+ this.emit("playbackFailed", classified);
310
+ return { type: "fatal", error: classified };
311
+ }
312
+
313
+ classifyWithDetails(
314
+ code: ErrorCode,
315
+ message: string,
316
+ details?: ClassifiedError["details"],
317
+ originalError?: Error | string
318
+ ): RecoveryAction {
319
+ if (CODE_TO_SEVERITY[code] === ErrorSeverity.FATAL) {
320
+ const classified: ClassifiedError = {
321
+ severity: ErrorSeverity.FATAL,
322
+ code,
323
+ message,
324
+ retriesRemaining: 0,
325
+ alternativesRemaining: this.alternativesRemaining,
326
+ originalError,
327
+ timestamp: Date.now(),
328
+ details,
329
+ };
330
+ this.emit("playbackFailed", classified);
331
+ return { type: "fatal", error: classified };
332
+ }
333
+
334
+ const action = this.classify(code, originalError);
335
+ if (action.type === "fatal") {
336
+ action.error.message = message;
337
+ action.error.details = details;
338
+ }
339
+ return action;
340
+ }
341
+
342
+ /**
343
+ * Notify classifier that a protocol swap occurred (for event emission)
344
+ */
345
+ notifyProtocolSwap(
346
+ fromPlayer: string,
347
+ toPlayer: string,
348
+ fromProtocol: string,
349
+ toProtocol: string,
350
+ reason: string
351
+ ): void {
352
+ // Note: alternativesRemaining is managed by PlayerManager.setAlternativesRemaining()
353
+ // Don't decrement here to avoid double-counting
354
+ this.emit("protocolSwapped", {
355
+ fromPlayer,
356
+ toPlayer,
357
+ fromProtocol,
358
+ toProtocol,
359
+ reason,
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Calculate exponential backoff delay with jitter
365
+ */
366
+ private calculateBackoff(code: ErrorCode, attempt: number): number {
367
+ const config = RETRY_CONFIGS[code];
368
+ const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt - 1);
369
+ const cappedDelay = Math.min(exponentialDelay, config.maxDelayMs);
370
+
371
+ // Add jitter
372
+ const jitterRange = cappedDelay * (config.jitterPercent / 100);
373
+ const jitter = (Math.random() * 2 - 1) * jitterRange;
374
+
375
+ return Math.round(cappedDelay + jitter);
376
+ }
377
+
378
+ /**
379
+ * Map common error patterns to error codes
380
+ */
381
+ static mapErrorToCode(error: Error | string): ErrorCode {
382
+ const message = typeof error === "string" ? error : error.message;
383
+ const lowerMessage = message.toLowerCase();
384
+
385
+ // Network errors
386
+ if (lowerMessage.includes("timeout") || lowerMessage.includes("timed out")) {
387
+ return ErrorCode.NETWORK_TIMEOUT;
388
+ }
389
+ if (lowerMessage.includes("websocket") || lowerMessage.includes("socket")) {
390
+ return ErrorCode.WEBSOCKET_DISCONNECT;
391
+ }
392
+ if (lowerMessage.includes("fetch") || lowerMessage.includes("network")) {
393
+ return ErrorCode.NETWORK_TIMEOUT;
394
+ }
395
+
396
+ // Stream state - check before segment errors (404 can mean offline)
397
+ if (
398
+ lowerMessage.includes("offline") ||
399
+ lowerMessage.includes("not found") ||
400
+ lowerMessage.includes("stream not found")
401
+ ) {
402
+ return ErrorCode.STREAM_OFFLINE;
403
+ }
404
+
405
+ // Segment/manifest errors (only if not a stream-level 404)
406
+ if (lowerMessage.includes("segment")) {
407
+ return ErrorCode.SEGMENT_LOAD_ERROR;
408
+ }
409
+ if (lowerMessage.includes("manifest") || lowerMessage.includes("playlist")) {
410
+ // Manifest 404 = stream offline, not stale
411
+ if (lowerMessage.includes("404")) {
412
+ return ErrorCode.STREAM_OFFLINE;
413
+ }
414
+ return ErrorCode.MANIFEST_STALE;
415
+ }
416
+
417
+ // ICE/WebRTC errors
418
+ if (lowerMessage.includes("ice") && lowerMessage.includes("disconnect")) {
419
+ return ErrorCode.ICE_DISCONNECTED;
420
+ }
421
+ if (lowerMessage.includes("ice") && lowerMessage.includes("fail")) {
422
+ return ErrorCode.ICE_FAILED;
423
+ }
424
+
425
+ // Codec errors
426
+ if (lowerMessage.includes("codec") || lowerMessage.includes("decode")) {
427
+ return ErrorCode.CODEC_DECODE_ERROR;
428
+ }
429
+ if (lowerMessage.includes("not supported") || lowerMessage.includes("unsupported")) {
430
+ return ErrorCode.PROTOCOL_UNSUPPORTED;
431
+ }
432
+
433
+ // Buffer errors
434
+ if (lowerMessage.includes("buffer") || lowerMessage.includes("underrun")) {
435
+ return ErrorCode.BUFFER_UNDERRUN;
436
+ }
437
+
438
+ // Auth errors
439
+ if (
440
+ lowerMessage.includes("401") ||
441
+ lowerMessage.includes("auth") ||
442
+ lowerMessage.includes("unauthorized")
443
+ ) {
444
+ return ErrorCode.AUTH_REQUIRED;
445
+ }
446
+ if (
447
+ lowerMessage.includes("403") ||
448
+ lowerMessage.includes("forbidden") ||
449
+ lowerMessage.includes("geo")
450
+ ) {
451
+ return ErrorCode.GEO_BLOCKED;
452
+ }
453
+
454
+ // DRM
455
+ if (
456
+ lowerMessage.includes("drm") ||
457
+ lowerMessage.includes("eme") ||
458
+ lowerMessage.includes("key")
459
+ ) {
460
+ return ErrorCode.DRM_ERROR;
461
+ }
462
+
463
+ return ErrorCode.UNKNOWN;
464
+ }
465
+
466
+ // Event emitter methods
467
+ on<K extends keyof ErrorHandlingEvents>(event: K, listener: EventListener<K>): void {
468
+ if (!this.listeners.has(event)) {
469
+ this.listeners.set(event, new Set());
470
+ }
471
+ this.listeners.get(event)!.add(listener);
472
+ }
473
+
474
+ off<K extends keyof ErrorHandlingEvents>(event: K, listener: EventListener<K>): void {
475
+ const eventListeners = this.listeners.get(event);
476
+ if (eventListeners) {
477
+ eventListeners.delete(listener);
478
+ }
479
+ }
480
+
481
+ private emit<K extends keyof ErrorHandlingEvents>(event: K, data: ErrorHandlingEvents[K]): void {
482
+ const eventListeners = this.listeners.get(event);
483
+ if (eventListeners) {
484
+ eventListeners.forEach((listener) => {
485
+ try {
486
+ (listener as EventListener<K>)(data);
487
+ } catch (e) {
488
+ console.error(`Error in ${event} listener:`, e);
489
+ }
490
+ });
491
+ }
492
+ }
493
+
494
+ private log(message: string): void {
495
+ if (this.debug) {
496
+ console.log(`[ErrorClassifier] ${message}`);
497
+ }
498
+ }
499
+ }
@@ -11,7 +11,7 @@
11
11
  import { TypedEventEmitter } from "./EventEmitter";
12
12
  import { GatewayClient } from "./GatewayClient";
13
13
  import { StreamStateClient } from "./StreamStateClient";
14
- import { PlayerManager } from "./PlayerManager";
14
+ import type { PlayerManager, PlayerManagerEvents } from "./PlayerManager";
15
15
  import { globalPlayerManager, ensurePlayersRegistered } from "./PlayerRegistry";
16
16
  import { ABRController } from "./ABRController";
17
17
  import { InteractionController } from "./InteractionController";
@@ -186,6 +186,15 @@ export interface PlayerControllerEvents {
186
186
  volume: number;
187
187
  muted: boolean;
188
188
  };
189
+
190
+ // ============================================================================
191
+ // Error Handling Events (from PlayerManager)
192
+ // ============================================================================
193
+
194
+ /** Protocol/player swap occurred - show toast */
195
+ protocolSwapped: PlayerManagerEvents["protocolSwapped"];
196
+ /** Playback failed after all recovery attempts - show error modal */
197
+ playbackFailed: PlayerManagerEvents["playbackFailed"];
189
198
  }
190
199
 
191
200
  // ============================================================================
@@ -642,6 +651,10 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
642
651
  this.config = config;
643
652
  this.playerManager = config.playerManager || globalPlayerManager;
644
653
 
654
+ // Forward error handling events from PlayerManager
655
+ this.playerManager.on("protocolSwapped", (data) => this.emit("protocolSwapped", data));
656
+ this.playerManager.on("playbackFailed", (data) => this.emit("playbackFailed", data));
657
+
645
658
  // Load loop state from localStorage
646
659
  try {
647
660
  if (typeof localStorage !== "undefined") {
@@ -2252,7 +2265,9 @@ export class PlayerController extends TypedEventEmitter<PlayerControllerEvents>
2252
2265
  this.setState("gateway_loading", { gatewayStatus: "loading" });
2253
2266
 
2254
2267
  try {
2255
- const jsonUrl = `${mistUrl.replace(/\/+$/, "")}/json_${encodeURIComponent(contentId)}.js`;
2268
+ let baseUrl = mistUrl;
2269
+ while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
2270
+ const jsonUrl = `${baseUrl}/json_${encodeURIComponent(contentId)}.js`;
2256
2271
  this.log(`[resolveFromMistServer] Fetching ${jsonUrl}`);
2257
2272
 
2258
2273
  const response = await fetch(jsonUrl, { cache: "no-store" });
@@ -93,6 +93,115 @@ export interface PlayerEvents {
93
93
  seekablechange: { start: number; end: number; bufferWindow: number };
94
94
  }
95
95
 
96
+ /**
97
+ * Error severity levels for the tiered error handling system.
98
+ *
99
+ * Tier 1 (TRANSIENT): Silent retry, no UI - network timeouts, brief stalls
100
+ * Tier 2 (RECOVERABLE): Protocol swap with toast - alternatives exist
101
+ * Tier 3 (DEGRADED): Quality drop with toast - playback continues at lower quality
102
+ * Tier 4 (FATAL): Blocking modal - all options exhausted
103
+ */
104
+ export enum ErrorSeverity {
105
+ /** Transient issues that self-resolve. User never sees UI. */
106
+ TRANSIENT = 1,
107
+ /** Current protocol failed but alternatives exist. Shows toast on swap. */
108
+ RECOVERABLE = 2,
109
+ /** Quality degraded but playback continues. Shows informational toast. */
110
+ DEGRADED = 3,
111
+ /** Cannot continue playback. Shows blocking error modal. */
112
+ FATAL = 4,
113
+ }
114
+
115
+ /**
116
+ * Error codes for classification. Maps to specific recovery strategies.
117
+ */
118
+ export enum ErrorCode {
119
+ // Tier 1: Silent recovery
120
+ NETWORK_TIMEOUT = "NETWORK_TIMEOUT",
121
+ WEBSOCKET_DISCONNECT = "WEBSOCKET_DISCONNECT",
122
+ SEGMENT_LOAD_ERROR = "SEGMENT_LOAD_ERROR",
123
+ ICE_DISCONNECTED = "ICE_DISCONNECTED",
124
+ BUFFER_UNDERRUN = "BUFFER_UNDERRUN",
125
+ CODEC_DECODE_ERROR = "CODEC_DECODE_ERROR",
126
+
127
+ // Tier 2: Protocol swap
128
+ PROTOCOL_UNSUPPORTED = "PROTOCOL_UNSUPPORTED",
129
+ CODEC_INCOMPATIBLE = "CODEC_INCOMPATIBLE",
130
+ ICE_FAILED = "ICE_FAILED",
131
+ MANIFEST_STALE = "MANIFEST_STALE",
132
+ PLAYER_INIT_FAILED = "PLAYER_INIT_FAILED",
133
+
134
+ // Tier 3: Quality degraded
135
+ QUALITY_DROPPED = "QUALITY_DROPPED",
136
+ BANDWIDTH_LIMITED = "BANDWIDTH_LIMITED",
137
+
138
+ // Tier 4: Fatal
139
+ ALL_PROTOCOLS_EXHAUSTED = "ALL_PROTOCOLS_EXHAUSTED",
140
+ ALL_PROTOCOLS_BLACKLISTED = "ALL_PROTOCOLS_BLACKLISTED",
141
+ STREAM_OFFLINE = "STREAM_OFFLINE",
142
+ AUTH_REQUIRED = "AUTH_REQUIRED",
143
+ GEO_BLOCKED = "GEO_BLOCKED",
144
+ DRM_ERROR = "DRM_ERROR",
145
+ CONTENT_UNAVAILABLE = "CONTENT_UNAVAILABLE",
146
+ UNKNOWN = "UNKNOWN",
147
+ }
148
+
149
+ /**
150
+ * Classified error with severity and recovery metadata.
151
+ * Used by ErrorClassifier to track retry state and decide next action.
152
+ */
153
+ export interface ClassifiedError {
154
+ /** Severity tier determining UI behavior */
155
+ severity: ErrorSeverity;
156
+ /** Specific error code for recovery strategy lookup */
157
+ code: ErrorCode;
158
+ /** Human-readable error message */
159
+ message: string;
160
+ /** Number of retries remaining for this error type */
161
+ retriesRemaining: number;
162
+ /** Number of alternative protocols/players remaining */
163
+ alternativesRemaining: number;
164
+ /** Original error if available */
165
+ originalError?: Error | string;
166
+ /** Timestamp when error occurred */
167
+ timestamp: number;
168
+ /** Diagnostic details for operators/debugging */
169
+ details?: {
170
+ incompatibilityReasons?: string[];
171
+ blacklistedProtocols?: string[];
172
+ originalCode?: ErrorCode;
173
+ originalMessage?: string;
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Events emitted by the error handling system.
179
+ * UI layers listen to these for toast/modal display.
180
+ */
181
+ export interface ErrorHandlingEvents {
182
+ /** Silent recovery attempted (Tier 1) - for telemetry only */
183
+ recoveryAttempted: {
184
+ code: ErrorCode;
185
+ attempt: number;
186
+ maxAttempts: number;
187
+ };
188
+ /** Protocol or player swapped (Tier 2) - shows toast */
189
+ protocolSwapped: {
190
+ fromPlayer: string;
191
+ toPlayer: string;
192
+ fromProtocol: string;
193
+ toProtocol: string;
194
+ reason: string;
195
+ };
196
+ /** Quality changed (Tier 3) - shows toast */
197
+ qualityChanged: {
198
+ direction: "up" | "down";
199
+ reason: string;
200
+ };
201
+ /** All recovery options exhausted (Tier 4) - shows modal */
202
+ playbackFailed: ClassifiedError;
203
+ }
204
+
96
205
  /**
97
206
  * Base interface all players must implement
98
207
  */