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
@@ -54,6 +54,16 @@ export class MoviElement extends HTMLElement {
54
54
  coverArtOverlay = null;
55
55
  coverArtCanvas = null;
56
56
  coverArtBitmap = null;
57
+ // For an audio-only source with NO embedded album art but a `poster` URL, the
58
+ // poster is loaded into a bitmap and painted through the same cover-art canvas
59
+ // (blurred backdrop + centered) so it reads as album art instead of the bare
60
+ // strip. Embedded art (coverArtBitmap) always wins over this.
61
+ _posterCoverBitmap = null;
62
+ _posterCoverUrl = ""; // URL the poster bitmap was loaded from
63
+ _posterCoverLoading = false;
64
+ // Blurred-backdrop element behind the sharp-art canvas — CSS filter:blur()
65
+ // (cross-browser Gaussian, unlike canvas ctx.filter).
66
+ _coverArtBgEl = null;
57
67
  // True once cover-art extraction has settled for the current source (bitmap
58
68
  // arrived OR extraction failed). Until then, if the source has an art track,
59
69
  // we hold the audio-strip layout off so the player doesn't flash strip→cover.
@@ -62,6 +72,7 @@ export class MoviElement extends HTMLElement {
62
72
  isOverControls = false;
63
73
  isSeeking = false;
64
74
  pendingSeekTarget = null; // Coalesces rapid currentTime sets while a seek is in flight
75
+ _pendingSeek = null; // Seek requested before the player was ready; applied on the next seekable state
65
76
  isDragging = false;
66
77
  isTouchDragging = false;
67
78
  // Gesture tracking
@@ -98,6 +109,14 @@ export class MoviElement extends HTMLElement {
98
109
  // wire any custom protocol (WebSocket, WebRTC data channel, IndexedDB,
99
110
  // bespoke encryption, etc.) into the element without losing the UI.
100
111
  _sourceAdapter = null;
112
+ // Custom HTTP headers applied to all media network requests (manifest +
113
+ // segments for streams, progressive downloads too). Set declaratively via the
114
+ // `headers` attribute (JSON) or programmatically via the `headers` property.
115
+ _headers = null;
116
+ // Audio-only (data-saver) mode: skip video decode (CPU) and, for adaptive
117
+ // streams, fetch only audio (bandwidth). Toggleable via the `audioonly`
118
+ // attribute or the `audioOnly` property. The UI shows the album-art / strip.
119
+ _audioOnly = false;
101
120
  _audioSrc = null; // Separate audio source URL
102
121
  // Pre-muxed video qualities declared via multiple <source> tags with
103
122
  // data-height / data-label. Lets the player drive a YouTube-style quality
@@ -112,6 +131,10 @@ export class MoviElement extends HTMLElement {
112
131
  // over the first frame during the brief pre-play startup window even
113
132
  // though the player is about to start on its own.
114
133
  _autoplayStarting = false;
134
+ // Autoplay was requested while the tab was hidden — deferred until the tab
135
+ // is visible (a backgrounded first-play seek gets throttled and stalls in
136
+ // buffering). _onVisibilityChange starts it when the tab is shown.
137
+ _autoplayPendingVisible = false;
115
138
  // True when autoplay-with-sound was blocked by the browser and we fell
116
139
  // back to muted playback (YouTube/Twitter behaviour). Unlike the `muted`
117
140
  // attribute, this is a runtime decision the user never asked for — so the
@@ -171,6 +194,10 @@ export class MoviElement extends HTMLElement {
171
194
  _objectFit = "contain"; // Configuration mode
172
195
  _currentFit = "contain"; // Actual fit being applied
173
196
  _thumb = false;
197
+ // True once the source falls back to linear (forward-only) playback: server
198
+ // has no Range support and the file is too big to cache whole. Hides the
199
+ // timeline and disables seeking + thumbnails.
200
+ _linearMode = false;
174
201
  _hdr = true; // HDR enabled by default
175
202
  _theme = "dark"; // Default theme
176
203
  _sw = "auto"; // Preferred decoder mode (auto or software)
@@ -279,6 +306,13 @@ export class MoviElement extends HTMLElement {
279
306
  if (!lost && this._contextLostTime === 0) {
280
307
  this._hideSnapshotPoster();
281
308
  }
309
+ // An autoplay deferred while the tab was hidden — start it now that the
310
+ // tab is visible, so the first-play seek runs with rAF/decoding active
311
+ // instead of stalling in a throttled background.
312
+ if (this._autoplayPendingVisible && this.player && !this._isUnsupported) {
313
+ this._autoplayPendingVisible = false;
314
+ void this._startAutoplay();
315
+ }
282
316
  }
283
317
  };
284
318
  // Observed attributes (native video element attributes)
@@ -328,13 +362,21 @@ export class MoviElement extends HTMLElement {
328
362
  "videoid",
329
363
  "drm",
330
364
  "licenseurl",
365
+ "licenseheaders",
366
+ "headers",
367
+ "audioonly",
368
+ "lcevc",
369
+ "lcevcurl",
331
370
  "postertime",
332
371
  ];
333
372
  }
334
373
  constructor() {
335
374
  super();
336
- // Enable keyboard focus
337
- this.tabIndex = 0;
375
+ // NOTE: do NOT set attributes (or anything that reflects to one, e.g.
376
+ // `this.tabIndex`) here. The Custom Elements spec forbids a constructor
377
+ // from gaining attributes, so `document.createElement("movi-player")`
378
+ // throws `NotSupportedError: The result must not have attributes`.
379
+ // Keyboard focus is enabled in connectedCallback instead. (issue #9)
338
380
  // Set log level to INFO by default (change to DEBUG for troubleshooting)
339
381
  Logger.setLevel(LogLevel.DEBUG);
340
382
  // Create shadow DOM for encapsulation
@@ -413,7 +455,16 @@ export class MoviElement extends HTMLElement {
413
455
  this.coverArtOverlay.style.zIndex = "1";
414
456
  this.coverArtOverlay.style.pointerEvents = "none";
415
457
  this.coverArtOverlay.style.background = "#000";
458
+ this.coverArtOverlay.style.overflow = "hidden"; // contain the blur bleed
459
+ // Blurred backdrop via CSS filter:blur() — a real Gaussian in EVERY browser
460
+ // (Chrome/Firefox/Safari incl. old versions), unlike canvas ctx.filter which
461
+ // Safari < 17 ignores. Sits behind the sharp-art canvas.
462
+ this._coverArtBgEl = document.createElement("div");
463
+ this._coverArtBgEl.className = "movi-cover-art-bg";
464
+ this.coverArtOverlay.appendChild(this._coverArtBgEl);
416
465
  this.coverArtCanvas = document.createElement("canvas");
466
+ this.coverArtCanvas.style.position = "absolute";
467
+ this.coverArtCanvas.style.inset = "0";
417
468
  this.coverArtCanvas.style.width = "100%";
418
469
  this.coverArtCanvas.style.height = "100%";
419
470
  this.coverArtCanvas.style.display = "block";
@@ -854,6 +905,9 @@ export class MoviElement extends HTMLElement {
854
905
  <span class="movi-current-time">0:00</span>
855
906
  <span class="movi-time-separator"> / </span>
856
907
  <span class="movi-duration">0:00</span>
908
+ <button class="movi-live-badge" type="button" aria-label="Go to live" title="Go to live edge">
909
+ <span class="movi-live-dot"></span>LIVE
910
+ </button>
857
911
  </div>
858
912
  </div>
859
913
 
@@ -1271,6 +1325,14 @@ export class MoviElement extends HTMLElement {
1271
1325
  this.loop = !this.loop;
1272
1326
  this.showOSD(OSD.loop, this.loop ? "Loop On" : "Loop Off");
1273
1327
  });
1328
+ // LIVE badge → jump to the live edge and resume.
1329
+ const liveBadge = shadowRoot.querySelector(".movi-live-badge");
1330
+ liveBadge?.addEventListener("click", (e) => {
1331
+ e.stopPropagation();
1332
+ this.player?.seekToLive?.();
1333
+ this.play();
1334
+ this.updateLiveState();
1335
+ });
1274
1336
  // Stable Audio Toggle
1275
1337
  const stableAudioBtn = shadowRoot.querySelector(".movi-stable-audio-btn");
1276
1338
  stableAudioBtn?.addEventListener("click", (e) => {
@@ -1401,7 +1463,7 @@ export class MoviElement extends HTMLElement {
1401
1463
  if (!showPreview) {
1402
1464
  return;
1403
1465
  }
1404
- const time = percent * duration;
1466
+ const time = this.barFractionToTime(percent);
1405
1467
  thumbnailTime.textContent = this.formatTime(time);
1406
1468
  // Show chapter title if hovering over a chapter
1407
1469
  const chapterTitleEl = shadowRoot.querySelector(".movi-seek-chapter-title");
@@ -1419,7 +1481,16 @@ export class MoviElement extends HTMLElement {
1419
1481
  chapterTitleEl.style.display = "none";
1420
1482
  }
1421
1483
  }
1422
- if (this._thumb) {
1484
+ // In linear (non-range) playback only frames inside the buffered RAM
1485
+ // window can be previewed. Outside it the thumbnailer either fails
1486
+ // (behind — bytes discarded) or clamps to the last buffered keyframe and
1487
+ // returns a STALE frame (ahead — not yet downloaded). Suppress the
1488
+ // preview there so a wrong/old thumbnail never shows.
1489
+ const previewInWindow = !this._linearMode ||
1490
+ !this.player ||
1491
+ (time >= (this.player.getBufferStartTime?.() ?? 0) &&
1492
+ time <= (this.player.getBufferEndTime?.() ?? duration));
1493
+ if (this._thumb && previewInWindow) {
1423
1494
  requestPreview(time);
1424
1495
  }
1425
1496
  else {
@@ -1600,10 +1671,15 @@ export class MoviElement extends HTMLElement {
1600
1671
  // matchMedia is reliable across browsers; pointerType on click events is
1601
1672
  // not (Android Chrome can synthesize click with pointerType="mouse" from
1602
1673
  // a touch tap, which previously fell through to mute on the first tap).
1674
+ // But hybrid / emulated devices can report hover-capable even when the
1675
+ // tap is genuinely touch, so also honour the recent-touchstart signal the
1676
+ // rest of the player uses — otherwise the first tap mutes instead of
1677
+ // opening the slider.
1603
1678
  const noHover = window.matchMedia("(hover: none)").matches;
1679
+ const fromTouch = noHover || Date.now() - this.lastTouchTime < 1000;
1604
1680
  const volumeContainer = shadowRoot.querySelector(".movi-volume-container");
1605
1681
  // Touch / mobile: first tap opens the slider, second tap mutes.
1606
- if (noHover && volumeContainer) {
1682
+ if (fromTouch && volumeContainer) {
1607
1683
  if (!volumeContainer.classList.contains("active")) {
1608
1684
  volumeContainer.classList.add("active");
1609
1685
  const closeVolume = (evt) => {
@@ -2086,7 +2162,11 @@ export class MoviElement extends HTMLElement {
2086
2162
  const duration = this.duration;
2087
2163
  if (duration <= 0)
2088
2164
  return;
2089
- const time = percent * duration;
2165
+ // Live: map the fraction into the DVR window. Otherwise (linear mode)
2166
+ // clamp the seek target to the buffered RAM window.
2167
+ const time = this.player?.isLiveStream?.()
2168
+ ? this.barFractionToTime(percent)
2169
+ : this.clampToBufferedWindow(percent * duration);
2090
2170
  const wasPlaying = state === "playing";
2091
2171
  this.isSeeking = true;
2092
2172
  try {
@@ -2142,7 +2222,11 @@ export class MoviElement extends HTMLElement {
2142
2222
  const duration = this.duration;
2143
2223
  if (duration <= 0)
2144
2224
  return;
2145
- const time = percent * duration;
2225
+ // Live: map the fraction into the DVR window. Otherwise (linear mode)
2226
+ // clamp the seek target to the buffered RAM window.
2227
+ const time = this.player?.isLiveStream?.()
2228
+ ? this.barFractionToTime(percent)
2229
+ : this.clampToBufferedWindow(percent * duration);
2146
2230
  const wasPlaying = state === "playing";
2147
2231
  this.isSeeking = true;
2148
2232
  try {
@@ -2190,7 +2274,8 @@ export class MoviElement extends HTMLElement {
2190
2274
  target.closest(".movi-btn")) {
2191
2275
  return;
2192
2276
  }
2193
- // If double tap is disabled, handle as single tap immediately (simple toggle)
2277
+ // If double tap is disabled, handle as single tap immediately (simple
2278
+ // toggle). In linear mode the skip seek clamps to the buffered window.
2194
2279
  if (!this._doubleTap) {
2195
2280
  if (tapTimer)
2196
2281
  clearTimeout(tapTimer);
@@ -2573,7 +2658,8 @@ export class MoviElement extends HTMLElement {
2573
2658
  }
2574
2659
  case "ArrowLeft":
2575
2660
  // Left Arrow: Seek backward 5 seconds or single frame (if Ctrl)
2576
- // Only enabled if fastseek is true
2661
+ // Only enabled if fastseek is true. In linear mode the seek clamps to
2662
+ // the buffered RAM window.
2577
2663
  if (!this._fastSeek)
2578
2664
  break;
2579
2665
  e.preventDefault();
@@ -2594,7 +2680,8 @@ export class MoviElement extends HTMLElement {
2594
2680
  break;
2595
2681
  case "ArrowRight":
2596
2682
  // Right Arrow: Seek forward 5 seconds or single frame (if Ctrl)
2597
- // Only enabled if fastseek is true
2683
+ // Only enabled if fastseek is true. In linear mode the seek clamps to
2684
+ // the buffered RAM window.
2598
2685
  if (!this._fastSeek)
2599
2686
  break;
2600
2687
  e.preventDefault();
@@ -2986,6 +3073,15 @@ export class MoviElement extends HTMLElement {
2986
3073
  e.stopPropagation();
2987
3074
  return false;
2988
3075
  }
3076
+ // No `controls` attribute → the player exposes no UI, so the custom
3077
+ // context menu (Play/Pause/Speed/…) shouldn't appear either. Suppress the
3078
+ // event (the browser default is already blocked by the canvas/video
3079
+ // oncontextmenu handlers) so right-click is a clean no-op.
3080
+ if (!this._controls) {
3081
+ e.preventDefault();
3082
+ e.stopPropagation();
3083
+ return false;
3084
+ }
2989
3085
  Logger.debug(TAG, "[ContextMenu] preventDefaultContextMenu called", {
2990
3086
  type: e.type,
2991
3087
  target: e.target,
@@ -4074,7 +4170,7 @@ export class MoviElement extends HTMLElement {
4074
4170
  return;
4075
4171
  const rect = progressBar.getBoundingClientRect();
4076
4172
  const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
4077
- this.currentTime = pct * this.duration;
4173
+ this.currentTime = this.barFractionToTime(pct);
4078
4174
  });
4079
4175
  // Update progress + time on interval
4080
4176
  const pipUpdateInterval = setInterval(() => {
@@ -4367,17 +4463,18 @@ export class MoviElement extends HTMLElement {
4367
4463
  const qualityContainer = this.shadowRoot?.querySelector(".movi-quality-container");
4368
4464
  if (!qualityList || !qualityBtn || !qualityContainer)
4369
4465
  return;
4370
- // Only show quality menu for HLS streams (URLs ending in .m3u8)
4371
- // Local files or single-file URLs should not show quality selection
4372
- const isHLS = typeof this._src === "string" &&
4373
- (this._src.includes(".m3u8") || this._src.toLowerCase().endsWith("m3u8"));
4466
+ // Only show the quality menu for adaptive streams (HLS .m3u8 / DASH .mpd).
4467
+ // Local files or single-file URLs should not show quality selection.
4468
+ const isAdaptive = typeof this._src === "string" &&
4469
+ (this._src.toLowerCase().includes(".m3u8") ||
4470
+ this._src.toLowerCase().includes(".mpd"));
4374
4471
  // Pre-muxed multi-quality path: build a virtual track list from the
4375
- // declarative <source> tags so the picker works without HLS.
4376
- if (!isHLS && this._videoQualities.length > 1) {
4472
+ // declarative <source> tags so the picker works without an adaptive stream.
4473
+ if (!isAdaptive && this._videoQualities.length > 1) {
4377
4474
  this.renderPremuxedQualityMenu(qualityList, qualityContainer);
4378
4475
  return;
4379
4476
  }
4380
- if (!isHLS) {
4477
+ if (!isAdaptive) {
4381
4478
  qualityContainer.style.display = "none";
4382
4479
  return;
4383
4480
  }
@@ -4437,18 +4534,16 @@ export class MoviElement extends HTMLElement {
4437
4534
  }
4438
4535
  qualityContainer.style.display = "flex";
4439
4536
  const activeTrack = this.player.trackManager?.getActiveVideoTrack();
4440
- // For HLS Auto mode, the active track id is -1 with height 0; surface the
4441
- // currently-playing level's badge instead so the gear stays informative.
4442
- // MoviPlayer exposes the wrapper as `hlsWrapper`; the previous `.hls`
4443
- // path silently resolved to undefined, leaving the badge blank in Auto.
4537
+ // In Auto/ABR mode the active track id is -1 with height 0; surface the
4538
+ // currently-playing rendition's dimensions instead so the gear badge stays
4539
+ // informative. Shaka backs both HLS and DASH via the unified streamWrapper.
4444
4540
  let activeHeight = activeTrack?.height || 0;
4445
4541
  let activeWidth = activeTrack?.width || 0;
4446
4542
  if (!activeHeight && activeTrack?.id === -1) {
4447
4543
  try {
4448
- const hls = this.player.hlsWrapper?.hls;
4449
- const hlsLevel = hls?.levels?.[hls?.currentLevel];
4450
- activeHeight = hlsLevel?.height || 0;
4451
- activeWidth = hlsLevel?.width || 0;
4544
+ const res = this.player.streamWrapper?.getActiveResolution?.();
4545
+ activeHeight = res?.height || 0;
4546
+ activeWidth = res?.width || 0;
4452
4547
  }
4453
4548
  catch { }
4454
4549
  }
@@ -4456,7 +4551,12 @@ export class MoviElement extends HTMLElement {
4456
4551
  qualityList.innerHTML = uniqueTracks
4457
4552
  .map((track) => {
4458
4553
  const isActive = activeTrack?.id === track.id;
4459
- const label = track.label || (track.height ? `${track.height}p` : "Auto");
4554
+ let label = track.label || (track.height ? `${track.height}p` : "Auto");
4555
+ // In Auto mode, show the currently-playing rendition in brackets —
4556
+ // e.g. "Auto (720p)" — so the user sees what ABR is actually serving.
4557
+ if (track.id === -1 && activeTrack?.id === -1 && activeHeight > 0) {
4558
+ label = `Auto (${activeHeight}p)`;
4559
+ }
4460
4560
  const h = track.height || 0;
4461
4561
  const w = track.width || 0;
4462
4562
  const eff = w > 0 ? Math.max(h, Math.round(w * 9 / 16)) : h;
@@ -5818,6 +5918,69 @@ export class MoviElement extends HTMLElement {
5818
5918
  });
5819
5919
  });
5820
5920
  }
5921
+ /**
5922
+ * True when an actual media source is loaded (plain src, a caller-supplied
5923
+ * SourceAdapter, or the encrypted video URL). Used to gate the controls
5924
+ * auto-hide — in the empty "No Video" state we keep the bar pinned.
5925
+ */
5926
+ hasMediaSource() {
5927
+ return (!!this._src ||
5928
+ !!this._sourceAdapter ||
5929
+ (this._encrypted && !!this._videoUrl));
5930
+ }
5931
+ /**
5932
+ * Switch the UI into linear (forward-only) playback: the source has no Range
5933
+ * support and is too big to cache, so seeking is impossible. Hide the
5934
+ * timeline and disable seek + thumbnail previews via the `movi-linear` host
5935
+ * class. Idempotent — safe to call from both the event and the loadEnd
5936
+ * backstop.
5937
+ */
5938
+ /**
5939
+ * In linear (non-seekable-source) playback only the bytes currently in the
5940
+ * RAM window are reachable, so clamp any seek target to the buffered time
5941
+ * range [bufferStart, bufferEnd]. A small margin keeps the (approximate,
5942
+ * linear byte→time) clamp safely inside the window so the keyframe for the
5943
+ * target is actually present. Returns the time unchanged when not linear.
5944
+ */
5945
+ clampToBufferedWindow(time) {
5946
+ if (!this._linearMode || !this.player)
5947
+ return time;
5948
+ // Backward edge: getSeekableStartTime already bakes in a byte safety margin
5949
+ // so the seek's keyframe lands inside the window (the linear time→byte
5950
+ // estimate alone is too optimistic — GOP + VBR push the keyframe lower).
5951
+ const lo = this.player.getSeekableStartTime?.()
5952
+ ?? (this.player.getBufferStartTime?.() ?? 0);
5953
+ const end = this.player.getBufferEndTime?.() ?? this.duration;
5954
+ if (!(end > lo))
5955
+ return time;
5956
+ const hi = Math.max(lo, end - Math.max(1, (end - lo) * 0.02));
5957
+ return Math.min(hi, Math.max(lo, time));
5958
+ }
5959
+ enterLinearMode() {
5960
+ if (this._linearMode)
5961
+ return;
5962
+ this._linearMode = true;
5963
+ this.classList.add("movi-linear");
5964
+ // If the seek-dependent timeline strip happens to be open, close it — it
5965
+ // can't generate frames without random access.
5966
+ const panel = this.shadowRoot?.querySelector(".movi-timeline-panel");
5967
+ if (panel && panel.style.display !== "none")
5968
+ panel.style.display = "none";
5969
+ }
5970
+ /**
5971
+ * Mirror "is the controls bar currently taking up bottom space?" onto a
5972
+ * plain host class so the empty-state placeholder can re-center reliably.
5973
+ * The old approach keyed off `:host:has(.movi-controls-hidden)` /
5974
+ * `:host(:not([controls]))`, but `:has()` is flaky in Safari/Firefox (and
5975
+ * an unsupported selector in a comma list drops the WHOLE rule), which left
5976
+ * the "No Video" text stuck off-center there. `:host(.movi-bar-collapsed)`
5977
+ * is universally supported, so the centering works in every browser.
5978
+ */
5979
+ syncBarCollapsedClass() {
5980
+ const collapsed = !this._controls ||
5981
+ !!this.controlsContainer?.classList.contains("movi-controls-hidden");
5982
+ this.classList.toggle("movi-bar-collapsed", collapsed);
5983
+ }
5821
5984
  showControls() {
5822
5985
  if (!this._controls)
5823
5986
  return;
@@ -5871,6 +6034,7 @@ export class MoviElement extends HTMLElement {
5871
6034
  if (container) {
5872
6035
  container.classList.add("movi-controls-visible");
5873
6036
  container.classList.remove("movi-controls-hidden");
6037
+ this.syncBarCollapsedClass();
5874
6038
  // Restore cursor — clear the inline `none` on the host so the
5875
6039
  // CSS-driven cursor (default on the visible-controls path)
5876
6040
  // takes back over. Mirrors what hideControls does in reverse.
@@ -5910,6 +6074,16 @@ export class MoviElement extends HTMLElement {
5910
6074
  centerPlayPause.classList.add("movi-center-visible");
5911
6075
  }
5912
6076
  }
6077
+ // Bar visible → lift the centre button + loading spinner slightly to
6078
+ // balance against it (host class the CSS keys off; see hideControls).
6079
+ // But only once playback has started — on the initial load / autoplay-off
6080
+ // screen the big play button and the spinner belong at the true centre.
6081
+ if (this._hasEverPlayed) {
6082
+ this.classList.add("movi-bar-visible");
6083
+ }
6084
+ else {
6085
+ this.classList.remove("movi-bar-visible");
6086
+ }
5913
6087
  // Shift timeline panel up above controls
5914
6088
  const bar = this.shadowRoot?.querySelector(".movi-controls-bar");
5915
6089
  const barHeight = bar?.offsetHeight ?? 80;
@@ -5941,16 +6115,22 @@ export class MoviElement extends HTMLElement {
5941
6115
  this.controlsTimeout = null;
5942
6116
  }
5943
6117
  // Auto-hide whenever the player isn't "actively held open":
5944
- // 1. Not dragging the scrubber
5945
- // 2. Mouse is NOT over the controls bar
5946
- // 3. No menu (speed / audio / subtitle / quality) is open
6118
+ // 1. There IS a media source loaded
6119
+ // 2. Not dragging the scrubber
6120
+ // 3. Mouse is NOT over the controls bar
6121
+ // 4. No menu (speed / audio / subtitle / quality) is open
5947
6122
  //
5948
6123
  // Previously this only fired during `playing`, which left the
5949
6124
  // bar stuck on screen indefinitely after a pause + cursor-away.
5950
6125
  // YouTube hides the bar on pause too once you stop interacting,
5951
6126
  // so we match that — the player will only stay open while the
5952
6127
  // user is actually hovering / scrubbing / has a menu out.
5953
- if (!this.isOverControls &&
6128
+ //
6129
+ // The source gate keeps the bar pinned in the empty "No Video"
6130
+ // state — with nothing loaded there's nothing to interact with,
6131
+ // so hiding the controls would just look broken.
6132
+ if (this.hasMediaSource() &&
6133
+ !this.isOverControls &&
5954
6134
  !this.isDragging &&
5955
6135
  !this.isTouchDragging &&
5956
6136
  !this.isAnyMenuOpen()) {
@@ -6150,6 +6330,8 @@ export class MoviElement extends HTMLElement {
6150
6330
  * tracks where the playhead is *headed* even mid-seek.
6151
6331
  */
6152
6332
  performRelativeSeek(direction, step = 10) {
6333
+ // In linear mode the currentTime setter clamps the target to the buffered
6334
+ // RAM window, so skipping stays within what's seekable.
6153
6335
  const now = Date.now();
6154
6336
  const continuing = this.lastSeekSide === direction &&
6155
6337
  now - this.lastSeekTime < 1000;
@@ -6219,6 +6401,11 @@ export class MoviElement extends HTMLElement {
6219
6401
  if (container) {
6220
6402
  container.classList.remove("movi-controls-visible");
6221
6403
  container.classList.add("movi-controls-hidden");
6404
+ this.syncBarCollapsedClass();
6405
+ // Collapse the touch-expanded volume slider along with the bar — leaving
6406
+ // it open over a hidden bar looks orphaned.
6407
+ const volumeContainer = this.shadowRoot?.querySelector(".movi-volume-container");
6408
+ volumeContainer?.classList.remove("active");
6222
6409
  // Hide cursor. Setting the host element's inline cursor is the
6223
6410
  // single most reliable signal — the :has-based CSS rule below
6224
6411
  // does the right thing in spec terms, but Safari (and Chrome
@@ -6254,6 +6441,11 @@ export class MoviElement extends HTMLElement {
6254
6441
  if (centerPlayPause && state === "playing") {
6255
6442
  centerPlayPause.classList.remove("movi-center-visible");
6256
6443
  }
6444
+ // Bar hidden → centre button + loading spinner sit at the true centre.
6445
+ // (The :host:has(.movi-controls-hidden) CSS rule meant to do this doesn't
6446
+ // apply against the shadow tree in some engines, e.g. Electron's Chromium,
6447
+ // so the CSS keys off this host class we toggle instead.)
6448
+ this.classList.remove("movi-bar-visible");
6257
6449
  // Shift timeline panel down when controls hide
6258
6450
  const timelinePanel = this.shadowRoot?.querySelector(".movi-timeline-panel");
6259
6451
  if (timelinePanel) {
@@ -6300,6 +6492,7 @@ export class MoviElement extends HTMLElement {
6300
6492
  if (centerPlayPause)
6301
6493
  centerPlayPause.classList.remove("movi-center-visible");
6302
6494
  }
6495
+ this.syncBarCollapsedClass();
6303
6496
  }
6304
6497
  startUIUpdates() {
6305
6498
  this.updateAspectRatioIcon();
@@ -6495,6 +6688,14 @@ export class MoviElement extends HTMLElement {
6495
6688
  -moz-osx-font-smoothing: grayscale;
6496
6689
  }
6497
6690
 
6691
+ /* The :host{display:block} above is an author rule, so it beats the
6692
+ UA stylesheet's [hidden]{display:none}. Without this, the standard
6693
+ hidden attribute does nothing and the player stays visible. Honour
6694
+ it explicitly. !important so source-order/specificity can't regress it. */
6695
+ :host([hidden]) {
6696
+ display: none !important;
6697
+ }
6698
+
6498
6699
  /* Light Theme Override */
6499
6700
  :host([theme="light"]) {
6500
6701
  --movi-primary: #7c3aed; /* Vibrancy boost for light theme */
@@ -7050,6 +7251,56 @@ export class MoviElement extends HTMLElement {
7050
7251
  font-weight: 400;
7051
7252
  }
7052
7253
 
7254
+ /* LIVE indicator — shown only for live (dynamic) streams. Clicking jumps
7255
+ to the live edge. Bright red + pulsing at the edge; dimmed grey when
7256
+ the viewer has scrubbed back into the DVR window. */
7257
+ /* Badge follows the player theme colour (themecolor / --movi-primary),
7258
+ falling back to a live-red. The dot inherits it via currentColor so the
7259
+ glow ring matches. When scrubbed back ("behind") it dims to grey. */
7260
+ .movi-live-badge {
7261
+ display: none;
7262
+ align-items: center;
7263
+ gap: 5px;
7264
+ background: none;
7265
+ border: none;
7266
+ cursor: pointer;
7267
+ padding: 2px 6px;
7268
+ border-radius: 4px;
7269
+ font: inherit;
7270
+ font-weight: 700;
7271
+ letter-spacing: 0.06em;
7272
+ color: var(--movi-primary, #ff4d4f);
7273
+ line-height: 1;
7274
+ }
7275
+ .movi-live-badge:hover { background: rgba(255, 255, 255, 0.12); }
7276
+ .movi-live-dot {
7277
+ width: 8px;
7278
+ height: 8px;
7279
+ border-radius: 50%;
7280
+ background: currentColor;
7281
+ /* Blink while at the live edge. */
7282
+ animation: movi-live-blink 1.2s ease-in-out infinite;
7283
+ }
7284
+ .movi-live-badge.behind { color: var(--movi-text-tertiary); }
7285
+ .movi-live-badge.behind .movi-live-dot {
7286
+ /* Scrubbed back into the DVR window → steady grey dot, no blink. */
7287
+ animation: none;
7288
+ opacity: 1;
7289
+ }
7290
+ @keyframes movi-live-blink {
7291
+ 0%, 100% { opacity: 1; }
7292
+ 50% { opacity: 0.15; }
7293
+ }
7294
+ @media (prefers-reduced-motion: reduce) {
7295
+ .movi-live-dot { animation: none; }
7296
+ }
7297
+ /* Live mode: wall-clock duration is meaningless (Infinity) — show only
7298
+ the LIVE badge, hide the running time / separator / duration. */
7299
+ :host(.movi-live) .movi-current-time,
7300
+ :host(.movi-live) .movi-time-separator,
7301
+ :host(.movi-live) .movi-duration { display: none; }
7302
+ :host(.movi-live) .movi-live-badge { display: inline-flex; }
7303
+
7053
7304
  .movi-progress-container {
7054
7305
  width: 100%;
7055
7306
  padding: 10px 0 15px;
@@ -9033,12 +9284,13 @@ export class MoviElement extends HTMLElement {
9033
9284
  height: 64px !important;
9034
9285
  }
9035
9286
 
9036
- /* Disable animations on mobile */
9287
+ /* Keyframe animations off on small/compact layouts for perf, but keep
9288
+ CSS transitions so the controls + centre button still move smoothly
9289
+ here too (the PiP / mini player is a small container). */
9037
9290
  .movi-controls-overlay,
9038
9291
  .movi-center-play-pause,
9039
9292
  .movi-btn,
9040
9293
  .movi-progress-handle {
9041
- transition: none !important;
9042
9294
  animation: none !important;
9043
9295
  transform: none !important;
9044
9296
  }
@@ -9530,7 +9782,7 @@ export class MoviElement extends HTMLElement {
9530
9782
  button's bar-hidden and both-bars-visible rules exactly. */
9531
9783
  .movi-loading-indicator {
9532
9784
  position: absolute;
9533
- top: calc(50% - var(--movi-controls-height) / 2);
9785
+ top: 50%;
9534
9786
  left: 50%;
9535
9787
  transform: translate(-50%, -50%);
9536
9788
  display: flex;
@@ -9539,15 +9791,7 @@ export class MoviElement extends HTMLElement {
9539
9791
  z-index: 1000;
9540
9792
  pointer-events: none;
9541
9793
  background: transparent;
9542
- }
9543
-
9544
- :host:has(.movi-controls-container.movi-controls-hidden) .movi-loading-indicator,
9545
- :host(:not([controls])) .movi-loading-indicator {
9546
- top: 50%;
9547
- }
9548
-
9549
- :host:has(.movi-controls-container.movi-controls-visible):has(.movi-title-bar.movi-title-visible) .movi-loading-indicator {
9550
- top: calc(50% - (var(--movi-controls-height) - 52px) / 2);
9794
+ transition: top var(--movi-transition-normal);
9551
9795
  }
9552
9796
 
9553
9797
  .movi-loader-container {
@@ -9589,7 +9833,7 @@ export class MoviElement extends HTMLElement {
9589
9833
  down adjust this for the other two layout states. */
9590
9834
  .movi-center-play-pause {
9591
9835
  position: absolute;
9592
- top: calc(50% - var(--movi-controls-height) / 2);
9836
+ top: 50%;
9593
9837
  left: 50%;
9594
9838
  transform: translate(-50%, -50%) scale(0.8);
9595
9839
  z-index: 5;
@@ -9618,23 +9862,15 @@ export class MoviElement extends HTMLElement {
9618
9862
  transition-delay: 0s;
9619
9863
  }
9620
9864
 
9621
- /* Bar hidden or no controls attribute no chrome to balance
9622
- against, sit at true geometric centre. */
9623
- :host:has(.movi-controls-container.movi-controls-hidden) .movi-center-play-pause,
9624
- :host(:not([controls])) .movi-center-play-pause {
9625
- top: 50%;
9626
- }
9627
-
9628
- /* Bar visible AND title bar visible centre the icon inside
9629
- the visible video band (between title bottom and controls
9630
- top), not the full player. The 44% default biases too far up
9631
- when there's also a title bar to balance the controls; here
9632
- the offset is just half the *difference* between the two
9633
- chrome heights (title ≈ 52px, controls 72px → ~10px above
9634
- centre on desktop). calc() keeps it right on mobile where
9635
- --movi-controls-height drops to 64px too. */
9636
- :host:has(.movi-controls-container.movi-controls-visible):has(.movi-title-bar.movi-title-visible) .movi-center-play-pause {
9637
- top: calc(50% - (var(--movi-controls-height) - 52px) / 2);
9865
+ /* Default (the base rule) is the true geometric centre. When the controls
9866
+ bar is visible, lift the centre button AND the loading spinner a little
9867
+ so they read as centred in the band above the bar. Keyed off a host
9868
+ class toggled in show/hideControls, because :host:has() checks against
9869
+ the shadow tree don't apply in every engine (notably Electron). The
9870
+ transition on top (in each base rule) animates the move. */
9871
+ :host(.movi-bar-visible) .movi-center-play-pause,
9872
+ :host(.movi-bar-visible) .movi-loading-indicator {
9873
+ top: calc(50% - var(--movi-controls-height) / 4);
9638
9874
  }
9639
9875
 
9640
9876
  .movi-center-play-pause:hover {
@@ -10469,6 +10705,17 @@ export class MoviElement extends HTMLElement {
10469
10705
  box-sizing: border-box;
10470
10706
  opacity: 0;
10471
10707
  animation: movi-fade-in 0.4s ease forwards;
10708
+ /* Animate the re-centering when the controls bar shows/hides, so the
10709
+ placeholder glides up/down in sync with the bar instead of jumping. */
10710
+ transition: padding-bottom var(--movi-transition-normal);
10711
+ }
10712
+
10713
+ /* No controls bar at the bottom (controls disabled, or auto-hidden) —
10714
+ drop the reserved bottom space so the placeholder centers truly.
10715
+ Driven by the JS-toggled .movi-bar-collapsed host class rather than
10716
+ :has()/:host(:not(...)), which are flaky in Safari/Firefox. */
10717
+ :host(.movi-bar-collapsed) .movi-empty-state {
10718
+ padding-bottom: 40px;
10472
10719
  }
10473
10720
 
10474
10721
  /* Short / narrow players: shrink the placeholder so it fits above
@@ -10477,6 +10724,9 @@ export class MoviElement extends HTMLElement {
10477
10724
  .movi-empty-state {
10478
10725
  padding: 16px 16px calc(var(--movi-controls-height) + 12px);
10479
10726
  }
10727
+ :host(.movi-bar-collapsed) .movi-empty-state {
10728
+ padding-bottom: 16px;
10729
+ }
10480
10730
  .movi-empty-container {
10481
10731
  gap: 8px !important;
10482
10732
  }
@@ -10895,6 +11145,13 @@ export class MoviElement extends HTMLElement {
10895
11145
  order: 2 !important;
10896
11146
  flex: 0 0 auto !important;
10897
11147
  }
11148
+ /* Linear playback (no Range support, over-cap file): the scrubber and
11149
+ skip buttons stay — seeking is allowed but clamped in JS to the
11150
+ buffered RAM window. Only the timeline *strip* (thumbnail grid across
11151
+ the whole file) is dropped, since it needs full random access. */
11152
+ :host(.movi-linear) .movi-context-menu-item[data-action="timeline"] {
11153
+ display: none !important;
11154
+ }
10898
11155
  /* Keep the right-side cluster visible but trim it to controls that
10899
11156
  actually apply to audio playback. Subtitles, HDR, quality, PiP,
10900
11157
  fullscreen, and rotation are video-only — hiding the buttons
@@ -10957,6 +11214,27 @@ export class MoviElement extends HTMLElement {
10957
11214
  :host(.movi-audio-mode) .movi-fullscreen-btn {
10958
11215
  display: none !important;
10959
11216
  }
11217
+ /* In audio mode the album art is painted by the cover-art canvas (which
11218
+ persists through playback, unlike the pre-play poster overlay) — hide
11219
+ the raw poster <img> so it doesn't double up over the canvas. The
11220
+ poster URL still feeds the canvas via ensurePosterCoverArt(). */
11221
+ :host(.movi-audio-mode) .movi-poster-overlay {
11222
+ display: none !important;
11223
+ }
11224
+ /* Cover-art blurred backdrop. CSS filter:blur() is a real Gaussian in
11225
+ every browser (Chrome/Firefox/Safari, including old Safari) — unlike
11226
+ canvas ctx.filter, which Safari < 17 ignores. Oversized + scaled so the
11227
+ blur's soft edges stay outside the (overflow:hidden) overlay. */
11228
+ .movi-cover-art-bg {
11229
+ position: absolute;
11230
+ inset: -12%;
11231
+ background-size: cover;
11232
+ background-position: center;
11233
+ background-repeat: no-repeat;
11234
+ filter: blur(40px) brightness(0.5);
11235
+ transform: scale(1.18);
11236
+ will-change: filter;
11237
+ }
10960
11238
  /* Strip-mode keyboard-shortcuts panel: the base CSS centres it
10961
11239
  inside the host via position:absolute + top:50%/left:50%, but
10962
11240
  the host is only 56px tall in strip mode so the panel lands
@@ -11233,6 +11511,11 @@ export class MoviElement extends HTMLElement {
11233
11511
  });
11234
11512
  };
11235
11513
  connectedCallback() {
11514
+ // Enable keyboard focus. Set here (not in the constructor) because
11515
+ // assigning tabIndex reflects to a `tabindex` attribute, which a custom
11516
+ // element constructor is not allowed to do. (issue #9)
11517
+ if (!this.hasAttribute("tabindex"))
11518
+ this.tabIndex = 0;
11236
11519
  // Read initial attributes
11237
11520
  const srcAttr = this.getAttribute("src");
11238
11521
  this._src = srcAttr || null;
@@ -11373,6 +11656,7 @@ export class MoviElement extends HTMLElement {
11373
11656
  this._videoId = this.getAttribute("videoid") || "";
11374
11657
  this._resume = this.hasAttribute("resume");
11375
11658
  this._stableVolume = this.hasAttribute("stablevolume");
11659
+ this._audioOnly = this.hasAttribute("audioonly");
11376
11660
  // If no src attribute, check for <source> child elements (Video.js-style)
11377
11661
  if (!this._src && !this._encrypted) {
11378
11662
  const sourceEls = this.querySelectorAll("source");
@@ -11695,7 +11979,7 @@ export class MoviElement extends HTMLElement {
11695
11979
  this._hasEverPlayed = false;
11696
11980
  // Show/hide empty state indicator based on src
11697
11981
  if (this.emptyStateIndicator) {
11698
- if (!this._src && !this.player) {
11982
+ if (!this._src && !this.player && !this._isUnsupported) {
11699
11983
  this.emptyStateIndicator.style.display = "flex";
11700
11984
  }
11701
11985
  else {
@@ -11712,6 +11996,41 @@ export class MoviElement extends HTMLElement {
11712
11996
  }
11713
11997
  break;
11714
11998
  }
11999
+ case "headers": {
12000
+ // Declarative form of the `headers` property — a JSON object string,
12001
+ // e.g. headers='{"Authorization":"Bearer <token>"}'. Applied to all
12002
+ // media network requests (manifest + segments + progressive). The
12003
+ // property setter (object form) is preferred for non-trivial maps.
12004
+ if (!newValue) {
12005
+ this._headers = null;
12006
+ }
12007
+ else {
12008
+ try {
12009
+ const parsed = JSON.parse(newValue);
12010
+ this._headers =
12011
+ parsed && typeof parsed === "object" ? parsed : null;
12012
+ }
12013
+ catch {
12014
+ Logger.warn(TAG, "Invalid headers JSON attribute; ignoring");
12015
+ this._headers = null;
12016
+ }
12017
+ }
12018
+ if (this.isConnected && (this._src || this._sourceAdapter)) {
12019
+ this.load();
12020
+ }
12021
+ break;
12022
+ }
12023
+ case "audioonly": {
12024
+ // Data-saver toggle: skip video decode (CPU) and, for adaptive streams,
12025
+ // fetch only audio (bandwidth). Demuxer flips live; streams reload.
12026
+ const enabled = newValue !== null;
12027
+ if (enabled === this._audioOnly)
12028
+ break;
12029
+ this._audioOnly = enabled;
12030
+ if (this.isConnected)
12031
+ this.applyAudioOnly();
12032
+ break;
12033
+ }
11715
12034
  case "autoplay":
11716
12035
  this._autoplay = newValue !== null;
11717
12036
  this.updateUnmuteOverlay();
@@ -11932,6 +12251,27 @@ export class MoviElement extends HTMLElement {
11932
12251
  this.updateCoverArtOverlay();
11933
12252
  }
11934
12253
  }
12254
+ /**
12255
+ * Toggle the LIVE indicator for live (dynamic) adaptive streams. Adds the
12256
+ * `.movi-live` host class (CSS shows the badge + hides the meaningless
12257
+ * wall-clock duration), and marks the badge `.behind` when the viewer has
12258
+ * scrubbed back into the DVR window so it reads grey instead of pulsing red.
12259
+ * Cheap + idempotent — safe to call on every timeupdate.
12260
+ */
12261
+ updateLiveState() {
12262
+ const isLive = !!this.player?.isLiveStream?.();
12263
+ this.classList.toggle("movi-live", isLive);
12264
+ if (!isLive)
12265
+ return;
12266
+ const badge = this.shadowRoot?.querySelector(".movi-live-badge");
12267
+ if (!badge)
12268
+ return;
12269
+ const edge = this.player?.getLiveEdge?.() ?? 0;
12270
+ const cur = this.player?.getCurrentTime?.() ?? 0;
12271
+ // >12s behind the edge → "behind" (not live). Matches YouTube's threshold.
12272
+ const behind = isFinite(edge) && edge - cur > 12;
12273
+ badge.classList.toggle("behind", behind);
12274
+ }
11935
12275
  /**
11936
12276
  * Paint embedded cover art (audio-only sources). Shows when:
11937
12277
  * - the player has emitted a coverart bitmap, AND
@@ -11943,12 +12283,82 @@ export class MoviElement extends HTMLElement {
11943
12283
  * with the sharp artwork centred and sized to ~60% of the smaller host
11944
12284
  * dimension. Matches the YouTube Music / Apple Music aesthetic.
11945
12285
  */
12286
+ /**
12287
+ * For an audio-only source with a `poster` URL and no embedded album art,
12288
+ * lazily load the poster into an ImageBitmap so updateCoverArtOverlay can
12289
+ * paint it as album art (via the same blurred-backdrop cover-art canvas).
12290
+ * Idempotent: loads once per URL, clears when no longer applicable (source
12291
+ * became video, poster cleared, or embedded art arrived). Re-runs
12292
+ * updateCoverArtOverlay once the bitmap is ready.
12293
+ */
12294
+ ensurePosterCoverArt(audioMode) {
12295
+ const url = audioMode && !this.coverArtBitmap
12296
+ ? this._poster || this._generatedPosterUrl || ""
12297
+ : "";
12298
+ if (!url) {
12299
+ // No longer applicable — drop any loaded poster-cover bitmap.
12300
+ if (this._posterCoverBitmap) {
12301
+ try {
12302
+ this._posterCoverBitmap.close();
12303
+ }
12304
+ catch { }
12305
+ this._posterCoverBitmap = null;
12306
+ }
12307
+ this._posterCoverUrl = "";
12308
+ this._posterCoverLoading = false;
12309
+ return;
12310
+ }
12311
+ // Already have it (or already loading it) for this exact URL — no-op.
12312
+ if (this._posterCoverUrl === url)
12313
+ return;
12314
+ this._posterCoverUrl = url;
12315
+ this._posterCoverLoading = true;
12316
+ const img = new Image();
12317
+ img.crossOrigin = "anonymous"; // album art must be CORS-clean to bitmap
12318
+ img.referrerPolicy = "no-referrer";
12319
+ img.decoding = "async";
12320
+ img.onload = () => {
12321
+ if (this._posterCoverUrl !== url)
12322
+ return; // superseded mid-load
12323
+ createImageBitmap(img)
12324
+ .then((bmp) => {
12325
+ if (this._posterCoverUrl !== url) {
12326
+ try {
12327
+ bmp.close();
12328
+ }
12329
+ catch { }
12330
+ ;
12331
+ return;
12332
+ }
12333
+ if (this._posterCoverBitmap) {
12334
+ try {
12335
+ this._posterCoverBitmap.close();
12336
+ }
12337
+ catch { }
12338
+ }
12339
+ this._posterCoverBitmap = bmp;
12340
+ this._posterCoverLoading = false;
12341
+ this.updateCoverArtOverlay();
12342
+ })
12343
+ .catch(() => {
12344
+ this._posterCoverLoading = false;
12345
+ this.updateCoverArtOverlay(); // fall back to the strip
12346
+ });
12347
+ };
12348
+ img.onerror = () => {
12349
+ if (this._posterCoverUrl !== url)
12350
+ return;
12351
+ this._posterCoverLoading = false;
12352
+ this._posterCoverUrl = ""; // allow a retry if the URL is set again
12353
+ this.updateCoverArtOverlay();
12354
+ };
12355
+ img.src = url;
12356
+ }
11946
12357
  updateCoverArtOverlay() {
11947
12358
  const overlay = this.coverArtOverlay;
11948
12359
  const canvas = this.coverArtCanvas;
11949
12360
  if (!overlay || !canvas)
11950
12361
  return;
11951
- const bitmap = this.coverArtBitmap;
11952
12362
  const hasVideoTrack = !!this.player?.trackManager?.getActiveVideoTrack?.();
11953
12363
  const hasAudio = !!this.player?.hasAudibleSource?.();
11954
12364
  // Two related-but-distinct host states for an audio source (audio
@@ -11962,7 +12372,33 @@ export class MoviElement extends HTMLElement {
11962
12372
  // player to a native-<audio>-style 56px control strip. Cover art
11963
12373
  // needs the full surface to paint, so strip layout is suppressed
11964
12374
  // when a bitmap is present (audio-mode still hides the controls).
11965
- const audioMode = !hasVideoTrack && hasAudio;
12375
+ // A failed load has NO tracks, so hasVideoTrack is always false and the
12376
+ // audio renderer (built at construction) makes hasAudibleSource() read true
12377
+ // — which wrongly flags a failed VIDEO/manifest as audio and, on the next
12378
+ // resize, collapses it to the 56px strip. In the error state there are no
12379
+ // tracks to trust, so decide from the src's own media type instead: a real
12380
+ // audio file stays a strip, a failed video/manifest shows full-size.
12381
+ // The SAME trap exists DURING load (idle/loading): tracks aren't resolved
12382
+ // yet, hasVideoTrack is still false, yet hasAudibleSource() already reads
12383
+ // true — so a resize mid-load (e.g. responsive layout settling, sidebar
12384
+ // toggling) collapses a still-loading VIDEO into the strip. Treat that
12385
+ // window like the error state and decide from the src's media type until
12386
+ // the real tracks arrive.
12387
+ const state = this.player?.getState?.();
12388
+ const errored = state === "error" || this._isUnsupported;
12389
+ const tracksUnresolved = !hasVideoTrack && (errored || state === "idle" || state === "loading");
12390
+ const srcIsAudio = typeof this._src === "string" &&
12391
+ this.guessMediaType(this._src).startsWith("audio/");
12392
+ // Audio-only (data-saver) forces the audio surface even on a video source —
12393
+ // we're deliberately not decoding the video, so show album art / strip.
12394
+ const audioMode = this._audioOnly ||
12395
+ (tracksUnresolved ? srcIsAudio : !hasVideoTrack && hasAudio);
12396
+ // Audio-only with a `poster` URL but no embedded album art: load the poster
12397
+ // into a bitmap and paint it through the cover-art canvas so it reads as
12398
+ // album art. Embedded art always wins. The effective bitmap below feeds the
12399
+ // strip decision + rendering just like real cover art.
12400
+ this.ensurePosterCoverArt(audioMode);
12401
+ const bitmap = this.coverArtBitmap ?? this._posterCoverBitmap;
11966
12402
  // VLC-like: if the source HAS an attached-picture track and previews are
11967
12403
  // on, cover art is on its way — hold the strip layout off until extraction
11968
12404
  // settles (_coverArtResolved) so we don't flash the 56px strip and then
@@ -11970,7 +12406,10 @@ export class MoviElement extends HTMLElement {
11970
12406
  // "coverart" event flips _coverArtResolved and we fall back to the strip.
11971
12407
  const hasArtTrack = (this.player?.trackManager?.getAttachedPicTracks?.()?.length ?? 0) > 0;
11972
12408
  const coverArtPending = audioMode && !bitmap && this._thumb && hasArtTrack && !this._coverArtResolved;
11973
- const stripMode = audioMode && !bitmap && !coverArtPending;
12409
+ // Same idea for a poster acting as album art: while its bitmap is still
12410
+ // loading, hold the strip off so we don't flash strip → album-art.
12411
+ const posterCoverPending = audioMode && !bitmap && this._posterCoverLoading;
12412
+ const stripMode = audioMode && !bitmap && !coverArtPending && !posterCoverPending;
11974
12413
  this.classList.toggle("movi-audio-mode", audioMode);
11975
12414
  const wasStrip = this.classList.contains("movi-audio-strip");
11976
12415
  this.classList.toggle("movi-audio-strip", stripMode);
@@ -12003,8 +12442,14 @@ export class MoviElement extends HTMLElement {
12003
12442
  expandable.appendChild(loopBtn);
12004
12443
  }
12005
12444
  }
12006
- if (!bitmap || hasVideoTrack) {
12445
+ // Show the album-art overlay only when there's art AND we're in audio mode.
12446
+ // Keying off !audioMode (not hasVideoTrack) is what makes audio-only work on
12447
+ // a VIDEO source: the video track still exists (we just don't decode it), so
12448
+ // the old hasVideoTrack guard hid the art and left a black screen.
12449
+ if (!bitmap || !audioMode) {
12007
12450
  overlay.style.display = "none";
12451
+ if (this._coverArtBgEl)
12452
+ this._coverArtBgEl.style.backgroundImage = "none";
12008
12453
  return;
12009
12454
  }
12010
12455
  const rect = this.getBoundingClientRect();
@@ -12024,27 +12469,19 @@ export class MoviElement extends HTMLElement {
12024
12469
  return;
12025
12470
  ctx.save();
12026
12471
  ctx.scale(dpr, dpr);
12027
- // Blurred backdrop. drawImage with ctx.filter is the only way to get
12028
- // a real Gaussian on a canvas. Cover the full area like background-
12029
- // size: cover.
12472
+ // Blurred backdrop is now a CSS-blurred DOM element BEHIND this canvas — a
12473
+ // real Gaussian in every browser (canvas ctx.filter "blur()" is unsupported
12474
+ // in Safari < 17 and looked bad faked). Point it at the poster URL; embedded
12475
+ // art (an ImageBitmap, currently disabled) has no URL, so it falls back to
12476
+ // the overlay's plain dark background. The canvas paints only the sharp art.
12477
+ if (this._coverArtBgEl) {
12478
+ const bgUrl = this.coverArtBitmap ? "" : this._posterCoverUrl;
12479
+ this._coverArtBgEl.style.backgroundImage = bgUrl
12480
+ ? `url("${bgUrl.replace(/"/g, '\\"')}")`
12481
+ : "none";
12482
+ }
12483
+ ctx.clearRect(0, 0, cssW, cssH); // transparent — let the blurred bg show
12030
12484
  const bitmapAR = bitmap.width / bitmap.height;
12031
- const hostAR = cssW / cssH;
12032
- let bgW, bgH, bgX, bgY;
12033
- if (bitmapAR > hostAR) {
12034
- bgH = cssH;
12035
- bgW = cssH * bitmapAR;
12036
- bgX = (cssW - bgW) / 2;
12037
- bgY = 0;
12038
- }
12039
- else {
12040
- bgW = cssW;
12041
- bgH = cssW / bitmapAR;
12042
- bgX = 0;
12043
- bgY = (cssH - bgH) / 2;
12044
- }
12045
- ctx.filter = "blur(40px) brightness(0.5)";
12046
- ctx.drawImage(bitmap, bgX, bgY, bgW, bgH);
12047
- ctx.filter = "none";
12048
12485
  // Centred sharp artwork — square, 60% of the smaller dimension. The
12049
12486
  // bitmap itself is rarely a perfect square, so contain it inside the
12050
12487
  // square frame (no crop) and let the blur backdrop fill the surround.
@@ -12070,6 +12507,30 @@ export class MoviElement extends HTMLElement {
12070
12507
  * resume() kicked off inside play() is async, so we give it a couple of
12071
12508
  * animation frames to settle before reading the state.
12072
12509
  */
12510
+ /**
12511
+ * Kick off autoplay: suppress the centre play overlay through the startup
12512
+ * window, call play(), and run the muted-autoplay fallback. Shared by the
12513
+ * initial load and the deferred (tab-was-hidden) path in _onVisibilityChange.
12514
+ */
12515
+ async _startAutoplay() {
12516
+ if (!this.player || !this._autoplay || this._isUnsupported)
12517
+ return;
12518
+ // Suppress the center play overlay through the startup window so the big
12519
+ // play icon doesn't flash before playback begins on its own.
12520
+ this._autoplayStarting = true;
12521
+ this.updatePlayPauseIcon();
12522
+ await this.player.play().catch(() => {
12523
+ // Hard play() rejection (rare for canvas mode — video keeps going even
12524
+ // when audio can't). Surface the overlay so there's something to click;
12525
+ // the suspended-audio check below still runs.
12526
+ this._autoplayStarting = false;
12527
+ this.updatePlayPauseIcon();
12528
+ });
12529
+ // Browser autoplay policy blocks audio-with-sound silently: play() resolves
12530
+ // and video rolls, but the AudioContext stays suspended. Poll the audio
12531
+ // state and fall back to muted playback + a "Tap to unmute" pill.
12532
+ this.maybeFallbackToMutedAutoplay();
12533
+ }
12073
12534
  maybeFallbackToMutedAutoplay(attempt = 0) {
12074
12535
  if (!this.player || this._muted || this._userHasUnmuted)
12075
12536
  return;
@@ -12312,14 +12773,24 @@ export class MoviElement extends HTMLElement {
12312
12773
  else {
12313
12774
  throw new Error("Invalid source type");
12314
12775
  }
12776
+ // Custom media headers: ride along on the SourceConfig (demuxer/HttpSource
12777
+ // path) AND as a top-level config field the stream wrappers read for
12778
+ // adaptive manifest + segment requests. One object, both code paths.
12779
+ if (this._headers && source && source.type === "url") {
12780
+ source.headers = this._headers;
12781
+ }
12315
12782
  // Create MoviPlayer instance
12316
12783
  // Configure MoviPlayer options
12317
12784
  const playerConfig = {
12318
12785
  source,
12319
12786
  decoder: this._sw,
12320
12787
  cache: { type: "lru", maxSizeMB: 520 },
12321
- enablePreviews: this._thumb,
12788
+ // Audio-only disables scrub-preview thumbnails too — they'd decode video
12789
+ // frames and defeat the CPU saving.
12790
+ enablePreviews: this._thumb && !this._audioOnly,
12322
12791
  ...(this._fps > 0 && { frameRate: this._fps }),
12792
+ ...(this._headers && { headers: this._headers }),
12793
+ ...(this._audioOnly && { audioOnly: true }),
12323
12794
  ...(this._sourceAdapter && { sourceAdapter: this._sourceAdapter }),
12324
12795
  };
12325
12796
  // Separate audio source — multi-language or single
@@ -12343,14 +12814,32 @@ export class MoviElement extends HTMLElement {
12343
12814
  format: t.format,
12344
12815
  }));
12345
12816
  }
12346
- // DRM mode: use native video element for HLS (EME requires <video>)
12347
- const isDrm = this.hasAttribute("drm");
12348
- const isHLS = typeof this._src === "string" &&
12349
- (this._src.includes(".m3u8") || this._src.toLowerCase().endsWith("m3u8"));
12350
- if (isDrm && isHLS) {
12817
+ // DRM mode: use the native video element for adaptive streams (EME
12818
+ // requires <video>; protected frames can't be copied to a canvas)
12819
+ // applies to HLS (.m3u8) and DASH (.mpd) alike. Providing a `licenseurl`
12820
+ // is enough to enable it; the bare `drm` boolean attribute still works.
12821
+ const licenseUrl = this.getAttribute("licenseurl") || "";
12822
+ const isDrm = this.hasAttribute("drm") || licenseUrl.length > 0;
12823
+ const isAdaptiveStream = typeof this._src === "string" &&
12824
+ (this._src.toLowerCase().includes(".m3u8") ||
12825
+ this._src.toLowerCase().includes(".mpd") ||
12826
+ this._src.toLowerCase().includes(".ism"));
12827
+ if (isDrm && isAdaptiveStream) {
12351
12828
  playerConfig.renderer = "video";
12352
12829
  playerConfig.drm = true;
12353
- playerConfig.licenseUrl = this.getAttribute("licenseurl") || "";
12830
+ if (licenseUrl)
12831
+ playerConfig.licenseUrl = licenseUrl;
12832
+ // Optional auth headers for the license request, as a JSON object,
12833
+ // e.g. licenseheaders='{"Authorization":"Bearer <token>"}'.
12834
+ const rawHeaders = this.getAttribute("licenseheaders");
12835
+ if (rawHeaders) {
12836
+ try {
12837
+ playerConfig.licenseHeaders = JSON.parse(rawHeaders);
12838
+ }
12839
+ catch {
12840
+ Logger.warn(TAG, "Invalid licenseheaders JSON attribute; ignoring");
12841
+ }
12842
+ }
12354
12843
  this.canvas.style.display = "none";
12355
12844
  this.video.style.display = "block";
12356
12845
  }
@@ -12360,6 +12849,15 @@ export class MoviElement extends HTMLElement {
12360
12849
  this.canvas.style.display = "block";
12361
12850
  this.video.style.display = "none";
12362
12851
  }
12852
+ // MPEG-5 LCEVC (opt-in): Shaka composites the enhanced layer onto the
12853
+ // canvas. Needs the external lcevc_dec.js library — point `lcevcurl` at
12854
+ // it to lazy-load, or load it yourself (global LCEVCdec). Without DRM.
12855
+ if (!playerConfig.drm && this.hasAttribute("lcevc")) {
12856
+ playerConfig.lcevc = true;
12857
+ const lcevcUrl = this.getAttribute("lcevcurl");
12858
+ if (lcevcUrl)
12859
+ playerConfig.lcevcUrl = lcevcUrl;
12860
+ }
12363
12861
  // Create Player instance
12364
12862
  const mode = playerConfig.drm ? "DRM/Native Video" : "Canvas Renderer";
12365
12863
  Logger.info(TAG, `Initializing MoviPlayer (${mode} Mode)`);
@@ -12374,18 +12872,19 @@ export class MoviElement extends HTMLElement {
12374
12872
  catch { }
12375
12873
  this._carryAudioEl = null;
12376
12874
  }
12377
- // In DRM mode, use HLS wrapper's video element directly
12875
+ // In DRM mode, use the stream wrapper's native <video> element directly
12876
+ // (Shaka manages EME on it; protected frames can't go through canvas).
12378
12877
  if (playerConfig.drm) {
12379
- const hlsVideo = this.player.getHLSVideoElement();
12380
- if (hlsVideo && this.video) {
12381
- // Replace our video element with HLS's video element
12382
- hlsVideo.style.width = "100%";
12383
- hlsVideo.style.height = "100%";
12384
- hlsVideo.style.display = "block";
12385
- hlsVideo.style.objectFit = "contain";
12386
- this.video.replaceWith(hlsVideo);
12387
- this.video = hlsVideo;
12388
- Logger.info(TAG, "DRM: Swapped in HLS native video element");
12878
+ const streamVideo = this.player.getHLSVideoElement();
12879
+ if (streamVideo && this.video) {
12880
+ // Replace our video element with the stream's native video element
12881
+ streamVideo.style.width = "100%";
12882
+ streamVideo.style.height = "100%";
12883
+ streamVideo.style.display = "block";
12884
+ streamVideo.style.objectFit = "contain";
12885
+ this.video.replaceWith(streamVideo);
12886
+ this.video = streamVideo;
12887
+ Logger.info(TAG, "DRM: Swapped in native stream video element");
12389
12888
  }
12390
12889
  }
12391
12890
  // Reset unsupported state
@@ -12477,27 +12976,22 @@ export class MoviElement extends HTMLElement {
12477
12976
  if (this._resume) {
12478
12977
  this.startResumeSaving();
12479
12978
  }
12480
- // Auto-play if requested
12979
+ // Auto-play if requested — but never while the tab is hidden. A
12980
+ // backgrounded autoplay kicks off a first-play seek the browser then
12981
+ // throttles (rAF paused, WakeLock denied); it times out into a buffering
12982
+ // state that doesn't cleanly resume on return (the user had to pause→play
12983
+ // to unstick it). Defer to the first time the tab is visible — which is
12984
+ // also how browsers gate background autoplay. _onVisibilityChange starts
12985
+ // it via _startAutoplay() once shown. The center play button stays visible
12986
+ // meanwhile (autoplayStarting unset), so a manual start works too.
12481
12987
  if (this._autoplay && this.player) {
12482
- // Suppress the center play overlay through the startup window so the
12483
- // big play icon doesn't flash before playback begins on its own.
12484
- this._autoplayStarting = true;
12485
- this.updatePlayPauseIcon();
12486
- await this.player.play().catch(() => {
12487
- // Hard play() rejection (rare for canvas mode — video keeps going
12488
- // even when audio can't). Surface the overlay so there's something
12489
- // to click; the suspended-audio check below still runs.
12490
- this._autoplayStarting = false;
12491
- this.updatePlayPauseIcon();
12492
- });
12493
- // Browser autoplay policy blocks audio-with-sound silently: play()
12494
- // resolves and video rolls, but the AudioContext stays suspended
12495
- // (resume() neither throws nor wakes it without a user gesture). So
12496
- // we can't detect this from the promise — poll the audio state right
12497
- // after play() and fall back to muted playback + a "Tap to unmute"
12498
- // pill (YouTube/Twitter behaviour) so the user has a one-tap path to
12499
- // audio instead of silently-broken sound.
12500
- this.maybeFallbackToMutedAutoplay();
12988
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") {
12989
+ this._autoplayPendingVisible = true;
12990
+ Logger.info(TAG, "Autoplay deferred — tab hidden; will start when visible");
12991
+ }
12992
+ else {
12993
+ await this._startAutoplay();
12994
+ }
12501
12995
  }
12502
12996
  else if (this._startAt === 0 && this.player && !this._poster) {
12503
12997
  // Render the poster frame on canvas via a seek on the main decoder
@@ -12573,6 +13067,12 @@ export class MoviElement extends HTMLElement {
12573
13067
  else if (message.includes("decode")) {
12574
13068
  title = "Playback Error";
12575
13069
  }
13070
+ else if (/\(HTTP \d|was denied|could not be found|video server/i.test(message)) {
13071
+ // The stream wrapper already produced a precise HTTP reason (403/404/
13072
+ // 5xx/…); "Initialization Failed" reads like a player bug, so use a
13073
+ // neutral title and let the specific message carry the detail.
13074
+ title = "Can't Play Video";
13075
+ }
12576
13076
  }
12577
13077
  this.handleUnsupportedVideo(title, message);
12578
13078
  }
@@ -12647,6 +13147,15 @@ export class MoviElement extends HTMLElement {
12647
13147
  // Forward player events to element
12648
13148
  const stateChangeHandler = (state) => {
12649
13149
  Logger.info(TAG, `stateChange: ${state}`);
13150
+ // A seek requested before the player was ready was held — apply it now
13151
+ // that we've reached a seekable state (fixes e.g. a PiP/handoff seek that
13152
+ // arrives while the source is still loading).
13153
+ if (this._pendingSeek !== null &&
13154
+ (state === "ready" || state === "paused" || state === "playing")) {
13155
+ const target = this._pendingSeek;
13156
+ this._pendingSeek = null;
13157
+ this.currentTime = target;
13158
+ }
12650
13159
  // Hide poster on state change to playing
12651
13160
  if (state === "playing" && this.posterElement) {
12652
13161
  // Drop the frozen-frame snapshot overlay used during quality switch
@@ -12663,6 +13172,17 @@ export class MoviElement extends HTMLElement {
12663
13172
  this.updateLoadingIndicator(state);
12664
13173
  this.updateControlsState();
12665
13174
  this.updatePlayPauseIcon();
13175
+ // Autoplay suppression also ends when startup stops SHORT of playback —
13176
+ // e.g. the browser blocked autoplay pending a user gesture (common with a
13177
+ // separate native-audio track), leaving the player paused. play() doesn't
13178
+ // reject in that case and no "playing" event fires, so without this the
13179
+ // flag would stay set and keep the centre play button hidden. Clear it and
13180
+ // repaint so the big play affordance surfaces for a manual start.
13181
+ if (this._autoplayStarting &&
13182
+ (state === "paused" || state === "ended" || state === "error")) {
13183
+ this._autoplayStarting = false;
13184
+ this.updatePlayPauseIcon();
13185
+ }
12666
13186
  if (state === "playing") {
12667
13187
  // Autoplay reached playback — startup window is over. Capture it
12668
13188
  // first so we can skip the click-confirmation UI below.
@@ -12773,6 +13293,13 @@ export class MoviElement extends HTMLElement {
12773
13293
  // is async, so the strip may flip OFF later when the coverart
12774
13294
  // event lands — updateCoverArtOverlay handles both transitions.
12775
13295
  this.updateCoverArtOverlay();
13296
+ // Live (dynamic) streams: show the LIVE indicator + hide the meaningless
13297
+ // wall-clock duration.
13298
+ this.updateLiveState();
13299
+ // Backstop for the "linearmode" event in case it fired before this
13300
+ // listener was wired (detection happens during the demux open).
13301
+ if (this.player?.isLinearPlayback?.())
13302
+ this.enterLinearMode();
12776
13303
  this.dispatchEvent(new Event("loadeddata"));
12777
13304
  };
12778
13305
  this.player.on("loadEnd", loadEndHandler);
@@ -12798,8 +13325,14 @@ export class MoviElement extends HTMLElement {
12798
13325
  };
12799
13326
  this.player.on("preloadcomplete", preloadCompleteHandler);
12800
13327
  this.eventHandlers.set("preloadcomplete", () => this.player?.off("preloadcomplete", preloadCompleteHandler));
13328
+ // Source fell back to linear (non-seekable) playback — drop the timeline,
13329
+ // seeking and thumbnails.
13330
+ const linearModeHandler = () => this.enterLinearMode();
13331
+ this.player.on("linearmode", linearModeHandler);
13332
+ this.eventHandlers.set("linearmode", () => this.player?.off("linearmode", linearModeHandler));
12801
13333
  const timeUpdateHandler = (time) => {
12802
13334
  this.dispatchEvent(new CustomEvent("timeupdate", { detail: time }));
13335
+ this.updateLiveState();
12803
13336
  };
12804
13337
  this.player.on("timeUpdate", timeUpdateHandler);
12805
13338
  this.eventHandlers.set("timeUpdate", () => this.player?.off("timeUpdate", timeUpdateHandler));
@@ -12904,6 +13437,16 @@ export class MoviElement extends HTMLElement {
12904
13437
  // overlay so the new load doesn't briefly show last track's art.
12905
13438
  this.coverArtBitmap = null;
12906
13439
  this._coverArtResolved = false;
13440
+ // Drop the poster-as-album-art bitmap too — we own it, so close to free it.
13441
+ if (this._posterCoverBitmap) {
13442
+ try {
13443
+ this._posterCoverBitmap.close();
13444
+ }
13445
+ catch { }
13446
+ }
13447
+ this._posterCoverBitmap = null;
13448
+ this._posterCoverUrl = "";
13449
+ this._posterCoverLoading = false;
12907
13450
  if (this.coverArtOverlay)
12908
13451
  this.coverArtOverlay.style.display = "none";
12909
13452
  // Also drop the audio-mode/strip layout — re-decided once the new
@@ -12924,8 +13467,14 @@ export class MoviElement extends HTMLElement {
12924
13467
  // Reset unsupported and loading state on source change so new source can load
12925
13468
  this._isUnsupported = false;
12926
13469
  this.isLoading = false;
13470
+ // Drop any autoplay deferred for the previous source — initializePlayer
13471
+ // re-arms it for the new one if still hidden.
13472
+ this._autoplayPendingVisible = false;
12927
13473
  if (this.brokenIndicator)
12928
13474
  this.brokenIndicator.style.display = "none";
13475
+ // A fresh source may well support Range — clear any linear-mode lock.
13476
+ this._linearMode = false;
13477
+ this.classList.remove("movi-linear");
12929
13478
  // Show empty state if no src after player cleanup
12930
13479
  if (!this._src && this.emptyStateIndicator) {
12931
13480
  this.emptyStateIndicator.style.display = "flex";
@@ -12983,6 +13532,7 @@ export class MoviElement extends HTMLElement {
12983
13532
  this._pendingPlay = false;
12984
13533
  this._preloadGateActive = false;
12985
13534
  this._resumeDialogPending = false;
13535
+ this._autoplayPendingVisible = false;
12986
13536
  // Tear down internal player
12987
13537
  if (this.player) {
12988
13538
  try {
@@ -13030,6 +13580,15 @@ export class MoviElement extends HTMLElement {
13030
13580
  // audio-mode/strip layout leak into the next track. The classes are
13031
13581
  // re-decided once the new source's tracks land (updateCoverArtOverlay).
13032
13582
  this.coverArtBitmap = null;
13583
+ if (this._posterCoverBitmap) {
13584
+ try {
13585
+ this._posterCoverBitmap.close();
13586
+ }
13587
+ catch { }
13588
+ }
13589
+ this._posterCoverBitmap = null;
13590
+ this._posterCoverUrl = "";
13591
+ this._posterCoverLoading = false;
13033
13592
  if (this.coverArtOverlay)
13034
13593
  this.coverArtOverlay.style.display = "none";
13035
13594
  this.classList.remove("movi-audio-strip", "movi-audio-mode");
@@ -13310,6 +13869,20 @@ export class MoviElement extends HTMLElement {
13310
13869
  container.appendChild(segment);
13311
13870
  }
13312
13871
  }
13872
+ /**
13873
+ * Convert a 0..1 seek-bar fraction to a media time. For live streams the bar
13874
+ * spans the seekable DVR window [seekStart .. liveEdge], so the fraction maps
13875
+ * into that window; for VOD it's the usual 0..duration.
13876
+ */
13877
+ barFractionToTime(fraction) {
13878
+ const f = Math.max(0, Math.min(1, fraction));
13879
+ if (this.player?.isLiveStream?.()) {
13880
+ const start = this.player.getSeekRangeStart?.() ?? 0;
13881
+ const end = this.player.getLiveEdge?.() ?? 0;
13882
+ return start + f * Math.max(0, end - start);
13883
+ }
13884
+ return f * this.duration;
13885
+ }
13313
13886
  updateProgressBar() {
13314
13887
  // Don't update visuals if user is scrubbing or seeking
13315
13888
  if (this.isDragging || this.isTouchDragging || this.isSeeking)
@@ -13317,6 +13890,30 @@ export class MoviElement extends HTMLElement {
13317
13890
  const progressFilled = this.shadowRoot?.querySelector(".movi-progress-filled");
13318
13891
  const progressHandle = this.shadowRoot?.querySelector(".movi-progress-handle");
13319
13892
  const progressBuffer = this.shadowRoot?.querySelector(".movi-progress-buffer");
13893
+ // Live (dynamic) streams: duration is Infinity, so scale the bar against
13894
+ // the seekable DVR window [seekStart .. liveEdge] instead — the playhead
13895
+ // sits at the right edge when watching live and moves left as you scrub
13896
+ // back into the window.
13897
+ if (this.player?.isLiveStream?.()) {
13898
+ const start = this.player.getSeekRangeStart?.() ?? 0;
13899
+ const end = this.player.getLiveEdge?.() ?? 0;
13900
+ const span = end - start;
13901
+ const ct = this.player.getCurrentTime?.() ?? 0;
13902
+ const pct = span > 0 ? Math.min(100, Math.max(0, ((ct - start) / span) * 100)) : 0;
13903
+ if (progressFilled)
13904
+ progressFilled.style.width = `${pct}%`;
13905
+ if (progressHandle)
13906
+ progressHandle.style.left = `${pct}%`;
13907
+ if (progressBuffer) {
13908
+ const bufEnd = this.player.getBufferEndTime?.() ?? 0;
13909
+ const bufPct = span > 0
13910
+ ? Math.min(100, Math.max(0, ((bufEnd - start) / span) * 100))
13911
+ : 0;
13912
+ progressBuffer.style.left = "0%";
13913
+ progressBuffer.style.width = `${bufPct}%`;
13914
+ }
13915
+ return;
13916
+ }
13320
13917
  if (this.duration > 0) {
13321
13918
  // Clamp to 0 during the postertime poster seek (clock transiently parked
13322
13919
  // at the poster timestamp) so the filled bar/handle don't flick to ~2s.
@@ -13342,9 +13939,18 @@ export class MoviElement extends HTMLElement {
13342
13939
  ? 0
13343
13940
  : this.player.getBufferEndTime();
13344
13941
  if (bufferEnd > 0) {
13942
+ // In linear mode the RAM window has a moving start (older bytes are
13943
+ // dropped), so draw the buffer bar as [bufferStart, bufferEnd] — the
13944
+ // actual window of bytes held in memory. (Seeks clamp to a slightly
13945
+ // inset seekableStart, but showing the true window reads better — no
13946
+ // gap between the playhead and the buffered segment.) Otherwise from 0.
13947
+ const bufferStart = this._linearMode
13948
+ ? Math.max(0, this.player.getBufferStartTime?.() ?? 0)
13949
+ : 0;
13950
+ const startPercent = Math.min(100, (bufferStart / this.duration) * 100);
13345
13951
  const endPercent = Math.min(100, (bufferEnd / this.duration) * 100);
13346
- progressBuffer.style.left = "0%";
13347
- progressBuffer.style.width = `${endPercent}%`;
13952
+ progressBuffer.style.left = `${startPercent}%`;
13953
+ progressBuffer.style.width = `${Math.max(0, endPercent - startPercent)}%`;
13348
13954
  }
13349
13955
  else {
13350
13956
  progressBuffer.style.width = "0%";
@@ -13522,6 +14128,12 @@ export class MoviElement extends HTMLElement {
13522
14128
  // Show error message
13523
14129
  if (this.brokenIndicator) {
13524
14130
  this.brokenIndicator.style.display = "flex";
14131
+ // Hide the empty-state placeholder so it doesn't render behind the
14132
+ // error overlay — both default to visible with no src, which stacks
14133
+ // the "No Video" text under "Security Headers Missing".
14134
+ if (this.emptyStateIndicator) {
14135
+ this.emptyStateIndicator.style.display = "none";
14136
+ }
13525
14137
  const titleEl = this.brokenIndicator.querySelector(".movi-broken-title");
13526
14138
  if (titleEl)
13527
14139
  titleEl.textContent = "Security Headers Missing";
@@ -13697,34 +14309,31 @@ export class MoviElement extends HTMLElement {
13697
14309
  }
13698
14310
  }
13699
14311
  startAmbientColorSampling() {
13700
- if (this._ambientRafId !== null)
13701
- return;
13702
14312
  // Bail when the player isn't built yet — connectedCallback runs
13703
- // updateAmbientMode() before initializePlayer() creates the
13704
- // player, and that pre-init call would otherwise set up an
13705
- // rAF loop that runs against a missing renderer (no mirror to
13706
- // enable, sampleCanvasColors returns early). Worse, the rAF
13707
- // handle would then block the *real* startAmbientColorSampling
13708
- // call later in initializePlayer — the `_ambientRafId !== null`
13709
- // guard above made the second call a no-op, so the renderer
13710
- // never had enableAmbientMirror() invoked on it. Net effect: a
13711
- // page that loads with `ambientmode` already on showed no
13712
- // ambient glow until the user toggled the feature off and on.
14313
+ // updateAmbientMode() before initializePlayer() creates the player.
13713
14314
  if (!this.player)
13714
14315
  return;
13715
- // Reset adaptive interval a previously-throttled session may have left
13716
- // it ratcheted up to 2s, which would make ambient appear "frozen" until
13717
- // the per-sample recovery slowly walked it back down.
13718
- this._ambientSampleInterval = 200;
13719
- // Ask the renderer to maintain a 16×16 mirror of each drawn frame.
13720
- // sampleCanvasColors() will read from that 256-pixel buffer instead of
13721
- // triggering a full canvas readback per sample the old path caused
13722
- // visible frame slips on 8K HDR sources from GPU contention with
13723
- // presentation.
14316
+ // Ask the CURRENT renderer to maintain a 16×16 mirror of each drawn frame.
14317
+ // sampleCanvasColors() reads from that 256-pixel buffer instead of a full
14318
+ // canvas readback per sample (which caused frame slips on 8K HDR sources).
14319
+ //
14320
+ // This MUST run before the rafId guard below: on a src change a fresh
14321
+ // player/renderer replaces the old one while this rAF loop keeps running,
14322
+ // so a guard-first version would skip enabling the mirror on the NEW
14323
+ // renderer leaving ambient "on" in the menu but never painting on the new
14324
+ // video. Re-enabling here is idempotent and fixes that.
13724
14325
  const renderer = this.player?.videoRenderer;
13725
14326
  if (renderer && typeof renderer.enableAmbientMirror === "function") {
13726
14327
  renderer.enableAmbientMirror();
13727
14328
  }
14329
+ // Loop already running (e.g. carried across a src change) — the mirror was
14330
+ // just re-enabled above on the current renderer, so nothing more to start.
14331
+ if (this._ambientRafId !== null)
14332
+ return;
14333
+ // Reset adaptive interval — a previously-throttled session may have left
14334
+ // it ratcheted up to 2s, which would make ambient appear "frozen" until
14335
+ // the per-sample recovery slowly walked it back down.
14336
+ this._ambientSampleInterval = 200;
13728
14337
  const loop = (timestamp) => {
13729
14338
  // If software decoding is active, pause ambient sampling to save main thread cycles
13730
14339
  // This is crucial for CPU-heavy 4K software decoding
@@ -13978,7 +14587,7 @@ export class MoviElement extends HTMLElement {
13978
14587
  this.removeAttribute("src");
13979
14588
  this._src = null;
13980
14589
  // Show empty state when src is cleared
13981
- if (this.emptyStateIndicator && !this.player) {
14590
+ if (this.emptyStateIndicator && !this.player && !this._isUnsupported) {
13982
14591
  this.emptyStateIndicator.style.display = "flex";
13983
14592
  }
13984
14593
  }
@@ -14045,6 +14654,70 @@ export class MoviElement extends HTMLElement {
14045
14654
  setSourceAdapter(adapter) {
14046
14655
  this.sourceAdapter = adapter;
14047
14656
  }
14657
+ /**
14658
+ * Custom HTTP headers sent with every media network request — the adaptive
14659
+ * manifest (.mpd/.m3u8) and all of its segments, plus progressive downloads.
14660
+ * Use for auth tokens, signed headers, etc. The object form is the
14661
+ * programmatic counterpart to the JSON `headers` attribute; objects can't go
14662
+ * through attributes, so set non-trivial header maps via this property.
14663
+ *
14664
+ * player.headers = { Authorization: "Bearer <token>" };
14665
+ */
14666
+ get headers() {
14667
+ return this._headers;
14668
+ }
14669
+ set headers(value) {
14670
+ this._headers = value && typeof value === "object" ? { ...value } : null;
14671
+ // Headers are consumed when the player/stream-wrapper is constructed, so a
14672
+ // live change only takes effect on the next load. Reload if a source is
14673
+ // already active.
14674
+ if (this.isConnected && (this._src || this._sourceAdapter)) {
14675
+ this.load();
14676
+ }
14677
+ }
14678
+ /**
14679
+ * Audio-only (data-saver) mode. When true the player skips video decoding to
14680
+ * save CPU and, for adaptive streams (HLS/DASH), fetches only audio
14681
+ * renditions to save bandwidth; the UI shows the album-art / strip surface.
14682
+ * Toggle via this property or the `audioonly` attribute.
14683
+ *
14684
+ * player.audioOnly = true; // data saver on
14685
+ */
14686
+ get audioOnly() {
14687
+ return this._audioOnly;
14688
+ }
14689
+ set audioOnly(value) {
14690
+ const enabled = !!value;
14691
+ if (enabled === this._audioOnly)
14692
+ return;
14693
+ this._audioOnly = enabled;
14694
+ // Reflect to the attribute (without re-triggering this setter — the
14695
+ // attributeChangedCallback no-ops when the flag already matches).
14696
+ if (enabled)
14697
+ this.setAttribute("audioonly", "");
14698
+ else
14699
+ this.removeAttribute("audioonly");
14700
+ if (this.isConnected)
14701
+ this.applyAudioOnly();
14702
+ }
14703
+ /**
14704
+ * Apply an audio-only toggle to the live player. Adaptive streams change what
14705
+ * gets downloaded (Shaka ignores video at manifest-parse time), so they
14706
+ * reload; the demuxer path flips video decode on/off in place.
14707
+ */
14708
+ applyAudioOnly() {
14709
+ if (!this.player)
14710
+ return;
14711
+ // Always flip in place — NEVER reload. A reload destroys the player (and,
14712
+ // for a stream, re-runs Shaka's manifest parse, which fails on HLS with
14713
+ // disableVideo and drops the LIVE badge on the hls.js fallback). setAudioOnly
14714
+ // handles every source live: streams pick an audio-only / smallest-video
14715
+ // variant; split sources stop the demux loop; muxed skips the video decode.
14716
+ this.player.setAudioOnly?.(this._audioOnly);
14717
+ this.updateCoverArtOverlay();
14718
+ this.updateControlsVisibility();
14719
+ this.updateLiveState(); // keep the LIVE badge correct (stream stays loaded)
14720
+ }
14048
14721
  /**
14049
14722
  * Video.js-style source API
14050
14723
  *
@@ -14212,17 +14885,23 @@ export class MoviElement extends HTMLElement {
14212
14885
  this.emptyStateIndicator.style.display = "none";
14213
14886
  }
14214
14887
  try {
14215
- // Create player with encrypted source (respecting element properties)
14888
+ // Create player with encrypted source (respecting element properties).
14889
+ // Custom media headers ride on the SourceConfig — createSource forwards
14890
+ // them to EncryptedHttpSource, which spreads them onto both the stream
14891
+ // GET and the token-refresh request (alongside the per-request signing
14892
+ // headers).
14216
14893
  this.player = new MoviPlayer({
14217
14894
  source: {
14218
14895
  type: "encrypted",
14219
14896
  encrypted: config,
14897
+ ...(this._headers && { headers: this._headers }),
14220
14898
  },
14221
14899
  renderer: "canvas",
14222
14900
  decoder: this._sw,
14223
14901
  canvas: this.canvas,
14224
14902
  enablePreviews: this._thumb,
14225
14903
  frameRate: this._fps || undefined,
14904
+ ...(this._headers && { headers: this._headers }),
14226
14905
  });
14227
14906
  this.setupEventHandlers();
14228
14907
  await this.player.load();
@@ -14722,7 +15401,9 @@ export class MoviElement extends HTMLElement {
14722
15401
  * back and replay it after preload settles.
14723
15402
  */
14724
15403
  maybeShowResumeDialog() {
14725
- if (!this._resume || !this.player || this._contextLostTime !== 0)
15404
+ // Resume means seeking to the saved position — impossible in linear
15405
+ // (non-seekable) playback, so don't offer it.
15406
+ if (!this._resume || !this.player || this._contextLostTime !== 0 || this._linearMode)
14726
15407
  return;
14727
15408
  const savedTime = this.getResumePosition();
14728
15409
  if (savedTime > 2 && savedTime < this.duration - 5) {
@@ -14733,6 +15414,9 @@ export class MoviElement extends HTMLElement {
14733
15414
  * Show resume dialog with saved position
14734
15415
  */
14735
15416
  showResumeDialog(savedTime) {
15417
+ // Linear (non-seekable) playback can't jump to the saved position.
15418
+ if (this._linearMode)
15419
+ return;
14736
15420
  const shadowRoot = this.shadowRoot;
14737
15421
  if (!shadowRoot)
14738
15422
  return;
@@ -14867,6 +15551,10 @@ export class MoviElement extends HTMLElement {
14867
15551
  * Toggle timeline panel visibility
14868
15552
  */
14869
15553
  toggleTimeline() {
15554
+ // Linear (non-seekable) playback — the timeline strip generates frames by
15555
+ // seeking across the file, which this source can't do. No timeline.
15556
+ if (this._linearMode)
15557
+ return;
14870
15558
  const shadowRoot = this.shadowRoot;
14871
15559
  if (!shadowRoot)
14872
15560
  return;
@@ -15226,8 +15914,15 @@ export class MoviElement extends HTMLElement {
15226
15914
  return this.player?.getCurrentTime() || 0;
15227
15915
  }
15228
15916
  set currentTime(value) {
15229
- if (!this.player)
15917
+ // Player not built yet — hold the seek and apply it once it's ready.
15918
+ if (!this.player) {
15919
+ this._pendingSeek = value;
15230
15920
  return;
15921
+ }
15922
+ // Linear (non-seekable source) playback — only the bytes in the RAM window
15923
+ // are reachable, so clamp the target to the buffered range instead of
15924
+ // jumping somewhere that was discarded / not yet downloaded.
15925
+ value = this.clampToBufferedWindow(value);
15231
15926
  const state = this.player.getState();
15232
15927
  Logger.info(TAG, `currentTime setter: value=${value.toFixed(2)}, state=${state}, isSeeking=${this.isSeeking}`);
15233
15928
  if (state !== "ready" &&
@@ -15236,9 +15931,14 @@ export class MoviElement extends HTMLElement {
15236
15931
  state !== "ended" &&
15237
15932
  state !== "seeking" &&
15238
15933
  state !== "buffering") {
15239
- Logger.warn(TAG, `Seek blocked state=${state} not allowed`);
15934
+ // Not seekable yet (still loading/initialising). Hold the seek; the
15935
+ // stateChange handler applies it the moment the player becomes ready.
15936
+ Logger.info(TAG, `Seek held until ready — state=${state}`);
15937
+ this._pendingSeek = value;
15240
15938
  return;
15241
15939
  }
15940
+ // We're committed to seeking now, so any earlier held seek is superseded.
15941
+ this._pendingSeek = null;
15242
15942
  // Coalesce: a previous seek is still running. Stash the latest target and
15243
15943
  // bail. The in-flight seek's finally() will pick up the latest target,
15244
15944
  // collapsing any number of intermediate sets into one tail seek.