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 CHANGED
@@ -4,6 +4,50 @@ All notable changes to **avbridge.js** are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.8.4]
8
+
9
+ Decode-stall detection — the robustness follow-up that addresses the
10
+ v2.8.1 "Known deferred" item.
11
+
12
+ ### Added
13
+
14
+ - **Silent-video watchdog in the stall supervisor.** Catches the class
15
+ of bug where MSE reports a codec as supported but the decoder can't
16
+ actually decode it — audio plays, `currentTime` advances, but the
17
+ video decoder never produces frames. The supervisor now samples
18
+ `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames` (or
19
+ `webkitDecodedFrameCount` on older Safari) and triggers strategy
20
+ escalation when audio is advancing but frames haven't for 3 s. The
21
+ Firefox HEVC case (MSE lies about `hev1.*`) is the motivating
22
+ example, but the watchdog is codec- and browser-agnostic.
23
+ - **`fallbackChain` on `REMUX_CANDIDATE` classifications.** Previously
24
+ the supervisor had nowhere to escalate to from a plain remux
25
+ classification, so stalls sat there forever. The chain is
26
+ `["hybrid", "fallback"]` (or `["fallback"]` without WebCodecs) —
27
+ initial strategy is still remux; the chain only engages on stall.
28
+ - **`evaluateDecodeHealth(input)` and `readDecodedFrameCount(target)`
29
+ extracted as pure helpers** in `src/player.ts` (not re-exported from
30
+ the package root; same pattern as `buildInitialDecision`) so the
31
+ supervisor's decision logic is unit-testable without a browser.
32
+
33
+ ### Fixed
34
+
35
+ - **Firefox HEVC** in `tests/browser/playback.spec.ts` should now
36
+ un-skip once this ships — the watchdog gives Firefox a mechanism to
37
+ escalate off the lying MSE path to hybrid / fallback automatically.
38
+
39
+ ## [2.8.3]
40
+
41
+ ### Added
42
+
43
+ - **`showControls(durationMs?)` on `<avbridge-player>`.** Public method
44
+ to reveal the auto-hiding chrome (top toolbar + bottom controls)
45
+ and re-start the auto-hide timer. Intended for app-level "flash the
46
+ UI" moments like a carousel slide change or focus handoff — one call
47
+ instead of reaching into `data-controls-hidden`. Custom duration
48
+ overrides the default 3 s; pointer movement during the flash resets
49
+ the timer so there's no flicker if the user interacts mid-flash.
50
+
7
51
  ## [2.8.2]
8
52
 
9
53
  Small ergonomics release driven by downstream `<avbridge-player>`
