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.
- package/README.md +23 -12
- package/dist/core/MoviPlayer.d.ts +65 -1
- package/dist/core/MoviPlayer.d.ts.map +1 -1
- package/dist/core/MoviPlayer.js +239 -10
- package/dist/core/MoviPlayer.js.map +1 -1
- package/dist/decode/AudioDecoder.d.ts +4 -0
- package/dist/decode/AudioDecoder.d.ts.map +1 -1
- package/dist/decode/AudioDecoder.js +14 -0
- package/dist/decode/AudioDecoder.js.map +1 -1
- package/dist/decode/SoftwareAudioDecoder.d.ts +12 -0
- package/dist/decode/SoftwareAudioDecoder.d.ts.map +1 -1
- package/dist/decode/SoftwareAudioDecoder.js +91 -2
- package/dist/decode/SoftwareAudioDecoder.js.map +1 -1
- package/dist/decode/VideoDecoder.d.ts.map +1 -1
- package/dist/decode/VideoDecoder.js +7 -0
- package/dist/decode/VideoDecoder.js.map +1 -1
- package/dist/demuxer.cjs +1863 -1788
- package/dist/demuxer.js +1722 -1649
- package/dist/element.cjs +2904 -1867
- package/dist/element.d.ts +48 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +7935 -6894
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +2904 -1867
- package/dist/index.js +7935 -6894
- package/dist/player.cjs +3060 -2930
- package/dist/player.js +1763 -1633
- package/dist/render/AudioRenderer.d.ts +30 -0
- package/dist/render/AudioRenderer.d.ts.map +1 -1
- package/dist/render/AudioRenderer.js +127 -44
- package/dist/render/AudioRenderer.js.map +1 -1
- package/dist/render/CanvasRenderer.d.ts +31 -0
- package/dist/render/CanvasRenderer.d.ts.map +1 -1
- package/dist/render/CanvasRenderer.js +31 -0
- package/dist/render/CanvasRenderer.js.map +1 -1
- package/dist/render/DASHPlayerWrapper.d.ts.map +1 -1
- package/dist/render/DASHPlayerWrapper.js +3 -1
- package/dist/render/DASHPlayerWrapper.js.map +1 -1
- package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
- package/dist/render/HLSPlayerWrapper.js +3 -1
- package/dist/render/HLSPlayerWrapper.js.map +1 -1
- package/dist/render/MoviElement.d.ts +223 -0
- package/dist/render/MoviElement.d.ts.map +1 -1
- package/dist/render/MoviElement.js +2072 -209
- package/dist/render/MoviElement.js.map +1 -1
- package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -1
- package/dist/render/ShakaPlayerWrapper.js +3 -1
- package/dist/render/ShakaPlayerWrapper.js.map +1 -1
- package/dist/source/HttpSource.d.ts +3 -0
- package/dist/source/HttpSource.d.ts.map +1 -1
- package/dist/source/HttpSource.js +81 -22
- package/dist/source/HttpSource.js.map +1 -1
- package/dist/utils/QoE.d.ts +126 -0
- package/dist/utils/QoE.d.ts.map +1 -0
- package/dist/utils/QoE.js +192 -0
- package/dist/utils/QoE.js.map +1 -0
- package/dist/utils/ThumbnailRenderer.d.ts +25 -0
- package/dist/utils/ThumbnailRenderer.d.ts.map +1 -1
- package/dist/utils/ThumbnailRenderer.js +195 -2
- package/dist/utils/ThumbnailRenderer.js.map +1 -1
- package/dist/wasm/bindings.d.ts +8 -0
- package/dist/wasm/bindings.d.ts.map +1 -1
- package/dist/wasm/bindings.js +31 -7
- package/dist/wasm/bindings.js.map +1 -1
- package/dist/wasm/movi.js +0 -0
- package/package.json +3 -2
package/dist/core/MoviPlayer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1424
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|