@livepeer-frameworks/player-core 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cjs/index.js +19493 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +19398 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/player.css +2140 -0
  6. package/dist/types/core/ABRController.d.ts +164 -0
  7. package/dist/types/core/CodecUtils.d.ts +54 -0
  8. package/dist/types/core/Disposable.d.ts +61 -0
  9. package/dist/types/core/EventEmitter.d.ts +73 -0
  10. package/dist/types/core/GatewayClient.d.ts +144 -0
  11. package/dist/types/core/InteractionController.d.ts +121 -0
  12. package/dist/types/core/LiveDurationProxy.d.ts +102 -0
  13. package/dist/types/core/MetaTrackManager.d.ts +220 -0
  14. package/dist/types/core/MistReporter.d.ts +163 -0
  15. package/dist/types/core/MistSignaling.d.ts +148 -0
  16. package/dist/types/core/PlayerController.d.ts +665 -0
  17. package/dist/types/core/PlayerInterface.d.ts +230 -0
  18. package/dist/types/core/PlayerManager.d.ts +182 -0
  19. package/dist/types/core/PlayerRegistry.d.ts +27 -0
  20. package/dist/types/core/QualityMonitor.d.ts +184 -0
  21. package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
  22. package/dist/types/core/SeekingUtils.d.ts +142 -0
  23. package/dist/types/core/StreamStateClient.d.ts +108 -0
  24. package/dist/types/core/SubtitleManager.d.ts +111 -0
  25. package/dist/types/core/TelemetryReporter.d.ts +79 -0
  26. package/dist/types/core/TimeFormat.d.ts +97 -0
  27. package/dist/types/core/TimerManager.d.ts +83 -0
  28. package/dist/types/core/UrlUtils.d.ts +81 -0
  29. package/dist/types/core/detector.d.ts +149 -0
  30. package/dist/types/core/index.d.ts +49 -0
  31. package/dist/types/core/scorer.d.ts +167 -0
  32. package/dist/types/core/selector.d.ts +9 -0
  33. package/dist/types/index.d.ts +45 -0
  34. package/dist/types/lib/utils.d.ts +2 -0
  35. package/dist/types/players/DashJsPlayer.d.ts +102 -0
  36. package/dist/types/players/HlsJsPlayer.d.ts +70 -0
  37. package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
  38. package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
  39. package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
  40. package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
  41. package/dist/types/players/MistPlayer.d.ts +25 -0
  42. package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
  43. package/dist/types/players/NativePlayer.d.ts +143 -0
  44. package/dist/types/players/VideoJsPlayer.d.ts +59 -0
  45. package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
  46. package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
  47. package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
  48. package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
  49. package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
  50. package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
  51. package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
  52. package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
  53. package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
  54. package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
  55. package/dist/types/players/index.d.ts +14 -0
  56. package/dist/types/styles/index.d.ts +11 -0
  57. package/dist/types/types.d.ts +363 -0
  58. package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
  59. package/dist/types/vanilla/index.d.ts +19 -0
  60. package/dist/workers/decoder.worker.js +989 -0
  61. package/dist/workers/decoder.worker.js.map +1 -0
  62. package/package.json +80 -0
  63. package/src/core/ABRController.ts +550 -0
  64. package/src/core/CodecUtils.ts +257 -0
  65. package/src/core/Disposable.ts +120 -0
  66. package/src/core/EventEmitter.ts +113 -0
  67. package/src/core/GatewayClient.ts +439 -0
  68. package/src/core/InteractionController.ts +712 -0
  69. package/src/core/LiveDurationProxy.ts +270 -0
  70. package/src/core/MetaTrackManager.ts +753 -0
  71. package/src/core/MistReporter.ts +543 -0
  72. package/src/core/MistSignaling.ts +346 -0
  73. package/src/core/PlayerController.ts +2829 -0
  74. package/src/core/PlayerInterface.ts +432 -0
  75. package/src/core/PlayerManager.ts +900 -0
  76. package/src/core/PlayerRegistry.ts +149 -0
  77. package/src/core/QualityMonitor.ts +597 -0
  78. package/src/core/ScreenWakeLockManager.ts +163 -0
  79. package/src/core/SeekingUtils.ts +364 -0
  80. package/src/core/StreamStateClient.ts +457 -0
  81. package/src/core/SubtitleManager.ts +297 -0
  82. package/src/core/TelemetryReporter.ts +308 -0
  83. package/src/core/TimeFormat.ts +205 -0
  84. package/src/core/TimerManager.ts +209 -0
  85. package/src/core/UrlUtils.ts +179 -0
  86. package/src/core/detector.ts +382 -0
  87. package/src/core/index.ts +140 -0
  88. package/src/core/scorer.ts +553 -0
  89. package/src/core/selector.ts +16 -0
  90. package/src/global.d.ts +11 -0
  91. package/src/index.ts +75 -0
  92. package/src/lib/utils.ts +6 -0
  93. package/src/players/DashJsPlayer.ts +642 -0
  94. package/src/players/HlsJsPlayer.ts +483 -0
  95. package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
  96. package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
  97. package/src/players/MewsWsPlayer/index.ts +1065 -0
  98. package/src/players/MewsWsPlayer/types.ts +106 -0
  99. package/src/players/MistPlayer.ts +188 -0
  100. package/src/players/MistWebRTCPlayer/index.ts +703 -0
  101. package/src/players/NativePlayer.ts +820 -0
  102. package/src/players/VideoJsPlayer.ts +643 -0
  103. package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
  104. package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
  105. package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
  106. package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
  107. package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
  108. package/src/players/WebCodecsPlayer/index.ts +1650 -0
  109. package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
  110. package/src/players/WebCodecsPlayer/types.ts +542 -0
  111. package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
  112. package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
  113. package/src/players/index.ts +22 -0
  114. package/src/styles/animations.css +21 -0
  115. package/src/styles/index.ts +52 -0
  116. package/src/styles/player.css +2126 -0
  117. package/src/styles/tailwind.css +1015 -0
  118. package/src/types.ts +421 -0
  119. package/src/vanilla/FrameWorksPlayer.ts +367 -0
  120. package/src/vanilla/index.ts +22 -0
