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