@livepeer-frameworks/player-core 0.0.4 → 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.
Files changed (58) hide show
  1. package/README.md +14 -1
  2. package/dist/cjs/index.js +792 -146
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/esm/index.js +792 -146
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/player.css +3 -331
  7. package/dist/types/core/GatewayClient.d.ts +3 -4
  8. package/dist/types/core/InteractionController.d.ts +12 -0
  9. package/dist/types/core/MetaTrackManager.d.ts +1 -1
  10. package/dist/types/core/PlayerController.d.ts +18 -2
  11. package/dist/types/core/PlayerInterface.d.ts +10 -0
  12. package/dist/types/core/SeekingUtils.d.ts +3 -1
  13. package/dist/types/core/StreamStateClient.d.ts +1 -1
  14. package/dist/types/players/HlsJsPlayer.d.ts +8 -0
  15. package/dist/types/players/MewsWsPlayer/index.d.ts +1 -1
  16. package/dist/types/players/VideoJsPlayer.d.ts +12 -4
  17. package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +1 -1
  18. package/dist/types/players/WebCodecsPlayer/index.d.ts +11 -0
  19. package/dist/types/players/WebCodecsPlayer/types.d.ts +25 -3
  20. package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +20 -2
  21. package/dist/types/types.d.ts +32 -1
  22. package/dist/types/vanilla/FrameWorksPlayer.d.ts +5 -5
  23. package/dist/types/vanilla/index.d.ts +3 -3
  24. package/dist/workers/decoder.worker.js +183 -6
  25. package/dist/workers/decoder.worker.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/core/ABRController.ts +1 -1
  28. package/src/core/CodecUtils.ts +1 -1
  29. package/src/core/GatewayClient.ts +8 -10
  30. package/src/core/LiveDurationProxy.ts +0 -1
  31. package/src/core/MetaTrackManager.ts +1 -1
  32. package/src/core/PlayerController.ts +232 -26
  33. package/src/core/PlayerInterface.ts +6 -0
  34. package/src/core/PlayerManager.ts +49 -0
  35. package/src/core/StreamStateClient.ts +3 -3
  36. package/src/core/SubtitleManager.ts +1 -1
  37. package/src/core/TelemetryReporter.ts +1 -1
  38. package/src/core/TimerManager.ts +1 -1
  39. package/src/core/scorer.ts +8 -4
  40. package/src/players/DashJsPlayer.ts +23 -11
  41. package/src/players/HlsJsPlayer.ts +29 -5
  42. package/src/players/MewsWsPlayer/SourceBufferManager.ts +3 -3
  43. package/src/players/MewsWsPlayer/WebSocketManager.ts +0 -1
  44. package/src/players/MewsWsPlayer/index.ts +7 -5
  45. package/src/players/MistPlayer.ts +1 -1
  46. package/src/players/MistWebRTCPlayer/index.ts +1 -1
  47. package/src/players/NativePlayer.ts +2 -2
  48. package/src/players/VideoJsPlayer.ts +33 -31
  49. package/src/players/WebCodecsPlayer/SyncController.ts +1 -2
  50. package/src/players/WebCodecsPlayer/WebSocketController.ts +1 -1
  51. package/src/players/WebCodecsPlayer/index.ts +25 -7
  52. package/src/players/WebCodecsPlayer/types.ts +31 -3
  53. package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +20 -13
  54. package/src/players/WebCodecsPlayer/worker/types.ts +4 -0
  55. package/src/styles/player.css +0 -314
  56. package/src/types.ts +43 -1
  57. package/src/vanilla/FrameWorksPlayer.ts +5 -5
  58. 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
- const tierFlag = 0; // Assume main tier
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.45,
675
- 'mist/webrtc': 0.45,
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.35,
723
- 'mist/webrtc': 0.35,
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', 'native'], // VideoJS loads faster than HLS.js, native for Safari
763
+ prefer: ['videojs', 'hlsjs'],
764
+ avoid: ['native'],
760
765
  },
