avbridge 2.7.0 → 2.8.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/CHANGELOG.md +106 -0
- package/README.md +35 -0
- package/dist/{chunk-6SOFJV44.cjs → chunk-IUSFLVLJ.cjs} +21 -6
- package/dist/chunk-IUSFLVLJ.cjs.map +1 -0
- package/dist/{chunk-OGYHFY6K.js → chunk-JSQOBUQB.js} +21 -6
- package/dist/chunk-JSQOBUQB.js.map +1 -0
- package/dist/element-browser.js +146 -7
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +129 -5
- package/dist/element.cjs.map +1 -1
- package/dist/element.d.cts +52 -3
- package/dist/element.d.ts +52 -3
- package/dist/element.js +128 -4
- package/dist/element.js.map +1 -1
- package/dist/index.cjs +8 -8
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{player-DGXeCNfD.d.cts → player-DXEKOky8.d.cts} +3 -0
- package/dist/{player-DGXeCNfD.d.ts → player-DXEKOky8.d.ts} +3 -0
- package/dist/player.cjs +254 -12
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +62 -3
- package/dist/player.d.ts +62 -3
- package/dist/player.js +254 -12
- package/dist/player.js.map +1 -1
- package/package.json +1 -1
- package/src/classify/rules.ts +23 -0
- package/src/element/avbridge-player.ts +83 -7
- package/src/element/avbridge-video.ts +148 -4
- package/src/element/player-styles.ts +44 -0
- package/src/element.ts +3 -3
- package/src/strategies/fallback/decoder.ts +14 -7
- package/src/strategies/fallback/video-renderer.ts +7 -5
- package/src/types.ts +1 -0
- package/dist/chunk-6SOFJV44.cjs.map +0 -1
- package/dist/chunk-OGYHFY6K.js.map +0 -1
package/package.json
CHANGED
package/src/classify/rules.ts
CHANGED
|
@@ -195,6 +195,29 @@ export function classifyContext(ctx: MediaContext): Classification {
|
|
|
195
195
|
// Remuxing goes through mediabunny, which only supports certain containers.
|
|
196
196
|
// AVI/ASF/FLV are NOT in that set — mediabunny rejects them at Input().
|
|
197
197
|
if (REMUXABLE_CONTAINERS.has(ctx.container)) {
|
|
198
|
+
// Feature-detect: the remux target is fragmented MP4 via MSE. If the
|
|
199
|
+
// browser's MSE can't decode this codec combo (e.g. HEVC on
|
|
200
|
+
// open-source Chromium — no proprietary codecs by design — or
|
|
201
|
+
// future codec/container combos we haven't thought of), remux will
|
|
202
|
+
// stall. Degrade gracefully: try hybrid (WebCodecs hardware decode)
|
|
203
|
+
// or fallback (WASM software decode) without waiting for runtime
|
|
204
|
+
// escalation.
|
|
205
|
+
const mime = mp4MimeFor(video, audio);
|
|
206
|
+
if (mime && typeof MediaSource !== "undefined" && !mseSupports(mime)) {
|
|
207
|
+
if (webCodecsAvailable()) {
|
|
208
|
+
return {
|
|
209
|
+
class: "HYBRID_CANDIDATE",
|
|
210
|
+
strategy: "hybrid",
|
|
211
|
+
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime — routing to WebCodecs hardware decode`,
|
|
212
|
+
fallbackChain: ["fallback"],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
class: "FALLBACK_REQUIRED",
|
|
217
|
+
strategy: "fallback",
|
|
218
|
+
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable — falling back to WASM decode`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
198
221
|
return {
|
|
199
222
|
class: "REMUX_CANDIDATE",
|
|
200
223
|
strategy: "remux",
|
|
@@ -58,12 +58,19 @@ const FORWARDED_EVENTS = [
|
|
|
58
58
|
const PROXY_ATTRIBUTES = [
|
|
59
59
|
"src", "autoplay", "muted", "loop", "preload", "poster",
|
|
60
60
|
"playsinline", "crossorigin", "disableremoteplayback", "preferstrategy",
|
|
61
|
+
"fit",
|
|
61
62
|
] as const;
|
|
62
63
|
|
|
64
|
+
/** Player-only attributes that don't forward to <avbridge-video>. */
|
|
65
|
+
const PLAYER_ATTRIBUTES = ["show-fit"] as const;
|
|
66
|
+
|
|
67
|
+
const FIT_MODES = ["contain", "cover", "fill"] as const;
|
|
68
|
+
type FitMode = (typeof FIT_MODES)[number];
|
|
69
|
+
|
|
63
70
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
71
|
|
|
65
72
|
export class AvbridgePlayerElement extends HTMLElement {
|
|
66
|
-
static readonly observedAttributes = [...PROXY_ATTRIBUTES];
|
|
73
|
+
static readonly observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
|
|
67
74
|
|
|
68
75
|
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
69
76
|
|
|
@@ -102,6 +109,8 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
102
109
|
private _statsEl!: HTMLDivElement;
|
|
103
110
|
private _statsInterval: ReturnType<typeof setInterval> | null = null;
|
|
104
111
|
private _eventCleanup: (() => void)[] = [];
|
|
112
|
+
private _updateToolbarEmpty: () => void = () => { /* wired in constructor */ };
|
|
113
|
+
private _toolbarTop!: HTMLDivElement;
|
|
105
114
|
|
|
106
115
|
// ── Constructor ────────────────────────────────────────────────────────
|
|
107
116
|
|
|
@@ -131,6 +140,21 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
131
140
|
this._statsEl = shadow.querySelector(".avp-stats") as HTMLDivElement;
|
|
132
141
|
this._rippleLeft = shadow.querySelector(".avp-ripple-left") as HTMLDivElement;
|
|
133
142
|
this._rippleRight = shadow.querySelector(".avp-ripple-right") as HTMLDivElement;
|
|
143
|
+
this._toolbarTop = shadow.querySelector('[part="toolbar-top"]') as HTMLDivElement;
|
|
144
|
+
// Start visible — controls are shown until the auto-hide timer fires.
|
|
145
|
+
this._toolbarTop.setAttribute("data-visible", "true");
|
|
146
|
+
|
|
147
|
+
// Track whether the top toolbar has any slotted content. Used to hide
|
|
148
|
+
// its gradient when empty (see data-toolbar-empty in player-styles.ts).
|
|
149
|
+
// MUST defer the initial attribute write to connectedCallback — the
|
|
150
|
+
// Custom Elements spec forbids constructors from adding attributes.
|
|
151
|
+
const slots = shadow.querySelectorAll<HTMLSlotElement>('slot[name="top-left"], slot[name="top-right"]');
|
|
152
|
+
this._updateToolbarEmpty = () => {
|
|
153
|
+
const hasContent = Array.from(slots).some((s) => s.assignedNodes({ flatten: true }).length > 0);
|
|
154
|
+
if (hasContent) this.removeAttribute("data-toolbar-empty");
|
|
155
|
+
else this.setAttribute("data-toolbar-empty", "");
|
|
156
|
+
};
|
|
157
|
+
for (const s of slots) s.addEventListener("slotchange", this._updateToolbarEmpty);
|
|
134
158
|
|
|
135
159
|
this._bindEvents();
|
|
136
160
|
}
|
|
@@ -139,6 +163,10 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
139
163
|
return `
|
|
140
164
|
<div part="container" class="avp">
|
|
141
165
|
<avbridge-video part="video"></avbridge-video>
|
|
166
|
+
<div part="toolbar-top" class="avp-toolbar-top">
|
|
167
|
+
<div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
|
|
168
|
+
<div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
|
|
169
|
+
</div>
|
|
142
170
|
<div part="overlay" class="avp-overlay">
|
|
143
171
|
<button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
|
|
144
172
|
<div class="avp-spinner"></div>
|
|
@@ -326,17 +354,20 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
326
354
|
|
|
327
355
|
// Keyboard
|
|
328
356
|
on(this, "keydown", (e) => this._onKeydown(e as KeyboardEvent));
|
|
329
|
-
|
|
330
|
-
// Make focusable for keyboard events
|
|
331
|
-
if (!this.hasAttribute("tabindex")) {
|
|
332
|
-
this.setAttribute("tabindex", "0");
|
|
333
|
-
}
|
|
334
357
|
}
|
|
335
358
|
|
|
336
359
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
337
360
|
|
|
338
361
|
connectedCallback(): void {
|
|
339
362
|
this._setState("idle");
|
|
363
|
+
// Attribute writes the Custom Elements spec forbids in constructors
|
|
364
|
+
// — document.createElement rejects with "The result must not have
|
|
365
|
+
// attributes" — deferred here. Surfaced by the Playwright
|
|
366
|
+
// cross-browser tests.
|
|
367
|
+
if (!this.hasAttribute("tabindex")) {
|
|
368
|
+
this.setAttribute("tabindex", "0");
|
|
369
|
+
}
|
|
370
|
+
this._updateToolbarEmpty();
|
|
340
371
|
}
|
|
341
372
|
|
|
342
373
|
disconnectedCallback(): void {
|
|
@@ -344,8 +375,15 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
344
375
|
}
|
|
345
376
|
|
|
346
377
|
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
|
347
|
-
// Proxy attributes down to inner avbridge-video
|
|
348
378
|
if (!this._video) return;
|
|
379
|
+
// Player-only attributes — not forwarded to the inner <avbridge-video>.
|
|
380
|
+
if ((PLAYER_ATTRIBUTES as readonly string[]).includes(name)) {
|
|
381
|
+
if (name === "show-fit" && this._settingsOpen) {
|
|
382
|
+
this._buildSettingsMenu();
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Proxy everything else down.
|
|
349
387
|
if (value == null) this._video.removeAttribute(name);
|
|
350
388
|
else this._video.setAttribute(name, value);
|
|
351
389
|
}
|
|
@@ -499,6 +537,20 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
499
537
|
private _buildSettingsMenu(): void {
|
|
500
538
|
const sections: string[] = [];
|
|
501
539
|
|
|
540
|
+
// Fit mode — opt-in via the `show-fit` attribute. Off by default so
|
|
541
|
+
// chromeless consumers don't get a surprise entry they have to theme
|
|
542
|
+
// around.
|
|
543
|
+
if (this.hasAttribute("show-fit")) {
|
|
544
|
+
const currentFit = (this._video.fit ?? "contain") as FitMode;
|
|
545
|
+
let fitItems = "";
|
|
546
|
+
for (const mode of FIT_MODES) {
|
|
547
|
+
const active = mode === currentFit;
|
|
548
|
+
const label = mode[0].toUpperCase() + mode.slice(1);
|
|
549
|
+
fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
|
|
550
|
+
}
|
|
551
|
+
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
|
|
552
|
+
}
|
|
553
|
+
|
|
502
554
|
// Playback speed
|
|
503
555
|
const currentRate = this._video.playbackRate ?? 1;
|
|
504
556
|
let speedItems = "";
|
|
@@ -535,6 +587,14 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
535
587
|
this._settingsMenu.innerHTML = sections.join("");
|
|
536
588
|
|
|
537
589
|
// Bind click handlers
|
|
590
|
+
for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
|
|
591
|
+
item.addEventListener("click", (e) => {
|
|
592
|
+
e.stopPropagation();
|
|
593
|
+
const mode = (item as HTMLElement).dataset.fit as FitMode;
|
|
594
|
+
this.setAttribute("fit", mode);
|
|
595
|
+
this._buildSettingsMenu();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
538
598
|
for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
|
|
539
599
|
item.addEventListener("click", (e) => {
|
|
540
600
|
e.stopPropagation();
|
|
@@ -622,6 +682,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
622
682
|
|
|
623
683
|
private _showControls(): void {
|
|
624
684
|
this.removeAttribute("data-controls-hidden");
|
|
685
|
+
this._toolbarTop.setAttribute("data-visible", "true");
|
|
625
686
|
this._scheduleHide();
|
|
626
687
|
}
|
|
627
688
|
|
|
@@ -632,6 +693,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
632
693
|
this._controlsTimer = setTimeout(() => {
|
|
633
694
|
if (this._state === "playing") {
|
|
634
695
|
this.setAttribute("data-controls-hidden", "");
|
|
696
|
+
this._toolbarTop.setAttribute("data-visible", "false");
|
|
635
697
|
}
|
|
636
698
|
}, CONTROLS_HIDE_MS);
|
|
637
699
|
}
|
|
@@ -649,9 +711,21 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
649
711
|
/** Track whether the last interaction was touch so click handler can skip. */
|
|
650
712
|
private _lastPointerTypeWasTouch = false;
|
|
651
713
|
|
|
714
|
+
/** True if the event's composed path passes through consumer-slotted toolbar
|
|
715
|
+
* content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
|
|
716
|
+
* on the event target won't find the shadow-DOM wrapper — `composedPath()`
|
|
717
|
+
* does. */
|
|
718
|
+
private _isToolbarEvent(e: Event): boolean {
|
|
719
|
+
for (const node of e.composedPath()) {
|
|
720
|
+
if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
|
|
721
|
+
}
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
|
|
652
725
|
private _onContainerClick(e: MouseEvent): void {
|
|
653
726
|
// Ignore clicks on controls
|
|
654
727
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
728
|
+
if (this._isToolbarEvent(e)) return;
|
|
655
729
|
|
|
656
730
|
// Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
|
|
657
731
|
// The browser fires a synthetic click after touchend — skip it.
|
|
@@ -670,6 +744,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
670
744
|
|
|
671
745
|
private _onContainerDblClick(e: MouseEvent): void {
|
|
672
746
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
|
|
747
|
+
if (this._isToolbarEvent(e)) return;
|
|
673
748
|
// Cancel the pending single-click play/pause
|
|
674
749
|
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
675
750
|
this._toggleFullscreen();
|
|
@@ -695,6 +770,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
695
770
|
|
|
696
771
|
// Ignore touches on controls — buttons have their own handlers
|
|
697
772
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
773
|
+
if (this._isToolbarEvent(e)) return;
|
|
698
774
|
|
|
699
775
|
// Double-tap detection
|
|
700
776
|
const now = Date.now();
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* 3. Give consumers a `<video>`-compatible primitive they can wrap with
|
|
11
11
|
* their own UI.
|
|
12
12
|
*
|
|
13
|
-
* **It is not a player UI framework.**
|
|
14
|
-
*
|
|
13
|
+
* **It is not a player UI framework.** For YouTube-style chrome (seek
|
|
14
|
+
* bar, play/pause, settings menu, fullscreen, auto-hiding controls) use
|
|
15
|
+
* `<avbridge-player>` — it wraps this element with a full UI. See
|
|
15
16
|
* `docs/dev/WEB_COMPONENT_SPEC.md` for the full spec, lifecycle invariants,
|
|
16
17
|
* and edge case list.
|
|
17
18
|
*/
|
|
@@ -38,6 +39,11 @@ const PREFERRED_STRATEGY_VALUES = new Set<PreferredStrategy>([
|
|
|
38
39
|
"fallback",
|
|
39
40
|
]);
|
|
40
41
|
|
|
42
|
+
/** Fit mode — how the video fills the element's box. Mirrors CSS object-fit. */
|
|
43
|
+
type FitMode = "contain" | "cover" | "fill";
|
|
44
|
+
const FIT_VALUES = new Set<FitMode>(["contain", "cover", "fill"]);
|
|
45
|
+
const DEFAULT_FIT: FitMode = "contain";
|
|
46
|
+
|
|
41
47
|
/**
|
|
42
48
|
* Standard `HTMLMediaElement` events we forward from the inner `<video>`
|
|
43
49
|
* to the wrapper element so consumers can `el.addEventListener("loadedmetadata", ...)`
|
|
@@ -110,6 +116,8 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
110
116
|
"disableremoteplayback",
|
|
111
117
|
"diagnostics",
|
|
112
118
|
"preferstrategy",
|
|
119
|
+
"fit",
|
|
120
|
+
"no-orientation-lock",
|
|
113
121
|
];
|
|
114
122
|
|
|
115
123
|
// ── Internal state ─────────────────────────────────────────────────────
|
|
@@ -172,6 +180,14 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
172
180
|
*/
|
|
173
181
|
private _preferredStrategy: PreferredStrategy = "auto";
|
|
174
182
|
|
|
183
|
+
/** Current fit mode. Applied to the inner `<video>` via object-fit, and
|
|
184
|
+
* to the fallback canvas via the `--avbridge-fit` CSS custom property on
|
|
185
|
+
* the stage wrapper (see `src/strategies/fallback/video-renderer.ts`). */
|
|
186
|
+
private _fit: FitMode = DEFAULT_FIT;
|
|
187
|
+
/** The stage wrapper — the element the canvas attaches into, and where
|
|
188
|
+
* the `--avbridge-fit` CSS custom property lives. */
|
|
189
|
+
private _stageEl!: HTMLDivElement;
|
|
190
|
+
|
|
175
191
|
/** Set if currentTime was assigned before the player was ready. */
|
|
176
192
|
private _pendingSeek: number | null = null;
|
|
177
193
|
/** Set if play() was called before the player was ready. */
|
|
@@ -180,6 +196,14 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
180
196
|
/** MutationObserver tracking light-DOM `<track>` children. */
|
|
181
197
|
private _trackObserver: MutationObserver | null = null;
|
|
182
198
|
|
|
199
|
+
/** Document-level fullscreenchange handler — installed while connected so
|
|
200
|
+
* the element can lock/unlock screen orientation to match the video's
|
|
201
|
+
* intrinsic aspect. */
|
|
202
|
+
private _fullscreenChangeHandler: (() => void) | null = null;
|
|
203
|
+
/** True if we successfully called screen.orientation.lock() on the last
|
|
204
|
+
* fullscreen entry. Used to know whether to unlock on exit. */
|
|
205
|
+
private _orientationLocked = false;
|
|
206
|
+
|
|
183
207
|
// ── Construction & lifecycle ───────────────────────────────────────────
|
|
184
208
|
|
|
185
209
|
constructor() {
|
|
@@ -193,12 +217,13 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
193
217
|
// not an Element) and the canvas would never attach to the DOM.
|
|
194
218
|
const stage = document.createElement("div");
|
|
195
219
|
stage.setAttribute("part", "stage");
|
|
196
|
-
stage.style.cssText =
|
|
220
|
+
stage.style.cssText = `position:relative;width:100%;height:100%;display:block;--avbridge-fit:${DEFAULT_FIT};`;
|
|
197
221
|
root.appendChild(stage);
|
|
222
|
+
this._stageEl = stage;
|
|
198
223
|
|
|
199
224
|
this._videoEl = document.createElement("video");
|
|
200
225
|
this._videoEl.setAttribute("part", "video");
|
|
201
|
-
this._videoEl.style.cssText =
|
|
226
|
+
this._videoEl.style.cssText = `width:100%;height:100%;display:block;background:#000;object-fit:var(--avbridge-fit, ${DEFAULT_FIT});`;
|
|
202
227
|
this._videoEl.playsInline = true;
|
|
203
228
|
stage.appendChild(this._videoEl);
|
|
204
229
|
|
|
@@ -233,6 +258,10 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
233
258
|
this._trackObserver = new MutationObserver(() => this._syncTextTracks());
|
|
234
259
|
this._trackObserver.observe(this, { childList: true, subtree: false });
|
|
235
260
|
}
|
|
261
|
+
if (!this._fullscreenChangeHandler) {
|
|
262
|
+
this._fullscreenChangeHandler = () => this._onFullscreenChange();
|
|
263
|
+
document.addEventListener("fullscreenchange", this._fullscreenChangeHandler);
|
|
264
|
+
}
|
|
236
265
|
// Connection is the trigger for bootstrap. If we have a pending source
|
|
237
266
|
// (set before connect), kick off bootstrap now.
|
|
238
267
|
const source = this._activeSource();
|
|
@@ -247,6 +276,13 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
247
276
|
this._trackObserver.disconnect();
|
|
248
277
|
this._trackObserver = null;
|
|
249
278
|
}
|
|
279
|
+
if (this._fullscreenChangeHandler) {
|
|
280
|
+
document.removeEventListener("fullscreenchange", this._fullscreenChangeHandler);
|
|
281
|
+
this._fullscreenChangeHandler = null;
|
|
282
|
+
}
|
|
283
|
+
// If we were fullscreen via some ancestor and got disconnected, release
|
|
284
|
+
// any orientation lock we had taken.
|
|
285
|
+
this._releaseOrientationLock();
|
|
250
286
|
// Bump the bootstrap token so any in-flight async work is invalidated
|
|
251
287
|
// before we tear down. _teardown() also bumps but we want the bump to
|
|
252
288
|
// happen synchronously here so any awaited promise that resolves
|
|
@@ -287,6 +323,16 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
287
323
|
this._preferredStrategy = "auto";
|
|
288
324
|
}
|
|
289
325
|
break;
|
|
326
|
+
case "fit": {
|
|
327
|
+
const next: FitMode = newValue && FIT_VALUES.has(newValue as FitMode)
|
|
328
|
+
? (newValue as FitMode)
|
|
329
|
+
: DEFAULT_FIT;
|
|
330
|
+
if (next === this._fit) break;
|
|
331
|
+
this._fit = next;
|
|
332
|
+
this._stageEl.style.setProperty("--avbridge-fit", next);
|
|
333
|
+
this._dispatch("fitchange", { fit: next });
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
290
336
|
}
|
|
291
337
|
}
|
|
292
338
|
|
|
@@ -587,6 +633,15 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
587
633
|
else this.removeAttribute("diagnostics");
|
|
588
634
|
}
|
|
589
635
|
|
|
636
|
+
get fit(): FitMode {
|
|
637
|
+
return this._fit;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
set fit(value: FitMode) {
|
|
641
|
+
if (!FIT_VALUES.has(value)) return;
|
|
642
|
+
this.setAttribute("fit", value);
|
|
643
|
+
}
|
|
644
|
+
|
|
590
645
|
get preferredStrategy(): PreferredStrategy {
|
|
591
646
|
return this._preferredStrategy;
|
|
592
647
|
}
|
|
@@ -796,6 +851,95 @@ export class AvbridgeVideoElement extends HTMLElementCtor {
|
|
|
796
851
|
);
|
|
797
852
|
}
|
|
798
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Disable the automatic `screen.orientation.lock()` that runs on
|
|
856
|
+
* fullscreen entry. Set when you want to honor the device's native
|
|
857
|
+
* auto-rotate instead of matching the video's intrinsic orientation.
|
|
858
|
+
*/
|
|
859
|
+
get noOrientationLock(): boolean {
|
|
860
|
+
return this.hasAttribute("no-orientation-lock");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
set noOrientationLock(value: boolean) {
|
|
864
|
+
if (value) this.setAttribute("no-orientation-lock", "");
|
|
865
|
+
else this.removeAttribute("no-orientation-lock");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ── Fullscreen orientation lock ────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
/** Called whenever `document.fullscreenchange` fires. If this element (or
|
|
871
|
+
* any of its ancestors) is now fullscreen, derive the target orientation
|
|
872
|
+
* from the video's intrinsic size and call `screen.orientation.lock()`.
|
|
873
|
+
* On exit, release the lock we took. iOS Safari rejects `lock()` — we
|
|
874
|
+
* swallow the rejection so nothing breaks on that path. */
|
|
875
|
+
private _onFullscreenChange(): void {
|
|
876
|
+
if (this._destroyed) return;
|
|
877
|
+
const fsEl = document.fullscreenElement;
|
|
878
|
+
const nowFullscreen = fsEl != null && this._isInsideOrEquals(fsEl);
|
|
879
|
+
if (nowFullscreen && !this._orientationLocked) {
|
|
880
|
+
if (this.noOrientationLock) return;
|
|
881
|
+
const target = this._desiredOrientation();
|
|
882
|
+
if (!target) return; // square or unknown — don't lock
|
|
883
|
+
void this._lockOrientation(target);
|
|
884
|
+
} else if (!nowFullscreen && this._orientationLocked) {
|
|
885
|
+
this._releaseOrientationLock();
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Walk composed-tree ancestors to see if `target` is this element or
|
|
890
|
+
* any ancestor across shadow boundaries. `Node.contains()` can't cross
|
|
891
|
+
* shadow roots, so when `<avbridge-player>` (the fullscreen element)
|
|
892
|
+
* hosts this `<avbridge-video>` inside its shadow DOM, `contains()`
|
|
893
|
+
* returns false. */
|
|
894
|
+
private _isInsideOrEquals(target: Element): boolean {
|
|
895
|
+
let node: Node | null = this;
|
|
896
|
+
while (node) {
|
|
897
|
+
if (node === target) return true;
|
|
898
|
+
const parent: Node | null = node.parentNode;
|
|
899
|
+
if (parent instanceof ShadowRoot) node = parent.host;
|
|
900
|
+
else node = parent;
|
|
901
|
+
}
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Derive "landscape" / "portrait" from the intrinsic video dimensions.
|
|
906
|
+
* Returns null when dimensions aren't known yet or the video is square.
|
|
907
|
+
* Uses `videoWidth` / `videoHeight` from the inner `<video>`, which the
|
|
908
|
+
* browser sets to the display-aspect-corrected size (so anamorphic
|
|
909
|
+
* content is judged by its display aspect, not pixel aspect). */
|
|
910
|
+
private _desiredOrientation(): "landscape" | "portrait" | null {
|
|
911
|
+
const w = this._videoEl.videoWidth;
|
|
912
|
+
const h = this._videoEl.videoHeight;
|
|
913
|
+
if (!w || !h) return null;
|
|
914
|
+
if (w === h) return null;
|
|
915
|
+
return w > h ? "landscape" : "portrait";
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Attempt to lock screen orientation. Swallows rejections — iOS Safari
|
|
919
|
+
* doesn't implement `lock()`, and desktop / non-fullscreen contexts will
|
|
920
|
+
* reject too. Records success so we know whether to unlock on exit. */
|
|
921
|
+
private async _lockOrientation(target: "landscape" | "portrait"): Promise<void> {
|
|
922
|
+
const so = (screen as Screen & {
|
|
923
|
+
orientation?: ScreenOrientation & { lock?: (o: string) => Promise<void> };
|
|
924
|
+
}).orientation;
|
|
925
|
+
if (!so || typeof so.lock !== "function") return;
|
|
926
|
+
try {
|
|
927
|
+
await so.lock(target);
|
|
928
|
+
this._orientationLocked = true;
|
|
929
|
+
} catch {
|
|
930
|
+
// iOS Safari, desktop, or user denied — ignore.
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private _releaseOrientationLock(): void {
|
|
935
|
+
if (!this._orientationLocked) return;
|
|
936
|
+
this._orientationLocked = false;
|
|
937
|
+
const so = screen.orientation as ScreenOrientation | undefined;
|
|
938
|
+
if (so && typeof so.unlock === "function") {
|
|
939
|
+
try { so.unlock(); } catch { /* ignore */ }
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
799
943
|
// ── Public methods ─────────────────────────────────────────────────────
|
|
800
944
|
|
|
801
945
|
/** Force a (re-)bootstrap if a source is currently set. */
|
|
@@ -197,6 +197,50 @@ export const PLAYER_STYLES = /* css */ `
|
|
|
197
197
|
|
|
198
198
|
:host([data-controls-hidden]) { cursor: none; }
|
|
199
199
|
|
|
200
|
+
/* ── Top toolbar (slotted consumer chrome) ─────────────────────────────
|
|
201
|
+
Two named slots (top-left, top-right) let consumers place back / title /
|
|
202
|
+
translate buttons inside the auto-hide chrome. Wrapper has
|
|
203
|
+
pointer-events:none so empty slots don't block container clicks; each
|
|
204
|
+
side re-enables pointer-events so real buttons remain interactive. */
|
|
205
|
+
|
|
206
|
+
.avp-toolbar-top {
|
|
207
|
+
position: absolute;
|
|
208
|
+
top: 0;
|
|
209
|
+
left: 0;
|
|
210
|
+
right: 0;
|
|
211
|
+
z-index: 5;
|
|
212
|
+
padding: 8px 12px 24px;
|
|
213
|
+
background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: flex-start;
|
|
216
|
+
justify-content: space-between;
|
|
217
|
+
gap: 8px;
|
|
218
|
+
opacity: 1;
|
|
219
|
+
pointer-events: none;
|
|
220
|
+
transition: opacity 0.25s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.avp-toolbar-top-left,
|
|
224
|
+
.avp-toolbar-top-right {
|
|
225
|
+
display: flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
gap: 8px;
|
|
228
|
+
pointer-events: auto;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.avp-toolbar-top-right { margin-left: auto; }
|
|
232
|
+
|
|
233
|
+
/* Hide the gradient band when no consumer has slotted anything — we
|
|
234
|
+
toggle data-toolbar-empty from JS via slotchange. */
|
|
235
|
+
:host([data-toolbar-empty]) .avp-toolbar-top {
|
|
236
|
+
background: none;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
:host([data-controls-hidden]) .avp-toolbar-top {
|
|
240
|
+
opacity: 0;
|
|
241
|
+
pointer-events: none;
|
|
242
|
+
}
|
|
243
|
+
|
|
200
244
|
/* ── Seek bar ─────────────────────────────────────────────────────────── */
|
|
201
245
|
|
|
202
246
|
.avp-seek {
|
package/src/element.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* The registration is guarded so re-importing this module (e.g. via HMR or
|
|
10
10
|
* multiple bundles) does not throw a "name already defined" error.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Only `<avbridge-video>` (the bare HTMLMediaElement-compatible primitive)
|
|
13
|
+
* is registered here. The chrome-bearing `<avbridge-player>` lives at the
|
|
14
|
+
* `avbridge/player-element` subpath.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { AvbridgeVideoElement } from "./element/avbridge-video.js";
|
|
@@ -59,7 +59,16 @@ export interface StartDecoderOptions {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHandles> {
|
|
62
|
-
|
|
62
|
+
// Fallback always does full software decode. The "webcodecs" libav
|
|
63
|
+
// variant is trimmed to demuxing + WebCodecs-companion use; it lacks
|
|
64
|
+
// software decoders for codecs whose browsers usually handle them
|
|
65
|
+
// (e.g. h265). When we've reached fallback for those codecs, it's
|
|
66
|
+
// precisely because the browser *can't* decode them — so we need
|
|
67
|
+
// the full "avbridge" variant with software decoders. pickLibavVariant
|
|
68
|
+
// is still right for the hybrid strategy (which software-decodes only
|
|
69
|
+
// audio and relies on WebCodecs for video), but not here.
|
|
70
|
+
const variant: LibavVariant = "avbridge";
|
|
71
|
+
void pickLibavVariant; // kept in scope for future opt-in use
|
|
63
72
|
const libav = (await loadLibav(variant)) as unknown as LibavRuntime;
|
|
64
73
|
const bridge = await loadBridge();
|
|
65
74
|
|
|
@@ -137,13 +146,11 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
137
146
|
videoStream ? `video: ${opts.context.videoTracks[0]?.codec ?? "unknown"}` : null,
|
|
138
147
|
audioStream ? `audio: ${opts.context.audioTracks[0]?.codec ?? "unknown"}` : null,
|
|
139
148
|
].filter(Boolean).join(", ");
|
|
140
|
-
const hint = variant === "webcodecs"
|
|
141
|
-
? ` The "${variant}" libav variant does not include software decoders for these codecs. ` +
|
|
142
|
-
`Try the custom "avbridge" variant (scripts/build-libav.sh) for broader codec support, ` +
|
|
143
|
-
`or use a lighter strategy (native, remux, hybrid) instead.`
|
|
144
|
-
: "";
|
|
145
149
|
throw new Error(
|
|
146
|
-
`fallback decoder: could not initialize any libav decoders (${codecs})
|
|
150
|
+
`fallback decoder: could not initialize any libav decoders (${codecs}). ` +
|
|
151
|
+
`The "${variant}" libav variant lacks software decoders for these codecs — ` +
|
|
152
|
+
`rebuild with scripts/build-libav.sh including the missing decoder, ` +
|
|
153
|
+
`or use a lighter strategy (native, remux, hybrid) instead.`,
|
|
147
154
|
);
|
|
148
155
|
}
|
|
149
156
|
|
|
@@ -84,12 +84,14 @@ export class VideoRenderer {
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
this.canvas = document.createElement("canvas");
|
|
87
|
-
// object-fit
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
87
|
+
// `object-fit` is driven by the `--avbridge-fit` custom property so the
|
|
88
|
+
// `<avbridge-video>` element's `fit` attribute can retarget the canvas
|
|
89
|
+
// without reaching into the fallback strategy. Default is `contain` —
|
|
90
|
+
// letterboxes the canvas bitmap (sized to frame.displayWidth ×
|
|
91
|
+
// displayHeight in paint()) inside the stage so portrait / non-stage-aspect
|
|
92
|
+
// content isn't stretched. Canvas is a replaced element, so object-fit applies.
|
|
91
93
|
this.canvas.style.cssText =
|
|
92
|
-
"position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
|
|
94
|
+
"position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:var(--avbridge-fit, contain);";
|
|
93
95
|
|
|
94
96
|
// Attach the canvas next to the video. When the video lives inside an
|
|
95
97
|
// `<avbridge-video>` shadow root, `target.parentElement` is the
|
package/src/types.ts
CHANGED
|
@@ -342,6 +342,7 @@ export interface AvbridgeVideoElementEventMap {
|
|
|
342
342
|
error: CustomEvent<{ error: Error; diagnostics: DiagnosticsSnapshot | null }>;
|
|
343
343
|
progress: CustomEvent<{ buffered: TimeRanges }>;
|
|
344
344
|
loadstart: CustomEvent<Record<string, never>>;
|
|
345
|
+
fitchange: CustomEvent<{ fit: "contain" | "cover" | "fill" }>;
|
|
345
346
|
}
|
|
346
347
|
|
|
347
348
|
// ── Conversion types ────────────────────────────────────────────────────
|