avbridge 2.8.2 → 2.8.4
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 +44 -0
- package/dist/{chunk-JSQOBUQB.js → chunk-KBWQRGHS.js} +62 -11
- package/dist/chunk-KBWQRGHS.js.map +1 -0
- package/dist/{chunk-IUSFLVLJ.cjs → chunk-YX4AGLNF.cjs} +62 -11
- package/dist/chunk-YX4AGLNF.cjs.map +1 -0
- package/dist/element-browser.js +60 -9
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +2 -2
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +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-DXEKOky8.d.cts → player-BptSJPfn.d.cts} +7 -0
- package/dist/{player-DXEKOky8.d.ts → player-BptSJPfn.d.ts} +7 -0
- package/dist/player.cjs +79 -13
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +20 -0
- package/dist/player.d.ts +20 -0
- package/dist/player.js +79 -13
- package/dist/player.js.map +1 -1
- package/package.json +1 -1
- package/src/classify/rules.ts +9 -0
- package/src/element/avbridge-player.ts +20 -4
- package/src/player.ts +96 -8
- package/dist/chunk-IUSFLVLJ.cjs.map +0 -1
- package/dist/chunk-JSQOBUQB.js.map +0 -1
package/package.json
CHANGED
package/src/classify/rules.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
Classification,
|
|
5
5
|
ContainerKind,
|
|
6
6
|
MediaContext,
|
|
7
|
+
StrategyName,
|
|
7
8
|
VideoCodec,
|
|
8
9
|
VideoTrackInfo,
|
|
9
10
|
} from "../types.js";
|
|
@@ -218,10 +219,18 @@ export function classifyContext(ctx: MediaContext): Classification {
|
|
|
218
219
|
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
|
};
|
|
220
221
|
}
|
|
222
|
+
// Give REMUX_CANDIDATE a fallback chain so the runtime stall / decode
|
|
223
|
+
// supervisors have somewhere to escalate to when MSE lies about codec
|
|
224
|
+
// support (the Firefox HEVC case — audio plays, video never paints).
|
|
225
|
+
// The initial pick is still remux; these only engage on stall.
|
|
226
|
+
const fallbackChain: StrategyName[] = webCodecsAvailable()
|
|
227
|
+
? ["hybrid", "fallback"]
|
|
228
|
+
: ["fallback"];
|
|
221
229
|
return {
|
|
222
230
|
class: "REMUX_CANDIDATE",
|
|
223
231
|
strategy: "remux",
|
|
224
232
|
reason: `${ctx.container} container with native-supported codecs — remux to fragmented MP4 for reliable playback`,
|
|
233
|
+
fallbackChain,
|
|
225
234
|
};
|
|
226
235
|
}
|
|
227
236
|
|
|
@@ -680,13 +680,29 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
680
680
|
|
|
681
681
|
// ── Controls: auto-hide ────────────────────────────────────────────────
|
|
682
682
|
|
|
683
|
-
|
|
683
|
+
/**
|
|
684
|
+
* Reveal the auto-hiding chrome (top toolbar + bottom controls) and
|
|
685
|
+
* re-start the auto-hide timer. Call this from app-level code to
|
|
686
|
+
* briefly surface the player UI — e.g. to confirm "you just swiped to
|
|
687
|
+
* this video" in a carousel, or to flash the title on focus change.
|
|
688
|
+
*
|
|
689
|
+
* @param durationMs How long the chrome stays visible before fading.
|
|
690
|
+
* Defaults to the player's normal 3 s auto-hide.
|
|
691
|
+
* Pointer movement or any other interaction resets
|
|
692
|
+
* the timer, so a user hovering during the flash
|
|
693
|
+
* sees no flicker.
|
|
694
|
+
*/
|
|
695
|
+
showControls(durationMs?: number): void {
|
|
684
696
|
this.removeAttribute("data-controls-hidden");
|
|
685
697
|
this._toolbarTop.setAttribute("data-visible", "true");
|
|
686
|
-
this._scheduleHide();
|
|
698
|
+
this._scheduleHide(durationMs);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private _showControls(): void {
|
|
702
|
+
this.showControls();
|
|
687
703
|
}
|
|
688
704
|
|
|
689
|
-
private _scheduleHide(): void {
|
|
705
|
+
private _scheduleHide(durationMs: number = CONTROLS_HIDE_MS): void {
|
|
690
706
|
if (this._controlsTimer) clearTimeout(this._controlsTimer);
|
|
691
707
|
if (this._state !== "playing" && this._state !== "buffering") return;
|
|
692
708
|
if (this._settingsOpen) return;
|
|
@@ -695,7 +711,7 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
695
711
|
this.setAttribute("data-controls-hidden", "");
|
|
696
712
|
this._toolbarTop.setAttribute("data-visible", "false");
|
|
697
713
|
}
|
|
698
|
-
},
|
|
714
|
+
}, durationMs);
|
|
699
715
|
}
|
|
700
716
|
|
|
701
717
|
// Strategy is visible in Stats for Nerds, no badge in controls bar.
|
package/src/player.ts
CHANGED
|
@@ -20,6 +20,62 @@ import type {
|
|
|
20
20
|
} from "./types.js";
|
|
21
21
|
import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Decoded-video-frame counter reader. Prefers the standard
|
|
25
|
+
* `getVideoPlaybackQuality().totalVideoFrames` (all evergreen browsers);
|
|
26
|
+
* falls back to the WebKit-prefixed `webkitDecodedFrameCount` for older
|
|
27
|
+
* Safari. Returns 0 for non-video elements or when nothing exposes the
|
|
28
|
+
* count — the caller treats 0 as "no signal" (constant across samples,
|
|
29
|
+
* which is fine).
|
|
30
|
+
*/
|
|
31
|
+
export function readDecodedFrameCount(target: HTMLMediaElement): number {
|
|
32
|
+
if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
|
|
33
|
+
const vq = (target as HTMLVideoElement & { getVideoPlaybackQuality?: () => { totalVideoFrames: number } }).getVideoPlaybackQuality;
|
|
34
|
+
if (typeof vq === "function") {
|
|
35
|
+
try { return vq.call(target).totalVideoFrames; } catch { /* fall through */ }
|
|
36
|
+
}
|
|
37
|
+
const legacy = (target as HTMLVideoElement & { webkitDecodedFrameCount?: number }).webkitDecodedFrameCount;
|
|
38
|
+
return typeof legacy === "number" ? legacy : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pure decision function for the stall supervisor. Takes a snapshot of
|
|
43
|
+
* the observable state and returns whether to escalate. Extracted so it
|
|
44
|
+
* can be unit-tested without spinning up a real player / media element.
|
|
45
|
+
*
|
|
46
|
+
* - `time-stall`: `currentTime` hasn't moved for `timeStallThresholdMs`
|
|
47
|
+
* despite the element being in a state where it should be playing.
|
|
48
|
+
* - `silent-video`: the media has a video track, `currentTime` is
|
|
49
|
+
* advancing (audio is playing), but the decoder has produced no new
|
|
50
|
+
* frames for `frameStallThresholdMs`. Catches Firefox-style "MSE
|
|
51
|
+
* reports codec supported but the decoder can't actually decode it".
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateDecodeHealth(input: {
|
|
54
|
+
hasVideoTrack: boolean;
|
|
55
|
+
timeAdvanced: boolean;
|
|
56
|
+
framesAdvanced: boolean;
|
|
57
|
+
now: number;
|
|
58
|
+
lastProgressTime: number;
|
|
59
|
+
lastFrameProgressTime: number;
|
|
60
|
+
timeStallThresholdMs?: number;
|
|
61
|
+
frameStallThresholdMs?: number;
|
|
62
|
+
}): { escalate: false } | { escalate: true; kind: "time-stall" | "silent-video" } {
|
|
63
|
+
const timeThreshold = input.timeStallThresholdMs ?? 5000;
|
|
64
|
+
const frameThreshold = input.frameStallThresholdMs ?? 3000;
|
|
65
|
+
if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
|
|
66
|
+
return { escalate: true, kind: "time-stall" };
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
input.hasVideoTrack &&
|
|
70
|
+
input.timeAdvanced &&
|
|
71
|
+
!input.framesAdvanced &&
|
|
72
|
+
input.now - input.lastFrameProgressTime > frameThreshold
|
|
73
|
+
) {
|
|
74
|
+
return { escalate: true, kind: "silent-video" };
|
|
75
|
+
}
|
|
76
|
+
return { escalate: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
23
79
|
export class UnifiedPlayer {
|
|
24
80
|
private emitter = new TypedEmitter<PlayerEventMap>();
|
|
25
81
|
private session: PlaybackSession | null = null;
|
|
@@ -34,6 +90,13 @@ export class UnifiedPlayer {
|
|
|
34
90
|
private stallTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
91
|
private lastProgressTime = 0;
|
|
36
92
|
private lastProgressPosition = -1;
|
|
93
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
94
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
95
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
96
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
97
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
98
|
+
private lastVideoFrameCount = 0;
|
|
99
|
+
private lastVideoFrameProgressTime = 0;
|
|
37
100
|
private errorListener: (() => void) | null = null;
|
|
38
101
|
|
|
39
102
|
// Bound so we can removeEventListener in destroy(); without this the
|
|
@@ -351,23 +414,48 @@ export class UnifiedPlayer {
|
|
|
351
414
|
// Monitor currentTime progress
|
|
352
415
|
this.lastProgressPosition = this.options.target.currentTime;
|
|
353
416
|
this.lastProgressTime = performance.now();
|
|
417
|
+
this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
|
|
418
|
+
this.lastVideoFrameProgressTime = performance.now();
|
|
419
|
+
|
|
420
|
+
const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
|
|
354
421
|
|
|
355
422
|
this.stallTimer = setInterval(() => {
|
|
356
423
|
const t = this.options.target;
|
|
424
|
+
const now = performance.now();
|
|
357
425
|
if (t.paused || t.ended || t.readyState < 2) {
|
|
358
426
|
this.lastProgressPosition = t.currentTime;
|
|
359
|
-
this.lastProgressTime =
|
|
427
|
+
this.lastProgressTime = now;
|
|
428
|
+
this.lastVideoFrameCount = readDecodedFrameCount(t);
|
|
429
|
+
this.lastVideoFrameProgressTime = now;
|
|
360
430
|
return;
|
|
361
431
|
}
|
|
362
|
-
|
|
432
|
+
const timeAdvanced = t.currentTime !== this.lastProgressPosition;
|
|
433
|
+
const frames = readDecodedFrameCount(t);
|
|
434
|
+
const framesAdvanced = frames > this.lastVideoFrameCount;
|
|
435
|
+
|
|
436
|
+
const health = evaluateDecodeHealth({
|
|
437
|
+
hasVideoTrack,
|
|
438
|
+
timeAdvanced,
|
|
439
|
+
framesAdvanced,
|
|
440
|
+
now,
|
|
441
|
+
lastProgressTime: this.lastProgressTime,
|
|
442
|
+
lastFrameProgressTime: this.lastVideoFrameProgressTime,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
if (timeAdvanced) {
|
|
363
446
|
this.lastProgressPosition = t.currentTime;
|
|
364
|
-
this.lastProgressTime =
|
|
365
|
-
return;
|
|
447
|
+
this.lastProgressTime = now;
|
|
366
448
|
}
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
449
|
+
if (framesAdvanced) {
|
|
450
|
+
this.lastVideoFrameCount = frames;
|
|
451
|
+
this.lastVideoFrameProgressTime = now;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (health.escalate) {
|
|
455
|
+
const reason = health.kind === "time-stall"
|
|
456
|
+
? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
|
|
457
|
+
: `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s — likely a silent codec failure`;
|
|
458
|
+
void this.escalate(reason);
|
|
371
459
|
}
|
|
372
460
|
}, 1000);
|
|
373
461
|
|