@@ -0,0 +1,597 @@
1
+ import type { PlaybackQuality, QualityThresholds } from '../types';
2
+ import { TimerManager } from './TimerManager';
3
+
4
+ /**
5
+ * Default quality thresholds
6
+ */
7
+ const DEFAULT_THRESHOLDS: QualityThresholds = {
8
+ minScore: 60,
9
+ maxStalls: 3,
10
+ minBuffer: 2,
11
+ };
12
+
13
+ /**
14
+ * Rolling average window size
15
+ */
16
+ const ROLLING_WINDOW_SIZE = 20;
17
+
18
+ /**
19
+ * Playback score history entry (for MistPlayer-style 0-2.0 score)
20
+ */
21
+ interface PlaybackScoreEntry {
22
+ clock: number; // Wall clock time in seconds
23
+ video: number; // Video currentTime
24
+ score: number; // Calculated score for this sample
25
+ }
26
+
27
+ /** Protocol type for threshold selection */
28
+ export type PlayerProtocol = 'webrtc' | 'hls' | 'dash' | 'html5' | 'unknown';
29
+
30
+ /** Protocol-specific playback score thresholds (MistMetaPlayer reference) */
31
+ export const PROTOCOL_THRESHOLDS: Record<PlayerProtocol, number> = {
32
+ webrtc: 0.95, // Very strict for low-latency
33
+ hls: 0.75, // More lenient for adaptive streaming
34
+ dash: 0.75, // More lenient for adaptive streaming
35
+ html5: 0.75, // Standard threshold
36
+ unknown: 0.75, // Default
37
+ };
38
+
39
+ export interface QualityMonitorOptions {
40
+ /** Sample interval in ms */
41
+ sampleInterval?: number;
42
+ /** Quality thresholds */
43
+ thresholds?: Partial<QualityThresholds>;
44
+ /** Callback when quality degrades */
45
+ onQualityDegraded?: (quality: PlaybackQuality) => void;
46
+ /** Callback on every sample */
47
+ onSample?: (quality: PlaybackQuality) => void;
48
+ /** Current player protocol for threshold selection */
49
+ protocol?: PlayerProtocol;
50
+ /** Custom playback score threshold (overrides protocol default) */
51
+ playbackScoreThreshold?: number;
52
+ /**
53
+ * Callback when sustained poor quality triggers a fallback request
54
+ * Reference: player.js:654-665 - "nextCombo" action
55
+ */
56
+ onFallbackRequest?: (reason: { score: number; consecutivePoorSamples: number }) => void;
57
+ /**
58
+ * Number of consecutive poor samples before requesting fallback
59
+ * Default: 5 (2.5 seconds at 500ms sample interval)
60
+ */
61
+ poorSamplesBeforeFallback?: number;
62
+ }
63
+
64
+ export interface QualityMonitorState {
65
+ isMonitoring: boolean;
66
+ quality: PlaybackQuality | null;
67
+ history: PlaybackQuality[];
68
+ }
69
+
70
+ /**
71
+ * QualityMonitor - Tracks playback quality metrics
72
+ *
73
+ * Monitors:
74
+ * - Buffer health (seconds ahead)
75
+ * - Stall count (waiting events)
76
+ * - Frame drop rate (via video.getVideoPlaybackQuality())
77
+ * - Estimated bitrate
78
+ * - Latency (for live streams)
79
+ *
80
+ * Calculates a composite quality score (0-100) and triggers
81
+ * callbacks when quality degrades below thresholds.
82
+ */
83
+ export class QualityMonitor {
84
+ private videoElement: HTMLVideoElement | null = null;
85
+ private options: Required<Omit<QualityMonitorOptions, 'protocol' | 'playbackScoreThreshold'>> & {
86
+ protocol: PlayerProtocol;
87
+ playbackScoreThreshold: number | null;
88
+ };
89
+ private thresholds: QualityThresholds;
90
+ private timers = new TimerManager();
91
+ private stallCount = 0;
92
+ private lastStallTime = 0;
93
+ private totalStallMs = 0;
94
+ private history: PlaybackQuality[] = [];
95
+ private lastBytesLoaded = 0;
96
+ private lastBytesTime = 0;
97
+ private listeners: Array<() => void> = [];
98
+
99
+ // MistPlayer-style playback score (0-2.0 scale)
100
+ private playbackScoreHistory: PlaybackScoreEntry[] = [];
101
+ private playbackScore = 1.0;
102
+ private readonly PLAYBACK_SCORE_AVERAGING_STEPS = 10;
103
+
104
+ // Automatic fallback tracking
105
+ // Reference: player.js:654-665 - triggers "nextCombo" after sustained poor quality
106
+ private consecutivePoorSamples = 0;
107
+ private fallbackTriggered = false;
108
+
109
+ constructor(options: QualityMonitorOptions = {}) {
110
+ this.options = {
111
+ sampleInterval: options.sampleInterval ?? 500,
112
+ thresholds: options.thresholds ?? {},
113
+ onQualityDegraded: options.onQualityDegraded ?? (() => {}),
114
+ onSample: options.onSample ?? (() => {}),
115
+ protocol: options.protocol ?? 'unknown',
116
+ playbackScoreThreshold: options.playbackScoreThreshold ?? null,
117
+ onFallbackRequest: options.onFallbackRequest ?? (() => {}),
118
+ poorSamplesBeforeFallback: options.poorSamplesBeforeFallback ?? 5,
119
+ };
120
+ this.thresholds = { ...DEFAULT_THRESHOLDS, ...options.thresholds };
121
+ }
122
+
123
+ /**
124
+ * Set the current player protocol for threshold selection
125
+ */
126
+ setProtocol(protocol: PlayerProtocol): void {
127
+ this.options.protocol = protocol;
128
+ }
129
+
130
+ /**
131
+ * Get the current player protocol
132
+ */
133
+ getProtocol(): PlayerProtocol {
134
+ return this.options.protocol;
135
+ }
136
+
137
+ /**
138
+ * Get the playback score threshold for the current protocol
139
+ */
140
+ getPlaybackScoreThreshold(): number {
141
+ // Custom threshold takes precedence
142
+ if (this.options.playbackScoreThreshold !== null) {
143
+ return this.options.playbackScoreThreshold;
144
+ }
145
+ return PROTOCOL_THRESHOLDS[this.options.protocol];
146
+ }
147
+
148
+ /**
149
+ * Set a custom playback score threshold (overrides protocol default)
150
+ */
151
+ setPlaybackScoreThreshold(threshold: number | null): void {
152
+ this.options.playbackScoreThreshold = threshold;
153
+ }
154
+
155
+ /**
156
+ * Start monitoring a video element
157
+ */
158
+ start(videoElement: HTMLVideoElement): void {
159
+ this.stop();
160
+
161
+ this.videoElement = videoElement;
162
+ this.stallCount = 0;
163
+ this.totalStallMs = 0;
164
+ this.lastStallTime = 0;
165
+ this.history = [];
166
+ this.lastBytesLoaded = 0;
167
+ this.lastBytesTime = 0;
168
+ this.consecutivePoorSamples = 0;
169
+ this.fallbackTriggered = false;
170
+ this.playbackScoreHistory = [];
171
+ this.playbackScore = 1.0;
172
+
173
+ // Listen for stall events
174
+ const onWaiting = () => {
175
+ this.stallCount++;
176
+ this.lastStallTime = performance.now();
177
+ };
178
+
179
+ const onPlaying = () => {
180
+ if (this.lastStallTime > 0) {
181
+ this.totalStallMs += performance.now() - this.lastStallTime;
182
+ this.lastStallTime = 0;
183
+ }
184
+ };
185
+
186
+ const onCanPlay = () => {
187
+ if (this.lastStallTime > 0) {
188
+ this.totalStallMs += performance.now() - this.lastStallTime;
189
+ this.lastStallTime = 0;
190
+ }
191
+ };
192
+
193
+ videoElement.addEventListener('waiting', onWaiting);
194
+ videoElement.addEventListener('playing', onPlaying);
195
+ videoElement.addEventListener('canplay', onCanPlay);
196
+
197
+ this.listeners = [
198
+ () => videoElement.removeEventListener('waiting', onWaiting),
199
+ () => videoElement.removeEventListener('playing', onPlaying),
200
+ () => videoElement.removeEventListener('canplay', onCanPlay),
201
+ ];
202
+
203
+ // Start sampling interval
204
+ this.timers.startInterval(() => this.sample(), this.options.sampleInterval, 'sampling');
205
+
206
+ // Take initial sample
207
+ this.sample();
208
+ }
209
+
210
+ /**
211
+ * Stop monitoring
212
+ */
213
+ stop(): void {
214
+ this.timers.destroy();
215
+
216
+ this.listeners.forEach(cleanup => cleanup());
217
+ this.listeners = [];
218
+
219
+ this.videoElement = null;
220
+ }
221
+
222
+ /**
223
+ * Take a quality sample
224
+ */
225
+ private sample(): void {
226
+ const video = this.videoElement;
227
+ if (!video) return;
228
+
229
+ // Update MistPlayer-style playback score
230
+ this.updatePlaybackScore();
231
+
232
+ const quality = this.calculateQuality(video);
233
+ this.history.push(quality);
234
+
235
+ // Keep rolling window
236
+ if (this.history.length > ROLLING_WINDOW_SIZE) {
237
+ this.history.shift();
238
+ }
239
+
240
+ // Notify listeners
241
+ this.options.onSample(quality);
242
+
243
+ // Check for quality degradation
244
+ if (quality.score < this.thresholds.minScore ||
245
+ quality.stallCount > this.thresholds.maxStalls ||
246
+ quality.bufferedAhead < this.thresholds.minBuffer) {
247
+ this.options.onQualityDegraded(quality);
248
+ }
249
+
250
+ // Track sustained poor quality for automatic fallback
251
+ // Reference: player.js:654-665 - "nextCombo" after sustained poor playback
252
+ if (this.isPlaybackPoor()) {
253
+ this.consecutivePoorSamples++;
254
+
255
+ // Trigger fallback after sustained poor quality
256
+ // Only trigger once until quality improves or reset
257
+ if (!this.fallbackTriggered &&
258
+ this.consecutivePoorSamples >= this.options.poorSamplesBeforeFallback) {
259
+ this.fallbackTriggered = true;
260
+ console.warn(
261
+ `[QualityMonitor] Poor playback detected: ${Math.round(this.playbackScore * 100)}% ` +
262
+ `(threshold: ${Math.round(this.getPlaybackScoreThreshold() * 100)}%, ` +
263
+ `protocol: ${this.options.protocol})`
264
+ );
265
+ this.options.onFallbackRequest({
266
+ score: this.playbackScore,
267
+ consecutivePoorSamples: this.consecutivePoorSamples,
268
+ });
269
+ }
270
+ } else {
271
+ // Quality recovered - reset counters
272
+ this.consecutivePoorSamples = 0;
273
+ this.fallbackTriggered = false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Calculate current quality metrics
279
+ */
280
+ private calculateQuality(video: HTMLVideoElement): PlaybackQuality {
281
+ const now = Date.now();
282
+
283
+ // Calculate buffered ahead
284
+ let bufferedAhead = 0;
285
+ if (video.buffered.length > 0) {
286
+ for (let i = 0; i < video.buffered.length; i++) {
287
+ if (video.buffered.start(i) <= video.currentTime && video.buffered.end(i) > video.currentTime) {
288
+ bufferedAhead = video.buffered.end(i) - video.currentTime;
289
+ break;
290
+ }
291
+ }
292
+ }
293
+
294
+ // Get frame stats if available
295
+ let framesDecoded = 0;
296
+ let framesDropped = 0;
297
+ let frameDropRate = 0;
298
+
299
+ if ('getVideoPlaybackQuality' in video) {
300
+ const stats = video.getVideoPlaybackQuality();
301
+ framesDecoded = stats.totalVideoFrames;
302
+ framesDropped = stats.droppedVideoFrames;
303
+ frameDropRate = framesDecoded > 0 ? (framesDropped / framesDecoded) * 100 : 0;
304
+ }
305
+
306
+ // Estimate bitrate from buffer loading
307
+ let bitrate = 0;
308
+ if (video.buffered.length > 0 && this.lastBytesTime > 0) {
309
+ const timeElapsed = (now - this.lastBytesTime) / 1000;
310
+ if (timeElapsed > 0) {
311
+ // Estimate from buffer growth
312
+ // This is a rough approximation - real bitrate tracking would use MSE
313
+ const bufferEnd = video.buffered.end(video.buffered.length - 1);
314
+ const bufferDuration = bufferEnd - video.currentTime;
315
+ // Assume average bitrate based on buffer size
316
+ bitrate = bufferDuration > 0 ? Math.round((bufferDuration * 1000000) / timeElapsed) : 0;
317
+ }
318
+ }
319
+ this.lastBytesTime = now;
320
+
321
+ // Calculate latency for live streams
322
+ let latency = 0;
323
+ if (video.duration === Infinity || !isFinite(video.duration)) {
324
+ // Live stream - estimate latency from buffer
325
+ if (video.buffered.length > 0) {
326
+ const liveEdge = video.buffered.end(video.buffered.length - 1);
327
+ latency = (liveEdge - video.currentTime) * 1000;
328
+ }
329
+ }
330
+
331
+ // Calculate composite quality score (0-100) with duration-weighted stalls
332
+ const score = this.calculateScore({
333
+ bufferedAhead,
334
+ stallCount: this.stallCount,
335
+ stallDurationMs: this.totalStallMs,
336
+ frameDropRate,
337
+ latency,
338
+ });
339
+
340
+ return {
341
+ score,
342
+ bitrate,
343
+ bufferedAhead,
344
+ stallCount: this.stallCount,
345
+ frameDropRate,
346
+ latency,
347
+ timestamp: now,
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Calculate composite quality score
353
+ *
354
+ * D4: Duration-weighted stall tracking - stall penalty considers both
355
+ * count AND duration. 10x 0.1s stalls (1s total) weighs less than 1x 1s stall.
356
+ */
357
+ private calculateScore(metrics: {
358
+ bufferedAhead: number;
359
+ stallCount: number;
360
+ stallDurationMs: number;
361
+ frameDropRate: number;
362
+ latency: number;
363
+ }): number {
364
+ let score = 100;
365
+
366
+ // Buffer penalty (max -40 points)
367
+ if (metrics.bufferedAhead < this.thresholds.minBuffer) {
368
+ const bufferPenalty = Math.min(40, (this.thresholds.minBuffer - metrics.bufferedAhead) * 20);
369
+ score -= bufferPenalty;
370
+ }
371
+
372
+ // D4: Duration-weighted stall penalty (max -30 points)
373
+ // Base: 5 points per stall + 2 points per second of total stall time
374
+ // This weights duration: 1x 2s stall = 5 + 4 = 9 points
375
+ // 10x 0.2s stalls = 50 + 4 = 54 points (capped at 30)
376
+ // So many short stalls are penalized more than few long stalls of same duration
377
+ const countPenalty = metrics.stallCount * 5;
378
+ const durationPenalty = (metrics.stallDurationMs / 1000) * 2;
379
+ const stallPenalty = Math.min(30, countPenalty + durationPenalty);
380
+ score -= stallPenalty;
381
+
382
+ // Frame drop penalty (max -20 points)
383
+ const framePenalty = Math.min(20, metrics.frameDropRate * 2);
384
+ score -= framePenalty;
385
+
386
+ // Latency penalty for live streams (max -10 points)
387
+ if (metrics.latency > 5000) {
388
+ const latencyPenalty = Math.min(10, (metrics.latency - 5000) / 1000);
389
+ score -= latencyPenalty;
390
+ }
391
+
392
+ return Math.max(0, Math.round(score));
393
+ }
394
+
395
+ /**
396
+ * Get current quality metrics
397
+ */
398
+ getCurrentQuality(): PlaybackQuality | null {
399
+ return this.history.length > 0 ? this.history[this.history.length - 1] : null;
400
+ }
401
+
402
+ /**
403
+ * Get rolling average quality
404
+ */
405
+ getAverageQuality(): PlaybackQuality | null {
406
+ if (this.history.length === 0) return null;
407
+
408
+ const avg: PlaybackQuality = {
409
+ score: 0,
410
+ bitrate: 0,
411
+ bufferedAhead: 0,
412
+ stallCount: this.stallCount,
413
+ frameDropRate: 0,
414
+ latency: 0,
415
+ timestamp: Date.now(),
416
+ };
417
+
418
+ for (const q of this.history) {
419
+ avg.score += q.score;
420
+ avg.bitrate += q.bitrate;
421
+ avg.bufferedAhead += q.bufferedAhead;
422
+ avg.frameDropRate += q.frameDropRate;
423
+ avg.latency += q.latency;
424
+ }
425
+
426
+ const len = this.history.length;
427
+ avg.score = Math.round(avg.score / len);
428
+ avg.bitrate = Math.round(avg.bitrate / len);
429
+ avg.bufferedAhead = avg.bufferedAhead / len;
430
+ avg.frameDropRate = avg.frameDropRate / len;
431
+ avg.latency = avg.latency / len;
432
+
433
+ return avg;
434
+ }
435
+
436
+ /**
437
+ * Get quality history
438
+ */
439
+ getHistory(): PlaybackQuality[] {
440
+ return [...this.history];
441
+ }
442
+
443
+ /**
444
+ * Reset stall counters
445
+ */
446
+ resetStallCounters(): void {
447
+ this.stallCount = 0;
448
+ this.totalStallMs = 0;
449
+ }
450
+
451
+ /**
452
+ * Get total stall time in ms
453
+ */
454
+ getTotalStallMs(): number {
455
+ return this.totalStallMs;
456
+ }
457
+
458
+ /**
459
+ * Check if currently monitoring
460
+ */
461
+ isMonitoring(): boolean {
462
+ return this.videoElement !== null && this.timers.activeCount > 0;
463
+ }
464
+
465
+ // ========================================
466
+ // MistPlayer-style Playback Score (0-2.0)
467
+ // ========================================
468
+
469
+ /**
470
+ * Calculate playback score entry value
471
+ * Compares video time progress vs wall clock time
472
+ */
473
+ private getPlaybackScoreValue(): PlaybackScoreEntry {
474
+ const video = this.videoElement;
475
+ const clock = performance.now() / 1000;
476
+ const videoTime = video?.currentTime ?? 0;
477
+
478
+ const result: PlaybackScoreEntry = {
479
+ clock,
480
+ video: videoTime,
481
+ score: 1.0,
482
+ };
483
+
484
+ if (this.playbackScoreHistory.length > 0) {
485
+ const prev = this.playbackScoreHistory[this.playbackScoreHistory.length - 1];
486
+ result.score = this.calculatePlaybackScoreFromEntries(prev, result);
487
+ }
488
+
489
+ return result;
490
+ }
491
+
492
+ /**
493
+ * Calculate score between two entries
494
+ * Returns 1.0 for normal playback, >1.0 if faster, <1.0 if stalled, <0 if backwards
495
+ */
496
+ private calculatePlaybackScoreFromEntries(a: PlaybackScoreEntry, b: PlaybackScoreEntry): number {
497
+ const video = this.videoElement;
498
+ let rate = 1;
499
+ if (video) {
500
+ rate = video.playbackRate || 1;
501
+ }
502
+
503
+ const clockDelta = b.clock - a.clock;
504
+ const videoDelta = b.video - a.video;
505
+
506
+ if (clockDelta <= 0) return 1.0;
507
+
508
+ return (videoDelta / clockDelta) / rate;
509
+ }
510
+
511
+ /**
512
+ * Calculate and update the playback score
513
+ * Like MistPlayer's calcScore function
514
+ */
515
+ private updatePlaybackScore(): number {
516
+ const entry = this.getPlaybackScoreValue();
517
+ this.playbackScoreHistory.push(entry);
518
+
519
+ if (this.playbackScoreHistory.length <= 1) {
520
+ return 1.0;
521
+ }
522
+
523
+ // Calculate score from oldest to newest
524
+ const first = this.playbackScoreHistory[0];
525
+ const last = this.playbackScoreHistory[this.playbackScoreHistory.length - 1];
526
+ let score = this.calculatePlaybackScoreFromEntries(first, last);
527
+
528
+ // Trim history
529
+ if (this.playbackScoreHistory.length > this.PLAYBACK_SCORE_AVERAGING_STEPS) {
530
+ this.playbackScoreHistory.shift();
531
+ }
532
+
533
+ // Final score is max of averaged and current
534
+ score = Math.max(score, entry.score);
535
+ this.playbackScore = score;
536
+
537
+ return score;
538
+ }
539
+
540
+ /**
541
+ * Get current playback score (MistPlayer-style 0-2.0 scale)
542
+ *
543
+ * - 1.0 = normal playback (video progresses at expected rate)
544
+ * - > 1.0 = faster than expected (catching up)
545
+ * - < 1.0 = slower than expected (stalling/buffering)
546
+ * - < 0 = video went backwards
547
+ *
548
+ * Threshold recommendations:
549
+ * - WebRTC: warn below 0.95
550
+ * - HLS/DASH: warn below 0.75
551
+ */
552
+ getPlaybackScore(): number {
553
+ return this.playbackScore;
554
+ }
555
+
556
+ /**
557
+ * Check if playback quality is poor based on score
558
+ * Uses protocol-specific thresholds (MistPlayer-style)
559
+ * WebRTC: 0.95 (strict), HLS/DASH/HTML5: 0.75 (lenient)
560
+ */
561
+ isPlaybackPoor(): boolean {
562
+ return this.playbackScore < this.getPlaybackScoreThreshold();
563
+ }
564
+
565
+ /**
566
+ * Reset playback score tracking
567
+ */
568
+ resetPlaybackScore(): void {
569
+ this.playbackScoreHistory = [];
570
+ this.playbackScore = 1.0;
571
+ }
572
+
573
+ /**
574
+ * Reset fallback state
575
+ * Call after a player switch to allow fallback to trigger again
576
+ */
577
+ resetFallbackState(): void {
578
+ this.consecutivePoorSamples = 0;
579
+ this.fallbackTriggered = false;
580
+ }
581
+
582
+ /**
583
+ * Get consecutive poor sample count (for debugging)
584
+ */
585
+ getConsecutivePoorSamples(): number {
586
+ return this.consecutivePoorSamples;
587
+ }
588
+
589
+ /**
590
+ * Check if fallback has been triggered (for debugging)
591
+ */
592
+ hasFallbackTriggered(): boolean {
593
+ return this.fallbackTriggered;
594
+ }
595
+ }
596
+
597
+ export default QualityMonitor;