movi-player 0.3.0 → 0.3.2
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/AGENTS.md +2 -0
- package/README.md +54 -7
- package/dist/core/MoviPlayer.d.ts +77 -1
- package/dist/core/MoviPlayer.d.ts.map +1 -1
- package/dist/core/MoviPlayer.js +667 -121
- package/dist/core/MoviPlayer.js.map +1 -1
- package/dist/core/PlayerState.js +1 -1
- package/dist/core/PlayerState.js.map +1 -1
- package/dist/decode/VideoDecoder.d.ts.map +1 -1
- package/dist/decode/VideoDecoder.js +6 -2
- package/dist/decode/VideoDecoder.js.map +1 -1
- package/dist/demuxer.cjs +315 -129
- package/dist/demuxer.js +377 -191
- package/dist/element.cjs +58309 -16596
- package/dist/element.js +59520 -17807
- package/dist/index.cjs +58309 -16596
- package/dist/index.js +59520 -17807
- package/dist/player.cjs +56818 -15360
- package/dist/player.js +57818 -16360
- package/dist/render/DASHPlayerWrapper.d.ts +65 -0
- package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
- package/dist/render/DASHPlayerWrapper.js +540 -0
- package/dist/render/DASHPlayerWrapper.js.map +1 -0
- package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
- package/dist/render/HLSPlayerWrapper.js +47 -11
- package/dist/render/HLSPlayerWrapper.js.map +1 -1
- package/dist/render/MoviElement.d.ts +97 -0
- package/dist/render/MoviElement.d.ts.map +1 -1
- package/dist/render/MoviElement.js +852 -152
- package/dist/render/MoviElement.js.map +1 -1
- package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
- package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
- package/dist/render/ShakaPlayerWrapper.js +956 -0
- package/dist/render/ShakaPlayerWrapper.js.map +1 -0
- package/dist/source/DashFallback.d.ts +32 -0
- package/dist/source/DashFallback.d.ts.map +1 -0
- package/dist/source/DashFallback.js +142 -0
- package/dist/source/DashFallback.js.map +1 -0
- package/dist/source/DashManifest.d.ts +34 -0
- package/dist/source/DashManifest.d.ts.map +1 -0
- package/dist/source/DashManifest.js +194 -0
- package/dist/source/DashManifest.js.map +1 -0
- package/dist/source/DashSegmentSource.d.ts +32 -0
- package/dist/source/DashSegmentSource.d.ts.map +1 -0
- package/dist/source/DashSegmentSource.js +86 -0
- package/dist/source/DashSegmentSource.js.map +1 -0
- package/dist/source/HttpSource.d.ts +64 -0
- package/dist/source/HttpSource.d.ts.map +1 -1
- package/dist/source/HttpSource.js +444 -57
- package/dist/source/HttpSource.js.map +1 -1
- package/dist/source/ThumbnailHttpSource.d.ts +13 -0
- package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
- package/dist/source/ThumbnailHttpSource.js +70 -8
- package/dist/source/ThumbnailHttpSource.js.map +1 -1
- package/dist/source/index.d.ts +2 -0
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +16 -6
package/dist/core/MoviPlayer.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* MoviPlayer - Main public API for the streaming video library
|
|
3
3
|
*/
|
|
4
4
|
import { EventEmitter } from "../events/EventEmitter";
|
|
5
|
-
import { HttpSource, FileSource, ThumbnailHttpSource, EncryptedHttpSource, } from "../source";
|
|
5
|
+
import { HttpSource, FileSource, ThumbnailHttpSource, EncryptedHttpSource, analyzeDashFallback, } from "../source";
|
|
6
6
|
import { LRUCache } from "../cache";
|
|
7
7
|
import { Demuxer } from "../demux";
|
|
8
8
|
import { TrackManager } from "./TrackManager";
|
|
@@ -16,7 +16,9 @@ import { CanvasRenderer } from "../render/CanvasRenderer";
|
|
|
16
16
|
import { AudioRenderer } from "../render/AudioRenderer";
|
|
17
17
|
import { updateAllBindingsLogLevel, ThumbnailBindings } from "../wasm/bindings";
|
|
18
18
|
import { loadWasmModuleNew } from "../wasm/FFmpegLoader";
|
|
19
|
+
import { ShakaPlayerWrapper } from "../render/ShakaPlayerWrapper";
|
|
19
20
|
import { HLSPlayerWrapper } from "../render/HLSPlayerWrapper";
|
|
21
|
+
import { DASHPlayerWrapper } from "../render/DASHPlayerWrapper";
|
|
20
22
|
import { ThumbnailRenderer } from "../utils/ThumbnailRenderer";
|
|
21
23
|
const TAG = "MoviPlayer";
|
|
22
24
|
export class MoviPlayer extends EventEmitter {
|
|
@@ -45,6 +47,16 @@ export class MoviPlayer extends EventEmitter {
|
|
|
45
47
|
demuxer = null;
|
|
46
48
|
// Separate audio source — uses native <audio> element (zero WASM overhead)
|
|
47
49
|
nativeAudioEl = null;
|
|
50
|
+
// When custom media headers are set, the native <audio> can't send them, so
|
|
51
|
+
// we fetch the file ourselves and feed it a blob: URL. Track the logical
|
|
52
|
+
// (pre-blob) URL for same-source detection and the object URL for revocation.
|
|
53
|
+
_nativeAudioObjectUrl = null;
|
|
54
|
+
_nativeAudioLogicalUrl = null;
|
|
55
|
+
// Set when autoplay-with-sound was blocked for the native <audio> track and we
|
|
56
|
+
// fell back to muted playback. Surfaces via isAudioBlockedSuspended() so the
|
|
57
|
+
// element shows the "Tap to unmute" pill (mirrors the WebAudio path). Cleared
|
|
58
|
+
// on a successful unmuted play or when the user unmutes.
|
|
59
|
+
_nativeAudioAutoplayBlocked = false;
|
|
48
60
|
_audioTracks = [];
|
|
49
61
|
_activeAudioLang = "";
|
|
50
62
|
// External subtitle tracks (VTT/SRT)
|
|
@@ -61,6 +73,9 @@ export class MoviPlayer extends EventEmitter {
|
|
|
61
73
|
previewsAllowed() {
|
|
62
74
|
if (!this.config.enablePreviews)
|
|
63
75
|
return false;
|
|
76
|
+
// Non-range sources keep previews ON: the thumbnail source borrows frames
|
|
77
|
+
// straight from the main source's RAM window (no network), so previews work
|
|
78
|
+
// for any position inside the buffered/seekable range.
|
|
64
79
|
// NOTE: a file-size cap used to live here — the seek-bar thumbnail
|
|
65
80
|
// pipeline opens a SECOND isolated WASM module + FFmpeg context, and on
|
|
66
81
|
// large 1 GB+ sources the two heaps can exhaust the tab's memory budget,
|
|
@@ -75,6 +90,20 @@ export class MoviPlayer extends EventEmitter {
|
|
|
75
90
|
return false;
|
|
76
91
|
return true;
|
|
77
92
|
}
|
|
93
|
+
/** Proxy a stream wrapper's events + mirror its TrackManager onto the player. */
|
|
94
|
+
wireStreamWrapper(wrapper) {
|
|
95
|
+
const events = [
|
|
96
|
+
"loadStart", "loadEnd", "play", "pause", "ended", "timeUpdate",
|
|
97
|
+
"durationChange", "stateChange", "error", "buffering", "seeking", "seeked",
|
|
98
|
+
];
|
|
99
|
+
events.forEach((evt) => {
|
|
100
|
+
// @ts-ignore — event names line up across the wrapper and player maps
|
|
101
|
+
wrapper.on(evt, (arg) => this.emit(evt, arg));
|
|
102
|
+
});
|
|
103
|
+
wrapper.trackManager.on("tracksChange", (tracks) => {
|
|
104
|
+
this.trackManager.setTracks(tracks);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
78
107
|
// Decoders and Renderers
|
|
79
108
|
videoDecoder;
|
|
80
109
|
audioDecoder;
|
|
@@ -89,8 +118,10 @@ export class MoviPlayer extends EventEmitter {
|
|
|
89
118
|
// Extracted once at load time when the demuxer reports an attached_pic
|
|
90
119
|
// pseudo-stream; null for plain video files or audio without artwork.
|
|
91
120
|
coverArt = null;
|
|
92
|
-
//
|
|
93
|
-
|
|
121
|
+
// Active adaptive-streaming wrapper (Shaka primary, hls.js/dash.js fallback).
|
|
122
|
+
// Non-null only while a stream source is active; delegation throughout the
|
|
123
|
+
// player stays format-agnostic.
|
|
124
|
+
streamWrapper = null;
|
|
94
125
|
// Preview pipeline (C-based FFmpeg software decoding)
|
|
95
126
|
thumbnailBindings = null;
|
|
96
127
|
thumbnailSource = null;
|
|
@@ -99,8 +130,15 @@ export class MoviPlayer extends EventEmitter {
|
|
|
99
130
|
isPreviewGenerating = false;
|
|
100
131
|
audioRenderer;
|
|
101
132
|
previewInitPromise = null; // Guard for preview initialization
|
|
133
|
+
previewInitAttempts = 0; // Bounded retries for preview pipeline init
|
|
134
|
+
previewInitGaveUp = false; // Stop retrying once init has failed too often
|
|
102
135
|
// Debug flag to disable audio processing
|
|
103
136
|
disableAudio = false; // Set to true to disable audio for debugging
|
|
137
|
+
// Audio-only mode (data-saver): skip video decoding to save CPU (the
|
|
138
|
+
// demuxer still reads the interleaved bytes, but decode is the expensive
|
|
139
|
+
// part); for adaptive streams the wrapper also drops the video renditions to
|
|
140
|
+
// save bandwidth. The UI switches to the album-art / strip surface.
|
|
141
|
+
_audioOnly = false;
|
|
104
142
|
muted = false; // Mute state
|
|
105
143
|
wasPlayingBeforeRebuffer = false; // Track if we were playing before entering rebuffering state
|
|
106
144
|
_stallStartTime = 0; // When stall was first detected
|
|
@@ -175,6 +213,7 @@ export class MoviPlayer extends EventEmitter {
|
|
|
175
213
|
constructor(config) {
|
|
176
214
|
super();
|
|
177
215
|
this.config = config;
|
|
216
|
+
this._audioOnly = !!config.audioOnly;
|
|
178
217
|
this.cache = new LRUCache(config.cache?.maxSizeMB ?? 100);
|
|
179
218
|
this.trackManager = new TrackManager();
|
|
180
219
|
this.clock = new Clock();
|
|
@@ -409,63 +448,119 @@ export class MoviPlayer extends EventEmitter {
|
|
|
409
448
|
this.coverArt = null;
|
|
410
449
|
// Clean up any existing preview pipeline
|
|
411
450
|
this.destroyPreviewPipeline();
|
|
412
|
-
//
|
|
413
|
-
// SourceAdapter bypasses URL
|
|
451
|
+
// Adaptive streaming — only when the caller used SourceConfig (a custom
|
|
452
|
+
// SourceAdapter bypasses URL detection entirely). HLS (.m3u8) and DASH
|
|
453
|
+
// (.mpd) both go through Shaka Player (one engine, MSE under the hood). This
|
|
454
|
+
// covers multiplexed DASH too — Shaka plays it natively, so there's no
|
|
455
|
+
// FFmpeg fallback to fork on here.
|
|
414
456
|
const src = this.config.source;
|
|
415
|
-
|
|
416
|
-
src
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
"
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
"buffering",
|
|
434
|
-
"seeking",
|
|
435
|
-
"seeked",
|
|
436
|
-
];
|
|
437
|
-
events.forEach((evt) => {
|
|
438
|
-
// @ts-ignore
|
|
439
|
-
this.hlsWrapper.on(evt, (arg) => this.emit(evt, arg));
|
|
457
|
+
const streamUrl = !this.config.sourceAdapter && src && src.type === "url" && src.url
|
|
458
|
+
? src.url
|
|
459
|
+
: null;
|
|
460
|
+
const lowerUrl = streamUrl?.toLowerCase() ?? "";
|
|
461
|
+
// HLS (.m3u8), DASH (.mpd), and Smooth Streaming (.ism/.isml) all go
|
|
462
|
+
// through Shaka. `.ism` also matches `.isml/manifest`.
|
|
463
|
+
const isStream = !!streamUrl &&
|
|
464
|
+
(lowerUrl.includes(".m3u8") ||
|
|
465
|
+
lowerUrl.includes(".mpd") ||
|
|
466
|
+
lowerUrl.includes(".ism"));
|
|
467
|
+
if (isStream) {
|
|
468
|
+
const isHls = lowerUrl.includes(".m3u8");
|
|
469
|
+
const isDash = lowerUrl.includes(".mpd");
|
|
470
|
+
const kind = isHls ? "HLS" : lowerUrl.includes(".ism") ? "Smooth Streaming" : "DASH";
|
|
471
|
+
// Forward track selections from the main TrackManager to whichever stream
|
|
472
|
+
// wrapper is currently active (added once; resolves the live field).
|
|
473
|
+
this.trackManager.on("videoTrackChange", (track) => {
|
|
474
|
+
this.streamWrapper?.selectVideoTrack(track ? track.id : -1);
|
|
440
475
|
});
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// Sync tracks from HLS wrapper to main track manager
|
|
445
|
-
this.hlsWrapper.trackManager.on("tracksChange", (tracks) => {
|
|
446
|
-
this.trackManager.setTracks(tracks);
|
|
476
|
+
this.trackManager.on("audioTrackChange", (track) => {
|
|
477
|
+
if (track)
|
|
478
|
+
this.streamWrapper?.selectAudioTrack(track.id);
|
|
447
479
|
});
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (this.hlsWrapper) {
|
|
451
|
-
this.hlsWrapper.selectVideoTrack(track ? track.id : -1);
|
|
452
|
-
}
|
|
480
|
+
this.trackManager.on("subtitleTrackChange", (track) => {
|
|
481
|
+
this.streamWrapper?.selectSubtitleTrack(track ? track.id : null);
|
|
453
482
|
});
|
|
483
|
+
// --- Tier 1: Shaka (HLS + DASH + MSS + muxed). ---
|
|
454
484
|
try {
|
|
455
|
-
|
|
456
|
-
this.
|
|
485
|
+
const shaka = new ShakaPlayerWrapper(this.config);
|
|
486
|
+
this.streamWrapper = shaka;
|
|
487
|
+
this.wireStreamWrapper(shaka);
|
|
488
|
+
Logger.info(TAG, `Detected ${kind} stream, using ShakaPlayerWrapper`);
|
|
489
|
+
await shaka.load();
|
|
490
|
+
this.stateManager.setState("ready");
|
|
457
491
|
return;
|
|
458
492
|
}
|
|
459
|
-
catch (
|
|
460
|
-
|
|
461
|
-
|
|
493
|
+
catch (eShaka) {
|
|
494
|
+
Logger.warn(TAG, `Shaka failed on ${kind} stream`, eShaka);
|
|
495
|
+
try {
|
|
496
|
+
this.streamWrapper?.destroy();
|
|
497
|
+
}
|
|
498
|
+
catch { }
|
|
499
|
+
this.streamWrapper = null;
|
|
500
|
+
// --- Tier 2: hls.js / dash.js. Their MSE engines play streams Shaka
|
|
501
|
+
// rejects (e.g. under-specified single-file DASH the browser demuxer
|
|
502
|
+
// handles but Shaka/FFmpeg won't). ---
|
|
503
|
+
if (isHls || isDash) {
|
|
504
|
+
try {
|
|
505
|
+
const fb = isHls
|
|
506
|
+
? new HLSPlayerWrapper(this.config)
|
|
507
|
+
: new DASHPlayerWrapper(this.config);
|
|
508
|
+
this.streamWrapper = fb;
|
|
509
|
+
this.wireStreamWrapper(fb);
|
|
510
|
+
Logger.info(TAG, `Shaka failed; retrying with ${isHls ? "hls.js" : "dash.js"}`);
|
|
511
|
+
await fb.load();
|
|
512
|
+
this.stateManager.setState("ready");
|
|
513
|
+
Logger.info(TAG, `Recovered via ${isHls ? "hls.js" : "dash.js"}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
catch (eFallback) {
|
|
517
|
+
Logger.warn(TAG, `${isHls ? "hls.js" : "dash.js"} fallback also failed`, eFallback);
|
|
518
|
+
try {
|
|
519
|
+
this.streamWrapper?.destroy();
|
|
520
|
+
}
|
|
521
|
+
catch { }
|
|
522
|
+
this.streamWrapper = null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// --- Tier 3: FFmpeg demuxer for bare-<BaseURL> single-file DASH that
|
|
526
|
+
// even the MSE engines refuse (e.g. muxed single-file). Falls through
|
|
527
|
+
// to the demuxer path below with the video file as the source (+ the
|
|
528
|
+
// separate audio file as a native-audio source for demuxed content). ---
|
|
529
|
+
let fellBack = false;
|
|
530
|
+
if (isDash) {
|
|
531
|
+
try {
|
|
532
|
+
const plan = await analyzeDashFallback(streamUrl, src?.headers);
|
|
533
|
+
if (plan) {
|
|
534
|
+
Logger.info(TAG, "Falling back to the FFmpeg demuxer");
|
|
535
|
+
this.source = await this.createSource({
|
|
536
|
+
type: "url",
|
|
537
|
+
url: plan.videoUrl,
|
|
538
|
+
headers: src?.headers,
|
|
539
|
+
});
|
|
540
|
+
if (plan.audioUrl) {
|
|
541
|
+
this.config.audioSource = { type: "url", url: plan.audioUrl, headers: src?.headers };
|
|
542
|
+
}
|
|
543
|
+
fellBack = true; // fall through to the demuxer path below
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch (eDemux) {
|
|
547
|
+
Logger.warn(TAG, "FFmpeg DASH fallback failed", eDemux);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!fellBack) {
|
|
551
|
+
this.stateManager.setState("error");
|
|
552
|
+
throw eShaka; // surface the original Shaka error
|
|
553
|
+
}
|
|
462
554
|
}
|
|
463
555
|
}
|
|
464
556
|
try {
|
|
465
557
|
// Create source — honor a pre-built adapter if the caller supplied
|
|
466
558
|
// one (custom protocol, encrypted blob, IndexedDB-backed source, etc.)
|
|
467
559
|
// so the demuxer can read through it without going through SourceConfig.
|
|
468
|
-
if (this.
|
|
560
|
+
if (this.source) {
|
|
561
|
+
// Already resolved (defensive — a prior load may have set it).
|
|
562
|
+
}
|
|
563
|
+
else if (this.config.sourceAdapter) {
|
|
469
564
|
this.source = this.config.sourceAdapter;
|
|
470
565
|
}
|
|
471
566
|
else if (this.config.source) {
|
|
@@ -574,6 +669,9 @@ export class MoviPlayer extends EventEmitter {
|
|
|
574
669
|
if (config.type === "url" && config.url) {
|
|
575
670
|
const maxBufferSizeMB = this.config.cache?.maxSizeMB;
|
|
576
671
|
const source = new HttpSource(config.url, config.headers, maxBufferSizeMB);
|
|
672
|
+
// Server has no Range support + file too big to cache → forward-only
|
|
673
|
+
// linear playback. Surface it so the UI can drop the timeline / seeking.
|
|
674
|
+
source.setOnLinearMode(() => this.emit("linearmode", undefined));
|
|
577
675
|
return source;
|
|
578
676
|
}
|
|
579
677
|
throw new Error("Invalid source configuration");
|
|
@@ -691,9 +789,23 @@ export class MoviPlayer extends EventEmitter {
|
|
|
691
789
|
* renderer starts AudioContext playback the moment samples arrive. Both
|
|
692
790
|
* break if we decode during prebuffer.
|
|
693
791
|
*/
|
|
792
|
+
/**
|
|
793
|
+
* Native-audio-only playback (split-source data saver): audio-only mode AND a
|
|
794
|
+
* separate <audio> track. There's no video pipeline to run, and the audio is
|
|
795
|
+
* its own element, so the demuxer body is never read — only the header it
|
|
796
|
+
* downloaded at open() — saving the video file's entire bandwidth on top of
|
|
797
|
+
* the skipped decode. Drives playback straight off the <audio> element.
|
|
798
|
+
*/
|
|
799
|
+
nativeAudioOnlyPlayback() {
|
|
800
|
+
return this._audioOnly && !!this.nativeAudioEl;
|
|
801
|
+
}
|
|
694
802
|
async prebuffer() {
|
|
695
803
|
if (!this.demuxer)
|
|
696
804
|
return;
|
|
805
|
+
// Native-audio-only: nothing to prebuffer — reading video packets here would
|
|
806
|
+
// start downloading the body we're trying to skip.
|
|
807
|
+
if (this.nativeAudioOnlyPlayback())
|
|
808
|
+
return;
|
|
697
809
|
const hasVideoTrack = !!this.trackManager.getActiveVideoTrack();
|
|
698
810
|
const hasInFileAudio = !!this.trackManager.getActiveAudioTrack() &&
|
|
699
811
|
!this.disableAudio &&
|
|
@@ -749,8 +861,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
749
861
|
* Start playback
|
|
750
862
|
*/
|
|
751
863
|
async play() {
|
|
752
|
-
if (this.
|
|
753
|
-
return this.
|
|
864
|
+
if (this.streamWrapper) {
|
|
865
|
+
return this.streamWrapper.play();
|
|
754
866
|
}
|
|
755
867
|
// Stop pause-time buffering — we're resuming active playback
|
|
756
868
|
this.stopPauseBuffering();
|
|
@@ -758,6 +870,12 @@ export class MoviPlayer extends EventEmitter {
|
|
|
758
870
|
Logger.warn(TAG, "Cannot play in current state");
|
|
759
871
|
return;
|
|
760
872
|
}
|
|
873
|
+
// Native-audio-only (split-source data saver): no demuxer/decode pipeline —
|
|
874
|
+
// drive playback straight off the <audio> element so the video body is never
|
|
875
|
+
// fetched. Handles first play, resume, and replay.
|
|
876
|
+
if (this.nativeAudioOnlyPlayback()) {
|
|
877
|
+
return this.playNativeAudioOnly();
|
|
878
|
+
}
|
|
761
879
|
const currentState = this.stateManager.getState();
|
|
762
880
|
// During buffering or seeking, mark intent to resume when ready
|
|
763
881
|
if (currentState === "buffering" || currentState === "seeking") {
|
|
@@ -787,6 +905,23 @@ export class MoviPlayer extends EventEmitter {
|
|
|
787
905
|
this.wasPlayingBeforeSeek = true;
|
|
788
906
|
try {
|
|
789
907
|
await this.seek(0, { suppressSpinner: true });
|
|
908
|
+
// The separate native <audio> track ended with the video; seek(0)
|
|
909
|
+
// rewinds its currentTime but leaves it paused, so the replay plays
|
|
910
|
+
// silent. Restart it here (the normal play() audio-resume block is
|
|
911
|
+
// skipped on the wasEnded path). A manual replay is a user gesture so
|
|
912
|
+
// this succeeds; an auto-loop restart that was never unmuted can still
|
|
913
|
+
// be blocked — keep the muted-rolling flag so the unmute pill stays.
|
|
914
|
+
if (this.nativeAudioEl && this.nativeAudioEl.paused) {
|
|
915
|
+
this.nativeAudioEl.playbackRate = this.clock.getPlaybackRate();
|
|
916
|
+
try {
|
|
917
|
+
await this.nativeAudioEl.play();
|
|
918
|
+
this._nativeAudioAutoplayBlocked = false;
|
|
919
|
+
}
|
|
920
|
+
catch (e) {
|
|
921
|
+
this._nativeAudioAutoplayBlocked = true;
|
|
922
|
+
Logger.warn(TAG, "Native audio replay blocked — rolling video muted", e);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
790
925
|
}
|
|
791
926
|
catch (error) {
|
|
792
927
|
this.suppressSeekSpinner = false;
|
|
@@ -861,7 +996,25 @@ export class MoviPlayer extends EventEmitter {
|
|
|
861
996
|
// audio context. Running them concurrently let the audio renderer
|
|
862
997
|
// accept the very first decoded packet before the demuxer cursor
|
|
863
998
|
// had finished rewinding.
|
|
864
|
-
|
|
999
|
+
//
|
|
1000
|
+
// Guard the seek: the demuxer rejects with "error -1" when the source
|
|
1001
|
+
// opened in a degenerate state (e.g. a non-faststart file whose prebuffer
|
|
1002
|
+
// hit EOF with zero frames, or a rapid source-switch that tore down the
|
|
1003
|
+
// read path mid-open). Unguarded, it escaped as an uncaught rejection —
|
|
1004
|
+
// and with the caller re-issuing play() it spammed/looped. Bail cleanly
|
|
1005
|
+
// and mark the source unplayable so the UI can show the broken state
|
|
1006
|
+
// instead of retrying a seek that can never succeed.
|
|
1007
|
+
try {
|
|
1008
|
+
await this.demuxer.seek(targetTime);
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
Logger.error(TAG, "Demuxer seek on first play failed", error);
|
|
1012
|
+
this.wasPlayingBeforeSeek = false;
|
|
1013
|
+
this.suppressSeekSpinner = false;
|
|
1014
|
+
this.stateManager.setState("error");
|
|
1015
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
865
1018
|
// After Open-GOP recovery (decoder rejected the first keyframe past
|
|
866
1019
|
// the seek target and reset), the next decoded frames will begin at
|
|
867
1020
|
// the previous GOP's keyframe — often 1-2s behind targetTime. Without
|
|
@@ -914,17 +1067,22 @@ export class MoviPlayer extends EventEmitter {
|
|
|
914
1067
|
this.nativeAudioEl.playbackRate = this.clock.getPlaybackRate();
|
|
915
1068
|
try {
|
|
916
1069
|
await this.nativeAudioEl.play();
|
|
1070
|
+
this._nativeAudioAutoplayBlocked = false;
|
|
917
1071
|
}
|
|
918
1072
|
catch {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1073
|
+
// Autoplay-with-sound was blocked (no user gesture yet). A bare <audio>
|
|
1074
|
+
// element won't autoplay even when muted (Chrome's muted-autoplay
|
|
1075
|
+
// allowance is really for <video>), so we can't start its audio yet —
|
|
1076
|
+
// but the VIDEO lives on the canvas and needs no autoplay permission.
|
|
1077
|
+
// Roll the video on the wall clock (while the element is paused the
|
|
1078
|
+
// clock's audio provider returns -1, so it falls back automatically),
|
|
1079
|
+
// flag the block, and let the element surface the "Tap to unmute" pill.
|
|
1080
|
+
// The unmute gesture (setMuted(false)) then seeks + plays the audio,
|
|
1081
|
+
// which re-assumes clock-master duty. Mirrors the WebAudio/in-file path,
|
|
1082
|
+
// where video rolls while the AudioContext stays suspended.
|
|
1083
|
+
Logger.warn(TAG, "Native audio autoplay blocked — rolling video muted (tap to unmute)");
|
|
1084
|
+
this._nativeAudioAutoplayBlocked = true;
|
|
1085
|
+
// Fall through — do NOT pause or stop the presentation loop.
|
|
928
1086
|
}
|
|
929
1087
|
}
|
|
930
1088
|
this.clock.start();
|
|
@@ -955,12 +1113,54 @@ export class MoviPlayer extends EventEmitter {
|
|
|
955
1113
|
this.processLoop();
|
|
956
1114
|
Logger.info(TAG, "Playing");
|
|
957
1115
|
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Play in native-audio-only mode (split-source data saver). No demuxer reads,
|
|
1118
|
+
* no decode loop — just the <audio> element + clock. Handles first play,
|
|
1119
|
+
* resume, and replay (rewind on ended). Autoplay-blocked stays paused (there's
|
|
1120
|
+
* no video to roll), with the centre play button as the resume affordance.
|
|
1121
|
+
*/
|
|
1122
|
+
async playNativeAudioOnly() {
|
|
1123
|
+
const audioEl = this.nativeAudioEl;
|
|
1124
|
+
if (!audioEl)
|
|
1125
|
+
return;
|
|
1126
|
+
this.requestWakeLock();
|
|
1127
|
+
// Replay: rewind the audio + clock before starting again.
|
|
1128
|
+
if (this.stateManager.getState() === "ended") {
|
|
1129
|
+
try {
|
|
1130
|
+
audioEl.currentTime = 0;
|
|
1131
|
+
this.clock.seek(this.startTime);
|
|
1132
|
+
}
|
|
1133
|
+
catch { }
|
|
1134
|
+
this.stateManager.setState("seeking"); // ended → seeking (valid)
|
|
1135
|
+
}
|
|
1136
|
+
audioEl.playbackRate = this.clock.getPlaybackRate();
|
|
1137
|
+
try {
|
|
1138
|
+
await audioEl.play();
|
|
1139
|
+
this._nativeAudioAutoplayBlocked = false;
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
// Autoplay-with-sound blocked and there's no video to roll muted — stay
|
|
1143
|
+
// paused so the centre play button shows for a user gesture.
|
|
1144
|
+
Logger.warn(TAG, "Native audio autoplay blocked — staying paused for user gesture");
|
|
1145
|
+
const st = this.stateManager.getState();
|
|
1146
|
+
if (st !== "paused")
|
|
1147
|
+
this.stateManager.setState("paused");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
this.clock.start();
|
|
1151
|
+
this._playStartTime = performance.now();
|
|
1152
|
+
const st = this.stateManager.getState();
|
|
1153
|
+
if (st === "ready" || st === "paused" || st === "seeking") {
|
|
1154
|
+
this.stateManager.setState("playing");
|
|
1155
|
+
}
|
|
1156
|
+
Logger.info(TAG, "Playing (native-audio-only)");
|
|
1157
|
+
}
|
|
958
1158
|
/**
|
|
959
1159
|
* Pause playback
|
|
960
1160
|
*/
|
|
961
1161
|
pause() {
|
|
962
|
-
if (this.
|
|
963
|
-
this.
|
|
1162
|
+
if (this.streamWrapper) {
|
|
1163
|
+
this.streamWrapper.pause();
|
|
964
1164
|
return;
|
|
965
1165
|
}
|
|
966
1166
|
if (!this.stateManager.canPause()) {
|
|
@@ -1699,6 +1899,14 @@ export class MoviPlayer extends EventEmitter {
|
|
|
1699
1899
|
const activeVideo = this.trackManager.getActiveVideoTrack();
|
|
1700
1900
|
const activeAudio = this.trackManager.getActiveAudioTrack();
|
|
1701
1901
|
if (activeVideo && activeVideo.id === packet.streamIndex) {
|
|
1902
|
+
// Audio-only mode: skip ALL video decoding to save CPU. Decode is
|
|
1903
|
+
// the expensive part; the interleaved bytes still arrive (no single-
|
|
1904
|
+
// file bandwidth saving — that's the adaptive-stream wrapper's job),
|
|
1905
|
+
// but the GPU/CPU video pipeline stays idle. Toggling back to video
|
|
1906
|
+
// re-seeks to recover a keyframe (see setAudioOnly).
|
|
1907
|
+
if (this._audioOnly) {
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1702
1910
|
// In background (not PiP), skip video decoding entirely.
|
|
1703
1911
|
// This prevents frame queue buildup that blocks audio demuxing via backpressure.
|
|
1704
1912
|
// At 60fps, video queue fills in ~1.7s and starves audio.
|
|
@@ -1941,8 +2149,26 @@ export class MoviPlayer extends EventEmitter {
|
|
|
1941
2149
|
// cleared on seek completion (notifySeekCompletion).
|
|
1942
2150
|
suppressSeekSpinner = false;
|
|
1943
2151
|
async seek(seconds, opts) {
|
|
1944
|
-
if (this.
|
|
1945
|
-
return this.
|
|
2152
|
+
if (this.streamWrapper) {
|
|
2153
|
+
return this.streamWrapper.seek(seconds);
|
|
2154
|
+
}
|
|
2155
|
+
// Native-audio-only (split-source data saver): no demuxer/decoder pipeline —
|
|
2156
|
+
// just move the <audio> element and the clock. No demuxer.seek (which would
|
|
2157
|
+
// start reading the video body we're skipping).
|
|
2158
|
+
if (this.nativeAudioOnlyPlayback() && this.nativeAudioEl) {
|
|
2159
|
+
const t = Math.max(0, Math.min(seconds, this.getDuration() || seconds));
|
|
2160
|
+
try {
|
|
2161
|
+
this.nativeAudioEl.currentTime = t;
|
|
2162
|
+
}
|
|
2163
|
+
catch { }
|
|
2164
|
+
this.clock.seek(t + this.startTime);
|
|
2165
|
+
this.seekKeyframeOffset = 0;
|
|
2166
|
+
this.eofReached = false;
|
|
2167
|
+
this.eofSince = 0;
|
|
2168
|
+
this.emit("seeking", t);
|
|
2169
|
+
this.emit("timeUpdate", t);
|
|
2170
|
+
this.emit("seeked", t);
|
|
2171
|
+
return;
|
|
1946
2172
|
}
|
|
1947
2173
|
// A genuine user seek (no opt) clears any leftover suppression so its
|
|
1948
2174
|
// spinner shows; play()-initiated seeks pass suppressSpinner to hide it.
|
|
@@ -2159,10 +2385,18 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2159
2385
|
* Generates a preview frame for the given time using C for demuxing and WebCodecs for decoding.
|
|
2160
2386
|
*/
|
|
2161
2387
|
async getPreviewFrame(time) {
|
|
2388
|
+
if (this._audioOnly)
|
|
2389
|
+
return null; // Data-saver: never decode video for previews
|
|
2162
2390
|
if (!this.previewsAllowed())
|
|
2163
2391
|
return null; // Disabled, or source too large for a 2nd WASM context
|
|
2164
|
-
|
|
2165
|
-
|
|
2392
|
+
// Adaptive streams: use the manifest's own thumbnail track via Shaka
|
|
2393
|
+
// (DASH-IF tiled thumbnails / HLS image playlists). Returns null when the
|
|
2394
|
+
// manifest has no thumbnail track, so the preview just stays hidden — far
|
|
2395
|
+
// cheaper than the FFmpeg path, which can't byte-range-seek a stream.
|
|
2396
|
+
if (this.streamWrapper)
|
|
2397
|
+
return this.streamWrapper.getThumbnailBlob?.(time) ?? null;
|
|
2398
|
+
if (this.previewInitGaveUp)
|
|
2399
|
+
return null; // Init failed repeatedly — stop retrying (and re-loading WASM)
|
|
2166
2400
|
if (this.isPreviewGenerating)
|
|
2167
2401
|
return null; // Busy
|
|
2168
2402
|
// Audio-only sources have no video track to thumbnail. Bail early
|
|
@@ -2184,8 +2418,15 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2184
2418
|
this.previewInitPromise = null;
|
|
2185
2419
|
}
|
|
2186
2420
|
}
|
|
2187
|
-
// If still no bindings (init failed or promise was cleared), retry
|
|
2421
|
+
// If still no bindings (init failed or promise was cleared), retry —
|
|
2422
|
+
// but cap attempts so a persistent failure doesn't re-load a fresh WASM
|
|
2423
|
+
// module on every seek-bar hover.
|
|
2188
2424
|
if (!this.thumbnailBindings) {
|
|
2425
|
+
if (++this.previewInitAttempts > 3) {
|
|
2426
|
+
this.previewInitGaveUp = true;
|
|
2427
|
+
Logger.warn(TAG, "Thumbnail pipeline init failed repeatedly — disabling previews.");
|
|
2428
|
+
return null;
|
|
2429
|
+
}
|
|
2189
2430
|
Logger.debug(TAG, "Initializing thumbnail pipeline (retry)...");
|
|
2190
2431
|
this.previewInitPromise = this.initPreviewPipeline();
|
|
2191
2432
|
try {
|
|
@@ -2637,6 +2878,15 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2637
2878
|
else {
|
|
2638
2879
|
throw new Error("No source available for thumbnail pipeline");
|
|
2639
2880
|
}
|
|
2881
|
+
// Reuse the size the main source already resolved — a dedicated
|
|
2882
|
+
// ThumbnailHttpSource can't probe it on a non-range server (HEAD strips
|
|
2883
|
+
// Content-Length, the 200 GET may be chunked), which used to fail init.
|
|
2884
|
+
if (this.fileSize > 0 &&
|
|
2885
|
+
this.thumbnailSource &&
|
|
2886
|
+
"seedSize" in this.thumbnailSource &&
|
|
2887
|
+
typeof this.thumbnailSource.seedSize === "function") {
|
|
2888
|
+
this.thumbnailSource.seedSize(this.fileSize);
|
|
2889
|
+
}
|
|
2640
2890
|
}
|
|
2641
2891
|
const fileSize = await this.thumbnailSource.getSize();
|
|
2642
2892
|
Logger.debug(TAG, `Thumbnail source created, file size: ${fileSize}`);
|
|
@@ -2763,6 +3013,12 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2763
3013
|
}
|
|
2764
3014
|
return result;
|
|
2765
3015
|
}
|
|
3016
|
+
// Adaptive streams: Shaka already applied the text-track selection (via the
|
|
3017
|
+
// trackManager → streamWrapper wiring) and renders cues itself. There's no
|
|
3018
|
+
// FFmpeg demuxer / subtitle decoder to configure, so stop here.
|
|
3019
|
+
if (this.streamWrapper) {
|
|
3020
|
+
return result;
|
|
3021
|
+
}
|
|
2766
3022
|
// Configure decoder for new subtitle track
|
|
2767
3023
|
if (this.demuxer && this.subtitleDecoder) {
|
|
2768
3024
|
const subtitleTrack = this.trackManager.getActiveSubtitleTrack();
|
|
@@ -2829,8 +3085,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2829
3085
|
* Get current playback time
|
|
2830
3086
|
*/
|
|
2831
3087
|
getCurrentTime() {
|
|
2832
|
-
if (this.
|
|
2833
|
-
return this.
|
|
3088
|
+
if (this.streamWrapper) {
|
|
3089
|
+
return this.streamWrapper.getCurrentTime();
|
|
2834
3090
|
}
|
|
2835
3091
|
// Subtract seekKeyframeOffset so the timeline reports the user-requested
|
|
2836
3092
|
// time after a seek instead of where the decoder actually landed (which
|
|
@@ -2848,7 +3104,7 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2848
3104
|
* re-seeks the demuxer to 0 itself). Pure time-math; touches no media state.
|
|
2849
3105
|
*/
|
|
2850
3106
|
resetClockToStartForPoster() {
|
|
2851
|
-
if (this.
|
|
3107
|
+
if (this.streamWrapper)
|
|
2852
3108
|
return; // HLS owns its own timeline
|
|
2853
3109
|
this.clock.seek(this.startTime); // paused → pausedTime = startTime
|
|
2854
3110
|
this.seekKeyframeOffset = 0; // so getCurrentTime() === 0
|
|
@@ -2867,8 +3123,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2867
3123
|
* Get duration
|
|
2868
3124
|
*/
|
|
2869
3125
|
getDuration() {
|
|
2870
|
-
if (this.
|
|
2871
|
-
return this.
|
|
3126
|
+
if (this.streamWrapper) {
|
|
3127
|
+
return this.streamWrapper.getDuration();
|
|
2872
3128
|
}
|
|
2873
3129
|
return this.mediaInfo?.duration ?? 0;
|
|
2874
3130
|
}
|
|
@@ -2915,8 +3171,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2915
3171
|
* Get current state
|
|
2916
3172
|
*/
|
|
2917
3173
|
getState() {
|
|
2918
|
-
if (this.
|
|
2919
|
-
return this.
|
|
3174
|
+
if (this.streamWrapper) {
|
|
3175
|
+
return this.streamWrapper.getState();
|
|
2920
3176
|
}
|
|
2921
3177
|
return this.stateManager.getState();
|
|
2922
3178
|
}
|
|
@@ -2971,7 +3227,7 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2971
3227
|
* Get HLS video element (DRM mode) for direct DOM insertion
|
|
2972
3228
|
*/
|
|
2973
3229
|
getHLSVideoElement() {
|
|
2974
|
-
return this.
|
|
3230
|
+
return this.streamWrapper?.getVideoElement() ?? null;
|
|
2975
3231
|
}
|
|
2976
3232
|
/**
|
|
2977
3233
|
* Get chapters from the media (empty array if none)
|
|
@@ -2980,12 +3236,16 @@ export class MoviPlayer extends EventEmitter {
|
|
|
2980
3236
|
return this.mediaInfo?.chapters ?? [];
|
|
2981
3237
|
}
|
|
2982
3238
|
resizeCanvas(width, height) {
|
|
2983
|
-
if (this.
|
|
2984
|
-
this.
|
|
3239
|
+
if (this.streamWrapper) {
|
|
3240
|
+
this.streamWrapper.resizeCanvas(width, height);
|
|
2985
3241
|
}
|
|
2986
3242
|
if (this.videoRenderer) {
|
|
2987
3243
|
this.videoRenderer.resize(width, height);
|
|
2988
3244
|
}
|
|
3245
|
+
// A resize often coincides with a fullscreen / orientation / PiP change —
|
|
3246
|
+
// a good moment to recover a wake lock that dropped or whose first request
|
|
3247
|
+
// failed. Idempotent: no-op when already held / not playing / page hidden.
|
|
3248
|
+
this.ensureWakeLock();
|
|
2989
3249
|
}
|
|
2990
3250
|
/**
|
|
2991
3251
|
* Set HDR enabled state
|
|
@@ -3046,8 +3306,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3046
3306
|
this.videoRenderer?.setManualRotation(deg);
|
|
3047
3307
|
}
|
|
3048
3308
|
setFitMode(mode) {
|
|
3049
|
-
if (this.
|
|
3050
|
-
this.
|
|
3309
|
+
if (this.streamWrapper) {
|
|
3310
|
+
this.streamWrapper.setFitMode(mode);
|
|
3051
3311
|
}
|
|
3052
3312
|
if (this.videoRenderer) {
|
|
3053
3313
|
this.videoRenderer.setFitMode(mode);
|
|
@@ -3062,8 +3322,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3062
3322
|
* Set playback rate
|
|
3063
3323
|
*/
|
|
3064
3324
|
setPlaybackRate(rate) {
|
|
3065
|
-
if (this.
|
|
3066
|
-
this.
|
|
3325
|
+
if (this.streamWrapper) {
|
|
3326
|
+
this.streamWrapper.setPlaybackRate(rate);
|
|
3067
3327
|
}
|
|
3068
3328
|
const savedTime = this.getCurrentTime();
|
|
3069
3329
|
// Only the corrective seek's purpose (undoing the audio read-ahead pivot)
|
|
@@ -3105,7 +3365,11 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3105
3365
|
// preservePlaying keeps the play/pause state across it; the seek-session
|
|
3106
3366
|
// guard keeps rapid rate changes from a superseded completion landing
|
|
3107
3367
|
// paused. Only when actually playing — see playingNow above.
|
|
3108
|
-
|
|
3368
|
+
// Linear (non-seekable) playback can't do the corrective seek — the
|
|
3369
|
+
// keyframe before savedTime is usually behind the sliding window and the
|
|
3370
|
+
// read would fail (seek timeout → buffering). Skip it; the worst case is a
|
|
3371
|
+
// brief read-ahead pivot on rate change, far better than a stalled seek.
|
|
3372
|
+
if (playingNow && !this.isLinearPlayback()) {
|
|
3109
3373
|
this.seek(savedTime, {
|
|
3110
3374
|
suppressSpinner: true,
|
|
3111
3375
|
preservePlaying: true,
|
|
@@ -3116,19 +3380,54 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3116
3380
|
* Setup native <audio> element for separate audio source.
|
|
3117
3381
|
* Shared by single audioSource and multi-language audioTracks.
|
|
3118
3382
|
*/
|
|
3383
|
+
/**
|
|
3384
|
+
* Wire the native <audio> element's media events to player state/time. These
|
|
3385
|
+
* only act in native-audio-only mode (split-source data saver) — where there's
|
|
3386
|
+
* no demux loop to emit timeUpdate / detect EOF — and are inert during normal
|
|
3387
|
+
* split-source playback, which the processLoop drives.
|
|
3388
|
+
*/
|
|
3389
|
+
wireNativeAudioEvents(el) {
|
|
3390
|
+
el.addEventListener("timeupdate", () => {
|
|
3391
|
+
if (!this.nativeAudioOnlyPlayback())
|
|
3392
|
+
return;
|
|
3393
|
+
this.emit("timeUpdate", this.getCurrentTime());
|
|
3394
|
+
});
|
|
3395
|
+
el.addEventListener("durationchange", () => {
|
|
3396
|
+
if (!this.nativeAudioOnlyPlayback())
|
|
3397
|
+
return;
|
|
3398
|
+
const d = el.duration;
|
|
3399
|
+
if (isFinite(d) && d > 0) {
|
|
3400
|
+
if (this.mediaInfo)
|
|
3401
|
+
this.mediaInfo.duration = d;
|
|
3402
|
+
this.clock.setDuration(d + this.startTime);
|
|
3403
|
+
this.emit("durationChange", d);
|
|
3404
|
+
}
|
|
3405
|
+
});
|
|
3406
|
+
el.addEventListener("ended", () => {
|
|
3407
|
+
if (!this.nativeAudioOnlyPlayback())
|
|
3408
|
+
return;
|
|
3409
|
+
if (this.stateManager.getState() === "ended")
|
|
3410
|
+
return;
|
|
3411
|
+
const dur = this.getDuration() || 0;
|
|
3412
|
+
this.clock.seek(dur + this.startTime);
|
|
3413
|
+
this.emit("timeUpdate", dur);
|
|
3414
|
+
this.stateManager.setState("ended");
|
|
3415
|
+
this.emit("ended", undefined);
|
|
3416
|
+
this.releaseWakeLock();
|
|
3417
|
+
});
|
|
3418
|
+
}
|
|
3119
3419
|
setupNativeAudio(url) {
|
|
3120
3420
|
const wasPlaying = this.nativeAudioEl && !this.nativeAudioEl.paused;
|
|
3121
3421
|
const currentTime = this.nativeAudioEl?.currentTime ?? 0;
|
|
3122
|
-
|
|
3422
|
+
// Same-source detection: match the logical URL (the blob path rewrites
|
|
3423
|
+
// .src to a blob: URL) OR the element's raw .src (an adopted element from a
|
|
3424
|
+
// quality switch carries the URL but not our logical-URL field).
|
|
3425
|
+
const sameSrc = !!this.nativeAudioEl &&
|
|
3426
|
+
(this._nativeAudioLogicalUrl === url || this.nativeAudioEl.src === url);
|
|
3123
3427
|
// Reuse or create element
|
|
3124
3428
|
if (!this.nativeAudioEl) {
|
|
3125
3429
|
this.nativeAudioEl = new Audio();
|
|
3126
|
-
|
|
3127
|
-
// Skip src reassignment when adopting an already-playing element with the
|
|
3128
|
-
// same URL (quality switch where audio track is shared) — reassigning the
|
|
3129
|
-
// same src triggers a reload and loses the user-activated play() context.
|
|
3130
|
-
if (!sameSrc) {
|
|
3131
|
-
this.nativeAudioEl.src = url;
|
|
3430
|
+
this.wireNativeAudioEvents(this.nativeAudioEl);
|
|
3132
3431
|
}
|
|
3133
3432
|
this.nativeAudioEl.preload = "auto";
|
|
3134
3433
|
this.nativeAudioEl.volume = this.muted ? 0 : this.audioRenderer.getVolume();
|
|
@@ -3150,12 +3449,67 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3150
3449
|
if (this.videoRenderer) {
|
|
3151
3450
|
this.videoRenderer.setAudioTimeProvider(() => isAudioReady() ? audioEl.currentTime + self.startTime : -1, isAudioReady);
|
|
3152
3451
|
}
|
|
3153
|
-
// Restore position and
|
|
3154
|
-
|
|
3155
|
-
|
|
3452
|
+
// Restore position and resume after the source is in place. Also resume if
|
|
3453
|
+
// the player has since entered "playing" (e.g. autoplay/play() fired while
|
|
3454
|
+
// the headed blob was still fetching, so the earlier audio play() was a
|
|
3455
|
+
// no-op) — this lets the audio join as soon as it's ready.
|
|
3456
|
+
const restorePlayback = () => {
|
|
3457
|
+
if (currentTime > 0)
|
|
3458
|
+
audioEl.currentTime = currentTime;
|
|
3459
|
+
if (wasPlaying || this.stateManager.is("playing")) {
|
|
3460
|
+
audioEl.play().catch(() => { });
|
|
3461
|
+
}
|
|
3462
|
+
};
|
|
3463
|
+
if (sameSrc) {
|
|
3464
|
+
// Adopting an already-playing element with the same URL (quality switch
|
|
3465
|
+
// where the audio track is shared) — reassigning src would reload and
|
|
3466
|
+
// lose the user-activated play() context, so leave it untouched.
|
|
3467
|
+
restorePlayback();
|
|
3468
|
+
return;
|
|
3156
3469
|
}
|
|
3157
|
-
|
|
3158
|
-
|
|
3470
|
+
this._nativeAudioLogicalUrl = url;
|
|
3471
|
+
const headers = this.config.headers;
|
|
3472
|
+
if (headers && Object.keys(headers).length > 0) {
|
|
3473
|
+
// Native <audio> ignores custom request headers, so the .mpd-split / API
|
|
3474
|
+
// audio file would 401/403 without them. Fetch it ourselves with the
|
|
3475
|
+
// headers and play from an in-memory blob: URL. Trade-off: the whole file
|
|
3476
|
+
// is buffered up front (no range streaming) — acceptable for a separate
|
|
3477
|
+
// audio track, and only taken when headers are actually required.
|
|
3478
|
+
this.revokeNativeAudioObjectUrl();
|
|
3479
|
+
fetch(url, { headers })
|
|
3480
|
+
.then((res) => {
|
|
3481
|
+
if (!res.ok)
|
|
3482
|
+
throw new Error(`HTTP ${res.status}`);
|
|
3483
|
+
return res.blob();
|
|
3484
|
+
})
|
|
3485
|
+
.then((blob) => {
|
|
3486
|
+
// A newer setup (track switch / new source) may have superseded this
|
|
3487
|
+
// fetch while it was in flight — bail so we don't clobber it.
|
|
3488
|
+
if (this.nativeAudioEl !== audioEl || this._nativeAudioLogicalUrl !== url) {
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
this._nativeAudioObjectUrl = URL.createObjectURL(blob);
|
|
3492
|
+
audioEl.src = this._nativeAudioObjectUrl;
|
|
3493
|
+
restorePlayback();
|
|
3494
|
+
})
|
|
3495
|
+
.catch((e) => {
|
|
3496
|
+
Logger.error(TAG, `Separate audio with custom headers failed to load: ${url}`, e);
|
|
3497
|
+
});
|
|
3498
|
+
}
|
|
3499
|
+
else {
|
|
3500
|
+
this.revokeNativeAudioObjectUrl();
|
|
3501
|
+
audioEl.src = url;
|
|
3502
|
+
restorePlayback();
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
/** Release the in-memory blob: URL backing a header-authenticated audio file. */
|
|
3506
|
+
revokeNativeAudioObjectUrl() {
|
|
3507
|
+
if (this._nativeAudioObjectUrl) {
|
|
3508
|
+
try {
|
|
3509
|
+
URL.revokeObjectURL(this._nativeAudioObjectUrl);
|
|
3510
|
+
}
|
|
3511
|
+
catch { }
|
|
3512
|
+
this._nativeAudioObjectUrl = null;
|
|
3159
3513
|
}
|
|
3160
3514
|
}
|
|
3161
3515
|
/**
|
|
@@ -3169,6 +3523,13 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3169
3523
|
releaseNativeAudio() {
|
|
3170
3524
|
const el = this.nativeAudioEl;
|
|
3171
3525
|
if (el) {
|
|
3526
|
+
// Hand the blob: URL (and its logical URL) to the successor via the
|
|
3527
|
+
// element itself, so it reuses the same in-memory audio instead of
|
|
3528
|
+
// re-fetching — and so we DON'T revoke a URL the element still plays.
|
|
3529
|
+
el.__moviLogicalUrl = this._nativeAudioLogicalUrl;
|
|
3530
|
+
el.__moviObjectUrl = this._nativeAudioObjectUrl;
|
|
3531
|
+
this._nativeAudioObjectUrl = null; // ownership moves with the element
|
|
3532
|
+
this._nativeAudioLogicalUrl = null;
|
|
3172
3533
|
this.nativeAudioEl = null;
|
|
3173
3534
|
}
|
|
3174
3535
|
return el;
|
|
@@ -3186,6 +3547,14 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3186
3547
|
catch { }
|
|
3187
3548
|
}
|
|
3188
3549
|
this.nativeAudioEl = el;
|
|
3550
|
+
// Reclaim the blob: URL ownership stashed by releaseNativeAudio, so the
|
|
3551
|
+
// same-source check matches (no re-fetch) and destroy() later revokes it.
|
|
3552
|
+
if (el.__moviObjectUrl !== undefined) {
|
|
3553
|
+
this._nativeAudioObjectUrl = el.__moviObjectUrl ?? null;
|
|
3554
|
+
this._nativeAudioLogicalUrl = el.__moviLogicalUrl ?? null;
|
|
3555
|
+
delete el.__moviObjectUrl;
|
|
3556
|
+
delete el.__moviLogicalUrl;
|
|
3557
|
+
}
|
|
3189
3558
|
}
|
|
3190
3559
|
/**
|
|
3191
3560
|
* Get available audio language tracks (multi-language mode)
|
|
@@ -3231,6 +3600,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3231
3600
|
this.nativeAudioEl.pause();
|
|
3232
3601
|
this.nativeAudioEl.src = "";
|
|
3233
3602
|
this.nativeAudioEl = null;
|
|
3603
|
+
this.revokeNativeAudioObjectUrl();
|
|
3604
|
+
this._nativeAudioLogicalUrl = null;
|
|
3234
3605
|
this._activeAudioLang = "";
|
|
3235
3606
|
// Re-enable WASM audio
|
|
3236
3607
|
this.disableAudio = false;
|
|
@@ -3259,7 +3630,30 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3259
3630
|
hasAudibleSource() {
|
|
3260
3631
|
return (this.trackManager.getAudioTracks().length > 0 ||
|
|
3261
3632
|
this.hasNativeAudio() ||
|
|
3262
|
-
this.
|
|
3633
|
+
this.streamWrapper !== null);
|
|
3634
|
+
}
|
|
3635
|
+
/** True for a live (dynamic) adaptive stream — drives the LIVE indicator. */
|
|
3636
|
+
isLiveStream() {
|
|
3637
|
+
// Shaka-only extras — undefined on the hls.js/dash.js fallback wrappers.
|
|
3638
|
+
return this.streamWrapper?.isLive?.() ?? false;
|
|
3639
|
+
}
|
|
3640
|
+
/** True when the active adaptive stream is audio-only (no video track). */
|
|
3641
|
+
isStreamAudioOnly() {
|
|
3642
|
+
return this.streamWrapper?.isAudioOnly?.() ?? false;
|
|
3643
|
+
}
|
|
3644
|
+
/** Live-edge time of a live stream (seekable range end). */
|
|
3645
|
+
getLiveEdge() {
|
|
3646
|
+
return this.streamWrapper?.getLiveEdge?.() ?? this.getDuration();
|
|
3647
|
+
}
|
|
3648
|
+
/** Start of a live stream's seekable (DVR) window. */
|
|
3649
|
+
getSeekRangeStart() {
|
|
3650
|
+
return this.streamWrapper?.getSeekRangeStart?.() ?? 0;
|
|
3651
|
+
}
|
|
3652
|
+
/** Jump to the live edge of a live stream. */
|
|
3653
|
+
seekToLive() {
|
|
3654
|
+
const edge = this.getLiveEdge();
|
|
3655
|
+
if (isFinite(edge) && edge > 0)
|
|
3656
|
+
this.streamWrapper?.seek(edge);
|
|
3263
3657
|
}
|
|
3264
3658
|
/**
|
|
3265
3659
|
* Get available external subtitle tracks
|
|
@@ -3377,8 +3771,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3377
3771
|
* Get playback rate
|
|
3378
3772
|
*/
|
|
3379
3773
|
getPlaybackRate() {
|
|
3380
|
-
if (this.
|
|
3381
|
-
return this.
|
|
3774
|
+
if (this.streamWrapper) {
|
|
3775
|
+
return this.streamWrapper.getPlaybackRate();
|
|
3382
3776
|
}
|
|
3383
3777
|
return this.clock.getPlaybackRate();
|
|
3384
3778
|
}
|
|
@@ -3561,8 +3955,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3561
3955
|
* Set volume (0-1)
|
|
3562
3956
|
*/
|
|
3563
3957
|
setVolume(volume) {
|
|
3564
|
-
if (this.
|
|
3565
|
-
this.
|
|
3958
|
+
if (this.streamWrapper) {
|
|
3959
|
+
this.streamWrapper.setVolume(volume);
|
|
3566
3960
|
}
|
|
3567
3961
|
this.audioRenderer.setVolume(volume);
|
|
3568
3962
|
if (this.nativeAudioEl) {
|
|
@@ -3573,8 +3967,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3573
3967
|
* Get volume (0-1)
|
|
3574
3968
|
*/
|
|
3575
3969
|
getVolume() {
|
|
3576
|
-
if (this.
|
|
3577
|
-
return this.
|
|
3970
|
+
if (this.streamWrapper) {
|
|
3971
|
+
return this.streamWrapper.getVolume();
|
|
3578
3972
|
}
|
|
3579
3973
|
return this.audioRenderer.getVolume();
|
|
3580
3974
|
}
|
|
@@ -3585,8 +3979,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3585
3979
|
if (this.muted === muted)
|
|
3586
3980
|
return; // No change
|
|
3587
3981
|
this.muted = muted;
|
|
3588
|
-
if (this.
|
|
3589
|
-
this.
|
|
3982
|
+
if (this.streamWrapper) {
|
|
3983
|
+
this.streamWrapper.setMuted(muted);
|
|
3590
3984
|
return;
|
|
3591
3985
|
}
|
|
3592
3986
|
if (muted) {
|
|
@@ -3601,6 +3995,20 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3601
3995
|
}
|
|
3602
3996
|
if (this.nativeAudioEl) {
|
|
3603
3997
|
this.nativeAudioEl.muted = muted;
|
|
3998
|
+
// Unmuting resolves a native-audio autoplay block. The <audio> couldn't
|
|
3999
|
+
// start without a gesture, so the video has been rolling on the wall clock
|
|
4000
|
+
// with the audio paused — THIS unmute is the gesture. Sync the audio to
|
|
4001
|
+
// the current playhead and start it; it then re-assumes clock-master duty.
|
|
4002
|
+
if (!muted && this._nativeAudioAutoplayBlocked) {
|
|
4003
|
+
this._nativeAudioAutoplayBlocked = false;
|
|
4004
|
+
if (this.nativeAudioEl.paused && this.stateManager.getState() === "playing") {
|
|
4005
|
+
try {
|
|
4006
|
+
this.nativeAudioEl.currentTime = Math.max(0, this.clock.getTime() - this.startTime);
|
|
4007
|
+
}
|
|
4008
|
+
catch { }
|
|
4009
|
+
this.nativeAudioEl.play().catch(() => { });
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
3604
4012
|
}
|
|
3605
4013
|
}
|
|
3606
4014
|
/**
|
|
@@ -3628,8 +4036,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3628
4036
|
*/
|
|
3629
4037
|
getStats() {
|
|
3630
4038
|
// HLS mode: delegate to HLS wrapper
|
|
3631
|
-
if (this.
|
|
3632
|
-
return this.
|
|
4039
|
+
if (this.streamWrapper) {
|
|
4040
|
+
return this.streamWrapper.getStats();
|
|
3633
4041
|
}
|
|
3634
4042
|
const mediaInfo = this.mediaInfo;
|
|
3635
4043
|
const videoTrack = this.trackManager.getActiveVideoTrack();
|
|
@@ -3755,8 +4163,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3755
4163
|
*/
|
|
3756
4164
|
getNetworkSpeed() {
|
|
3757
4165
|
// HLS mode: delegate to HLS wrapper
|
|
3758
|
-
if (this.
|
|
3759
|
-
return this.
|
|
4166
|
+
if (this.streamWrapper) {
|
|
4167
|
+
return this.streamWrapper.getNetworkSpeed();
|
|
3760
4168
|
}
|
|
3761
4169
|
// EncryptedHttpSource extends HttpSource, so the HttpSource branch
|
|
3762
4170
|
// covers encrypted playback too.
|
|
@@ -3772,10 +4180,20 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3772
4180
|
* Check if source is a local file
|
|
3773
4181
|
*/
|
|
3774
4182
|
isFileSource() {
|
|
3775
|
-
if (this.
|
|
4183
|
+
if (this.streamWrapper)
|
|
3776
4184
|
return false;
|
|
3777
4185
|
return this.source instanceof FileSource;
|
|
3778
4186
|
}
|
|
4187
|
+
/**
|
|
4188
|
+
* True when the active source has fallen back to linear (forward-only,
|
|
4189
|
+
* non-seekable) playback — server lacks HTTP Range support and the file is
|
|
4190
|
+
* too big to cache whole. Used by the UI as a backstop alongside the
|
|
4191
|
+
* "linearmode" event (in case the event fired before listeners attached).
|
|
4192
|
+
*/
|
|
4193
|
+
isLinearPlayback() {
|
|
4194
|
+
const src = this.source;
|
|
4195
|
+
return typeof src?.isLinearMode === "function" ? src.isLinearMode() : false;
|
|
4196
|
+
}
|
|
3779
4197
|
/**
|
|
3780
4198
|
* True when audio is blocked by the browser's autoplay policy — the
|
|
3781
4199
|
* AudioContext is stuck suspended despite an unmuted play() because no
|
|
@@ -3784,10 +4202,73 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3784
4202
|
* muted playback + a "Tap to unmute" pill.
|
|
3785
4203
|
*/
|
|
3786
4204
|
isAudioBlockedSuspended() {
|
|
3787
|
-
if (this.
|
|
4205
|
+
if (this.streamWrapper)
|
|
4206
|
+
return false;
|
|
4207
|
+
// Native <audio> autoplay blocked → muted-and-rolling: report blocked so
|
|
4208
|
+
// the element shows the unmute pill (disableAudio is true in this path, so
|
|
4209
|
+
// it must be checked before the disableAudio short-circuit below).
|
|
4210
|
+
if (this._nativeAudioAutoplayBlocked)
|
|
4211
|
+
return true;
|
|
4212
|
+
if (this.disableAudio)
|
|
3788
4213
|
return false;
|
|
3789
4214
|
return this.audioRenderer.isBlockedSuspended();
|
|
3790
4215
|
}
|
|
4216
|
+
/** True when audio-only (data-saver) mode is active. */
|
|
4217
|
+
isAudioOnly() {
|
|
4218
|
+
return this._audioOnly;
|
|
4219
|
+
}
|
|
4220
|
+
/**
|
|
4221
|
+
* Toggle audio-only (data-saver) mode at runtime. On the demuxer path the
|
|
4222
|
+
* processLoop stops decoding video (CPU saving) — re-enabling re-seeks to
|
|
4223
|
+
* recover a keyframe and resume video in sync. Adaptive streams drop/restore
|
|
4224
|
+
* video renditions via a reload (config.audioOnly), so this only flips the
|
|
4225
|
+
* flag for them; the caller (MoviElement) owns that reload.
|
|
4226
|
+
*/
|
|
4227
|
+
setAudioOnly(enabled) {
|
|
4228
|
+
if (this._audioOnly === enabled)
|
|
4229
|
+
return;
|
|
4230
|
+
this._audioOnly = enabled;
|
|
4231
|
+
if (this.streamWrapper) {
|
|
4232
|
+
// Adaptive streams: the wrapper picks an audio-only / smallest-video
|
|
4233
|
+
// variant live (no reload, so the stream — and its LIVE state — survives).
|
|
4234
|
+
this.streamWrapper.setAudioOnly?.(enabled);
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
const splitSource = !!this.nativeAudioEl; // separate <audio> drives playback
|
|
4238
|
+
if (enabled) {
|
|
4239
|
+
// Freeze the video surface cleanly — drop queued + on-screen frames so the
|
|
4240
|
+
// UI can swap to the album-art / strip view without a stale last frame.
|
|
4241
|
+
if (this.videoRenderer)
|
|
4242
|
+
this.videoRenderer.clearQueue();
|
|
4243
|
+
if (splitSource) {
|
|
4244
|
+
// Split source: stop the demux loop entirely so the video body stops
|
|
4245
|
+
// downloading + decoding. The native <audio> keeps playing on its own.
|
|
4246
|
+
// (Doing this live — never via a reload — avoids tearing down the WASM
|
|
4247
|
+
// context while a read is in flight, which crashes with an OOB.)
|
|
4248
|
+
if (this.animationFrameId !== null) {
|
|
4249
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
4250
|
+
this.animationFrameId = null;
|
|
4251
|
+
}
|
|
4252
|
+
this.stopPauseBuffering();
|
|
4253
|
+
}
|
|
4254
|
+
// Muxed source: keep the demux loop running (it still decodes the in-file
|
|
4255
|
+
// audio); the processLoop's _audioOnly check skips only the video decode.
|
|
4256
|
+
}
|
|
4257
|
+
else {
|
|
4258
|
+
// Re-enabling video. Seek to the current playhead to recover a keyframe and
|
|
4259
|
+
// resync; for a split source the demux loop was stopped, so restart it.
|
|
4260
|
+
const t = this.getCurrentTime();
|
|
4261
|
+
this.seek(t)
|
|
4262
|
+
.then(() => {
|
|
4263
|
+
if (splitSource &&
|
|
4264
|
+
this.animationFrameId === null &&
|
|
4265
|
+
this.stateManager.getState() === "playing") {
|
|
4266
|
+
this.processLoop();
|
|
4267
|
+
}
|
|
4268
|
+
})
|
|
4269
|
+
.catch((e) => Logger.warn(TAG, "Audio-only → video resync seek failed", e));
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
3791
4272
|
/**
|
|
3792
4273
|
* True when the active source is not a FileSource (gate inactive), or when
|
|
3793
4274
|
* the FileSource's initial preload pass has settled.
|
|
@@ -3807,12 +4288,19 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3807
4288
|
/**
|
|
3808
4289
|
* Request WakeLock to prevent screen sleep
|
|
3809
4290
|
*/
|
|
3810
|
-
async requestWakeLock() {
|
|
4291
|
+
async requestWakeLock(retry = 1) {
|
|
3811
4292
|
// Check if WakeLock API is available
|
|
3812
4293
|
if (!("wakeLock" in navigator)) {
|
|
3813
4294
|
Logger.debug(TAG, "WakeLock API not available");
|
|
3814
4295
|
return;
|
|
3815
4296
|
}
|
|
4297
|
+
// The Screen Wake Lock API rejects (NotAllowedError) unless the page is
|
|
4298
|
+
// visible — don't even attempt while hidden. It's the wrong moment, not a
|
|
4299
|
+
// fault; handleVisibilityChange re-requests once the tab is shown.
|
|
4300
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
|
4301
|
+
Logger.debug(TAG, "WakeLock skipped — page not visible");
|
|
4302
|
+
return;
|
|
4303
|
+
}
|
|
3816
4304
|
try {
|
|
3817
4305
|
// Release existing wakeLock if any
|
|
3818
4306
|
if (this.wakeLock) {
|
|
@@ -3829,10 +4317,42 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3829
4317
|
});
|
|
3830
4318
|
}
|
|
3831
4319
|
catch (error) {
|
|
3832
|
-
Logger.warn(TAG, "Failed to acquire WakeLock", error);
|
|
3833
4320
|
this.wakeLock = null;
|
|
4321
|
+
Logger.warn(TAG, "Failed to acquire WakeLock", error);
|
|
4322
|
+
// Some devices reject the very FIRST request transiently even while
|
|
4323
|
+
// visible (a race as the page becomes fully interactive), then never
|
|
4324
|
+
// re-acquire for the rest of the session. Retry once shortly — but only
|
|
4325
|
+
// if we still want the lock (active playback, visible, none held).
|
|
4326
|
+
if (retry > 0) {
|
|
4327
|
+
setTimeout(() => {
|
|
4328
|
+
const st = this.stateManager.getState();
|
|
4329
|
+
if (!this.wakeLock &&
|
|
4330
|
+
(st === "playing" || st === "buffering") &&
|
|
4331
|
+
typeof document !== "undefined" &&
|
|
4332
|
+
document.visibilityState === "visible") {
|
|
4333
|
+
this.requestWakeLock(retry - 1);
|
|
4334
|
+
}
|
|
4335
|
+
}, 600);
|
|
4336
|
+
}
|
|
3834
4337
|
}
|
|
3835
4338
|
}
|
|
4339
|
+
/**
|
|
4340
|
+
* Re-acquire the screen wake lock if we should be holding one but aren't.
|
|
4341
|
+
* Called on the moments where the lock can quietly drop, or where a failed
|
|
4342
|
+
* first attempt gets a fresh chance: tab visibility changes and player
|
|
4343
|
+
* resizes (fullscreen / orientation / PiP transitions). Idempotent — no-op
|
|
4344
|
+
* when a lock is already held, playback isn't active, or the page is hidden.
|
|
4345
|
+
*/
|
|
4346
|
+
ensureWakeLock() {
|
|
4347
|
+
if (this.wakeLock)
|
|
4348
|
+
return; // already held
|
|
4349
|
+
const st = this.stateManager.getState();
|
|
4350
|
+
if (st !== "playing" && st !== "buffering")
|
|
4351
|
+
return; // not actively playing
|
|
4352
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible")
|
|
4353
|
+
return;
|
|
4354
|
+
this.requestWakeLock();
|
|
4355
|
+
}
|
|
3836
4356
|
/**
|
|
3837
4357
|
* Handle network recovery — re-seek to current position to restart cleanly
|
|
3838
4358
|
*/
|
|
@@ -3896,6 +4416,9 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3896
4416
|
// Tab visible again — stop background timer, RAF takes over
|
|
3897
4417
|
this.isBackgrounded = false;
|
|
3898
4418
|
this.stopBackgroundTimer();
|
|
4419
|
+
// The system drops the wake lock whenever the tab hides; now that we're
|
|
4420
|
+
// visible again, re-acquire it (idempotent, gated on active playback).
|
|
4421
|
+
this.ensureWakeLock();
|
|
3899
4422
|
if (isPlaying) {
|
|
3900
4423
|
// Resume AudioContext if needed. On mobile after long background the
|
|
3901
4424
|
// browser may keep it suspended (autoplay policy — prior gesture has
|
|
@@ -3997,11 +4520,9 @@ export class MoviPlayer extends EventEmitter {
|
|
|
3997
4520
|
// PiP was active — just restart processLoop, video was rendering in PiP
|
|
3998
4521
|
this.processLoop();
|
|
3999
4522
|
}
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
}
|
|
4004
|
-
}, 500);
|
|
4523
|
+
// Re-acquire once playback has settled back in — idempotent, so it's a
|
|
4524
|
+
// no-op if ensureWakeLock above already got it.
|
|
4525
|
+
setTimeout(() => this.ensureWakeLock(), 500);
|
|
4005
4526
|
}
|
|
4006
4527
|
}
|
|
4007
4528
|
};
|
|
@@ -4077,6 +4598,10 @@ export class MoviPlayer extends EventEmitter {
|
|
|
4077
4598
|
return;
|
|
4078
4599
|
if (!this.demuxer || this.eofReached)
|
|
4079
4600
|
return;
|
|
4601
|
+
// Native-audio-only: never read the demuxer — that would download the very
|
|
4602
|
+
// video body the data-saver mode exists to skip.
|
|
4603
|
+
if (this.nativeAudioOnlyPlayback())
|
|
4604
|
+
return;
|
|
4080
4605
|
// Only for HTTP sources — local files are already fully available
|
|
4081
4606
|
if (this.source instanceof FileSource)
|
|
4082
4607
|
return;
|
|
@@ -4201,8 +4726,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
4201
4726
|
* Returns the furthest time position that has been buffered
|
|
4202
4727
|
*/
|
|
4203
4728
|
getBufferedTime() {
|
|
4204
|
-
if (this.
|
|
4205
|
-
return this.
|
|
4729
|
+
if (this.streamWrapper) {
|
|
4730
|
+
return this.streamWrapper.getBufferEndTime();
|
|
4206
4731
|
}
|
|
4207
4732
|
if (!this.mediaInfo || !this.source) {
|
|
4208
4733
|
return 0;
|
|
@@ -4317,6 +4842,25 @@ export class MoviPlayer extends EventEmitter {
|
|
|
4317
4842
|
}
|
|
4318
4843
|
return 0;
|
|
4319
4844
|
}
|
|
4845
|
+
/**
|
|
4846
|
+
* Lowest time a backward seek can SAFELY land in linear (non-range) playback.
|
|
4847
|
+
* The window starts at getBufferStartTime, but a seek's keyframe sits at a
|
|
4848
|
+
* lower byte than the linear time→byte estimate (GOP span + VBR), so seeking
|
|
4849
|
+
* right at the window edge usually reads just below it and fails. Pad the
|
|
4850
|
+
* start by a byte safety margin so the keyframe stays inside the RAM window.
|
|
4851
|
+
* Returns 0 for seekable (range-capable) or non-HTTP sources.
|
|
4852
|
+
*/
|
|
4853
|
+
getSeekableStartTime() {
|
|
4854
|
+
if (!this.mediaInfo ||
|
|
4855
|
+
!(this.source instanceof HttpSource) ||
|
|
4856
|
+
this.fileSize <= 0 ||
|
|
4857
|
+
!this.source.isLinearMode()) {
|
|
4858
|
+
return 0;
|
|
4859
|
+
}
|
|
4860
|
+
const MARGIN_BYTES = 96 * 1024 * 1024; // covers a keyframe-before-target gap
|
|
4861
|
+
const safeBytes = Math.min(this.fileSize, this.source.getBufferStart() + MARGIN_BYTES);
|
|
4862
|
+
return Math.min(this.mediaInfo.duration, (safeBytes / this.fileSize) * this.mediaInfo.duration);
|
|
4863
|
+
}
|
|
4320
4864
|
/**
|
|
4321
4865
|
* Get buffer end time in seconds (for HttpSource)
|
|
4322
4866
|
* Same as getBufferedTime but more explicit
|
|
@@ -4445,10 +4989,10 @@ export class MoviPlayer extends EventEmitter {
|
|
|
4445
4989
|
}
|
|
4446
4990
|
this.stopBackgroundTimer();
|
|
4447
4991
|
this.stopPauseBuffering();
|
|
4448
|
-
// Destroy HLS
|
|
4449
|
-
if (this.
|
|
4450
|
-
this.
|
|
4451
|
-
this.
|
|
4992
|
+
// Destroy the adaptive-streaming wrapper (HLS or DASH, via Shaka)
|
|
4993
|
+
if (this.streamWrapper) {
|
|
4994
|
+
this.streamWrapper.destroy();
|
|
4995
|
+
this.streamWrapper = null;
|
|
4452
4996
|
}
|
|
4453
4997
|
this.pendingPrebufferPackets = [];
|
|
4454
4998
|
// Close resources
|
|
@@ -4469,6 +5013,8 @@ export class MoviPlayer extends EventEmitter {
|
|
|
4469
5013
|
this.nativeAudioEl.src = "";
|
|
4470
5014
|
this.nativeAudioEl = null;
|
|
4471
5015
|
}
|
|
5016
|
+
this.revokeNativeAudioObjectUrl();
|
|
5017
|
+
this._nativeAudioLogicalUrl = null;
|
|
4472
5018
|
// Cleanup external subtitles
|
|
4473
5019
|
this.stopExternalSubtitles();
|
|
4474
5020
|
this._externalSubCues = [];
|