@livepeer-frameworks/player-core 0.0.4 → 0.1.1

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