@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,643 @@
|
|
|
1
|
+
import { BasePlayer } from '../core/PlayerInterface';
|
|
2
|
+
import { LiveDurationProxy } from '../core/LiveDurationProxy';
|
|
3
|
+
import type { StreamSource, StreamInfo, PlayerOptions, PlayerCapability } from '../core/PlayerInterface';
|
|
4
|
+
|
|
5
|
+
export class VideoJsPlayerImpl extends BasePlayer {
|
|
6
|
+
readonly capability: PlayerCapability = {
|
|
7
|
+
name: "Video.js Player",
|
|
8
|
+
shortname: "videojs",
|
|
9
|
+
priority: 2,
|
|
10
|
+
// VideoJS only has built-in HLS support via VHS (videojs-http-streaming)
|
|
11
|
+
// DASH requires videojs-contrib-dash plugin which wraps dash.js - we use DashJsPlayer directly instead
|
|
12
|
+
mimes: [
|
|
13
|
+
"html5/application/vnd.apple.mpegurl",
|
|
14
|
+
"html5/application/vnd.apple.mpegurl;version=7",
|
|
15
|
+
]
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
private videojsPlayer: any = null;
|
|
19
|
+
private container: HTMLElement | null = null;
|
|
20
|
+
private destroyed = false;
|
|
21
|
+
private timeCorrection: number = 0;
|
|
22
|
+
private proxyElement: HTMLVideoElement | null = null;
|
|
23
|
+
private currentStreamInfo: StreamInfo | null = null;
|
|
24
|
+
private liveDurationProxy: LiveDurationProxy | null = null;
|
|
25
|
+
|
|
26
|
+
isMimeSupported(mimetype: string): boolean {
|
|
27
|
+
return this.capability.mimes.includes(mimetype);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
isBrowserSupported(mimetype: string, source: StreamSource, streamInfo: StreamInfo): boolean | string[] {
|
|
31
|
+
// Check for HTTP/HTTPS protocol mismatch
|
|
32
|
+
try {
|
|
33
|
+
const sourceProtocol = new URL(source.url).protocol;
|
|
34
|
+
if (typeof location !== 'undefined' && location.protocol !== sourceProtocol) {
|
|
35
|
+
console.debug('[VideoJS] HTTP/HTTPS mismatch - skipping');
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// URL parsing failed, continue with other checks
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Test codec support properly - don't just assume compatibility
|
|
43
|
+
const playableTracks: string[] = [];
|
|
44
|
+
const tracksByType: Record<string, typeof streamInfo.meta.tracks> = {};
|
|
45
|
+
|
|
46
|
+
// Group tracks by type
|
|
47
|
+
for (const track of streamInfo.meta.tracks) {
|
|
48
|
+
if (track.type === 'meta') {
|
|
49
|
+
if (track.codec === 'subtitle') {
|
|
50
|
+
playableTracks.push('subtitle');
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!tracksByType[track.type]) {
|
|
56
|
+
tracksByType[track.type] = [];
|
|
57
|
+
}
|
|
58
|
+
tracksByType[track.type].push(track);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// HLS-incompatible audio codecs (VideoJS uses VHS for HLS)
|
|
62
|
+
// HLS standard only supports: AAC, MP3, AC-3/E-AC-3
|
|
63
|
+
const HLS_INCOMPATIBLE_AUDIO = ['OPUS', 'Opus', 'opus', 'VORBIS', 'Vorbis', 'FLAC'];
|
|
64
|
+
|
|
65
|
+
// Test codec support for video/audio tracks using canPlayType
|
|
66
|
+
const testVideo = document.createElement('video');
|
|
67
|
+
for (const [trackType, tracks] of Object.entries(tracksByType)) {
|
|
68
|
+
let hasPlayableTrack = false;
|
|
69
|
+
|
|
70
|
+
for (const track of tracks) {
|
|
71
|
+
// Explicit HLS codec filtering - OPUS doesn't work in HLS even if canPlayType says yes
|
|
72
|
+
if (trackType === 'audio' && HLS_INCOMPATIBLE_AUDIO.includes(track.codec)) {
|
|
73
|
+
console.debug(`[VideoJS] Codec incompatible with HLS: ${track.codec}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build codec string
|
|
78
|
+
let codecString = track.codec;
|
|
79
|
+
if (track.init) {
|
|
80
|
+
// Use init data for accurate codec string like HLS.js does
|
|
81
|
+
const bin2hex = (idx: number) => {
|
|
82
|
+
if (!track.init || idx >= track.init.length) return '00';
|
|
83
|
+
return ('0' + track.init.charCodeAt(idx).toString(16)).slice(-2);
|
|
84
|
+
};
|
|
85
|
+
switch (track.codec) {
|
|
86
|
+
case 'H264':
|
|
87
|
+
codecString = `avc1.${bin2hex(1)}${bin2hex(2)}${bin2hex(3)}`;
|
|
88
|
+
break;
|
|
89
|
+
case 'AAC':
|
|
90
|
+
codecString = 'mp4a.40.2';
|
|
91
|
+
break;
|
|
92
|
+
case 'MP3':
|
|
93
|
+
codecString = 'mp4a.40.34';
|
|
94
|
+
break;
|
|
95
|
+
case 'HEVC':
|
|
96
|
+
codecString = 'hev1.1.6.L93.B0';
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Test with video element canPlayType
|
|
102
|
+
const mimeToTest = trackType === 'audio'
|
|
103
|
+
? `audio/mp4;codecs="${codecString}"`
|
|
104
|
+
: `video/mp4;codecs="${codecString}"`;
|
|
105
|
+
|
|
106
|
+
if (testVideo.canPlayType(mimeToTest) !== '') {
|
|
107
|
+
hasPlayableTrack = true;
|
|
108
|
+
break;
|
|
109
|
+
} else {
|
|
110
|
+
console.debug(`[VideoJS] Codec not supported: ${mimeToTest}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (hasPlayableTrack) {
|
|
115
|
+
playableTracks.push(trackType);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// If no tracks to test, assume basic support (fallback behavior)
|
|
120
|
+
if (Object.keys(tracksByType).length === 0) {
|
|
121
|
+
return ['video', 'audio'];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return playableTracks.length > 0 ? playableTracks : false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async initialize(container: HTMLElement, source: StreamSource, options: PlayerOptions, streamInfo?: StreamInfo): Promise<HTMLVideoElement> {
|
|
128
|
+
this.destroyed = false;
|
|
129
|
+
this.container = container;
|
|
130
|
+
this.currentStreamInfo = streamInfo || null;
|
|
131
|
+
container.classList.add('fw-player-container');
|
|
132
|
+
|
|
133
|
+
const video = document.createElement('video');
|
|
134
|
+
video.classList.add('fw-player-video');
|
|
135
|
+
video.setAttribute('playsinline', '');
|
|
136
|
+
video.setAttribute('crossorigin', 'anonymous');
|
|
137
|
+
video.className = 'video-js vjs-default-skin fw-player-video';
|
|
138
|
+
|
|
139
|
+
if (options.autoplay) video.autoplay = true;
|
|
140
|
+
if (options.muted) video.muted = true;
|
|
141
|
+
video.controls = options.controls === true; // Explicit false to hide native controls
|
|
142
|
+
if (options.loop) video.loop = true;
|
|
143
|
+
if (options.poster) video.poster = options.poster;
|
|
144
|
+
|
|
145
|
+
this.videoElement = video;
|
|
146
|
+
container.appendChild(video);
|
|
147
|
+
|
|
148
|
+
this.setupVideoEventListeners(video, options);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const mod = await import('video.js');
|
|
152
|
+
const videojs = (mod as any).default || (mod as any);
|
|
153
|
+
|
|
154
|
+
// When using custom controls (controls: false), disable ALL VideoJS UI elements
|
|
155
|
+
const useVideoJsControls = options.controls === true;
|
|
156
|
+
|
|
157
|
+
// Build VideoJS options
|
|
158
|
+
// NOTE: We disable UI components but NOT children array - that breaks playback
|
|
159
|
+
const vjsOptions: Record<string, any> = {
|
|
160
|
+
autoplay: options.autoplay,
|
|
161
|
+
controls: useVideoJsControls,
|
|
162
|
+
muted: options.muted,
|
|
163
|
+
sources: [{ src: source.url, type: this.getVideoJsType(source.type) }],
|
|
164
|
+
// Disable VideoJS UI components when using custom controls
|
|
165
|
+
loadingSpinner: useVideoJsControls,
|
|
166
|
+
bigPlayButton: useVideoJsControls,
|
|
167
|
+
textTrackDisplay: useVideoJsControls, // We handle subtitles ourselves
|
|
168
|
+
errorDisplay: useVideoJsControls,
|
|
169
|
+
controlBar: useVideoJsControls,
|
|
170
|
+
liveTracker: useVideoJsControls,
|
|
171
|
+
// Don't set children: [] - that can break internal VideoJS components
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Android < 7 workaround: enable overrideNative for HLS
|
|
175
|
+
const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
|
|
176
|
+
const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
|
|
177
|
+
if (androidVersion && androidVersion < 7) {
|
|
178
|
+
console.debug('[VideoJS] Android < 7 detected, enabling overrideNative');
|
|
179
|
+
vjsOptions.html5 = { hls: { overrideNative: true } };
|
|
180
|
+
vjsOptions.nativeAudioTracks = false;
|
|
181
|
+
vjsOptions.nativeVideoTracks = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.debug('[VideoJS] Creating player with options:', vjsOptions);
|
|
185
|
+
this.videojsPlayer = videojs(video, vjsOptions);
|
|
186
|
+
console.debug('[VideoJS] Player created');
|
|
187
|
+
|
|
188
|
+
// Hide VideoJS UI completely when using custom controls
|
|
189
|
+
if (!useVideoJsControls) {
|
|
190
|
+
// Add class to hide all VideoJS chrome
|
|
191
|
+
const wrapper = this.videojsPlayer.el();
|
|
192
|
+
if (wrapper) {
|
|
193
|
+
wrapper.classList.add('vjs-fw-custom-controls');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Error handling with Firefox NS_ERROR detection
|
|
198
|
+
this.videojsPlayer.on('error', () => {
|
|
199
|
+
if (this.destroyed) return; // Guard against zombie callbacks
|
|
200
|
+
const err = this.videojsPlayer?.error();
|
|
201
|
+
const errorMsg = err?.message || '';
|
|
202
|
+
|
|
203
|
+
// Firefox-specific segment error - trigger reload
|
|
204
|
+
if (errorMsg.includes('NS_ERROR_DOM_MEDIA_OVERFLOW_ERR')) {
|
|
205
|
+
console.warn('[VideoJS] Firefox segment error, requesting reload');
|
|
206
|
+
this.emit('reloadrequested', { reason: 'NS_ERROR_DOM_MEDIA_OVERFLOW_ERR' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.emit('error', errorMsg || 'VideoJS playback error');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// FIX: Explicitly trigger play after VideoJS is ready
|
|
214
|
+
// VideoJS autoplay option alone doesn't always work (browser policies)
|
|
215
|
+
this.videojsPlayer.ready(() => {
|
|
216
|
+
if (this.destroyed) return; // Guard against zombie callbacks
|
|
217
|
+
|
|
218
|
+
// Debug: Log VideoJS tech info
|
|
219
|
+
const tech = this.videojsPlayer.tech?.({ IWillNotUseThisInPlugins: true });
|
|
220
|
+
console.debug('[VideoJS] ready - tech:', tech?.name || 'unknown',
|
|
221
|
+
'videoWidth:', video.videoWidth,
|
|
222
|
+
'videoHeight:', video.videoHeight,
|
|
223
|
+
'readyState:', video.readyState,
|
|
224
|
+
'networkState:', video.networkState);
|
|
225
|
+
|
|
226
|
+
// Create time-corrected proxy for external consumers
|
|
227
|
+
if (this.currentStreamInfo) {
|
|
228
|
+
this.proxyElement = this.createTimeCorrectedProxy(video, this.currentStreamInfo);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check if live stream and set up LiveDurationProxy
|
|
232
|
+
const duration = this.videojsPlayer.duration();
|
|
233
|
+
if (!isFinite(duration) && !this.liveDurationProxy) {
|
|
234
|
+
this.liveDurationProxy = new LiveDurationProxy(video, {
|
|
235
|
+
constrainSeek: true,
|
|
236
|
+
liveOffset: 0,
|
|
237
|
+
});
|
|
238
|
+
console.debug('[VideoJS] LiveDurationProxy initialized for live stream');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (options.autoplay) {
|
|
242
|
+
// Ensure muted for autoplay - browsers block unmuted autoplay
|
|
243
|
+
if (!video.muted) {
|
|
244
|
+
video.muted = true;
|
|
245
|
+
}
|
|
246
|
+
this.videojsPlayer.play().catch((e: any) => {
|
|
247
|
+
console.warn('VideoJS autoplay failed:', e);
|
|
248
|
+
// Emit a warning but don't fail - user can click play
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Listen for VideoJS loadedmetadata to track loading progress
|
|
254
|
+
this.videojsPlayer.on('loadedmetadata', () => {
|
|
255
|
+
console.debug('[VideoJS] loadedmetadata - duration:', this.videojsPlayer.duration(),
|
|
256
|
+
'videoWidth:', video.videoWidth, 'videoHeight:', video.videoHeight);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Debug: Track VHS (video.js http-streaming) state
|
|
260
|
+
this.videojsPlayer.on('loadeddata', () => {
|
|
261
|
+
const tech = this.videojsPlayer.tech?.({ IWillNotUseThisInPlugins: true });
|
|
262
|
+
const vhs = tech?.vhs || tech?.hls;
|
|
263
|
+
if (vhs) {
|
|
264
|
+
console.debug('[VideoJS] VHS state -',
|
|
265
|
+
'bandwidth:', vhs.bandwidth,
|
|
266
|
+
'seekable:', vhs.seekable?.()?.length > 0 ? `${vhs.seekable().start(0)}-${vhs.seekable().end(0)}` : 'none',
|
|
267
|
+
'buffered:', video.buffered.length > 0 ? `${video.buffered.end(0)}s` : 'none');
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Listen for canplay from VideoJS to ensure we transition out of buffering
|
|
272
|
+
this.videojsPlayer.on('canplay', () => {
|
|
273
|
+
console.debug('[VideoJS] canplay');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Additional debug events
|
|
277
|
+
this.videojsPlayer.on('playing', () => {
|
|
278
|
+
console.debug('[VideoJS] playing - currentTime:', this.videojsPlayer.currentTime());
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
this.videojsPlayer.on('waiting', () => {
|
|
282
|
+
console.debug('[VideoJS] waiting/buffering');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
this.videojsPlayer.on('stalled', () => {
|
|
286
|
+
console.debug('[VideoJS] stalled');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Log video element state
|
|
290
|
+
video.addEventListener('loadeddata', () => {
|
|
291
|
+
console.debug('[VideoJS] video loadeddata - readyState:', video.readyState,
|
|
292
|
+
'videoWidth:', video.videoWidth, 'videoHeight:', video.videoHeight);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Emit seekable range updates for live streams (DVR support)
|
|
296
|
+
this.videojsPlayer.on('progress', () => {
|
|
297
|
+
if (this.destroyed) return;
|
|
298
|
+
try {
|
|
299
|
+
const seekable = this.videojsPlayer.seekable();
|
|
300
|
+
if (seekable && seekable.length > 0) {
|
|
301
|
+
const start = seekable.start(0);
|
|
302
|
+
const end = seekable.end(seekable.length - 1);
|
|
303
|
+
const bufferWindow = (end - start) * 1000; // Convert to ms
|
|
304
|
+
this.emit('seekablechange', {
|
|
305
|
+
start: start + this.timeCorrection,
|
|
306
|
+
end: end + this.timeCorrection,
|
|
307
|
+
bufferWindow
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// Seekable not available yet
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return this.proxyElement || video;
|
|
316
|
+
|
|
317
|
+
} catch (error: any) {
|
|
318
|
+
this.emit('error', error.message || String(error));
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Creates a Proxy wrapper around the video element that corrects
|
|
325
|
+
* currentTime/duration/buffered using the firstms offset from MistServer.
|
|
326
|
+
* This ensures timestamps align with MistServer's track metadata.
|
|
327
|
+
*/
|
|
328
|
+
private createTimeCorrectedProxy(video: HTMLVideoElement, streamInfo: StreamInfo): HTMLVideoElement {
|
|
329
|
+
// Calculate correction from minimum firstms across all tracks
|
|
330
|
+
let firstms = Infinity;
|
|
331
|
+
for (const track of streamInfo.meta.tracks) {
|
|
332
|
+
if ((track as any).firstms !== undefined && (track as any).firstms < firstms) {
|
|
333
|
+
firstms = (track as any).firstms;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
this.timeCorrection = firstms !== Infinity ? firstms / 1000 : 0;
|
|
337
|
+
|
|
338
|
+
// No correction needed or Proxy not supported
|
|
339
|
+
if (this.timeCorrection === 0 || typeof Proxy === 'undefined') {
|
|
340
|
+
return video;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.debug(`[VideoJS] Applying timestamp correction: ${this.timeCorrection}s (firstms=${firstms})`);
|
|
344
|
+
|
|
345
|
+
const correction = this.timeCorrection;
|
|
346
|
+
const vjsPlayer = this.videojsPlayer;
|
|
347
|
+
|
|
348
|
+
return new Proxy(video, {
|
|
349
|
+
get: (target, prop) => {
|
|
350
|
+
if (prop === 'currentTime') {
|
|
351
|
+
const time = vjsPlayer ? vjsPlayer.currentTime() : target.currentTime;
|
|
352
|
+
return isNaN(time) ? 0 : time + correction;
|
|
353
|
+
}
|
|
354
|
+
if (prop === 'duration') {
|
|
355
|
+
const duration = target.duration;
|
|
356
|
+
return isNaN(duration) ? 0 : duration + correction;
|
|
357
|
+
}
|
|
358
|
+
if (prop === 'buffered') {
|
|
359
|
+
const buffered = target.buffered;
|
|
360
|
+
return {
|
|
361
|
+
length: buffered.length,
|
|
362
|
+
start: (i: number) => buffered.start(i) + correction,
|
|
363
|
+
end: (i: number) => buffered.end(i) + correction,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const value = target[prop as keyof HTMLVideoElement];
|
|
367
|
+
if (typeof value === 'function') {
|
|
368
|
+
return value.bind(target);
|
|
369
|
+
}
|
|
370
|
+
return value;
|
|
371
|
+
},
|
|
372
|
+
set: (target, prop, value) => {
|
|
373
|
+
if (prop === 'currentTime') {
|
|
374
|
+
// Use VideoJS API for seeking (fixes backwards seeking in HLS)
|
|
375
|
+
const correctedValue = value - correction;
|
|
376
|
+
if (vjsPlayer) {
|
|
377
|
+
vjsPlayer.currentTime(correctedValue);
|
|
378
|
+
} else {
|
|
379
|
+
target.currentTime = correctedValue;
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
(target as any)[prop] = value;
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
}) as HTMLVideoElement;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private getVideoJsType(mimeType?: string): string {
|
|
390
|
+
if (!mimeType) return 'application/x-mpegURL';
|
|
391
|
+
|
|
392
|
+
// Convert our mime types to VideoJS types
|
|
393
|
+
if (mimeType.includes('mpegurl')) return 'application/x-mpegURL';
|
|
394
|
+
if (mimeType.includes('dash')) return 'application/dash+xml';
|
|
395
|
+
if (mimeType.includes('mp4')) return 'video/mp4';
|
|
396
|
+
if (mimeType.includes('webm')) return 'video/webm';
|
|
397
|
+
|
|
398
|
+
return mimeType.replace('html5/', '');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
setPlaybackRate(rate: number): void {
|
|
402
|
+
super.setPlaybackRate(rate);
|
|
403
|
+
try {
|
|
404
|
+
if (this.videojsPlayer) this.videojsPlayer.playbackRate(rate);
|
|
405
|
+
} catch {}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
getCurrentTime(): number {
|
|
409
|
+
const v = this.proxyElement || this.videoElement;
|
|
410
|
+
return v?.currentTime ?? 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
getDuration(): number {
|
|
414
|
+
const v = this.proxyElement || this.videoElement;
|
|
415
|
+
return v?.duration ?? 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
getSeekableRange(): { start: number; end: number } | null {
|
|
419
|
+
if (this.videojsPlayer?.seekable) {
|
|
420
|
+
try {
|
|
421
|
+
const seekable = this.videojsPlayer.seekable();
|
|
422
|
+
if (seekable && seekable.length > 0) {
|
|
423
|
+
const start = seekable.start(0) + this.timeCorrection;
|
|
424
|
+
const end = seekable.end(seekable.length - 1) + this.timeCorrection;
|
|
425
|
+
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
|
426
|
+
return { start, end };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch {}
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Seek to time using VideoJS API (fixes backwards seeking in HLS).
|
|
436
|
+
* Time should be in the corrected coordinate space (with firstms offset applied).
|
|
437
|
+
*/
|
|
438
|
+
seek(time: number): void {
|
|
439
|
+
const correctedTime = time - this.timeCorrection;
|
|
440
|
+
if (this.videojsPlayer) {
|
|
441
|
+
this.videojsPlayer.currentTime(correctedTime);
|
|
442
|
+
} else if (this.videoElement) {
|
|
443
|
+
this.videoElement.currentTime = correctedTime;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Get VideoJS-specific stats for playback monitoring
|
|
449
|
+
*/
|
|
450
|
+
async getStats(): Promise<{
|
|
451
|
+
type: 'videojs';
|
|
452
|
+
buffered: number;
|
|
453
|
+
currentTime: number;
|
|
454
|
+
duration: number;
|
|
455
|
+
readyState: number;
|
|
456
|
+
networkState: number;
|
|
457
|
+
playbackRate: number;
|
|
458
|
+
} | undefined> {
|
|
459
|
+
const video = this.videoElement;
|
|
460
|
+
if (!video) return undefined;
|
|
461
|
+
|
|
462
|
+
// Calculate buffered ahead of current position
|
|
463
|
+
let buffered = 0;
|
|
464
|
+
if (video.buffered.length > 0) {
|
|
465
|
+
for (let i = 0; i < video.buffered.length; i++) {
|
|
466
|
+
if (video.buffered.start(i) <= video.currentTime && video.buffered.end(i) > video.currentTime) {
|
|
467
|
+
buffered = video.buffered.end(i) - video.currentTime;
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
type: 'videojs',
|
|
475
|
+
buffered,
|
|
476
|
+
currentTime: video.currentTime,
|
|
477
|
+
duration: video.duration,
|
|
478
|
+
readyState: video.readyState,
|
|
479
|
+
networkState: video.networkState,
|
|
480
|
+
playbackRate: video.playbackRate,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ============================================================================
|
|
485
|
+
// Live Stream Support
|
|
486
|
+
// ============================================================================
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Check if the stream is live
|
|
490
|
+
*/
|
|
491
|
+
isLiveStream(): boolean {
|
|
492
|
+
if (this.liveDurationProxy) {
|
|
493
|
+
return this.liveDurationProxy.isLive();
|
|
494
|
+
}
|
|
495
|
+
const video = this.videoElement;
|
|
496
|
+
if (!video) return false;
|
|
497
|
+
return !isFinite(video.duration);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Get the calculated duration for live streams
|
|
502
|
+
*/
|
|
503
|
+
getDuration(): number {
|
|
504
|
+
if (this.liveDurationProxy) {
|
|
505
|
+
return this.liveDurationProxy.getDuration();
|
|
506
|
+
}
|
|
507
|
+
return this.videoElement?.duration ?? 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Jump to live edge
|
|
512
|
+
* Uses VideoJS liveTracker when available, otherwise LiveDurationProxy
|
|
513
|
+
*/
|
|
514
|
+
jumpToLive(): void {
|
|
515
|
+
const video = this.videoElement;
|
|
516
|
+
if (!video) return;
|
|
517
|
+
|
|
518
|
+
// VideoJS has a liveTracker module for live streams
|
|
519
|
+
if (this.videojsPlayer && this.videojsPlayer.liveTracker) {
|
|
520
|
+
const tracker = this.videojsPlayer.liveTracker;
|
|
521
|
+
if (tracker.isLive && tracker.isLive()) {
|
|
522
|
+
const liveCurrentTime = tracker.liveCurrentTime?.();
|
|
523
|
+
if (typeof liveCurrentTime === 'number' && liveCurrentTime > 0) {
|
|
524
|
+
console.debug('[VideoJS] jumpToLive using liveTracker:', liveCurrentTime);
|
|
525
|
+
this.videojsPlayer.currentTime(liveCurrentTime);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Fall back to LiveDurationProxy
|
|
532
|
+
if (this.liveDurationProxy && this.liveDurationProxy.isLive()) {
|
|
533
|
+
console.debug('[VideoJS] jumpToLive using LiveDurationProxy');
|
|
534
|
+
this.liveDurationProxy.jumpToLive();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// VideoJS seekable fallback
|
|
539
|
+
if (this.videojsPlayer) {
|
|
540
|
+
try {
|
|
541
|
+
const seekable = this.videojsPlayer.seekable();
|
|
542
|
+
if (seekable && seekable.length > 0) {
|
|
543
|
+
const liveEdge = seekable.end(seekable.length - 1);
|
|
544
|
+
if (isFinite(liveEdge) && liveEdge > 0) {
|
|
545
|
+
console.debug('[VideoJS] jumpToLive using seekable.end:', liveEdge);
|
|
546
|
+
this.videojsPlayer.currentTime(liveEdge);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} catch {}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Native video seekable fallback
|
|
554
|
+
if (video.seekable && video.seekable.length > 0) {
|
|
555
|
+
const liveEdge = video.seekable.end(video.seekable.length - 1);
|
|
556
|
+
if (isFinite(liveEdge) && liveEdge > 0) {
|
|
557
|
+
console.debug('[VideoJS] jumpToLive using video.seekable.end:', liveEdge);
|
|
558
|
+
video.currentTime = liveEdge;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Provide a seekable range override for live streams.
|
|
565
|
+
* Uses VideoJS liveTracker seekableEnd as the live edge when available.
|
|
566
|
+
*/
|
|
567
|
+
getSeekableRange(): { start: number; end: number } | null {
|
|
568
|
+
const video = this.videoElement;
|
|
569
|
+
if (!video?.seekable || video.seekable.length === 0) return null;
|
|
570
|
+
let start = video.seekable.start(0);
|
|
571
|
+
let end = video.seekable.end(video.seekable.length - 1);
|
|
572
|
+
|
|
573
|
+
if (this.videojsPlayer?.liveTracker) {
|
|
574
|
+
const tracker = this.videojsPlayer.liveTracker;
|
|
575
|
+
const trackerEnd = tracker.seekableEnd?.();
|
|
576
|
+
const trackerStart = tracker.seekableStart?.();
|
|
577
|
+
if (typeof trackerStart === 'number' && Number.isFinite(trackerStart)) {
|
|
578
|
+
start = trackerStart;
|
|
579
|
+
}
|
|
580
|
+
if (typeof trackerEnd === 'number' && Number.isFinite(trackerEnd) && trackerEnd > 0) {
|
|
581
|
+
end = Math.min(end, trackerEnd);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
|
|
586
|
+
return { start, end };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Get latency from live edge (for live streams)
|
|
591
|
+
*/
|
|
592
|
+
getLiveLatency(): number {
|
|
593
|
+
const video = this.videoElement;
|
|
594
|
+
if (!video) return 0;
|
|
595
|
+
|
|
596
|
+
// VideoJS liveTracker provides seekableEnd
|
|
597
|
+
if (this.videojsPlayer && this.videojsPlayer.liveTracker) {
|
|
598
|
+
const tracker = this.videojsPlayer.liveTracker;
|
|
599
|
+
if (tracker.isLive?.() && typeof tracker.seekableEnd === 'function') {
|
|
600
|
+
const liveEdge = tracker.seekableEnd();
|
|
601
|
+
if (typeof liveEdge === 'number' && isFinite(liveEdge)) {
|
|
602
|
+
return Math.max(0, (liveEdge - video.currentTime) * 1000);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Fall back to proxy
|
|
608
|
+
if (this.liveDurationProxy) {
|
|
609
|
+
return this.liveDurationProxy.getLatency() * 1000;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async destroy(): Promise<void> {
|
|
616
|
+
this.destroyed = true;
|
|
617
|
+
|
|
618
|
+
if (this.liveDurationProxy) {
|
|
619
|
+
this.liveDurationProxy.destroy();
|
|
620
|
+
this.liveDurationProxy = null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (this.videojsPlayer) {
|
|
624
|
+
try {
|
|
625
|
+
this.videojsPlayer.dispose();
|
|
626
|
+
} catch (e) {
|
|
627
|
+
console.warn('Error disposing VideoJS:', e);
|
|
628
|
+
}
|
|
629
|
+
this.videojsPlayer = null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (this.videoElement && this.container) {
|
|
633
|
+
try { this.container.removeChild(this.videoElement); } catch {}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
this.videoElement = null;
|
|
637
|
+
this.container = null;
|
|
638
|
+
this.proxyElement = null;
|
|
639
|
+
this.currentStreamInfo = null;
|
|
640
|
+
this.timeCorrection = 0;
|
|
641
|
+
this.listeners.clear();
|
|
642
|
+
}
|
|
643
|
+
}
|