@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,820 @@
1
+ import { BasePlayer } from '../core/PlayerInterface';
2
+ import { LiveDurationProxy } from '../core/LiveDurationProxy';
3
+ import { appendUrlParams, parseUrlParams, stripUrlParams } from '../core/UrlUtils';
4
+ import { checkProtocolMismatch, getBrowserInfo, checkWebRTCCodecCompatibility } from '../core/detector';
5
+ import type { StreamSource, StreamInfo, PlayerOptions, PlayerCapability } from '../core/PlayerInterface';
6
+
7
+ /**
8
+ * Native Player Implementation
9
+ *
10
+ * Handles direct playback using native browser APIs:
11
+ * - HTML5 video element for direct media
12
+ * - WHEP (WebRTC HTTP Egress Protocol) for WebRTC streams
13
+ *
14
+ * Ported from reference html5.js with:
15
+ * - Live duration proxy for meaningful seek bar
16
+ * - Auto-recovery on long pause (reload after 5s)
17
+ * - MP3 seeking restriction
18
+ * - Dynamic source switching via setSource()
19
+ */
20
+ export class NativePlayerImpl extends BasePlayer {
21
+ readonly capability: PlayerCapability = {
22
+ name: "Native Player",
23
+ shortname: "native",
24
+ priority: 1, // Highest priority as it's most compatible
25
+ mimes: [
26
+ "html5/video/mp4",
27
+ "html5/video/webm",
28
+ "html5/video/ogg",
29
+ "html5/audio/mp3",
30
+ "html5/audio/webm",
31
+ "html5/audio/ogg",
32
+ "html5/audio/wav",
33
+ "html5/application/vnd.apple.mpegurl", // Native HLS on Safari/iOS
34
+ "html5/application/vnd.apple.mpegurl;version=7",
35
+ "whep"
36
+ ]
37
+ };
38
+
39
+ private peerConnection: RTCPeerConnection | null = null;
40
+ private sessionUrl: string | null = null;
41
+ private lastInboundStats: any = null;
42
+ private reconnectEnabled = false;
43
+ private reconnectAttempts = 0;
44
+ private maxReconnectAttempts = 3;
45
+ private reconnectTimer: any = null;
46
+ private currentWhepUrl: string | null = null;
47
+ private currentHeaders: Record<string,string> | null = null;
48
+ private currentIceServers: RTCIceServer[] | null = null;
49
+ private container: HTMLElement | null = null;
50
+ private destroyed = false;
51
+
52
+ // Reference html5.js features
53
+ private liveDurationProxy: LiveDurationProxy | null = null;
54
+ private pausedAt: number | null = null;
55
+ private currentSourceUrl: string | null = null;
56
+ private currentMimeType: string | null = null;
57
+ private isMP3Source = false;
58
+ private liveSeekEnabled = false;
59
+ private liveSeekOffsetSec = 0;
60
+ private liveSeekBaseUrl: string | null = null;
61
+ private liveSeekListeners: Array<() => void> = [];
62
+ private liveSeekTimer: ReturnType<typeof setTimeout> | null = null;
63
+ private pendingLiveSeekOffset: number | null = null;
64
+
65
+ // Auto-recovery threshold (reference: 5 seconds)
66
+ private static readonly PAUSE_RECOVERY_THRESHOLD = 5000;
67
+ private static readonly LIVE_SEEK_DEBOUNCE_MS = 300;
68
+
69
+ isMimeSupported(mimetype: string): boolean {
70
+ return this.capability.mimes.indexOf(mimetype) !== -1;
71
+ }
72
+
73
+ isBrowserSupported(mimetype: string, source: StreamSource, streamInfo: StreamInfo): boolean | string[] {
74
+ if (mimetype === 'whep') {
75
+ // Check basic WebRTC support
76
+ if (!('RTCPeerConnection' in window) || !('fetch' in window)) return false;
77
+
78
+ // Check codec compatibility - WebRTC can only carry certain codecs
79
+ const codecCompat = checkWebRTCCodecCompatibility(streamInfo.meta.tracks);
80
+ if (!codecCompat.compatible) {
81
+ // Log why we're skipping WebRTC for this stream
82
+ if (codecCompat.incompatibleCodecs.length > 0) {
83
+ console.debug('[WHEP] Skipping - incompatible codecs:', codecCompat.incompatibleCodecs.join(', '));
84
+ }
85
+ return false;
86
+ }
87
+
88
+ // Return which track types we can play
89
+ const playable: string[] = [];
90
+ if (codecCompat.details.compatibleVideoCodecs.length > 0) {
91
+ playable.push('video');
92
+ }
93
+ if (codecCompat.details.compatibleAudioCodecs.length > 0) {
94
+ playable.push('audio');
95
+ }
96
+
97
+ // If stream has tracks but we found compatible ones, we can play
98
+ return playable.length > 0 ? playable : false;
99
+ }
100
+ // Check protocol mismatch
101
+ if (checkProtocolMismatch(source.url)) {
102
+ // Allow file:// -> http:// but warn
103
+ if (!(window.location.protocol === 'file:' && source.url.startsWith('http:'))) {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ const browser = getBrowserInfo();
109
+
110
+ // Safari cannot play WebM - skip entirely
111
+ // Reference: html5.js:28-29
112
+ if (mimetype === 'html5/video/webm' && browser.name === 'safari') {
113
+ return false;
114
+ }
115
+
116
+ // Special handling for HLS
117
+ if (mimetype === "html5/application/vnd.apple.mpegurl") {
118
+ // Check for native HLS support
119
+ const testVideo = document.createElement('video');
120
+ if (testVideo.canPlayType('application/vnd.apple.mpegurl')) {
121
+ // Prefer VideoJS for older Android
122
+ const androidVersion = this.getAndroidVersion();
123
+ if (androidVersion && androidVersion < 7) {
124
+ return false; // Let VideoJS handle it
125
+ }
126
+ return ['video', 'audio'];
127
+ }
128
+ return false;
129
+ }
130
+
131
+ // Test codec support for regular media types
132
+ const supportedTracks: string[] = [];
133
+ const testVideo = document.createElement('video');
134
+
135
+ // Extract the actual mime type from the format
136
+ const shortMime = mimetype.replace('html5/', '');
137
+
138
+ // For codec testing, we need to check against stream info
139
+ const tracksByType: Record<string, typeof streamInfo.meta.tracks> = {};
140
+ for (const track of streamInfo.meta.tracks) {
141
+ if (track.type === 'meta') {
142
+ if (track.codec === 'subtitle') {
143
+ supportedTracks.push('subtitle');
144
+ }
145
+ continue;
146
+ }
147
+
148
+ if (!tracksByType[track.type]) {
149
+ tracksByType[track.type] = [];
150
+ }
151
+ tracksByType[track.type].push(track);
152
+ }
153
+
154
+ // Test each track type
155
+ for (const [trackType, tracks] of Object.entries(tracksByType)) {
156
+ let hasPlayableTrack = false;
157
+
158
+ for (const track of tracks) {
159
+ // Build codec string for testing
160
+ let codecString = '';
161
+ if (track.codecstring) {
162
+ codecString = track.codecstring;
163
+ } else {
164
+ codecString = this.translateCodecForHtml5(track);
165
+ }
166
+
167
+ const testMimeType = `${shortMime};codecs="${codecString}"`;
168
+
169
+ // Special handling for WebM - Chrome reports issues with codec strings
170
+ if (shortMime === 'video/webm') {
171
+ if (testVideo.canPlayType(shortMime) !== '') {
172
+ hasPlayableTrack = true;
173
+ break;
174
+ }
175
+ } else {
176
+ if (testVideo.canPlayType(testMimeType) !== '') {
177
+ hasPlayableTrack = true;
178
+ break;
179
+ }
180
+ }
181
+ }
182
+
183
+ if (hasPlayableTrack) {
184
+ supportedTracks.push(trackType);
185
+ }
186
+ }
187
+
188
+ return supportedTracks.length > 0 ? supportedTracks : false;
189
+ }
190
+
191
+ private translateCodecForHtml5(track: { codec: string; codecstring?: string; init?: string }): string {
192
+ if (track.codecstring) return track.codecstring;
193
+
194
+ const bin2hex = (index: number) => {
195
+ if (!track.init || index >= track.init.length) return '00';
196
+ return ('0' + track.init.charCodeAt(index).toString(16)).slice(-2);
197
+ };
198
+
199
+ switch (track.codec) {
200
+ case 'AAC':
201
+ return 'mp4a.40.2';
202
+ case 'MP3':
203
+ return 'mp4a.40.34';
204
+ case 'AC3':
205
+ return 'ec-3';
206
+ case 'H264':
207
+ return `avc1.${bin2hex(1)}${bin2hex(2)}${bin2hex(3)}`;
208
+ case 'HEVC':
209
+ return `hev1.${bin2hex(1)}${bin2hex(6)}${bin2hex(7)}${bin2hex(8)}${bin2hex(9)}${bin2hex(10)}${bin2hex(11)}${bin2hex(12)}`;
210
+ case 'VP8':
211
+ return 'vp8';
212
+ case 'VP9':
213
+ return 'vp09.00.10.08';
214
+ case 'AV1':
215
+ return 'av01.0.04M.08';
216
+ case 'Opus':
217
+ return 'opus';
218
+ default:
219
+ return track.codec.toLowerCase();
220
+ }
221
+ }
222
+
223
+ private getAndroidVersion(): number | null {
224
+ const match = navigator.userAgent.match(/Android (\d+)(?:\.(\d+))?(?:\.(\d+))*/i);
225
+ if (!match) return null;
226
+
227
+ const major = parseInt(match[1], 10);
228
+ const minor = match[2] ? parseInt(match[2], 10) : 0;
229
+
230
+ return major + (minor / 10);
231
+ }
232
+
233
+ async initialize(container: HTMLElement, source: StreamSource, options: PlayerOptions, streamInfo?: StreamInfo): Promise<HTMLVideoElement> {
234
+ // Reset destroyed flag for reuse
235
+ this.destroyed = false;
236
+ this.container = container;
237
+ this.currentSourceUrl = source.url;
238
+ this.currentMimeType = source.type;
239
+ this.isMP3Source = source.type === 'html5/audio/mp3';
240
+ container.classList.add('fw-player-container');
241
+
242
+ // Create video element
243
+ const video = document.createElement('video');
244
+ video.classList.add('fw-player-video');
245
+ video.setAttribute('playsinline', '');
246
+ video.setAttribute('crossorigin', 'anonymous');
247
+
248
+ // Apply options
249
+ if (options.autoplay) video.autoplay = true;
250
+ if (options.muted) video.muted = true;
251
+ video.controls = options.controls === true; // Explicit false to hide native controls
252
+ if (options.loop) video.loop = true;
253
+ if (options.poster) video.poster = options.poster;
254
+
255
+ this.videoElement = video;
256
+ container.appendChild(video);
257
+
258
+ // Set up event listeners
259
+ this.setupVideoEventListeners(video, options);
260
+
261
+ // Setup reference features for HTML5 playback
262
+ // Use LiveDurationProxy for all live streams (non-WHEP)
263
+ // WHEP handles its own live edge via signaling
264
+ // This enables seeking and jump-to-live for native MP4/WebM/HLS live streams
265
+ const isLiveStream = streamInfo?.type === 'live' || !isFinite(streamInfo?.duration ?? Infinity);
266
+ if (source.type !== 'whep' && isLiveStream) {
267
+ this.setupLiveDurationProxy(video);
268
+ this.setupAutoRecovery(video);
269
+ this.liveSeekEnabled = true;
270
+ this.liveSeekOffsetSec = 0;
271
+ this.liveSeekBaseUrl = this.stripStartUnixParam(source.url);
272
+ } else {
273
+ this.liveSeekEnabled = false;
274
+ this.liveSeekOffsetSec = 0;
275
+ this.liveSeekBaseUrl = null;
276
+ }
277
+
278
+ // Optional subtitle tracks helper from source extras
279
+ try {
280
+ const subs = (source as any).subtitles as Array<{ label: string; lang: string; src: string }>;
281
+ if (Array.isArray(subs)) {
282
+ subs.forEach((s, idx) => {
283
+ const track = document.createElement('track');
284
+ track.kind = 'subtitles';
285
+ track.label = s.label;
286
+ track.srclang = s.lang;
287
+ track.src = s.src;
288
+ if (idx === 0) track.default = true;
289
+ video.appendChild(track);
290
+ });
291
+ }
292
+ } catch {}
293
+
294
+ try {
295
+ if (source.type === 'whep') {
296
+ // Read optional settings from source
297
+ const s: any = source as any;
298
+ const headers = (s && s.headers) ? (s.headers as Record<string,string>) : {};
299
+ const iceServers = (s && s.iceServers) ? (s.iceServers as RTCIceServer[]) : [];
300
+ this.reconnectEnabled = !!(s && s.reconnect);
301
+ this.currentWhepUrl = source.url;
302
+ this.currentHeaders = headers;
303
+ this.currentIceServers = iceServers;
304
+ await this.startWhep(video, source.url, headers, iceServers);
305
+ return video;
306
+ } else {
307
+ // Set the source for direct HTML5 playback
308
+ video.src = source.url;
309
+ if (options.autoplay) {
310
+ video.play().catch(e => console.warn('HTML5 autoplay failed:', e));
311
+ }
312
+ return video;
313
+ }
314
+
315
+ } catch (error: any) {
316
+ this.emit('error', error.message || String(error));
317
+ throw error;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Setup live duration proxy for meaningful seek bar on live streams
323
+ * Ported from reference html5.js:194-202
324
+ */
325
+ private setupLiveDurationProxy(video: HTMLVideoElement): void {
326
+ this.liveDurationProxy = new LiveDurationProxy(video, {
327
+ constrainSeek: true,
328
+ // Duration changes are handled by UI polling getDuration()
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Setup auto-recovery on long pause
334
+ * Ported from reference html5.js:227-239
335
+ *
336
+ * If the stream has been paused for more than 5 seconds,
337
+ * reload the stream on play to recover from stale buffer.
338
+ */
339
+ private setupAutoRecovery(video: HTMLVideoElement): void {
340
+ video.addEventListener('pause', () => {
341
+ if (this.destroyed) return;
342
+ this.pausedAt = Date.now();
343
+ });
344
+
345
+ video.addEventListener('play', () => {
346
+ if (this.destroyed) return;
347
+
348
+ // Check if we need to recover from long pause
349
+ if (this.pausedAt && this.liveDurationProxy?.isLive()) {
350
+ const pauseDuration = Date.now() - this.pausedAt;
351
+ if (pauseDuration > NativePlayerImpl.PAUSE_RECOVERY_THRESHOLD) {
352
+ console.debug('[NativePlayer] Auto-recovery: reloading stream after', pauseDuration, 'ms pause');
353
+ video.load();
354
+ }
355
+ }
356
+ this.pausedAt = null;
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Set a new source URL dynamically
362
+ * Ported from reference html5.js:276-281
363
+ */
364
+ setSource(url: string): void {
365
+ if (!this.videoElement) return;
366
+ this.currentSourceUrl = url;
367
+ if (this.liveSeekEnabled) {
368
+ this.liveSeekBaseUrl = this.stripStartUnixParam(url);
369
+ this.liveSeekOffsetSec = 0;
370
+ }
371
+ this.videoElement.src = url;
372
+ this.videoElement.load();
373
+ }
374
+
375
+ /**
376
+ * Override seek for MP3 files (seeking not supported)
377
+ * Ported from reference html5.js:185-191
378
+ */
379
+ seek(time: number): void {
380
+ if (this.isMP3Source) {
381
+ console.warn('[NativePlayer] Seek not supported for MP3 files');
382
+ return;
383
+ }
384
+
385
+ if (this.liveSeekEnabled && this.liveDurationProxy?.isLive()) {
386
+ const duration = this.getDuration();
387
+ let offset = time - duration;
388
+ if (offset > 0) offset = 0;
389
+ this.scheduleLiveSeekOffset(offset, false);
390
+ return;
391
+ }
392
+
393
+ if (this.liveDurationProxy?.isLive()) {
394
+ // Use live duration proxy for constrained seeking
395
+ this.liveDurationProxy.seek(time);
396
+ return;
397
+ }
398
+
399
+ if (this.videoElement) {
400
+ this.videoElement.currentTime = time;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Get the calculated duration (live-aware)
406
+ */
407
+ getDuration(): number {
408
+ if (this.liveSeekEnabled && this.liveDurationProxy?.isLive()) {
409
+ const base = this.liveDurationProxy.getDuration();
410
+ const offset = this.pendingLiveSeekOffset ?? this.liveSeekOffsetSec;
411
+ return Math.max(0, base - offset);
412
+ }
413
+ if (this.liveDurationProxy) {
414
+ return this.liveDurationProxy.getDuration();
415
+ }
416
+ return this.videoElement?.duration ?? 0;
417
+ }
418
+
419
+ getCurrentTime(): number {
420
+ if (this.liveSeekEnabled && this.liveDurationProxy?.isLive() && this.videoElement) {
421
+ const offset = this.pendingLiveSeekOffset ?? this.liveSeekOffsetSec;
422
+ return Math.max(0, this.videoElement.currentTime - offset);
423
+ }
424
+ return this.videoElement?.currentTime ?? 0;
425
+ }
426
+
427
+ getSeekableRange(): { start: number; end: number } | null {
428
+ if (!this.liveSeekEnabled || !this.liveDurationProxy?.isLive() || !this.videoElement) {
429
+ return null;
430
+ }
431
+ const buffered = this.videoElement.buffered;
432
+ if (!buffered || buffered.length === 0) return null;
433
+ const offset = this.pendingLiveSeekOffset ?? this.liveSeekOffsetSec;
434
+ const start = buffered.start(0) - offset;
435
+ const end = buffered.end(buffered.length - 1) - offset;
436
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
437
+ return { start: Math.max(0, start), end: Math.max(0, end) };
438
+ }
439
+
440
+ getBufferedRanges(): TimeRanges | null {
441
+ const video = this.videoElement;
442
+ if (!video) return null;
443
+ const buffered = video.buffered;
444
+ if (!this.liveSeekEnabled || !this.liveDurationProxy?.isLive()) {
445
+ return buffered;
446
+ }
447
+ if (!buffered || buffered.length === 0) return buffered;
448
+ const offset = this.pendingLiveSeekOffset ?? this.liveSeekOffsetSec;
449
+ const shifted: [number, number][] = [];
450
+ for (let i = 0; i < buffered.length; i++) {
451
+ const start = buffered.start(i) - offset;
452
+ const end = buffered.end(i) - offset;
453
+ if (Number.isFinite(start) && Number.isFinite(end)) {
454
+ shifted.push([Math.max(0, start), Math.max(0, end)]);
455
+ }
456
+ }
457
+ return this.createTimeRanges(shifted);
458
+ }
459
+
460
+ /**
461
+ * Check if current stream is live
462
+ */
463
+ isLive(): boolean {
464
+ return this.liveDurationProxy?.isLive() ?? false;
465
+ }
466
+
467
+ /**
468
+ * Get live latency in seconds
469
+ */
470
+ getLiveLatency(): number {
471
+ return this.liveDurationProxy?.getLatency() ?? 0;
472
+ }
473
+
474
+ /**
475
+ * Jump to live edge
476
+ */
477
+ jumpToLive(): void {
478
+ if (this.liveSeekEnabled && this.liveDurationProxy?.isLive()) {
479
+ this.scheduleLiveSeekOffset(0, true);
480
+ return;
481
+ }
482
+ this.liveDurationProxy?.jumpToLive();
483
+ }
484
+
485
+ async destroy(): Promise<void> {
486
+ // Set destroyed flag immediately to guard against async callbacks
487
+ this.destroyed = true;
488
+
489
+ // Cleanup live duration proxy
490
+ if (this.liveDurationProxy) {
491
+ this.liveDurationProxy.destroy();
492
+ this.liveDurationProxy = null;
493
+ }
494
+
495
+ if (this.reconnectTimer) {
496
+ try { clearTimeout(this.reconnectTimer); } catch {}
497
+ this.reconnectTimer = null;
498
+ }
499
+
500
+ // Best-effort WHEP session DELETE (CORS may block this)
501
+ if (this.sessionUrl) {
502
+ const url = this.sessionUrl;
503
+ this.sessionUrl = null;
504
+ fetch(url, { method: 'DELETE' }).catch(() => {
505
+ // Silently ignore - CORS often blocks DELETE, session will timeout on server
506
+ });
507
+ }
508
+
509
+ if (this.peerConnection) {
510
+ try { this.peerConnection.close(); } catch {}
511
+ this.peerConnection = null;
512
+ }
513
+
514
+ if (this.videoElement) {
515
+ try { (this.videoElement as any).srcObject = null; } catch {}
516
+ this.videoElement.pause();
517
+ this.videoElement.removeAttribute('src');
518
+ // Note: Don't call load() - it triggers "Empty src attribute" error event
519
+
520
+ if (this.container) {
521
+ try { this.container.removeChild(this.videoElement); } catch {}
522
+ }
523
+ }
524
+
525
+ this.videoElement = null;
526
+ this.container = null;
527
+ this.pausedAt = null;
528
+ this.currentSourceUrl = null;
529
+ this.currentMimeType = null;
530
+ this.liveSeekEnabled = false;
531
+ this.liveSeekOffsetSec = 0;
532
+ this.liveSeekBaseUrl = null;
533
+ this.liveSeekListeners.forEach(cleanup => cleanup());
534
+ this.liveSeekListeners = [];
535
+ if (this.liveSeekTimer) {
536
+ clearTimeout(this.liveSeekTimer);
537
+ this.liveSeekTimer = null;
538
+ }
539
+ this.pendingLiveSeekOffset = null;
540
+ this.listeners.clear();
541
+ }
542
+
543
+ private stripStartUnixParam(url: string): string {
544
+ const params = parseUrlParams(url);
545
+ delete params.startunix;
546
+ return appendUrlParams(stripUrlParams(url), params);
547
+ }
548
+
549
+ private buildLiveSeekUrl(offsetSec: number): string {
550
+ const base = this.liveSeekBaseUrl || this.currentSourceUrl || '';
551
+ if (!base) return '';
552
+ if (!offsetSec || offsetSec >= 0) {
553
+ return this.stripStartUnixParam(base);
554
+ }
555
+ const params = parseUrlParams(base);
556
+ params.startunix = String(offsetSec);
557
+ return appendUrlParams(stripUrlParams(base), params);
558
+ }
559
+
560
+ private applyLiveSeekOffset(offsetSec: number): void {
561
+ if (!this.videoElement) return;
562
+ const clamped = Math.min(0, offsetSec);
563
+ if (Math.abs(clamped - this.liveSeekOffsetSec) < 0.05) {
564
+ return;
565
+ }
566
+ this.liveSeekOffsetSec = clamped;
567
+ const nextUrl = this.buildLiveSeekUrl(clamped);
568
+ if (!nextUrl) return;
569
+ const wasPlaying = !this.videoElement.paused;
570
+ this.currentSourceUrl = nextUrl;
571
+ this.videoElement.src = nextUrl;
572
+ this.videoElement.load();
573
+ if (wasPlaying) {
574
+ this.videoElement.play().catch(() => {});
575
+ }
576
+ }
577
+
578
+ private createTimeRanges(ranges: [number, number][]): TimeRanges {
579
+ return {
580
+ length: ranges.length,
581
+ start(index: number): number {
582
+ if (index < 0 || index >= ranges.length) throw new DOMException('Index out of bounds');
583
+ return ranges[index][0];
584
+ },
585
+ end(index: number): number {
586
+ if (index < 0 || index >= ranges.length) throw new DOMException('Index out of bounds');
587
+ return ranges[index][1];
588
+ },
589
+ };
590
+ }
591
+
592
+ private scheduleLiveSeekOffset(offsetSec: number, immediate: boolean): void {
593
+ const clamped = Math.min(0, offsetSec);
594
+ if (immediate) {
595
+ if (this.liveSeekTimer) {
596
+ clearTimeout(this.liveSeekTimer);
597
+ this.liveSeekTimer = null;
598
+ }
599
+ this.pendingLiveSeekOffset = null;
600
+ this.applyLiveSeekOffset(clamped);
601
+ return;
602
+ }
603
+
604
+ this.pendingLiveSeekOffset = clamped;
605
+ if (this.liveSeekTimer) {
606
+ clearTimeout(this.liveSeekTimer);
607
+ }
608
+ this.liveSeekTimer = setTimeout(() => {
609
+ this.liveSeekTimer = null;
610
+ if (this.pendingLiveSeekOffset !== null) {
611
+ const pending = this.pendingLiveSeekOffset;
612
+ this.pendingLiveSeekOffset = null;
613
+ this.applyLiveSeekOffset(pending);
614
+ }
615
+ }, NativePlayerImpl.LIVE_SEEK_DEBOUNCE_MS);
616
+ }
617
+
618
+ /**
619
+ * Get WebRTC-specific stats including RTT, packet loss, jitter, bitrate
620
+ */
621
+ async getStats(): Promise<{
622
+ type: 'webrtc';
623
+ video?: {
624
+ bytesReceived: number;
625
+ packetsReceived: number;
626
+ packetsLost: number;
627
+ packetLossRate: number;
628
+ jitter: number;
629
+ framesDecoded: number;
630
+ framesDropped: number;
631
+ frameDropRate: number;
632
+ frameWidth: number;
633
+ frameHeight: number;
634
+ framesPerSecond: number;
635
+ bitrate: number;
636
+ jitterBufferDelay: number;
637
+ };
638
+ audio?: {
639
+ bytesReceived: number;
640
+ packetsReceived: number;
641
+ packetsLost: number;
642
+ packetLossRate: number;
643
+ jitter: number;
644
+ bitrate: number;
645
+ };
646
+ network?: {
647
+ rtt: number;
648
+ availableOutgoingBitrate: number;
649
+ availableIncomingBitrate: number;
650
+ bytesSent: number;
651
+ bytesReceived: number;
652
+ };
653
+ timestamp: number;
654
+ } | undefined> {
655
+ if (!this.peerConnection) return undefined;
656
+ try {
657
+ const stats = await this.peerConnection.getStats();
658
+ const now = Date.now();
659
+ const result: any = { type: 'webrtc', timestamp: now };
660
+
661
+ stats.forEach((report: any) => {
662
+ if (report.type === 'inbound-rtp') {
663
+ const packetLossRate = report.packetsReceived > 0
664
+ ? (report.packetsLost / (report.packetsReceived + report.packetsLost)) * 100
665
+ : 0;
666
+
667
+ // Calculate bitrate from previous sample
668
+ let bitrate = 0;
669
+ if (this.lastInboundStats && this.lastInboundStats[report.kind]) {
670
+ const prev = this.lastInboundStats[report.kind];
671
+ const timeDelta = (now - (this.lastInboundStats.timestamp || 0)) / 1000;
672
+ if (timeDelta > 0) {
673
+ const bytesDelta = report.bytesReceived - (prev.bytesReceived || 0);
674
+ bitrate = Math.round((bytesDelta * 8) / timeDelta); // bits per second
675
+ }
676
+ }
677
+
678
+ if (report.kind === 'video') {
679
+ const frameDropRate = report.framesDecoded > 0
680
+ ? (report.framesDropped / (report.framesDecoded + report.framesDropped)) * 100
681
+ : 0;
682
+
683
+ result.video = {
684
+ bytesReceived: report.bytesReceived || 0,
685
+ packetsReceived: report.packetsReceived || 0,
686
+ packetsLost: report.packetsLost || 0,
687
+ packetLossRate,
688
+ jitter: (report.jitter || 0) * 1000, // Convert to ms
689
+ framesDecoded: report.framesDecoded || 0,
690
+ framesDropped: report.framesDropped || 0,
691
+ frameDropRate,
692
+ frameWidth: report.frameWidth || 0,
693
+ frameHeight: report.frameHeight || 0,
694
+ framesPerSecond: report.framesPerSecond || 0,
695
+ bitrate,
696
+ jitterBufferDelay: report.jitterBufferDelay && report.jitterBufferEmittedCount
697
+ ? (report.jitterBufferDelay / report.jitterBufferEmittedCount) * 1000 // ms
698
+ : 0,
699
+ };
700
+ }
701
+ if (report.kind === 'audio') {
702
+ result.audio = {
703
+ bytesReceived: report.bytesReceived || 0,
704
+ packetsReceived: report.packetsReceived || 0,
705
+ packetsLost: report.packetsLost || 0,
706
+ packetLossRate,
707
+ jitter: (report.jitter || 0) * 1000, // Convert to ms
708
+ bitrate,
709
+ };
710
+ }
711
+ }
712
+ if (report.type === 'candidate-pair' && report.nominated) {
713
+ result.network = {
714
+ rtt: report.currentRoundTripTime ? report.currentRoundTripTime * 1000 : 0, // ms
715
+ availableOutgoingBitrate: report.availableOutgoingBitrate || 0,
716
+ availableIncomingBitrate: report.availableIncomingBitrate || 0,
717
+ bytesSent: report.bytesSent || 0,
718
+ bytesReceived: report.bytesReceived || 0,
719
+ };
720
+ }
721
+ });
722
+
723
+ // Store for next sample's bitrate calculation
724
+ this.lastInboundStats = {
725
+ video: result.video ? { bytesReceived: result.video.bytesReceived } : undefined,
726
+ audio: result.audio ? { bytesReceived: result.audio.bytesReceived } : undefined,
727
+ timestamp: now,
728
+ };
729
+
730
+ return result;
731
+ } catch {
732
+ return undefined;
733
+ }
734
+ }
735
+
736
+ async getLatency(): Promise<{ estimatedMs: number; jitterBufferMs: number; rttMs: number } | undefined> {
737
+ const s = await this.getStats();
738
+ if (!s) return undefined;
739
+
740
+ return {
741
+ estimatedMs: s.video?.jitterBufferDelay || 0,
742
+ jitterBufferMs: s.video?.jitterBufferDelay || 0,
743
+ rttMs: s.network?.rtt || 0,
744
+ };
745
+ }
746
+
747
+ private async startWhep(video: HTMLVideoElement, url: string, headers: Record<string,string>, iceServers: RTCIceServer[]) {
748
+ // Clean previous sessionUrl
749
+ if (this.sessionUrl) {
750
+ try { fetch(this.sessionUrl, { method: 'DELETE' }).catch(() => {}); } catch {}
751
+ this.sessionUrl = null;
752
+ }
753
+
754
+ // Create peer connection
755
+ const pc = new RTCPeerConnection({ iceServers });
756
+ this.peerConnection = pc;
757
+
758
+ pc.ontrack = (event: RTCTrackEvent) => {
759
+ if (this.destroyed) return; // Guard against zombie callbacks
760
+ if (video && event.streams[0]) {
761
+ video.srcObject = event.streams[0];
762
+ }
763
+ };
764
+
765
+ pc.oniceconnectionstatechange = () => {
766
+ if (this.destroyed) return; // Guard against zombie callbacks
767
+ const state = pc.iceConnectionState;
768
+ if (state === 'failed' || state === 'disconnected') {
769
+ this.emit('error', 'WHEP connection failed');
770
+ if (this.reconnectEnabled && this.reconnectAttempts < this.maxReconnectAttempts && this.currentWhepUrl) {
771
+ const backoff = Math.min(5000, 500 * Math.pow(2, this.reconnectAttempts));
772
+ this.reconnectAttempts++;
773
+ this.reconnectTimer = setTimeout(() => {
774
+ if (this.destroyed) return; // Guard inside timer callback too
775
+ this.startWhep(video, this.currentWhepUrl!, this.currentHeaders || {}, this.currentIceServers || []);
776
+ }, backoff);
777
+ }
778
+ }
779
+ if (state === 'connected') {
780
+ this.reconnectAttempts = 0;
781
+ }
782
+ };
783
+
784
+ pc.addTransceiver('video', { direction: 'recvonly' });
785
+ pc.addTransceiver('audio', { direction: 'recvonly' });
786
+
787
+ const offer = await pc.createOffer();
788
+ await pc.setLocalDescription(offer);
789
+
790
+ const requestHeaders: Record<string,string> = { 'Content-Type': 'application/sdp' };
791
+ for (const k in headers) requestHeaders[k] = headers[k];
792
+
793
+ const response = await fetch(url, {
794
+ method: 'POST',
795
+ headers: requestHeaders,
796
+ body: offer.sdp || ''
797
+ });
798
+ if (!response.ok) {
799
+ throw new Error(`WHEP request failed: ${response.status}`);
800
+ }
801
+ const answerSdp = await response.text();
802
+ await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: answerSdp }));
803
+
804
+ // Resolve sessionUrl against the WHEP endpoint URL (Location header may be relative)
805
+ const locationHeader = response.headers.get('Location');
806
+ if (locationHeader) {
807
+ try {
808
+ // Use URL constructor to resolve relative path against the WHEP endpoint
809
+ this.sessionUrl = new URL(locationHeader, url).href;
810
+ } catch {
811
+ this.sessionUrl = locationHeader;
812
+ }
813
+ } else {
814
+ this.sessionUrl = null;
815
+ }
816
+ }
817
+ }
818
+
819
+ // Backwards compatibility alias
820
+ export { NativePlayerImpl as DirectPlaybackPlayerImpl };