movi-player 0.3.3 → 0.3.4

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 (66) hide show
  1. package/README.md +23 -12
  2. package/dist/core/MoviPlayer.d.ts +65 -1
  3. package/dist/core/MoviPlayer.d.ts.map +1 -1
  4. package/dist/core/MoviPlayer.js +239 -10
  5. package/dist/core/MoviPlayer.js.map +1 -1
  6. package/dist/decode/AudioDecoder.d.ts +4 -0
  7. package/dist/decode/AudioDecoder.d.ts.map +1 -1
  8. package/dist/decode/AudioDecoder.js +14 -0
  9. package/dist/decode/AudioDecoder.js.map +1 -1
  10. package/dist/decode/SoftwareAudioDecoder.d.ts +12 -0
  11. package/dist/decode/SoftwareAudioDecoder.d.ts.map +1 -1
  12. package/dist/decode/SoftwareAudioDecoder.js +91 -2
  13. package/dist/decode/SoftwareAudioDecoder.js.map +1 -1
  14. package/dist/decode/VideoDecoder.d.ts.map +1 -1
  15. package/dist/decode/VideoDecoder.js +7 -0
  16. package/dist/decode/VideoDecoder.js.map +1 -1
  17. package/dist/demuxer.cjs +1863 -1788
  18. package/dist/demuxer.js +1722 -1649
  19. package/dist/element.cjs +2904 -1867
  20. package/dist/element.d.ts +48 -0
  21. package/dist/element.d.ts.map +1 -1
  22. package/dist/element.js +7935 -6894
  23. package/dist/element.js.map +1 -1
  24. package/dist/index.cjs +2904 -1867
  25. package/dist/index.js +7935 -6894
  26. package/dist/player.cjs +3060 -2930
  27. package/dist/player.js +1763 -1633
  28. package/dist/render/AudioRenderer.d.ts +30 -0
  29. package/dist/render/AudioRenderer.d.ts.map +1 -1
  30. package/dist/render/AudioRenderer.js +127 -44
  31. package/dist/render/AudioRenderer.js.map +1 -1
  32. package/dist/render/CanvasRenderer.d.ts +31 -0
  33. package/dist/render/CanvasRenderer.d.ts.map +1 -1
  34. package/dist/render/CanvasRenderer.js +31 -0
  35. package/dist/render/CanvasRenderer.js.map +1 -1
  36. package/dist/render/DASHPlayerWrapper.d.ts.map +1 -1
  37. package/dist/render/DASHPlayerWrapper.js +3 -1
  38. package/dist/render/DASHPlayerWrapper.js.map +1 -1
  39. package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
  40. package/dist/render/HLSPlayerWrapper.js +3 -1
  41. package/dist/render/HLSPlayerWrapper.js.map +1 -1
  42. package/dist/render/MoviElement.d.ts +223 -0
  43. package/dist/render/MoviElement.d.ts.map +1 -1
  44. package/dist/render/MoviElement.js +2072 -209
  45. package/dist/render/MoviElement.js.map +1 -1
  46. package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -1
  47. package/dist/render/ShakaPlayerWrapper.js +3 -1
  48. package/dist/render/ShakaPlayerWrapper.js.map +1 -1
  49. package/dist/source/HttpSource.d.ts +3 -0
  50. package/dist/source/HttpSource.d.ts.map +1 -1
  51. package/dist/source/HttpSource.js +81 -22
  52. package/dist/source/HttpSource.js.map +1 -1
  53. package/dist/utils/QoE.d.ts +126 -0
  54. package/dist/utils/QoE.d.ts.map +1 -0
  55. package/dist/utils/QoE.js +192 -0
  56. package/dist/utils/QoE.js.map +1 -0
  57. package/dist/utils/ThumbnailRenderer.d.ts +25 -0
  58. package/dist/utils/ThumbnailRenderer.d.ts.map +1 -1
  59. package/dist/utils/ThumbnailRenderer.js +195 -2
  60. package/dist/utils/ThumbnailRenderer.js.map +1 -1
  61. package/dist/wasm/bindings.d.ts +8 -0
  62. package/dist/wasm/bindings.d.ts.map +1 -1
  63. package/dist/wasm/bindings.js +31 -7
  64. package/dist/wasm/bindings.js.map +1 -1
  65. package/dist/wasm/movi.js +0 -0
  66. package/package.json +3 -2