761
766
  'html5/application/vnd.apple.mpegurl;version=7': {
762
- prefer: ['videojs', 'native'],
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($contentType: String!, $contentId: String!) {
1626
- resolveViewerEndpoint(contentType: $contentType, contentId: $contentId) {
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
- * contentType: 'live',
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, contentType, contentId, authToken, maxRetries = DEFAULT_MAX_RETRIES, initialDelayMs = DEFAULT_INITIAL_DELAY_MS, } = this.config;
1871
+ const { gatewayUrl, contentId, authToken, maxRetries = DEFAULT_MAX_RETRIES, initialDelayMs = DEFAULT_INITIAL_DELAY_MS, } = this.config;
1828
1872
  // Validate required params
1829
- if (!gatewayUrl || !contentType || !contentId) {
1830
- const error = 'Missing required parameters: gatewayUrl, contentType, or contentId';
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: { contentType, contentId },
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 [internalId, entry] of this.timers) {
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: 'my-stream',
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.name === 'safari') {
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' || !isFinite(streamInfo?.duration ?? Infinity);
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
- this.hls = new Hls({
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
- maxBufferLength: 15,
3925
- maxMaxBufferLength: 60,
3926
- backBufferLength: 30 // Reduced from 90 to prevent memory issues on long streams
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
- // Buffer settings
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 - try disabling to isolate the issue
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: -1, audio: -1 }, // Let dashjs choose
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
- // Live delay settings for live streams
4636
+ // AGGRESSIVE: Tighter live delay
4546
4637
  delay: {
4547
- liveDelay: 4, // Target 4 seconds behind live edge
4638
+ liveDelay: 2, // Reduced from 4 (2s behind live edge)
4548
4639
  liveDelayFragmentCount: null,
4549
- useSuggestedPresentationDelay: true,
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 (e) {
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 (e) {
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 (e) {
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 (e) {
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 options if available
6423
- if (source.type === 'live') {
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 (source.type === 'vod') {
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, options) {
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 = (event) => {
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(trackIndex, track) {
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 + AVCC NAL units) - NOT MP4-muxed
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
- result = await VideoDecoder.isConfigSupported(config);
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
- result = await AudioDecoder.isConfigSupported(config);
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' && this.videoElement) {
10385
- this.emit('timeupdate', this.videoElement.currentTime);
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 [name, track] of Object.entries(tracksObj)) {
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
- // Handle double-tap to skip (mobile-style)
11945
- if (!this.config.isLive) {
11946
- if (relativeX < 0.33) {
11947
- // Left third - skip back
11948
- this.config.onSeek(-SKIP_AMOUNT_SECONDS);
11949
- }
11950
- else if (relativeX > 0.67) {
11951
- // Right third - skip forward
11952
- this.config.onSeek(SKIP_AMOUNT_SECONDS);
11953
- }
11954
- else {
11955
- // Center - treat as play/pause
11956
- this.config.onPlayPause();
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-player-surface',
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: 'my-stream',
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. Fallback (only for non-WebRTC sources)
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 - track data doesn't indicate seekable buffer
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
- // PRIORITY 4: Fallback for non-WebRTC sources only
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 (e) {
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: 'my-stream',
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.endpoints?.metadata ?? null;
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
- return this.videoElement ? !this.videoElement.paused : false;
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 { contentType } = this.config;
15075
+ const contentType = this.getResolvedContentType() ?? 'live';
14572
15076
  const metadata = this.getMetadata();
14573
- return contentType === 'live' || (contentType === 'dvr' && metadata?.dvrStatus === 'recording');
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.config.contentType === 'live';
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
- return this.config.contentType !== 'live';
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
- if (this.videoElement?.paused) {
14937
- this.videoElement.play().catch(() => { });
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, contentType, contentId, authToken } = this.config;
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, contentType, contentId, authToken);
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
- const data = await response.json();
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
- // MEWS (MP4/WebM over WebSocket) - catches remaining ws/* types
15503
- if (mistType.startsWith('ws/') || mistType.startsWith('wss/'))
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, contentType, contentId, authToken) {
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 { contentType, contentId, mistUrl } = this.config;
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
- if (contentType !== 'live' && contentType !== 'dvr')
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: (t) => {
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 = () => this.setState('paused');
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: this.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 (e) {
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
- return formatTime(currentTime);
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) {