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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.8.2",
3
+ "version": "2.8.4",
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",
@@ -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
- private _showControls(): void {
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
- }, CONTROLS_HIDE_MS);
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 = performance.now();
427
+ this.lastProgressTime = now;
428
+ this.lastVideoFrameCount = readDecodedFrameCount(t);
429
+ this.lastVideoFrameProgressTime = now;
360
430
  return;
361
431
  }
362
- if (t.currentTime !== this.lastProgressPosition) {
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 = performance.now();
365
- return;
447
+ this.lastProgressTime = now;
366
448
  }
367
- if (performance.now() - this.lastProgressTime > 5000) {
368
- void this.escalate(
369
- `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`,
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