avbridge 2.8.3 → 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 +32 -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/player.ts +96 -8
- package/dist/chunk-IUSFLVLJ.cjs.map +0 -1
- package/dist/chunk-JSQOBUQB.js.map +0 -1
package/dist/element.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var chunkYX4AGLNF_cjs = require('./chunk-YX4AGLNF.cjs');
|
|
4
4
|
require('./chunk-S4WAZC2T.cjs');
|
|
5
5
|
require('./chunk-ZCUXHW55.cjs');
|
|
6
6
|
require('./chunk-2IJ66NTD.cjs');
|
|
@@ -296,7 +296,7 @@ var AvbridgeVideoElement = class extends HTMLElementCtor {
|
|
|
296
296
|
this._dispatch("loadstart", {});
|
|
297
297
|
let player;
|
|
298
298
|
try {
|
|
299
|
-
player = await
|
|
299
|
+
player = await chunkYX4AGLNF_cjs.createPlayer({
|
|
300
300
|
source,
|
|
301
301
|
target: this._videoEl,
|
|
302
302
|
// Honor the consumer's preferred initial strategy. "auto" means
|
package/dist/element.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as MediaInput, n as StrategyName, S as StrategyClass, U as UnifiedPlayer, e as AudioTrackInfo, o as SubtitleTrackInfo, D as DiagnosticsSnapshot, s as AvbridgeVideoElementEventMap } from './player-
|
|
1
|
+
import { a as MediaInput, n as StrategyName, S as StrategyClass, U as UnifiedPlayer, e as AudioTrackInfo, o as SubtitleTrackInfo, D as DiagnosticsSnapshot, s as AvbridgeVideoElementEventMap } from './player-BptSJPfn.cjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* `<avbridge-video>` — `HTMLMediaElement`-compatible primitive backed by the
|
package/dist/element.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as MediaInput, n as StrategyName, S as StrategyClass, U as UnifiedPlayer, e as AudioTrackInfo, o as SubtitleTrackInfo, D as DiagnosticsSnapshot, s as AvbridgeVideoElementEventMap } from './player-
|
|
1
|
+
import { a as MediaInput, n as StrategyName, S as StrategyClass, U as UnifiedPlayer, e as AudioTrackInfo, o as SubtitleTrackInfo, D as DiagnosticsSnapshot, s as AvbridgeVideoElementEventMap } from './player-BptSJPfn.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* `<avbridge-video>` — `HTMLMediaElement`-compatible primitive backed by the
|
package/dist/element.js
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var chunkQ2VUO52Z_cjs = require('./chunk-Q2VUO52Z.cjs');
|
|
4
|
-
var
|
|
4
|
+
var chunkYX4AGLNF_cjs = require('./chunk-YX4AGLNF.cjs');
|
|
5
5
|
var chunkS4WAZC2T_cjs = require('./chunk-S4WAZC2T.cjs');
|
|
6
6
|
var chunkZCUXHW55_cjs = require('./chunk-ZCUXHW55.cjs');
|
|
7
7
|
var chunk2IJ66NTD_cjs = require('./chunk-2IJ66NTD.cjs');
|
|
@@ -763,31 +763,31 @@ Object.defineProperty(exports, "remux", {
|
|
|
763
763
|
});
|
|
764
764
|
Object.defineProperty(exports, "FALLBACK_AUDIO_CODECS", {
|
|
765
765
|
enumerable: true,
|
|
766
|
-
get: function () { return
|
|
766
|
+
get: function () { return chunkYX4AGLNF_cjs.FALLBACK_AUDIO_CODECS; }
|
|
767
767
|
});
|
|
768
768
|
Object.defineProperty(exports, "FALLBACK_VIDEO_CODECS", {
|
|
769
769
|
enumerable: true,
|
|
770
|
-
get: function () { return
|
|
770
|
+
get: function () { return chunkYX4AGLNF_cjs.FALLBACK_VIDEO_CODECS; }
|
|
771
771
|
});
|
|
772
772
|
Object.defineProperty(exports, "NATIVE_AUDIO_CODECS", {
|
|
773
773
|
enumerable: true,
|
|
774
|
-
get: function () { return
|
|
774
|
+
get: function () { return chunkYX4AGLNF_cjs.NATIVE_AUDIO_CODECS; }
|
|
775
775
|
});
|
|
776
776
|
Object.defineProperty(exports, "NATIVE_VIDEO_CODECS", {
|
|
777
777
|
enumerable: true,
|
|
778
|
-
get: function () { return
|
|
778
|
+
get: function () { return chunkYX4AGLNF_cjs.NATIVE_VIDEO_CODECS; }
|
|
779
779
|
});
|
|
780
780
|
Object.defineProperty(exports, "UnifiedPlayer", {
|
|
781
781
|
enumerable: true,
|
|
782
|
-
get: function () { return
|
|
782
|
+
get: function () { return chunkYX4AGLNF_cjs.UnifiedPlayer; }
|
|
783
783
|
});
|
|
784
784
|
Object.defineProperty(exports, "classify", {
|
|
785
785
|
enumerable: true,
|
|
786
|
-
get: function () { return
|
|
786
|
+
get: function () { return chunkYX4AGLNF_cjs.classifyContext; }
|
|
787
787
|
});
|
|
788
788
|
Object.defineProperty(exports, "createPlayer", {
|
|
789
789
|
enumerable: true,
|
|
790
|
-
get: function () { return
|
|
790
|
+
get: function () { return chunkYX4AGLNF_cjs.createPlayer; }
|
|
791
791
|
});
|
|
792
792
|
Object.defineProperty(exports, "srtToVtt", {
|
|
793
793
|
enumerable: true,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-
|
|
2
|
-
export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-
|
|
1
|
+
import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-BptSJPfn.cjs';
|
|
2
|
+
export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-BptSJPfn.cjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Codecs we know `<video>` and MSE support across modern desktop + Android.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-
|
|
2
|
-
export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-
|
|
1
|
+
import { A as AudioCodec, V as VideoCodec, M as MediaContext, C as Classification, a as MediaInput, T as TransportConfig, b as ConvertOptions, c as ConvertResult, d as TranscodeOptions } from './player-BptSJPfn.js';
|
|
2
|
+
export { e as AudioTrackInfo, f as ContainerKind, g as CreatePlayerOptions, D as DiagnosticsSnapshot, F as FetchFn, H as HardwareAccelerationHint, O as OutputAudioCodec, h as OutputFormat, i as OutputVideoCodec, P as PlaybackSession, j as PlayerEventMap, k as PlayerEventName, l as Plugin, m as ProgressInfo, S as StrategyClass, n as StrategyName, o as SubtitleTrackInfo, p as TranscodeQuality, U as UnifiedPlayer, q as VideoTrackInfo, r as createPlayer } from './player-BptSJPfn.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Codecs we know `<video>` and MSE support across modern desktop + Android.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mimeForFormat, generateFilename, createOutputFormat } from './chunk-SMH6IOP2.js';
|
|
2
2
|
export { remux } from './chunk-SMH6IOP2.js';
|
|
3
|
-
export { FALLBACK_AUDIO_CODECS, FALLBACK_VIDEO_CODECS, NATIVE_AUDIO_CODECS, NATIVE_VIDEO_CODECS, UnifiedPlayer, classifyContext as classify, createPlayer } from './chunk-
|
|
3
|
+
export { FALLBACK_AUDIO_CODECS, FALLBACK_VIDEO_CODECS, NATIVE_AUDIO_CODECS, NATIVE_VIDEO_CODECS, UnifiedPlayer, classifyContext as classify, createPlayer } from './chunk-KBWQRGHS.js';
|
|
4
4
|
export { srtToVtt } from './chunk-5KVLE6YI.js';
|
|
5
5
|
import { probe, buildMediabunnySourceFromInput } from './chunk-SR3MPV4D.js';
|
|
6
6
|
export { probe } from './chunk-SR3MPV4D.js';
|
|
@@ -414,6 +414,13 @@ declare class UnifiedPlayer {
|
|
|
414
414
|
private stallTimer;
|
|
415
415
|
private lastProgressTime;
|
|
416
416
|
private lastProgressPosition;
|
|
417
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
418
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
419
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
420
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
421
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
422
|
+
private lastVideoFrameCount;
|
|
423
|
+
private lastVideoFrameProgressTime;
|
|
417
424
|
private errorListener;
|
|
418
425
|
private endedListener;
|
|
419
426
|
private userIntent;
|
|
@@ -414,6 +414,13 @@ declare class UnifiedPlayer {
|
|
|
414
414
|
private stallTimer;
|
|
415
415
|
private lastProgressTime;
|
|
416
416
|
private lastProgressPosition;
|
|
417
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
418
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
419
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
420
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
421
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
422
|
+
private lastVideoFrameCount;
|
|
423
|
+
private lastVideoFrameProgressTime;
|
|
417
424
|
private errorListener;
|
|
418
425
|
private endedListener;
|
|
419
426
|
private userIntent;
|
package/dist/player.cjs
CHANGED
|
@@ -512,10 +512,12 @@ function classifyContext(ctx) {
|
|
|
512
512
|
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`
|
|
513
513
|
};
|
|
514
514
|
}
|
|
515
|
+
const fallbackChain = webCodecsAvailable() ? ["hybrid", "fallback"] : ["fallback"];
|
|
515
516
|
return {
|
|
516
517
|
class: "REMUX_CANDIDATE",
|
|
517
518
|
strategy: "remux",
|
|
518
|
-
reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback
|
|
519
|
+
reason: `${ctx.container} container with native-supported codecs \u2014 remux to fragmented MP4 for reliable playback`,
|
|
520
|
+
fallbackChain
|
|
519
521
|
};
|
|
520
522
|
}
|
|
521
523
|
if (webCodecsAvailable()) {
|
|
@@ -3260,6 +3262,29 @@ function registerBuiltins(registry) {
|
|
|
3260
3262
|
}
|
|
3261
3263
|
|
|
3262
3264
|
// src/player.ts
|
|
3265
|
+
function readDecodedFrameCount(target) {
|
|
3266
|
+
if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
|
|
3267
|
+
const vq = target.getVideoPlaybackQuality;
|
|
3268
|
+
if (typeof vq === "function") {
|
|
3269
|
+
try {
|
|
3270
|
+
return vq.call(target).totalVideoFrames;
|
|
3271
|
+
} catch {
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
const legacy = target.webkitDecodedFrameCount;
|
|
3275
|
+
return typeof legacy === "number" ? legacy : 0;
|
|
3276
|
+
}
|
|
3277
|
+
function evaluateDecodeHealth(input) {
|
|
3278
|
+
const timeThreshold = input.timeStallThresholdMs ?? 5e3;
|
|
3279
|
+
const frameThreshold = input.frameStallThresholdMs ?? 3e3;
|
|
3280
|
+
if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
|
|
3281
|
+
return { escalate: true, kind: "time-stall" };
|
|
3282
|
+
}
|
|
3283
|
+
if (input.hasVideoTrack && input.timeAdvanced && !input.framesAdvanced && input.now - input.lastFrameProgressTime > frameThreshold) {
|
|
3284
|
+
return { escalate: true, kind: "silent-video" };
|
|
3285
|
+
}
|
|
3286
|
+
return { escalate: false };
|
|
3287
|
+
}
|
|
3263
3288
|
var UnifiedPlayer = class _UnifiedPlayer {
|
|
3264
3289
|
/**
|
|
3265
3290
|
* @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
|
|
@@ -3285,6 +3310,13 @@ var UnifiedPlayer = class _UnifiedPlayer {
|
|
|
3285
3310
|
stallTimer = null;
|
|
3286
3311
|
lastProgressTime = 0;
|
|
3287
3312
|
lastProgressPosition = -1;
|
|
3313
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
3314
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
3315
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
3316
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
3317
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
3318
|
+
lastVideoFrameCount = 0;
|
|
3319
|
+
lastVideoFrameProgressTime = 0;
|
|
3288
3320
|
errorListener = null;
|
|
3289
3321
|
// Bound so we can removeEventListener in destroy(); without this the
|
|
3290
3322
|
// listener outlives the player and accumulates on elements that swap
|
|
@@ -3529,22 +3561,41 @@ var UnifiedPlayer = class _UnifiedPlayer {
|
|
|
3529
3561
|
if (strategy === "native" || strategy === "remux") {
|
|
3530
3562
|
this.lastProgressPosition = this.options.target.currentTime;
|
|
3531
3563
|
this.lastProgressTime = performance.now();
|
|
3564
|
+
this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
|
|
3565
|
+
this.lastVideoFrameProgressTime = performance.now();
|
|
3566
|
+
const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
|
|
3532
3567
|
this.stallTimer = setInterval(() => {
|
|
3533
3568
|
const t = this.options.target;
|
|
3569
|
+
const now = performance.now();
|
|
3534
3570
|
if (t.paused || t.ended || t.readyState < 2) {
|
|
3535
3571
|
this.lastProgressPosition = t.currentTime;
|
|
3536
|
-
this.lastProgressTime =
|
|
3572
|
+
this.lastProgressTime = now;
|
|
3573
|
+
this.lastVideoFrameCount = readDecodedFrameCount(t);
|
|
3574
|
+
this.lastVideoFrameProgressTime = now;
|
|
3537
3575
|
return;
|
|
3538
3576
|
}
|
|
3539
|
-
|
|
3577
|
+
const timeAdvanced = t.currentTime !== this.lastProgressPosition;
|
|
3578
|
+
const frames = readDecodedFrameCount(t);
|
|
3579
|
+
const framesAdvanced = frames > this.lastVideoFrameCount;
|
|
3580
|
+
const health = evaluateDecodeHealth({
|
|
3581
|
+
hasVideoTrack,
|
|
3582
|
+
timeAdvanced,
|
|
3583
|
+
framesAdvanced,
|
|
3584
|
+
now,
|
|
3585
|
+
lastProgressTime: this.lastProgressTime,
|
|
3586
|
+
lastFrameProgressTime: this.lastVideoFrameProgressTime
|
|
3587
|
+
});
|
|
3588
|
+
if (timeAdvanced) {
|
|
3540
3589
|
this.lastProgressPosition = t.currentTime;
|
|
3541
|
-
this.lastProgressTime =
|
|
3542
|
-
return;
|
|
3590
|
+
this.lastProgressTime = now;
|
|
3543
3591
|
}
|
|
3544
|
-
if (
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3592
|
+
if (framesAdvanced) {
|
|
3593
|
+
this.lastVideoFrameCount = frames;
|
|
3594
|
+
this.lastVideoFrameProgressTime = now;
|
|
3595
|
+
}
|
|
3596
|
+
if (health.escalate) {
|
|
3597
|
+
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`;
|
|
3598
|
+
void this.escalate(reason);
|
|
3548
3599
|
}
|
|
3549
3600
|
}, 1e3);
|
|
3550
3601
|
const onError = () => {
|
|
@@ -5648,12 +5699,27 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5648
5699
|
this._fullscreenBtn.innerHTML = fs ? ICON_FULLSCREEN_EXIT : ICON_FULLSCREEN;
|
|
5649
5700
|
}
|
|
5650
5701
|
// ── Controls: auto-hide ────────────────────────────────────────────────
|
|
5651
|
-
|
|
5702
|
+
/**
|
|
5703
|
+
* Reveal the auto-hiding chrome (top toolbar + bottom controls) and
|
|
5704
|
+
* re-start the auto-hide timer. Call this from app-level code to
|
|
5705
|
+
* briefly surface the player UI — e.g. to confirm "you just swiped to
|
|
5706
|
+
* this video" in a carousel, or to flash the title on focus change.
|
|
5707
|
+
*
|
|
5708
|
+
* @param durationMs How long the chrome stays visible before fading.
|
|
5709
|
+
* Defaults to the player's normal 3 s auto-hide.
|
|
5710
|
+
* Pointer movement or any other interaction resets
|
|
5711
|
+
* the timer, so a user hovering during the flash
|
|
5712
|
+
* sees no flicker.
|
|
5713
|
+
*/
|
|
5714
|
+
showControls(durationMs) {
|
|
5652
5715
|
this.removeAttribute("data-controls-hidden");
|
|
5653
5716
|
this._toolbarTop.setAttribute("data-visible", "true");
|
|
5654
|
-
this._scheduleHide();
|
|
5717
|
+
this._scheduleHide(durationMs);
|
|
5718
|
+
}
|
|
5719
|
+
_showControls() {
|
|
5720
|
+
this.showControls();
|
|
5655
5721
|
}
|
|
5656
|
-
_scheduleHide() {
|
|
5722
|
+
_scheduleHide(durationMs = CONTROLS_HIDE_MS) {
|
|
5657
5723
|
if (this._controlsTimer) clearTimeout(this._controlsTimer);
|
|
5658
5724
|
if (this._state !== "playing" && this._state !== "buffering") return;
|
|
5659
5725
|
if (this._settingsOpen) return;
|
|
@@ -5662,7 +5728,7 @@ var AvbridgePlayerElement = class extends HTMLElement {
|
|
|
5662
5728
|
this.setAttribute("data-controls-hidden", "");
|
|
5663
5729
|
this._toolbarTop.setAttribute("data-visible", "false");
|
|
5664
5730
|
}
|
|
5665
|
-
},
|
|
5731
|
+
}, durationMs);
|
|
5666
5732
|
}
|
|
5667
5733
|
// Strategy is visible in Stats for Nerds, no badge in controls bar.
|
|
5668
5734
|
// ── Click / tap handling (YouTube delayed-tap pattern) ──────────────────
|