avbridge 2.12.1 → 2.13.0
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 +101 -0
- package/README.md +33 -0
- package/dist/{chunk-UM6WCSGL.cjs → chunk-OFJYEITB.cjs} +356 -91
- package/dist/chunk-OFJYEITB.cjs.map +1 -0
- package/dist/{chunk-BN7BRTLY.js → chunk-VOC24LYF.js} +357 -92
- package/dist/chunk-VOC24LYF.js.map +1 -0
- package/dist/element-browser.js +354 -111
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +2 -2
- package/dist/element.js +1 -1
- package/dist/index.cjs +8 -8
- package/dist/index.js +1 -1
- package/dist/player.cjs +457 -135
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +35 -4
- package/dist/player.d.ts +35 -4
- package/dist/player.js +457 -135
- package/dist/player.js.map +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +136 -28
- package/src/strategies/fallback/audio-output.ts +164 -35
- package/src/strategies/fallback/decoder.ts +336 -58
- package/src/strategies/fallback/video-renderer.ts +176 -34
- package/src/strategies/hybrid/decoder.ts +22 -19
- package/src/strategies/remux/pipeline.ts +12 -3
- package/dist/chunk-BN7BRTLY.js.map +0 -1
- package/dist/chunk-UM6WCSGL.cjs.map +0 -1
package/package.json
CHANGED
|
@@ -72,6 +72,44 @@ type FitMode = (typeof FIT_MODES)[number];
|
|
|
72
72
|
export class AvbridgePlayerElement extends HTMLElement {
|
|
73
73
|
static readonly observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Returns `true` if a DOM event originated from one of the player's
|
|
77
|
+
* **interactive chrome elements** (seek bar, control buttons, settings
|
|
78
|
+
* menu, overlay play button) rather than the bare video surface.
|
|
79
|
+
*
|
|
80
|
+
* This is the escape hatch for host pages that wrap the player in a
|
|
81
|
+
* gesture recognizer (e.g. TikTok-style vertical-swipe pager). For
|
|
82
|
+
* bubble-phase listeners the player's own handlers already call
|
|
83
|
+
* `stopPropagation()` on chrome interactions — but **capture-phase**
|
|
84
|
+
* listeners run *before* the player's handlers, so they need to check
|
|
85
|
+
* the event's path themselves and bail. This helper does that check
|
|
86
|
+
* via `composedPath()`, which traverses shadow boundaries correctly.
|
|
87
|
+
*
|
|
88
|
+
* Returns `false` for events on the bare video surface — host pages
|
|
89
|
+
* remain free to claim those for their own gestures (e.g. swipe-to-pan
|
|
90
|
+
* to the next video). Returns `false` for events that never hit a
|
|
91
|
+
* player at all.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* // TikTok-style vertical swipe on the document, capture phase:
|
|
95
|
+
* document.addEventListener("pointerdown", (e) => {
|
|
96
|
+
* if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return;
|
|
97
|
+
* startSwipeGesture(e);
|
|
98
|
+
* }, { capture: true });
|
|
99
|
+
*/
|
|
100
|
+
static isPlayerChromeEvent(event: Event): boolean {
|
|
101
|
+
// Mirrors the selector used by the player's internal tap-target
|
|
102
|
+
// gate (see `_onPlayerSurfaceClick` and friends): anything inside
|
|
103
|
+
// these regions is "chrome", everything else is the bare video.
|
|
104
|
+
const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn";
|
|
105
|
+
for (const node of event.composedPath()) {
|
|
106
|
+
if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
76
114
|
|
|
77
115
|
private _video!: AvbridgeVideoElement;
|
|
@@ -104,6 +142,11 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
104
142
|
private _activeAudioTrackId: number | null = null;
|
|
105
143
|
private _activeSubtitleTrackId: number | null = null;
|
|
106
144
|
private _userSeeking = false;
|
|
145
|
+
/** Last seek target the user committed. The thumb stays here (and
|
|
146
|
+
* `_updateTime` skips updating from `timeupdate`) until the underlying
|
|
147
|
+
* `currentTime` actually catches up — otherwise the thumb visibly snaps
|
|
148
|
+
* back to the pre-seek position while the remux pipeline rebuilds. */
|
|
149
|
+
private _pendingSeekTarget: number | null = null;
|
|
107
150
|
private _holdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
108
151
|
private _holdSpeedActive = false;
|
|
109
152
|
private _savedPlaybackRate = 1;
|
|
@@ -233,7 +276,10 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
233
276
|
}
|
|
234
277
|
|
|
235
278
|
// State tracking
|
|
236
|
-
on(this._video, "loadstart", () =>
|
|
279
|
+
on(this._video, "loadstart", () => {
|
|
280
|
+
this._pendingSeekTarget = null;
|
|
281
|
+
this._setState("loading");
|
|
282
|
+
});
|
|
237
283
|
on(this._video, "ready", () => {
|
|
238
284
|
this._setState(this._video.paused ? "paused" : "playing");
|
|
239
285
|
this._seekInput.max = String(this._video.duration || 0);
|
|
@@ -430,7 +476,9 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
430
476
|
}
|
|
431
477
|
|
|
432
478
|
private _onSeekCommit(): void {
|
|
433
|
-
|
|
479
|
+
const target = Number(this._seekInput.value);
|
|
480
|
+
this._pendingSeekTarget = target;
|
|
481
|
+
this._video.currentTime = target;
|
|
434
482
|
this._userSeeking = false;
|
|
435
483
|
}
|
|
436
484
|
|
|
@@ -442,49 +490,87 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
442
490
|
return frac * (this._video.duration || 0);
|
|
443
491
|
}
|
|
444
492
|
|
|
445
|
-
/** Seekbar width below which drag-to-scrub seeks in real-time (vs
|
|
446
|
-
* preview-only). On narrow bars precise positioning is hard, so
|
|
447
|
-
* immediate video feedback is more useful than a time tooltip. */
|
|
448
|
-
private static readonly SCRUB_WIDTH_THRESHOLD = 400;
|
|
449
|
-
|
|
450
493
|
private _onSeekPointerDown(e: PointerEvent): void {
|
|
451
494
|
// Ignore synthetic clicks originating from the input's own handling
|
|
452
495
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
453
496
|
e.preventDefault();
|
|
497
|
+
// Consume the event so host pages can layer the player inside a
|
|
498
|
+
// swipe-driven UI (e.g. TikTok-style vertical pager) without the
|
|
499
|
+
// pointerdown bubbling out and latching their gesture recognizer.
|
|
500
|
+
// The seekbar's CSS sets `touch-action: none` to suppress native
|
|
501
|
+
// browser pan/zoom — this complements that on the JS side, since
|
|
502
|
+
// swipe handlers built on PointerEvents wouldn't honor touch-action.
|
|
503
|
+
e.stopPropagation();
|
|
454
504
|
this._userSeeking = true;
|
|
455
505
|
const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
|
|
456
506
|
seekBar.setPointerCapture(e.pointerId);
|
|
457
507
|
seekBar.setAttribute("data-seeking", "");
|
|
458
508
|
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
509
|
+
// Two seek modes, picked by `(pointer: coarse)`:
|
|
510
|
+
// - **fine** (mouse / trackpad / stylus): absolute mapping —
|
|
511
|
+
// pointer X maps directly to seek time, thumb jumps under
|
|
512
|
+
// cursor. Standard desktop YouTube behavior.
|
|
513
|
+
// - **coarse** (touch): relative drag — initial tap doesn't move
|
|
514
|
+
// the thumb; finger Δx maps to a Δt added to the time at
|
|
515
|
+
// pointerdown. Standard YouTube-mobile behavior; matters because
|
|
516
|
+
// finger positioning is too imprecise for absolute on a small bar.
|
|
517
|
+
// Both modes commit live during drag (throttled to ~4 Hz so we don't
|
|
518
|
+
// overwhelm the seek pipeline — every commit restarts the decoder
|
|
519
|
+
// pump on canvas strategies) and once more on pointerup.
|
|
520
|
+
const coarse = typeof matchMedia !== "undefined"
|
|
521
|
+
&& matchMedia("(pointer: coarse)").matches;
|
|
522
|
+
const startTime = coarse ? (this._video.currentTime || 0) : 0;
|
|
523
|
+
const startClientX = e.clientX;
|
|
524
|
+
let lastCommit = 0;
|
|
525
|
+
|
|
526
|
+
const timeAt = (clientX: number): number => {
|
|
527
|
+
if (coarse) {
|
|
528
|
+
const rect = seekBar.getBoundingClientRect();
|
|
529
|
+
const dx = clientX - startClientX;
|
|
530
|
+
const dt = (dx / rect.width) * (this._video.duration || 0);
|
|
531
|
+
return Math.max(0, Math.min(this._video.duration || 0, startTime + dt));
|
|
532
|
+
}
|
|
533
|
+
return this._timeFromSeekPointer(clientX);
|
|
534
|
+
};
|
|
462
535
|
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
536
|
+
const showTooltip = (t: number, clientX: number): void => {
|
|
537
|
+
if (coarse) this._updateSeekTooltipAtTime(t);
|
|
538
|
+
else this._updateSeekTooltip(clientX);
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Fine mode: tap commits immediately (thumb jumps under pointer).
|
|
542
|
+
// Coarse mode: tap parks at the current time; only drag moves it.
|
|
543
|
+
if (!coarse) {
|
|
544
|
+
const initial = timeAt(e.clientX);
|
|
545
|
+
this._seekInput.value = String(initial);
|
|
546
|
+
this._onSeekInput();
|
|
547
|
+
showTooltip(initial, e.clientX);
|
|
548
|
+
this._onSeekCommit();
|
|
549
|
+
this._userSeeking = true; // commit clears it; we're still seeking
|
|
550
|
+
} else {
|
|
551
|
+
showTooltip(startTime, e.clientX);
|
|
552
|
+
}
|
|
468
553
|
|
|
469
554
|
const onMove = (ev: PointerEvent) => {
|
|
470
|
-
|
|
555
|
+
// Belt-and-suspenders for the host's swipe handler. Pointer capture
|
|
556
|
+
// changes the *target* of subsequent pointermove events to seekBar,
|
|
557
|
+
// but they still bubble through ancestors — a swipe listener
|
|
558
|
+
// attached to document/window would otherwise see every drag tick.
|
|
559
|
+
ev.stopPropagation();
|
|
560
|
+
const t = timeAt(ev.clientX);
|
|
471
561
|
this._seekInput.value = String(t);
|
|
472
562
|
this._onSeekInput();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (now - lastScrubCommit > 250) {
|
|
480
|
-
lastScrubCommit = now;
|
|
481
|
-
this._onSeekCommit();
|
|
482
|
-
this._userSeeking = true; // keep seeking flag live
|
|
483
|
-
}
|
|
563
|
+
showTooltip(t, ev.clientX);
|
|
564
|
+
const now = performance.now();
|
|
565
|
+
if (now - lastCommit > 250) {
|
|
566
|
+
lastCommit = now;
|
|
567
|
+
this._onSeekCommit();
|
|
568
|
+
this._userSeeking = true;
|
|
484
569
|
}
|
|
485
570
|
};
|
|
486
571
|
const onUp = (ev: PointerEvent) => {
|
|
487
|
-
|
|
572
|
+
ev.stopPropagation();
|
|
573
|
+
const t = timeAt(ev.clientX);
|
|
488
574
|
this._seekInput.value = String(t);
|
|
489
575
|
this._onSeekCommit();
|
|
490
576
|
this._seekInput.focus();
|
|
@@ -511,6 +597,16 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
511
597
|
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
512
598
|
}
|
|
513
599
|
|
|
600
|
+
/** Position the tooltip over a specific time (vs. pointer X). Used by
|
|
601
|
+
* relative-drag scrub on coarse pointers, where the displayed time
|
|
602
|
+
* is decoupled from the finger position. */
|
|
603
|
+
private _updateSeekTooltipAtTime(t: number): void {
|
|
604
|
+
const dur = this._video.duration || 0;
|
|
605
|
+
const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0;
|
|
606
|
+
this._seekTooltip.textContent = formatTime(t);
|
|
607
|
+
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
608
|
+
}
|
|
609
|
+
|
|
514
610
|
private _updateSeekVisuals(t: number): void {
|
|
515
611
|
const dur = this._video.duration || 0;
|
|
516
612
|
const pct = dur > 0 ? (t / dur) * 100 : 0;
|
|
@@ -526,6 +622,18 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
526
622
|
if (this._userSeeking) return;
|
|
527
623
|
const t = this._video.currentTime;
|
|
528
624
|
const d = this._video.duration;
|
|
625
|
+
// While a committed seek is still settling, keep the thumb at the
|
|
626
|
+
// target so it doesn't snap back to the pre-seek position. Clear once
|
|
627
|
+
// currentTime has landed within 0.5s of the target.
|
|
628
|
+
if (this._pendingSeekTarget !== null) {
|
|
629
|
+
if (Math.abs(t - this._pendingSeekTarget) < 0.5) {
|
|
630
|
+
this._pendingSeekTarget = null;
|
|
631
|
+
} else {
|
|
632
|
+
this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`;
|
|
633
|
+
this._updateBuffered();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
529
637
|
this._seekInput.value = String(t);
|
|
530
638
|
this._updateSeekVisuals(t);
|
|
531
639
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
|
|
@@ -35,6 +35,17 @@ interface PendingChunk {
|
|
|
35
35
|
sampleRate: number;
|
|
36
36
|
frameCount: number;
|
|
37
37
|
durationSec: number;
|
|
38
|
+
/** Source-domain content PTS in seconds. `null` for legacy callers
|
|
39
|
+
* that schedule sequentially without PTS information. */
|
|
40
|
+
ptsSec: number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when `globalThis.AVBRIDGE_DEBUG` is set. Used to gate [TRACE-AUD]
|
|
44
|
+
* per-chunk logs that are useful for diagnosing scheduling drift but
|
|
45
|
+
* unreadable in normal use. */
|
|
46
|
+
function isDebug(): boolean {
|
|
47
|
+
return typeof globalThis !== "undefined"
|
|
48
|
+
&& !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
export interface ClockSource {
|
|
@@ -42,6 +53,14 @@ export interface ClockSource {
|
|
|
42
53
|
now(): number;
|
|
43
54
|
/** True if media is currently playing (audio scheduler is running). */
|
|
44
55
|
isPlaying(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Media time at which the current playback session was anchored — i.e. the
|
|
58
|
+
* seek target after the most recent `reset()`, or 0 on cold start. Used by
|
|
59
|
+
* the video renderer for post-flush PTS calibration: `now()` includes any
|
|
60
|
+
* decode-stall lag accumulated since playback resumed, but the anchor is
|
|
61
|
+
* a stable reference that maps directly to the user's intended position.
|
|
62
|
+
*/
|
|
63
|
+
anchorTime(): number;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
export class AudioOutput implements ClockSource {
|
|
@@ -69,6 +88,17 @@ export class AudioOutput implements ClockSource {
|
|
|
69
88
|
|
|
70
89
|
/** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
|
|
71
90
|
private mediaTimeOfAnchor = 0;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ctx time at which the first audible chunk will start playing. `-1`
|
|
94
|
+
* before any chunk has been scheduled successfully (clock is frozen);
|
|
95
|
+
* the actual ctx time once one has. The renderer's `clock.now()` uses
|
|
96
|
+
* this to avoid advancing during the silent-gap window between
|
|
97
|
+
* `audio.start()` and the first chunk that schedules without being
|
|
98
|
+
* dropped — that gap is what produces the "audio-less fast-forward"
|
|
99
|
+
* the user sees post-seek when the gate releases on video-only grace.
|
|
100
|
+
*/
|
|
101
|
+
private firstAudibleCtxStart = -1;
|
|
72
102
|
private ctxTimeAtAnchor = 0;
|
|
73
103
|
|
|
74
104
|
private pendingQueue: PendingChunk[] = [];
|
|
@@ -154,11 +184,30 @@ export class AudioOutput implements ClockSource {
|
|
|
154
184
|
return this.mediaTimeOfAnchor;
|
|
155
185
|
}
|
|
156
186
|
if (this.state === "playing") {
|
|
187
|
+
// Freeze the clock until the first audio chunk has actually been
|
|
188
|
+
// scheduled. Without this, when `audio.start()` fires before any
|
|
189
|
+
// post-seek audio packets have made it through the decoder (e.g. the
|
|
190
|
+
// gate's "video-only grace" path released early), `clock.now()`
|
|
191
|
+
// would advance from `mediaTimeOfAnchor` at 1× wall time while the
|
|
192
|
+
// audio scheduler is dropping every chunk that arrives (their
|
|
193
|
+
// PTS-derived `ctxStart` is already in the past). The renderer would
|
|
194
|
+
// paint frames during that silent window — the user perceives that
|
|
195
|
+
// as a "fast-forward burst with no audio." When the first chunk
|
|
196
|
+
// finally arrives and schedules normally, `firstAudibleCtxStart` is
|
|
197
|
+
// set and the clock unfreezes from there in sync with the audible
|
|
198
|
+
// content's PTS.
|
|
199
|
+
if (this.firstAudibleCtxStart < 0) {
|
|
200
|
+
return this.mediaTimeOfAnchor;
|
|
201
|
+
}
|
|
157
202
|
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
|
|
158
203
|
}
|
|
159
204
|
return this.mediaTimeOfAnchor;
|
|
160
205
|
}
|
|
161
206
|
|
|
207
|
+
anchorTime(): number {
|
|
208
|
+
return this.mediaTimeOfAnchor;
|
|
209
|
+
}
|
|
210
|
+
|
|
162
211
|
isPlaying(): boolean {
|
|
163
212
|
return this.state === "playing";
|
|
164
213
|
}
|
|
@@ -192,18 +241,55 @@ export class AudioOutput implements ClockSource {
|
|
|
192
241
|
* Schedule a chunk of decoded samples. Queues internally while idle (cold
|
|
193
242
|
* start or post-seek), schedules directly to the audio graph while playing.
|
|
194
243
|
* In wall-clock mode, samples are silently discarded.
|
|
244
|
+
*
|
|
245
|
+
* `ptsSec` is the chunk's source-domain content PTS in seconds, from
|
|
246
|
+
* the demuxer. When provided, the chunk plays at the ctx-time
|
|
247
|
+
* corresponding to that PTS — so pre-target audio after a seek
|
|
248
|
+
* naturally drops (its computed `ctxStart` falls in the past) and
|
|
249
|
+
* post-target audio plays at its true content time, without any
|
|
250
|
+
* external trim or anchor rebase. When `ptsSec` is null (cold start
|
|
251
|
+
* with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
|
|
252
|
+
* the chunk is scheduled sequentially after `mediaTimeOfNext` — the
|
|
253
|
+
* pre-refactor behavior.
|
|
195
254
|
*/
|
|
196
|
-
schedule(
|
|
255
|
+
schedule(
|
|
256
|
+
samples: Float32Array,
|
|
257
|
+
channels: number,
|
|
258
|
+
sampleRate: number,
|
|
259
|
+
ptsSec?: number | null,
|
|
260
|
+
): void {
|
|
197
261
|
if (this.destroyed || this.noAudio) return;
|
|
198
262
|
const frameCount = samples.length / channels;
|
|
199
263
|
const durationSec = frameCount / sampleRate;
|
|
264
|
+
const hasPts = ptsSec != null && Number.isFinite(ptsSec);
|
|
265
|
+
|
|
266
|
+
// Pre-target gate: a chunk whose entire PTS span is before the
|
|
267
|
+
// current media anchor will be silently dropped by `scheduleNow`
|
|
268
|
+
// (its `ctxStart` falls in the past). We must apply the same drop
|
|
269
|
+
// here in idle/paused state too — otherwise the chunk sits in
|
|
270
|
+
// `pendingQueue`, `bufferAhead()` reports it as buffered audio,
|
|
271
|
+
// `waitForBuffer()`'s gate releases on a phantom audio buffer, and
|
|
272
|
+
// `audio.start()` fires with a queue full of chunks that immediately
|
|
273
|
+
// drop on drain. The user sees post-seek "sped up no audio" while
|
|
274
|
+
// the demuxer slowly chews through pre-target packets — `clock.now()`
|
|
275
|
+
// is advancing on wall time and the renderer paints video against
|
|
276
|
+
// it, but `node.start()` is never being called.
|
|
277
|
+
if (hasPts && (ptsSec as number) + durationSec / this._rate < this.mediaTimeOfAnchor) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
200
280
|
|
|
201
281
|
if (this.state === "idle" || this.state === "paused") {
|
|
202
|
-
this.pendingQueue.push({
|
|
282
|
+
this.pendingQueue.push({
|
|
283
|
+
samples, channels, sampleRate, frameCount, durationSec,
|
|
284
|
+
ptsSec: hasPts ? (ptsSec as number) : null,
|
|
285
|
+
});
|
|
203
286
|
return;
|
|
204
287
|
}
|
|
205
288
|
|
|
206
|
-
this.scheduleNow(
|
|
289
|
+
this.scheduleNow(
|
|
290
|
+
samples, channels, sampleRate, frameCount,
|
|
291
|
+
hasPts ? (ptsSec as number) : null,
|
|
292
|
+
);
|
|
207
293
|
}
|
|
208
294
|
|
|
209
295
|
private scheduleNow(
|
|
@@ -211,7 +297,67 @@ export class AudioOutput implements ClockSource {
|
|
|
211
297
|
channels: number,
|
|
212
298
|
sampleRate: number,
|
|
213
299
|
frameCount: number,
|
|
300
|
+
ptsSec: number | null,
|
|
214
301
|
): void {
|
|
302
|
+
const durationSec = frameCount / sampleRate;
|
|
303
|
+
|
|
304
|
+
// Compute ctxStart. Two paths:
|
|
305
|
+
//
|
|
306
|
+
// PTS-known: the chunk's content PTS maps to a specific ctx time
|
|
307
|
+
// via (mediaTimeOfAnchor, ctxTimeAtAnchor). If that ctx time is
|
|
308
|
+
// already in the past, the chunk represents audio the user should
|
|
309
|
+
// have heard before now — drop it. After a seek, this is what
|
|
310
|
+
// *automatically* skips pre-target audio packets returned by a
|
|
311
|
+
// keyframe-aligned demuxer seek; no manual trim needed.
|
|
312
|
+
//
|
|
313
|
+
// PTS-unknown (legacy): chain after the last-scheduled sample
|
|
314
|
+
// via `mediaTimeOfNext`. Same behavior as before the refactor.
|
|
315
|
+
let ctxStart: number;
|
|
316
|
+
if (ptsSec != null) {
|
|
317
|
+
ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
|
|
318
|
+
if (isDebug()) {
|
|
319
|
+
// eslint-disable-next-line no-console
|
|
320
|
+
console.log(`[TRACE-AUD] PTS sched #${this.framesScheduled} pts=${ptsSec.toFixed(3)} dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} rate=${this._rate}`);
|
|
321
|
+
}
|
|
322
|
+
if (ctxStart < this.ctx.currentTime - 0.001) {
|
|
323
|
+
if (isDebug()) {
|
|
324
|
+
// eslint-disable-next-line no-console
|
|
325
|
+
console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// First chunk to schedule successfully unfreezes `clock.now()`.
|
|
330
|
+
// We rebase the anchor onto this chunk: when ctx reaches `ctxStart`,
|
|
331
|
+
// clock should equal `ptsSec` (so `audioNow` matches audible content
|
|
332
|
+
// PTS exactly when the chunk plays). The renderer's deadline will
|
|
333
|
+
// then advance from there, in lockstep with what's audible.
|
|
334
|
+
if (this.firstAudibleCtxStart < 0) {
|
|
335
|
+
this.firstAudibleCtxStart = ctxStart;
|
|
336
|
+
this.mediaTimeOfAnchor = ptsSec;
|
|
337
|
+
this.ctxTimeAtAnchor = ctxStart;
|
|
338
|
+
if (isDebug()) {
|
|
339
|
+
// eslint-disable-next-line no-console
|
|
340
|
+
console.log(`[TRACE-AUD] UNFREEZE clock — first audible chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} → anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const endMediaTime = ptsSec + durationSec / this._rate;
|
|
344
|
+
if (endMediaTime > this.mediaTimeOfNext) {
|
|
345
|
+
this.mediaTimeOfNext = endMediaTime;
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
349
|
+
// eslint-disable-next-line no-console
|
|
350
|
+
console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
|
|
351
|
+
if (ctxStart < this.ctx.currentTime) {
|
|
352
|
+
// eslint-disable-next-line no-console
|
|
353
|
+
console.warn(`[TRACE-AUD] REBASE anchor was=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor was=${this.ctxTimeAtAnchor.toFixed(4)} → anchor=${this.mediaTimeOfNext.toFixed(3)} ctxAnchor=${this.ctx.currentTime.toFixed(4)}`);
|
|
354
|
+
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
355
|
+
this.mediaTimeOfAnchor = this.mediaTimeOfNext;
|
|
356
|
+
ctxStart = this.ctx.currentTime;
|
|
357
|
+
}
|
|
358
|
+
this.mediaTimeOfNext += durationSec;
|
|
359
|
+
}
|
|
360
|
+
|
|
215
361
|
const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
|
|
216
362
|
for (let ch = 0; ch < channels; ch++) {
|
|
217
363
|
const channelData = buffer.getChannelData(ch);
|
|
@@ -222,38 +368,8 @@ export class AudioOutput implements ClockSource {
|
|
|
222
368
|
const node = this.ctx.createBufferSource();
|
|
223
369
|
node.buffer = buffer;
|
|
224
370
|
node.connect(this.gain);
|
|
225
|
-
// Pitch the audio to match the playback rate (same as native <video>).
|
|
226
371
|
if (this._rate !== 1) node.playbackRate.value = this._rate;
|
|
227
|
-
|
|
228
|
-
// Convert media time → ctx time using the anchor + rate. At rate=2,
|
|
229
|
-
// each second of media time occupies 0.5s of ctx time.
|
|
230
|
-
let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
231
|
-
|
|
232
|
-
// When the decoder is slower than realtime, `ctxStart` falls into
|
|
233
|
-
// the past (ctx.currentTime has already passed it). Clamping each
|
|
234
|
-
// sample to `ctx.currentTime` individually (the old behavior)
|
|
235
|
-
// caused every stale sample in a burst to start at *the same
|
|
236
|
-
// instant*, stacking them on top of each other — the audible
|
|
237
|
-
// symptom was a series of clicks / a chord of stuttering cook
|
|
238
|
-
// packets.
|
|
239
|
-
//
|
|
240
|
-
// Correct behavior: when the first sample of a burst is behind,
|
|
241
|
-
// *rebase the anchor forward* so ctxStart = ctx.currentTime now.
|
|
242
|
-
// Subsequent samples in the same burst then schedule at
|
|
243
|
-
// ctxStart + offset as usual, laying out sequentially on the
|
|
244
|
-
// timeline instead of piling up. The downside is a visible jump
|
|
245
|
-
// in the audio clock — but the alternative was silent corruption.
|
|
246
|
-
// `now()` readers (the video renderer) just see the clock step
|
|
247
|
-
// forward and drop any frames older than the new time.
|
|
248
|
-
if (ctxStart < this.ctx.currentTime) {
|
|
249
|
-
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
250
|
-
this.mediaTimeOfAnchor = this.mediaTimeOfNext;
|
|
251
|
-
ctxStart = this.ctx.currentTime;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
372
|
node.start(ctxStart);
|
|
255
|
-
|
|
256
|
-
this.mediaTimeOfNext += frameCount / sampleRate;
|
|
257
373
|
this.framesScheduled++;
|
|
258
374
|
}
|
|
259
375
|
|
|
@@ -286,6 +402,10 @@ export class AudioOutput implements ClockSource {
|
|
|
286
402
|
try { this.gain.connect(this.ctx.destination); } catch { /* ignore */ }
|
|
287
403
|
|
|
288
404
|
if (this.state === "paused") {
|
|
405
|
+
if (isDebug()) {
|
|
406
|
+
// eslint-disable-next-line no-console
|
|
407
|
+
console.log(`[TRACE-AUD] START(resume) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} → ctxAnchor=${this.ctx.currentTime.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
|
|
408
|
+
}
|
|
289
409
|
// Resume: media time should continue from where we paused. ctx.currentTime
|
|
290
410
|
// is preserved across suspend/resume, so re-anchoring it to "now" with
|
|
291
411
|
// the same mediaTimeOfAnchor gives a continuous clock.
|
|
@@ -295,7 +415,7 @@ export class AudioOutput implements ClockSource {
|
|
|
295
415
|
const drain = this.pendingQueue;
|
|
296
416
|
this.pendingQueue = [];
|
|
297
417
|
for (const c of drain) {
|
|
298
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
418
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
299
419
|
}
|
|
300
420
|
return;
|
|
301
421
|
}
|
|
@@ -307,11 +427,15 @@ export class AudioOutput implements ClockSource {
|
|
|
307
427
|
this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
|
|
308
428
|
this.mediaTimeOfNext = this.mediaTimeOfAnchor;
|
|
309
429
|
this.state = "playing";
|
|
430
|
+
if (isDebug()) {
|
|
431
|
+
// eslint-disable-next-line no-console
|
|
432
|
+
console.log(`[TRACE-AUD] START(cold) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
|
|
433
|
+
}
|
|
310
434
|
|
|
311
435
|
const drain = this.pendingQueue;
|
|
312
436
|
this.pendingQueue = [];
|
|
313
437
|
for (const c of drain) {
|
|
314
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
438
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
315
439
|
}
|
|
316
440
|
}
|
|
317
441
|
|
|
@@ -341,6 +465,10 @@ export class AudioOutput implements ClockSource {
|
|
|
341
465
|
* supplying new samples) and then call `start()` to resume playback.
|
|
342
466
|
*/
|
|
343
467
|
async reset(newMediaTime: number): Promise<void> {
|
|
468
|
+
if (isDebug()) {
|
|
469
|
+
// eslint-disable-next-line no-console
|
|
470
|
+
console.log(`[TRACE-AUD] RESET to=${newMediaTime.toFixed(3)} prev_anchor=${this.mediaTimeOfAnchor.toFixed(3)} prev_mtNext=${this.mediaTimeOfNext.toFixed(3)} prev_ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} state=${this.state}`);
|
|
471
|
+
}
|
|
344
472
|
if (this.noAudio) {
|
|
345
473
|
this.pendingQueue = [];
|
|
346
474
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
@@ -358,6 +486,7 @@ export class AudioOutput implements ClockSource {
|
|
|
358
486
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
359
487
|
this.mediaTimeOfNext = newMediaTime;
|
|
360
488
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
489
|
+
this.firstAudibleCtxStart = -1;
|
|
361
490
|
this.state = "idle";
|
|
362
491
|
|
|
363
492
|
if (this.ctx.state === "running") {
|