avbridge 2.7.0 → 2.8.1
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 +87 -0
- package/README.md +23 -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 +222 -11
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +61 -3
- package/dist/player.d.ts +61 -3
- package/dist/player.js +222 -11
- 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 +40 -5
- 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,6 +58,7 @@ 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
|
|
|
63
64
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -102,6 +103,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
102
103
|
private _statsEl!: HTMLDivElement;
|
|
103
104
|
private _statsInterval: ReturnType<typeof setInterval> | null = null;
|
|
104
105
|
private _eventCleanup: (() => void)[] = [];
|
|
106
|
+
private _updateToolbarEmpty: () => void = () => { /* wired in constructor */ };
|
|
105
107
|
|
|
106
108
|
// ── Constructor ────────────────────────────────────────────────────────
|
|
107
109
|
|
|
@@ -132,6 +134,18 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
132
134
|
this._rippleLeft = shadow.querySelector(".avp-ripple-left") as HTMLDivElement;
|
|
133
135
|
this._rippleRight = shadow.querySelector(".avp-ripple-right") as HTMLDivElement;
|
|
134
136
|
|
|
137
|
+
// Track whether the top toolbar has any slotted content. Used to hide
|
|
138
|
+
// its gradient when empty (see data-toolbar-empty in player-styles.ts).
|
|
139
|
+
// MUST defer the initial attribute write to connectedCallback — the
|
|
140
|
+
// Custom Elements spec forbids constructors from adding attributes.
|
|
141
|
+
const slots = shadow.querySelectorAll<HTMLSlotElement>('slot[name="top-left"], slot[name="top-right"]');
|
|
142
|
+
this._updateToolbarEmpty = () => {
|
|
143
|
+
const hasContent = Array.from(slots).some((s) => s.assignedNodes({ flatten: true }).length > 0);
|
|
144
|
+
if (hasContent) this.removeAttribute("data-toolbar-empty");
|
|
145
|
+
else this.setAttribute("data-toolbar-empty", "");
|
|
146
|
+
};
|
|
147
|
+
for (const s of slots) s.addEventListener("slotchange", this._updateToolbarEmpty);
|
|
148
|
+
|
|
135
149
|
this._bindEvents();
|
|
136
150
|
}
|
|
137
151
|
|
|
@@ -139,6 +153,10 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
139
153
|
return `
|
|
140
154
|
<div part="container" class="avp">
|
|
141
155
|
<avbridge-video part="video"></avbridge-video>
|
|
156
|
+
<div part="toolbar-top" class="avp-toolbar-top">
|
|
157
|
+
<div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
|
|
158
|
+
<div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
|
|
159
|
+
</div>
|
|
142
160
|
<div part="overlay" class="avp-overlay">
|
|
143
161
|
<button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
|
|
144
162
|
<div class="avp-spinner"></div>
|
|
@@ -326,17 +344,20 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
326
344
|
|
|
327
345
|
// Keyboard
|
|
328
346
|
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
347
|
}
|
|
335
348
|
|
|
336
349
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
337
350
|
|
|
338
351
|
connectedCallback(): void {
|
|
339
352
|
this._setState("idle");
|
|
353
|
+
// Attribute writes the Custom Elements spec forbids in constructors
|
|
354
|
+
// — document.createElement rejects with "The result must not have
|
|
355
|
+
// attributes" — deferred here. Surfaced by the Playwright
|
|
356
|
+
// cross-browser tests.
|
|
357
|
+
if (!this.hasAttribute("tabindex")) {
|
|
358
|
+
this.setAttribute("tabindex", "0");
|
|
359
|
+
}
|
|
360
|
+
this._updateToolbarEmpty();
|
|
340
361
|
}
|
|
341
362
|
|
|
342
363
|
disconnectedCallback(): void {
|
|
@@ -649,9 +670,21 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
649
670
|
/** Track whether the last interaction was touch so click handler can skip. */
|
|
650
671
|
private _lastPointerTypeWasTouch = false;
|
|
651
672
|
|
|
673
|
+
/** True if the event's composed path passes through consumer-slotted toolbar
|
|
674
|
+
* content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
|
|
675
|
+
* on the event target won't find the shadow-DOM wrapper — `composedPath()`
|
|
676
|
+
* does. */
|
|
677
|
+
private _isToolbarEvent(e: Event): boolean {
|
|
678
|
+
for (const node of e.composedPath()) {
|
|
679
|
+
if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
|
|
652
684
|
private _onContainerClick(e: MouseEvent): void {
|
|
653
685
|
// Ignore clicks on controls
|
|
654
686
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
687
|
+
if (this._isToolbarEvent(e)) return;
|
|
655
688
|
|
|
656
689
|
// Touch taps are handled by _onPointerUp (show/hide controls + double-tap).
|
|
657
690
|
// The browser fires a synthetic click after touchend — skip it.
|
|
@@ -670,6 +703,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
670
703
|
|
|
671
704
|
private _onContainerDblClick(e: MouseEvent): void {
|
|
672
705
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings")) return;
|
|
706
|
+
if (this._isToolbarEvent(e)) return;
|
|
673
707
|
// Cancel the pending single-click play/pause
|
|
674
708
|
if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; }
|
|
675
709
|
this._toggleFullscreen();
|
|
@@ -695,6 +729,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
695
729
|
|
|
696
730
|
// Ignore touches on controls — buttons have their own handlers
|
|
697
731
|
if ((e.target as HTMLElement).closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
732
|
+
if (this._isToolbarEvent(e)) return;
|
|
698
733
|
|
|
699
734
|
// Double-tap detection
|
|
700
735
|
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 ────────────────────────────────────────────────────
|