@@ -70,6 +70,19 @@ export class MoviPlayer extends EventEmitter {
70
70
  mediaInfo = null;
71
71
  fileSize = -1; // Cached file size for buffer calculations
72
72
  lastBufferedTime = 0;
73
+ /**
74
+ * Enable/disable seek-bar scrub previews on an already-constructed player.
75
+ * Lets the `thumb` attribute be toggled at runtime (or applied after `src`,
76
+ * whose callback creates the player first) without recreating the player.
77
+ * The thumbnail pipeline stays lazy — it only spins up on the first hover.
78
+ */
79
+ setPreviewsEnabled(enabled) {
80
+ this.config.enablePreviews = enabled;
81
+ // Turning previews back on clears the "gave up" latch so a prior failed
82
+ // init (or a never-attempted one) can retry on the next hover.
83
+ if (enabled)
84
+ this.previewInitGaveUp = false;
85
+ }
73
86
  previewsAllowed() {
74
87
  if (!this.config.enablePreviews)
75
88
  return false;
@@ -144,6 +157,7 @@ export class MoviPlayer extends EventEmitter {
144
157
  _stallStartTime = 0; // When stall was first detected
145
158
  _bufferingEntryTime = 0; // When we entered buffering state
146
159
  _playStartTime = 0; // When play() was called — grace period for stall detection
160
+ _primingAudio = false; // true while the first-play buffer is filling its startup cushion
147
161
  _decoderStuckSince = 0; // When video decoder was first detected stuck
148
162
  _lastDesyncSeekTime = 0; // performance.now() of last desync-triggered resync
149
163
  // Playback Loop
@@ -412,6 +426,9 @@ export class MoviPlayer extends EventEmitter {
412
426
  Logger.warn(TAG, `Failed to reconfigure audio decoder for track ${track.id}`);
413
427
  }
414
428
  }
429
+ // Re-apply demuxer discard: re-enable the newly-selected audio track and
430
+ // discard the previously-active one (issue #11).
431
+ this.applyStreamDiscard();
415
432
  });
