@livepeer-frameworks/player-core 0.0.4 → 0.1.1
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/README.md +21 -6
- package/dist/cjs/index.js +792 -146
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +792 -146
- package/dist/esm/index.js.map +1 -1
- package/dist/player.css +185 -373
- package/dist/types/core/GatewayClient.d.ts +3 -4
- package/dist/types/core/InteractionController.d.ts +12 -0
- package/dist/types/core/MetaTrackManager.d.ts +1 -1
- package/dist/types/core/PlayerController.d.ts +18 -2
- package/dist/types/core/PlayerInterface.d.ts +10 -0
- package/dist/types/core/SeekingUtils.d.ts +3 -1
- package/dist/types/core/StreamStateClient.d.ts +1 -1
- package/dist/types/players/HlsJsPlayer.d.ts +8 -0
- package/dist/types/players/MewsWsPlayer/index.d.ts +1 -1
- package/dist/types/players/VideoJsPlayer.d.ts +12 -4
- package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +1 -1
- package/dist/types/players/WebCodecsPlayer/index.d.ts +11 -0
- package/dist/types/players/WebCodecsPlayer/types.d.ts +25 -3
- package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +20 -2
- package/dist/types/types.d.ts +32 -1
- package/dist/types/vanilla/FrameWorksPlayer.d.ts +5 -5
- package/dist/types/vanilla/index.d.ts +3 -3
- package/dist/workers/decoder.worker.js +183 -6
- package/dist/workers/decoder.worker.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ABRController.ts +38 -36
- package/src/core/CodecUtils.ts +50 -47
- package/src/core/Disposable.ts +4 -4
- package/src/core/EventEmitter.ts +1 -1
- package/src/core/GatewayClient.ts +48 -48
- package/src/core/InteractionController.ts +89 -82
- package/src/core/LiveDurationProxy.ts +14 -16
- package/src/core/MetaTrackManager.ts +74 -66
- package/src/core/MistReporter.ts +72 -45
- package/src/core/MistSignaling.ts +59 -56
- package/src/core/PlayerController.ts +724 -375
- package/src/core/PlayerInterface.ts +89 -59
- package/src/core/PlayerManager.ts +118 -123
- package/src/core/PlayerRegistry.ts +59 -42
- package/src/core/QualityMonitor.ts +38 -31
- package/src/core/ScreenWakeLockManager.ts +8 -9
- package/src/core/SeekingUtils.ts +31 -22
- package/src/core/StreamStateClient.ts +75 -69
- package/src/core/SubtitleManager.ts +25 -23
- package/src/core/TelemetryReporter.ts +34 -31
- package/src/core/TimeFormat.ts +13 -17
- package/src/core/TimerManager.ts +25 -9
- package/src/core/UrlUtils.ts +20 -17
- package/src/core/detector.ts +44 -44
- package/src/core/index.ts +57 -48
- package/src/core/scorer.ts +137 -138
- package/src/core/selector.ts +2 -6
- package/src/global.d.ts +1 -1
- package/src/index.ts +46 -35
- package/src/players/DashJsPlayer.ts +175 -114
- package/src/players/HlsJsPlayer.ts +154 -76
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +44 -39
- package/src/players/MewsWsPlayer/WebSocketManager.ts +9 -10
- package/src/players/MewsWsPlayer/index.ts +196 -154
- package/src/players/MewsWsPlayer/types.ts +21 -21
- package/src/players/MistPlayer.ts +46 -27
- package/src/players/MistWebRTCPlayer/index.ts +175 -129
- package/src/players/NativePlayer.ts +203 -143
- package/src/players/VideoJsPlayer.ts +200 -146
- package/src/players/WebCodecsPlayer/JitterBuffer.ts +6 -7
- package/src/players/WebCodecsPlayer/LatencyProfiles.ts +43 -43
- package/src/players/WebCodecsPlayer/RawChunkParser.ts +10 -10
- package/src/players/WebCodecsPlayer/SyncController.ts +46 -55
- package/src/players/WebCodecsPlayer/WebSocketController.ts +67 -69
- package/src/players/WebCodecsPlayer/index.ts +280 -220
- package/src/players/WebCodecsPlayer/polyfills/MediaStreamTrackGenerator.ts +12 -17
- package/src/players/WebCodecsPlayer/types.ts +81 -53
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +255 -192
- package/src/players/WebCodecsPlayer/worker/types.ts +33 -29
- package/src/players/index.ts +8 -8
- package/src/styles/animations.css +2 -1
- package/src/styles/player.css +182 -356
- package/src/styles/tailwind.css +473 -159
- package/src/types.ts +75 -33
- package/src/vanilla/FrameWorksPlayer.ts +34 -19
- package/src/vanilla/index.ts +7 -7
package/dist/cjs/index.js
CHANGED
|
@@ -161,7 +161,7 @@ function extractHEVCProfile(init) {
|
|
|
161
161
|
const profileIdc = bytes[i];
|
|
162
162
|
if (profileIdc >= 1 && profileIdc <= 5) {
|
|
163
163
|
// Valid profile IDC (1=Main, 2=Main10, 3=MainStill, 4=Range Extensions, 5=High Throughput)
|
|
164
|
-
|
|
164
|
+
// tierFlag assumed to be 0 (main tier)
|
|
165
165
|
const levelIdc = bytes[i + 1] || 93; // Default to level 3.1
|
|
166
166
|
// Format: hev1.{profile}.{tier_flag}{compatibility}.L{level}.{constraints}
|
|
167
167
|
return `hev1.${profileIdc}.6.L${levelIdc}.B0`;
|
|
@@ -602,6 +602,9 @@ const PROTOCOL_PENALTIES = {
|
|
|
602
602
|
// MEWS - heavy penalty, prefer HLS/WebRTC (reference mews.js has issues)
|
|
603
603
|
'ws/video/mp4': 0.50,
|
|
604
604
|
'wss/video/mp4': 0.50,
|
|
605
|
+
// Native Mist WebRTC signaling - treat like MEWS (legacy/less stable than WHEP)
|
|
606
|
+
'webrtc': 0.50,
|
|
607
|
+
'mist/webrtc': 0.50,
|
|
605
608
|
// DASH - heavy penalty, broken implementation
|
|
606
609
|
'dash/video/mp4': 0.90, // Below legacy
|
|
607
610
|
'dash/video/webm': 0.95,
|
|
@@ -674,8 +677,8 @@ const MODE_PROTOCOL_BONUSES = {
|
|
|
674
677
|
'wss/video/h264': 0.52,
|
|
675
678
|
// WHEP/WebRTC: sub-second latency
|
|
676
679
|
'whep': 0.50,
|
|
677
|
-
'webrtc': 0.
|
|
678
|
-
'mist/webrtc': 0.
|
|
680
|
+
'webrtc': 0.25,
|
|
681
|
+
'mist/webrtc': 0.25,
|
|
679
682
|
// MP4/WS (MEWS): 2-5s latency, good fallback
|
|
680
683
|
'ws/video/mp4': 0.30,
|
|
681
684
|
'wss/video/mp4': 0.30,
|
|
@@ -699,6 +702,7 @@ const MODE_PROTOCOL_BONUSES = {
|
|
|
699
702
|
// WebRTC: minimal for quality mode
|
|
700
703
|
'whep': 0.05,
|
|
701
704
|
'webrtc': 0.05,
|
|
705
|
+
'mist/webrtc': 0.05,
|
|
702
706
|
},
|
|
703
707
|
'vod': {
|
|
704
708
|
// VOD/Clip: Prefer seekable protocols, EXCLUDE WebRTC (no seek support)
|
|
@@ -722,8 +726,8 @@ const MODE_PROTOCOL_BONUSES = {
|
|
|
722
726
|
'html5/video/mp4': 0.42,
|
|
723
727
|
// WHEP/WebRTC: good for low latency
|
|
724
728
|
'whep': 0.38,
|
|
725
|
-
'webrtc': 0.
|
|
726
|
-
'mist/webrtc': 0.
|
|
729
|
+
'webrtc': 0.20,
|
|
730
|
+
'mist/webrtc': 0.20,
|
|
727
731
|
// MP4/WS (MEWS): lower latency than HLS
|
|
728
732
|
'ws/video/mp4': 0.30,
|
|
729
733
|
'wss/video/mp4': 0.30,
|
|
@@ -759,10 +763,12 @@ const PROTOCOL_ROUTING = {
|
|
|
759
763
|
'wss/video/webm': { prefer: ['mews'] },
|
|
760
764
|
// HLS
|
|
761
765
|
'html5/application/vnd.apple.mpegurl': {
|
|
762
|
-
prefer: ['videojs', '
|
|
766
|
+
prefer: ['videojs', 'hlsjs'],
|
|
767
|
+
avoid: ['native'],
|
|
763
768
|
},
|
|
764
769
|
'html5/application/vnd.apple.mpegurl;version=7': {
|
|
765
|
-
prefer: ['videojs', '
|
|
770
|
+
prefer: ['videojs', 'hlsjs'],
|
|
771
|
+
avoid: ['native'],
|
|
766
772
|
},
|
|
767
773
|
// DASH
|
|
768
774
|
'dash/video/mp4': { prefer: ['dashjs', 'videojs'] },
|
|
@@ -1093,6 +1099,13 @@ class PlayerManager {
|
|
|
1093
1099
|
const selectionIndexBySource = new Map();
|
|
1094
1100
|
selectionSources.forEach((s, idx) => selectionIndexBySource.set(s, idx));
|
|
1095
1101
|
const totalSources = selectionSources.length;
|
|
1102
|
+
const requiredTracks = [];
|
|
1103
|
+
if (streamInfo.meta.tracks.some((t) => t.type === 'video')) {
|
|
1104
|
+
requiredTracks.push('video');
|
|
1105
|
+
}
|
|
1106
|
+
if (streamInfo.meta.tracks.some((t) => t.type === 'audio')) {
|
|
1107
|
+
requiredTracks.push('audio');
|
|
1108
|
+
}
|
|
1096
1109
|
// Track seen player+sourceType pairs to avoid duplicates
|
|
1097
1110
|
const seenPairs = new Set();
|
|
1098
1111
|
for (const player of players) {
|
|
@@ -1166,6 +1179,38 @@ class PlayerManager {
|
|
|
1166
1179
|
});
|
|
1167
1180
|
continue;
|
|
1168
1181
|
}
|
|
1182
|
+
if (Array.isArray(tracktypes) && requiredTracks.length > 0) {
|
|
1183
|
+
const missing = requiredTracks.filter((t) => !tracktypes.includes(t));
|
|
1184
|
+
if (missing.length > 0) {
|
|
1185
|
+
const priorityScore = 1 - player.capability.priority / Math.max(maxPriority, 1);
|
|
1186
|
+
const sourceScore = 1 - sourceListIndex / Math.max(totalSources - 1, 1);
|
|
1187
|
+
const playerScore = scorePlayer(tracktypes, player.capability.priority, sourceListIndex, {
|
|
1188
|
+
maxPriority,
|
|
1189
|
+
totalSources,
|
|
1190
|
+
playerShortname: player.capability.shortname,
|
|
1191
|
+
mimeType: source.type,
|
|
1192
|
+
playbackMode: effectiveMode,
|
|
1193
|
+
});
|
|
1194
|
+
combinations.push({
|
|
1195
|
+
player: player.capability.shortname,
|
|
1196
|
+
playerName: player.capability.name,
|
|
1197
|
+
source,
|
|
1198
|
+
sourceIndex,
|
|
1199
|
+
sourceType: source.type,
|
|
1200
|
+
score: playerScore.total,
|
|
1201
|
+
compatible: false,
|
|
1202
|
+
incompatibleReason: `Missing required tracks: ${missing.join(', ')}`,
|
|
1203
|
+
scoreBreakdown: {
|
|
1204
|
+
trackScore: 0,
|
|
1205
|
+
trackTypes: tracktypes,
|
|
1206
|
+
priorityScore,
|
|
1207
|
+
sourceScore,
|
|
1208
|
+
weights: { tracks: 0.5, priority: 0.1, source: 0.05 },
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1169
1214
|
// Compatible - calculate full score
|
|
1170
1215
|
const trackScore = Array.isArray(tracktypes)
|
|
1171
1216
|
? tracktypes.reduce((sum, t) => sum + ({ video: 2.0, audio: 1.0, subtitle: 0.5 }[t] || 0), 0)
|
|
@@ -1625,8 +1670,8 @@ const DEFAULT_CACHE_TTL_MS = 10000;
|
|
|
1625
1670
|
const CIRCUIT_BREAKER_THRESHOLD = 5; // Open after 5 consecutive failures
|
|
1626
1671
|
const CIRCUIT_BREAKER_TIMEOUT_MS = 30000; // Half-open after 30 seconds
|
|
1627
1672
|
const RESOLVE_VIEWER_QUERY = `
|
|
1628
|
-
query ResolveViewer($
|
|
1629
|
-
resolveViewerEndpoint(
|
|
1673
|
+
query ResolveViewer($contentId: String!) {
|
|
1674
|
+
resolveViewerEndpoint(contentId: $contentId) {
|
|
1630
1675
|
primary { nodeId baseUrl protocol url geoDistance loadScore outputs }
|
|
1631
1676
|
fallbacks { nodeId baseUrl protocol url geoDistance loadScore outputs }
|
|
1632
1677
|
metadata { contentType contentId title description durationSeconds status isLive viewers recordingSizeBytes clipSource createdAt }
|
|
@@ -1672,8 +1717,7 @@ async function fetchWithRetry(url, options, maxRetries, initialDelay) {
|
|
|
1672
1717
|
* ```typescript
|
|
1673
1718
|
* const client = new GatewayClient({
|
|
1674
1719
|
* gatewayUrl: 'https://gateway.example.com/graphql',
|
|
1675
|
-
*
|
|
1676
|
-
* contentId: 'my-stream',
|
|
1720
|
+
* contentId: 'pk_...', // playbackId (view key)
|
|
1677
1721
|
* });
|
|
1678
1722
|
*
|
|
1679
1723
|
* client.on('statusChange', ({ status }) => console.log('Status:', status));
|
|
@@ -1827,10 +1871,10 @@ class GatewayClient extends TypedEventEmitter {
|
|
|
1827
1871
|
async doResolve() {
|
|
1828
1872
|
// Abort any in-flight fetch (different from inFlightRequest promise tracking)
|
|
1829
1873
|
this.abort();
|
|
1830
|
-
const { gatewayUrl,
|
|
1874
|
+
const { gatewayUrl, contentId, authToken, maxRetries = DEFAULT_MAX_RETRIES, initialDelayMs = DEFAULT_INITIAL_DELAY_MS, } = this.config;
|
|
1831
1875
|
// Validate required params
|
|
1832
|
-
if (!gatewayUrl || !
|
|
1833
|
-
const error = 'Missing required parameters: gatewayUrl
|
|
1876
|
+
if (!gatewayUrl || !contentId) {
|
|
1877
|
+
const error = 'Missing required parameters: gatewayUrl or contentId';
|
|
1834
1878
|
this.setStatus('error', error);
|
|
1835
1879
|
throw new Error(error);
|
|
1836
1880
|
}
|
|
@@ -1847,7 +1891,7 @@ class GatewayClient extends TypedEventEmitter {
|
|
|
1847
1891
|
},
|
|
1848
1892
|
body: JSON.stringify({
|
|
1849
1893
|
query: RESOLVE_VIEWER_QUERY,
|
|
1850
|
-
variables: {
|
|
1894
|
+
variables: { contentId },
|
|
1851
1895
|
}),
|
|
1852
1896
|
signal: ac.signal,
|
|
1853
1897
|
}, maxRetries, initialDelayMs);
|
|
@@ -2059,7 +2103,7 @@ class TimerManager {
|
|
|
2059
2103
|
*/
|
|
2060
2104
|
stopAll() {
|
|
2061
2105
|
const count = this.timers.size;
|
|
2062
|
-
for (const [
|
|
2106
|
+
for (const [_internalId, entry] of this.timers) {
|
|
2063
2107
|
if (entry.isInterval) {
|
|
2064
2108
|
clearInterval(entry.id);
|
|
2065
2109
|
}
|
|
@@ -2191,7 +2235,7 @@ function getStatusMessage(status, percentage) {
|
|
|
2191
2235
|
* ```typescript
|
|
2192
2236
|
* const client = new StreamStateClient({
|
|
2193
2237
|
* mistBaseUrl: 'https://mist.example.com',
|
|
2194
|
-
* streamName: '
|
|
2238
|
+
* streamName: 'pk_...', // playbackId (view key)
|
|
2195
2239
|
* });
|
|
2196
2240
|
*
|
|
2197
2241
|
* client.on('stateChange', ({ state }) => console.log('State:', state));
|
|
@@ -2342,7 +2386,7 @@ class StreamStateClient extends TypedEventEmitter {
|
|
|
2342
2386
|
.replace(/^http:/, 'ws:')
|
|
2343
2387
|
.replace(/^https:/, 'wss:')
|
|
2344
2388
|
.replace(/\/$/, '');
|
|
2345
|
-
const ws = new WebSocket(`${wsUrl}/json_${encodeURIComponent(streamName)}.js`);
|
|
2389
|
+
const ws = new WebSocket(`${wsUrl}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`);
|
|
2346
2390
|
this.ws = ws;
|
|
2347
2391
|
ws.onopen = () => {
|
|
2348
2392
|
console.debug('[StreamStateClient] WebSocket connected');
|
|
@@ -2383,7 +2427,7 @@ class StreamStateClient extends TypedEventEmitter {
|
|
|
2383
2427
|
return;
|
|
2384
2428
|
const { mistBaseUrl, streamName, pollInterval } = this.config;
|
|
2385
2429
|
try {
|
|
2386
|
-
const url = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js`;
|
|
2430
|
+
const url = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`;
|
|
2387
2431
|
const response = await fetch(url, {
|
|
2388
2432
|
method: 'GET',
|
|
2389
2433
|
headers: { 'Accept': 'application/json' },
|
|
@@ -2796,7 +2840,6 @@ class LiveDurationProxy {
|
|
|
2796
2840
|
}
|
|
2797
2841
|
// Find valid seek range
|
|
2798
2842
|
const bufferStart = buffered.start(0);
|
|
2799
|
-
this.getBufferEnd();
|
|
2800
2843
|
const liveEdge = this.getLiveEdge();
|
|
2801
2844
|
// Clamp to valid range
|
|
2802
2845
|
const clampedTime = Math.max(bufferStart, Math.min(time, liveEdge));
|
|
@@ -3133,7 +3176,7 @@ class NativePlayerImpl extends BasePlayer {
|
|
|
3133
3176
|
const browser = getBrowserInfo();
|
|
3134
3177
|
// Safari cannot play WebM - skip entirely
|
|
3135
3178
|
// Reference: html5.js:28-29
|
|
3136
|
-
if (mimetype === 'html5/video/webm' && browser.
|
|
3179
|
+
if (mimetype === 'html5/video/webm' && browser.isSafari) {
|
|
3137
3180
|
return false;
|
|
3138
3181
|
}
|
|
3139
3182
|
// Special handling for HLS
|
|
@@ -3272,7 +3315,7 @@ class NativePlayerImpl extends BasePlayer {
|
|
|
3272
3315
|
// Use LiveDurationProxy for all live streams (non-WHEP)
|
|
3273
3316
|
// WHEP handles its own live edge via signaling
|
|
3274
3317
|
// This enables seeking and jump-to-live for native MP4/WebM/HLS live streams
|
|
3275
|
-
const isLiveStream = streamInfo?.type === 'live'
|
|
3318
|
+
const isLiveStream = streamInfo?.type === 'live';
|
|
3276
3319
|
if (source.type !== 'whep' && isLiveStream) {
|
|
3277
3320
|
this.setupLiveDurationProxy(video);
|
|
3278
3321
|
this.setupAutoRecovery(video);
|
|
@@ -3921,13 +3964,29 @@ class HlsJsPlayerImpl extends BasePlayer {
|
|
|
3921
3964
|
const Hls = mod.default || mod;
|
|
3922
3965
|
console.log('[HLS.js] hls.js module imported, Hls.isSupported():', Hls.isSupported?.());
|
|
3923
3966
|
if (Hls.isSupported()) {
|
|
3924
|
-
|
|
3967
|
+
// Build optimized HLS.js config with user overrides
|
|
3968
|
+
const hlsConfig = {
|
|
3969
|
+
// Worker disabled for lower latency (per HLS.js maintainer recommendation)
|
|
3925
3970
|
enableWorker: false,
|
|
3971
|
+
// LL-HLS support
|
|
3926
3972
|
lowLatencyMode: true,
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3973
|
+
// AGGRESSIVE: Assume 5 Mbps initially (not 500kbps default)
|
|
3974
|
+
// This dramatically improves startup time by selecting appropriate quality faster
|
|
3975
|
+
abrEwmaDefaultEstimate: 5000000,
|
|
3976
|
+
// AGGRESSIVE: Minimal buffers for fastest startup
|
|
3977
|
+
maxBufferLength: 6, // Reduced from 15 (just 2 segments @ 3s)
|
|
3978
|
+
maxMaxBufferLength: 15, // Reduced from 60
|
|
3979
|
+
backBufferLength: Infinity, // Let browser manage (per maintainer advice)
|
|
3980
|
+
// Stay close to live edge but not too aggressive
|
|
3981
|
+
liveSyncDuration: 4, // Target 4 seconds behind live edge
|
|
3982
|
+
liveMaxLatencyDuration: 8, // Max 8 seconds before seeking to live
|
|
3983
|
+
// Faster ABR adaptation for live
|
|
3984
|
+
abrEwmaFastLive: 2.0, // Faster than default 3.0
|
|
3985
|
+
abrEwmaSlowLive: 6.0, // Faster than default 9.0
|
|
3986
|
+
// Allow user overrides
|
|
3987
|
+
...options.hlsConfig,
|
|
3988
|
+
};
|
|
3989
|
+
this.hls = new Hls(hlsConfig);
|
|
3931
3990
|
this.hls.attachMedia(video);
|
|
3932
3991
|
this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
3933
3992
|
this.hls.loadSource(source.url);
|
|
@@ -4098,6 +4157,26 @@ class HlsJsPlayerImpl extends BasePlayer {
|
|
|
4098
4157
|
}
|
|
4099
4158
|
}
|
|
4100
4159
|
}
|
|
4160
|
+
/**
|
|
4161
|
+
* Provide a seekable range override for live streams.
|
|
4162
|
+
* Uses liveSyncPosition as the live edge to avoid waiting for the absolute end.
|
|
4163
|
+
*/
|
|
4164
|
+
getSeekableRange() {
|
|
4165
|
+
const video = this.videoElement;
|
|
4166
|
+
if (!video?.seekable || video.seekable.length === 0)
|
|
4167
|
+
return null;
|
|
4168
|
+
const start = video.seekable.start(0);
|
|
4169
|
+
let end = video.seekable.end(video.seekable.length - 1);
|
|
4170
|
+
if (this.liveDurationProxy?.isLive() && this.hls && typeof this.hls.liveSyncPosition === 'number') {
|
|
4171
|
+
const sync = this.hls.liveSyncPosition;
|
|
4172
|
+
if (Number.isFinite(sync) && sync > 0) {
|
|
4173
|
+
end = Math.min(end, sync);
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
4177
|
+
return null;
|
|
4178
|
+
return { start, end };
|
|
4179
|
+
}
|
|
4101
4180
|
/**
|
|
4102
4181
|
* Get latency from live edge (for live streams)
|
|
4103
4182
|
*/
|
|
@@ -4487,16 +4566,17 @@ class DashJsPlayerImpl extends BasePlayer {
|
|
|
4487
4566
|
console.debug('[DashJS v5] streamInitialized - isDynamic:', isDynamic);
|
|
4488
4567
|
});
|
|
4489
4568
|
// Configure dashjs v5 streaming settings BEFORE initialization
|
|
4569
|
+
// AGGRESSIVE settings for fastest startup and low latency
|
|
4490
4570
|
this.dashPlayer.updateSettings({
|
|
4491
4571
|
streaming: {
|
|
4492
|
-
//
|
|
4572
|
+
// AGGRESSIVE: Minimal buffers for fastest startup
|
|
4493
4573
|
buffer: {
|
|
4494
4574
|
fastSwitchEnabled: true,
|
|
4495
|
-
stableBufferTime: 16
|
|
4496
|
-
bufferTimeAtTopQuality: 30
|
|
4497
|
-
bufferTimeAtTopQualityLongForm: 60
|
|
4498
|
-
bufferToKeep: 30
|
|
4499
|
-
bufferPruningInterval: 30
|
|
4575
|
+
stableBufferTime: 4, // Reduced from 16 (aggressive!)
|
|
4576
|
+
bufferTimeAtTopQuality: 8, // Reduced from 30
|
|
4577
|
+
bufferTimeAtTopQualityLongForm: 15, // Reduced from 60
|
|
4578
|
+
bufferToKeep: 10, // Reduced from 30
|
|
4579
|
+
bufferPruningInterval: 10, // Reduced from 30
|
|
4500
4580
|
},
|
|
4501
4581
|
// Gaps/stall handling
|
|
4502
4582
|
gaps: {
|
|
@@ -4505,12 +4585,23 @@ class DashJsPlayerImpl extends BasePlayer {
|
|
|
4505
4585
|
smallGapLimit: 1.5,
|
|
4506
4586
|
threshold: 0.3,
|
|
4507
4587
|
},
|
|
4508
|
-
// ABR
|
|
4588
|
+
// AGGRESSIVE: ABR with high initial bitrate estimate
|
|
4509
4589
|
abr: {
|
|
4510
4590
|
autoSwitchBitrate: { video: true, audio: true },
|
|
4511
4591
|
limitBitrateByPortal: false,
|
|
4512
4592
|
useDefaultABRRules: true,
|
|
4513
|
-
initialBitrate: { video:
|
|
4593
|
+
initialBitrate: { video: 5000000, audio: 128000 }, // 5Mbps initial (was -1)
|
|
4594
|
+
},
|
|
4595
|
+
// LIVE CATCHUP - critical for maintaining live edge (was missing!)
|
|
4596
|
+
liveCatchup: {
|
|
4597
|
+
enabled: true,
|
|
4598
|
+
maxDrift: 1.5, // Seek to live if drift > 1.5s
|
|
4599
|
+
playbackRate: {
|
|
4600
|
+
max: 0.15, // Speed up by max 15%
|
|
4601
|
+
min: -0.15, // Slow down by max 15%
|
|
4602
|
+
},
|
|
4603
|
+
playbackBufferMin: 0.3, // Min buffer before catchup
|
|
4604
|
+
mode: 'liveCatchupModeDefault',
|
|
4514
4605
|
},
|
|
4515
4606
|
// Retry settings - more aggressive
|
|
4516
4607
|
retryAttempts: {
|
|
@@ -4545,11 +4636,11 @@ class DashJsPlayerImpl extends BasePlayer {
|
|
|
4545
4636
|
abandonLoadTimeout: 5000, // 5 seconds instead of default 10
|
|
4546
4637
|
xhrWithCredentials: false,
|
|
4547
4638
|
text: { defaultEnabled: false },
|
|
4548
|
-
//
|
|
4639
|
+
// AGGRESSIVE: Tighter live delay
|
|
4549
4640
|
delay: {
|
|
4550
|
-
liveDelay:
|
|
4641
|
+
liveDelay: 2, // Reduced from 4 (2s behind live edge)
|
|
4551
4642
|
liveDelayFragmentCount: null,
|
|
4552
|
-
useSuggestedPresentationDelay:
|
|
4643
|
+
useSuggestedPresentationDelay: false, // Ignore manifest suggestions
|
|
4553
4644
|
},
|
|
4554
4645
|
},
|
|
4555
4646
|
debug: {
|
|
@@ -4963,6 +5054,9 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
4963
5054
|
const videojs = mod.default || mod;
|
|
4964
5055
|
// When using custom controls (controls: false), disable ALL VideoJS UI elements
|
|
4965
5056
|
const useVideoJsControls = options.controls === true;
|
|
5057
|
+
// Android < 7 workaround: enable overrideNative for HLS
|
|
5058
|
+
const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
|
|
5059
|
+
const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
|
|
4966
5060
|
// Build VideoJS options
|
|
4967
5061
|
// NOTE: We disable UI components but NOT children array - that breaks playback
|
|
4968
5062
|
const vjsOptions = {
|
|
@@ -4978,16 +5072,30 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
4978
5072
|
controlBar: useVideoJsControls,
|
|
4979
5073
|
liveTracker: useVideoJsControls,
|
|
4980
5074
|
// Don't set children: [] - that can break internal VideoJS components
|
|
5075
|
+
// VHS (http-streaming) configuration - AGGRESSIVE for fastest startup
|
|
5076
|
+
html5: {
|
|
5077
|
+
vhs: {
|
|
5078
|
+
// AGGRESSIVE: Start with lower quality for instant playback
|
|
5079
|
+
enableLowInitialPlaylist: true,
|
|
5080
|
+
// AGGRESSIVE: Assume 5 Mbps initially
|
|
5081
|
+
bandwidth: 5000000,
|
|
5082
|
+
// Persist bandwidth across sessions for returning users
|
|
5083
|
+
useBandwidthFromLocalStorage: true,
|
|
5084
|
+
// Enable partial segment processing for lower latency
|
|
5085
|
+
handlePartialData: true,
|
|
5086
|
+
// AGGRESSIVE: Very tight live range
|
|
5087
|
+
liveRangeSafeTimeDelta: 0.3,
|
|
5088
|
+
// Allow user overrides via options.vhsConfig
|
|
5089
|
+
...options.vhsConfig,
|
|
5090
|
+
},
|
|
5091
|
+
// Android < 7 workaround
|
|
5092
|
+
...(androidVersion && androidVersion < 7 ? {
|
|
5093
|
+
hls: { overrideNative: true }
|
|
5094
|
+
} : {}),
|
|
5095
|
+
},
|
|
5096
|
+
nativeAudioTracks: androidVersion && androidVersion < 7 ? false : undefined,
|
|
5097
|
+
nativeVideoTracks: androidVersion && androidVersion < 7 ? false : undefined,
|
|
4981
5098
|
};
|
|
4982
|
-
// Android < 7 workaround: enable overrideNative for HLS
|
|
4983
|
-
const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
|
|
4984
|
-
const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
|
|
4985
|
-
if (androidVersion && androidVersion < 7) {
|
|
4986
|
-
console.debug('[VideoJS] Android < 7 detected, enabling overrideNative');
|
|
4987
|
-
vjsOptions.html5 = { hls: { overrideNative: true } };
|
|
4988
|
-
vjsOptions.nativeAudioTracks = false;
|
|
4989
|
-
vjsOptions.nativeVideoTracks = false;
|
|
4990
|
-
}
|
|
4991
5099
|
console.debug('[VideoJS] Creating player with options:', vjsOptions);
|
|
4992
5100
|
this.videojsPlayer = videojs(video, vjsOptions);
|
|
4993
5101
|
console.debug('[VideoJS] Player created');
|
|
@@ -5191,26 +5299,6 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
5191
5299
|
const v = this.proxyElement || this.videoElement;
|
|
5192
5300
|
return v?.currentTime ?? 0;
|
|
5193
5301
|
}
|
|
5194
|
-
getDuration() {
|
|
5195
|
-
const v = this.proxyElement || this.videoElement;
|
|
5196
|
-
return v?.duration ?? 0;
|
|
5197
|
-
}
|
|
5198
|
-
getSeekableRange() {
|
|
5199
|
-
if (this.videojsPlayer?.seekable) {
|
|
5200
|
-
try {
|
|
5201
|
-
const seekable = this.videojsPlayer.seekable();
|
|
5202
|
-
if (seekable && seekable.length > 0) {
|
|
5203
|
-
const start = seekable.start(0) + this.timeCorrection;
|
|
5204
|
-
const end = seekable.end(seekable.length - 1) + this.timeCorrection;
|
|
5205
|
-
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
|
5206
|
-
return { start, end };
|
|
5207
|
-
}
|
|
5208
|
-
}
|
|
5209
|
-
}
|
|
5210
|
-
catch { }
|
|
5211
|
-
}
|
|
5212
|
-
return null;
|
|
5213
|
-
}
|
|
5214
5302
|
/**
|
|
5215
5303
|
* Seek to time using VideoJS API (fixes backwards seeking in HLS).
|
|
5216
5304
|
* Time should be in the corrected coordinate space (with firstms offset applied).
|
|
@@ -5325,6 +5413,31 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
5325
5413
|
}
|
|
5326
5414
|
}
|
|
5327
5415
|
}
|
|
5416
|
+
/**
|
|
5417
|
+
* Provide a seekable range override for live streams.
|
|
5418
|
+
* Uses VideoJS liveTracker seekableEnd as the live edge when available.
|
|
5419
|
+
*/
|
|
5420
|
+
getSeekableRange() {
|
|
5421
|
+
const video = this.videoElement;
|
|
5422
|
+
if (!video?.seekable || video.seekable.length === 0)
|
|
5423
|
+
return null;
|
|
5424
|
+
let start = video.seekable.start(0);
|
|
5425
|
+
let end = video.seekable.end(video.seekable.length - 1);
|
|
5426
|
+
if (this.videojsPlayer?.liveTracker) {
|
|
5427
|
+
const tracker = this.videojsPlayer.liveTracker;
|
|
5428
|
+
const trackerEnd = tracker.seekableEnd?.();
|
|
5429
|
+
const trackerStart = tracker.seekableStart?.();
|
|
5430
|
+
if (typeof trackerStart === 'number' && Number.isFinite(trackerStart)) {
|
|
5431
|
+
start = trackerStart;
|
|
5432
|
+
}
|
|
5433
|
+
if (typeof trackerEnd === 'number' && Number.isFinite(trackerEnd) && trackerEnd > 0) {
|
|
5434
|
+
end = Math.min(end, trackerEnd);
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
5438
|
+
return null;
|
|
5439
|
+
return { start, end };
|
|
5440
|
+
}
|
|
5328
5441
|
/**
|
|
5329
5442
|
* Get latency from live edge (for live streams)
|
|
5330
5443
|
*/
|
|
@@ -5519,7 +5632,7 @@ class MistPlayerImpl extends BasePlayer {
|
|
|
5519
5632
|
ref.unload();
|
|
5520
5633
|
}
|
|
5521
5634
|
}
|
|
5522
|
-
catch
|
|
5635
|
+
catch {
|
|
5523
5636
|
// Ignore cleanup errors
|
|
5524
5637
|
}
|
|
5525
5638
|
try {
|
|
@@ -5654,7 +5767,6 @@ class WebSocketManager {
|
|
|
5654
5767
|
this.onError('Too many send retries');
|
|
5655
5768
|
return false;
|
|
5656
5769
|
}
|
|
5657
|
-
// Helper to schedule retry with tracking
|
|
5658
5770
|
const scheduleRetry = (delay) => {
|
|
5659
5771
|
const timer = setTimeout(() => {
|
|
5660
5772
|
this.pendingRetryTimers.delete(timer);
|
|
@@ -6065,7 +6177,7 @@ class SourceBufferManager {
|
|
|
6065
6177
|
// Make sure end time is never 0 (mews.js:376)
|
|
6066
6178
|
this.sourceBuffer.remove(0, Math.max(0.1, currentTime - keepaway));
|
|
6067
6179
|
}
|
|
6068
|
-
catch
|
|
6180
|
+
catch {
|
|
6069
6181
|
// Ignore errors during cleanup
|
|
6070
6182
|
}
|
|
6071
6183
|
});
|
|
@@ -6092,7 +6204,7 @@ class SourceBufferManager {
|
|
|
6092
6204
|
this.sourceBuffer.remove(0, Infinity);
|
|
6093
6205
|
}
|
|
6094
6206
|
}
|
|
6095
|
-
catch
|
|
6207
|
+
catch {
|
|
6096
6208
|
// Ignore
|
|
6097
6209
|
}
|
|
6098
6210
|
// Wait for remove to complete, then reinit
|
|
@@ -6114,7 +6226,7 @@ class SourceBufferManager {
|
|
|
6114
6226
|
try {
|
|
6115
6227
|
this.mediaSource.removeSourceBuffer(this.sourceBuffer);
|
|
6116
6228
|
}
|
|
6117
|
-
catch
|
|
6229
|
+
catch {
|
|
6118
6230
|
// Ignore
|
|
6119
6231
|
}
|
|
6120
6232
|
}
|
|
@@ -6392,7 +6504,7 @@ class MewsWsPlayerImpl extends BasePlayer {
|
|
|
6392
6504
|
return false;
|
|
6393
6505
|
return Object.keys(playableTracks);
|
|
6394
6506
|
}
|
|
6395
|
-
async initialize(container, source, options) {
|
|
6507
|
+
async initialize(container, source, options, streamInfo) {
|
|
6396
6508
|
this.container = container;
|
|
6397
6509
|
container.classList.add('fw-player-container');
|
|
6398
6510
|
const video = document.createElement('video');
|
|
@@ -6422,13 +6534,15 @@ class MewsWsPlayerImpl extends BasePlayer {
|
|
|
6422
6534
|
enabled: !!anyOpts.analytics?.enabled,
|
|
6423
6535
|
endpoint: anyOpts.analytics?.endpoint || null
|
|
6424
6536
|
};
|
|
6425
|
-
// Get stream type from
|
|
6426
|
-
|
|
6537
|
+
// Get stream type from streamInfo if available
|
|
6538
|
+
// Note: source.type is a MIME string (e.g., 'ws/video/mp4'), not 'live'/'vod'
|
|
6539
|
+
if (streamInfo?.type === 'live') {
|
|
6427
6540
|
this.streamType = 'live';
|
|
6428
6541
|
}
|
|
6429
|
-
else if (
|
|
6542
|
+
else if (streamInfo?.type === 'vod') {
|
|
6430
6543
|
this.streamType = 'vod';
|
|
6431
6544
|
}
|
|
6545
|
+
// Fallback: will be determined by server on_time messages (end === 0 means live)
|
|
6432
6546
|
try {
|
|
6433
6547
|
// Initialize MediaSource (mews.js:138-196)
|
|
6434
6548
|
this.mediaSource = new MediaSource();
|
|
@@ -7969,7 +8083,7 @@ class MistWebRTCPlayerImpl extends BasePlayer {
|
|
|
7969
8083
|
});
|
|
7970
8084
|
}
|
|
7971
8085
|
// Private methods
|
|
7972
|
-
async setupWebRTC(video, source,
|
|
8086
|
+
async setupWebRTC(video, source, _options) {
|
|
7973
8087
|
const sourceAny = source;
|
|
7974
8088
|
const iceServers = sourceAny?.iceServers || [];
|
|
7975
8089
|
// Create signaling
|
|
@@ -8404,7 +8518,7 @@ class WebSocketController {
|
|
|
8404
8518
|
this.setState('disconnected');
|
|
8405
8519
|
}
|
|
8406
8520
|
};
|
|
8407
|
-
this.ws.onerror = (
|
|
8521
|
+
this.ws.onerror = (_event) => {
|
|
8408
8522
|
this.log('WebSocket error');
|
|
8409
8523
|
this.emit('error', new Error('WebSocket error'));
|
|
8410
8524
|
};
|
|
@@ -9389,7 +9503,7 @@ class SyncController {
|
|
|
9389
9503
|
/**
|
|
9390
9504
|
* Register a new track
|
|
9391
9505
|
*/
|
|
9392
|
-
addTrack(
|
|
9506
|
+
addTrack(_trackIndex, _track) {
|
|
9393
9507
|
// Jitter tracking will be initialized on first chunk
|
|
9394
9508
|
}
|
|
9395
9509
|
/**
|
|
@@ -9838,11 +9952,13 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9838
9952
|
name: 'WebCodecs Player',
|
|
9839
9953
|
shortname: 'webcodecs',
|
|
9840
9954
|
priority: 0, // Highest priority - lowest latency option
|
|
9841
|
-
// Raw WebSocket (12-byte header +
|
|
9955
|
+
// Raw WebSocket (12-byte header + codec frames) - NOT MP4-muxed
|
|
9842
9956
|
// MistServer's output_wsraw.cpp provides full codec negotiation (audio + video)
|
|
9957
|
+
// MistServer's output_h264.cpp uses same 12-byte header but Annex B payload (video-only)
|
|
9843
9958
|
// NOTE: ws/video/mp4 is MP4-fragmented which needs MEWS player (uses MSE)
|
|
9844
9959
|
mimes: [
|
|
9845
|
-
'ws/video/raw', 'wss/video/raw', // Raw codec frames (audio + video)
|
|
9960
|
+
'ws/video/raw', 'wss/video/raw', // Raw codec frames - AVCC format (audio + video)
|
|
9961
|
+
'ws/video/h264', 'wss/video/h264', // Annex B H264/HEVC (video-only, same 12-byte header)
|
|
9846
9962
|
],
|
|
9847
9963
|
};
|
|
9848
9964
|
this.wsController = null;
|
|
@@ -9859,6 +9975,8 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9859
9975
|
this.debugging = false;
|
|
9860
9976
|
this.verboseDebugging = false;
|
|
9861
9977
|
this.streamType = 'live';
|
|
9978
|
+
/** Payload format: 'avcc' for ws/video/raw, 'annexb' for ws/video/h264 */
|
|
9979
|
+
this.payloadFormat = 'avcc';
|
|
9862
9980
|
this.workerUidCounter = 0;
|
|
9863
9981
|
this.workerListeners = new Map();
|
|
9864
9982
|
// Playback state
|
|
@@ -9872,6 +9990,10 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9872
9990
|
this._framesDecoded = 0;
|
|
9873
9991
|
this._bytesReceived = 0;
|
|
9874
9992
|
this._messagesReceived = 0;
|
|
9993
|
+
this._isPaused = true;
|
|
9994
|
+
this._suppressPlayPauseSync = false;
|
|
9995
|
+
this._pendingStepPause = false;
|
|
9996
|
+
this._stepPauseTimeout = null;
|
|
9875
9997
|
}
|
|
9876
9998
|
/**
|
|
9877
9999
|
* Get cache key for a track's codec configuration
|
|
@@ -9918,7 +10040,8 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9918
10040
|
}
|
|
9919
10041
|
else {
|
|
9920
10042
|
// Use VideoDecoder.isConfigSupported()
|
|
9921
|
-
|
|
10043
|
+
const videoResult = await VideoDecoder.isConfigSupported(config);
|
|
10044
|
+
result = { supported: videoResult.supported === true, config: videoResult.config };
|
|
9922
10045
|
}
|
|
9923
10046
|
break;
|
|
9924
10047
|
}
|
|
@@ -9926,7 +10049,8 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9926
10049
|
// Audio requires numberOfChannels and sampleRate
|
|
9927
10050
|
config.numberOfChannels = track.channels ?? 2;
|
|
9928
10051
|
config.sampleRate = track.rate ?? 48000;
|
|
9929
|
-
|
|
10052
|
+
const audioResult = await AudioDecoder.isConfigSupported(config);
|
|
10053
|
+
result = { supported: audioResult.supported === true, config: audioResult.config };
|
|
9930
10054
|
break;
|
|
9931
10055
|
}
|
|
9932
10056
|
default:
|
|
@@ -10007,6 +10131,10 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10007
10131
|
playableTracks['subtitle'] = true;
|
|
10008
10132
|
}
|
|
10009
10133
|
}
|
|
10134
|
+
// Annex B H264 WebSocket is video-only (no audio payloads)
|
|
10135
|
+
if (mimetype.includes('video/h264')) {
|
|
10136
|
+
delete playableTracks.audio;
|
|
10137
|
+
}
|
|
10010
10138
|
if (Object.keys(playableTracks).length === 0) {
|
|
10011
10139
|
return false;
|
|
10012
10140
|
}
|
|
@@ -10029,6 +10157,12 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10029
10157
|
this._framesDecoded = 0;
|
|
10030
10158
|
this._bytesReceived = 0;
|
|
10031
10159
|
this._messagesReceived = 0;
|
|
10160
|
+
// Detect payload format from source MIME type
|
|
10161
|
+
// ws/video/h264 uses Annex B (start code delimited NALs), ws/video/raw uses AVCC (length-prefixed)
|
|
10162
|
+
this.payloadFormat = source.type?.includes('h264') ? 'annexb' : 'avcc';
|
|
10163
|
+
if (this.payloadFormat === 'annexb') {
|
|
10164
|
+
this.log('Using Annex B payload format (ws/video/h264)');
|
|
10165
|
+
}
|
|
10032
10166
|
this.container = container;
|
|
10033
10167
|
container.classList.add('fw-player-container');
|
|
10034
10168
|
// Pre-populate track metadata from streamInfo (fetched via HTTP before WebSocket)
|
|
@@ -10082,6 +10216,31 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10082
10216
|
video.poster = options.poster;
|
|
10083
10217
|
this.videoElement = video;
|
|
10084
10218
|
container.appendChild(video);
|
|
10219
|
+
// Keep paused state in sync with actual element state
|
|
10220
|
+
this._onVideoPlay = () => {
|
|
10221
|
+
if (this._suppressPlayPauseSync)
|
|
10222
|
+
return;
|
|
10223
|
+
this._isPaused = false;
|
|
10224
|
+
this.sendToWorker({
|
|
10225
|
+
type: 'frametiming',
|
|
10226
|
+
action: 'setPaused',
|
|
10227
|
+
paused: false,
|
|
10228
|
+
uid: this.workerUidCounter++,
|
|
10229
|
+
}).catch(() => { });
|
|
10230
|
+
};
|
|
10231
|
+
this._onVideoPause = () => {
|
|
10232
|
+
if (this._suppressPlayPauseSync)
|
|
10233
|
+
return;
|
|
10234
|
+
this._isPaused = true;
|
|
10235
|
+
this.sendToWorker({
|
|
10236
|
+
type: 'frametiming',
|
|
10237
|
+
action: 'setPaused',
|
|
10238
|
+
paused: true,
|
|
10239
|
+
uid: this.workerUidCounter++,
|
|
10240
|
+
}).catch(() => { });
|
|
10241
|
+
};
|
|
10242
|
+
video.addEventListener('play', this._onVideoPlay);
|
|
10243
|
+
video.addEventListener('pause', this._onVideoPause);
|
|
10085
10244
|
// Create MediaStream for output
|
|
10086
10245
|
this.mediaStream = new MediaStream();
|
|
10087
10246
|
video.srcObject = this.mediaStream;
|
|
@@ -10222,6 +10381,19 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10222
10381
|
}
|
|
10223
10382
|
// Clean up video element
|
|
10224
10383
|
if (this.videoElement) {
|
|
10384
|
+
if (this._onVideoPlay) {
|
|
10385
|
+
this.videoElement.removeEventListener('play', this._onVideoPlay);
|
|
10386
|
+
this._onVideoPlay = undefined;
|
|
10387
|
+
}
|
|
10388
|
+
if (this._onVideoPause) {
|
|
10389
|
+
this.videoElement.removeEventListener('pause', this._onVideoPause);
|
|
10390
|
+
this._onVideoPause = undefined;
|
|
10391
|
+
}
|
|
10392
|
+
if (this._stepPauseTimeout) {
|
|
10393
|
+
clearTimeout(this._stepPauseTimeout);
|
|
10394
|
+
this._stepPauseTimeout = null;
|
|
10395
|
+
}
|
|
10396
|
+
this._pendingStepPause = false;
|
|
10225
10397
|
this.videoElement.srcObject = null;
|
|
10226
10398
|
this.videoElement.remove();
|
|
10227
10399
|
this.videoElement = null;
|
|
@@ -10384,8 +10556,17 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10384
10556
|
break;
|
|
10385
10557
|
}
|
|
10386
10558
|
case 'sendevent': {
|
|
10387
|
-
if (msg.kind === 'timeupdate'
|
|
10388
|
-
|
|
10559
|
+
if (msg.kind === 'timeupdate') {
|
|
10560
|
+
if (this._pendingStepPause) {
|
|
10561
|
+
this.finishStepPause();
|
|
10562
|
+
}
|
|
10563
|
+
if (typeof msg.time === 'number' && Number.isFinite(msg.time)) {
|
|
10564
|
+
this._currentTime = msg.time;
|
|
10565
|
+
this.emit('timeupdate', this._currentTime);
|
|
10566
|
+
}
|
|
10567
|
+
else if (this.videoElement) {
|
|
10568
|
+
this.emit('timeupdate', this.videoElement.currentTime);
|
|
10569
|
+
}
|
|
10389
10570
|
}
|
|
10390
10571
|
else if (msg.kind === 'error') {
|
|
10391
10572
|
this.emit('error', new Error(msg.message ?? 'Unknown error'));
|
|
@@ -10518,7 +10699,7 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10518
10699
|
if (msg.meta?.tracks) {
|
|
10519
10700
|
const tracksObj = msg.meta.tracks;
|
|
10520
10701
|
this.log(`Info contains ${Object.keys(tracksObj).length} tracks`);
|
|
10521
|
-
for (const [
|
|
10702
|
+
for (const [_name, track] of Object.entries(tracksObj)) {
|
|
10522
10703
|
// Store track by its index for lookup when chunks arrive
|
|
10523
10704
|
if (track.idx !== undefined) {
|
|
10524
10705
|
this.tracksByIndex.set(track.idx, track);
|
|
@@ -10730,6 +10911,7 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10730
10911
|
track,
|
|
10731
10912
|
opts: {
|
|
10732
10913
|
optimizeForLatency: this.streamType === 'live',
|
|
10914
|
+
payloadFormat: this.payloadFormat, // 'avcc' for ws/video/raw, 'annexb' for ws/video/h264
|
|
10733
10915
|
},
|
|
10734
10916
|
uid: this.workerUidCounter++,
|
|
10735
10917
|
});
|
|
@@ -10868,18 +11050,94 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10868
11050
|
// Playback Control
|
|
10869
11051
|
// ============================================================================
|
|
10870
11052
|
async play() {
|
|
11053
|
+
this._isPaused = false;
|
|
10871
11054
|
this.wsController?.play();
|
|
11055
|
+
this.sendToWorker({
|
|
11056
|
+
type: 'frametiming',
|
|
11057
|
+
action: 'setPaused',
|
|
11058
|
+
paused: false,
|
|
11059
|
+
uid: this.workerUidCounter++,
|
|
11060
|
+
});
|
|
10872
11061
|
await this.videoElement?.play();
|
|
10873
11062
|
}
|
|
10874
11063
|
pause() {
|
|
11064
|
+
this._isPaused = true;
|
|
10875
11065
|
this.wsController?.hold();
|
|
11066
|
+
this.sendToWorker({
|
|
11067
|
+
type: 'frametiming',
|
|
11068
|
+
action: 'setPaused',
|
|
11069
|
+
paused: true,
|
|
11070
|
+
uid: this.workerUidCounter++,
|
|
11071
|
+
});
|
|
10876
11072
|
this.videoElement?.pause();
|
|
10877
11073
|
}
|
|
11074
|
+
finishStepPause() {
|
|
11075
|
+
if (!this.videoElement) {
|
|
11076
|
+
this._pendingStepPause = false;
|
|
11077
|
+
this._suppressPlayPauseSync = false;
|
|
11078
|
+
if (this._stepPauseTimeout) {
|
|
11079
|
+
clearTimeout(this._stepPauseTimeout);
|
|
11080
|
+
this._stepPauseTimeout = null;
|
|
11081
|
+
}
|
|
11082
|
+
return;
|
|
11083
|
+
}
|
|
11084
|
+
if (this._stepPauseTimeout) {
|
|
11085
|
+
clearTimeout(this._stepPauseTimeout);
|
|
11086
|
+
this._stepPauseTimeout = null;
|
|
11087
|
+
}
|
|
11088
|
+
this._pendingStepPause = false;
|
|
11089
|
+
this._suppressPlayPauseSync = false;
|
|
11090
|
+
try {
|
|
11091
|
+
this.videoElement.pause();
|
|
11092
|
+
}
|
|
11093
|
+
catch { }
|
|
11094
|
+
}
|
|
11095
|
+
frameStep(direction, _seconds) {
|
|
11096
|
+
if (!this._isPaused)
|
|
11097
|
+
return;
|
|
11098
|
+
if (!this.videoElement)
|
|
11099
|
+
return;
|
|
11100
|
+
this.log(`Frame step requested dir=${direction} paused=${this._isPaused} videoPaused=${this.videoElement.paused}`);
|
|
11101
|
+
// Ensure worker is paused (in case pause didn't flow through)
|
|
11102
|
+
this.sendToWorker({
|
|
11103
|
+
type: 'frametiming',
|
|
11104
|
+
action: 'setPaused',
|
|
11105
|
+
paused: true,
|
|
11106
|
+
uid: this.workerUidCounter++,
|
|
11107
|
+
}).catch(() => { });
|
|
11108
|
+
// MediaStream-backed video elements don't present new frames while paused.
|
|
11109
|
+
// Pulse playback briefly so the stepped frame can render, then pause again.
|
|
11110
|
+
if (this.videoElement.paused) {
|
|
11111
|
+
const video = this.videoElement;
|
|
11112
|
+
this._suppressPlayPauseSync = true;
|
|
11113
|
+
this._pendingStepPause = true;
|
|
11114
|
+
try {
|
|
11115
|
+
const maybePromise = video.play();
|
|
11116
|
+
if (maybePromise && typeof maybePromise.catch === 'function') {
|
|
11117
|
+
maybePromise.catch(() => { });
|
|
11118
|
+
}
|
|
11119
|
+
}
|
|
11120
|
+
catch { }
|
|
11121
|
+
if ('requestVideoFrameCallback' in video) {
|
|
11122
|
+
video.requestVideoFrameCallback(() => this.finishStepPause());
|
|
11123
|
+
}
|
|
11124
|
+
// Failsafe: avoid staying in suppressed state if no frame is delivered
|
|
11125
|
+
this._stepPauseTimeout = setTimeout(() => this.finishStepPause(), 200);
|
|
11126
|
+
}
|
|
11127
|
+
this.sendToWorker({
|
|
11128
|
+
type: 'framestep',
|
|
11129
|
+
direction,
|
|
11130
|
+
uid: this.workerUidCounter++,
|
|
11131
|
+
});
|
|
11132
|
+
}
|
|
10878
11133
|
seek(time) {
|
|
10879
11134
|
if (!this.wsController || !this.syncController)
|
|
10880
11135
|
return;
|
|
10881
11136
|
const timeMs = time * 1000;
|
|
10882
11137
|
const seekId = this.syncController.startSeek(timeMs);
|
|
11138
|
+
// Optimistically update current time for immediate UI feedback
|
|
11139
|
+
this._currentTime = time;
|
|
11140
|
+
this.emit('timeupdate', this._currentTime);
|
|
10883
11141
|
// Flush worker queues
|
|
10884
11142
|
this.sendToWorker({
|
|
10885
11143
|
type: 'seek',
|
|
@@ -10905,6 +11163,9 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10905
11163
|
setPlaybackRate(rate) {
|
|
10906
11164
|
this.syncController?.setMainSpeed(rate);
|
|
10907
11165
|
}
|
|
11166
|
+
isPaused() {
|
|
11167
|
+
return this._isPaused;
|
|
11168
|
+
}
|
|
10908
11169
|
isLive() {
|
|
10909
11170
|
return this.streamType === 'live';
|
|
10910
11171
|
}
|
|
@@ -11690,6 +11951,9 @@ class InteractionController {
|
|
|
11690
11951
|
this.boundPointerCancel = this.handlePointerCancel.bind(this);
|
|
11691
11952
|
this.boundContextMenu = this.handleContextMenu.bind(this);
|
|
11692
11953
|
this.boundMouseMove = this.handleMouseMove.bind(this);
|
|
11954
|
+
this.boundDoubleClick = this.handleDoubleClick.bind(this);
|
|
11955
|
+
this.boundDocumentKeyDown = this.handleKeyDown.bind(this);
|
|
11956
|
+
this.boundDocumentKeyUp = this.handleKeyUp.bind(this);
|
|
11693
11957
|
}
|
|
11694
11958
|
/**
|
|
11695
11959
|
* Attach event listeners to container
|
|
@@ -11705,6 +11969,8 @@ class InteractionController {
|
|
|
11705
11969
|
// Keyboard events
|
|
11706
11970
|
container.addEventListener('keydown', this.boundKeyDown);
|
|
11707
11971
|
container.addEventListener('keyup', this.boundKeyUp);
|
|
11972
|
+
document.addEventListener('keydown', this.boundDocumentKeyDown);
|
|
11973
|
+
document.addEventListener('keyup', this.boundDocumentKeyUp);
|
|
11708
11974
|
// Pointer events (unified mouse + touch)
|
|
11709
11975
|
container.addEventListener('pointerdown', this.boundPointerDown);
|
|
11710
11976
|
container.addEventListener('pointerup', this.boundPointerUp);
|
|
@@ -11712,6 +11978,8 @@ class InteractionController {
|
|
|
11712
11978
|
container.addEventListener('pointerleave', this.boundPointerCancel);
|
|
11713
11979
|
// Mouse move for idle detection
|
|
11714
11980
|
container.addEventListener('mousemove', this.boundMouseMove);
|
|
11981
|
+
// Double click for fullscreen (desktop)
|
|
11982
|
+
container.addEventListener('dblclick', this.boundDoubleClick);
|
|
11715
11983
|
// Prevent context menu on long press
|
|
11716
11984
|
container.addEventListener('contextmenu', this.boundContextMenu);
|
|
11717
11985
|
// Start idle tracking
|
|
@@ -11727,11 +11995,14 @@ class InteractionController {
|
|
|
11727
11995
|
const { container } = this.config;
|
|
11728
11996
|
container.removeEventListener('keydown', this.boundKeyDown);
|
|
11729
11997
|
container.removeEventListener('keyup', this.boundKeyUp);
|
|
11998
|
+
document.removeEventListener('keydown', this.boundDocumentKeyDown);
|
|
11999
|
+
document.removeEventListener('keyup', this.boundDocumentKeyUp);
|
|
11730
12000
|
container.removeEventListener('pointerdown', this.boundPointerDown);
|
|
11731
12001
|
container.removeEventListener('pointerup', this.boundPointerUp);
|
|
11732
12002
|
container.removeEventListener('pointercancel', this.boundPointerCancel);
|
|
11733
12003
|
container.removeEventListener('pointerleave', this.boundPointerCancel);
|
|
11734
12004
|
container.removeEventListener('mousemove', this.boundMouseMove);
|
|
12005
|
+
container.removeEventListener('dblclick', this.boundDoubleClick);
|
|
11735
12006
|
container.removeEventListener('contextmenu', this.boundContextMenu);
|
|
11736
12007
|
// Clear any pending timeouts
|
|
11737
12008
|
if (this.holdCheckTimeout) {
|
|
@@ -11791,9 +12062,14 @@ class InteractionController {
|
|
|
11791
12062
|
// Ignore if focus is on an input element
|
|
11792
12063
|
if (this.isInputElement(e.target))
|
|
11793
12064
|
return;
|
|
12065
|
+
if (e.defaultPrevented)
|
|
12066
|
+
return;
|
|
12067
|
+
if (!this.shouldHandleKeyboard(e))
|
|
12068
|
+
return;
|
|
11794
12069
|
// Record interaction for idle detection
|
|
11795
12070
|
this.recordInteraction();
|
|
11796
12071
|
const { isLive } = this.config;
|
|
12072
|
+
const isPaused = this.config.isPaused?.() ?? this.config.videoElement?.paused ?? false;
|
|
11797
12073
|
switch (e.key) {
|
|
11798
12074
|
case ' ':
|
|
11799
12075
|
case 'Spacebar':
|
|
@@ -11846,7 +12122,6 @@ class InteractionController {
|
|
|
11846
12122
|
this.config.onPlayPause();
|
|
11847
12123
|
break;
|
|
11848
12124
|
case '<':
|
|
11849
|
-
case ',':
|
|
11850
12125
|
// Decrease speed (shift+, = <)
|
|
11851
12126
|
e.preventDefault();
|
|
11852
12127
|
if (!isLive) {
|
|
@@ -11854,13 +12129,26 @@ class InteractionController {
|
|
|
11854
12129
|
}
|
|
11855
12130
|
break;
|
|
11856
12131
|
case '>':
|
|
11857
|
-
case '.':
|
|
11858
12132
|
// Increase speed (shift+. = >)
|
|
11859
12133
|
e.preventDefault();
|
|
11860
12134
|
if (!isLive) {
|
|
11861
12135
|
this.adjustPlaybackSpeed(0.25);
|
|
11862
12136
|
}
|
|
11863
12137
|
break;
|
|
12138
|
+
case ',':
|
|
12139
|
+
// Previous frame when paused
|
|
12140
|
+
if (this.config.onFrameStep || (!isLive && isPaused)) {
|
|
12141
|
+
e.preventDefault();
|
|
12142
|
+
this.stepFrame(-1);
|
|
12143
|
+
}
|
|
12144
|
+
break;
|
|
12145
|
+
case '.':
|
|
12146
|
+
// Next frame when paused
|
|
12147
|
+
if (this.config.onFrameStep || (!isLive && isPaused)) {
|
|
12148
|
+
e.preventDefault();
|
|
12149
|
+
this.stepFrame(1);
|
|
12150
|
+
}
|
|
12151
|
+
break;
|
|
11864
12152
|
// Number keys for seeking to percentage
|
|
11865
12153
|
case '0':
|
|
11866
12154
|
case '1':
|
|
@@ -11883,11 +12171,36 @@ class InteractionController {
|
|
|
11883
12171
|
handleKeyUp(e) {
|
|
11884
12172
|
if (this.isInputElement(e.target))
|
|
11885
12173
|
return;
|
|
12174
|
+
if (e.defaultPrevented)
|
|
12175
|
+
return;
|
|
12176
|
+
if (!this.shouldHandleKeyboard(e))
|
|
12177
|
+
return;
|
|
11886
12178
|
if (e.key === ' ' || e.key === 'Spacebar') {
|
|
11887
12179
|
e.preventDefault();
|
|
11888
12180
|
this.handleSpaceUp();
|
|
11889
12181
|
}
|
|
11890
12182
|
}
|
|
12183
|
+
shouldHandleKeyboard(e) {
|
|
12184
|
+
if (this.spaceKeyDownTime > 0)
|
|
12185
|
+
return true;
|
|
12186
|
+
const target = e.target;
|
|
12187
|
+
if (target && this.config.container.contains(target))
|
|
12188
|
+
return true;
|
|
12189
|
+
const active = document.activeElement;
|
|
12190
|
+
if (active && this.config.container.contains(active))
|
|
12191
|
+
return true;
|
|
12192
|
+
try {
|
|
12193
|
+
if (this.config.container.matches(':focus-within'))
|
|
12194
|
+
return true;
|
|
12195
|
+
if (this.config.container.matches(':hover'))
|
|
12196
|
+
return true;
|
|
12197
|
+
}
|
|
12198
|
+
catch { }
|
|
12199
|
+
const now = Date.now();
|
|
12200
|
+
if (now - this.lastInteractionTime < DEFAULT_IDLE_TIMEOUT_MS)
|
|
12201
|
+
return true;
|
|
12202
|
+
return false;
|
|
12203
|
+
}
|
|
11891
12204
|
handleSpaceDown() {
|
|
11892
12205
|
if (this.spaceKeyDownTime > 0)
|
|
11893
12206
|
return; // Already tracking
|
|
@@ -11921,6 +12234,41 @@ class InteractionController {
|
|
|
11921
12234
|
}
|
|
11922
12235
|
}
|
|
11923
12236
|
}
|
|
12237
|
+
handleDoubleClick(e) {
|
|
12238
|
+
if (this.isControlElement(e.target))
|
|
12239
|
+
return;
|
|
12240
|
+
this.recordInteraction();
|
|
12241
|
+
e.preventDefault();
|
|
12242
|
+
this.config.onFullscreenToggle();
|
|
12243
|
+
}
|
|
12244
|
+
stepFrame(direction) {
|
|
12245
|
+
const step = this.getFrameStepSeconds();
|
|
12246
|
+
if (!Number.isFinite(step) || step <= 0)
|
|
12247
|
+
return;
|
|
12248
|
+
if (this.config.onFrameStep?.(direction, step))
|
|
12249
|
+
return;
|
|
12250
|
+
const video = this.config.videoElement;
|
|
12251
|
+
if (!video)
|
|
12252
|
+
return;
|
|
12253
|
+
const target = video.currentTime + (direction * step);
|
|
12254
|
+
if (!Number.isFinite(target))
|
|
12255
|
+
return;
|
|
12256
|
+
// Only step within already-buffered ranges to avoid network seeks
|
|
12257
|
+
const buffered = video.buffered;
|
|
12258
|
+
if (buffered && buffered.length > 0) {
|
|
12259
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
12260
|
+
const start = buffered.start(i);
|
|
12261
|
+
const end = buffered.end(i);
|
|
12262
|
+
if (target >= start && target <= end) {
|
|
12263
|
+
try {
|
|
12264
|
+
video.currentTime = target;
|
|
12265
|
+
}
|
|
12266
|
+
catch { }
|
|
12267
|
+
return;
|
|
12268
|
+
}
|
|
12269
|
+
}
|
|
12270
|
+
}
|
|
12271
|
+
}
|
|
11924
12272
|
// ─────────────────────────────────────────────────────────────────
|
|
11925
12273
|
// Pointer (Mouse/Touch) Handling
|
|
11926
12274
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -11937,6 +12285,7 @@ class InteractionController {
|
|
|
11937
12285
|
const now = Date.now();
|
|
11938
12286
|
const rect = this.config.container.getBoundingClientRect();
|
|
11939
12287
|
const relativeX = (e.clientX - rect.left) / rect.width;
|
|
12288
|
+
const isMouse = e.pointerType === 'mouse';
|
|
11940
12289
|
// Check for double-tap
|
|
11941
12290
|
if (now - this.lastTapTime < DOUBLE_TAP_WINDOW_MS) {
|
|
11942
12291
|
// Clear pending single-tap
|
|
@@ -11944,19 +12293,22 @@ class InteractionController {
|
|
|
11944
12293
|
clearTimeout(this.pendingTapTimeout);
|
|
11945
12294
|
this.pendingTapTimeout = null;
|
|
11946
12295
|
}
|
|
11947
|
-
//
|
|
11948
|
-
if (!
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
|
|
11952
|
-
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
12296
|
+
// Mouse double-click handled via dblclick event (fullscreen)
|
|
12297
|
+
if (!isMouse) {
|
|
12298
|
+
// Handle double-tap to skip (mobile-style)
|
|
12299
|
+
if (!this.config.isLive) {
|
|
12300
|
+
if (relativeX < 0.33) {
|
|
12301
|
+
// Left third - skip back
|
|
12302
|
+
this.config.onSeek(-SKIP_AMOUNT_SECONDS);
|
|
12303
|
+
}
|
|
12304
|
+
else if (relativeX > 0.67) {
|
|
12305
|
+
// Right third - skip forward
|
|
12306
|
+
this.config.onSeek(SKIP_AMOUNT_SECONDS);
|
|
12307
|
+
}
|
|
12308
|
+
else {
|
|
12309
|
+
// Center - treat as play/pause
|
|
12310
|
+
this.config.onPlayPause();
|
|
12311
|
+
}
|
|
11960
12312
|
}
|
|
11961
12313
|
}
|
|
11962
12314
|
this.lastTapTime = 0;
|
|
@@ -12127,13 +12479,26 @@ class InteractionController {
|
|
|
12127
12479
|
'select',
|
|
12128
12480
|
'.fw-player-controls',
|
|
12129
12481
|
'[data-player-controls]',
|
|
12482
|
+
'.fw-controls-wrapper',
|
|
12130
12483
|
'.fw-control-bar',
|
|
12131
|
-
'.fw-
|
|
12484
|
+
'.fw-settings-menu',
|
|
12485
|
+
'.fw-context-menu',
|
|
12486
|
+
'.fw-stats-panel',
|
|
12487
|
+
'.fw-dev-panel',
|
|
12488
|
+
'.fw-error-overlay',
|
|
12489
|
+
'.fw-error-popup',
|
|
12490
|
+
'.fw-player-error',
|
|
12132
12491
|
];
|
|
12133
12492
|
return controlSelectors.some(selector => {
|
|
12134
12493
|
return target.matches(selector) || target.closest(selector) !== null;
|
|
12135
12494
|
});
|
|
12136
12495
|
}
|
|
12496
|
+
getFrameStepSeconds() {
|
|
12497
|
+
const step = this.config.frameStepSeconds;
|
|
12498
|
+
if (Number.isFinite(step) && step > 0)
|
|
12499
|
+
return step;
|
|
12500
|
+
return 1 / 30;
|
|
12501
|
+
}
|
|
12137
12502
|
}
|
|
12138
12503
|
|
|
12139
12504
|
/**
|
|
@@ -13052,7 +13417,7 @@ class QualityMonitor {
|
|
|
13052
13417
|
* ```ts
|
|
13053
13418
|
* const manager = new MetaTrackManager({
|
|
13054
13419
|
* mistBaseUrl: 'https://mist.example.com',
|
|
13055
|
-
* streamName: '
|
|
13420
|
+
* streamName: 'pk_...', // playbackId (view key)
|
|
13056
13421
|
* });
|
|
13057
13422
|
*
|
|
13058
13423
|
* manager.subscribe('1', (event) => {
|
|
@@ -13755,13 +14120,13 @@ function supportsPlaybackRate(video) {
|
|
|
13755
14120
|
* 1. Browser's video.seekable ranges (most accurate for MSE-based players)
|
|
13756
14121
|
* 2. Track firstms/lastms from MistServer metadata
|
|
13757
14122
|
* 3. buffer_window from MistServer signaling
|
|
13758
|
-
* 4.
|
|
14123
|
+
* 4. No fallback (treat as live-only when no reliable data)
|
|
13759
14124
|
*
|
|
13760
14125
|
* @param params - Calculation parameters
|
|
13761
14126
|
* @returns Seekable range with start and live edge
|
|
13762
14127
|
*/
|
|
13763
14128
|
function calculateSeekableRange(params) {
|
|
13764
|
-
const { isLive, video, mistStreamInfo, currentTime, duration } = params;
|
|
14129
|
+
const { isLive, video, mistStreamInfo, currentTime, duration, allowMediaStreamDvr = false } = params;
|
|
13765
14130
|
// VOD: full duration is seekable
|
|
13766
14131
|
if (!isLive) {
|
|
13767
14132
|
return { seekableStart: 0, liveEdge: duration };
|
|
@@ -13776,8 +14141,8 @@ function calculateSeekableRange(params) {
|
|
|
13776
14141
|
}
|
|
13777
14142
|
}
|
|
13778
14143
|
// PRIORITY 2: Track firstms/lastms from MistServer (accurate when available)
|
|
13779
|
-
// Skip for MediaStream
|
|
13780
|
-
if (!isMediaStream && mistStreamInfo?.meta?.tracks) {
|
|
14144
|
+
// Skip for MediaStream unless explicitly allowed (e.g., WebCodecs DVR via server)
|
|
14145
|
+
if ((allowMediaStreamDvr || !isMediaStream) && mistStreamInfo?.meta?.tracks) {
|
|
13781
14146
|
const tracks = Object.values(mistStreamInfo.meta.tracks);
|
|
13782
14147
|
if (tracks.length > 0) {
|
|
13783
14148
|
const firstmsValues = tracks.map(t => t.firstms).filter((v) => v !== undefined);
|
|
@@ -13798,16 +14163,7 @@ function calculateSeekableRange(params) {
|
|
|
13798
14163
|
liveEdge: currentTime,
|
|
13799
14164
|
};
|
|
13800
14165
|
}
|
|
13801
|
-
//
|
|
13802
|
-
// WebRTC has no DVR buffer by default, so don't create a fake seekable window
|
|
13803
|
-
// Use reference player's 60s default for other protocols
|
|
13804
|
-
if (!isMediaStream && currentTime > 0) {
|
|
13805
|
-
return {
|
|
13806
|
-
seekableStart: Math.max(0, currentTime - DEFAULT_BUFFER_WINDOW_SEC),
|
|
13807
|
-
liveEdge: currentTime,
|
|
13808
|
-
};
|
|
13809
|
-
}
|
|
13810
|
-
// For WebRTC or unknown: no seekable range (live only)
|
|
14166
|
+
// No seekable range (live only)
|
|
13811
14167
|
return { seekableStart: currentTime, liveEdge: currentTime };
|
|
13812
14168
|
}
|
|
13813
14169
|
/**
|
|
@@ -13945,6 +14301,9 @@ function isLiveContent(isContentLive, mistStreamInfo, duration) {
|
|
|
13945
14301
|
* Both React and Vanilla wrappers use this class internally.
|
|
13946
14302
|
*/
|
|
13947
14303
|
// ============================================================================
|
|
14304
|
+
// Content Type Resolution Helpers
|
|
14305
|
+
// ============================================================================
|
|
14306
|
+
// ============================================================================
|
|
13948
14307
|
// MistServer Source Type Mapping
|
|
13949
14308
|
// ============================================================================
|
|
13950
14309
|
/**
|
|
@@ -13966,6 +14325,8 @@ const MIST_SOURCE_TYPES = {
|
|
|
13966
14325
|
// ===== WEBSOCKET STREAMING =====
|
|
13967
14326
|
'ws/video/mp4': { hrn: 'MP4 WebSocket', player: 'mews', supported: true },
|
|
13968
14327
|
'wss/video/mp4': { hrn: 'MP4 WebSocket (SSL)', player: 'mews', supported: true },
|
|
14328
|
+
'ws/video/webm': { hrn: 'WebM WebSocket', player: 'mews', supported: true },
|
|
14329
|
+
'wss/video/webm': { hrn: 'WebM WebSocket (SSL)', player: 'mews', supported: true },
|
|
13969
14330
|
'ws/video/raw': { hrn: 'Raw WebSocket', player: 'webcodecs', supported: true },
|
|
13970
14331
|
'wss/video/raw': { hrn: 'Raw WebSocket (SSL)', player: 'webcodecs', supported: true },
|
|
13971
14332
|
'ws/video/h264': { hrn: 'Annex B WebSocket', player: 'webcodecs', supported: true },
|
|
@@ -13973,6 +14334,7 @@ const MIST_SOURCE_TYPES = {
|
|
|
13973
14334
|
// ===== WEBRTC =====
|
|
13974
14335
|
'whep': { hrn: 'WebRTC (WHEP)', player: 'native', supported: true },
|
|
13975
14336
|
'webrtc': { hrn: 'WebRTC (WebSocket)', player: 'mist-webrtc', supported: true },
|
|
14337
|
+
'mist/webrtc': { hrn: 'MistServer WebRTC', player: 'mist-webrtc', supported: true },
|
|
13976
14338
|
// ===== AUDIO ONLY =====
|
|
13977
14339
|
'html5/audio/aac': { hrn: 'AAC progressive', player: 'native', supported: true },
|
|
13978
14340
|
'html5/audio/mp3': { hrn: 'MP3 progressive', player: 'native', supported: true },
|
|
@@ -14011,12 +14373,17 @@ const PROTOCOL_TO_MIME = {
|
|
|
14011
14373
|
'WEBM': 'html5/video/webm',
|
|
14012
14374
|
'WHEP': 'whep',
|
|
14013
14375
|
'WebRTC': 'webrtc',
|
|
14376
|
+
'MIST_WEBRTC': 'mist/webrtc', // MistServer native WebRTC signaling
|
|
14014
14377
|
// WebSocket variants
|
|
14015
14378
|
'MEWS': 'ws/video/mp4',
|
|
14016
14379
|
'MEWS_WS': 'ws/video/mp4',
|
|
14017
14380
|
'MEWS_WSS': 'wss/video/mp4',
|
|
14381
|
+
'MEWS_WEBM': 'ws/video/webm',
|
|
14382
|
+
'MEWS_WEBM_SSL': 'wss/video/webm',
|
|
14018
14383
|
'RAW_WS': 'ws/video/raw',
|
|
14019
14384
|
'RAW_WSS': 'wss/video/raw',
|
|
14385
|
+
'H264_WS': 'ws/video/h264',
|
|
14386
|
+
'H264_WSS': 'wss/video/h264',
|
|
14020
14387
|
// Audio
|
|
14021
14388
|
'AAC': 'html5/audio/aac',
|
|
14022
14389
|
'MP3': 'html5/audio/mp3',
|
|
@@ -14101,7 +14468,7 @@ function buildStreamInfoFromEndpoints(endpoints, contentId) {
|
|
|
14101
14468
|
try {
|
|
14102
14469
|
outputs = JSON.parse(primary.outputs);
|
|
14103
14470
|
}
|
|
14104
|
-
catch
|
|
14471
|
+
catch {
|
|
14105
14472
|
console.warn('[buildStreamInfoFromEndpoints] Failed to parse outputs JSON');
|
|
14106
14473
|
outputs = {};
|
|
14107
14474
|
}
|
|
@@ -14112,7 +14479,6 @@ function buildStreamInfoFromEndpoints(endpoints, contentId) {
|
|
|
14112
14479
|
}
|
|
14113
14480
|
const sources = [];
|
|
14114
14481
|
const oKeys = Object.keys(outputs);
|
|
14115
|
-
// Helper to attach MistServer sources
|
|
14116
14482
|
const attachMistSource = (html, playerJs) => {
|
|
14117
14483
|
if (!html && !playerJs)
|
|
14118
14484
|
return;
|
|
@@ -14210,7 +14576,7 @@ function buildStreamInfoFromEndpoints(endpoints, contentId) {
|
|
|
14210
14576
|
* @example
|
|
14211
14577
|
* ```typescript
|
|
14212
14578
|
* const controller = new PlayerController({
|
|
14213
|
-
* contentId: '
|
|
14579
|
+
* contentId: 'pk_...', // playbackId (view key)
|
|
14214
14580
|
* contentType: 'live',
|
|
14215
14581
|
* gatewayUrl: 'https://gateway.example.com/graphql',
|
|
14216
14582
|
* });
|
|
@@ -14230,6 +14596,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14230
14596
|
super();
|
|
14231
14597
|
this.state = 'booting';
|
|
14232
14598
|
this.lastEmittedState = null;
|
|
14599
|
+
this.suppressPlayPauseEventsUntil = 0;
|
|
14233
14600
|
this.gatewayClient = null;
|
|
14234
14601
|
this.streamStateClient = null;
|
|
14235
14602
|
this.currentPlayer = null;
|
|
@@ -14240,6 +14607,10 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14240
14607
|
this.streamState = null;
|
|
14241
14608
|
/** Tracks parsed from MistServer JSON response (used for direct MistServer mode) */
|
|
14242
14609
|
this.mistTracks = null;
|
|
14610
|
+
/** Gateway-seeded metadata (used as base for Mist enrichment) */
|
|
14611
|
+
this.metadataSeed = null;
|
|
14612
|
+
/** Merged metadata (gateway seed + Mist enrichment) */
|
|
14613
|
+
this.metadata = null;
|
|
14243
14614
|
this.cleanupFns = [];
|
|
14244
14615
|
this.isDestroyed = false;
|
|
14245
14616
|
this.isAttached = false;
|
|
@@ -14389,6 +14760,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14389
14760
|
this.endpoints = null;
|
|
14390
14761
|
this.streamInfo = null;
|
|
14391
14762
|
this.streamState = null;
|
|
14763
|
+
this.metadataSeed = null;
|
|
14764
|
+
this.metadata = null;
|
|
14392
14765
|
this.videoElement = null;
|
|
14393
14766
|
this.currentPlayer = null;
|
|
14394
14767
|
this.lastEmittedState = null;
|
|
@@ -14423,7 +14796,115 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14423
14796
|
}
|
|
14424
14797
|
/** Get content metadata (title, description, duration, etc.) */
|
|
14425
14798
|
getMetadata() {
|
|
14426
|
-
return this.
|
|
14799
|
+
return this.metadata ?? null;
|
|
14800
|
+
}
|
|
14801
|
+
// ============================================================================
|
|
14802
|
+
// Metadata Merge (Gateway seed + Mist enrichment)
|
|
14803
|
+
// ============================================================================
|
|
14804
|
+
setMetadataSeed(seed) {
|
|
14805
|
+
this.metadataSeed = seed ? { ...seed } : null;
|
|
14806
|
+
this.refreshMergedMetadata();
|
|
14807
|
+
}
|
|
14808
|
+
refreshMergedMetadata() {
|
|
14809
|
+
const seed = this.metadataSeed ? { ...this.metadataSeed } : null;
|
|
14810
|
+
const mist = this.streamState?.streamInfo;
|
|
14811
|
+
const streamStatus = this.streamState?.status;
|
|
14812
|
+
if (!seed && !mist) {
|
|
14813
|
+
this.metadata = null;
|
|
14814
|
+
return;
|
|
14815
|
+
}
|
|
14816
|
+
const merged = seed ? { ...seed } : {};
|
|
14817
|
+
if (mist) {
|
|
14818
|
+
merged.mist = this.sanitizeMistInfo(mist);
|
|
14819
|
+
if (mist.type) {
|
|
14820
|
+
merged.contentType = mist.type;
|
|
14821
|
+
merged.isLive = mist.type === 'live';
|
|
14822
|
+
}
|
|
14823
|
+
if (streamStatus) {
|
|
14824
|
+
merged.status = streamStatus;
|
|
14825
|
+
}
|
|
14826
|
+
if (mist.meta?.duration && (!merged.durationSeconds || merged.durationSeconds <= 0)) {
|
|
14827
|
+
merged.durationSeconds = Math.round(mist.meta.duration);
|
|
14828
|
+
}
|
|
14829
|
+
if (mist.meta?.tracks) {
|
|
14830
|
+
merged.tracks = this.buildMetadataTracks(mist.meta.tracks);
|
|
14831
|
+
}
|
|
14832
|
+
}
|
|
14833
|
+
this.metadata = merged;
|
|
14834
|
+
}
|
|
14835
|
+
buildMetadataTracks(tracksObj) {
|
|
14836
|
+
const tracks = [];
|
|
14837
|
+
for (const [, trackData] of Object.entries(tracksObj)) {
|
|
14838
|
+
const t = trackData;
|
|
14839
|
+
const trackType = t.type;
|
|
14840
|
+
if (trackType !== 'video' && trackType !== 'audio' && trackType !== 'meta') {
|
|
14841
|
+
continue;
|
|
14842
|
+
}
|
|
14843
|
+
const bitrate = typeof t.bps === 'number' ? Math.round(t.bps) : undefined;
|
|
14844
|
+
const fps = typeof t.fpks === 'number' ? t.fpks / 1000 : undefined;
|
|
14845
|
+
tracks.push({
|
|
14846
|
+
type: trackType,
|
|
14847
|
+
codec: t.codec,
|
|
14848
|
+
width: t.width,
|
|
14849
|
+
height: t.height,
|
|
14850
|
+
bitrate,
|
|
14851
|
+
fps,
|
|
14852
|
+
channels: t.channels,
|
|
14853
|
+
sampleRate: t.rate,
|
|
14854
|
+
});
|
|
14855
|
+
}
|
|
14856
|
+
return tracks.length ? tracks : undefined;
|
|
14857
|
+
}
|
|
14858
|
+
sanitizeMistInfo(info) {
|
|
14859
|
+
const sanitized = {
|
|
14860
|
+
error: info.error,
|
|
14861
|
+
on_error: info.on_error,
|
|
14862
|
+
perc: info.perc,
|
|
14863
|
+
type: info.type,
|
|
14864
|
+
hasVideo: info.hasVideo,
|
|
14865
|
+
hasAudio: info.hasAudio,
|
|
14866
|
+
unixoffset: info.unixoffset,
|
|
14867
|
+
lastms: info.lastms,
|
|
14868
|
+
};
|
|
14869
|
+
if (info.source) {
|
|
14870
|
+
sanitized.source = info.source.map((src) => ({
|
|
14871
|
+
url: src.url,
|
|
14872
|
+
type: src.type,
|
|
14873
|
+
priority: src.priority,
|
|
14874
|
+
simul_tracks: src.simul_tracks,
|
|
14875
|
+
relurl: src.relurl,
|
|
14876
|
+
}));
|
|
14877
|
+
}
|
|
14878
|
+
if (info.meta) {
|
|
14879
|
+
sanitized.meta = {
|
|
14880
|
+
buffer_window: info.meta.buffer_window,
|
|
14881
|
+
duration: info.meta.duration,
|
|
14882
|
+
mistUrl: info.meta.mistUrl,
|
|
14883
|
+
};
|
|
14884
|
+
if (info.meta.tracks) {
|
|
14885
|
+
const tracks = {};
|
|
14886
|
+
for (const [key, track] of Object.entries(info.meta.tracks)) {
|
|
14887
|
+
tracks[key] = {
|
|
14888
|
+
type: track.type,
|
|
14889
|
+
codec: track.codec,
|
|
14890
|
+
width: track.width,
|
|
14891
|
+
height: track.height,
|
|
14892
|
+
bps: track.bps,
|
|
14893
|
+
fpks: track.fpks,
|
|
14894
|
+
codecstring: track.codecstring,
|
|
14895
|
+
firstms: track.firstms,
|
|
14896
|
+
lastms: track.lastms,
|
|
14897
|
+
lang: track.lang,
|
|
14898
|
+
idx: track.idx,
|
|
14899
|
+
channels: track.channels,
|
|
14900
|
+
rate: track.rate,
|
|
14901
|
+
size: track.size,
|
|
14902
|
+
};
|
|
14903
|
+
}
|
|
14904
|
+
sanitized.meta.tracks = tracks;
|
|
14905
|
+
}
|
|
14906
|
+
}
|
|
14907
|
+
return sanitized;
|
|
14427
14908
|
}
|
|
14428
14909
|
/** Get stream info (sources + tracks for player selection) */
|
|
14429
14910
|
getStreamInfo() {
|
|
@@ -14446,7 +14927,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14446
14927
|
// ============================================================================
|
|
14447
14928
|
/** Check if video is currently playing (not paused) */
|
|
14448
14929
|
isPlaying() {
|
|
14449
|
-
|
|
14930
|
+
const paused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
14931
|
+
return !paused;
|
|
14450
14932
|
}
|
|
14451
14933
|
/** Check if currently buffering */
|
|
14452
14934
|
isBuffering() {
|
|
@@ -14559,6 +15041,28 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14559
15041
|
canAdjustPlaybackRate() {
|
|
14560
15042
|
return this._supportsPlaybackRate;
|
|
14561
15043
|
}
|
|
15044
|
+
/** Resolve content type from config override or Gateway metadata */
|
|
15045
|
+
getResolvedContentType() {
|
|
15046
|
+
if (this.config.contentType) {
|
|
15047
|
+
return this.config.contentType;
|
|
15048
|
+
}
|
|
15049
|
+
const metadata = this.getMetadata();
|
|
15050
|
+
const metaType = metadata?.contentType?.toLowerCase();
|
|
15051
|
+
if (metaType === 'live' || metaType === 'clip' || metaType === 'dvr' || metaType === 'vod') {
|
|
15052
|
+
return metaType;
|
|
15053
|
+
}
|
|
15054
|
+
const mistType = this.streamState?.streamInfo?.type;
|
|
15055
|
+
if (mistType === 'live' || mistType === 'vod') {
|
|
15056
|
+
return mistType;
|
|
15057
|
+
}
|
|
15058
|
+
if (metadata?.isLive === true) {
|
|
15059
|
+
return 'live';
|
|
15060
|
+
}
|
|
15061
|
+
if (metadata?.isLive === false) {
|
|
15062
|
+
return 'vod';
|
|
15063
|
+
}
|
|
15064
|
+
return null;
|
|
15065
|
+
}
|
|
14562
15066
|
/** Check if source is WebRTC/MediaStream */
|
|
14563
15067
|
isWebRTCSource() {
|
|
14564
15068
|
return this._isWebRTC;
|
|
@@ -14571,13 +15075,28 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14571
15075
|
}
|
|
14572
15076
|
/** Check if content is effectively live (live or DVR still recording) */
|
|
14573
15077
|
isEffectivelyLive() {
|
|
14574
|
-
const
|
|
15078
|
+
const contentType = this.getResolvedContentType() ?? 'live';
|
|
14575
15079
|
const metadata = this.getMetadata();
|
|
14576
|
-
|
|
15080
|
+
// Explicit VOD content types are never live
|
|
15081
|
+
if (contentType === 'vod' || contentType === 'clip') {
|
|
15082
|
+
return false;
|
|
15083
|
+
}
|
|
15084
|
+
// If Gateway metadata says it's not live, trust it
|
|
15085
|
+
if (metadata?.isLive === false) {
|
|
15086
|
+
return false;
|
|
15087
|
+
}
|
|
15088
|
+
// DVR that's finished recording is not live
|
|
15089
|
+
if (contentType === 'dvr' && metadata?.dvrStatus === 'completed') {
|
|
15090
|
+
return false;
|
|
15091
|
+
}
|
|
15092
|
+
// Default: trust contentType or duration-based detection
|
|
15093
|
+
return contentType === 'live' ||
|
|
15094
|
+
(contentType === 'dvr' && metadata?.dvrStatus === 'recording') ||
|
|
15095
|
+
!Number.isFinite(this.getDuration());
|
|
14577
15096
|
}
|
|
14578
15097
|
/** Check if content is strictly live (not DVR/clip/vod) */
|
|
14579
15098
|
isLive() {
|
|
14580
|
-
return this.
|
|
15099
|
+
return (this.getResolvedContentType() ?? 'live') === 'live';
|
|
14581
15100
|
}
|
|
14582
15101
|
/**
|
|
14583
15102
|
* Check if content needs cold start (VOD-like loading).
|
|
@@ -14586,7 +15105,10 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14586
15105
|
* DVR-while-recording needs cold start because MistServer may not be serving the VOD yet
|
|
14587
15106
|
*/
|
|
14588
15107
|
needsColdStart() {
|
|
14589
|
-
|
|
15108
|
+
const contentType = this.getResolvedContentType();
|
|
15109
|
+
if (!contentType)
|
|
15110
|
+
return true;
|
|
15111
|
+
return contentType !== 'live';
|
|
14590
15112
|
}
|
|
14591
15113
|
/**
|
|
14592
15114
|
* Check if we should show idle/loading screen.
|
|
@@ -14728,12 +15250,20 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14728
15250
|
// ============================================================================
|
|
14729
15251
|
/** Start playback */
|
|
14730
15252
|
async play() {
|
|
15253
|
+
if (this.currentPlayer?.play) {
|
|
15254
|
+
await this.currentPlayer.play();
|
|
15255
|
+
return;
|
|
15256
|
+
}
|
|
14731
15257
|
if (this.videoElement) {
|
|
14732
15258
|
await this.videoElement.play();
|
|
14733
15259
|
}
|
|
14734
15260
|
}
|
|
14735
15261
|
/** Pause playback */
|
|
14736
15262
|
pause() {
|
|
15263
|
+
if (this.currentPlayer?.pause) {
|
|
15264
|
+
this.currentPlayer.pause();
|
|
15265
|
+
return;
|
|
15266
|
+
}
|
|
14737
15267
|
this.videoElement?.pause();
|
|
14738
15268
|
}
|
|
14739
15269
|
/** Seek to time */
|
|
@@ -14783,6 +15313,24 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14783
15313
|
// Try player-specific implementation first (WebCodecs uses server time)
|
|
14784
15314
|
if (this.currentPlayer?.jumpToLive) {
|
|
14785
15315
|
this.currentPlayer.jumpToLive();
|
|
15316
|
+
const el = this.videoElement;
|
|
15317
|
+
if (el && !isMediaStreamSource(el)) {
|
|
15318
|
+
const target = this._liveEdge;
|
|
15319
|
+
if (Number.isFinite(target) && target > 0) {
|
|
15320
|
+
// Fallback: if player-specific jump doesn't move, seek to computed live edge
|
|
15321
|
+
setTimeout(() => {
|
|
15322
|
+
if (!this.videoElement)
|
|
15323
|
+
return;
|
|
15324
|
+
const current = this.getEffectiveCurrentTime();
|
|
15325
|
+
if (target - current > 1) {
|
|
15326
|
+
try {
|
|
15327
|
+
this.videoElement.currentTime = target;
|
|
15328
|
+
}
|
|
15329
|
+
catch { }
|
|
15330
|
+
}
|
|
15331
|
+
}, 200);
|
|
15332
|
+
}
|
|
15333
|
+
}
|
|
14786
15334
|
this._isNearLive = true;
|
|
14787
15335
|
this.emitSeekingState();
|
|
14788
15336
|
return;
|
|
@@ -14891,6 +15439,19 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14891
15439
|
}
|
|
14892
15440
|
return null;
|
|
14893
15441
|
}
|
|
15442
|
+
getFrameStepSecondsFromTracks() {
|
|
15443
|
+
const tracks = this.streamInfo?.meta?.tracks;
|
|
15444
|
+
if (!tracks || tracks.length === 0)
|
|
15445
|
+
return undefined;
|
|
15446
|
+
const videoTracks = tracks.filter(t => t.type === 'video' && typeof t.fpks === 'number' && t.fpks > 0);
|
|
15447
|
+
if (videoTracks.length === 0)
|
|
15448
|
+
return undefined;
|
|
15449
|
+
const fpks = Math.max(...videoTracks.map(t => t.fpks));
|
|
15450
|
+
if (!Number.isFinite(fpks) || fpks <= 0)
|
|
15451
|
+
return undefined;
|
|
15452
|
+
// fpks = frames per kilosecond => frame duration in seconds = 1000 / fpks
|
|
15453
|
+
return 1000 / fpks;
|
|
15454
|
+
}
|
|
14894
15455
|
deriveBufferWindowMsFromTracks(tracks) {
|
|
14895
15456
|
if (!tracks)
|
|
14896
15457
|
return undefined;
|
|
@@ -14918,7 +15479,15 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14918
15479
|
}
|
|
14919
15480
|
/** Check if paused */
|
|
14920
15481
|
isPaused() {
|
|
14921
|
-
return this.videoElement?.paused ?? true;
|
|
15482
|
+
return this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
15483
|
+
}
|
|
15484
|
+
/** Suppress play/pause-driven UI updates for a short window */
|
|
15485
|
+
suppressPlayPauseEvents(ms = 200) {
|
|
15486
|
+
this.suppressPlayPauseEventsUntil = Date.now() + ms;
|
|
15487
|
+
}
|
|
15488
|
+
/** Check if play/pause UI updates should be suppressed */
|
|
15489
|
+
shouldSuppressVideoEvents() {
|
|
15490
|
+
return Date.now() < this.suppressPlayPauseEventsUntil;
|
|
14922
15491
|
}
|
|
14923
15492
|
/** Check if muted */
|
|
14924
15493
|
isMuted() {
|
|
@@ -14936,8 +15505,18 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14936
15505
|
}
|
|
14937
15506
|
/** Toggle play/pause */
|
|
14938
15507
|
togglePlay() {
|
|
14939
|
-
|
|
14940
|
-
|
|
15508
|
+
const isPaused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
15509
|
+
if (isPaused) {
|
|
15510
|
+
if (this.currentPlayer?.play) {
|
|
15511
|
+
this.currentPlayer.play().catch(() => { });
|
|
15512
|
+
}
|
|
15513
|
+
else {
|
|
15514
|
+
this.videoElement?.play().catch(() => { });
|
|
15515
|
+
}
|
|
15516
|
+
return;
|
|
15517
|
+
}
|
|
15518
|
+
if (this.currentPlayer?.pause) {
|
|
15519
|
+
this.currentPlayer.pause();
|
|
14941
15520
|
}
|
|
14942
15521
|
else {
|
|
14943
15522
|
this.videoElement?.pause();
|
|
@@ -15033,6 +15612,9 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15033
15612
|
this._liveThresholds = calculateLiveThresholds(sourceType, this._isWebRTC, bufferWindowMs);
|
|
15034
15613
|
// Calculate seekable range using centralized logic (allow player overrides)
|
|
15035
15614
|
const playerRange = this.getPlayerSeekableRange();
|
|
15615
|
+
const allowMediaStreamDvr = isMediaStreamSource(el) &&
|
|
15616
|
+
(bufferWindowMs !== undefined && bufferWindowMs > 0) &&
|
|
15617
|
+
(sourceType !== 'whep' && sourceType !== 'webrtc');
|
|
15036
15618
|
const { seekableStart, liveEdge } = playerRange
|
|
15037
15619
|
? { seekableStart: playerRange.start, liveEdge: playerRange.end }
|
|
15038
15620
|
: calculateSeekableRange({
|
|
@@ -15041,6 +15623,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15041
15623
|
mistStreamInfo,
|
|
15042
15624
|
currentTime,
|
|
15043
15625
|
duration,
|
|
15626
|
+
allowMediaStreamDvr,
|
|
15044
15627
|
});
|
|
15045
15628
|
// Update can seek - pass player's canSeek if available (e.g., WebCodecs uses server commands)
|
|
15046
15629
|
const playerCanSeek = this.currentPlayer && typeof this.currentPlayer.canSeek === 'function'
|
|
@@ -15057,9 +15640,15 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15057
15640
|
this._buffered = el.buffered.length > 0 ? el.buffered : null;
|
|
15058
15641
|
// Check if values changed
|
|
15059
15642
|
const seekableChanged = this._seekableStart !== seekableStart || this._liveEdge !== liveEdge;
|
|
15060
|
-
this._canSeek !== this._canSeek; // Already updated above
|
|
15061
15643
|
this._seekableStart = seekableStart;
|
|
15062
15644
|
this._liveEdge = liveEdge;
|
|
15645
|
+
// Update interaction controller live-only state (allow DVR shortcuts when seekable window exists)
|
|
15646
|
+
const hasDvrWindow = isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart;
|
|
15647
|
+
const isLiveOnly = isLive && !hasDvrWindow;
|
|
15648
|
+
this.interactionController?.updateConfig({
|
|
15649
|
+
isLive: isLiveOnly,
|
|
15650
|
+
frameStepSeconds: this.getFrameStepSecondsFromTracks(),
|
|
15651
|
+
});
|
|
15063
15652
|
// Update isNearLive using hysteresis
|
|
15064
15653
|
if (isLive) {
|
|
15065
15654
|
const newIsNearLive = calculateIsNearLive(currentTime, liveEdge, this._liveThresholds, this._isNearLive);
|
|
@@ -15416,10 +16005,11 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15416
16005
|
// Private Methods
|
|
15417
16006
|
// ============================================================================
|
|
15418
16007
|
async resolveEndpoints() {
|
|
15419
|
-
const { endpoints, gatewayUrl, mistUrl,
|
|
16008
|
+
const { endpoints, gatewayUrl, mistUrl, contentId, authToken } = this.config;
|
|
15420
16009
|
// Priority 1: Use pre-resolved endpoints if provided
|
|
15421
16010
|
if (endpoints?.primary) {
|
|
15422
16011
|
this.endpoints = endpoints;
|
|
16012
|
+
this.setMetadataSeed(endpoints.metadata ?? null);
|
|
15423
16013
|
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
15424
16014
|
return;
|
|
15425
16015
|
}
|
|
@@ -15430,7 +16020,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15430
16020
|
}
|
|
15431
16021
|
// Priority 3: Gateway resolution
|
|
15432
16022
|
if (gatewayUrl) {
|
|
15433
|
-
await this.resolveFromGateway(gatewayUrl,
|
|
16023
|
+
await this.resolveFromGateway(gatewayUrl, contentId, authToken);
|
|
15434
16024
|
return;
|
|
15435
16025
|
}
|
|
15436
16026
|
throw new Error('No endpoints provided and no gatewayUrl or mistUrl configured');
|
|
@@ -15448,7 +16038,13 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15448
16038
|
if (!response.ok) {
|
|
15449
16039
|
throw new Error(`MistServer HTTP ${response.status}`);
|
|
15450
16040
|
}
|
|
15451
|
-
|
|
16041
|
+
// MistServer can return JSONP: callback({...}); - strip wrapper if present
|
|
16042
|
+
let text = await response.text();
|
|
16043
|
+
const jsonpMatch = text.match(/^[^(]+\(([\s\S]*)\);?$/);
|
|
16044
|
+
if (jsonpMatch) {
|
|
16045
|
+
text = jsonpMatch[1];
|
|
16046
|
+
}
|
|
16047
|
+
const data = JSON.parse(text);
|
|
15452
16048
|
if (data.error) {
|
|
15453
16049
|
throw new Error(data.error);
|
|
15454
16050
|
}
|
|
@@ -15477,6 +16073,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15477
16073
|
outputs,
|
|
15478
16074
|
};
|
|
15479
16075
|
this.endpoints = { primary, fallbacks: [] };
|
|
16076
|
+
this.setMetadataSeed(null);
|
|
15480
16077
|
// Parse track metadata from MistServer response
|
|
15481
16078
|
if (data.meta?.tracks && typeof data.meta.tracks === 'object') {
|
|
15482
16079
|
const tracks = this.parseMistTracks(data.meta.tracks);
|
|
@@ -15497,14 +16094,25 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15497
16094
|
*/
|
|
15498
16095
|
mapMistTypeToProtocol(mistType) {
|
|
15499
16096
|
// WebCodecs raw streams - check BEFORE generic ws/ catch-all
|
|
15500
|
-
// MistServer rawws.js uses 'ws/video/raw', mews.js uses 'ws/video/mp4' and 'ws/video/webm'
|
|
15501
16097
|
if (mistType === 'ws/video/raw')
|
|
15502
16098
|
return 'RAW_WS';
|
|
15503
16099
|
if (mistType === 'wss/video/raw')
|
|
15504
16100
|
return 'RAW_WSS';
|
|
15505
|
-
//
|
|
15506
|
-
if (mistType
|
|
16101
|
+
// Annex B H264 over WebSocket (video-only, uses same 12-byte header as raw)
|
|
16102
|
+
if (mistType === 'ws/video/h264')
|
|
16103
|
+
return 'H264_WS';
|
|
16104
|
+
if (mistType === 'wss/video/h264')
|
|
16105
|
+
return 'H264_WSS';
|
|
16106
|
+
// WebM over WebSocket - check BEFORE generic ws/ catch-all
|
|
16107
|
+
if (mistType === 'ws/video/webm')
|
|
16108
|
+
return 'MEWS_WEBM';
|
|
16109
|
+
if (mistType === 'wss/video/webm')
|
|
16110
|
+
return 'MEWS_WEBM_SSL';
|
|
16111
|
+
// MEWS MP4 over WebSocket - catch remaining ws/* types (defaults to mp4)
|
|
16112
|
+
if (mistType.startsWith('ws/'))
|
|
15507
16113
|
return 'MEWS_WS';
|
|
16114
|
+
if (mistType.startsWith('wss/'))
|
|
16115
|
+
return 'MEWS_WSS';
|
|
15508
16116
|
if (mistType.includes('webrtc'))
|
|
15509
16117
|
return 'MIST_WEBRTC';
|
|
15510
16118
|
if (mistType.includes('mpegurl') || mistType.includes('m3u8'))
|
|
@@ -15535,11 +16143,10 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15535
16143
|
/**
|
|
15536
16144
|
* Resolve endpoints from Gateway GraphQL API
|
|
15537
16145
|
*/
|
|
15538
|
-
async resolveFromGateway(gatewayUrl,
|
|
16146
|
+
async resolveFromGateway(gatewayUrl, contentId, authToken) {
|
|
15539
16147
|
this.setState('gateway_loading', { gatewayStatus: 'loading' });
|
|
15540
16148
|
this.gatewayClient = new GatewayClient({
|
|
15541
16149
|
gatewayUrl,
|
|
15542
|
-
contentType,
|
|
15543
16150
|
contentId,
|
|
15544
16151
|
authToken,
|
|
15545
16152
|
});
|
|
@@ -15553,6 +16160,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15553
16160
|
this.cleanupFns.push(() => this.gatewayClient?.destroy());
|
|
15554
16161
|
try {
|
|
15555
16162
|
this.endpoints = await this.gatewayClient.resolve();
|
|
16163
|
+
this.setMetadataSeed(this.endpoints?.metadata ?? null);
|
|
15556
16164
|
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
15557
16165
|
}
|
|
15558
16166
|
catch (error) {
|
|
@@ -15562,10 +16170,18 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15562
16170
|
}
|
|
15563
16171
|
}
|
|
15564
16172
|
startStreamStatePolling() {
|
|
15565
|
-
const {
|
|
16173
|
+
const { contentId, mistUrl } = this.config;
|
|
16174
|
+
const contentType = this.getResolvedContentType();
|
|
15566
16175
|
// Only poll for live-like content. DVR should only poll while recording.
|
|
15567
|
-
|
|
16176
|
+
// If contentType is unknown but mistUrl is provided, still poll so we can
|
|
16177
|
+
// detect when a stream comes online and initialize playback.
|
|
16178
|
+
if (contentType == null) {
|
|
16179
|
+
if (!mistUrl)
|
|
16180
|
+
return;
|
|
16181
|
+
}
|
|
16182
|
+
else if (contentType !== 'live' && contentType !== 'dvr') {
|
|
15568
16183
|
return;
|
|
16184
|
+
}
|
|
15569
16185
|
if (contentType === 'dvr') {
|
|
15570
16186
|
const dvrStatus = this.getMetadata()?.dvrStatus;
|
|
15571
16187
|
if (dvrStatus && dvrStatus !== 'recording')
|
|
@@ -15604,6 +16220,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15604
16220
|
this.log(`[stateChange] Updated ${mistTracks.length} tracks from MistServer`);
|
|
15605
16221
|
}
|
|
15606
16222
|
}
|
|
16223
|
+
// Merge Mist metadata into the unified metadata surface
|
|
16224
|
+
this.refreshMergedMetadata();
|
|
15607
16225
|
this.emit('streamStateChange', { state });
|
|
15608
16226
|
// Auto-play when stream transitions from offline to online
|
|
15609
16227
|
// This handles the case where user is watching IdleScreen and stream comes online
|
|
@@ -15662,6 +16280,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15662
16280
|
outputs,
|
|
15663
16281
|
};
|
|
15664
16282
|
this.endpoints = { primary, fallbacks: [] };
|
|
16283
|
+
this.setMetadataSeed(this.endpoints.metadata ?? null);
|
|
15665
16284
|
// Parse track metadata from stream info
|
|
15666
16285
|
if (streamInfo.meta?.tracks && typeof streamInfo.meta.tracks === 'object') {
|
|
15667
16286
|
const tracks = this.parseMistTracks(streamInfo.meta.tracks);
|
|
@@ -15778,6 +16397,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15778
16397
|
muted: muted !== false,
|
|
15779
16398
|
controls: controls !== false,
|
|
15780
16399
|
poster: poster,
|
|
16400
|
+
debug: this.config.debug,
|
|
15781
16401
|
onReady: (el) => {
|
|
15782
16402
|
// Guard against zombie callbacks after destroy
|
|
15783
16403
|
if (this.isDestroyed || !this.container) {
|
|
@@ -15801,7 +16421,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15801
16421
|
this.initializeSubControllers();
|
|
15802
16422
|
this.emit('ready', { videoElement: el });
|
|
15803
16423
|
},
|
|
15804
|
-
onTimeUpdate: (
|
|
16424
|
+
onTimeUpdate: (_t) => {
|
|
15805
16425
|
if (this.isDestroyed)
|
|
15806
16426
|
return;
|
|
15807
16427
|
// Defensive: keep video element attached even if some other lifecycle cleared the container.
|
|
@@ -15862,6 +16482,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15862
16482
|
this.setState('buffering');
|
|
15863
16483
|
};
|
|
15864
16484
|
const onPlaying = () => {
|
|
16485
|
+
if (this.shouldSuppressVideoEvents())
|
|
16486
|
+
return;
|
|
15865
16487
|
this._isBuffering = false;
|
|
15866
16488
|
this._hasPlaybackStarted = true;
|
|
15867
16489
|
// Clear stall timer on successful playback
|
|
@@ -15881,7 +16503,11 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15881
16503
|
// Attempt to clear error on canplay
|
|
15882
16504
|
this.attemptClearError();
|
|
15883
16505
|
};
|
|
15884
|
-
const onPause = () =>
|
|
16506
|
+
const onPause = () => {
|
|
16507
|
+
if (this.shouldSuppressVideoEvents())
|
|
16508
|
+
return;
|
|
16509
|
+
this.setState('paused');
|
|
16510
|
+
};
|
|
15885
16511
|
const onEnded = () => this.setState('ended');
|
|
15886
16512
|
const onError = () => {
|
|
15887
16513
|
const message = el.error ? el.error.message || 'Playback error' : 'Playback error';
|
|
@@ -16052,10 +16678,29 @@ class PlayerController extends TypedEventEmitter {
|
|
|
16052
16678
|
if (!this.container || !this.videoElement)
|
|
16053
16679
|
return;
|
|
16054
16680
|
const isLive = this.isEffectivelyLive();
|
|
16681
|
+
const hasDvrWindow = isLive && Number.isFinite(this._liveEdge) && Number.isFinite(this._seekableStart) && this._liveEdge > this._seekableStart;
|
|
16682
|
+
const isLiveOnly = isLive && !hasDvrWindow;
|
|
16683
|
+
const interactionContainer = this.container.closest('[data-player-container="true"]') ?? this.container;
|
|
16055
16684
|
this.interactionController = new InteractionController({
|
|
16056
|
-
container:
|
|
16685
|
+
container: interactionContainer,
|
|
16057
16686
|
videoElement: this.videoElement,
|
|
16058
|
-
isLive,
|
|
16687
|
+
isLive: isLiveOnly,
|
|
16688
|
+
isPaused: () => this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true,
|
|
16689
|
+
frameStepSeconds: this.getFrameStepSecondsFromTracks(),
|
|
16690
|
+
onFrameStep: (direction, seconds) => {
|
|
16691
|
+
const player = this.currentPlayer ?? this.playerManager.getCurrentPlayer();
|
|
16692
|
+
const playerName = player?.capability?.shortname ?? this._currentPlayerInfo?.shortname ?? 'unknown';
|
|
16693
|
+
const hasFrameStep = typeof player?.frameStep === 'function';
|
|
16694
|
+
this.log(`[interaction] frameStep dir=${direction} player=${playerName} hasFrameStep=${hasFrameStep}`);
|
|
16695
|
+
if (playerName === 'webcodecs') {
|
|
16696
|
+
this.suppressPlayPauseEvents(250);
|
|
16697
|
+
}
|
|
16698
|
+
if (hasFrameStep && player && player.frameStep) {
|
|
16699
|
+
player.frameStep(direction, seconds);
|
|
16700
|
+
return true;
|
|
16701
|
+
}
|
|
16702
|
+
return false;
|
|
16703
|
+
},
|
|
16059
16704
|
onPlayPause: () => this.togglePlay(),
|
|
16060
16705
|
onSeek: (delta) => {
|
|
16061
16706
|
// End any speed hold before seeking
|
|
@@ -16395,7 +17040,7 @@ class SubtitleManager {
|
|
|
16395
17040
|
try {
|
|
16396
17041
|
textTrack.addCue(cue);
|
|
16397
17042
|
}
|
|
16398
|
-
catch
|
|
17043
|
+
catch {
|
|
16399
17044
|
// Ignore errors from invalid cue timing
|
|
16400
17045
|
}
|
|
16401
17046
|
}
|
|
@@ -16777,7 +17422,8 @@ function formatTimeDisplay(params) {
|
|
|
16777
17422
|
}
|
|
16778
17423
|
return `-${formatTime(Math.abs(behindSeconds))}`;
|
|
16779
17424
|
}
|
|
16780
|
-
|
|
17425
|
+
// No DVR window: show LIVE instead of a misleading timestamp
|
|
17426
|
+
return 'LIVE';
|
|
16781
17427
|
}
|
|
16782
17428
|
// VOD: show current / total
|
|
16783
17429
|
if (Number.isFinite(duration) && duration > 0) {
|