@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.
- package/dist/cjs/index.js +19493 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +19398 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/player.css +2140 -0
- package/dist/types/core/ABRController.d.ts +164 -0
- package/dist/types/core/CodecUtils.d.ts +54 -0
- package/dist/types/core/Disposable.d.ts +61 -0
- package/dist/types/core/EventEmitter.d.ts +73 -0
- package/dist/types/core/GatewayClient.d.ts +144 -0
- package/dist/types/core/InteractionController.d.ts +121 -0
- package/dist/types/core/LiveDurationProxy.d.ts +102 -0
- package/dist/types/core/MetaTrackManager.d.ts +220 -0
- package/dist/types/core/MistReporter.d.ts +163 -0
- package/dist/types/core/MistSignaling.d.ts +148 -0
- package/dist/types/core/PlayerController.d.ts +665 -0
- package/dist/types/core/PlayerInterface.d.ts +230 -0
- package/dist/types/core/PlayerManager.d.ts +182 -0
- package/dist/types/core/PlayerRegistry.d.ts +27 -0
- package/dist/types/core/QualityMonitor.d.ts +184 -0
- package/dist/types/core/ScreenWakeLockManager.d.ts +70 -0
- package/dist/types/core/SeekingUtils.d.ts +142 -0
- package/dist/types/core/StreamStateClient.d.ts +108 -0
- package/dist/types/core/SubtitleManager.d.ts +111 -0
- package/dist/types/core/TelemetryReporter.d.ts +79 -0
- package/dist/types/core/TimeFormat.d.ts +97 -0
- package/dist/types/core/TimerManager.d.ts +83 -0
- package/dist/types/core/UrlUtils.d.ts +81 -0
- package/dist/types/core/detector.d.ts +149 -0
- package/dist/types/core/index.d.ts +49 -0
- package/dist/types/core/scorer.d.ts +167 -0
- package/dist/types/core/selector.d.ts +9 -0
- package/dist/types/index.d.ts +45 -0
- package/dist/types/lib/utils.d.ts +2 -0
- package/dist/types/players/DashJsPlayer.d.ts +102 -0
- package/dist/types/players/HlsJsPlayer.d.ts +70 -0
- package/dist/types/players/MewsWsPlayer/SourceBufferManager.d.ts +119 -0
- package/dist/types/players/MewsWsPlayer/WebSocketManager.d.ts +60 -0
- package/dist/types/players/MewsWsPlayer/index.d.ts +220 -0
- package/dist/types/players/MewsWsPlayer/types.d.ts +89 -0
- package/dist/types/players/MistPlayer.d.ts +25 -0
- package/dist/types/players/MistWebRTCPlayer/index.d.ts +133 -0
- package/dist/types/players/NativePlayer.d.ts +143 -0
- package/dist/types/players/VideoJsPlayer.d.ts +59 -0
- package/dist/types/players/WebCodecsPlayer/JitterBuffer.d.ts +118 -0
- package/dist/types/players/WebCodecsPlayer/LatencyProfiles.d.ts +64 -0
- package/dist/types/players/WebCodecsPlayer/RawChunkParser.d.ts +63 -0
- package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +174 -0
- package/dist/types/players/WebCodecsPlayer/WebSocketController.d.ts +164 -0
- package/dist/types/players/WebCodecsPlayer/index.d.ts +149 -0
- package/dist/types/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.d.ts +105 -0
- package/dist/types/players/WebCodecsPlayer/types.d.ts +395 -0
- package/dist/types/players/WebCodecsPlayer/worker/decoder.worker.d.ts +13 -0
- package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +197 -0
- package/dist/types/players/index.d.ts +14 -0
- package/dist/types/styles/index.d.ts +11 -0
- package/dist/types/types.d.ts +363 -0
- package/dist/types/vanilla/FrameWorksPlayer.d.ts +143 -0
- package/dist/types/vanilla/index.d.ts +19 -0
- package/dist/workers/decoder.worker.js +989 -0
- package/dist/workers/decoder.worker.js.map +1 -0
- package/package.json +80 -0
- package/src/core/ABRController.ts +550 -0
- package/src/core/CodecUtils.ts +257 -0
- package/src/core/Disposable.ts +120 -0
- package/src/core/EventEmitter.ts +113 -0
- package/src/core/GatewayClient.ts +439 -0
- package/src/core/InteractionController.ts +712 -0
- package/src/core/LiveDurationProxy.ts +270 -0
- package/src/core/MetaTrackManager.ts +753 -0
- package/src/core/MistReporter.ts +543 -0
- package/src/core/MistSignaling.ts +346 -0
- package/src/core/PlayerController.ts +2829 -0
- package/src/core/PlayerInterface.ts +432 -0
- package/src/core/PlayerManager.ts +900 -0
- package/src/core/PlayerRegistry.ts +149 -0
- package/src/core/QualityMonitor.ts +597 -0
- package/src/core/ScreenWakeLockManager.ts +163 -0
- package/src/core/SeekingUtils.ts +364 -0
- package/src/core/StreamStateClient.ts +457 -0
- package/src/core/SubtitleManager.ts +297 -0
- package/src/core/TelemetryReporter.ts +308 -0
- package/src/core/TimeFormat.ts +205 -0
- package/src/core/TimerManager.ts +209 -0
- package/src/core/UrlUtils.ts +179 -0
- package/src/core/detector.ts +382 -0
- package/src/core/index.ts +140 -0
- package/src/core/scorer.ts +553 -0
- package/src/core/selector.ts +16 -0
- package/src/global.d.ts +11 -0
- package/src/index.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/players/DashJsPlayer.ts +642 -0
- package/src/players/HlsJsPlayer.ts +483 -0
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +572 -0
- package/src/players/MewsWsPlayer/WebSocketManager.ts +241 -0
- package/src/players/MewsWsPlayer/index.ts +1065 -0
- package/src/players/MewsWsPlayer/types.ts +106 -0
- package/src/players/MistPlayer.ts +188 -0
- package/src/players/MistWebRTCPlayer/index.ts +703 -0
- package/src/players/NativePlayer.ts +820 -0
- package/src/players/VideoJsPlayer.ts +643 -0
- package/src/players/WebCodecsPlayer/JitterBuffer.ts +299 -0
- package/src/players/WebCodecsPlayer/LatencyProfiles.ts +151 -0
- package/src/players/WebCodecsPlayer/RawChunkParser.ts +151 -0
- package/src/players/WebCodecsPlayer/SyncController.ts +456 -0
- package/src/players/WebCodecsPlayer/WebSocketController.ts +564 -0
- package/src/players/WebCodecsPlayer/index.ts +1650 -0
- package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +379 -0
- package/src/players/WebCodecsPlayer/types.ts +542 -0
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +1360 -0
- package/src/players/WebCodecsPlayer/worker/types.ts +276 -0
- package/src/players/index.ts +22 -0
- package/src/styles/animations.css +21 -0
- package/src/styles/index.ts +52 -0
- package/src/styles/player.css +2126 -0
- package/src/styles/tailwind.css +1015 -0
- package/src/types.ts +421 -0
- package/src/vanilla/FrameWorksPlayer.ts +367 -0
- 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 };
|