@@ -231,10 +231,12 @@ function classifyContext(ctx) {
231
231
  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`
232
232
  };
233
233
  }
234
+ const fallbackChain = webCodecsAvailable() ? ["hybrid", "fallback"] : ["fallback"];
234
235
  return {
235
236
  class: "REMUX_CANDIDATE",
236
237
  strategy: "remux",
237
- reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`
238
+ reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`,
239
+ fallbackChain
238
240
  };
239
241
  }
240
242
  if (webCodecsAvailable()) {
@@ -2858,6 +2860,29 @@ function registerBuiltins(registry) {
2858
2860
  }
2859
2861
 
2860
2862
  // src/player.ts
2863
+ function readDecodedFrameCount(target) {
2864
+ if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
2865
+ const vq = target.getVideoPlaybackQuality;
2866
+ if (typeof vq === "function") {
2867
+ try {
2868
+ return vq.call(target).totalVideoFrames;
2869
+ } catch {
2870
+ }
2871
+ }
2872
+ const legacy = target.webkitDecodedFrameCount;
2873
+ return typeof legacy === "number" ? legacy : 0;
2874
+ }
2875
+ function evaluateDecodeHealth(input) {
2876
+ const timeThreshold = input.timeStallThresholdMs ?? 5e3;
2877
+ const frameThreshold = input.frameStallThresholdMs ?? 3e3;
2878
+ if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
2879
+ return { escalate: true, kind: "time-stall" };
2880
+ }
2881
+ if (input.hasVideoTrack && input.timeAdvanced && !input.framesAdvanced && input.now - input.lastFrameProgressTime > frameThreshold) {
2882
+ return { escalate: true, kind: "silent-video" };
2883
+ }
2884
+ return { escalate: false };
2885
+ }
2861
2886
  var UnifiedPlayer = class _UnifiedPlayer {
2862
2887
  /**
2863
2888
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
@@ -2883,6 +2908,13 @@ var UnifiedPlayer = class _UnifiedPlayer {
2883
2908
  stallTimer = null;
2884
2909
  lastProgressTime = 0;
2885
2910
  lastProgressPosition = -1;
2911
+ /** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
2912
+ * (or `webkitDecodedFrameCount` fallback). Used by the silent-video
2913
+ * watchdog — catches cases where `currentTime` advances (audio plays)
2914
+ * but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
2915
+ * via MSE when the decoder actually can't decode HEVC. */
2916
+ lastVideoFrameCount = 0;
2917
+ lastVideoFrameProgressTime = 0;
2886
2918
  errorListener = null;
2887
2919
  // Bound so we can removeEventListener in destroy(); without this the
2888
2920
  // listener outlives the player and accumulates on elements that swap
@@ -3127,22 +3159,41 @@ var UnifiedPlayer = class _UnifiedPlayer {
3127
3159
  if (strategy === "native" || strategy === "remux") {
3128
3160
  this.lastProgressPosition = this.options.target.currentTime;
3129
3161
  this.lastProgressTime = performance.now();
3162
+ this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
3163
+ this.lastVideoFrameProgressTime = performance.now();
3164
+ const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
3130
3165
  this.stallTimer = setInterval(() => {
3131
3166
  const t = this.options.target;
3167
+ const now = performance.now();
3132
3168
  if (t.paused || t.ended || t.readyState < 2) {
3133
3169
  this.lastProgressPosition = t.currentTime;
3134
- this.lastProgressTime = performance.now();
3170
+ this.lastProgressTime = now;
3171
+ this.lastVideoFrameCount = readDecodedFrameCount(t);
3172
+ this.lastVideoFrameProgressTime = now;
3135
3173
  return;
3136
3174
  }
3137
- if (t.currentTime !== this.lastProgressPosition) {
3175
+ const timeAdvanced = t.currentTime !== this.lastProgressPosition;
3176
+ const frames = readDecodedFrameCount(t);
3177
+ const framesAdvanced = frames > this.lastVideoFrameCount;
3178
+ const health = evaluateDecodeHealth({
3179
+ hasVideoTrack,
3180
+ timeAdvanced,
3181
+ framesAdvanced,
3182
+ now,
3183
+ lastProgressTime: this.lastProgressTime,
3184
+ lastFrameProgressTime: this.lastVideoFrameProgressTime
3185
+ });
3186
+ if (timeAdvanced) {
3138
3187
  this.lastProgressPosition = t.currentTime;
3139
- this.lastProgressTime = performance.now();
3140
- return;
3188
+ this.lastProgressTime = now;
3141
3189
  }
3142
- if (performance.now() - this.lastProgressTime > 5e3) {
3143
- void this.escalate(
3144
- `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
3145
- );
3190
+ if (framesAdvanced) {
3191
+ this.lastVideoFrameCount = frames;
3192
+ this.lastVideoFrameProgressTime = now;
3193
+ }
3194
+ if (health.escalate) {
3195
+ const reason = health.kind === "time-stall" ? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s` : `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s \u2014 likely a silent codec failure`;
3196
+ void this.escalate(reason);
3146
3197
  }
3147
3198
  }, 1e3);
3148
3199
  const onError = () => {
@@ -3373,5 +3424,5 @@ function defaultFallbackChain(strategy) {
3373
3424
  }
3374
3425
 
3375
3426
  export { FALLBACK_AUDIO_CODECS, FALLBACK_VIDEO_CODECS, NATIVE_AUDIO_CODECS, NATIVE_VIDEO_CODECS, UnifiedPlayer, classifyContext, createPlayer };
3376
- //# sourceMappingURL=chunk-JSQOBUQB.js.map
3377
- //# sourceMappingURL=chunk-JSQOBUQB.js.map
3427
+ //# sourceMappingURL=chunk-KBWQRGHS.js.map
3428
+ //# sourceMappingURL=chunk-KBWQRGHS.js.map