@livepeer-frameworks/player-core 0.0.3 → 0.1.0
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 +78 -0
- 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 +3 -331
- 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 +1 -1
- package/src/core/CodecUtils.ts +1 -1
- package/src/core/GatewayClient.ts +8 -10
- package/src/core/LiveDurationProxy.ts +0 -1
- package/src/core/MetaTrackManager.ts +1 -1
- package/src/core/PlayerController.ts +232 -26
- package/src/core/PlayerInterface.ts +6 -0
- package/src/core/PlayerManager.ts +49 -0
- package/src/core/StreamStateClient.ts +3 -3
- package/src/core/SubtitleManager.ts +1 -1
- package/src/core/TelemetryReporter.ts +1 -1
- package/src/core/TimerManager.ts +1 -1
- package/src/core/scorer.ts +8 -4
- package/src/players/DashJsPlayer.ts +23 -11
- package/src/players/HlsJsPlayer.ts +29 -5
- package/src/players/MewsWsPlayer/SourceBufferManager.ts +3 -3
- package/src/players/MewsWsPlayer/WebSocketManager.ts +0 -1
- package/src/players/MewsWsPlayer/index.ts +7 -5
- package/src/players/MistPlayer.ts +1 -1
- package/src/players/MistWebRTCPlayer/index.ts +1 -1
- package/src/players/NativePlayer.ts +2 -2
- package/src/players/VideoJsPlayer.ts +33 -31
- package/src/players/WebCodecsPlayer/SyncController.ts +1 -2
- package/src/players/WebCodecsPlayer/WebSocketController.ts +1 -1
- package/src/players/WebCodecsPlayer/index.ts +25 -7
- package/src/players/WebCodecsPlayer/types.ts +31 -3
- package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +20 -13
- package/src/players/WebCodecsPlayer/worker/types.ts +4 -0
- package/src/styles/player.css +0 -314
- package/src/types.ts +43 -1
- package/src/vanilla/FrameWorksPlayer.ts +5 -5
- package/src/vanilla/index.ts +3 -3
package/dist/esm/index.js
CHANGED
|
@@ -158,7 +158,7 @@ function extractHEVCProfile(init) {
|
|
|
158
158
|
const profileIdc = bytes[i];
|
|
159
159
|
if (profileIdc >= 1 && profileIdc <= 5) {
|
|
160
160
|
// Valid profile IDC (1=Main, 2=Main10, 3=MainStill, 4=Range Extensions, 5=High Throughput)
|
|
161
|
-
|
|
161
|
+
// tierFlag assumed to be 0 (main tier)
|
|
162
162
|
const levelIdc = bytes[i + 1] || 93; // Default to level 3.1
|
|
163
163
|
// Format: hev1.{profile}.{tier_flag}{compatibility}.L{level}.{constraints}
|
|
164
164
|
return `hev1.${profileIdc}.6.L${levelIdc}.B0`;
|
|
@@ -599,6 +599,9 @@ const PROTOCOL_PENALTIES = {
|
|
|
599
599
|
// MEWS - heavy penalty, prefer HLS/WebRTC (reference mews.js has issues)
|
|
600
600
|
'ws/video/mp4': 0.50,
|
|
601
601
|
'wss/video/mp4': 0.50,
|
|
602
|
+
// Native Mist WebRTC signaling - treat like MEWS (legacy/less stable than WHEP)
|
|
603
|
+
'webrtc': 0.50,
|
|
604
|
+
'mist/webrtc': 0.50,
|
|
602
605
|
// DASH - heavy penalty, broken implementation
|
|
603
606
|
'dash/video/mp4': 0.90, // Below legacy
|
|
604
607
|
'dash/video/webm': 0.95,
|
|
@@ -671,8 +674,8 @@ const MODE_PROTOCOL_BONUSES = {
|
|
|
671
674
|
'wss/video/h264': 0.52,
|
|
672
675
|
// WHEP/WebRTC: sub-second latency
|
|
673
676
|
'whep': 0.50,
|
|
674
|
-
'webrtc': 0.
|
|
675
|
-
'mist/webrtc': 0.
|
|
677
|
+
'webrtc': 0.25,
|
|
678
|
+
'mist/webrtc': 0.25,
|
|
676
679
|
// MP4/WS (MEWS): 2-5s latency, good fallback
|
|
677
680
|
'ws/video/mp4': 0.30,
|
|
678
681
|
'wss/video/mp4': 0.30,
|
|
@@ -696,6 +699,7 @@ const MODE_PROTOCOL_BONUSES = {
|
|
|
696
699
|
// WebRTC: minimal for quality mode
|
|
697
700
|
'whep': 0.05,
|
|
698
701
|
'webrtc': 0.05,
|
|
702
|
+
'mist/webrtc': 0.05,
|
|
699
703
|
},
|
|
700
704
|
'vod': {
|
|
701
705
|
// VOD/Clip: Prefer seekable protocols, EXCLUDE WebRTC (no seek support)
|
|
@@ -719,8 +723,8 @@ const MODE_PROTOCOL_BONUSES = {
|
|
|
719
723
|
'html5/video/mp4': 0.42,
|
|
720
724
|
// WHEP/WebRTC: good for low latency
|
|
721
725
|
'whep': 0.38,
|
|
722
|
-
'webrtc': 0.
|
|
723
|
-
'mist/webrtc': 0.
|
|
726
|
+
'webrtc': 0.20,
|
|
727
|
+
'mist/webrtc': 0.20,
|
|
724
728
|
// MP4/WS (MEWS): lower latency than HLS
|
|
725
729
|
'ws/video/mp4': 0.30,
|
|
726
730
|
'wss/video/mp4': 0.30,
|
|
@@ -756,10 +760,12 @@ const PROTOCOL_ROUTING = {
|
|
|
756
760
|
'wss/video/webm': { prefer: ['mews'] },
|
|
757
761
|
// HLS
|
|
758
762
|
'html5/application/vnd.apple.mpegurl': {
|
|
759
|
-
prefer: ['videojs', '
|
|
763
|
+
prefer: ['videojs', 'hlsjs'],
|
|
764
|
+
avoid: ['native'],
|
|
760
765
|
},
|
|
761
766
|
'html5/application/vnd.apple.mpegurl;version=7': {
|
|
762
|
-
prefer: ['videojs', '
|
|
767
|
+
prefer: ['videojs', 'hlsjs'],
|
|
768
|
+
avoid: ['native'],
|
|
763
769
|
},
|
|
764
770
|
// DASH
|
|
765
771
|
'dash/video/mp4': { prefer: ['dashjs', 'videojs'] },
|
|
@@ -1090,6 +1096,13 @@ class PlayerManager {
|
|
|
1090
1096
|
const selectionIndexBySource = new Map();
|
|
1091
1097
|
selectionSources.forEach((s, idx) => selectionIndexBySource.set(s, idx));
|
|
1092
1098
|
const totalSources = selectionSources.length;
|
|
1099
|
+
const requiredTracks = [];
|
|
1100
|
+
if (streamInfo.meta.tracks.some((t) => t.type === 'video')) {
|
|
1101
|
+
requiredTracks.push('video');
|
|
1102
|
+
}
|
|
1103
|
+
if (streamInfo.meta.tracks.some((t) => t.type === 'audio')) {
|
|
1104
|
+
requiredTracks.push('audio');
|
|
1105
|
+
}
|
|
1093
1106
|
// Track seen player+sourceType pairs to avoid duplicates
|
|
1094
1107
|
const seenPairs = new Set();
|
|
1095
1108
|
for (const player of players) {
|
|
@@ -1163,6 +1176,38 @@ class PlayerManager {
|
|
|
1163
1176
|
});
|
|
1164
1177
|
continue;
|
|
1165
1178
|
}
|
|
1179
|
+
if (Array.isArray(tracktypes) && requiredTracks.length > 0) {
|
|
1180
|
+
const missing = requiredTracks.filter((t) => !tracktypes.includes(t));
|
|
1181
|
+
if (missing.length > 0) {
|
|
1182
|
+
const priorityScore = 1 - player.capability.priority / Math.max(maxPriority, 1);
|
|
1183
|
+
const sourceScore = 1 - sourceListIndex / Math.max(totalSources - 1, 1);
|
|
1184
|
+
const playerScore = scorePlayer(tracktypes, player.capability.priority, sourceListIndex, {
|
|
1185
|
+
maxPriority,
|
|
1186
|
+
totalSources,
|
|
1187
|
+
playerShortname: player.capability.shortname,
|
|
1188
|
+
mimeType: source.type,
|
|
1189
|
+
playbackMode: effectiveMode,
|
|
1190
|
+
});
|
|
1191
|
+
combinations.push({
|
|
1192
|
+
player: player.capability.shortname,
|
|
1193
|
+
playerName: player.capability.name,
|
|
1194
|
+
source,
|
|
1195
|
+
sourceIndex,
|
|
1196
|
+
sourceType: source.type,
|
|
1197
|
+
score: playerScore.total,
|
|
1198
|
+
compatible: false,
|
|
1199
|
+
incompatibleReason: `Missing required tracks: ${missing.join(', ')}`,
|
|
1200
|
+
scoreBreakdown: {
|
|
1201
|
+
trackScore: 0,
|
|
1202
|
+
trackTypes: tracktypes,
|
|
1203
|
+
priorityScore,
|
|
1204
|
+
sourceScore,
|
|
1205
|
+
weights: { tracks: 0.5, priority: 0.1, source: 0.05 },
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1166
1211
|
// Compatible - calculate full score
|
|
1167
1212
|
const trackScore = Array.isArray(tracktypes)
|
|
1168
1213
|
? tracktypes.reduce((sum, t) => sum + ({ video: 2.0, audio: 1.0, subtitle: 0.5 }[t] || 0), 0)
|
|
@@ -1622,8 +1667,8 @@ const DEFAULT_CACHE_TTL_MS = 10000;
|
|
|
1622
1667
|
const CIRCUIT_BREAKER_THRESHOLD = 5; // Open after 5 consecutive failures
|
|
1623
1668
|
const CIRCUIT_BREAKER_TIMEOUT_MS = 30000; // Half-open after 30 seconds
|
|
1624
1669
|
const RESOLVE_VIEWER_QUERY = `
|
|
1625
|
-
query ResolveViewer($
|
|
1626
|
-
resolveViewerEndpoint(
|
|
1670
|
+
query ResolveViewer($contentId: String!) {
|
|
1671
|
+
resolveViewerEndpoint(contentId: $contentId) {
|
|
1627
1672
|
primary { nodeId baseUrl protocol url geoDistance loadScore outputs }
|
|
1628
1673
|
fallbacks { nodeId baseUrl protocol url geoDistance loadScore outputs }
|
|
1629
1674
|
metadata { contentType contentId title description durationSeconds status isLive viewers recordingSizeBytes clipSource createdAt }
|
|
@@ -1669,8 +1714,7 @@ async function fetchWithRetry(url, options, maxRetries, initialDelay) {
|
|
|
1669
1714
|
* ```typescript
|
|
1670
1715
|
* const client = new GatewayClient({
|
|
1671
1716
|
* gatewayUrl: 'https://gateway.example.com/graphql',
|
|
1672
|
-
*
|
|
1673
|
-
* contentId: 'my-stream',
|
|
1717
|
+
* contentId: 'pk_...', // playbackId (view key)
|
|
1674
1718
|
* });
|
|
1675
1719
|
*
|
|
1676
1720
|
* client.on('statusChange', ({ status }) => console.log('Status:', status));
|
|
@@ -1824,10 +1868,10 @@ class GatewayClient extends TypedEventEmitter {
|
|
|
1824
1868
|
async doResolve() {
|
|
1825
1869
|
// Abort any in-flight fetch (different from inFlightRequest promise tracking)
|
|
1826
1870
|
this.abort();
|
|
1827
|
-
const { gatewayUrl,
|
|
1871
|
+
const { gatewayUrl, contentId, authToken, maxRetries = DEFAULT_MAX_RETRIES, initialDelayMs = DEFAULT_INITIAL_DELAY_MS, } = this.config;
|
|
1828
1872
|
// Validate required params
|
|
1829
|
-
if (!gatewayUrl || !
|
|
1830
|
-
const error = 'Missing required parameters: gatewayUrl
|
|
1873
|
+
if (!gatewayUrl || !contentId) {
|
|
1874
|
+
const error = 'Missing required parameters: gatewayUrl or contentId';
|
|
1831
1875
|
this.setStatus('error', error);
|
|
1832
1876
|
throw new Error(error);
|
|
1833
1877
|
}
|
|
@@ -1844,7 +1888,7 @@ class GatewayClient extends TypedEventEmitter {
|
|
|
1844
1888
|
},
|
|
1845
1889
|
body: JSON.stringify({
|
|
1846
1890
|
query: RESOLVE_VIEWER_QUERY,
|
|
1847
|
-
variables: {
|
|
1891
|
+
variables: { contentId },
|
|
1848
1892
|
}),
|
|
1849
1893
|
signal: ac.signal,
|
|
1850
1894
|
}, maxRetries, initialDelayMs);
|
|
@@ -2056,7 +2100,7 @@ class TimerManager {
|
|
|
2056
2100
|
*/
|
|
2057
2101
|
stopAll() {
|
|
2058
2102
|
const count = this.timers.size;
|
|
2059
|
-
for (const [
|
|
2103
|
+
for (const [_internalId, entry] of this.timers) {
|
|
2060
2104
|
if (entry.isInterval) {
|
|
2061
2105
|
clearInterval(entry.id);
|
|
2062
2106
|
}
|
|
@@ -2188,7 +2232,7 @@ function getStatusMessage(status, percentage) {
|
|
|
2188
2232
|
* ```typescript
|
|
2189
2233
|
* const client = new StreamStateClient({
|
|
2190
2234
|
* mistBaseUrl: 'https://mist.example.com',
|
|
2191
|
-
* streamName: '
|
|
2235
|
+
* streamName: 'pk_...', // playbackId (view key)
|
|
2192
2236
|
* });
|
|
2193
2237
|
*
|
|
2194
2238
|
* client.on('stateChange', ({ state }) => console.log('State:', state));
|
|
@@ -2339,7 +2383,7 @@ class StreamStateClient extends TypedEventEmitter {
|
|
|
2339
2383
|
.replace(/^http:/, 'ws:')
|
|
2340
2384
|
.replace(/^https:/, 'wss:')
|
|
2341
2385
|
.replace(/\/$/, '');
|
|
2342
|
-
const ws = new WebSocket(`${wsUrl}/json_${encodeURIComponent(streamName)}.js`);
|
|
2386
|
+
const ws = new WebSocket(`${wsUrl}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`);
|
|
2343
2387
|
this.ws = ws;
|
|
2344
2388
|
ws.onopen = () => {
|
|
2345
2389
|
console.debug('[StreamStateClient] WebSocket connected');
|
|
@@ -2380,7 +2424,7 @@ class StreamStateClient extends TypedEventEmitter {
|
|
|
2380
2424
|
return;
|
|
2381
2425
|
const { mistBaseUrl, streamName, pollInterval } = this.config;
|
|
2382
2426
|
try {
|
|
2383
|
-
const url = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js`;
|
|
2427
|
+
const url = `${mistBaseUrl.replace(/\/$/, '')}/json_${encodeURIComponent(streamName)}.js?metaeverywhere=1&inclzero=1`;
|
|
2384
2428
|
const response = await fetch(url, {
|
|
2385
2429
|
method: 'GET',
|
|
2386
2430
|
headers: { 'Accept': 'application/json' },
|
|
@@ -2793,7 +2837,6 @@ class LiveDurationProxy {
|
|
|
2793
2837
|
}
|
|
2794
2838
|
// Find valid seek range
|
|
2795
2839
|
const bufferStart = buffered.start(0);
|
|
2796
|
-
this.getBufferEnd();
|
|
2797
2840
|
const liveEdge = this.getLiveEdge();
|
|
2798
2841
|
// Clamp to valid range
|
|
2799
2842
|
const clampedTime = Math.max(bufferStart, Math.min(time, liveEdge));
|
|
@@ -3130,7 +3173,7 @@ class NativePlayerImpl extends BasePlayer {
|
|
|
3130
3173
|
const browser = getBrowserInfo();
|
|
3131
3174
|
// Safari cannot play WebM - skip entirely
|
|
3132
3175
|
// Reference: html5.js:28-29
|
|
3133
|
-
if (mimetype === 'html5/video/webm' && browser.
|
|
3176
|
+
if (mimetype === 'html5/video/webm' && browser.isSafari) {
|
|
3134
3177
|
return false;
|
|
3135
3178
|
}
|
|
3136
3179
|
// Special handling for HLS
|
|
@@ -3269,7 +3312,7 @@ class NativePlayerImpl extends BasePlayer {
|
|
|
3269
3312
|
// Use LiveDurationProxy for all live streams (non-WHEP)
|
|
3270
3313
|
// WHEP handles its own live edge via signaling
|
|
3271
3314
|
// This enables seeking and jump-to-live for native MP4/WebM/HLS live streams
|
|
3272
|
-
const isLiveStream = streamInfo?.type === 'live'
|
|
3315
|
+
const isLiveStream = streamInfo?.type === 'live';
|
|
3273
3316
|
if (source.type !== 'whep' && isLiveStream) {
|
|
3274
3317
|
this.setupLiveDurationProxy(video);
|
|
3275
3318
|
this.setupAutoRecovery(video);
|
|
@@ -3918,13 +3961,29 @@ class HlsJsPlayerImpl extends BasePlayer {
|
|
|
3918
3961
|
const Hls = mod.default || mod;
|
|
3919
3962
|
console.log('[HLS.js] hls.js module imported, Hls.isSupported():', Hls.isSupported?.());
|
|
3920
3963
|
if (Hls.isSupported()) {
|
|
3921
|
-
|
|
3964
|
+
// Build optimized HLS.js config with user overrides
|
|
3965
|
+
const hlsConfig = {
|
|
3966
|
+
// Worker disabled for lower latency (per HLS.js maintainer recommendation)
|
|
3922
3967
|
enableWorker: false,
|
|
3968
|
+
// LL-HLS support
|
|
3923
3969
|
lowLatencyMode: true,
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3970
|
+
// AGGRESSIVE: Assume 5 Mbps initially (not 500kbps default)
|
|
3971
|
+
// This dramatically improves startup time by selecting appropriate quality faster
|
|
3972
|
+
abrEwmaDefaultEstimate: 5000000,
|
|
3973
|
+
// AGGRESSIVE: Minimal buffers for fastest startup
|
|
3974
|
+
maxBufferLength: 6, // Reduced from 15 (just 2 segments @ 3s)
|
|
3975
|
+
maxMaxBufferLength: 15, // Reduced from 60
|
|
3976
|
+
backBufferLength: Infinity, // Let browser manage (per maintainer advice)
|
|
3977
|
+
// Stay close to live edge but not too aggressive
|
|
3978
|
+
liveSyncDuration: 4, // Target 4 seconds behind live edge
|
|
3979
|
+
liveMaxLatencyDuration: 8, // Max 8 seconds before seeking to live
|
|
3980
|
+
// Faster ABR adaptation for live
|
|
3981
|
+
abrEwmaFastLive: 2.0, // Faster than default 3.0
|
|
3982
|
+
abrEwmaSlowLive: 6.0, // Faster than default 9.0
|
|
3983
|
+
// Allow user overrides
|
|
3984
|
+
...options.hlsConfig,
|
|
3985
|
+
};
|
|
3986
|
+
this.hls = new Hls(hlsConfig);
|
|
3928
3987
|
this.hls.attachMedia(video);
|
|
3929
3988
|
this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
3930
3989
|
this.hls.loadSource(source.url);
|
|
@@ -4095,6 +4154,26 @@ class HlsJsPlayerImpl extends BasePlayer {
|
|
|
4095
4154
|
}
|
|
4096
4155
|
}
|
|
4097
4156
|
}
|
|
4157
|
+
/**
|
|
4158
|
+
* Provide a seekable range override for live streams.
|
|
4159
|
+
* Uses liveSyncPosition as the live edge to avoid waiting for the absolute end.
|
|
4160
|
+
*/
|
|
4161
|
+
getSeekableRange() {
|
|
4162
|
+
const video = this.videoElement;
|
|
4163
|
+
if (!video?.seekable || video.seekable.length === 0)
|
|
4164
|
+
return null;
|
|
4165
|
+
const start = video.seekable.start(0);
|
|
4166
|
+
let end = video.seekable.end(video.seekable.length - 1);
|
|
4167
|
+
if (this.liveDurationProxy?.isLive() && this.hls && typeof this.hls.liveSyncPosition === 'number') {
|
|
4168
|
+
const sync = this.hls.liveSyncPosition;
|
|
4169
|
+
if (Number.isFinite(sync) && sync > 0) {
|
|
4170
|
+
end = Math.min(end, sync);
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
4174
|
+
return null;
|
|
4175
|
+
return { start, end };
|
|
4176
|
+
}
|
|
4098
4177
|
/**
|
|
4099
4178
|
* Get latency from live edge (for live streams)
|
|
4100
4179
|
*/
|
|
@@ -4484,16 +4563,17 @@ class DashJsPlayerImpl extends BasePlayer {
|
|
|
4484
4563
|
console.debug('[DashJS v5] streamInitialized - isDynamic:', isDynamic);
|
|
4485
4564
|
});
|
|
4486
4565
|
// Configure dashjs v5 streaming settings BEFORE initialization
|
|
4566
|
+
// AGGRESSIVE settings for fastest startup and low latency
|
|
4487
4567
|
this.dashPlayer.updateSettings({
|
|
4488
4568
|
streaming: {
|
|
4489
|
-
//
|
|
4569
|
+
// AGGRESSIVE: Minimal buffers for fastest startup
|
|
4490
4570
|
buffer: {
|
|
4491
4571
|
fastSwitchEnabled: true,
|
|
4492
|
-
stableBufferTime: 16
|
|
4493
|
-
bufferTimeAtTopQuality: 30
|
|
4494
|
-
bufferTimeAtTopQualityLongForm: 60
|
|
4495
|
-
bufferToKeep: 30
|
|
4496
|
-
bufferPruningInterval: 30
|
|
4572
|
+
stableBufferTime: 4, // Reduced from 16 (aggressive!)
|
|
4573
|
+
bufferTimeAtTopQuality: 8, // Reduced from 30
|
|
4574
|
+
bufferTimeAtTopQualityLongForm: 15, // Reduced from 60
|
|
4575
|
+
bufferToKeep: 10, // Reduced from 30
|
|
4576
|
+
bufferPruningInterval: 10, // Reduced from 30
|
|
4497
4577
|
},
|
|
4498
4578
|
// Gaps/stall handling
|
|
4499
4579
|
gaps: {
|
|
@@ -4502,12 +4582,23 @@ class DashJsPlayerImpl extends BasePlayer {
|
|
|
4502
4582
|
smallGapLimit: 1.5,
|
|
4503
4583
|
threshold: 0.3,
|
|
4504
4584
|
},
|
|
4505
|
-
// ABR
|
|
4585
|
+
// AGGRESSIVE: ABR with high initial bitrate estimate
|
|
4506
4586
|
abr: {
|
|
4507
4587
|
autoSwitchBitrate: { video: true, audio: true },
|
|
4508
4588
|
limitBitrateByPortal: false,
|
|
4509
4589
|
useDefaultABRRules: true,
|
|
4510
|
-
initialBitrate: { video:
|
|
4590
|
+
initialBitrate: { video: 5000000, audio: 128000 }, // 5Mbps initial (was -1)
|
|
4591
|
+
},
|
|
4592
|
+
// LIVE CATCHUP - critical for maintaining live edge (was missing!)
|
|
4593
|
+
liveCatchup: {
|
|
4594
|
+
enabled: true,
|
|
4595
|
+
maxDrift: 1.5, // Seek to live if drift > 1.5s
|
|
4596
|
+
playbackRate: {
|
|
4597
|
+
max: 0.15, // Speed up by max 15%
|
|
4598
|
+
min: -0.15, // Slow down by max 15%
|
|
4599
|
+
},
|
|
4600
|
+
playbackBufferMin: 0.3, // Min buffer before catchup
|
|
4601
|
+
mode: 'liveCatchupModeDefault',
|
|
4511
4602
|
},
|
|
4512
4603
|
// Retry settings - more aggressive
|
|
4513
4604
|
retryAttempts: {
|
|
@@ -4542,11 +4633,11 @@ class DashJsPlayerImpl extends BasePlayer {
|
|
|
4542
4633
|
abandonLoadTimeout: 5000, // 5 seconds instead of default 10
|
|
4543
4634
|
xhrWithCredentials: false,
|
|
4544
4635
|
text: { defaultEnabled: false },
|
|
4545
|
-
//
|
|
4636
|
+
// AGGRESSIVE: Tighter live delay
|
|
4546
4637
|
delay: {
|
|
4547
|
-
liveDelay:
|
|
4638
|
+
liveDelay: 2, // Reduced from 4 (2s behind live edge)
|
|
4548
4639
|
liveDelayFragmentCount: null,
|
|
4549
|
-
useSuggestedPresentationDelay:
|
|
4640
|
+
useSuggestedPresentationDelay: false, // Ignore manifest suggestions
|
|
4550
4641
|
},
|
|
4551
4642
|
},
|
|
4552
4643
|
debug: {
|
|
@@ -4960,6 +5051,9 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
4960
5051
|
const videojs = mod.default || mod;
|
|
4961
5052
|
// When using custom controls (controls: false), disable ALL VideoJS UI elements
|
|
4962
5053
|
const useVideoJsControls = options.controls === true;
|
|
5054
|
+
// Android < 7 workaround: enable overrideNative for HLS
|
|
5055
|
+
const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
|
|
5056
|
+
const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
|
|
4963
5057
|
// Build VideoJS options
|
|
4964
5058
|
// NOTE: We disable UI components but NOT children array - that breaks playback
|
|
4965
5059
|
const vjsOptions = {
|
|
@@ -4975,16 +5069,30 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
4975
5069
|
controlBar: useVideoJsControls,
|
|
4976
5070
|
liveTracker: useVideoJsControls,
|
|
4977
5071
|
// Don't set children: [] - that can break internal VideoJS components
|
|
5072
|
+
// VHS (http-streaming) configuration - AGGRESSIVE for fastest startup
|
|
5073
|
+
html5: {
|
|
5074
|
+
vhs: {
|
|
5075
|
+
// AGGRESSIVE: Start with lower quality for instant playback
|
|
5076
|
+
enableLowInitialPlaylist: true,
|
|
5077
|
+
// AGGRESSIVE: Assume 5 Mbps initially
|
|
5078
|
+
bandwidth: 5000000,
|
|
5079
|
+
// Persist bandwidth across sessions for returning users
|
|
5080
|
+
useBandwidthFromLocalStorage: true,
|
|
5081
|
+
// Enable partial segment processing for lower latency
|
|
5082
|
+
handlePartialData: true,
|
|
5083
|
+
// AGGRESSIVE: Very tight live range
|
|
5084
|
+
liveRangeSafeTimeDelta: 0.3,
|
|
5085
|
+
// Allow user overrides via options.vhsConfig
|
|
5086
|
+
...options.vhsConfig,
|
|
5087
|
+
},
|
|
5088
|
+
// Android < 7 workaround
|
|
5089
|
+
...(androidVersion && androidVersion < 7 ? {
|
|
5090
|
+
hls: { overrideNative: true }
|
|
5091
|
+
} : {}),
|
|
5092
|
+
},
|
|
5093
|
+
nativeAudioTracks: androidVersion && androidVersion < 7 ? false : undefined,
|
|
5094
|
+
nativeVideoTracks: androidVersion && androidVersion < 7 ? false : undefined,
|
|
4978
5095
|
};
|
|
4979
|
-
// Android < 7 workaround: enable overrideNative for HLS
|
|
4980
|
-
const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
|
|
4981
|
-
const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
|
|
4982
|
-
if (androidVersion && androidVersion < 7) {
|
|
4983
|
-
console.debug('[VideoJS] Android < 7 detected, enabling overrideNative');
|
|
4984
|
-
vjsOptions.html5 = { hls: { overrideNative: true } };
|
|
4985
|
-
vjsOptions.nativeAudioTracks = false;
|
|
4986
|
-
vjsOptions.nativeVideoTracks = false;
|
|
4987
|
-
}
|
|
4988
5096
|
console.debug('[VideoJS] Creating player with options:', vjsOptions);
|
|
4989
5097
|
this.videojsPlayer = videojs(video, vjsOptions);
|
|
4990
5098
|
console.debug('[VideoJS] Player created');
|
|
@@ -5188,26 +5296,6 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
5188
5296
|
const v = this.proxyElement || this.videoElement;
|
|
5189
5297
|
return v?.currentTime ?? 0;
|
|
5190
5298
|
}
|
|
5191
|
-
getDuration() {
|
|
5192
|
-
const v = this.proxyElement || this.videoElement;
|
|
5193
|
-
return v?.duration ?? 0;
|
|
5194
|
-
}
|
|
5195
|
-
getSeekableRange() {
|
|
5196
|
-
if (this.videojsPlayer?.seekable) {
|
|
5197
|
-
try {
|
|
5198
|
-
const seekable = this.videojsPlayer.seekable();
|
|
5199
|
-
if (seekable && seekable.length > 0) {
|
|
5200
|
-
const start = seekable.start(0) + this.timeCorrection;
|
|
5201
|
-
const end = seekable.end(seekable.length - 1) + this.timeCorrection;
|
|
5202
|
-
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
|
5203
|
-
return { start, end };
|
|
5204
|
-
}
|
|
5205
|
-
}
|
|
5206
|
-
}
|
|
5207
|
-
catch { }
|
|
5208
|
-
}
|
|
5209
|
-
return null;
|
|
5210
|
-
}
|
|
5211
5299
|
/**
|
|
5212
5300
|
* Seek to time using VideoJS API (fixes backwards seeking in HLS).
|
|
5213
5301
|
* Time should be in the corrected coordinate space (with firstms offset applied).
|
|
@@ -5322,6 +5410,31 @@ class VideoJsPlayerImpl extends BasePlayer {
|
|
|
5322
5410
|
}
|
|
5323
5411
|
}
|
|
5324
5412
|
}
|
|
5413
|
+
/**
|
|
5414
|
+
* Provide a seekable range override for live streams.
|
|
5415
|
+
* Uses VideoJS liveTracker seekableEnd as the live edge when available.
|
|
5416
|
+
*/
|
|
5417
|
+
getSeekableRange() {
|
|
5418
|
+
const video = this.videoElement;
|
|
5419
|
+
if (!video?.seekable || video.seekable.length === 0)
|
|
5420
|
+
return null;
|
|
5421
|
+
let start = video.seekable.start(0);
|
|
5422
|
+
let end = video.seekable.end(video.seekable.length - 1);
|
|
5423
|
+
if (this.videojsPlayer?.liveTracker) {
|
|
5424
|
+
const tracker = this.videojsPlayer.liveTracker;
|
|
5425
|
+
const trackerEnd = tracker.seekableEnd?.();
|
|
5426
|
+
const trackerStart = tracker.seekableStart?.();
|
|
5427
|
+
if (typeof trackerStart === 'number' && Number.isFinite(trackerStart)) {
|
|
5428
|
+
start = trackerStart;
|
|
5429
|
+
}
|
|
5430
|
+
if (typeof trackerEnd === 'number' && Number.isFinite(trackerEnd) && trackerEnd > 0) {
|
|
5431
|
+
end = Math.min(end, trackerEnd);
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
5434
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
|
|
5435
|
+
return null;
|
|
5436
|
+
return { start, end };
|
|
5437
|
+
}
|
|
5325
5438
|
/**
|
|
5326
5439
|
* Get latency from live edge (for live streams)
|
|
5327
5440
|
*/
|
|
@@ -5516,7 +5629,7 @@ class MistPlayerImpl extends BasePlayer {
|
|
|
5516
5629
|
ref.unload();
|
|
5517
5630
|
}
|
|
5518
5631
|
}
|
|
5519
|
-
catch
|
|
5632
|
+
catch {
|
|
5520
5633
|
// Ignore cleanup errors
|
|
5521
5634
|
}
|
|
5522
5635
|
try {
|
|
@@ -5651,7 +5764,6 @@ class WebSocketManager {
|
|
|
5651
5764
|
this.onError('Too many send retries');
|
|
5652
5765
|
return false;
|
|
5653
5766
|
}
|
|
5654
|
-
// Helper to schedule retry with tracking
|
|
5655
5767
|
const scheduleRetry = (delay) => {
|
|
5656
5768
|
const timer = setTimeout(() => {
|
|
5657
5769
|
this.pendingRetryTimers.delete(timer);
|
|
@@ -6062,7 +6174,7 @@ class SourceBufferManager {
|
|
|
6062
6174
|
// Make sure end time is never 0 (mews.js:376)
|
|
6063
6175
|
this.sourceBuffer.remove(0, Math.max(0.1, currentTime - keepaway));
|
|
6064
6176
|
}
|
|
6065
|
-
catch
|
|
6177
|
+
catch {
|
|
6066
6178
|
// Ignore errors during cleanup
|
|
6067
6179
|
}
|
|
6068
6180
|
});
|
|
@@ -6089,7 +6201,7 @@ class SourceBufferManager {
|
|
|
6089
6201
|
this.sourceBuffer.remove(0, Infinity);
|
|
6090
6202
|
}
|
|
6091
6203
|
}
|
|
6092
|
-
catch
|
|
6204
|
+
catch {
|
|
6093
6205
|
// Ignore
|
|
6094
6206
|
}
|
|
6095
6207
|
// Wait for remove to complete, then reinit
|
|
@@ -6111,7 +6223,7 @@ class SourceBufferManager {
|
|
|
6111
6223
|
try {
|
|
6112
6224
|
this.mediaSource.removeSourceBuffer(this.sourceBuffer);
|
|
6113
6225
|
}
|
|
6114
|
-
catch
|
|
6226
|
+
catch {
|
|
6115
6227
|
// Ignore
|
|
6116
6228
|
}
|
|
6117
6229
|
}
|
|
@@ -6389,7 +6501,7 @@ class MewsWsPlayerImpl extends BasePlayer {
|
|
|
6389
6501
|
return false;
|
|
6390
6502
|
return Object.keys(playableTracks);
|
|
6391
6503
|
}
|
|
6392
|
-
async initialize(container, source, options) {
|
|
6504
|
+
async initialize(container, source, options, streamInfo) {
|
|
6393
6505
|
this.container = container;
|
|
6394
6506
|
container.classList.add('fw-player-container');
|
|
6395
6507
|
const video = document.createElement('video');
|
|
@@ -6419,13 +6531,15 @@ class MewsWsPlayerImpl extends BasePlayer {
|
|
|
6419
6531
|
enabled: !!anyOpts.analytics?.enabled,
|
|
6420
6532
|
endpoint: anyOpts.analytics?.endpoint || null
|
|
6421
6533
|
};
|
|
6422
|
-
// Get stream type from
|
|
6423
|
-
|
|
6534
|
+
// Get stream type from streamInfo if available
|
|
6535
|
+
// Note: source.type is a MIME string (e.g., 'ws/video/mp4'), not 'live'/'vod'
|
|
6536
|
+
if (streamInfo?.type === 'live') {
|
|
6424
6537
|
this.streamType = 'live';
|
|
6425
6538
|
}
|
|
6426
|
-
else if (
|
|
6539
|
+
else if (streamInfo?.type === 'vod') {
|
|
6427
6540
|
this.streamType = 'vod';
|
|
6428
6541
|
}
|
|
6542
|
+
// Fallback: will be determined by server on_time messages (end === 0 means live)
|
|
6429
6543
|
try {
|
|
6430
6544
|
// Initialize MediaSource (mews.js:138-196)
|
|
6431
6545
|
this.mediaSource = new MediaSource();
|
|
@@ -7966,7 +8080,7 @@ class MistWebRTCPlayerImpl extends BasePlayer {
|
|
|
7966
8080
|
});
|
|
7967
8081
|
}
|
|
7968
8082
|
// Private methods
|
|
7969
|
-
async setupWebRTC(video, source,
|
|
8083
|
+
async setupWebRTC(video, source, _options) {
|
|
7970
8084
|
const sourceAny = source;
|
|
7971
8085
|
const iceServers = sourceAny?.iceServers || [];
|
|
7972
8086
|
// Create signaling
|
|
@@ -8401,7 +8515,7 @@ class WebSocketController {
|
|
|
8401
8515
|
this.setState('disconnected');
|
|
8402
8516
|
}
|
|
8403
8517
|
};
|
|
8404
|
-
this.ws.onerror = (
|
|
8518
|
+
this.ws.onerror = (_event) => {
|
|
8405
8519
|
this.log('WebSocket error');
|
|
8406
8520
|
this.emit('error', new Error('WebSocket error'));
|
|
8407
8521
|
};
|
|
@@ -9386,7 +9500,7 @@ class SyncController {
|
|
|
9386
9500
|
/**
|
|
9387
9501
|
* Register a new track
|
|
9388
9502
|
*/
|
|
9389
|
-
addTrack(
|
|
9503
|
+
addTrack(_trackIndex, _track) {
|
|
9390
9504
|
// Jitter tracking will be initialized on first chunk
|
|
9391
9505
|
}
|
|
9392
9506
|
/**
|
|
@@ -9835,11 +9949,13 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9835
9949
|
name: 'WebCodecs Player',
|
|
9836
9950
|
shortname: 'webcodecs',
|
|
9837
9951
|
priority: 0, // Highest priority - lowest latency option
|
|
9838
|
-
// Raw WebSocket (12-byte header +
|
|
9952
|
+
// Raw WebSocket (12-byte header + codec frames) - NOT MP4-muxed
|
|
9839
9953
|
// MistServer's output_wsraw.cpp provides full codec negotiation (audio + video)
|
|
9954
|
+
// MistServer's output_h264.cpp uses same 12-byte header but Annex B payload (video-only)
|
|
9840
9955
|
// NOTE: ws/video/mp4 is MP4-fragmented which needs MEWS player (uses MSE)
|
|
9841
9956
|
mimes: [
|
|
9842
|
-
'ws/video/raw', 'wss/video/raw', // Raw codec frames (audio + video)
|
|
9957
|
+
'ws/video/raw', 'wss/video/raw', // Raw codec frames - AVCC format (audio + video)
|
|
9958
|
+
'ws/video/h264', 'wss/video/h264', // Annex B H264/HEVC (video-only, same 12-byte header)
|
|
9843
9959
|
],
|
|
9844
9960
|
};
|
|
9845
9961
|
this.wsController = null;
|
|
@@ -9856,6 +9972,8 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9856
9972
|
this.debugging = false;
|
|
9857
9973
|
this.verboseDebugging = false;
|
|
9858
9974
|
this.streamType = 'live';
|
|
9975
|
+
/** Payload format: 'avcc' for ws/video/raw, 'annexb' for ws/video/h264 */
|
|
9976
|
+
this.payloadFormat = 'avcc';
|
|
9859
9977
|
this.workerUidCounter = 0;
|
|
9860
9978
|
this.workerListeners = new Map();
|
|
9861
9979
|
// Playback state
|
|
@@ -9869,6 +9987,10 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9869
9987
|
this._framesDecoded = 0;
|
|
9870
9988
|
this._bytesReceived = 0;
|
|
9871
9989
|
this._messagesReceived = 0;
|
|
9990
|
+
this._isPaused = true;
|
|
9991
|
+
this._suppressPlayPauseSync = false;
|
|
9992
|
+
this._pendingStepPause = false;
|
|
9993
|
+
this._stepPauseTimeout = null;
|
|
9872
9994
|
}
|
|
9873
9995
|
/**
|
|
9874
9996
|
* Get cache key for a track's codec configuration
|
|
@@ -9915,7 +10037,8 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9915
10037
|
}
|
|
9916
10038
|
else {
|
|
9917
10039
|
// Use VideoDecoder.isConfigSupported()
|
|
9918
|
-
|
|
10040
|
+
const videoResult = await VideoDecoder.isConfigSupported(config);
|
|
10041
|
+
result = { supported: videoResult.supported === true, config: videoResult.config };
|
|
9919
10042
|
}
|
|
9920
10043
|
break;
|
|
9921
10044
|
}
|
|
@@ -9923,7 +10046,8 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
9923
10046
|
// Audio requires numberOfChannels and sampleRate
|
|
9924
10047
|
config.numberOfChannels = track.channels ?? 2;
|
|
9925
10048
|
config.sampleRate = track.rate ?? 48000;
|
|
9926
|
-
|
|
10049
|
+
const audioResult = await AudioDecoder.isConfigSupported(config);
|
|
10050
|
+
result = { supported: audioResult.supported === true, config: audioResult.config };
|
|
9927
10051
|
break;
|
|
9928
10052
|
}
|
|
9929
10053
|
default:
|
|
@@ -10004,6 +10128,10 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10004
10128
|
playableTracks['subtitle'] = true;
|
|
10005
10129
|
}
|
|
10006
10130
|
}
|
|
10131
|
+
// Annex B H264 WebSocket is video-only (no audio payloads)
|
|
10132
|
+
if (mimetype.includes('video/h264')) {
|
|
10133
|
+
delete playableTracks.audio;
|
|
10134
|
+
}
|
|
10007
10135
|
if (Object.keys(playableTracks).length === 0) {
|
|
10008
10136
|
return false;
|
|
10009
10137
|
}
|
|
@@ -10026,6 +10154,12 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10026
10154
|
this._framesDecoded = 0;
|
|
10027
10155
|
this._bytesReceived = 0;
|
|
10028
10156
|
this._messagesReceived = 0;
|
|
10157
|
+
// Detect payload format from source MIME type
|
|
10158
|
+
// ws/video/h264 uses Annex B (start code delimited NALs), ws/video/raw uses AVCC (length-prefixed)
|
|
10159
|
+
this.payloadFormat = source.type?.includes('h264') ? 'annexb' : 'avcc';
|
|
10160
|
+
if (this.payloadFormat === 'annexb') {
|
|
10161
|
+
this.log('Using Annex B payload format (ws/video/h264)');
|
|
10162
|
+
}
|
|
10029
10163
|
this.container = container;
|
|
10030
10164
|
container.classList.add('fw-player-container');
|
|
10031
10165
|
// Pre-populate track metadata from streamInfo (fetched via HTTP before WebSocket)
|
|
@@ -10079,6 +10213,31 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10079
10213
|
video.poster = options.poster;
|
|
10080
10214
|
this.videoElement = video;
|
|
10081
10215
|
container.appendChild(video);
|
|
10216
|
+
// Keep paused state in sync with actual element state
|
|
10217
|
+
this._onVideoPlay = () => {
|
|
10218
|
+
if (this._suppressPlayPauseSync)
|
|
10219
|
+
return;
|
|
10220
|
+
this._isPaused = false;
|
|
10221
|
+
this.sendToWorker({
|
|
10222
|
+
type: 'frametiming',
|
|
10223
|
+
action: 'setPaused',
|
|
10224
|
+
paused: false,
|
|
10225
|
+
uid: this.workerUidCounter++,
|
|
10226
|
+
}).catch(() => { });
|
|
10227
|
+
};
|
|
10228
|
+
this._onVideoPause = () => {
|
|
10229
|
+
if (this._suppressPlayPauseSync)
|
|
10230
|
+
return;
|
|
10231
|
+
this._isPaused = true;
|
|
10232
|
+
this.sendToWorker({
|
|
10233
|
+
type: 'frametiming',
|
|
10234
|
+
action: 'setPaused',
|
|
10235
|
+
paused: true,
|
|
10236
|
+
uid: this.workerUidCounter++,
|
|
10237
|
+
}).catch(() => { });
|
|
10238
|
+
};
|
|
10239
|
+
video.addEventListener('play', this._onVideoPlay);
|
|
10240
|
+
video.addEventListener('pause', this._onVideoPause);
|
|
10082
10241
|
// Create MediaStream for output
|
|
10083
10242
|
this.mediaStream = new MediaStream();
|
|
10084
10243
|
video.srcObject = this.mediaStream;
|
|
@@ -10219,6 +10378,19 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10219
10378
|
}
|
|
10220
10379
|
// Clean up video element
|
|
10221
10380
|
if (this.videoElement) {
|
|
10381
|
+
if (this._onVideoPlay) {
|
|
10382
|
+
this.videoElement.removeEventListener('play', this._onVideoPlay);
|
|
10383
|
+
this._onVideoPlay = undefined;
|
|
10384
|
+
}
|
|
10385
|
+
if (this._onVideoPause) {
|
|
10386
|
+
this.videoElement.removeEventListener('pause', this._onVideoPause);
|
|
10387
|
+
this._onVideoPause = undefined;
|
|
10388
|
+
}
|
|
10389
|
+
if (this._stepPauseTimeout) {
|
|
10390
|
+
clearTimeout(this._stepPauseTimeout);
|
|
10391
|
+
this._stepPauseTimeout = null;
|
|
10392
|
+
}
|
|
10393
|
+
this._pendingStepPause = false;
|
|
10222
10394
|
this.videoElement.srcObject = null;
|
|
10223
10395
|
this.videoElement.remove();
|
|
10224
10396
|
this.videoElement = null;
|
|
@@ -10381,8 +10553,17 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10381
10553
|
break;
|
|
10382
10554
|
}
|
|
10383
10555
|
case 'sendevent': {
|
|
10384
|
-
if (msg.kind === 'timeupdate'
|
|
10385
|
-
|
|
10556
|
+
if (msg.kind === 'timeupdate') {
|
|
10557
|
+
if (this._pendingStepPause) {
|
|
10558
|
+
this.finishStepPause();
|
|
10559
|
+
}
|
|
10560
|
+
if (typeof msg.time === 'number' && Number.isFinite(msg.time)) {
|
|
10561
|
+
this._currentTime = msg.time;
|
|
10562
|
+
this.emit('timeupdate', this._currentTime);
|
|
10563
|
+
}
|
|
10564
|
+
else if (this.videoElement) {
|
|
10565
|
+
this.emit('timeupdate', this.videoElement.currentTime);
|
|
10566
|
+
}
|
|
10386
10567
|
}
|
|
10387
10568
|
else if (msg.kind === 'error') {
|
|
10388
10569
|
this.emit('error', new Error(msg.message ?? 'Unknown error'));
|
|
@@ -10515,7 +10696,7 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10515
10696
|
if (msg.meta?.tracks) {
|
|
10516
10697
|
const tracksObj = msg.meta.tracks;
|
|
10517
10698
|
this.log(`Info contains ${Object.keys(tracksObj).length} tracks`);
|
|
10518
|
-
for (const [
|
|
10699
|
+
for (const [_name, track] of Object.entries(tracksObj)) {
|
|
10519
10700
|
// Store track by its index for lookup when chunks arrive
|
|
10520
10701
|
if (track.idx !== undefined) {
|
|
10521
10702
|
this.tracksByIndex.set(track.idx, track);
|
|
@@ -10727,6 +10908,7 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10727
10908
|
track,
|
|
10728
10909
|
opts: {
|
|
10729
10910
|
optimizeForLatency: this.streamType === 'live',
|
|
10911
|
+
payloadFormat: this.payloadFormat, // 'avcc' for ws/video/raw, 'annexb' for ws/video/h264
|
|
10730
10912
|
},
|
|
10731
10913
|
uid: this.workerUidCounter++,
|
|
10732
10914
|
});
|
|
@@ -10865,18 +11047,94 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10865
11047
|
// Playback Control
|
|
10866
11048
|
// ============================================================================
|
|
10867
11049
|
async play() {
|
|
11050
|
+
this._isPaused = false;
|
|
10868
11051
|
this.wsController?.play();
|
|
11052
|
+
this.sendToWorker({
|
|
11053
|
+
type: 'frametiming',
|
|
11054
|
+
action: 'setPaused',
|
|
11055
|
+
paused: false,
|
|
11056
|
+
uid: this.workerUidCounter++,
|
|
11057
|
+
});
|
|
10869
11058
|
await this.videoElement?.play();
|
|
10870
11059
|
}
|
|
10871
11060
|
pause() {
|
|
11061
|
+
this._isPaused = true;
|
|
10872
11062
|
this.wsController?.hold();
|
|
11063
|
+
this.sendToWorker({
|
|
11064
|
+
type: 'frametiming',
|
|
11065
|
+
action: 'setPaused',
|
|
11066
|
+
paused: true,
|
|
11067
|
+
uid: this.workerUidCounter++,
|
|
11068
|
+
});
|
|
10873
11069
|
this.videoElement?.pause();
|
|
10874
11070
|
}
|
|
11071
|
+
finishStepPause() {
|
|
11072
|
+
if (!this.videoElement) {
|
|
11073
|
+
this._pendingStepPause = false;
|
|
11074
|
+
this._suppressPlayPauseSync = false;
|
|
11075
|
+
if (this._stepPauseTimeout) {
|
|
11076
|
+
clearTimeout(this._stepPauseTimeout);
|
|
11077
|
+
this._stepPauseTimeout = null;
|
|
11078
|
+
}
|
|
11079
|
+
return;
|
|
11080
|
+
}
|
|
11081
|
+
if (this._stepPauseTimeout) {
|
|
11082
|
+
clearTimeout(this._stepPauseTimeout);
|
|
11083
|
+
this._stepPauseTimeout = null;
|
|
11084
|
+
}
|
|
11085
|
+
this._pendingStepPause = false;
|
|
11086
|
+
this._suppressPlayPauseSync = false;
|
|
11087
|
+
try {
|
|
11088
|
+
this.videoElement.pause();
|
|
11089
|
+
}
|
|
11090
|
+
catch { }
|
|
11091
|
+
}
|
|
11092
|
+
frameStep(direction, _seconds) {
|
|
11093
|
+
if (!this._isPaused)
|
|
11094
|
+
return;
|
|
11095
|
+
if (!this.videoElement)
|
|
11096
|
+
return;
|
|
11097
|
+
this.log(`Frame step requested dir=${direction} paused=${this._isPaused} videoPaused=${this.videoElement.paused}`);
|
|
11098
|
+
// Ensure worker is paused (in case pause didn't flow through)
|
|
11099
|
+
this.sendToWorker({
|
|
11100
|
+
type: 'frametiming',
|
|
11101
|
+
action: 'setPaused',
|
|
11102
|
+
paused: true,
|
|
11103
|
+
uid: this.workerUidCounter++,
|
|
11104
|
+
}).catch(() => { });
|
|
11105
|
+
// MediaStream-backed video elements don't present new frames while paused.
|
|
11106
|
+
// Pulse playback briefly so the stepped frame can render, then pause again.
|
|
11107
|
+
if (this.videoElement.paused) {
|
|
11108
|
+
const video = this.videoElement;
|
|
11109
|
+
this._suppressPlayPauseSync = true;
|
|
11110
|
+
this._pendingStepPause = true;
|
|
11111
|
+
try {
|
|
11112
|
+
const maybePromise = video.play();
|
|
11113
|
+
if (maybePromise && typeof maybePromise.catch === 'function') {
|
|
11114
|
+
maybePromise.catch(() => { });
|
|
11115
|
+
}
|
|
11116
|
+
}
|
|
11117
|
+
catch { }
|
|
11118
|
+
if ('requestVideoFrameCallback' in video) {
|
|
11119
|
+
video.requestVideoFrameCallback(() => this.finishStepPause());
|
|
11120
|
+
}
|
|
11121
|
+
// Failsafe: avoid staying in suppressed state if no frame is delivered
|
|
11122
|
+
this._stepPauseTimeout = setTimeout(() => this.finishStepPause(), 200);
|
|
11123
|
+
}
|
|
11124
|
+
this.sendToWorker({
|
|
11125
|
+
type: 'framestep',
|
|
11126
|
+
direction,
|
|
11127
|
+
uid: this.workerUidCounter++,
|
|
11128
|
+
});
|
|
11129
|
+
}
|
|
10875
11130
|
seek(time) {
|
|
10876
11131
|
if (!this.wsController || !this.syncController)
|
|
10877
11132
|
return;
|
|
10878
11133
|
const timeMs = time * 1000;
|
|
10879
11134
|
const seekId = this.syncController.startSeek(timeMs);
|
|
11135
|
+
// Optimistically update current time for immediate UI feedback
|
|
11136
|
+
this._currentTime = time;
|
|
11137
|
+
this.emit('timeupdate', this._currentTime);
|
|
10880
11138
|
// Flush worker queues
|
|
10881
11139
|
this.sendToWorker({
|
|
10882
11140
|
type: 'seek',
|
|
@@ -10902,6 +11160,9 @@ class WebCodecsPlayerImpl extends BasePlayer {
|
|
|
10902
11160
|
setPlaybackRate(rate) {
|
|
10903
11161
|
this.syncController?.setMainSpeed(rate);
|
|
10904
11162
|
}
|
|
11163
|
+
isPaused() {
|
|
11164
|
+
return this._isPaused;
|
|
11165
|
+
}
|
|
10905
11166
|
isLive() {
|
|
10906
11167
|
return this.streamType === 'live';
|
|
10907
11168
|
}
|
|
@@ -11687,6 +11948,9 @@ class InteractionController {
|
|
|
11687
11948
|
this.boundPointerCancel = this.handlePointerCancel.bind(this);
|
|
11688
11949
|
this.boundContextMenu = this.handleContextMenu.bind(this);
|
|
11689
11950
|
this.boundMouseMove = this.handleMouseMove.bind(this);
|
|
11951
|
+
this.boundDoubleClick = this.handleDoubleClick.bind(this);
|
|
11952
|
+
this.boundDocumentKeyDown = this.handleKeyDown.bind(this);
|
|
11953
|
+
this.boundDocumentKeyUp = this.handleKeyUp.bind(this);
|
|
11690
11954
|
}
|
|
11691
11955
|
/**
|
|
11692
11956
|
* Attach event listeners to container
|
|
@@ -11702,6 +11966,8 @@ class InteractionController {
|
|
|
11702
11966
|
// Keyboard events
|
|
11703
11967
|
container.addEventListener('keydown', this.boundKeyDown);
|
|
11704
11968
|
container.addEventListener('keyup', this.boundKeyUp);
|
|
11969
|
+
document.addEventListener('keydown', this.boundDocumentKeyDown);
|
|
11970
|
+
document.addEventListener('keyup', this.boundDocumentKeyUp);
|
|
11705
11971
|
// Pointer events (unified mouse + touch)
|
|
11706
11972
|
container.addEventListener('pointerdown', this.boundPointerDown);
|
|
11707
11973
|
container.addEventListener('pointerup', this.boundPointerUp);
|
|
@@ -11709,6 +11975,8 @@ class InteractionController {
|
|
|
11709
11975
|
container.addEventListener('pointerleave', this.boundPointerCancel);
|
|
11710
11976
|
// Mouse move for idle detection
|
|
11711
11977
|
container.addEventListener('mousemove', this.boundMouseMove);
|
|
11978
|
+
// Double click for fullscreen (desktop)
|
|
11979
|
+
container.addEventListener('dblclick', this.boundDoubleClick);
|
|
11712
11980
|
// Prevent context menu on long press
|
|
11713
11981
|
container.addEventListener('contextmenu', this.boundContextMenu);
|
|
11714
11982
|
// Start idle tracking
|
|
@@ -11724,11 +11992,14 @@ class InteractionController {
|
|
|
11724
11992
|
const { container } = this.config;
|
|
11725
11993
|
container.removeEventListener('keydown', this.boundKeyDown);
|
|
11726
11994
|
container.removeEventListener('keyup', this.boundKeyUp);
|
|
11995
|
+
document.removeEventListener('keydown', this.boundDocumentKeyDown);
|
|
11996
|
+
document.removeEventListener('keyup', this.boundDocumentKeyUp);
|
|
11727
11997
|
container.removeEventListener('pointerdown', this.boundPointerDown);
|
|
11728
11998
|
container.removeEventListener('pointerup', this.boundPointerUp);
|
|
11729
11999
|
container.removeEventListener('pointercancel', this.boundPointerCancel);
|
|
11730
12000
|
container.removeEventListener('pointerleave', this.boundPointerCancel);
|
|
11731
12001
|
container.removeEventListener('mousemove', this.boundMouseMove);
|
|
12002
|
+
container.removeEventListener('dblclick', this.boundDoubleClick);
|
|
11732
12003
|
container.removeEventListener('contextmenu', this.boundContextMenu);
|
|
11733
12004
|
// Clear any pending timeouts
|
|
11734
12005
|
if (this.holdCheckTimeout) {
|
|
@@ -11788,9 +12059,14 @@ class InteractionController {
|
|
|
11788
12059
|
// Ignore if focus is on an input element
|
|
11789
12060
|
if (this.isInputElement(e.target))
|
|
11790
12061
|
return;
|
|
12062
|
+
if (e.defaultPrevented)
|
|
12063
|
+
return;
|
|
12064
|
+
if (!this.shouldHandleKeyboard(e))
|
|
12065
|
+
return;
|
|
11791
12066
|
// Record interaction for idle detection
|
|
11792
12067
|
this.recordInteraction();
|
|
11793
12068
|
const { isLive } = this.config;
|
|
12069
|
+
const isPaused = this.config.isPaused?.() ?? this.config.videoElement?.paused ?? false;
|
|
11794
12070
|
switch (e.key) {
|
|
11795
12071
|
case ' ':
|
|
11796
12072
|
case 'Spacebar':
|
|
@@ -11843,7 +12119,6 @@ class InteractionController {
|
|
|
11843
12119
|
this.config.onPlayPause();
|
|
11844
12120
|
break;
|
|
11845
12121
|
case '<':
|
|
11846
|
-
case ',':
|
|
11847
12122
|
// Decrease speed (shift+, = <)
|
|
11848
12123
|
e.preventDefault();
|
|
11849
12124
|
if (!isLive) {
|
|
@@ -11851,13 +12126,26 @@ class InteractionController {
|
|
|
11851
12126
|
}
|
|
11852
12127
|
break;
|
|
11853
12128
|
case '>':
|
|
11854
|
-
case '.':
|
|
11855
12129
|
// Increase speed (shift+. = >)
|
|
11856
12130
|
e.preventDefault();
|
|
11857
12131
|
if (!isLive) {
|
|
11858
12132
|
this.adjustPlaybackSpeed(0.25);
|
|
11859
12133
|
}
|
|
11860
12134
|
break;
|
|
12135
|
+
case ',':
|
|
12136
|
+
// Previous frame when paused
|
|
12137
|
+
if (this.config.onFrameStep || (!isLive && isPaused)) {
|
|
12138
|
+
e.preventDefault();
|
|
12139
|
+
this.stepFrame(-1);
|
|
12140
|
+
}
|
|
12141
|
+
break;
|
|
12142
|
+
case '.':
|
|
12143
|
+
// Next frame when paused
|
|
12144
|
+
if (this.config.onFrameStep || (!isLive && isPaused)) {
|
|
12145
|
+
e.preventDefault();
|
|
12146
|
+
this.stepFrame(1);
|
|
12147
|
+
}
|
|
12148
|
+
break;
|
|
11861
12149
|
// Number keys for seeking to percentage
|
|
11862
12150
|
case '0':
|
|
11863
12151
|
case '1':
|
|
@@ -11880,11 +12168,36 @@ class InteractionController {
|
|
|
11880
12168
|
handleKeyUp(e) {
|
|
11881
12169
|
if (this.isInputElement(e.target))
|
|
11882
12170
|
return;
|
|
12171
|
+
if (e.defaultPrevented)
|
|
12172
|
+
return;
|
|
12173
|
+
if (!this.shouldHandleKeyboard(e))
|
|
12174
|
+
return;
|
|
11883
12175
|
if (e.key === ' ' || e.key === 'Spacebar') {
|
|
11884
12176
|
e.preventDefault();
|
|
11885
12177
|
this.handleSpaceUp();
|
|
11886
12178
|
}
|
|
11887
12179
|
}
|
|
12180
|
+
shouldHandleKeyboard(e) {
|
|
12181
|
+
if (this.spaceKeyDownTime > 0)
|
|
12182
|
+
return true;
|
|
12183
|
+
const target = e.target;
|
|
12184
|
+
if (target && this.config.container.contains(target))
|
|
12185
|
+
return true;
|
|
12186
|
+
const active = document.activeElement;
|
|
12187
|
+
if (active && this.config.container.contains(active))
|
|
12188
|
+
return true;
|
|
12189
|
+
try {
|
|
12190
|
+
if (this.config.container.matches(':focus-within'))
|
|
12191
|
+
return true;
|
|
12192
|
+
if (this.config.container.matches(':hover'))
|
|
12193
|
+
return true;
|
|
12194
|
+
}
|
|
12195
|
+
catch { }
|
|
12196
|
+
const now = Date.now();
|
|
12197
|
+
if (now - this.lastInteractionTime < DEFAULT_IDLE_TIMEOUT_MS)
|
|
12198
|
+
return true;
|
|
12199
|
+
return false;
|
|
12200
|
+
}
|
|
11888
12201
|
handleSpaceDown() {
|
|
11889
12202
|
if (this.spaceKeyDownTime > 0)
|
|
11890
12203
|
return; // Already tracking
|
|
@@ -11918,6 +12231,41 @@ class InteractionController {
|
|
|
11918
12231
|
}
|
|
11919
12232
|
}
|
|
11920
12233
|
}
|
|
12234
|
+
handleDoubleClick(e) {
|
|
12235
|
+
if (this.isControlElement(e.target))
|
|
12236
|
+
return;
|
|
12237
|
+
this.recordInteraction();
|
|
12238
|
+
e.preventDefault();
|
|
12239
|
+
this.config.onFullscreenToggle();
|
|
12240
|
+
}
|
|
12241
|
+
stepFrame(direction) {
|
|
12242
|
+
const step = this.getFrameStepSeconds();
|
|
12243
|
+
if (!Number.isFinite(step) || step <= 0)
|
|
12244
|
+
return;
|
|
12245
|
+
if (this.config.onFrameStep?.(direction, step))
|
|
12246
|
+
return;
|
|
12247
|
+
const video = this.config.videoElement;
|
|
12248
|
+
if (!video)
|
|
12249
|
+
return;
|
|
12250
|
+
const target = video.currentTime + (direction * step);
|
|
12251
|
+
if (!Number.isFinite(target))
|
|
12252
|
+
return;
|
|
12253
|
+
// Only step within already-buffered ranges to avoid network seeks
|
|
12254
|
+
const buffered = video.buffered;
|
|
12255
|
+
if (buffered && buffered.length > 0) {
|
|
12256
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
12257
|
+
const start = buffered.start(i);
|
|
12258
|
+
const end = buffered.end(i);
|
|
12259
|
+
if (target >= start && target <= end) {
|
|
12260
|
+
try {
|
|
12261
|
+
video.currentTime = target;
|
|
12262
|
+
}
|
|
12263
|
+
catch { }
|
|
12264
|
+
return;
|
|
12265
|
+
}
|
|
12266
|
+
}
|
|
12267
|
+
}
|
|
12268
|
+
}
|
|
11921
12269
|
// ─────────────────────────────────────────────────────────────────
|
|
11922
12270
|
// Pointer (Mouse/Touch) Handling
|
|
11923
12271
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -11934,6 +12282,7 @@ class InteractionController {
|
|
|
11934
12282
|
const now = Date.now();
|
|
11935
12283
|
const rect = this.config.container.getBoundingClientRect();
|
|
11936
12284
|
const relativeX = (e.clientX - rect.left) / rect.width;
|
|
12285
|
+
const isMouse = e.pointerType === 'mouse';
|
|
11937
12286
|
// Check for double-tap
|
|
11938
12287
|
if (now - this.lastTapTime < DOUBLE_TAP_WINDOW_MS) {
|
|
11939
12288
|
// Clear pending single-tap
|
|
@@ -11941,19 +12290,22 @@ class InteractionController {
|
|
|
11941
12290
|
clearTimeout(this.pendingTapTimeout);
|
|
11942
12291
|
this.pendingTapTimeout = null;
|
|
11943
12292
|
}
|
|
11944
|
-
//
|
|
11945
|
-
if (!
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
|
|
11952
|
-
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
12293
|
+
// Mouse double-click handled via dblclick event (fullscreen)
|
|
12294
|
+
if (!isMouse) {
|
|
12295
|
+
// Handle double-tap to skip (mobile-style)
|
|
12296
|
+
if (!this.config.isLive) {
|
|
12297
|
+
if (relativeX < 0.33) {
|
|
12298
|
+
// Left third - skip back
|
|
12299
|
+
this.config.onSeek(-SKIP_AMOUNT_SECONDS);
|
|
12300
|
+
}
|
|
12301
|
+
else if (relativeX > 0.67) {
|
|
12302
|
+
// Right third - skip forward
|
|
12303
|
+
this.config.onSeek(SKIP_AMOUNT_SECONDS);
|
|
12304
|
+
}
|
|
12305
|
+
else {
|
|
12306
|
+
// Center - treat as play/pause
|
|
12307
|
+
this.config.onPlayPause();
|
|
12308
|
+
}
|
|
11957
12309
|
}
|
|
11958
12310
|
}
|
|
11959
12311
|
this.lastTapTime = 0;
|
|
@@ -12124,13 +12476,26 @@ class InteractionController {
|
|
|
12124
12476
|
'select',
|
|
12125
12477
|
'.fw-player-controls',
|
|
12126
12478
|
'[data-player-controls]',
|
|
12479
|
+
'.fw-controls-wrapper',
|
|
12127
12480
|
'.fw-control-bar',
|
|
12128
|
-
'.fw-
|
|
12481
|
+
'.fw-settings-menu',
|
|
12482
|
+
'.fw-context-menu',
|
|
12483
|
+
'.fw-stats-panel',
|
|
12484
|
+
'.fw-dev-panel',
|
|
12485
|
+
'.fw-error-overlay',
|
|
12486
|
+
'.fw-error-popup',
|
|
12487
|
+
'.fw-player-error',
|
|
12129
12488
|
];
|
|
12130
12489
|
return controlSelectors.some(selector => {
|
|
12131
12490
|
return target.matches(selector) || target.closest(selector) !== null;
|
|
12132
12491
|
});
|
|
12133
12492
|
}
|
|
12493
|
+
getFrameStepSeconds() {
|
|
12494
|
+
const step = this.config.frameStepSeconds;
|
|
12495
|
+
if (Number.isFinite(step) && step > 0)
|
|
12496
|
+
return step;
|
|
12497
|
+
return 1 / 30;
|
|
12498
|
+
}
|
|
12134
12499
|
}
|
|
12135
12500
|
|
|
12136
12501
|
/**
|
|
@@ -13049,7 +13414,7 @@ class QualityMonitor {
|
|
|
13049
13414
|
* ```ts
|
|
13050
13415
|
* const manager = new MetaTrackManager({
|
|
13051
13416
|
* mistBaseUrl: 'https://mist.example.com',
|
|
13052
|
-
* streamName: '
|
|
13417
|
+
* streamName: 'pk_...', // playbackId (view key)
|
|
13053
13418
|
* });
|
|
13054
13419
|
*
|
|
13055
13420
|
* manager.subscribe('1', (event) => {
|
|
@@ -13752,13 +14117,13 @@ function supportsPlaybackRate(video) {
|
|
|
13752
14117
|
* 1. Browser's video.seekable ranges (most accurate for MSE-based players)
|
|
13753
14118
|
* 2. Track firstms/lastms from MistServer metadata
|
|
13754
14119
|
* 3. buffer_window from MistServer signaling
|
|
13755
|
-
* 4.
|
|
14120
|
+
* 4. No fallback (treat as live-only when no reliable data)
|
|
13756
14121
|
*
|
|
13757
14122
|
* @param params - Calculation parameters
|
|
13758
14123
|
* @returns Seekable range with start and live edge
|
|
13759
14124
|
*/
|
|
13760
14125
|
function calculateSeekableRange(params) {
|
|
13761
|
-
const { isLive, video, mistStreamInfo, currentTime, duration } = params;
|
|
14126
|
+
const { isLive, video, mistStreamInfo, currentTime, duration, allowMediaStreamDvr = false } = params;
|
|
13762
14127
|
// VOD: full duration is seekable
|
|
13763
14128
|
if (!isLive) {
|
|
13764
14129
|
return { seekableStart: 0, liveEdge: duration };
|
|
@@ -13773,8 +14138,8 @@ function calculateSeekableRange(params) {
|
|
|
13773
14138
|
}
|
|
13774
14139
|
}
|
|
13775
14140
|
// PRIORITY 2: Track firstms/lastms from MistServer (accurate when available)
|
|
13776
|
-
// Skip for MediaStream
|
|
13777
|
-
if (!isMediaStream && mistStreamInfo?.meta?.tracks) {
|
|
14141
|
+
// Skip for MediaStream unless explicitly allowed (e.g., WebCodecs DVR via server)
|
|
14142
|
+
if ((allowMediaStreamDvr || !isMediaStream) && mistStreamInfo?.meta?.tracks) {
|
|
13778
14143
|
const tracks = Object.values(mistStreamInfo.meta.tracks);
|
|
13779
14144
|
if (tracks.length > 0) {
|
|
13780
14145
|
const firstmsValues = tracks.map(t => t.firstms).filter((v) => v !== undefined);
|
|
@@ -13795,16 +14160,7 @@ function calculateSeekableRange(params) {
|
|
|
13795
14160
|
liveEdge: currentTime,
|
|
13796
14161
|
};
|
|
13797
14162
|
}
|
|
13798
|
-
//
|
|
13799
|
-
// WebRTC has no DVR buffer by default, so don't create a fake seekable window
|
|
13800
|
-
// Use reference player's 60s default for other protocols
|
|
13801
|
-
if (!isMediaStream && currentTime > 0) {
|
|
13802
|
-
return {
|
|
13803
|
-
seekableStart: Math.max(0, currentTime - DEFAULT_BUFFER_WINDOW_SEC),
|
|
13804
|
-
liveEdge: currentTime,
|
|
13805
|
-
};
|
|
13806
|
-
}
|
|
13807
|
-
// For WebRTC or unknown: no seekable range (live only)
|
|
14163
|
+
// No seekable range (live only)
|
|
13808
14164
|
return { seekableStart: currentTime, liveEdge: currentTime };
|
|
13809
14165
|
}
|
|
13810
14166
|
/**
|
|
@@ -13942,6 +14298,9 @@ function isLiveContent(isContentLive, mistStreamInfo, duration) {
|
|
|
13942
14298
|
* Both React and Vanilla wrappers use this class internally.
|
|
13943
14299
|
*/
|
|
13944
14300
|
// ============================================================================
|
|
14301
|
+
// Content Type Resolution Helpers
|
|
14302
|
+
// ============================================================================
|
|
14303
|
+
// ============================================================================
|
|
13945
14304
|
// MistServer Source Type Mapping
|
|
13946
14305
|
// ============================================================================
|
|
13947
14306
|
/**
|
|
@@ -13963,6 +14322,8 @@ const MIST_SOURCE_TYPES = {
|
|
|
13963
14322
|
// ===== WEBSOCKET STREAMING =====
|
|
13964
14323
|
'ws/video/mp4': { hrn: 'MP4 WebSocket', player: 'mews', supported: true },
|
|
13965
14324
|
'wss/video/mp4': { hrn: 'MP4 WebSocket (SSL)', player: 'mews', supported: true },
|
|
14325
|
+
'ws/video/webm': { hrn: 'WebM WebSocket', player: 'mews', supported: true },
|
|
14326
|
+
'wss/video/webm': { hrn: 'WebM WebSocket (SSL)', player: 'mews', supported: true },
|
|
13966
14327
|
'ws/video/raw': { hrn: 'Raw WebSocket', player: 'webcodecs', supported: true },
|
|
13967
14328
|
'wss/video/raw': { hrn: 'Raw WebSocket (SSL)', player: 'webcodecs', supported: true },
|
|
13968
14329
|
'ws/video/h264': { hrn: 'Annex B WebSocket', player: 'webcodecs', supported: true },
|
|
@@ -13970,6 +14331,7 @@ const MIST_SOURCE_TYPES = {
|
|
|
13970
14331
|
// ===== WEBRTC =====
|
|
13971
14332
|
'whep': { hrn: 'WebRTC (WHEP)', player: 'native', supported: true },
|
|
13972
14333
|
'webrtc': { hrn: 'WebRTC (WebSocket)', player: 'mist-webrtc', supported: true },
|
|
14334
|
+
'mist/webrtc': { hrn: 'MistServer WebRTC', player: 'mist-webrtc', supported: true },
|
|
13973
14335
|
// ===== AUDIO ONLY =====
|
|
13974
14336
|
'html5/audio/aac': { hrn: 'AAC progressive', player: 'native', supported: true },
|
|
13975
14337
|
'html5/audio/mp3': { hrn: 'MP3 progressive', player: 'native', supported: true },
|
|
@@ -14008,12 +14370,17 @@ const PROTOCOL_TO_MIME = {
|
|
|
14008
14370
|
'WEBM': 'html5/video/webm',
|
|
14009
14371
|
'WHEP': 'whep',
|
|
14010
14372
|
'WebRTC': 'webrtc',
|
|
14373
|
+
'MIST_WEBRTC': 'mist/webrtc', // MistServer native WebRTC signaling
|
|
14011
14374
|
// WebSocket variants
|
|
14012
14375
|
'MEWS': 'ws/video/mp4',
|
|
14013
14376
|
'MEWS_WS': 'ws/video/mp4',
|
|
14014
14377
|
'MEWS_WSS': 'wss/video/mp4',
|
|
14378
|
+
'MEWS_WEBM': 'ws/video/webm',
|
|
14379
|
+
'MEWS_WEBM_SSL': 'wss/video/webm',
|
|
14015
14380
|
'RAW_WS': 'ws/video/raw',
|
|
14016
14381
|
'RAW_WSS': 'wss/video/raw',
|
|
14382
|
+
'H264_WS': 'ws/video/h264',
|
|
14383
|
+
'H264_WSS': 'wss/video/h264',
|
|
14017
14384
|
// Audio
|
|
14018
14385
|
'AAC': 'html5/audio/aac',
|
|
14019
14386
|
'MP3': 'html5/audio/mp3',
|
|
@@ -14098,7 +14465,7 @@ function buildStreamInfoFromEndpoints(endpoints, contentId) {
|
|
|
14098
14465
|
try {
|
|
14099
14466
|
outputs = JSON.parse(primary.outputs);
|
|
14100
14467
|
}
|
|
14101
|
-
catch
|
|
14468
|
+
catch {
|
|
14102
14469
|
console.warn('[buildStreamInfoFromEndpoints] Failed to parse outputs JSON');
|
|
14103
14470
|
outputs = {};
|
|
14104
14471
|
}
|
|
@@ -14109,7 +14476,6 @@ function buildStreamInfoFromEndpoints(endpoints, contentId) {
|
|
|
14109
14476
|
}
|
|
14110
14477
|
const sources = [];
|
|
14111
14478
|
const oKeys = Object.keys(outputs);
|
|
14112
|
-
// Helper to attach MistServer sources
|
|
14113
14479
|
const attachMistSource = (html, playerJs) => {
|
|
14114
14480
|
if (!html && !playerJs)
|
|
14115
14481
|
return;
|
|
@@ -14207,7 +14573,7 @@ function buildStreamInfoFromEndpoints(endpoints, contentId) {
|
|
|
14207
14573
|
* @example
|
|
14208
14574
|
* ```typescript
|
|
14209
14575
|
* const controller = new PlayerController({
|
|
14210
|
-
* contentId: '
|
|
14576
|
+
* contentId: 'pk_...', // playbackId (view key)
|
|
14211
14577
|
* contentType: 'live',
|
|
14212
14578
|
* gatewayUrl: 'https://gateway.example.com/graphql',
|
|
14213
14579
|
* });
|
|
@@ -14227,6 +14593,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14227
14593
|
super();
|
|
14228
14594
|
this.state = 'booting';
|
|
14229
14595
|
this.lastEmittedState = null;
|
|
14596
|
+
this.suppressPlayPauseEventsUntil = 0;
|
|
14230
14597
|
this.gatewayClient = null;
|
|
14231
14598
|
this.streamStateClient = null;
|
|
14232
14599
|
this.currentPlayer = null;
|
|
@@ -14237,6 +14604,10 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14237
14604
|
this.streamState = null;
|
|
14238
14605
|
/** Tracks parsed from MistServer JSON response (used for direct MistServer mode) */
|
|
14239
14606
|
this.mistTracks = null;
|
|
14607
|
+
/** Gateway-seeded metadata (used as base for Mist enrichment) */
|
|
14608
|
+
this.metadataSeed = null;
|
|
14609
|
+
/** Merged metadata (gateway seed + Mist enrichment) */
|
|
14610
|
+
this.metadata = null;
|
|
14240
14611
|
this.cleanupFns = [];
|
|
14241
14612
|
this.isDestroyed = false;
|
|
14242
14613
|
this.isAttached = false;
|
|
@@ -14386,6 +14757,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14386
14757
|
this.endpoints = null;
|
|
14387
14758
|
this.streamInfo = null;
|
|
14388
14759
|
this.streamState = null;
|
|
14760
|
+
this.metadataSeed = null;
|
|
14761
|
+
this.metadata = null;
|
|
14389
14762
|
this.videoElement = null;
|
|
14390
14763
|
this.currentPlayer = null;
|
|
14391
14764
|
this.lastEmittedState = null;
|
|
@@ -14420,7 +14793,115 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14420
14793
|
}
|
|
14421
14794
|
/** Get content metadata (title, description, duration, etc.) */
|
|
14422
14795
|
getMetadata() {
|
|
14423
|
-
return this.
|
|
14796
|
+
return this.metadata ?? null;
|
|
14797
|
+
}
|
|
14798
|
+
// ============================================================================
|
|
14799
|
+
// Metadata Merge (Gateway seed + Mist enrichment)
|
|
14800
|
+
// ============================================================================
|
|
14801
|
+
setMetadataSeed(seed) {
|
|
14802
|
+
this.metadataSeed = seed ? { ...seed } : null;
|
|
14803
|
+
this.refreshMergedMetadata();
|
|
14804
|
+
}
|
|
14805
|
+
refreshMergedMetadata() {
|
|
14806
|
+
const seed = this.metadataSeed ? { ...this.metadataSeed } : null;
|
|
14807
|
+
const mist = this.streamState?.streamInfo;
|
|
14808
|
+
const streamStatus = this.streamState?.status;
|
|
14809
|
+
if (!seed && !mist) {
|
|
14810
|
+
this.metadata = null;
|
|
14811
|
+
return;
|
|
14812
|
+
}
|
|
14813
|
+
const merged = seed ? { ...seed } : {};
|
|
14814
|
+
if (mist) {
|
|
14815
|
+
merged.mist = this.sanitizeMistInfo(mist);
|
|
14816
|
+
if (mist.type) {
|
|
14817
|
+
merged.contentType = mist.type;
|
|
14818
|
+
merged.isLive = mist.type === 'live';
|
|
14819
|
+
}
|
|
14820
|
+
if (streamStatus) {
|
|
14821
|
+
merged.status = streamStatus;
|
|
14822
|
+
}
|
|
14823
|
+
if (mist.meta?.duration && (!merged.durationSeconds || merged.durationSeconds <= 0)) {
|
|
14824
|
+
merged.durationSeconds = Math.round(mist.meta.duration);
|
|
14825
|
+
}
|
|
14826
|
+
if (mist.meta?.tracks) {
|
|
14827
|
+
merged.tracks = this.buildMetadataTracks(mist.meta.tracks);
|
|
14828
|
+
}
|
|
14829
|
+
}
|
|
14830
|
+
this.metadata = merged;
|
|
14831
|
+
}
|
|
14832
|
+
buildMetadataTracks(tracksObj) {
|
|
14833
|
+
const tracks = [];
|
|
14834
|
+
for (const [, trackData] of Object.entries(tracksObj)) {
|
|
14835
|
+
const t = trackData;
|
|
14836
|
+
const trackType = t.type;
|
|
14837
|
+
if (trackType !== 'video' && trackType !== 'audio' && trackType !== 'meta') {
|
|
14838
|
+
continue;
|
|
14839
|
+
}
|
|
14840
|
+
const bitrate = typeof t.bps === 'number' ? Math.round(t.bps) : undefined;
|
|
14841
|
+
const fps = typeof t.fpks === 'number' ? t.fpks / 1000 : undefined;
|
|
14842
|
+
tracks.push({
|
|
14843
|
+
type: trackType,
|
|
14844
|
+
codec: t.codec,
|
|
14845
|
+
width: t.width,
|
|
14846
|
+
height: t.height,
|
|
14847
|
+
bitrate,
|
|
14848
|
+
fps,
|
|
14849
|
+
channels: t.channels,
|
|
14850
|
+
sampleRate: t.rate,
|
|
14851
|
+
});
|
|
14852
|
+
}
|
|
14853
|
+
return tracks.length ? tracks : undefined;
|
|
14854
|
+
}
|
|
14855
|
+
sanitizeMistInfo(info) {
|
|
14856
|
+
const sanitized = {
|
|
14857
|
+
error: info.error,
|
|
14858
|
+
on_error: info.on_error,
|
|
14859
|
+
perc: info.perc,
|
|
14860
|
+
type: info.type,
|
|
14861
|
+
hasVideo: info.hasVideo,
|
|
14862
|
+
hasAudio: info.hasAudio,
|
|
14863
|
+
unixoffset: info.unixoffset,
|
|
14864
|
+
lastms: info.lastms,
|
|
14865
|
+
};
|
|
14866
|
+
if (info.source) {
|
|
14867
|
+
sanitized.source = info.source.map((src) => ({
|
|
14868
|
+
url: src.url,
|
|
14869
|
+
type: src.type,
|
|
14870
|
+
priority: src.priority,
|
|
14871
|
+
simul_tracks: src.simul_tracks,
|
|
14872
|
+
relurl: src.relurl,
|
|
14873
|
+
}));
|
|
14874
|
+
}
|
|
14875
|
+
if (info.meta) {
|
|
14876
|
+
sanitized.meta = {
|
|
14877
|
+
buffer_window: info.meta.buffer_window,
|
|
14878
|
+
duration: info.meta.duration,
|
|
14879
|
+
mistUrl: info.meta.mistUrl,
|
|
14880
|
+
};
|
|
14881
|
+
if (info.meta.tracks) {
|
|
14882
|
+
const tracks = {};
|
|
14883
|
+
for (const [key, track] of Object.entries(info.meta.tracks)) {
|
|
14884
|
+
tracks[key] = {
|
|
14885
|
+
type: track.type,
|
|
14886
|
+
codec: track.codec,
|
|
14887
|
+
width: track.width,
|
|
14888
|
+
height: track.height,
|
|
14889
|
+
bps: track.bps,
|
|
14890
|
+
fpks: track.fpks,
|
|
14891
|
+
codecstring: track.codecstring,
|
|
14892
|
+
firstms: track.firstms,
|
|
14893
|
+
lastms: track.lastms,
|
|
14894
|
+
lang: track.lang,
|
|
14895
|
+
idx: track.idx,
|
|
14896
|
+
channels: track.channels,
|
|
14897
|
+
rate: track.rate,
|
|
14898
|
+
size: track.size,
|
|
14899
|
+
};
|
|
14900
|
+
}
|
|
14901
|
+
sanitized.meta.tracks = tracks;
|
|
14902
|
+
}
|
|
14903
|
+
}
|
|
14904
|
+
return sanitized;
|
|
14424
14905
|
}
|
|
14425
14906
|
/** Get stream info (sources + tracks for player selection) */
|
|
14426
14907
|
getStreamInfo() {
|
|
@@ -14443,7 +14924,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14443
14924
|
// ============================================================================
|
|
14444
14925
|
/** Check if video is currently playing (not paused) */
|
|
14445
14926
|
isPlaying() {
|
|
14446
|
-
|
|
14927
|
+
const paused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
14928
|
+
return !paused;
|
|
14447
14929
|
}
|
|
14448
14930
|
/** Check if currently buffering */
|
|
14449
14931
|
isBuffering() {
|
|
@@ -14556,6 +15038,28 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14556
15038
|
canAdjustPlaybackRate() {
|
|
14557
15039
|
return this._supportsPlaybackRate;
|
|
14558
15040
|
}
|
|
15041
|
+
/** Resolve content type from config override or Gateway metadata */
|
|
15042
|
+
getResolvedContentType() {
|
|
15043
|
+
if (this.config.contentType) {
|
|
15044
|
+
return this.config.contentType;
|
|
15045
|
+
}
|
|
15046
|
+
const metadata = this.getMetadata();
|
|
15047
|
+
const metaType = metadata?.contentType?.toLowerCase();
|
|
15048
|
+
if (metaType === 'live' || metaType === 'clip' || metaType === 'dvr' || metaType === 'vod') {
|
|
15049
|
+
return metaType;
|
|
15050
|
+
}
|
|
15051
|
+
const mistType = this.streamState?.streamInfo?.type;
|
|
15052
|
+
if (mistType === 'live' || mistType === 'vod') {
|
|
15053
|
+
return mistType;
|
|
15054
|
+
}
|
|
15055
|
+
if (metadata?.isLive === true) {
|
|
15056
|
+
return 'live';
|
|
15057
|
+
}
|
|
15058
|
+
if (metadata?.isLive === false) {
|
|
15059
|
+
return 'vod';
|
|
15060
|
+
}
|
|
15061
|
+
return null;
|
|
15062
|
+
}
|
|
14559
15063
|
/** Check if source is WebRTC/MediaStream */
|
|
14560
15064
|
isWebRTCSource() {
|
|
14561
15065
|
return this._isWebRTC;
|
|
@@ -14568,13 +15072,28 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14568
15072
|
}
|
|
14569
15073
|
/** Check if content is effectively live (live or DVR still recording) */
|
|
14570
15074
|
isEffectivelyLive() {
|
|
14571
|
-
const
|
|
15075
|
+
const contentType = this.getResolvedContentType() ?? 'live';
|
|
14572
15076
|
const metadata = this.getMetadata();
|
|
14573
|
-
|
|
15077
|
+
// Explicit VOD content types are never live
|
|
15078
|
+
if (contentType === 'vod' || contentType === 'clip') {
|
|
15079
|
+
return false;
|
|
15080
|
+
}
|
|
15081
|
+
// If Gateway metadata says it's not live, trust it
|
|
15082
|
+
if (metadata?.isLive === false) {
|
|
15083
|
+
return false;
|
|
15084
|
+
}
|
|
15085
|
+
// DVR that's finished recording is not live
|
|
15086
|
+
if (contentType === 'dvr' && metadata?.dvrStatus === 'completed') {
|
|
15087
|
+
return false;
|
|
15088
|
+
}
|
|
15089
|
+
// Default: trust contentType or duration-based detection
|
|
15090
|
+
return contentType === 'live' ||
|
|
15091
|
+
(contentType === 'dvr' && metadata?.dvrStatus === 'recording') ||
|
|
15092
|
+
!Number.isFinite(this.getDuration());
|
|
14574
15093
|
}
|
|
14575
15094
|
/** Check if content is strictly live (not DVR/clip/vod) */
|
|
14576
15095
|
isLive() {
|
|
14577
|
-
return this.
|
|
15096
|
+
return (this.getResolvedContentType() ?? 'live') === 'live';
|
|
14578
15097
|
}
|
|
14579
15098
|
/**
|
|
14580
15099
|
* Check if content needs cold start (VOD-like loading).
|
|
@@ -14583,7 +15102,10 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14583
15102
|
* DVR-while-recording needs cold start because MistServer may not be serving the VOD yet
|
|
14584
15103
|
*/
|
|
14585
15104
|
needsColdStart() {
|
|
14586
|
-
|
|
15105
|
+
const contentType = this.getResolvedContentType();
|
|
15106
|
+
if (!contentType)
|
|
15107
|
+
return true;
|
|
15108
|
+
return contentType !== 'live';
|
|
14587
15109
|
}
|
|
14588
15110
|
/**
|
|
14589
15111
|
* Check if we should show idle/loading screen.
|
|
@@ -14725,12 +15247,20 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14725
15247
|
// ============================================================================
|
|
14726
15248
|
/** Start playback */
|
|
14727
15249
|
async play() {
|
|
15250
|
+
if (this.currentPlayer?.play) {
|
|
15251
|
+
await this.currentPlayer.play();
|
|
15252
|
+
return;
|
|
15253
|
+
}
|
|
14728
15254
|
if (this.videoElement) {
|
|
14729
15255
|
await this.videoElement.play();
|
|
14730
15256
|
}
|
|
14731
15257
|
}
|
|
14732
15258
|
/** Pause playback */
|
|
14733
15259
|
pause() {
|
|
15260
|
+
if (this.currentPlayer?.pause) {
|
|
15261
|
+
this.currentPlayer.pause();
|
|
15262
|
+
return;
|
|
15263
|
+
}
|
|
14734
15264
|
this.videoElement?.pause();
|
|
14735
15265
|
}
|
|
14736
15266
|
/** Seek to time */
|
|
@@ -14780,6 +15310,24 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14780
15310
|
// Try player-specific implementation first (WebCodecs uses server time)
|
|
14781
15311
|
if (this.currentPlayer?.jumpToLive) {
|
|
14782
15312
|
this.currentPlayer.jumpToLive();
|
|
15313
|
+
const el = this.videoElement;
|
|
15314
|
+
if (el && !isMediaStreamSource(el)) {
|
|
15315
|
+
const target = this._liveEdge;
|
|
15316
|
+
if (Number.isFinite(target) && target > 0) {
|
|
15317
|
+
// Fallback: if player-specific jump doesn't move, seek to computed live edge
|
|
15318
|
+
setTimeout(() => {
|
|
15319
|
+
if (!this.videoElement)
|
|
15320
|
+
return;
|
|
15321
|
+
const current = this.getEffectiveCurrentTime();
|
|
15322
|
+
if (target - current > 1) {
|
|
15323
|
+
try {
|
|
15324
|
+
this.videoElement.currentTime = target;
|
|
15325
|
+
}
|
|
15326
|
+
catch { }
|
|
15327
|
+
}
|
|
15328
|
+
}, 200);
|
|
15329
|
+
}
|
|
15330
|
+
}
|
|
14783
15331
|
this._isNearLive = true;
|
|
14784
15332
|
this.emitSeekingState();
|
|
14785
15333
|
return;
|
|
@@ -14888,6 +15436,19 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14888
15436
|
}
|
|
14889
15437
|
return null;
|
|
14890
15438
|
}
|
|
15439
|
+
getFrameStepSecondsFromTracks() {
|
|
15440
|
+
const tracks = this.streamInfo?.meta?.tracks;
|
|
15441
|
+
if (!tracks || tracks.length === 0)
|
|
15442
|
+
return undefined;
|
|
15443
|
+
const videoTracks = tracks.filter(t => t.type === 'video' && typeof t.fpks === 'number' && t.fpks > 0);
|
|
15444
|
+
if (videoTracks.length === 0)
|
|
15445
|
+
return undefined;
|
|
15446
|
+
const fpks = Math.max(...videoTracks.map(t => t.fpks));
|
|
15447
|
+
if (!Number.isFinite(fpks) || fpks <= 0)
|
|
15448
|
+
return undefined;
|
|
15449
|
+
// fpks = frames per kilosecond => frame duration in seconds = 1000 / fpks
|
|
15450
|
+
return 1000 / fpks;
|
|
15451
|
+
}
|
|
14891
15452
|
deriveBufferWindowMsFromTracks(tracks) {
|
|
14892
15453
|
if (!tracks)
|
|
14893
15454
|
return undefined;
|
|
@@ -14915,7 +15476,15 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14915
15476
|
}
|
|
14916
15477
|
/** Check if paused */
|
|
14917
15478
|
isPaused() {
|
|
14918
|
-
return this.videoElement?.paused ?? true;
|
|
15479
|
+
return this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
15480
|
+
}
|
|
15481
|
+
/** Suppress play/pause-driven UI updates for a short window */
|
|
15482
|
+
suppressPlayPauseEvents(ms = 200) {
|
|
15483
|
+
this.suppressPlayPauseEventsUntil = Date.now() + ms;
|
|
15484
|
+
}
|
|
15485
|
+
/** Check if play/pause UI updates should be suppressed */
|
|
15486
|
+
shouldSuppressVideoEvents() {
|
|
15487
|
+
return Date.now() < this.suppressPlayPauseEventsUntil;
|
|
14919
15488
|
}
|
|
14920
15489
|
/** Check if muted */
|
|
14921
15490
|
isMuted() {
|
|
@@ -14933,8 +15502,18 @@ class PlayerController extends TypedEventEmitter {
|
|
|
14933
15502
|
}
|
|
14934
15503
|
/** Toggle play/pause */
|
|
14935
15504
|
togglePlay() {
|
|
14936
|
-
|
|
14937
|
-
|
|
15505
|
+
const isPaused = this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true;
|
|
15506
|
+
if (isPaused) {
|
|
15507
|
+
if (this.currentPlayer?.play) {
|
|
15508
|
+
this.currentPlayer.play().catch(() => { });
|
|
15509
|
+
}
|
|
15510
|
+
else {
|
|
15511
|
+
this.videoElement?.play().catch(() => { });
|
|
15512
|
+
}
|
|
15513
|
+
return;
|
|
15514
|
+
}
|
|
15515
|
+
if (this.currentPlayer?.pause) {
|
|
15516
|
+
this.currentPlayer.pause();
|
|
14938
15517
|
}
|
|
14939
15518
|
else {
|
|
14940
15519
|
this.videoElement?.pause();
|
|
@@ -15030,6 +15609,9 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15030
15609
|
this._liveThresholds = calculateLiveThresholds(sourceType, this._isWebRTC, bufferWindowMs);
|
|
15031
15610
|
// Calculate seekable range using centralized logic (allow player overrides)
|
|
15032
15611
|
const playerRange = this.getPlayerSeekableRange();
|
|
15612
|
+
const allowMediaStreamDvr = isMediaStreamSource(el) &&
|
|
15613
|
+
(bufferWindowMs !== undefined && bufferWindowMs > 0) &&
|
|
15614
|
+
(sourceType !== 'whep' && sourceType !== 'webrtc');
|
|
15033
15615
|
const { seekableStart, liveEdge } = playerRange
|
|
15034
15616
|
? { seekableStart: playerRange.start, liveEdge: playerRange.end }
|
|
15035
15617
|
: calculateSeekableRange({
|
|
@@ -15038,6 +15620,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15038
15620
|
mistStreamInfo,
|
|
15039
15621
|
currentTime,
|
|
15040
15622
|
duration,
|
|
15623
|
+
allowMediaStreamDvr,
|
|
15041
15624
|
});
|
|
15042
15625
|
// Update can seek - pass player's canSeek if available (e.g., WebCodecs uses server commands)
|
|
15043
15626
|
const playerCanSeek = this.currentPlayer && typeof this.currentPlayer.canSeek === 'function'
|
|
@@ -15054,9 +15637,15 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15054
15637
|
this._buffered = el.buffered.length > 0 ? el.buffered : null;
|
|
15055
15638
|
// Check if values changed
|
|
15056
15639
|
const seekableChanged = this._seekableStart !== seekableStart || this._liveEdge !== liveEdge;
|
|
15057
|
-
this._canSeek !== this._canSeek; // Already updated above
|
|
15058
15640
|
this._seekableStart = seekableStart;
|
|
15059
15641
|
this._liveEdge = liveEdge;
|
|
15642
|
+
// Update interaction controller live-only state (allow DVR shortcuts when seekable window exists)
|
|
15643
|
+
const hasDvrWindow = isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart;
|
|
15644
|
+
const isLiveOnly = isLive && !hasDvrWindow;
|
|
15645
|
+
this.interactionController?.updateConfig({
|
|
15646
|
+
isLive: isLiveOnly,
|
|
15647
|
+
frameStepSeconds: this.getFrameStepSecondsFromTracks(),
|
|
15648
|
+
});
|
|
15060
15649
|
// Update isNearLive using hysteresis
|
|
15061
15650
|
if (isLive) {
|
|
15062
15651
|
const newIsNearLive = calculateIsNearLive(currentTime, liveEdge, this._liveThresholds, this._isNearLive);
|
|
@@ -15413,10 +16002,11 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15413
16002
|
// Private Methods
|
|
15414
16003
|
// ============================================================================
|
|
15415
16004
|
async resolveEndpoints() {
|
|
15416
|
-
const { endpoints, gatewayUrl, mistUrl,
|
|
16005
|
+
const { endpoints, gatewayUrl, mistUrl, contentId, authToken } = this.config;
|
|
15417
16006
|
// Priority 1: Use pre-resolved endpoints if provided
|
|
15418
16007
|
if (endpoints?.primary) {
|
|
15419
16008
|
this.endpoints = endpoints;
|
|
16009
|
+
this.setMetadataSeed(endpoints.metadata ?? null);
|
|
15420
16010
|
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
15421
16011
|
return;
|
|
15422
16012
|
}
|
|
@@ -15427,7 +16017,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15427
16017
|
}
|
|
15428
16018
|
// Priority 3: Gateway resolution
|
|
15429
16019
|
if (gatewayUrl) {
|
|
15430
|
-
await this.resolveFromGateway(gatewayUrl,
|
|
16020
|
+
await this.resolveFromGateway(gatewayUrl, contentId, authToken);
|
|
15431
16021
|
return;
|
|
15432
16022
|
}
|
|
15433
16023
|
throw new Error('No endpoints provided and no gatewayUrl or mistUrl configured');
|
|
@@ -15445,7 +16035,13 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15445
16035
|
if (!response.ok) {
|
|
15446
16036
|
throw new Error(`MistServer HTTP ${response.status}`);
|
|
15447
16037
|
}
|
|
15448
|
-
|
|
16038
|
+
// MistServer can return JSONP: callback({...}); - strip wrapper if present
|
|
16039
|
+
let text = await response.text();
|
|
16040
|
+
const jsonpMatch = text.match(/^[^(]+\(([\s\S]*)\);?$/);
|
|
16041
|
+
if (jsonpMatch) {
|
|
16042
|
+
text = jsonpMatch[1];
|
|
16043
|
+
}
|
|
16044
|
+
const data = JSON.parse(text);
|
|
15449
16045
|
if (data.error) {
|
|
15450
16046
|
throw new Error(data.error);
|
|
15451
16047
|
}
|
|
@@ -15474,6 +16070,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15474
16070
|
outputs,
|
|
15475
16071
|
};
|
|
15476
16072
|
this.endpoints = { primary, fallbacks: [] };
|
|
16073
|
+
this.setMetadataSeed(null);
|
|
15477
16074
|
// Parse track metadata from MistServer response
|
|
15478
16075
|
if (data.meta?.tracks && typeof data.meta.tracks === 'object') {
|
|
15479
16076
|
const tracks = this.parseMistTracks(data.meta.tracks);
|
|
@@ -15494,14 +16091,25 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15494
16091
|
*/
|
|
15495
16092
|
mapMistTypeToProtocol(mistType) {
|
|
15496
16093
|
// WebCodecs raw streams - check BEFORE generic ws/ catch-all
|
|
15497
|
-
// MistServer rawws.js uses 'ws/video/raw', mews.js uses 'ws/video/mp4' and 'ws/video/webm'
|
|
15498
16094
|
if (mistType === 'ws/video/raw')
|
|
15499
16095
|
return 'RAW_WS';
|
|
15500
16096
|
if (mistType === 'wss/video/raw')
|
|
15501
16097
|
return 'RAW_WSS';
|
|
15502
|
-
//
|
|
15503
|
-
if (mistType
|
|
16098
|
+
// Annex B H264 over WebSocket (video-only, uses same 12-byte header as raw)
|
|
16099
|
+
if (mistType === 'ws/video/h264')
|
|
16100
|
+
return 'H264_WS';
|
|
16101
|
+
if (mistType === 'wss/video/h264')
|
|
16102
|
+
return 'H264_WSS';
|
|
16103
|
+
// WebM over WebSocket - check BEFORE generic ws/ catch-all
|
|
16104
|
+
if (mistType === 'ws/video/webm')
|
|
16105
|
+
return 'MEWS_WEBM';
|
|
16106
|
+
if (mistType === 'wss/video/webm')
|
|
16107
|
+
return 'MEWS_WEBM_SSL';
|
|
16108
|
+
// MEWS MP4 over WebSocket - catch remaining ws/* types (defaults to mp4)
|
|
16109
|
+
if (mistType.startsWith('ws/'))
|
|
15504
16110
|
return 'MEWS_WS';
|
|
16111
|
+
if (mistType.startsWith('wss/'))
|
|
16112
|
+
return 'MEWS_WSS';
|
|
15505
16113
|
if (mistType.includes('webrtc'))
|
|
15506
16114
|
return 'MIST_WEBRTC';
|
|
15507
16115
|
if (mistType.includes('mpegurl') || mistType.includes('m3u8'))
|
|
@@ -15532,11 +16140,10 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15532
16140
|
/**
|
|
15533
16141
|
* Resolve endpoints from Gateway GraphQL API
|
|
15534
16142
|
*/
|
|
15535
|
-
async resolveFromGateway(gatewayUrl,
|
|
16143
|
+
async resolveFromGateway(gatewayUrl, contentId, authToken) {
|
|
15536
16144
|
this.setState('gateway_loading', { gatewayStatus: 'loading' });
|
|
15537
16145
|
this.gatewayClient = new GatewayClient({
|
|
15538
16146
|
gatewayUrl,
|
|
15539
|
-
contentType,
|
|
15540
16147
|
contentId,
|
|
15541
16148
|
authToken,
|
|
15542
16149
|
});
|
|
@@ -15550,6 +16157,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15550
16157
|
this.cleanupFns.push(() => this.gatewayClient?.destroy());
|
|
15551
16158
|
try {
|
|
15552
16159
|
this.endpoints = await this.gatewayClient.resolve();
|
|
16160
|
+
this.setMetadataSeed(this.endpoints?.metadata ?? null);
|
|
15553
16161
|
this.setState('gateway_ready', { gatewayStatus: 'ready' });
|
|
15554
16162
|
}
|
|
15555
16163
|
catch (error) {
|
|
@@ -15559,10 +16167,18 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15559
16167
|
}
|
|
15560
16168
|
}
|
|
15561
16169
|
startStreamStatePolling() {
|
|
15562
|
-
const {
|
|
16170
|
+
const { contentId, mistUrl } = this.config;
|
|
16171
|
+
const contentType = this.getResolvedContentType();
|
|
15563
16172
|
// Only poll for live-like content. DVR should only poll while recording.
|
|
15564
|
-
|
|
16173
|
+
// If contentType is unknown but mistUrl is provided, still poll so we can
|
|
16174
|
+
// detect when a stream comes online and initialize playback.
|
|
16175
|
+
if (contentType == null) {
|
|
16176
|
+
if (!mistUrl)
|
|
16177
|
+
return;
|
|
16178
|
+
}
|
|
16179
|
+
else if (contentType !== 'live' && contentType !== 'dvr') {
|
|
15565
16180
|
return;
|
|
16181
|
+
}
|
|
15566
16182
|
if (contentType === 'dvr') {
|
|
15567
16183
|
const dvrStatus = this.getMetadata()?.dvrStatus;
|
|
15568
16184
|
if (dvrStatus && dvrStatus !== 'recording')
|
|
@@ -15601,6 +16217,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15601
16217
|
this.log(`[stateChange] Updated ${mistTracks.length} tracks from MistServer`);
|
|
15602
16218
|
}
|
|
15603
16219
|
}
|
|
16220
|
+
// Merge Mist metadata into the unified metadata surface
|
|
16221
|
+
this.refreshMergedMetadata();
|
|
15604
16222
|
this.emit('streamStateChange', { state });
|
|
15605
16223
|
// Auto-play when stream transitions from offline to online
|
|
15606
16224
|
// This handles the case where user is watching IdleScreen and stream comes online
|
|
@@ -15659,6 +16277,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15659
16277
|
outputs,
|
|
15660
16278
|
};
|
|
15661
16279
|
this.endpoints = { primary, fallbacks: [] };
|
|
16280
|
+
this.setMetadataSeed(this.endpoints.metadata ?? null);
|
|
15662
16281
|
// Parse track metadata from stream info
|
|
15663
16282
|
if (streamInfo.meta?.tracks && typeof streamInfo.meta.tracks === 'object') {
|
|
15664
16283
|
const tracks = this.parseMistTracks(streamInfo.meta.tracks);
|
|
@@ -15775,6 +16394,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15775
16394
|
muted: muted !== false,
|
|
15776
16395
|
controls: controls !== false,
|
|
15777
16396
|
poster: poster,
|
|
16397
|
+
debug: this.config.debug,
|
|
15778
16398
|
onReady: (el) => {
|
|
15779
16399
|
// Guard against zombie callbacks after destroy
|
|
15780
16400
|
if (this.isDestroyed || !this.container) {
|
|
@@ -15798,7 +16418,7 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15798
16418
|
this.initializeSubControllers();
|
|
15799
16419
|
this.emit('ready', { videoElement: el });
|
|
15800
16420
|
},
|
|
15801
|
-
onTimeUpdate: (
|
|
16421
|
+
onTimeUpdate: (_t) => {
|
|
15802
16422
|
if (this.isDestroyed)
|
|
15803
16423
|
return;
|
|
15804
16424
|
// Defensive: keep video element attached even if some other lifecycle cleared the container.
|
|
@@ -15859,6 +16479,8 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15859
16479
|
this.setState('buffering');
|
|
15860
16480
|
};
|
|
15861
16481
|
const onPlaying = () => {
|
|
16482
|
+
if (this.shouldSuppressVideoEvents())
|
|
16483
|
+
return;
|
|
15862
16484
|
this._isBuffering = false;
|
|
15863
16485
|
this._hasPlaybackStarted = true;
|
|
15864
16486
|
// Clear stall timer on successful playback
|
|
@@ -15878,7 +16500,11 @@ class PlayerController extends TypedEventEmitter {
|
|
|
15878
16500
|
// Attempt to clear error on canplay
|
|
15879
16501
|
this.attemptClearError();
|
|
15880
16502
|
};
|
|
15881
|
-
const onPause = () =>
|
|
16503
|
+
const onPause = () => {
|
|
16504
|
+
if (this.shouldSuppressVideoEvents())
|
|
16505
|
+
return;
|
|
16506
|
+
this.setState('paused');
|
|
16507
|
+
};
|
|
15882
16508
|
const onEnded = () => this.setState('ended');
|
|
15883
16509
|
const onError = () => {
|
|
15884
16510
|
const message = el.error ? el.error.message || 'Playback error' : 'Playback error';
|
|
@@ -16049,10 +16675,29 @@ class PlayerController extends TypedEventEmitter {
|
|
|
16049
16675
|
if (!this.container || !this.videoElement)
|
|
16050
16676
|
return;
|
|
16051
16677
|
const isLive = this.isEffectivelyLive();
|
|
16678
|
+
const hasDvrWindow = isLive && Number.isFinite(this._liveEdge) && Number.isFinite(this._seekableStart) && this._liveEdge > this._seekableStart;
|
|
16679
|
+
const isLiveOnly = isLive && !hasDvrWindow;
|
|
16680
|
+
const interactionContainer = this.container.closest('[data-player-container="true"]') ?? this.container;
|
|
16052
16681
|
this.interactionController = new InteractionController({
|
|
16053
|
-
container:
|
|
16682
|
+
container: interactionContainer,
|
|
16054
16683
|
videoElement: this.videoElement,
|
|
16055
|
-
isLive,
|
|
16684
|
+
isLive: isLiveOnly,
|
|
16685
|
+
isPaused: () => this.currentPlayer?.isPaused?.() ?? this.videoElement?.paused ?? true,
|
|
16686
|
+
frameStepSeconds: this.getFrameStepSecondsFromTracks(),
|
|
16687
|
+
onFrameStep: (direction, seconds) => {
|
|
16688
|
+
const player = this.currentPlayer ?? this.playerManager.getCurrentPlayer();
|
|
16689
|
+
const playerName = player?.capability?.shortname ?? this._currentPlayerInfo?.shortname ?? 'unknown';
|
|
16690
|
+
const hasFrameStep = typeof player?.frameStep === 'function';
|
|
16691
|
+
this.log(`[interaction] frameStep dir=${direction} player=${playerName} hasFrameStep=${hasFrameStep}`);
|
|
16692
|
+
if (playerName === 'webcodecs') {
|
|
16693
|
+
this.suppressPlayPauseEvents(250);
|
|
16694
|
+
}
|
|
16695
|
+
if (hasFrameStep && player && player.frameStep) {
|
|
16696
|
+
player.frameStep(direction, seconds);
|
|
16697
|
+
return true;
|
|
16698
|
+
}
|
|
16699
|
+
return false;
|
|
16700
|
+
},
|
|
16056
16701
|
onPlayPause: () => this.togglePlay(),
|
|
16057
16702
|
onSeek: (delta) => {
|
|
16058
16703
|
// End any speed hold before seeking
|
|
@@ -16392,7 +17037,7 @@ class SubtitleManager {
|
|
|
16392
17037
|
try {
|
|
16393
17038
|
textTrack.addCue(cue);
|
|
16394
17039
|
}
|
|
16395
|
-
catch
|
|
17040
|
+
catch {
|
|
16396
17041
|
// Ignore errors from invalid cue timing
|
|
16397
17042
|
}
|
|
16398
17043
|
}
|
|
@@ -16774,7 +17419,8 @@ function formatTimeDisplay(params) {
|
|
|
16774
17419
|
}
|
|
16775
17420
|
return `-${formatTime(Math.abs(behindSeconds))}`;
|
|
16776
17421
|
}
|
|
16777
|
-
|
|
17422
|
+
// No DVR window: show LIVE instead of a misleading timestamp
|
|
17423
|
+
return 'LIVE';
|
|
16778
17424
|
}
|
|
16779
17425
|
// VOD: show current / total
|
|
16780
17426
|
if (Number.isFinite(duration) && duration > 0) {
|