avbridge 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.6.0",
3
+ "version": "2.8.1",
4
4
  "description": "Play and convert arbitrary video files in the browser. Native, remux, hybrid, fallback, and transcode — one API.",
5
5
  "license": "MIT",
6
6
  "author": "Keishi Hattori",
@@ -92,6 +92,11 @@
92
92
  "test:element": "node scripts/element-test.mjs",
93
93
  "test:player-controls": "node scripts/player-controls-test.mjs",
94
94
  "test:url-streaming": "node scripts/url-streaming-test.mjs",
95
+ "test:browser": "playwright test",
96
+ "test:browser:chromium": "playwright test --project=chromium",
97
+ "test:browser:firefox": "playwright test --project=firefox",
98
+ "test:browser:webkit": "playwright test --project=webkit",
99
+ "test:browser:ui": "playwright test --ui",
95
100
  "fixtures": "node scripts/generate-fixtures.mjs",
96
101
  "audit:bundle": "node scripts/bundle-audit.mjs"
97
102
  },
@@ -104,6 +109,7 @@
104
109
  "@libav.js/types": "^6.8.8"
105
110
  },
106
111
  "devDependencies": {
112
+ "@playwright/test": "^1.59.1",
107
113
  "@types/node": "^20.11.0",
108
114
  "jsdom": "^24.0.0",
109
115
  "puppeteer": "^24.40.0",
@@ -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.** The tag name `<avbridge-player>` is
14
- * reserved for a future controls-bearing element. See
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 = "position:relative;width:100%;height:100%;display:block;";
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 = "width:100%;height:100%;display:block;background:#000;";
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
- * The tag name `<avbridge-player>` is reserved for a future controls-bearing
13
- * element. Today, only `<avbridge-video>` (the bare HTMLMediaElement-compatible
14
- * primitive) is registered.
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
- const variant: LibavVariant = pickLibavVariant(opts.context);
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}).${hint}`,
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:contain letterboxes the canvas bitmap (sized to
88
- // frame.displayWidth × displayHeight in paint()) inside the stage so
89
- // portrait / non-stage-aspect content isn't stretched. Canvas is a
90
- // replaced element, so object-fit applies.
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 ────────────────────────────────────────────────────