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/dist/player.js
CHANGED
|
@@ -494,6 +494,22 @@ function classifyContext(ctx) {
|
|
|
494
494
|
};
|
|
495
495
|
}
|
|
496
496
|
if (REMUXABLE_CONTAINERS.has(ctx.container)) {
|
|
497
|
+
const mime = mp4MimeFor(video, audio);
|
|
498
|
+
if (mime && typeof MediaSource !== "undefined" && !mseSupports(mime)) {
|
|
499
|
+
if (webCodecsAvailable()) {
|
|
500
|
+
return {
|
|
501
|
+
class: "HYBRID_CANDIDATE",
|
|
502
|
+
strategy: "hybrid",
|
|
503
|
+
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime \u2014 routing to WebCodecs hardware decode`,
|
|
504
|
+
fallbackChain: ["fallback"]
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
class: "FALLBACK_REQUIRED",
|
|
509
|
+
strategy: "fallback",
|
|
510
|
+
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable \u2014 falling back to WASM decode`
|
|
511
|
+
};
|
|
512
|
+
}
|
|
497
513
|
return {
|
|
498
514
|
class: "REMUX_CANDIDATE",
|
|
499
515
|
strategy: "remux",
|
|
@@ -1237,7 +1253,7 @@ var VideoRenderer = class {
|
|
|
1237
1253
|
this.resolveFirstFrame = resolve;
|
|
1238
1254
|
});
|
|
1239
1255
|
this.canvas = document.createElement("canvas");
|
|
1240
|
-
this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
|
|
1256
|
+
this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:var(--avbridge-fit, contain);";
|
|
1241
1257
|
const parent = target.parentElement ?? target.parentNode;
|
|
1242
1258
|
if (parent && parent instanceof HTMLElement) {
|
|
1243
1259
|
if (getComputedStyle(parent).position === "static") {
|
|
@@ -2580,7 +2596,7 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2580
2596
|
|
|
2581
2597
|
// src/strategies/fallback/decoder.ts
|
|
2582
2598
|
async function startDecoder(opts) {
|
|
2583
|
-
const variant =
|
|
2599
|
+
const variant = "avbridge";
|
|
2584
2600
|
const libav = await loadLibav(variant);
|
|
2585
2601
|
const bridge = await loadBridge2();
|
|
2586
2602
|
const { prepareLibavInput } = await import('./libav-http-reader-WXG3Z7AI.js');
|
|
@@ -2636,9 +2652,8 @@ async function startDecoder(opts) {
|
|
|
2636
2652
|
videoStream ? `video: ${opts.context.videoTracks[0]?.codec ?? "unknown"}` : null,
|
|
2637
2653
|
audioStream ? `audio: ${opts.context.audioTracks[0]?.codec ?? "unknown"}` : null
|
|
2638
2654
|
].filter(Boolean).join(", ");
|
|
2639
|
-
const hint = variant === "webcodecs" ? ` The "${variant}" libav variant does not include software decoders for these codecs. Try the custom "avbridge" variant (scripts/build-libav.sh) for broader codec support, or use a lighter strategy (native, remux, hybrid) instead.` : "";
|
|
2640
2655
|
throw new Error(
|
|
2641
|
-
`fallback decoder: could not initialize any libav decoders (${codecs})
|
|
2656
|
+
`fallback decoder: could not initialize any libav decoders (${codecs}). The "${variant}" libav variant lacks software decoders for these codecs \u2014 rebuild with scripts/build-libav.sh including the missing decoder, or use a lighter strategy (native, remux, hybrid) instead.`
|
|
2642
2657
|
);
|
|
2643
2658
|
}
|
|
2644
2659
|
let bsfCtx = null;
|
|
@@ -3765,6 +3780,8 @@ var PREFERRED_STRATEGY_VALUES = /* @__PURE__ */ new Set([
|
|
|
3765
3780
|
"hybrid",
|
|
3766
3781
|
"fallback"
|
|
3767
3782
|
]);
|
|
3783
|
+
var FIT_VALUES = /* @__PURE__ */ new Set(["contain", "cover", "fill"]);
|
|
3784
|
+
var DEFAULT_FIT = "contain";
|
|
3768
3785
|
var FORWARDED_VIDEO_EVENTS = [
|
|
3769
3786
|
"loadstart",
|
|
3770
3787
|
"loadedmetadata",
|
|
@@ -3799,7 +3816,9 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
3799
3816
|
"crossorigin",
|
|
3800
3817
|
"disableremoteplayback",
|
|
3801
3818
|
"diagnostics",
|
|
3802
|
-
"preferstrategy"
|
|
3819
|
+
"preferstrategy",
|
|
3820
|
+
"fit",
|
|
3821
|
+
"no-orientation-lock"
|
|
3803
3822
|
];
|
|
3804
3823
|
// ── Internal state ─────────────────────────────────────────────────────
|
|
3805
3824
|
/** The shadow DOM `<video>` element that strategies render into. */
|
|
@@ -3851,23 +3870,38 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
3851
3870
|
* native fails.
|
|
3852
3871
|
*/
|
|
3853
3872
|
_preferredStrategy = "auto";
|
|
3873
|
+
/** Current fit mode. Applied to the inner `<video>` via object-fit, and
|
|
3874
|
+
* to the fallback canvas via the `--avbridge-fit` CSS custom property on
|
|
3875
|
+
* the stage wrapper (see `src/strategies/fallback/video-renderer.ts`). */
|
|
3876
|
+
_fit = DEFAULT_FIT;
|
|
3877
|
+
/** The stage wrapper — the element the canvas attaches into, and where
|
|
3878
|
+
* the `--avbridge-fit` CSS custom property lives. */
|
|
3879
|
+
_stageEl;
|
|
3854
3880
|
/** Set if currentTime was assigned before the player was ready. */
|
|
3855
3881
|
_pendingSeek = null;
|
|
3856
3882
|
/** Set if play() was called before the player was ready. */
|
|
3857
3883
|
_pendingPlay = false;
|
|
3858
3884
|
/** MutationObserver tracking light-DOM `<track>` children. */
|
|
3859
3885
|
_trackObserver = null;
|
|
3886
|
+
/** Document-level fullscreenchange handler — installed while connected so
|
|
3887
|
+
* the element can lock/unlock screen orientation to match the video's
|
|
3888
|
+
* intrinsic aspect. */
|
|
3889
|
+
_fullscreenChangeHandler = null;
|
|
3890
|
+
/** True if we successfully called screen.orientation.lock() on the last
|
|
3891
|
+
* fullscreen entry. Used to know whether to unlock on exit. */
|
|
3892
|
+
_orientationLocked = false;
|
|
3860
3893
|
// ── Construction & lifecycle ───────────────────────────────────────────
|
|
3861
3894
|
constructor() {
|
|
3862
3895
|
super();
|
|
3863
3896
|
const root = this.attachShadow({ mode: "open" });
|
|
3864
3897
|
const stage = document.createElement("div");
|
|
3865
3898
|
stage.setAttribute("part", "stage");
|
|
3866
|
-
stage.style.cssText =
|
|
3899
|
+
stage.style.cssText = `position:relative;width:100%;height:100%;display:block;--avbridge-fit:${DEFAULT_FIT};`;
|
|
3867
3900
|
root.appendChild(stage);
|
|
3901
|
+
this._stageEl = stage;
|
|
3868
3902
|
this._videoEl = document.createElement("video");
|
|
3869
3903
|
this._videoEl.setAttribute("part", "video");
|
|
3870
|
-
this._videoEl.style.cssText =
|
|
3904
|
+
this._videoEl.style.cssText = `width:100%;height:100%;display:block;background:#000;object-fit:var(--avbridge-fit, ${DEFAULT_FIT});`;
|
|
3871
3905
|
this._videoEl.playsInline = true;
|
|
3872
3906
|
stage.appendChild(this._videoEl);
|
|
3873
3907
|
this._videoEl.addEventListener("progress", () => {
|
|
@@ -3888,6 +3922,10 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
3888
3922
|
this._trackObserver = new MutationObserver(() => this._syncTextTracks());
|
|
3889
3923
|
this._trackObserver.observe(this, { childList: true, subtree: false });
|
|
3890
3924
|
}
|
|
3925
|
+
if (!this._fullscreenChangeHandler) {
|
|
3926
|
+
this._fullscreenChangeHandler = () => this._onFullscreenChange();
|
|
3927
|
+
document.addEventListener("fullscreenchange", this._fullscreenChangeHandler);
|
|
3928
|
+
}
|
|
3891
3929
|
const source = this._activeSource();
|
|
3892
3930
|
if (source != null) {
|
|
3893
3931
|
void this._bootstrap(source);
|
|
@@ -3899,6 +3937,11 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
3899
3937
|
this._trackObserver.disconnect();
|
|
3900
3938
|
this._trackObserver = null;
|
|
3901
3939
|
}
|
|
3940
|
+
if (this._fullscreenChangeHandler) {
|
|
3941
|
+
document.removeEventListener("fullscreenchange", this._fullscreenChangeHandler);
|
|
3942
|
+
this._fullscreenChangeHandler = null;
|
|
3943
|
+
}
|
|
3944
|
+
this._releaseOrientationLock();
|
|
3902
3945
|
this._bootstrapId++;
|
|
3903
3946
|
void this._teardown();
|
|
3904
3947
|
}
|
|
@@ -3932,6 +3975,14 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
3932
3975
|
this._preferredStrategy = "auto";
|
|
3933
3976
|
}
|
|
3934
3977
|
break;
|
|
3978
|
+
case "fit": {
|
|
3979
|
+
const next = newValue && FIT_VALUES.has(newValue) ? newValue : DEFAULT_FIT;
|
|
3980
|
+
if (next === this._fit) break;
|
|
3981
|
+
this._fit = next;
|
|
3982
|
+
this._stageEl.style.setProperty("--avbridge-fit", next);
|
|
3983
|
+
this._dispatch("fitchange", { fit: next });
|
|
3984
|
+
break;
|
|
3985
|
+
}
|
|
3935
3986
|
}
|
|
3936
3987
|
}
|
|
3937
3988
|
// ── Source handling ────────────────────────────────────────────────────
|
|
@@ -4173,6 +4224,13 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
4173
4224
|
if (value) this.setAttribute("diagnostics", "");
|
|
4174
4225
|
else this.removeAttribute("diagnostics");
|
|
4175
4226
|
}
|
|
4227
|
+
get fit() {
|
|
4228
|
+
return this._fit;
|
|
4229
|
+
}
|
|
4230
|
+
set fit(value) {
|
|
4231
|
+
if (!FIT_VALUES.has(value)) return;
|
|
4232
|
+
this.setAttribute("fit", value);
|
|
4233
|
+
}
|
|
4176
4234
|
get preferredStrategy() {
|
|
4177
4235
|
return this._preferredStrategy;
|
|
4178
4236
|
}
|
|
@@ -4346,6 +4404,87 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
4346
4404
|
}
|
|
4347
4405
|
);
|
|
4348
4406
|
}
|
|
4407
|
+
/**
|
|
4408
|
+
* Disable the automatic `screen.orientation.lock()` that runs on
|
|
4409
|
+
* fullscreen entry. Set when you want to honor the device's native
|
|
4410
|
+
* auto-rotate instead of matching the video's intrinsic orientation.
|
|
4411
|
+
*/
|
|
4412
|
+
get noOrientationLock() {
|
|
4413
|
+
return this.hasAttribute("no-orientation-lock");
|
|
4414
|
+
}
|
|
4415
|
+
set noOrientationLock(value) {
|
|
4416
|
+
if (value) this.setAttribute("no-orientation-lock", "");
|
|
4417
|
+
else this.removeAttribute("no-orientation-lock");
|
|
4418
|
+
}
|
|
4419
|
+
// ── Fullscreen orientation lock ────────────────────────────────────────
|
|
4420
|
+
/** Called whenever `document.fullscreenchange` fires. If this element (or
|
|
4421
|
+
* any of its ancestors) is now fullscreen, derive the target orientation
|
|
4422
|
+
* from the video's intrinsic size and call `screen.orientation.lock()`.
|
|
4423
|
+
* On exit, release the lock we took. iOS Safari rejects `lock()` — we
|
|
4424
|
+
* swallow the rejection so nothing breaks on that path. */
|
|
4425
|
+
_onFullscreenChange() {
|
|
4426
|
+
if (this._destroyed) return;
|
|
4427
|
+
const fsEl = document.fullscreenElement;
|
|
4428
|
+
const nowFullscreen = fsEl != null && this._isInsideOrEquals(fsEl);
|
|
4429
|
+
if (nowFullscreen && !this._orientationLocked) {
|
|
4430
|
+
if (this.noOrientationLock) return;
|
|
4431
|
+
const target = this._desiredOrientation();
|
|
4432
|
+
if (!target) return;
|
|
4433
|
+
void this._lockOrientation(target);
|
|
4434
|
+
} else if (!nowFullscreen && this._orientationLocked) {
|
|
4435
|
+
this._releaseOrientationLock();
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
/** Walk composed-tree ancestors to see if `target` is this element or
|
|
4439
|
+
* any ancestor across shadow boundaries. `Node.contains()` can't cross
|
|
4440
|
+
* shadow roots, so when `<avbridge-player>` (the fullscreen element)
|
|
4441
|
+
* hosts this `<avbridge-video>` inside its shadow DOM, `contains()`
|
|
4442
|
+
* returns false. */
|
|
4443
|
+
_isInsideOrEquals(target) {
|
|
4444
|
+
let node = this;
|
|
4445
|
+
while (node) {
|
|
4446
|
+
if (node === target) return true;
|
|
4447
|
+
const parent = node.parentNode;
|
|
4448
|
+
if (parent instanceof ShadowRoot) node = parent.host;
|
|
4449
|
+
else node = parent;
|
|
4450
|
+
}
|
|
4451
|
+
return false;
|
|
4452
|
+
}
|
|
4453
|
+
/** Derive "landscape" / "portrait" from the intrinsic video dimensions.
|
|
4454
|
+
* Returns null when dimensions aren't known yet or the video is square.
|
|
4455
|
+
* Uses `videoWidth` / `videoHeight` from the inner `<video>`, which the
|
|
4456
|
+
* browser sets to the display-aspect-corrected size (so anamorphic
|
|
4457
|
+
* content is judged by its display aspect, not pixel aspect). */
|
|
4458
|
+
_desiredOrientation() {
|
|
4459
|
+
const w = this._videoEl.videoWidth;
|
|
4460
|
+
const h = this._videoEl.videoHeight;
|
|
4461
|
+
if (!w || !h) return null;
|
|
4462
|
+
if (w === h) return null;
|
|
4463
|
+
return w > h ? "landscape" : "portrait";
|
|
4464
|
+
}
|
|
4465
|
+
/** Attempt to lock screen orientation. Swallows rejections — iOS Safari
|
|
4466
|
+
* doesn't implement `lock()`, and desktop / non-fullscreen contexts will
|
|
4467
|
+
* reject too. Records success so we know whether to unlock on exit. */
|
|
4468
|
+
async _lockOrientation(target) {
|
|
4469
|
+
const so = screen.orientation;
|
|
4470
|
+
if (!so || typeof so.lock !== "function") return;
|
|
4471
|
+
try {
|
|
4472
|
+
await so.lock(target);
|
|
4473
|
+
this._orientationLocked = true;
|
|
4474
|
+
} catch {
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
_releaseOrientationLock() {
|
|
4478
|
+
if (!this._orientationLocked) return;
|
|
4479
|
+
this._orientationLocked = false;
|
|
4480
|
+
const so = screen.orientation;
|
|
4481
|
+
if (so && typeof so.unlock === "function") {
|
|
4482
|
+
try {
|
|
4483
|
+
so.unlock();
|
|
4484
|
+
} catch {
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4349
4488
|
// ── Public methods ─────────────────────────────────────────────────────
|
|
4350
4489
|
/** Force a (re-)bootstrap if a source is currently set. */
|
|
4351
4490
|
async load() {
|
|
@@ -4605,6 +4744,50 @@ var PLAYER_STYLES = (
|
|
|
4605
4744
|
|
|
4606
4745
|
:host([data-controls-hidden]) { cursor: none; }
|
|
4607
4746
|
|
|
4747
|
+
/* \u2500\u2500 Top toolbar (slotted consumer chrome) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4748
|
+
Two named slots (top-left, top-right) let consumers place back / title /
|
|
4749
|
+
translate buttons inside the auto-hide chrome. Wrapper has
|
|
4750
|
+
pointer-events:none so empty slots don't block container clicks; each
|
|
4751
|
+
side re-enables pointer-events so real buttons remain interactive. */
|
|
4752
|
+
|
|
4753
|
+
.avp-toolbar-top {
|
|
4754
|
+
position: absolute;
|
|
4755
|
+
top: 0;
|
|
4756
|
+
left: 0;
|
|
4757
|
+
right: 0;
|
|
4758
|
+
z-index: 5;
|
|
4759
|
+
padding: 8px 12px 24px;
|
|
4760
|
+
background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
|
|
4761
|
+
display: flex;
|
|
4762
|
+
align-items: flex-start;
|
|
4763
|
+
justify-content: space-between;
|
|
4764
|
+
gap: 8px;
|
|
4765
|
+
opacity: 1;
|
|
4766
|
+
pointer-events: none;
|
|
4767
|
+
transition: opacity 0.25s;
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
.avp-toolbar-top-left,
|
|
4771
|
+
.avp-toolbar-top-right {
|
|
4772
|
+
display: flex;
|
|
4773
|
+
align-items: center;
|
|
4774
|
+
gap: 8px;
|
|
4775
|
+
pointer-events: auto;
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
.avp-toolbar-top-right { margin-left: auto; }
|
|
4779
|
+
|
|
4780
|
+
/* Hide the gradient band when no consumer has slotted anything \u2014 we
|
|
4781
|
+
toggle data-toolbar-empty from JS via slotchange. */
|
|
4782
|
+
:host([data-toolbar-empty]) .avp-toolbar-top {
|
|
4783
|
+
background: none;
|
|
4784
|
+
}
|
|
4785
|
+
|
|
4786
|
+
:host([data-controls-hidden]) .avp-toolbar-top {
|
|
4787
|
+
opacity: 0;
|
|
4788
|
+
pointer-events: none;
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4608
4791
|
/* \u2500\u2500 Seek bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
4609
4792
|
|
|
4610
4793
|
.avp-seek {
|
|
@@ -4956,10 +5139,13 @@ var PROXY_ATTRIBUTES = [
|
|
|
4956
5139
|
"playsinline",
|
|
4957
5140
|
"crossorigin",
|
|
4958
5141
|
"disableremoteplayback",
|
|
4959
|
-
"preferstrategy"
|
|
5142
|
+
"preferstrategy",
|
|
5143
|
+
"fit"
|
|
4960
5144
|
];
|
|
5145
|
+
var PLAYER_ATTRIBUTES = ["show-fit"];
|
|
5146
|
+
var FIT_MODES = ["contain", "cover", "fill"];
|
|
4961
5147
|
var AvbridgePlayerElement = class extends HTMLElement {
|
|
4962
|
-
static observedAttributes = [...PROXY_ATTRIBUTES];
|
|
5148
|
+
static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
|
|
4963
5149
|
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
4964
5150
|
_video;
|
|
4965
5151
|
_playBtn;
|
|
@@ -4994,6 +5180,9 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
4994
5180
|
_statsEl;
|
|
4995
5181
|
_statsInterval = null;
|
|
4996
5182
|
_eventCleanup = [];
|
|
5183
|
+
_updateToolbarEmpty = () => {
|
|
5184
|
+
};
|
|
5185
|
+
_toolbarTop;
|
|
4997
5186
|
// ── Constructor ────────────────────────────────────────────────────────
|
|
4998
5187
|
constructor() {
|
|
4999
5188
|
super();
|
|
@@ -5017,12 +5206,25 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5017
5206
|
this._statsEl = shadow.querySelector(".avp-stats");
|
|
5018
5207
|
this._rippleLeft = shadow.querySelector(".avp-ripple-left");
|
|
5019
5208
|
this._rippleRight = shadow.querySelector(".avp-ripple-right");
|
|
5209
|
+
this._toolbarTop = shadow.querySelector('[part="toolbar-top"]');
|
|
5210
|
+
this._toolbarTop.setAttribute("data-visible", "true");
|
|
5211
|
+
const slots = shadow.querySelectorAll('slot[name="top-left"], slot[name="top-right"]');
|
|
5212
|
+
this._updateToolbarEmpty = () => {
|
|
5213
|
+
const hasContent = Array.from(slots).some((s) => s.assignedNodes({ flatten: true }).length > 0);
|
|
5214
|
+
if (hasContent) this.removeAttribute("data-toolbar-empty");
|
|
5215
|
+
else this.setAttribute("data-toolbar-empty", "");
|
|
5216
|
+
};
|
|
5217
|
+
for (const s of slots) s.addEventListener("slotchange", this._updateToolbarEmpty);
|
|
5020
5218
|
this._bindEvents();
|
|
5021
5219
|
}
|
|
5022
5220
|
_template() {
|
|
5023
5221
|
return `
|
|
5024
5222
|
<div part="container" class="avp">
|
|
5025
5223
|
<avbridge-video part="video"></avbridge-video>
|
|
5224
|
+
<div part="toolbar-top" class="avp-toolbar-top">
|
|
5225
|
+
<div part="toolbar-top-left" class="avp-toolbar-top-left"><slot name="top-left"></slot></div>
|
|
5226
|
+
<div part="toolbar-top-right" class="avp-toolbar-top-right"><slot name="top-right"></slot></div>
|
|
5227
|
+
</div>
|
|
5026
5228
|
<div part="overlay" class="avp-overlay">
|
|
5027
5229
|
<button class="avp-overlay-btn" aria-label="Play">${ICON_PLAY}</button>
|
|
5028
5230
|
<div class="avp-spinner"></div>
|
|
@@ -5175,19 +5377,26 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5175
5377
|
});
|
|
5176
5378
|
});
|
|
5177
5379
|
on(this, "keydown", (e) => this._onKeydown(e));
|
|
5178
|
-
if (!this.hasAttribute("tabindex")) {
|
|
5179
|
-
this.setAttribute("tabindex", "0");
|
|
5180
|
-
}
|
|
5181
5380
|
}
|
|
5182
5381
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
5183
5382
|
connectedCallback() {
|
|
5184
5383
|
this._setState("idle");
|
|
5384
|
+
if (!this.hasAttribute("tabindex")) {
|
|
5385
|
+
this.setAttribute("tabindex", "0");
|
|
5386
|
+
}
|
|
5387
|
+
this._updateToolbarEmpty();
|
|
5185
5388
|
}
|
|
5186
5389
|
disconnectedCallback() {
|
|
5187
5390
|
this._clearTimers();
|
|
5188
5391
|
}
|
|
5189
5392
|
attributeChangedCallback(name, _old, value) {
|
|
5190
5393
|
if (!this._video) return;
|
|
5394
|
+
if (PLAYER_ATTRIBUTES.includes(name)) {
|
|
5395
|
+
if (name === "show-fit" && this._settingsOpen) {
|
|
5396
|
+
this._buildSettingsMenu();
|
|
5397
|
+
}
|
|
5398
|
+
return;
|
|
5399
|
+
}
|
|
5191
5400
|
if (value == null) this._video.removeAttribute(name);
|
|
5192
5401
|
else this._video.setAttribute(name, value);
|
|
5193
5402
|
}
|
|
@@ -5310,6 +5519,16 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5310
5519
|
}
|
|
5311
5520
|
_buildSettingsMenu() {
|
|
5312
5521
|
const sections = [];
|
|
5522
|
+
if (this.hasAttribute("show-fit")) {
|
|
5523
|
+
const currentFit = this._video.fit ?? "contain";
|
|
5524
|
+
let fitItems = "";
|
|
5525
|
+
for (const mode of FIT_MODES) {
|
|
5526
|
+
const active = mode === currentFit;
|
|
5527
|
+
const label = mode[0].toUpperCase() + mode.slice(1);
|
|
5528
|
+
fitItems += `<div class="avp-settings-item${active ? " active" : ""}" data-fit="${mode}">${label}</div>`;
|
|
5529
|
+
}
|
|
5530
|
+
sections.push(`<div class="avp-settings-section"><div class="avp-settings-label">Fit</div>${fitItems}</div>`);
|
|
5531
|
+
}
|
|
5313
5532
|
const currentRate = this._video.playbackRate ?? 1;
|
|
5314
5533
|
let speedItems = "";
|
|
5315
5534
|
for (const spd of PLAYBACK_SPEEDS) {
|
|
@@ -5336,6 +5555,14 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5336
5555
|
}
|
|
5337
5556
|
sections.push(`<div class="avp-settings-section"><div class="avp-settings-item" data-stats>Stats for nerds</div></div>`);
|
|
5338
5557
|
this._settingsMenu.innerHTML = sections.join("");
|
|
5558
|
+
for (const item of this._settingsMenu.querySelectorAll("[data-fit]")) {
|
|
5559
|
+
item.addEventListener("click", (e) => {
|
|
5560
|
+
e.stopPropagation();
|
|
5561
|
+
const mode = item.dataset.fit;
|
|
5562
|
+
this.setAttribute("fit", mode);
|
|
5563
|
+
this._buildSettingsMenu();
|
|
5564
|
+
});
|
|
5565
|
+
}
|
|
5339
5566
|
for (const item of this._settingsMenu.querySelectorAll("[data-speed]")) {
|
|
5340
5567
|
item.addEventListener("click", (e) => {
|
|
5341
5568
|
e.stopPropagation();
|
|
@@ -5421,6 +5648,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5421
5648
|
// ── Controls: auto-hide ────────────────────────────────────────────────
|
|
5422
5649
|
_showControls() {
|
|
5423
5650
|
this.removeAttribute("data-controls-hidden");
|
|
5651
|
+
this._toolbarTop.setAttribute("data-visible", "true");
|
|
5424
5652
|
this._scheduleHide();
|
|
5425
5653
|
}
|
|
5426
5654
|
_scheduleHide() {
|
|
@@ -5430,6 +5658,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5430
5658
|
this._controlsTimer = setTimeout(() => {
|
|
5431
5659
|
if (this._state === "playing") {
|
|
5432
5660
|
this.setAttribute("data-controls-hidden", "");
|
|
5661
|
+
this._toolbarTop.setAttribute("data-visible", "false");
|
|
5433
5662
|
}
|
|
5434
5663
|
}, CONTROLS_HIDE_MS);
|
|
5435
5664
|
}
|
|
@@ -5443,8 +5672,19 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5443
5672
|
// it's treated as a double-click and the single-click action is cancelled.
|
|
5444
5673
|
/** Track whether the last interaction was touch so click handler can skip. */
|
|
5445
5674
|
_lastPointerTypeWasTouch = false;
|
|
5675
|
+
/** True if the event's composed path passes through consumer-slotted toolbar
|
|
5676
|
+
* content. Slotted content lives in the light DOM so `.closest(".avp-toolbar-top")`
|
|
5677
|
+
* on the event target won't find the shadow-DOM wrapper — `composedPath()`
|
|
5678
|
+
* does. */
|
|
5679
|
+
_isToolbarEvent(e) {
|
|
5680
|
+
for (const node of e.composedPath()) {
|
|
5681
|
+
if (node instanceof HTMLElement && node.classList.contains("avp-toolbar-top")) return true;
|
|
5682
|
+
}
|
|
5683
|
+
return false;
|
|
5684
|
+
}
|
|
5446
5685
|
_onContainerClick(e) {
|
|
5447
5686
|
if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
5687
|
+
if (this._isToolbarEvent(e)) return;
|
|
5448
5688
|
if (this._lastPointerTypeWasTouch) {
|
|
5449
5689
|
this._lastPointerTypeWasTouch = false;
|
|
5450
5690
|
return;
|
|
@@ -5460,6 +5700,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5460
5700
|
}
|
|
5461
5701
|
_onContainerDblClick(e) {
|
|
5462
5702
|
if (e.target.closest?.(".avp-controls, .avp-settings")) return;
|
|
5703
|
+
if (this._isToolbarEvent(e)) return;
|
|
5463
5704
|
if (this._tapTimer) {
|
|
5464
5705
|
clearTimeout(this._tapTimer);
|
|
5465
5706
|
this._tapTimer = null;
|
|
@@ -5481,6 +5722,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5481
5722
|
if (e.pointerType !== "touch") return;
|
|
5482
5723
|
this._lastPointerTypeWasTouch = true;
|
|
5483
5724
|
if (e.target.closest?.(".avp-controls, .avp-settings, .avp-overlay-btn")) return;
|
|
5725
|
+
if (this._isToolbarEvent(e)) return;
|
|
5484
5726
|
const now = Date.now();
|
|
5485
5727
|
if (now - this._lastTapTime < 300) {
|
|
5486
5728
|
if (this._tapTimer) {
|