416
433
  this.trackManager.on("tracksChange", (tracks) => {
417
434
  this.emit("tracksChange", tracks);
@@ -744,6 +761,8 @@ export class MoviPlayer extends EventEmitter {
744
761
  else if (audioTrack && this.disableAudio) {
745
762
  Logger.info(TAG, "Audio processing disabled for debugging");
746
763
  }
764
+ // Drop unused audio tracks from the demuxer read path (issue #11).
765
+ this.applyStreamDiscard();
747
766
  // Configure subtitle decoder
748
767
  const subtitleTrack = this.trackManager.getActiveSubtitleTrack();
749
768
  if (subtitleTrack && this.subtitleDecoder) {
@@ -903,6 +922,17 @@ export class MoviPlayer extends EventEmitter {
903
922
  // wasPlayingBeforeSeek from the entry state ("ended" → false), which is
904
923
  // why we must force it true here up front rather than after the await.
905
924
  this.wasPlayingBeforeSeek = true;
925
+ // Re-arm the play grace so the stall/desync detectors don't fire on the
926
+ // replay-seek transient. Right after "Replaying from beginning" the video
927
+ // renderer's currentTime is still stale at the ended position (~duration)
928
+ // while audio resets to 0 — without a fresh grace the desync detector sees
929
+ // a ~full-duration "behind" and kicks off a spurious resync seek (an
930
+ // audible trip at the start of every replay).
931
+ this._playStartTime = performance.now();
932
+ // The replay seek(0) flushes the audio decoder like any seek, so heavy
933
+ // software audio (TrueHD/DTS) re-primes automatically via the seek-resume
934
+ // path — no separate first-play re-arm needed. Codec-gated, so
935
+ // hardware/lightweight audio replays stay instant.
906
936
  try {
907
937
  await this.seek(0, { suppressSpinner: true });
908
938
  // The separate native <audio> track ended with the video; seek(0)
@@ -1110,6 +1140,28 @@ export class MoviPlayer extends EventEmitter {
1110
1140
  cancelAnimationFrame(this.animationFrameId);
1111
1141
  this.animationFrameId = null;
1112
1142
  }
1143
+ // Resuming while the tab is hidden (e.g. a Media Session / lock-screen play,
1144
+ // or a media-key press with the tab in the background): the rAF-driven
1145
+ // processLoop is throttled to ~1fps in background tabs, so decode falls
1146
+ // behind and audio starves and stops within seconds. Drive decode off the
1147
+ // un-throttled background Worker timer instead — pause() tore it down, and
1148
+ // if the tab was hidden while already paused it was never started. Mirror
1149
+ // the hide path's setup (drop video presentation; frames are discarded while
1150
+ // hidden anyway). Gated on audio, matching handleVisibilityChange — a
1151
+ // no-audio hidden source would just race the demuxer to EOF.
1152
+ const resumingHidden = typeof document !== "undefined" &&
1153
+ document.visibilityState === "hidden" &&
1154
+ !this.isPiPActive;
1155
+ if (resumingHidden) {
1156
+ this.isBackgrounded = true;
1157
+ if (this.videoRenderer) {
1158
+ this.videoRenderer.stopPresentationLoop();
1159
+ this.videoRenderer.clearQueue();
1160
+ }
1161
+ const hasAudio = !!this.trackManager.getActiveAudioTrack() && !this.disableAudio;
1162
+ if (hasAudio)
1163
+ this.startBackgroundTimer();
1164
+ }
1113
1165
  this.processLoop();
1114
1166
  Logger.info(TAG, "Playing");
1115
1167
  }
@@ -1323,18 +1375,57 @@ export class MoviPlayer extends EventEmitter {
1323
1375
  this.wasPlayingBeforeRebuffer = true; // resume intent for buffering→play
1324
1376
  this._bufferingEntryTime = performance.now();
1325
1377
  this.stateManager.setState("buffering");
1378
+ // Heavy software audio is flushed-cold by the seek. This branch waits for
1379
+ // the first video frame — which on an open-GOP CRA source can take a few
1380
+ // seconds (HW decoder recreate + CRA wait). Without priming, the audio
1381
+ // context keeps running through that wait and drains its buffer, so when
1382
+ // the first frame finally lands and we resume, the sub-realtime cold
1383
+ // decode underruns into gap-fill jitter. Hold the context suspended so
1384
+ // the catch-up decode instead accumulates a cushion, and flag the resume
1385
+ // gate to wait for it (issue #11, seek case).
1386
+ if (this.activeAudioNeedsColdPrime()) {
1387
+ this.beginAudioPrime();
1388
+ }
1326
1389
  if (this._playStartTime === 0) {
1327
1390
  this._playStartTime = performance.now();
1328
1391
  }
1329
1392
  }
1330
1393
  else if (this.wasPlayingBeforeSeek || this.wasPlayingBeforeRebuffer) {
1331
- Logger.info(TAG, "Resuming playback after seek");
1332
1394
  // Consume the resume intent so it doesn't leak into the next seek. It's
1333
1395
  // never reset elsewhere, so a stale `true` would make a later paused
1334
1396
  // user-seek wrongly auto-resume (and would defeat seek()'s re-derivation
1335
1397
  // guard that now skips re-deriving when this is already true).
1336
1398
  this.wasPlayingBeforeSeek = false;
1337
1399
  this.wasPlayingBeforeRebuffer = false;
1400
+ if (this._playStartTime === 0) {
1401
+ this._playStartTime = performance.now();
1402
+ }
1403
+ // Cold-start audio prime for heavy software audio (TrueHD/DTS via WASM),
1404
+ // which decodes sub-realtime for ~1-2s while the decode path warms up. If
1405
+ // we start now, the fast HW video races ahead while audio underruns —
1406
+ // gap-fills ("atak-atak") then a ~2s A/V resync once the buffer empties.
1407
+ // Route the resume through the same buffering machinery the stall path
1408
+ // uses: hold the AudioContext suspended (isPlaying=true so decoded audio
1409
+ // still accumulates), hold the clock and video presentation, and let the
1410
+ // buffering→resume gate below start both together once a real audio
1411
+ // cushion exists. NOT one-shot — every seek flushes the decoder, so each
1412
+ // post-seek resume is a cold start that needs the cushion too, else a
1413
+ // mid-playback seek resumes thin and underruns into ~2s of jitter (issue
1414
+ // #11, seek case). Hardware audio (AAC) and lightweight software codecs
1415
+ // (Opus/FLAC/AC-3/E-AC-3) decode faster than realtime even cold, so
1416
+ // activeAudioNeedsColdPrime() gates them out — no needless buffer for them.
1417
+ if (this.activeAudioNeedsColdPrime()) {
1418
+ this.beginAudioPrime();
1419
+ this.wasPlayingBeforeRebuffer = true; // resume intent for buffering→play
1420
+ this._bufferingEntryTime = performance.now();
1421
+ this.stateManager.setState("buffering");
1422
+ this.clock.pause();
1423
+ if (this.videoRenderer)
1424
+ this.videoRenderer.stopPresentationLoop();
1425
+ Logger.info(TAG, "Audio cold-prime: buffering until cushion");
1426
+ return;
1427
+ }
1428
+ Logger.info(TAG, "Resuming playback after seek");
1338
1429
  this.stateManager.setState("playing");
1339
1430
  // Mark that playback has actually started. When play() is pressed during
1340
1431
  // the initial poster-seek it early-returns before reaching the body that
@@ -1380,6 +1471,60 @@ export class MoviPlayer extends EventEmitter {
1380
1471
  // Convert back from media time to UI time
1381
1472
  this.emit("seeked", Math.max(0, time - this.startTime));
1382
1473
  }
1474
+ /**
1475
+ * Heavy lossless/complex software audio codecs (TrueHD/MLP, DTS/DCA) decode
1476
+ * sub-realtime for ~1-2s from a cold start. Every seek flushes the decoder,
1477
+ * so each post-seek resume is a cold start that needs the audio prime cushion
1478
+ * — not just the first play/replay. Hardware audio (AAC) and lightweight
1479
+ * software codecs (Opus/FLAC/AC-3/E-AC-3) decode faster than realtime even
1480
+ * cold and don't need it. (issue #11)
1481
+ */
1482
+ activeAudioNeedsColdPrime() {
1483
+ const codec = (this.trackManager.getActiveAudioTrack()?.codec ?? "").toLowerCase();
1484
+ return (!this.disableAudio &&
1485
+ this.audioDecoder.usesSoftware &&
1486
+ /truehd|mlp|dts|dca/.test(codec));
1487
+ }
1488
+ /**
1489
+ * Tell the demuxer to skip every audio track except the active one. A file
1490
+ * with a second, unused audio track (e.g. a dual-TrueHD source with ~933
1491
+ * packets/s PER track) otherwise floods the read path with packets the loop
1492
+ * immediately throws away — roughly doubling the reads needed per second. On
1493
+ * a slower engine (Safari pulls ~half the packets/s of Chromium) that starved
1494
+ * the ACTIVE audio below realtime and stalled every few seconds (issue #11).
1495
+ * AVDISCARD_ALL makes av_read_frame skip them internally so each read returns
1496
+ * a useful packet. Only audio streams are touched — video and subtitles keep
1497
+ * their demuxer defaults (subtitles are read on demand). Re-applied on every
1498
+ * audio-track switch so the newly-selected track is re-enabled.
1499
+ */
1500
+ applyStreamDiscard() {
1501
+ const bindings = this.demuxer?.getBindings();
1502
+ if (!bindings)
1503
+ return;
1504
+ const activeAudioId = this.trackManager.getActiveAudioTrack()?.id;
1505
+ for (const t of this.trackManager.getTracks()) {
1506
+ if (t.type === "audio") {
1507
+ bindings.setStreamDiscard(t.id, t.id !== activeAudioId);
1508
+ }
1509
+ }
1510
+ }
1511
+ /**
1512
+ * Begin an audio prime: hold the AudioContext suspended (primeForBuffering,
1513
+ * which never issues a resume() so it can't drain/race), flush any pending
1514
+ * post-seek audio packets into the decoder so the cushion starts filling, and
1515
+ * flag _primingAudio so the buffering→resume gate waits for a real cushion
1516
+ * (2s) rather than the thin 0.1s default before resuming.
1517
+ */
1518
+ beginAudioPrime() {
1519
+ this.audioRenderer.primeForBuffering();
1520
+ if (this.pendingAudioPackets.length > 0) {
1521
+ for (const pkt of this.pendingAudioPackets) {
1522
+ this.audioDecoder.decode(pkt.data, pkt.timestamp, pkt.keyframe);
1523
+ }
1524
+ this.pendingAudioPackets = [];
1525
+ }
1526
+ this._primingAudio = true;
1527
+ }
1383
1528
  /**
1384
1529
  * Main Playback Loop
1385
1530
  */
@@ -1416,15 +1561,31 @@ export class MoviPlayer extends EventEmitter {
1416
1561
  else if (this.stateManager.getState() === "buffering" && this.wasPlayingBeforeRebuffer) {
1417
1562
  // Resume after minimum dwell time to accumulate enough data
1418
1563
  const hasAudioTrack = !!this.trackManager.getActiveAudioTrack();
1419
- const audioReady = this.disableAudio || !hasAudioTrack || this.audioRenderer.getBufferedDuration() > 0.1;
1564
+ // The first-play cold prime needs a REAL cushion before it starts: the
1565
+ // software decode is still sub-realtime (and gets even slower once video
1566
+ // decode/render competes for CPU), so resuming on a thin 0.1s buffer just
1567
+ // underruns again → a second stall + a ~2s A/V resync. Require ~1.5s
1568
+ // buffered for the prime (with a generous max-dwell fallback so a very
1569
+ // slow decoder still eventually starts). A normal mid-playback rebuffer
1570
+ // keeps the lighter 0.1s threshold for responsiveness.
1571
+ const audioTargetS = this._primingAudio ? 2.0 : 0.1;
1572
+ const audioReady = this.disableAudio ||
1573
+ !hasAudioTrack ||
1574
+ this.audioRenderer.getBufferedDuration() > audioTargetS;
1420
1575
  const videoReady = !this.videoRenderer || this.videoRenderer.getQueueSize() > 0;
1421
1576
  const dwellMs = performance.now() - this._bufferingEntryTime;
1422
1577
  const minDwell = 1500; // Wait at least 1.5s to accumulate buffer
1423
- // Resume if: (1) both ready after minDwell, or (2) audio ready after longer wait
1424
- // Video decoder output is async don't block forever if frames are delayed
1578
+ // Cap the prime startup so a very CPU-bound decoder doesn't spin forever;
1579
+ // it starts with whatever cushion it built (a residual stall is possible
1580
+ // on such machines — the real fix is off-thread audio decode).
1581
+ const maxDwell = this._primingAudio ? 4000 : 3000;
1582
+ // Resume if: (1) both ready after minDwell, (2) audio ready after longer
1583
+ // wait, or (3) the max-dwell fallback (don't wait forever).
1425
1584
  const canResume = dwellMs >= minDwell && ((audioReady && videoReady) ||
1426
- (audioReady && dwellMs >= 3000));
1585
+ (audioReady && dwellMs >= 3000) ||
1586
+ dwellMs >= maxDwell);
1427
1587
  if (canResume) {
1588
+ this._primingAudio = false;
1428
1589
  this.stateManager.setState("paused");
1429
1590
  this.wasPlayingBeforeRebuffer = false;
1430
1591
  // Resume AudioContext before play() so audio picks up from where it was
@@ -1810,7 +1971,18 @@ export class MoviPlayer extends EventEmitter {
1810
1971
  burstSize = 20 * fpsScale; // Gentler ramp during initial fill with audio
1811
1972
  }
1812
1973
  else {
1813
- burstSize = (isSoftware ? 80 : 40) * fpsScale;
1974
+ // Burst is a PACKET cap, but what matters for a cushion is how many
1975
+ // VIDEO packets it reads — and in a heavily-interleaved file that
1976
+ // can be a tiny fraction. e.g. a dual-audio 1080p TrueHD source
1977
+ // reads ~933 pkt/s PER audio track vs ~19 video pkt/s, so a
1978
+ // 40-packet burst pulls only ~0.4 video packets and video can never
1979
+ // read ahead of realtime → no cushion → a stall on any hiccup
1980
+ // (issue #11, "stalls every ~60s" on a 2×TrueHD file). A larger cap
1981
+ // lets video outpace consumption and build a cushion; it stays
1982
+ // self-limiting because the outer backpressure gate stops reading
1983
+ // once videoBuffered/audioBuffered pass their targets, and the
1984
+ // periodic MessageChannel yield below keeps the tick non-blocking.
1985
+ burstSize = (isSoftware ? 160 : 160) * fpsScale;
1814
1986
  }
1815
1987
  }
1816
1988
  }
@@ -1958,7 +2130,19 @@ export class MoviPlayer extends EventEmitter {
1958
2130
  // keyframes for long stretches (seeking deep into a DoVi P8 .ts),
1959
2131
  // where waiting for an IDR never resumes and the video stays black.
1960
2132
  const idrWaitElapsed = elapsed > MoviPlayer.SEEK_IDR_WAIT_MS;
1961
- const acceptThisKeyframe = packet.keyframe && (packet.isIdr || idrWaitElapsed);
2133
+ // A CRA at/before the seek target IS the demuxer's restart point —
2134
+ // decode straight from it (pre-target frames are dropped by the
2135
+ // onFrame filter). The IDR-preference below only helps when a true
2136
+ // IDR sits within a few frames of the target; a CRA-only stream
2137
+ // has none, and skipping the target CRA then lets the demux loop
2138
+ // (which outruns the 400ms wall-clock IDR wait) race through the
2139
+ // whole file, skipping every CRA to EOF with no frame decoded →
2140
+ // permanent "buffering". So accept the target CRA outright rather
2141
+ // than hunt for an IDR that never arrives.
2142
+ const isTargetCra = packet.keyframe &&
2143
+ this.seekTargetTime !== -1 &&
2144
+ packet.timestamp <= this.seekTargetTime + 0.05;
2145
+ const acceptThisKeyframe = packet.keyframe && (packet.isIdr || idrWaitElapsed || isTargetCra);
1962
2146
  if (elapsed > MoviPlayer.KEYFRAME_SEEK_TIMEOUT) {
1963
2147
  Logger.warn(TAG, `Keyframe seek timeout after ${elapsed}ms, accepting any frame`);
1964
2148
  this.seekingToKeyframe = false;
@@ -2384,7 +2568,7 @@ export class MoviPlayer extends EventEmitter {
2384
2568
  /**
2385
2569
  * Generates a preview frame for the given time using C for demuxing and WebCodecs for decoding.
2386
2570
  */
2387
- async getPreviewFrame(time) {
2571
+ async getPreviewFrame(time, view) {
2388
2572
  if (this._audioOnly)
2389
2573
  return null; // Data-saver: never decode video for previews
2390
2574
  if (!this.previewsAllowed())
@@ -2441,6 +2625,11 @@ export class MoviPlayer extends EventEmitter {
2441
2625
  Logger.warn(TAG, "Thumbnail bindings or renderer not available");
2442
2626
  return null;
2443
2627
  }
2628
+ // 360°: reproject the equirect keyframe to the current viewing angle so
2629
+ // the seek-bar preview matches what's on screen. Must be set BEFORE the
2630
+ // decode/render below — draw() happens synchronously inside the WebCodecs
2631
+ // output callback. Null (2D sources) keeps the flat passthrough path.
2632
+ this.thumbnailRenderer.setProjection(view ?? null);
2444
2633
  // Read keyframe from thumbnailer
2445
2634
  // Convert time to media time (PTS) by adding startTime
2446
2635
  const packetSize = await this.thumbnailBindings.readKeyframe(time);
@@ -3122,6 +3311,7 @@ export class MoviPlayer extends EventEmitter {
3122
3311
  this.seekTargetTime = -1; // clear any lingering pre-target frame-drop filter
3123
3312
  this.waitingForVideoSync = false; // no stale seek-completion armed
3124
3313
  this._playStartTime = 0; // keep first-play branch eligible
3314
+ this._primingAudio = false;
3125
3315
  this.pendingAudioPackets = []; // poster-era audio is stale; play() re-seeks
3126
3316
  this.pendingPrebufferPackets = [];
3127
3317
  // The poster seek advanced HttpSource's monotonic buffered-end to ~poster
@@ -3307,6 +3497,13 @@ export class MoviPlayer extends EventEmitter {
3307
3497
  }
3308
3498
  return 0;
3309
3499
  }
3500
+ /**
3501
+ * The currently-displayed decoded VideoFrame, or null. Fallback capture
3502
+ * source for snapshots when the WebGL canvas reads back blank.
3503
+ */
3504
+ getCurrentVideoFrame() {
3505
+ return this.videoRenderer?.getCurrentFrame() ?? null;
3506
+ }
3310
3507
  /**
3311
3508
  * Get current video rotation
3312
3509
  */
@@ -3337,6 +3534,11 @@ export class MoviPlayer extends EventEmitter {
3337
3534
  isVR360Enabled() {
3338
3535
  return this.videoRenderer?.isVR360Enabled() ?? false;
3339
3536
  }
3537
+ /** Live 360° camera + projection snapshot for reprojecting seek-bar previews
3538
+ * to the current view. Null when not in 360. */
3539
+ getVR360View() {
3540
+ return this.videoRenderer?.getVRView() ?? null;
3541
+ }
3340
3542
  /** Select the VR projection/layout: half = VR180, fisheye = equidistant
3341
3543
  * fisheye, stereoSbs = side-by-side stereo (left eye), stereographic =
3342
3544
  * little-planet. */
@@ -3473,7 +3675,9 @@ export class MoviPlayer extends EventEmitter {
3473
3675
  this.wireNativeAudioEvents(this.nativeAudioEl);
3474
3676
  }
3475
3677
  this.nativeAudioEl.preload = "auto";
3476
- this.nativeAudioEl.volume = this.muted ? 0 : this.audioRenderer.getVolume();
3678
+ // HTMLMediaElement.volume caps at [0,1]; getVolume() can be up to 2 (boost),
3679
+ // which the AudioContext gain handles — the native element just clamps.
3680
+ this.nativeAudioEl.volume = this.muted ? 0 : Math.min(1, this.audioRenderer.getVolume());
3477
3681
  this.nativeAudioEl.muted = this.muted;
3478
3682
  this.disableAudio = true;
3479
3683
  // Wire up clock + video renderer to native audio.
@@ -3675,6 +3879,15 @@ export class MoviPlayer extends EventEmitter {
3675
3879
  this.hasNativeAudio() ||
3676
3880
  this.streamWrapper !== null);
3677
3881
  }
3882
+ /**
3883
+ * True when audio plays through a native HTMLMediaElement — an adaptive
3884
+ * stream (HLS/DASH via the stream wrapper's <video>) or native split-source
3885
+ * audio — rather than the AudioContext. Such audio can't be boosted above
3886
+ * 100% (HTMLMediaElement.volume caps at [0,1]), so the volume UI caps at 100%.
3887
+ */
3888
+ usesNativeAudio() {
3889
+ return this.streamWrapper !== null || this.isNativeAudioActive();
3890
+ }
3678
3891
  /** True for a live (dynamic) adaptive stream — drives the LIVE indicator. */
3679
3892
  isLiveStream() {
3680
3893
  // Shaka-only extras — undefined on the hls.js/dash.js fallback wrappers.
@@ -4003,7 +4216,8 @@ export class MoviPlayer extends EventEmitter {
4003
4216
  }
4004
4217
  this.audioRenderer.setVolume(volume);
4005
4218
  if (this.nativeAudioEl) {
4006
- this.nativeAudioEl.volume = volume;
4219
+ // Native element caps at [0,1]; >1 boost lives in the AudioContext gain.
4220
+ this.nativeAudioEl.volume = Math.min(1, Math.max(0, volume));
4007
4221
  }
4008
4222
  }
4009
4223
  /**
@@ -4077,6 +4291,21 @@ export class MoviPlayer extends EventEmitter {
4077
4291
  /**
4078
4292
  * Get comprehensive player stats for "Stats for nerds" overlay
4079
4293
  */
4294
+ /**
4295
+ * Raw render-health numbers for the stutter-hint monitor — distinct from
4296
+ * getStats(), which formats display strings. framesPresented is cumulative
4297
+ * (resets to 0 on seek/reset), so callers must handle it going backwards.
4298
+ * Null when there's no video renderer (audio-only / adaptive streams).
4299
+ */
4300
+ getRenderHealth() {
4301
+ if (!this.videoRenderer || this.streamWrapper)
4302
+ return null;
4303
+ const vt = this.trackManager.getActiveVideoTrack();
4304
+ return {
4305
+ framesPresented: this.videoRenderer.getStats().framesPresented,
4306
+ sourceFps: vt?.frameRate && vt.frameRate > 0 ? vt.frameRate : 30,
4307
+ };
4308
+ }
4080
4309
  getStats() {
4081
4310
  // HLS mode: delegate to HLS wrapper
4082
4311
  if (this.streamWrapper) {