movi-player 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/README.md +54 -7
- package/dist/core/MoviPlayer.d.ts +77 -1
- package/dist/core/MoviPlayer.d.ts.map +1 -1
- package/dist/core/MoviPlayer.js +667 -121
- package/dist/core/MoviPlayer.js.map +1 -1
- package/dist/core/PlayerState.js +1 -1
- package/dist/core/PlayerState.js.map +1 -1
- package/dist/decode/VideoDecoder.d.ts.map +1 -1
- package/dist/decode/VideoDecoder.js +6 -2
- package/dist/decode/VideoDecoder.js.map +1 -1
- package/dist/demuxer.cjs +315 -129
- package/dist/demuxer.js +377 -191
- package/dist/element.cjs +58309 -16596
- package/dist/element.js +59520 -17807
- package/dist/index.cjs +58309 -16596
- package/dist/index.js +59520 -17807
- package/dist/player.cjs +56818 -15360
- package/dist/player.js +57818 -16360
- package/dist/render/DASHPlayerWrapper.d.ts +65 -0
- package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
- package/dist/render/DASHPlayerWrapper.js +540 -0
- package/dist/render/DASHPlayerWrapper.js.map +1 -0
- package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
- package/dist/render/HLSPlayerWrapper.js +47 -11
- package/dist/render/HLSPlayerWrapper.js.map +1 -1
- package/dist/render/MoviElement.d.ts +97 -0
- package/dist/render/MoviElement.d.ts.map +1 -1
- package/dist/render/MoviElement.js +852 -152
- package/dist/render/MoviElement.js.map +1 -1
- package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
- package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
- package/dist/render/ShakaPlayerWrapper.js +956 -0
- package/dist/render/ShakaPlayerWrapper.js.map +1 -0
- package/dist/source/DashFallback.d.ts +32 -0
- package/dist/source/DashFallback.d.ts.map +1 -0
- package/dist/source/DashFallback.js +142 -0
- package/dist/source/DashFallback.js.map +1 -0
- package/dist/source/DashManifest.d.ts +34 -0
- package/dist/source/DashManifest.d.ts.map +1 -0
- package/dist/source/DashManifest.js +194 -0
- package/dist/source/DashManifest.js.map +1 -0
- package/dist/source/DashSegmentSource.d.ts +32 -0
- package/dist/source/DashSegmentSource.d.ts.map +1 -0
- package/dist/source/DashSegmentSource.js +86 -0
- package/dist/source/DashSegmentSource.js.map +1 -0
- package/dist/source/HttpSource.d.ts +64 -0
- package/dist/source/HttpSource.d.ts.map +1 -1
- package/dist/source/HttpSource.js +444 -57
- package/dist/source/HttpSource.js.map +1 -1
- package/dist/source/ThumbnailHttpSource.d.ts +13 -0
- package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
- package/dist/source/ThumbnailHttpSource.js +70 -8
- package/dist/source/ThumbnailHttpSource.js.map +1 -1
- package/dist/source/index.d.ts +2 -0
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +16 -6
|
@@ -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
|
-
//
|
|
337
|
-
this.tabIndex
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
4371
|
-
// Local files or single-file URLs should not show quality selection
|
|
4372
|
-
const
|
|
4373
|
-
(this._src.includes(".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
|
|
4376
|
-
if (!
|
|
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 (!
|
|
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
|
-
//
|
|
4441
|
-
// currently-playing
|
|
4442
|
-
//
|
|
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
|
|
4449
|
-
|
|
4450
|
-
|
|
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
|
-
|
|
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.
|
|
5945
|
-
// 2.
|
|
5946
|
-
// 3.
|
|
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
|
-
|
|
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
|
-
/*
|
|
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:
|
|
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:
|
|
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
|
-
/*
|
|
9622
|
-
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9628
|
-
|
|
9629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12028
|
-
//
|
|
12029
|
-
//
|
|
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
|
-
|
|
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
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12350
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
12380
|
-
if (
|
|
12381
|
-
// Replace our video element with
|
|
12382
|
-
|
|
12383
|
-
|
|
12384
|
-
|
|
12385
|
-
|
|
12386
|
-
this.video.replaceWith(
|
|
12387
|
-
this.video =
|
|
12388
|
-
Logger.info(TAG, "DRM: Swapped in
|
|
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
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
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 =
|
|
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
|
-
//
|
|
13716
|
-
//
|
|
13717
|
-
//
|
|
13718
|
-
|
|
13719
|
-
//
|
|
13720
|
-
//
|
|
13721
|
-
//
|
|
13722
|
-
//
|
|
13723
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|