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.
Files changed (61) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +54 -7
  3. package/dist/core/MoviPlayer.d.ts +77 -1
  4. package/dist/core/MoviPlayer.d.ts.map +1 -1
  5. package/dist/core/MoviPlayer.js +667 -121
  6. package/dist/core/MoviPlayer.js.map +1 -1
  7. package/dist/core/PlayerState.js +1 -1
  8. package/dist/core/PlayerState.js.map +1 -1
  9. package/dist/decode/VideoDecoder.d.ts.map +1 -1
  10. package/dist/decode/VideoDecoder.js +6 -2
  11. package/dist/decode/VideoDecoder.js.map +1 -1
  12. package/dist/demuxer.cjs +315 -129
  13. package/dist/demuxer.js +377 -191
  14. package/dist/element.cjs +58309 -16596
  15. package/dist/element.js +59520 -17807
  16. package/dist/index.cjs +58309 -16596
  17. package/dist/index.js +59520 -17807
  18. package/dist/player.cjs +56818 -15360
  19. package/dist/player.js +57818 -16360
  20. package/dist/render/DASHPlayerWrapper.d.ts +65 -0
  21. package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
  22. package/dist/render/DASHPlayerWrapper.js +540 -0
  23. package/dist/render/DASHPlayerWrapper.js.map +1 -0
  24. package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
  25. package/dist/render/HLSPlayerWrapper.js +47 -11
  26. package/dist/render/HLSPlayerWrapper.js.map +1 -1
  27. package/dist/render/MoviElement.d.ts +97 -0
  28. package/dist/render/MoviElement.d.ts.map +1 -1
  29. package/dist/render/MoviElement.js +852 -152
  30. package/dist/render/MoviElement.js.map +1 -1
  31. package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
  32. package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
  33. package/dist/render/ShakaPlayerWrapper.js +956 -0
  34. package/dist/render/ShakaPlayerWrapper.js.map +1 -0
  35. package/dist/source/DashFallback.d.ts +32 -0
  36. package/dist/source/DashFallback.d.ts.map +1 -0
  37. package/dist/source/DashFallback.js +142 -0
  38. package/dist/source/DashFallback.js.map +1 -0
  39. package/dist/source/DashManifest.d.ts +34 -0
  40. package/dist/source/DashManifest.d.ts.map +1 -0
  41. package/dist/source/DashManifest.js +194 -0
  42. package/dist/source/DashManifest.js.map +1 -0
  43. package/dist/source/DashSegmentSource.d.ts +32 -0
  44. package/dist/source/DashSegmentSource.d.ts.map +1 -0
  45. package/dist/source/DashSegmentSource.js +86 -0
  46. package/dist/source/DashSegmentSource.js.map +1 -0
  47. package/dist/source/HttpSource.d.ts +64 -0
  48. package/dist/source/HttpSource.d.ts.map +1 -1
  49. package/dist/source/HttpSource.js +444 -57
  50. package/dist/source/HttpSource.js.map +1 -1
  51. package/dist/source/ThumbnailHttpSource.d.ts +13 -0
  52. package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
  53. package/dist/source/ThumbnailHttpSource.js +70 -8
  54. package/dist/source/ThumbnailHttpSource.js.map +1 -1
  55. package/dist/source/index.d.ts +2 -0
  56. package/dist/source/index.d.ts.map +1 -1
  57. package/dist/source/index.js +1 -0
  58. package/dist/source/index.js.map +1 -1
  59. package/dist/types.d.ts +10 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +16 -6
@@ -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
- // HLS Wrapper
93
- hlsWrapper = null;
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
- // Check for HLS — only when caller used SourceConfig. A custom
413
- // SourceAdapter bypasses URL/HLS detection entirely.
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
- if (!this.config.sourceAdapter &&
416
- src &&
417
- src.type === "url" &&
418
- src.url &&
419
- (src.url.includes(".m3u8") || src.url.toLowerCase().endsWith("m3u8"))) {
420
- Logger.info(TAG, "Detected HLS stream, switching to HLSPlayerWrapper");
421
- this.hlsWrapper = new HLSPlayerWrapper(this.config);
422
- // Proxy events
423
- const events = [
424
- "loadStart",
425
- "loadEnd",
426
- "play",
427
- "pause",
428
- "ended",
429
- "timeUpdate",
430
- "durationChange",
431
- "stateChange",
432
- "error",
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
- // Special handling for tracks to integrate with TrackManager?
442
- // HLSWrapper has its own TrackManager. We might need to expose it or sync it.
443
- // For now, let's swap the trackManager so external API calls work naturally.
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
- // Forward track selection from main track manager to HLS wrapper
449
- this.trackManager.on("videoTrackChange", (track) => {
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
- await this.hlsWrapper.load();
456
- this.stateManager.setState("ready"); // Sync local state manager just in case
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 (e) {
460
- this.stateManager.setState("error");
461
- throw e;
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.config.sourceAdapter) {
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.hlsWrapper) {
753
- return this.hlsWrapper.play();
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
- await this.demuxer.seek(targetTime);
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
- Logger.warn(TAG, "Native audio play blocked staying paused for user gesture");
920
- if (!this.disableAudio)
921
- this.audioRenderer.pause();
922
- if (this.videoRenderer)
923
- this.videoRenderer.stopPresentationLoop();
924
- if (this.stateManager.getState() !== "paused") {
925
- this.stateManager.setState("paused");
926
- }
927
- return;
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.hlsWrapper) {
963
- this.hlsWrapper.pause();
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.hlsWrapper) {
1945
- return this.hlsWrapper.seek(seconds);
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
- if (this.hlsWrapper)
2165
- return null; // Previews not supported for HLS
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.hlsWrapper) {
2833
- return this.hlsWrapper.getCurrentTime();
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.hlsWrapper)
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.hlsWrapper) {
2871
- return this.hlsWrapper.getDuration();
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.hlsWrapper) {
2919
- return this.hlsWrapper.getState();
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.hlsWrapper?.getVideoElement() ?? null;
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.hlsWrapper) {
2984
- this.hlsWrapper.resizeCanvas(width, height);
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.hlsWrapper) {
3050
- this.hlsWrapper.setFitMode(mode);
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.hlsWrapper) {
3066
- this.hlsWrapper.setPlaybackRate(rate);
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
- if (playingNow) {
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
- const sameSrc = this.nativeAudioEl && this.nativeAudioEl.src === url;
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 playback state when switching tracks
3154
- if (currentTime > 0) {
3155
- audioEl.currentTime = currentTime;
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
- if (wasPlaying) {
3158
- audioEl.play().catch(() => { });
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.hlsWrapper !== null);
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.hlsWrapper) {
3381
- return this.hlsWrapper.getPlaybackRate();
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.hlsWrapper) {
3565
- this.hlsWrapper.setVolume(volume);
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.hlsWrapper) {
3577
- return this.hlsWrapper.getVolume();
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.hlsWrapper) {
3589
- this.hlsWrapper.setMuted(muted);
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.hlsWrapper) {
3632
- return this.hlsWrapper.getStats();
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.hlsWrapper) {
3759
- return this.hlsWrapper.getNetworkSpeed();
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.hlsWrapper)
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.disableAudio || this.hlsWrapper)
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
- setTimeout(() => {
4001
- if (this.stateManager.getState() === "playing") {
4002
- this.requestWakeLock();
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.hlsWrapper) {
4205
- return this.hlsWrapper.getBufferEndTime();
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 wrapper
4449
- if (this.hlsWrapper) {
4450
- this.hlsWrapper.destroy();
4451
- this.hlsWrapper = null;
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 = [];