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/dist/player.cjs CHANGED
@@ -1022,22 +1022,25 @@ async function createRemuxPipeline(ctx, video) {
1022
1022
  }
1023
1023
  }
1024
1024
  let mimePromise = null;
1025
+ const myToken = pumpToken;
1025
1026
  const writable = new WritableStream({
1026
1027
  write: async (chunk) => {
1027
- if (destroyed) return;
1028
+ if (destroyed || pumpToken !== myToken) return;
1028
1029
  if (!sink) {
1029
1030
  const mime = await (mimePromise ??= output.getMimeType());
1031
+ if (destroyed || pumpToken !== myToken) return;
1030
1032
  sink = new MseSink({ mime, video });
1031
1033
  await sink.ready();
1034
+ if (destroyed || pumpToken !== myToken) return;
1032
1035
  if (pendingStartTime > 0) {
1033
1036
  sink.invalidate(pendingStartTime);
1034
1037
  }
1035
1038
  sink.setPlayOnSeek(pendingAutoPlay);
1036
1039
  }
1037
- while (sink && !destroyed && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
1040
+ while (sink && !destroyed && pumpToken === myToken && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
1038
1041
  await new Promise((r) => setTimeout(r, 500));
1039
1042
  }
1040
- if (destroyed) return;
1043
+ if (destroyed || pumpToken !== myToken) return;
1041
1044
  sink.append(chunk.data);
1042
1045
  stats.bytesWritten += chunk.data.byteLength;
1043
1046
  stats.fragments++;
@@ -1304,7 +1307,21 @@ var VideoRenderer = class {
1304
1307
  framesPainted = 0;
1305
1308
  framesDroppedLate = 0;
1306
1309
  framesDroppedOverflow = 0;
1310
+ /** True once the head frame has been painted as a pre-roll poster
1311
+ * since the last flush. Used to ensure pre-roll paints exactly one
1312
+ * frame (held static) during the post-seek discard window. */
1307
1313
  prerolled = false;
1314
+ /** PTS (µs) of the most recently painted frame. Used as the calibration
1315
+ * reference on the first post-flush snap: the pre-roll path paints one
1316
+ * frame *before* PTS-based playback starts, so the queue head's PTS at
1317
+ * first PTS-based paint is the *next* frame, off by one frameDur from
1318
+ * the actually-displayed frame. Calibrating against the painted frame
1319
+ * instead of the queue head removes that one-frame offset and yields
1320
+ * calib ≈ 0 instead of +frameDur. */
1321
+ lastPaintedPtsUs = 0;
1322
+ hasLastPaintedPts = false;
1323
+ /** Audio-clock reading (ms) at the previous paint, for overlay Δaud. */
1324
+ lastPaintAudMs = 0;
1308
1325
  /** Wall-clock time of the last paint, in ms (performance.now()). */
1309
1326
  lastPaintWall = 0;
1310
1327
  /** Minimum ms between paints — paces video at roughly source fps. */
@@ -1354,13 +1371,17 @@ var VideoRenderer = class {
1354
1371
  return this.queue.length;
1355
1372
  }
1356
1373
  /**
1357
- * Soft cap for decoder backpressure. The decoder pump throttles when
1358
- * `queueDepth() >= queueHighWater`. Set high enough that normal decode
1359
- * bursts don't trigger the renderer's overflow-drop loop (which runs at
1360
- * every paint), but low enough that the decoder doesn't run unboundedly
1361
- * ahead. The hard cap in `enqueue()` is 64.
1374
+ * Cap the decoder may fill the queue up to. Used by the decoder's
1375
+ * enqueue-side discard logic (it closes new frames instead of pushing
1376
+ * them when this is reached). Sized so a long post-seek catch-up
1377
+ * fits the decoder produces frames at PTS T_kf onwards rapidly
1378
+ * while the demuxer is chewing through pre-target audio; if the
1379
+ * queue can hold the whole post-seek burst, the renderer plays
1380
+ * smoothly from pre-roll without a frozen-video gap when audio.start
1381
+ * fires. At ~340 KB per SD frame the cap is ~85 MB peak; at HD it's
1382
+ * larger but still bounded.
1362
1383
  */
1363
- queueHighWater = 30;
1384
+ queueHighWater = 256;
1364
1385
  enqueue(frame) {
1365
1386
  if (this.destroyed) {
1366
1387
  frame.close();
@@ -1371,7 +1392,7 @@ var VideoRenderer = class {
1371
1392
  if (this.queue.length === 1 && this.framesPainted === 0) {
1372
1393
  this.resolveFirstFrame();
1373
1394
  }
1374
- while (this.queue.length > 60) {
1395
+ while (this.queue.length > this.queueHighWater + 8) {
1375
1396
  this.queue.shift()?.close();
1376
1397
  this.framesDroppedOverflow++;
1377
1398
  }
@@ -1453,12 +1474,9 @@ var VideoRenderer = class {
1453
1474
  if (this.queue.length === 0) return;
1454
1475
  const playing = this.clock.isPlaying();
1455
1476
  if (!playing) {
1456
- if (!this.prerolled) {
1457
- const head = this.queue.shift();
1458
- this.paint(head);
1459
- head.close();
1477
+ if (!this.prerolled && this.queue.length > 0) {
1460
1478
  this.prerolled = true;
1461
- this.lastPaintWall = performance.now();
1479
+ this.paint(this.queue[0]);
1462
1480
  }
1463
1481
  return;
1464
1482
  }
@@ -1467,14 +1485,29 @@ var VideoRenderer = class {
1467
1485
  const hasPts = headTs > 0 || this.queue.length > 1;
1468
1486
  if (hasPts) {
1469
1487
  const wallNow2 = performance.now();
1470
- if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
1471
- this.ptsCalibrationUs = headTs - rawAudioNowUs;
1488
+ if (!this.ptsCalibrated) {
1489
+ const anchorUs = (this.clock.anchorTime?.() ?? this.clock.now()) * 1e6;
1490
+ const referencePtsUs = this.hasLastPaintedPts ? this.lastPaintedPtsUs : headTs;
1491
+ this.ptsCalibrationUs = referencePtsUs - anchorUs;
1472
1492
  this.ptsCalibrated = true;
1473
1493
  this.lastCalibrationWall = wallNow2;
1494
+ if (isDebug()) {
1495
+ console.log(
1496
+ `[avbridge:renderer] CALIB-FIRST audioAnchor=${(anchorUs / 1e3).toFixed(1)}ms prerolledPTS=${this.hasLastPaintedPts ? (this.lastPaintedPtsUs / 1e3).toFixed(1) : "n/a"}ms queueHeadPTS=${(headTs / 1e3).toFixed(1)}ms rawAudioNow=${(rawAudioNowUs / 1e3).toFixed(1)}ms \u2192 calib=${(this.ptsCalibrationUs / 1e3).toFixed(1)}ms`
1497
+ );
1498
+ }
1499
+ } else if (wallNow2 - this.lastCalibrationWall > 1e4) {
1500
+ const oldCalib = this.ptsCalibrationUs;
1501
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
1502
+ this.lastCalibrationWall = wallNow2;
1503
+ if (isDebug()) {
1504
+ console.log(
1505
+ `[avbridge:renderer] CALIB-RESNAP headPTS=${(headTs / 1e3).toFixed(1)}ms rawAudioNow=${(rawAudioNowUs / 1e3).toFixed(1)}ms calib ${(oldCalib / 1e3).toFixed(1)}ms \u2192 ${(this.ptsCalibrationUs / 1e3).toFixed(1)}ms (\u0394=${((this.ptsCalibrationUs - oldCalib) / 1e3).toFixed(1)}ms after 10s)`
1506
+ );
1507
+ }
1474
1508
  }
1475
1509
  const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
1476
- const frameDurationUs = this.paintIntervalMs * 1e3;
1477
- const deadlineUs = audioNowUs + frameDurationUs;
1510
+ const deadlineUs = audioNowUs;
1478
1511
  let bestIdx = -1;
1479
1512
  for (let i = 0; i < this.queue.length; i++) {
1480
1513
  const ts = this.queue[i].timestamp ?? 0;
@@ -1502,19 +1535,20 @@ var VideoRenderer = class {
1502
1535
  return;
1503
1536
  }
1504
1537
  const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
1505
- const dropThresholdUs = _relaxDrop ? audioNowUs - 60 * 1e6 : audioNowUs - frameDurationUs * 2;
1506
1538
  let dropped = 0;
1507
- while (bestIdx > 0) {
1508
- const ts = this.queue[0].timestamp ?? 0;
1509
- if (ts < dropThresholdUs) {
1539
+ const initialBestIdx = bestIdx;
1540
+ if (!_relaxDrop) {
1541
+ while (bestIdx > 0) {
1510
1542
  this.queue.shift()?.close();
1511
1543
  this.framesDroppedLate++;
1512
1544
  bestIdx--;
1513
1545
  dropped++;
1514
- } else {
1515
- break;
1516
1546
  }
1517
1547
  }
1548
+ const paintTs = this.queue[0]?.timestamp ?? 0;
1549
+ if (isDebug()) {
1550
+ console.log(`[TRACE] PAINT bestIdx_initial=${initialBestIdx} dropped=${dropped} paintPts=${(paintTs / 1e3).toFixed(1)}ms audioNow=${(audioNowUs / 1e3).toFixed(1)}ms deadline=${(deadlineUs / 1e3).toFixed(1)}ms queueLen=${this.queue.length} wall=${performance.now().toFixed(0)}`);
1551
+ }
1518
1552
  this.ticksPainted++;
1519
1553
  if (isDebug()) {
1520
1554
  const now = performance.now();
@@ -1550,6 +1584,33 @@ var VideoRenderer = class {
1550
1584
  }
1551
1585
  try {
1552
1586
  this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
1587
+ if (isDebug()) {
1588
+ const wallNow = performance.now();
1589
+ const audNowMs = this.clock.now() * 1e3;
1590
+ const ptsMs = (frame.timestamp ?? 0) / 1e3;
1591
+ const dWall = this.lastPaintWall > 0 ? wallNow - this.lastPaintWall : 0;
1592
+ const dAud = this.lastPaintAudMs > 0 ? audNowMs - this.lastPaintAudMs : 0;
1593
+ const dPts = this.hasLastPaintedPts ? ptsMs - this.lastPaintedPtsUs / 1e3 : 0;
1594
+ this.ctx.save();
1595
+ this.ctx.font = "bold 18px monospace";
1596
+ const lines = [
1597
+ `#${this.framesPainted + 1} pts=${ptsMs.toFixed(0)} aud=${audNowMs.toFixed(0)} wall=${wallNow.toFixed(0)}`,
1598
+ `\u0394pts=${dPts.toFixed(0)} \u0394aud=${dAud.toFixed(0)} \u0394wall=${dWall.toFixed(0)}`
1599
+ ];
1600
+ const lineHeight = 22;
1601
+ const padTop = 6;
1602
+ const stripH = padTop + lineHeight * lines.length;
1603
+ this.ctx.fillStyle = "rgba(0,0,0,0.7)";
1604
+ this.ctx.fillRect(0, 0, this.canvas.width, stripH);
1605
+ this.ctx.fillStyle = "#0f0";
1606
+ for (let i = 0; i < lines.length; i++) {
1607
+ this.ctx.fillText(lines[i], 8, padTop + lineHeight * (i + 1) - 4);
1608
+ }
1609
+ this.ctx.restore();
1610
+ }
1611
+ this.lastPaintedPtsUs = frame.timestamp ?? 0;
1612
+ this.hasLastPaintedPts = true;
1613
+ this.lastPaintAudMs = this.clock.now() * 1e3;
1553
1614
  this.framesPainted++;
1554
1615
  } catch (err) {
1555
1616
  if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
@@ -1562,6 +1623,7 @@ var VideoRenderer = class {
1562
1623
  const count = this.queue.length;
1563
1624
  while (this.queue.length > 0) this.queue.shift()?.close();
1564
1625
  this.prerolled = false;
1626
+ this.hasLastPaintedPts = false;
1565
1627
  this.ptsCalibrated = false;
1566
1628
  this.hasEverEnqueuedSinceFlush = false;
1567
1629
  if (isDebug() && count > 0) {
@@ -1602,6 +1664,9 @@ var VideoRenderer = class {
1602
1664
  };
1603
1665
 
1604
1666
  // src/strategies/fallback/audio-output.ts
1667
+ function isDebug2() {
1668
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
1669
+ }
1605
1670
  var AudioOutput = class {
1606
1671
  ctx;
1607
1672
  gain;
@@ -1623,6 +1688,16 @@ var AudioOutput = class {
1623
1688
  mediaTimeOfNext = 0;
1624
1689
  /** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
1625
1690
  mediaTimeOfAnchor = 0;
1691
+ /**
1692
+ * Ctx time at which the first audible chunk will start playing. `-1`
1693
+ * before any chunk has been scheduled successfully (clock is frozen);
1694
+ * the actual ctx time once one has. The renderer's `clock.now()` uses
1695
+ * this to avoid advancing during the silent-gap window between
1696
+ * `audio.start()` and the first chunk that schedules without being
1697
+ * dropped — that gap is what produces the "audio-less fast-forward"
1698
+ * the user sees post-seek when the gate releases on video-only grace.
1699
+ */
1700
+ firstAudibleCtxStart = -1;
1626
1701
  ctxTimeAtAnchor = 0;
1627
1702
  pendingQueue = [];
1628
1703
  framesScheduled = 0;
@@ -1695,10 +1770,16 @@ var AudioOutput = class {
1695
1770
  return this.mediaTimeOfAnchor;
1696
1771
  }
1697
1772
  if (this.state === "playing") {
1773
+ if (this.firstAudibleCtxStart < 0) {
1774
+ return this.mediaTimeOfAnchor;
1775
+ }
1698
1776
  return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1699
1777
  }
1700
1778
  return this.mediaTimeOfAnchor;
1701
1779
  }
1780
+ anchorTime() {
1781
+ return this.mediaTimeOfAnchor;
1782
+ }
1702
1783
  isPlaying() {
1703
1784
  return this.state === "playing";
1704
1785
  }
@@ -1725,18 +1806,81 @@ var AudioOutput = class {
1725
1806
  * Schedule a chunk of decoded samples. Queues internally while idle (cold
1726
1807
  * start or post-seek), schedules directly to the audio graph while playing.
1727
1808
  * In wall-clock mode, samples are silently discarded.
1809
+ *
1810
+ * `ptsSec` is the chunk's source-domain content PTS in seconds, from
1811
+ * the demuxer. When provided, the chunk plays at the ctx-time
1812
+ * corresponding to that PTS — so pre-target audio after a seek
1813
+ * naturally drops (its computed `ctxStart` falls in the past) and
1814
+ * post-target audio plays at its true content time, without any
1815
+ * external trim or anchor rebase. When `ptsSec` is null (cold start
1816
+ * with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
1817
+ * the chunk is scheduled sequentially after `mediaTimeOfNext` — the
1818
+ * pre-refactor behavior.
1728
1819
  */
1729
- schedule(samples, channels, sampleRate) {
1820
+ schedule(samples, channels, sampleRate, ptsSec) {
1730
1821
  if (this.destroyed || this.noAudio) return;
1731
1822
  const frameCount = samples.length / channels;
1732
1823
  const durationSec = frameCount / sampleRate;
1824
+ const hasPts = ptsSec != null && Number.isFinite(ptsSec);
1825
+ if (hasPts && ptsSec + durationSec / this._rate < this.mediaTimeOfAnchor) {
1826
+ return;
1827
+ }
1733
1828
  if (this.state === "idle" || this.state === "paused") {
1734
- this.pendingQueue.push({ samples, channels, sampleRate, frameCount, durationSec });
1829
+ this.pendingQueue.push({
1830
+ samples,
1831
+ channels,
1832
+ sampleRate,
1833
+ frameCount,
1834
+ durationSec,
1835
+ ptsSec: hasPts ? ptsSec : null
1836
+ });
1735
1837
  return;
1736
1838
  }
1737
- this.scheduleNow(samples, channels, sampleRate, frameCount);
1839
+ this.scheduleNow(
1840
+ samples,
1841
+ channels,
1842
+ sampleRate,
1843
+ frameCount,
1844
+ hasPts ? ptsSec : null
1845
+ );
1738
1846
  }
1739
- scheduleNow(samples, channels, sampleRate, frameCount) {
1847
+ scheduleNow(samples, channels, sampleRate, frameCount, ptsSec) {
1848
+ const durationSec = frameCount / sampleRate;
1849
+ let ctxStart;
1850
+ if (ptsSec != null) {
1851
+ ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
1852
+ if (isDebug2()) {
1853
+ 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}`);
1854
+ }
1855
+ if (ctxStart < this.ctx.currentTime - 1e-3) {
1856
+ if (isDebug2()) {
1857
+ console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1858
+ }
1859
+ return;
1860
+ }
1861
+ if (this.firstAudibleCtxStart < 0) {
1862
+ this.firstAudibleCtxStart = ctxStart;
1863
+ this.mediaTimeOfAnchor = ptsSec;
1864
+ this.ctxTimeAtAnchor = ctxStart;
1865
+ if (isDebug2()) {
1866
+ console.log(`[TRACE-AUD] UNFREEZE clock \u2014 first audible chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} \u2192 anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)}`);
1867
+ }
1868
+ }
1869
+ const endMediaTime = ptsSec + durationSec / this._rate;
1870
+ if (endMediaTime > this.mediaTimeOfNext) {
1871
+ this.mediaTimeOfNext = endMediaTime;
1872
+ }
1873
+ } else {
1874
+ ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1875
+ console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1876
+ if (ctxStart < this.ctx.currentTime) {
1877
+ console.warn(`[TRACE-AUD] REBASE anchor was=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor was=${this.ctxTimeAtAnchor.toFixed(4)} \u2192 anchor=${this.mediaTimeOfNext.toFixed(3)} ctxAnchor=${this.ctx.currentTime.toFixed(4)}`);
1878
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
1879
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1880
+ ctxStart = this.ctx.currentTime;
1881
+ }
1882
+ this.mediaTimeOfNext += durationSec;
1883
+ }
1740
1884
  const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
1741
1885
  for (let ch = 0; ch < channels; ch++) {
1742
1886
  const channelData = buffer.getChannelData(ch);
@@ -1748,14 +1892,7 @@ var AudioOutput = class {
1748
1892
  node.buffer = buffer;
1749
1893
  node.connect(this.gain);
1750
1894
  if (this._rate !== 1) node.playbackRate.value = this._rate;
1751
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1752
- if (ctxStart < this.ctx.currentTime) {
1753
- this.ctxTimeAtAnchor = this.ctx.currentTime;
1754
- this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1755
- ctxStart = this.ctx.currentTime;
1756
- }
1757
1895
  node.start(ctxStart);
1758
- this.mediaTimeOfNext += frameCount / sampleRate;
1759
1896
  this.framesScheduled++;
1760
1897
  }
1761
1898
  // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -1780,12 +1917,15 @@ var AudioOutput = class {
1780
1917
  } catch {
1781
1918
  }
1782
1919
  if (this.state === "paused") {
1920
+ if (isDebug2()) {
1921
+ console.log(`[TRACE-AUD] START(resume) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} \u2192 ctxAnchor=${this.ctx.currentTime.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
1922
+ }
1783
1923
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1784
1924
  this.state = "playing";
1785
1925
  const drain2 = this.pendingQueue;
1786
1926
  this.pendingQueue = [];
1787
1927
  for (const c of drain2) {
1788
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1928
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1789
1929
  }
1790
1930
  return;
1791
1931
  }
@@ -1793,10 +1933,13 @@ var AudioOutput = class {
1793
1933
  this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
1794
1934
  this.mediaTimeOfNext = this.mediaTimeOfAnchor;
1795
1935
  this.state = "playing";
1936
+ if (isDebug2()) {
1937
+ 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}`);
1938
+ }
1796
1939
  const drain = this.pendingQueue;
1797
1940
  this.pendingQueue = [];
1798
1941
  for (const c of drain) {
1799
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1942
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1800
1943
  }
1801
1944
  }
1802
1945
  /** Pause playback. Suspends the audio context. */
@@ -1822,6 +1965,9 @@ var AudioOutput = class {
1822
1965
  * supplying new samples) and then call `start()` to resume playback.
1823
1966
  */
1824
1967
  async reset(newMediaTime) {
1968
+ if (isDebug2()) {
1969
+ 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}`);
1970
+ }
1825
1971
  if (this.noAudio) {
1826
1972
  this.pendingQueue = [];
1827
1973
  this.mediaTimeOfAnchor = newMediaTime;
@@ -1840,6 +1986,7 @@ var AudioOutput = class {
1840
1986
  this.mediaTimeOfAnchor = newMediaTime;
1841
1987
  this.mediaTimeOfNext = newMediaTime;
1842
1988
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1989
+ this.firstAudibleCtxStart = -1;
1843
1990
  this.state = "idle";
1844
1991
  if (this.ctx.state === "running") {
1845
1992
  await this.ctx.suspend();
@@ -2019,28 +2166,6 @@ function asUint8(x) {
2019
2166
  const ta = x;
2020
2167
  return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
2021
2168
  }
2022
- function sanitizeFrameTimestamp(frame, nextUs, fallbackTimeBase) {
2023
- const lo = frame.pts ?? 0;
2024
- const hi = frame.ptshi ?? 0;
2025
- const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
2026
- if (isInvalid) {
2027
- const us2 = nextUs();
2028
- frame.pts = us2;
2029
- frame.ptshi = 0;
2030
- return;
2031
- }
2032
- const tb = fallbackTimeBase ?? [1, 1e6];
2033
- const pts64 = hi * 4294967296 + lo;
2034
- const us = Math.round(pts64 * 1e6 * tb[0] / tb[1]);
2035
- if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
2036
- frame.pts = us;
2037
- frame.ptshi = us < 0 ? -1 : 0;
2038
- return;
2039
- }
2040
- const fallback = nextUs();
2041
- frame.pts = fallback;
2042
- frame.ptshi = 0;
2043
- }
2044
2169
 
2045
2170
  // src/strategies/hybrid/decoder.ts
2046
2171
  async function startHybridDecoder(opts) {
@@ -2188,7 +2313,6 @@ async function startHybridDecoder(opts) {
2188
2313
  let videoChunksFed = 0;
2189
2314
  let bufferedUntilSec = 0;
2190
2315
  let syntheticVideoUs = 0;
2191
- let syntheticAudioUs = 0;
2192
2316
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
2193
2317
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
2194
2318
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -2220,7 +2344,13 @@ async function startHybridDecoder(opts) {
2220
2344
  }
2221
2345
  }
2222
2346
  if (audioDec && audioPackets && audioPackets.length > 0) {
2223
- await decodeAudioBatch(audioPackets, myToken);
2347
+ await decodeAudioBatch(
2348
+ audioPackets,
2349
+ myToken,
2350
+ /*flush*/
2351
+ false,
2352
+ audioTimeBase
2353
+ );
2224
2354
  }
2225
2355
  if (myToken !== pumpToken || destroyed) return;
2226
2356
  await new Promise((r) => setTimeout(r, 0));
@@ -2267,8 +2397,11 @@ async function startHybridDecoder(opts) {
2267
2397
  }
2268
2398
  }
2269
2399
  }
2270
- async function decodeAudioBatch(pkts, myToken, flush = false) {
2400
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
2271
2401
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2402
+ const pktPtsSec = pkts.map(
2403
+ (p) => tb ? packetPtsSec(p, tb) : null
2404
+ );
2272
2405
  const AUDIO_SUB_BATCH = 4;
2273
2406
  let allFrames = [];
2274
2407
  for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
@@ -2306,22 +2439,13 @@ async function startHybridDecoder(opts) {
2306
2439
  }
2307
2440
  if (myToken !== pumpToken || destroyed) return;
2308
2441
  const frames = allFrames;
2309
- for (const f of frames) {
2442
+ for (let i = 0; i < frames.length; i++) {
2310
2443
  if (myToken !== pumpToken || destroyed) return;
2311
- sanitizeFrameTimestamp(
2312
- f,
2313
- () => {
2314
- const ts = syntheticAudioUs;
2315
- const samples2 = f.nb_samples ?? 1024;
2316
- const sampleRate = f.sample_rate ?? 44100;
2317
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
2318
- return ts;
2319
- },
2320
- audioTimeBase
2321
- );
2444
+ const f = frames[i];
2322
2445
  const samples = libavFrameToInterleavedFloat32(f);
2323
2446
  if (samples) {
2324
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
2447
+ const pts = pktPtsSec[i] ?? null;
2448
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
2325
2449
  audioFramesDecoded++;
2326
2450
  }
2327
2451
  }
@@ -2431,7 +2555,6 @@ async function startHybridDecoder(opts) {
2431
2555
  }
2432
2556
  await flushBSF();
2433
2557
  syntheticVideoUs = Math.round(timeSec * 1e6);
2434
- syntheticAudioUs = Math.round(timeSec * 1e6);
2435
2558
  pumpRunning = pumpLoop(newToken).catch(
2436
2559
  (err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
2437
2560
  );
@@ -2470,7 +2593,6 @@ async function startHybridDecoder(opts) {
2470
2593
  }
2471
2594
  await flushBSF();
2472
2595
  syntheticVideoUs = Math.round(timeSec * 1e6);
2473
- syntheticAudioUs = Math.round(timeSec * 1e6);
2474
2596
  pumpRunning = pumpLoop(newToken).catch(
2475
2597
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2476
2598
  );
@@ -2723,6 +2845,9 @@ async function createHybridSession(ctx, target, transport) {
2723
2845
  }
2724
2846
 
2725
2847
  // src/strategies/fallback/decoder.ts
2848
+ function isDebug3() {
2849
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
2850
+ }
2726
2851
  async function startDecoder(opts) {
2727
2852
  const variant = "avbridge";
2728
2853
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
@@ -2854,8 +2979,15 @@ async function startDecoder(opts) {
2854
2979
  let watchdogSlowSinceMs = 0;
2855
2980
  let watchdogSlowWarned = false;
2856
2981
  let watchdogOverflowWarned = false;
2857
- let syntheticVideoUs = 0;
2858
- let syntheticAudioUs = 0;
2982
+ let lastContentUs = -1;
2983
+ let firstValidPtsLoggedSinceSeek = false;
2984
+ let seenFirstAudioPacketSinceSeek = false;
2985
+ let seekTargetSec = 0;
2986
+ let diagPktsLoggedSinceSeek = 0;
2987
+ let diagFramesLoggedSinceSeek = 0;
2988
+ let diagFrameKeysDumped = false;
2989
+ const DIAG_MAX_PKTS = 100;
2990
+ const DIAG_MAX_FRAMES = 300;
2859
2991
  let videoDecodeMsTotal = 0;
2860
2992
  let audioDecodeMsTotal = 0;
2861
2993
  let videoDecodeBatches = 0;
@@ -2894,6 +3026,18 @@ async function startDecoder(opts) {
2894
3026
  for (const pkt of videoPackets) {
2895
3027
  const sec = packetPtsSec(pkt, videoTimeBase);
2896
3028
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
3029
+ if (isDebug3() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
3030
+ const rawHi = pkt.ptshi ?? 0;
3031
+ const rawLo = pkt.pts ?? 0;
3032
+ const isInvalidPts = rawHi === -2147483648 && rawLo === 0;
3033
+ const rawPts64 = isInvalidPts ? null : rawHi * 4294967296 + rawLo;
3034
+ const rawSec = rawPts64 != null && videoTimeBase ? rawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
3035
+ const pktKeys = diagPktsLoggedSinceSeek === 0 ? `[keys: ${Object.keys(pkt).join(",")}]` : "";
3036
+ console.log(
3037
+ `[DIAG-PKT] vidx=${diagPktsLoggedSinceSeek} pts=${isInvalidPts ? "NOPTS" : rawPts64} pts_sec=${rawSec != null ? rawSec.toFixed(3) : "n/a"} ptshi=${rawHi} ptslo=${rawLo} flags=0x${(pkt.flags ?? 0).toString(16)} keyframe=${(pkt.flags ?? 0) & 1 ? "Y" : "N"} stream=${pkt.stream_index} dataLen=${pkt.data?.length ?? 0} seekTarget=${seekTargetSec.toFixed(3)} ` + pktKeys
3038
+ );
3039
+ diagPktsLoggedSinceSeek++;
3040
+ }
2897
3041
  }
2898
3042
  }
2899
3043
  if (audioPackets && audioTimeBase) {
@@ -2901,9 +3045,25 @@ async function startDecoder(opts) {
2901
3045
  const sec = packetPtsSec(pkt, audioTimeBase);
2902
3046
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2903
3047
  }
3048
+ if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
3049
+ const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
3050
+ if (firstSec != null && Number.isFinite(firstSec)) {
3051
+ seenFirstAudioPacketSinceSeek = true;
3052
+ chunkNNVOHKXJ_cjs.dbg.info(
3053
+ "av-anchor",
3054
+ `seek-target=${seekTargetSec.toFixed(3)}s, first-audio-pkt-pts=${firstSec.toFixed(3)}s (\u0394=${((firstSec - seekTargetSec) * 1e3).toFixed(1)}ms \u2014 pre-target packets will be skipped by AudioOutput)`
3055
+ );
3056
+ }
3057
+ }
2904
3058
  }
2905
3059
  if (audioDec && audioPackets && audioPackets.length > 0) {
2906
- await decodeAudioBatch(audioPackets, myToken);
3060
+ await decodeAudioBatch(
3061
+ audioPackets,
3062
+ myToken,
3063
+ /*flush*/
3064
+ false,
3065
+ audioTimeBase
3066
+ );
2907
3067
  }
2908
3068
  if (myToken !== pumpToken || destroyed) return;
2909
3069
  if (videoDec && videoPackets && videoPackets.length > 0) {
@@ -2947,7 +3107,7 @@ async function startDecoder(opts) {
2947
3107
  {
2948
3108
  const _throttleStart = performance.now();
2949
3109
  let _throttled = false;
2950
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
3110
+ while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
2951
3111
  _throttled = true;
2952
3112
  await new Promise((r) => setTimeout(r, 50));
2953
3113
  }
@@ -3002,18 +3162,83 @@ async function startDecoder(opts) {
3002
3162
  if (myToken !== pumpToken || destroyed) return;
3003
3163
  for (const f of frames) {
3004
3164
  if (myToken !== pumpToken || destroyed) return;
3005
- sanitizeFrameTimestamp(
3006
- f,
3007
- () => {
3008
- const base = lastEmittedPtsUs >= 0 ? lastEmittedPtsUs + videoFrameStepUs : syntheticVideoUs;
3009
- syntheticVideoUs = base + videoFrameStepUs;
3010
- return base;
3011
- },
3012
- videoTimeBase
3013
- );
3014
- const _fPts = (f.ptshi ?? 0) * 4294967296 + (f.pts ?? 0);
3165
+ const _diagShouldLog = isDebug3() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
3166
+ const _diagRawHi = f.ptshi ?? 0;
3167
+ const _diagRawLo = f.pts ?? 0;
3168
+ const _diagInvalid = _diagRawHi === -2147483648 && _diagRawLo === 0;
3169
+ const _diagRawPts64 = _diagInvalid ? null : _diagRawHi * 4294967296 + _diagRawLo;
3170
+ const _diagRawSec = _diagRawPts64 != null && videoTimeBase ? _diagRawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
3171
+ if (_diagShouldLog && !diagFrameKeysDumped) {
3172
+ diagFrameKeysDumped = true;
3173
+ const allKeys = Object.keys(f);
3174
+ const fieldDump = {};
3175
+ for (const k of allKeys) {
3176
+ const v = f[k];
3177
+ if (k === "data") continue;
3178
+ if (typeof v === "object" && v !== null && "length" in v) continue;
3179
+ fieldDump[k] = v;
3180
+ }
3181
+ console.log(`[DIAG-FRAME] FIRST FRAME post-seek \u2014 all keys: ${allKeys.join(",")}`);
3182
+ console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
3183
+ }
3184
+ let rawUs = null;
3185
+ if (!_diagInvalid && _diagRawPts64 != null) {
3186
+ const tb = videoTimeBase ?? [1, 1e6];
3187
+ const us = Math.round(_diagRawPts64 * 1e6 * tb[0] / tb[1]);
3188
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
3189
+ rawUs = us;
3190
+ }
3191
+ }
3192
+ const _diagLog = (decision, finalPtsUs, sanFallback) => {
3193
+ if (!_diagShouldLog) return;
3194
+ const ptsSrc = sanFallback ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})` : "LIBAV";
3195
+ console.log(
3196
+ `[DIAG-FRAME] vidx=${diagFramesLoggedSinceSeek} raw_pts=${_diagInvalid ? "NOPTS" : _diagRawPts64} raw_pts_sec=${_diagRawSec != null ? _diagRawSec.toFixed(3) : "n/a"} pts_src=${ptsSrc} final_pts_us=${finalPtsUs} final_pts_sec=${(finalPtsUs / 1e6).toFixed(3)} seekTarget=${seekTargetSec.toFixed(3)} offset_to_target_ms=${(finalPtsUs / 1e3 - seekTargetSec * 1e3).toFixed(1)} lastEmittedPts_us=${lastEmittedPtsUs} decision=${decision}`
3197
+ );
3198
+ diagFramesLoggedSinceSeek++;
3199
+ };
3200
+ let _diagSanFallbackFired = false;
3201
+ const seekTargetUs = Math.round(seekTargetSec * 1e6);
3202
+ if (lastContentUs < 0) {
3203
+ if (rawUs == null) {
3204
+ const isColdStartKeyframe = seekTargetSec === 0 && f.key_frame === 1;
3205
+ if (isColdStartKeyframe) {
3206
+ lastContentUs = 0;
3207
+ _diagSanFallbackFired = true;
3208
+ } else {
3209
+ _diagLog("PRE-ANCHOR-DROP", 0, true);
3210
+ continue;
3211
+ }
3212
+ } else {
3213
+ lastContentUs = rawUs;
3214
+ if (!firstValidPtsLoggedSinceSeek) {
3215
+ firstValidPtsLoggedSinceSeek = true;
3216
+ if (isDebug3()) {
3217
+ console.log(
3218
+ `[avbridge:decoder] post-seek anchor established: first valid raw pts = ${(rawUs / 1e3).toFixed(1)}ms (seekTarget = ${(seekTargetSec * 1e3).toFixed(1)}ms, \u0394 = ${((rawUs - seekTargetUs) / 1e3).toFixed(1)}ms)`
3219
+ );
3220
+ }
3221
+ if (rawUs >= seekTargetUs) {
3222
+ console.warn(
3223
+ `[avbridge:decoder] first valid raw pts \u2265 seek target \u2014 pre-anchor NOPTS frames may have straddled the target and been mis-discarded. First painted frame may be late by up to one keyframe interval.`
3224
+ );
3225
+ }
3226
+ }
3227
+ }
3228
+ } else {
3229
+ if (rawUs != null) {
3230
+ lastContentUs = rawUs;
3231
+ } else {
3232
+ lastContentUs += videoFrameStepUs;
3233
+ _diagSanFallbackFired = true;
3234
+ }
3235
+ }
3236
+ f.pts = lastContentUs;
3237
+ f.ptshi = lastContentUs < 0 ? -1 : 0;
3238
+ const _fPts = lastContentUs;
3015
3239
  if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
3016
3240
  if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
3241
+ _diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
3017
3242
  ptsRegressions++;
3018
3243
  const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
3019
3244
  if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
@@ -3025,19 +3250,34 @@ async function startDecoder(opts) {
3025
3250
  continue;
3026
3251
  }
3027
3252
  lastEmittedPtsUs = _fPts;
3253
+ const targetUs = Math.round(seekTargetSec * 1e6);
3254
+ if (_fPts < targetUs - videoFrameStepUs) {
3255
+ _diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
3256
+ continue;
3257
+ }
3028
3258
  try {
3029
3259
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
3030
- opts.renderer.enqueue(vf);
3260
+ if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
3261
+ vf.close();
3262
+ _diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
3263
+ } else {
3264
+ opts.renderer.enqueue(vf);
3265
+ _diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
3266
+ }
3031
3267
  videoFramesDecoded++;
3032
3268
  } catch (err) {
3033
3269
  if (videoFramesDecoded === 0) {
3034
3270
  console.warn("[avbridge] laFrameToVideoFrame failed:", err);
3035
3271
  }
3272
+ _diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
3036
3273
  }
3037
3274
  }
3038
3275
  }
3039
- async function decodeAudioBatch(pkts, myToken, flush = false) {
3276
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
3040
3277
  if (!audioDec || destroyed || myToken !== pumpToken) return;
3278
+ const pktPtsSec = pkts.map(
3279
+ (p) => tb ? packetPtsSec(p, tb) : null
3280
+ );
3041
3281
  let frames;
3042
3282
  const _t0 = performance.now();
3043
3283
  try {
@@ -3055,22 +3295,17 @@ async function startDecoder(opts) {
3055
3295
  audioDecodeMsTotal += performance.now() - _t0;
3056
3296
  audioDecodeBatches++;
3057
3297
  if (myToken !== pumpToken || destroyed) return;
3058
- for (const f of frames) {
3298
+ for (let i = 0; i < frames.length; i++) {
3059
3299
  if (myToken !== pumpToken || destroyed) return;
3060
- sanitizeFrameTimestamp(
3061
- f,
3062
- () => {
3063
- const ts = syntheticAudioUs;
3064
- const samples2 = f.nb_samples ?? 1024;
3065
- const sampleRate = f.sample_rate ?? 44100;
3066
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
3067
- return ts;
3068
- },
3069
- audioTimeBase
3070
- );
3300
+ const f = frames[i];
3071
3301
  const samples = libavFrameToInterleavedFloat32(f);
3072
3302
  if (samples) {
3073
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
3303
+ const pts = pktPtsSec[i] ?? null;
3304
+ if (isDebug3()) {
3305
+ const dur = samples.data.length / samples.channels / samples.sampleRate;
3306
+ console.log(`[TRACE-DEC] audio frame #${audioFramesDecoded} pts=${pts != null ? pts.toFixed(4) : "NULL"} dur=${dur.toFixed(4)} samples=${samples.data.length / samples.channels} sr=${samples.sampleRate} ch=${samples.channels} pktsIn=${pkts.length} framesOut=${frames.length}`);
3307
+ }
3308
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
3074
3309
  audioFramesDecoded++;
3075
3310
  }
3076
3311
  }
@@ -3173,14 +3408,17 @@ async function startDecoder(opts) {
3173
3408
  } catch {
3174
3409
  }
3175
3410
  await flushBSF();
3176
- syntheticVideoUs = Math.round(timeSec * 1e6);
3177
- syntheticAudioUs = Math.round(timeSec * 1e6);
3411
+ lastContentUs = -1;
3178
3412
  lastEmittedPtsUs = -1;
3413
+ firstValidPtsLoggedSinceSeek = false;
3179
3414
  pumpRunning = pumpLoop(newToken).catch(
3180
3415
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
3181
3416
  );
3182
3417
  },
3183
3418
  async seek(timeSec) {
3419
+ if (isDebug3()) {
3420
+ console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1e3).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
3421
+ }
3184
3422
  const newToken = ++pumpToken;
3185
3423
  if (pumpRunning) {
3186
3424
  try {
@@ -3211,9 +3449,14 @@ async function startDecoder(opts) {
3211
3449
  } catch {
3212
3450
  }
3213
3451
  await flushBSF();
3214
- syntheticVideoUs = Math.round(timeSec * 1e6);
3215
- syntheticAudioUs = Math.round(timeSec * 1e6);
3452
+ lastContentUs = -1;
3216
3453
  lastEmittedPtsUs = -1;
3454
+ firstValidPtsLoggedSinceSeek = false;
3455
+ seenFirstAudioPacketSinceSeek = false;
3456
+ seekTargetSec = timeSec;
3457
+ diagPktsLoggedSinceSeek = 0;
3458
+ diagFramesLoggedSinceSeek = 0;
3459
+ diagFrameKeysDumped = false;
3217
3460
  pumpRunning = pumpLoop(newToken).catch(
3218
3461
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3219
3462
  );
@@ -5584,8 +5827,42 @@ var PROXY_ATTRIBUTES = [
5584
5827
  ];
5585
5828
  var PLAYER_ATTRIBUTES = ["show-fit"];
5586
5829
  var FIT_MODES = ["contain", "cover", "fill"];
5587
- var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5830
+ var AvbridgePlayerElement = class extends HTMLElement {
5588
5831
  static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
5832
+ /**
5833
+ * Returns `true` if a DOM event originated from one of the player's
5834
+ * **interactive chrome elements** (seek bar, control buttons, settings
5835
+ * menu, overlay play button) rather than the bare video surface.
5836
+ *
5837
+ * This is the escape hatch for host pages that wrap the player in a
5838
+ * gesture recognizer (e.g. TikTok-style vertical-swipe pager). For
5839
+ * bubble-phase listeners the player's own handlers already call
5840
+ * `stopPropagation()` on chrome interactions — but **capture-phase**
5841
+ * listeners run *before* the player's handlers, so they need to check
5842
+ * the event's path themselves and bail. This helper does that check
5843
+ * via `composedPath()`, which traverses shadow boundaries correctly.
5844
+ *
5845
+ * Returns `false` for events on the bare video surface — host pages
5846
+ * remain free to claim those for their own gestures (e.g. swipe-to-pan
5847
+ * to the next video). Returns `false` for events that never hit a
5848
+ * player at all.
5849
+ *
5850
+ * @example
5851
+ * // TikTok-style vertical swipe on the document, capture phase:
5852
+ * document.addEventListener("pointerdown", (e) => {
5853
+ * if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return;
5854
+ * startSwipeGesture(e);
5855
+ * }, { capture: true });
5856
+ */
5857
+ static isPlayerChromeEvent(event) {
5858
+ const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn";
5859
+ for (const node of event.composedPath()) {
5860
+ if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) {
5861
+ return true;
5862
+ }
5863
+ }
5864
+ return false;
5865
+ }
5589
5866
  // ── Internal DOM refs ──────────────────────────────────────────────────
5590
5867
  _video;
5591
5868
  _playBtn;
@@ -5615,6 +5892,11 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5615
5892
  _activeAudioTrackId = null;
5616
5893
  _activeSubtitleTrackId = null;
5617
5894
  _userSeeking = false;
5895
+ /** Last seek target the user committed. The thumb stays here (and
5896
+ * `_updateTime` skips updating from `timeupdate`) until the underlying
5897
+ * `currentTime` actually catches up — otherwise the thumb visibly snaps
5898
+ * back to the pre-seek position while the remux pipeline rebuilds. */
5899
+ _pendingSeekTarget = null;
5618
5900
  _holdTimer = null;
5619
5901
  _holdSpeedActive = false;
5620
5902
  _savedPlaybackRate = 1;
@@ -5721,7 +6003,10 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5721
6003
  );
5722
6004
  });
5723
6005
  }
5724
- on(this._video, "loadstart", () => this._setState("loading"));
6006
+ on(this._video, "loadstart", () => {
6007
+ this._pendingSeekTarget = null;
6008
+ this._setState("loading");
6009
+ });
5725
6010
  on(this._video, "ready", () => {
5726
6011
  this._setState(this._video.paused ? "paused" : "playing");
5727
6012
  this._seekInput.max = String(this._video.duration || 0);
@@ -5868,7 +6153,9 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5868
6153
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`;
5869
6154
  }
5870
6155
  _onSeekCommit() {
5871
- this._video.currentTime = Number(this._seekInput.value);
6156
+ const target = Number(this._seekInput.value);
6157
+ this._pendingSeekTarget = target;
6158
+ this._video.currentTime = target;
5872
6159
  this._userSeeking = false;
5873
6160
  }
5874
6161
  /** Linear click-to-time mapping across the full track width (no edge clamping). */
@@ -5878,40 +6165,57 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5878
6165
  const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5879
6166
  return frac * (this._video.duration || 0);
5880
6167
  }
5881
- /** Seekbar width below which drag-to-scrub seeks in real-time (vs
5882
- * preview-only). On narrow bars precise positioning is hard, so
5883
- * immediate video feedback is more useful than a time tooltip. */
5884
- static SCRUB_WIDTH_THRESHOLD = 400;
5885
6168
  _onSeekPointerDown(e) {
5886
6169
  if (e.button !== 0 && e.pointerType === "mouse") return;
5887
6170
  e.preventDefault();
6171
+ e.stopPropagation();
5888
6172
  this._userSeeking = true;
5889
6173
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5890
6174
  seekBar.setPointerCapture(e.pointerId);
5891
6175
  seekBar.setAttribute("data-seeking", "");
5892
- const scrubMode = seekBar.getBoundingClientRect().width < _AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
5893
- let lastScrubCommit = 0;
5894
- const initial = this._timeFromSeekPointer(e.clientX);
5895
- this._seekInput.value = String(initial);
5896
- this._onSeekInput();
5897
- this._updateSeekTooltip(e.clientX);
5898
- if (scrubMode) this._onSeekCommit();
6176
+ const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
6177
+ const startTime = coarse ? this._video.currentTime || 0 : 0;
6178
+ const startClientX = e.clientX;
6179
+ let lastCommit = 0;
6180
+ const timeAt = (clientX) => {
6181
+ if (coarse) {
6182
+ const rect = seekBar.getBoundingClientRect();
6183
+ const dx = clientX - startClientX;
6184
+ const dt = dx / rect.width * (this._video.duration || 0);
6185
+ return Math.max(0, Math.min(this._video.duration || 0, startTime + dt));
6186
+ }
6187
+ return this._timeFromSeekPointer(clientX);
6188
+ };
6189
+ const showTooltip = (t, clientX) => {
6190
+ if (coarse) this._updateSeekTooltipAtTime(t);
6191
+ else this._updateSeekTooltip(clientX);
6192
+ };
6193
+ if (!coarse) {
6194
+ const initial = timeAt(e.clientX);
6195
+ this._seekInput.value = String(initial);
6196
+ this._onSeekInput();
6197
+ showTooltip(initial, e.clientX);
6198
+ this._onSeekCommit();
6199
+ this._userSeeking = true;
6200
+ } else {
6201
+ showTooltip(startTime, e.clientX);
6202
+ }
5899
6203
  const onMove = (ev) => {
5900
- const t = this._timeFromSeekPointer(ev.clientX);
6204
+ ev.stopPropagation();
6205
+ const t = timeAt(ev.clientX);
5901
6206
  this._seekInput.value = String(t);
5902
6207
  this._onSeekInput();
5903
- this._updateSeekTooltip(ev.clientX);
5904
- if (scrubMode) {
5905
- const now = performance.now();
5906
- if (now - lastScrubCommit > 250) {
5907
- lastScrubCommit = now;
5908
- this._onSeekCommit();
5909
- this._userSeeking = true;
5910
- }
6208
+ showTooltip(t, ev.clientX);
6209
+ const now = performance.now();
6210
+ if (now - lastCommit > 250) {
6211
+ lastCommit = now;
6212
+ this._onSeekCommit();
6213
+ this._userSeeking = true;
5911
6214
  }
5912
6215
  };
5913
6216
  const onUp = (ev) => {
5914
- const t = this._timeFromSeekPointer(ev.clientX);
6217
+ ev.stopPropagation();
6218
+ const t = timeAt(ev.clientX);
5915
6219
  this._seekInput.value = String(t);
5916
6220
  this._onSeekCommit();
5917
6221
  this._seekInput.focus();
@@ -5938,6 +6242,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5938
6242
  this._seekTooltip.textContent = formatTime(t);
5939
6243
  this._seekTooltip.style.left = `${frac * 100}%`;
5940
6244
  }
6245
+ /** Position the tooltip over a specific time (vs. pointer X). Used by
6246
+ * relative-drag scrub on coarse pointers, where the displayed time
6247
+ * is decoupled from the finger position. */
6248
+ _updateSeekTooltipAtTime(t) {
6249
+ const dur = this._video.duration || 0;
6250
+ const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0;
6251
+ this._seekTooltip.textContent = formatTime(t);
6252
+ this._seekTooltip.style.left = `${frac * 100}%`;
6253
+ }
5941
6254
  _updateSeekVisuals(t) {
5942
6255
  const dur = this._video.duration || 0;
5943
6256
  const pct = dur > 0 ? t / dur * 100 : 0;
@@ -5949,6 +6262,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5949
6262
  if (this._userSeeking) return;
5950
6263
  const t = this._video.currentTime;
5951
6264
  const d = this._video.duration;
6265
+ if (this._pendingSeekTarget !== null) {
6266
+ if (Math.abs(t - this._pendingSeekTarget) < 0.5) {
6267
+ this._pendingSeekTarget = null;
6268
+ } else {
6269
+ this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`;
6270
+ this._updateBuffered();
6271
+ return;
6272
+ }
6273
+ }
5952
6274
  this._seekInput.value = String(t);
5953
6275
  this._updateSeekVisuals(t);
5954
6276
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;