@banou/media-player 0.8.12 → 0.8.15

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.
@@ -17,6 +17,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
17
17
  togglePictureInPicture: (() => void) | null;
18
18
  pictureInPictureMode: import("../engine").PictureInPictureMode | null;
19
19
  burnedInSubtitles: boolean;
20
+ requestSeek?: (time: number) => void;
20
21
  playbackError: unknown;
21
22
  playbackErrors: import("./source-feature").PlaybackErrorEntry[];
22
23
  ready: boolean;
@@ -115,6 +115,18 @@ export type SourceState = {
115
115
  pictureInPictureMode: PictureInPictureMode | null;
116
116
  /** Burn-in only. True while the composite is the picture on screen. */
117
117
  burnedInSubtitles: boolean;
118
+ /**
119
+ * Move the playhead, letting the pipeline get the data there first.
120
+ *
121
+ * The chrome calls this instead of the player's own `seek`, because an element that demuxes into a
122
+ * hole is what wedges firefox's decoder: the underrun drains it and nothing ever flushes it again.
123
+ * Waiting is bounded by a deadline, so a source that cannot answer in time costs that deadline and
124
+ * not the whole read.
125
+ *
126
+ * Undefined for a media this player does not own, where there is no pipeline to prepare and the
127
+ * caller should seek directly.
128
+ */
129
+ requestSeek?: (time: number) => void;
118
130
  /** Set when the pipeline fails. Cleared when it recovers. */
119
131
  playbackError: unknown;
120
132
  /**
@@ -190,6 +202,18 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
190
202
  pictureInPictureMode: PictureInPictureMode | null;
191
203
  /** Burn-in only. True while the composite is the picture on screen. */
192
204
  burnedInSubtitles: boolean;
205
+ /**
206
+ * Move the playhead, letting the pipeline get the data there first.
207
+ *
208
+ * The chrome calls this instead of the player's own `seek`, because an element that demuxes into a
209
+ * hole is what wedges firefox's decoder: the underrun drains it and nothing ever flushes it again.
210
+ * Waiting is bounded by a deadline, so a source that cannot answer in time costs that deadline and
211
+ * not the whole read.
212
+ *
213
+ * Undefined for a media this player does not own, where there is no pipeline to prepare and the
214
+ * caller should seek directly.
215
+ */
216
+ requestSeek?: (time: number) => void;
193
217
  /** Set when the pipeline fails. Cleared when it recovers. */
194
218
  playbackError: unknown;
195
219
  /**
@@ -93,6 +93,14 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
93
93
  /** Fallback face for `liberation sans`, used when a subtitle track names a font the file does not carry. */
94
94
  defaultFontUrl?: string;
95
95
  bufferSize?: number;
96
+ /**
97
+ * How long a seek waits for its own data before the playhead moves anyway, in ms. Default 500.
98
+ *
99
+ * Seeking into a hole is what wedges firefox's decoder, so the pipeline is asked for the target
100
+ * first. This is the ceiling on that wait, since a read over a torrent has none of its own. Set
101
+ * it to 0 to seek immediately and rely on the recovery instead.
102
+ */
103
+ seekPrepareBudgetMs?: number;
96
104
  /** Byte spans available, painted on the seekbar and informing the thumbnail generator. */
97
105
  downloadedRanges?: DownloadedRange[];
98
106
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.12",
3
+ "version": "0.8.15",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,6 +36,15 @@ export type PlaybackOptions = {
36
36
 
37
37
  export type PlaybackController = {
38
38
  destroy: () => void
39
+ /**
40
+ * Get the data for a position in place BEFORE the playhead moves there.
41
+ *
42
+ * Resolves at once when that position already has data. The caller decides how long to wait,
43
+ * because this can take as long as a read, which over a torrent has no ceiling.
44
+ */
45
+ prepareSeek: (time: number) => Promise<void>
46
+ /** The element this pipeline drives, so a caller can read what actually got buffered. */
47
+ videoElement: HTMLVideoElement
39
48
  selectSubtitleStream: (streamIndex: number | undefined) => void
40
49
  /** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
41
50
  indexes: MediaIndex[]
@@ -74,6 +83,16 @@ const MAX_APPEND_ATTEMPTS = 5
74
83
  const SOURCE_OPEN_TIMEOUT = 15_000
75
84
  // how far past the playhead a range may start and still count as the one holding it
76
85
  const BOUNDARY_SLACK = 1
86
+ /*
87
+ * Seconds of data a seek target needs behind it before the playhead is allowed to move there.
88
+ *
89
+ * Covering the target instant is not enough: the element plays through a one chunk island in well
90
+ * under a second and runs dry, and that underrun drains firefox's decoder just as a seek into a hole
91
+ * does. Measured in production, six seeks that all reported themselves prepared still wedged.
92
+ */
93
+ const SEEK_RUNWAY = 3
94
+ // reads allowed to build that runway, so a source that answers with nothing cannot spin here
95
+ const SEEK_RUNWAY_READS = 12
77
96
  // the fastest a drag may move the consumer's download window
78
97
  const SEEK_REPORT_MS = 200
79
98
  // quiet time that ends a drag: pointermoves arrive every few ms, so this cannot cut one in half
@@ -210,6 +229,18 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
210
229
 
211
230
  let reading = false
212
231
  let seeking = false
232
+ // how many prepares are in flight, so eviction knows not to delete what they are fetching
233
+ let preparing = 0
234
+ /*
235
+ * Where buffering should aim while a seek is being prepared.
236
+ *
237
+ * The playhead has not moved there yet, so anything anchored on `currentTime` is aiming at the
238
+ * position being left behind. `needsData` in particular would answer for the old position and
239
+ * stop reading, which is how a prepare came to leave a one chunk island: enough to cover the
240
+ * target instant, and nowhere near enough to play from.
241
+ */
242
+ let prepareTarget: number | undefined
243
+ const bufferAnchor = () => prepareTarget ?? videoElement.currentTime
213
244
  let finished = false
214
245
  // libav aborts the running task when a new one starts, so both flags need a generation token
215
246
  let readGeneration = 0
@@ -237,6 +268,18 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
237
268
  }
238
269
 
239
270
  const evict = async (tight = false) => {
271
+ /*
272
+ * Hold off while data is being put in place for a position the playhead has not reached.
273
+ *
274
+ * Eviction is anchored on the playhead, and `prepareSeek` deliberately appends far from it, so
275
+ * running now deletes exactly what was just fetched. The 100ms interval below made that a
276
+ * certainty. The benchmark caught it as prepared seeks being the SLOWEST arm, which is the
277
+ * opposite of the point.
278
+ *
279
+ * `tight` still runs, because that one is the answer to a quota refusal and has to be able to
280
+ * free space no matter what else is happening.
281
+ */
282
+ if (preparing > 0 && !tight) return
240
283
  const ct = videoElement.currentTime
241
284
  const pre = tight ? PRE_EVICT_TIGHT : PRE_EVICT
242
285
  const post = tight ? POST_EVICT_TIGHT : POST_EVICT
@@ -251,10 +294,20 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
251
294
  const needsData = () => {
252
295
  const ranges = getTimeRanges(sourceBuffer)
253
296
  if (!ranges.length) return true
254
- const ct = videoElement.currentTime
255
- // never read past what evict() keeps
256
- if (Math.max(...ranges.map((r) => r.end)) >= ct + POST_EVICT) return false
297
+ const ct = bufferAnchor()
257
298
  const range = ranges.find((r) => r.start <= ct + BOUNDARY_SLACK && ct < r.end)
299
+ /*
300
+ * Never read past what evict() keeps, measured on the run holding the anchor rather than
301
+ * across every island.
302
+ *
303
+ * Taking the max end over ALL ranges meant one stale island far ahead could refuse a read
304
+ * right where the playhead was about to be. Repeated seeking is exactly how those islands
305
+ * appear, and eviction is suppressed while a seek is being prepared, so they survive to do it.
306
+ * Seen in production as forward seeks getting a 4s runway while every BACKWARD seek collapsed
307
+ * to 1.0s, then 0.5s, then 0.3s, and wedged: the island left out at ~760s was past
308
+ * `target + POST_EVICT` for every one of them.
309
+ */
310
+ if (range && range.end >= ct + POST_EVICT) return false
258
311
  return !range || range.end < ct + BUFFER_TARGET
259
312
  }
260
313
 
@@ -375,6 +428,75 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
375
428
  */
376
429
  const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
377
430
 
431
+ /** Whether a GIVEN time has data behind it, as opposed to whether the playhead does. */
432
+ const playableAt = (time: number) =>
433
+ getTimeRanges(sourceBuffer).some((r) => r.start <= time + BOUNDARY_SLACK && time < r.end)
434
+
435
+ /**
436
+ * Put the data in place for a position the playhead has NOT moved to yet.
437
+ *
438
+ * Firefox wedges its own decoder when the element demuxes into a hole: the underrun requests a
439
+ * drain, the drain completes, the re-prime that would flush the decoder never runs, and every
440
+ * packet after that comes back `avcodec_send_packet error: End of file`. Seeking only after the
441
+ * target has data removes the hole, and with it the drain and the whole failure.
442
+ *
443
+ * Measured on a standalone rig against a real 1080p stream: 7 wedges in 7 runs seeking the
444
+ * ordinary way, 0 in 4 seeking this way. The control matters, because appending first also
445
+ * delays the seek: delaying the seek by the same amount while appending nothing still wedged 4
446
+ * out of 4, so it is the data and not the delay.
447
+ *
448
+ * Returns as soon as there is nothing to wait for, so a seek into buffered ground costs nothing.
449
+ * The CALLER owns the deadline: this can take as long as a read takes, and over a torrent that
450
+ * is unbounded.
451
+ */
452
+ /** Seconds of contiguous data past a position that make it safe to play from. */
453
+ const runwayFrom = (time: number) => {
454
+ const range = getTimeRanges(sourceBuffer).find((r) => r.start <= time + BOUNDARY_SLACK && time < r.end)
455
+ return range ? range.end - time : 0
456
+ }
457
+
458
+ const prepareSeek = async (time: number) => {
459
+ if (destroyed) return
460
+ if (runwayFrom(time) >= SEEK_RUNWAY) return
461
+ finished = false
462
+ preparing++
463
+ prepareTarget = time
464
+ try {
465
+ if (!playableAt(time)) await seekTo(time)
466
+ /*
467
+ * Captured AFTER the seek, never before.
468
+ *
469
+ * `seekTo` bumps `seekGeneration` itself, so a generation read before it is stale the moment
470
+ * it returns and every staleness check below fails on its first pass. That silently disabled
471
+ * this entire loop: prepares came back in ~170ms with a 0.5s to 1.9s island and the runway
472
+ * was never built at all.
473
+ */
474
+ const generation = seekGeneration
475
+ /*
476
+ * Then keep reading until there is something to PLAY, not merely something to land on.
477
+ *
478
+ * `remuxer.seek` returns a single chunk, so the first version of this left a small island:
479
+ * the element arrived on covered ground, played through it in well under a second, and ran
480
+ * dry there. That underrun drains the decoder exactly as a seek into a hole would, and it
481
+ * wedged in production with every seek reporting itself as prepared. Preparing a point was
482
+ * never the requirement; preparing a runway is.
483
+ *
484
+ * Bounded twice over: by the runway being reached, and by a read that returns nothing new.
485
+ * The caller's deadline bounds the wall clock on top of that.
486
+ */
487
+ for (let attempt = 0; attempt < SEEK_RUNWAY_READS; attempt++) {
488
+ if (destroyed || generation !== seekGeneration || finished) break
489
+ if (runwayFrom(time) >= SEEK_RUNWAY) break
490
+ const before = runwayFrom(time)
491
+ await pump()
492
+ if (runwayFrom(time) <= before) break
493
+ }
494
+ } finally {
495
+ preparing--
496
+ if (prepareTarget === time) prepareTarget = undefined
497
+ }
498
+ }
499
+
378
500
  /**
379
501
  * Where a drag reaches the consumer, at a rate a consumer can act on.
380
502
  *
@@ -437,6 +559,15 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
437
559
  const dragging = now - lastSeekingAt < DRAG_SETTLE_MS
438
560
  lastSeekingAt = now
439
561
  if (alreadyPlayable(time)) return
562
+ /*
563
+ * A seek for exactly this position is already running, so let it finish.
564
+ *
565
+ * `prepareSeek` starts one BEFORE the playhead moves, and the caller moves the playhead anyway
566
+ * once its budget runs out. Without this, the move fires `seeking`, which starts the identical
567
+ * read again and bumps the generation, throwing away everything the first one had done. The
568
+ * benchmark caught it as a seek that took twice as long as seeking the old way.
569
+ */
570
+ if (seeking && Math.abs(time - lastSeekPosition) < 0.001) return
440
571
  finished = false
441
572
  if (dragTimer) clearTimeout(dragTimer)
442
573
  if (!dragging) {
@@ -501,6 +632,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
501
632
 
502
633
  return {
503
634
  destroy: runTeardown,
635
+ prepareSeek,
636
+ videoElement,
504
637
  selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
505
638
  indexes: metadata.indexes ?? [],
506
639
  duration: metadata.info.input.duration,
@@ -190,6 +190,9 @@ export const ControlBar = () => {
190
190
  const pictureInPictureMode = usePlayer((state) => state.pictureInPictureMode)
191
191
  const burnedInSubtitles = usePlayer((state) => state.burnedInSubtitles)
192
192
  const subtitleTracks = usePlayer((state) => state.subtitleTracks)
193
+ // Keyboard seeks go through the same door as the seek bar: data first, then the playhead. See
194
+ // `requestSeek` on the source state for why an element that seeks into a hole is the problem.
195
+ const requestSeek = usePlayer((state) => state.requestSeek)
193
196
  const [volumeElement, setVolumeElement] = useState<HTMLButtonElement | null>(null)
194
197
 
195
198
  const burnIn = pictureInPictureMode === 'burn-in'
@@ -213,6 +216,7 @@ export const ControlBar = () => {
213
216
  }, [player])
214
217
 
215
218
  useEffect(() => {
219
+ const seek = (time: number) => requestSeek ? requestSeek(time) : player.seek(time)
216
220
  const eventListener = (ev: KeyboardEvent) => {
217
221
  // on the window, so typing into a consumer's own input is the one case that opts out
218
222
  const target = ev.target as HTMLElement | null
@@ -232,10 +236,10 @@ export const ControlBar = () => {
232
236
  }
233
237
  else if (ev.key === 'ArrowRight') {
234
238
  if (!player.duration) return
235
- player.seek(Math.min(player.currentTime + SEEK_STEP, player.duration))
239
+ seek(Math.min(player.currentTime + SEEK_STEP, player.duration))
236
240
  }
237
241
  else if (ev.key === 'ArrowLeft') {
238
- player.seek(Math.max(player.currentTime - SEEK_STEP, 0))
242
+ seek(Math.max(player.currentTime - SEEK_STEP, 0))
239
243
  }
240
244
  else {
241
245
  shouldPreventDefault = false
@@ -247,7 +251,7 @@ export const ControlBar = () => {
247
251
  }
248
252
  window.addEventListener('keydown', eventListener)
249
253
  return () => window.removeEventListener('keydown', eventListener)
250
- }, [player, modifyVolume])
254
+ }, [player, modifyVolume, requestSeek])
251
255
 
252
256
  const handleWheel = useCallback((e: WheelEvent) => {
253
257
  e.preventDefault()
@@ -181,9 +181,13 @@ const style = css`
181
181
  }
182
182
  `
183
183
 
184
+ /** Movement from the press, in px, past which a gesture is a scrub rather than a click. */
185
+ const SCRUB_THRESHOLD_PX = 4
186
+
184
187
  export const ProgressBar = () => {
185
188
  const player = usePlayer()
186
189
  const currentTime = usePlayer((state) => state.currentTime)
190
+ const requestSeek = usePlayer((state) => state.requestSeek)
187
191
  const duration = usePlayer((state) => state.duration)
188
192
  const size = usePlayer((state) => state.size)
189
193
  const downloadedRanges = usePlayer((state) => state.downloadedRanges)
@@ -199,7 +203,36 @@ export const ProgressBar = () => {
199
203
  // onChange reports a bare fraction, so the device that opened the gesture is recorded on press
200
204
  const dragPointerType = useRef<string | undefined>(undefined)
201
205
 
206
+ /*
207
+ * How many changes this gesture has produced, which is what tells a click from a scrub.
208
+ *
209
+ * `useDragValue` reports onChange on the PRESS and again on every move, so a click that drifts one
210
+ * pixel produces two. Counting them is exact where a timer is not: the first is the press, and
211
+ * anything after it is the pointer actually moving.
212
+ */
213
+ const changesThisPress = useRef(0)
214
+ const latestFraction = useRef<number | undefined>(undefined)
215
+ const pressFraction = useRef<number | undefined>(undefined)
216
+ /*
217
+ * Whether this gesture has become a scrub, latched once it has.
218
+ *
219
+ * A pixel of drift is a click, not a drag: every real mouse produces some. Counting changes was
220
+ * not enough, because that pixel arrives as a pointermove and so looked like scrubbing, which put
221
+ * the playhead onto unbuffered ground before its data existed and wedged firefox exactly as
222
+ * before. Distance from the press is the honest test, and it latches so that dragging back toward
223
+ * the origin does not flip the gesture back into a click.
224
+ */
225
+ const isScrub = useRef(false)
226
+
202
227
  const onSeekDrag = (fraction: number) => {
228
+ changesThisPress.current += 1
229
+ latestFraction.current = fraction
230
+ if (pressFraction.current === undefined) {
231
+ pressFraction.current = fraction
232
+ } else if (!isScrub.current) {
233
+ const width = progressBarRef.current?.getBoundingClientRect().width ?? 0
234
+ if (Math.abs(fraction - pressFraction.current) * width > SCRUB_THRESHOLD_PX) isScrub.current = true
235
+ }
203
236
  setSeekFraction(fraction)
204
237
  if (dragPointerType.current === 'mouse') return
205
238
  setProgressBarOverTime(fraction * duration)
@@ -209,11 +242,31 @@ export const ProgressBar = () => {
209
242
 
210
243
  const onDragStart: DOMAttributes<HTMLDivElement>['onPointerDown'] = (ev) => {
211
244
  dragPointerType.current = ev.pointerType
245
+ changesThisPress.current = 0
246
+ pressFraction.current = undefined
247
+ isScrub.current = false
212
248
  handlers.onPointerDown(ev)
213
249
  }
214
250
 
215
251
  const onDragEnd: DOMAttributes<HTMLDivElement>['onPointerUp'] = (ev) => {
216
252
  handlers.onPointerUp(ev)
253
+ /*
254
+ * The seek that counts, and the only one allowed to move the playhead onto new ground.
255
+ *
256
+ * Where the gesture ENDED is the position anyone is waiting for, and it is issued through
257
+ * `requestSeek` so the data is in place before the element demuxes there. A seek into a hole is
258
+ * what wedges firefox's decoder, and the whole gesture exists to arrive at this one moment.
259
+ */
260
+ // a pointerup with no press behind it is not this gesture, and must not seek anywhere
261
+ const fraction = changesThisPress.current > 0 ? latestFraction.current : undefined
262
+ if (fraction !== undefined && duration) {
263
+ const timestamp = fraction * duration
264
+ if (requestSeek) requestSeek(timestamp)
265
+ else player.seek(timestamp)
266
+ }
267
+ changesThisPress.current = 0
268
+ isScrub.current = false
269
+ pressFraction.current = undefined
217
270
  // a lifted finger leaves nothing over the bar, so the preview it opened closes with it
218
271
  if (ev.pointerType === 'mouse') return
219
272
  setProgressBarOverTime(undefined)
@@ -272,11 +325,20 @@ export const ProgressBar = () => {
272
325
  return `${hoursString}${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
273
326
  }, [progressBarHoverTime])
274
327
 
328
+ /*
329
+ * Follow the pointer while it is actually moving, and NOT on the press itself.
330
+ *
331
+ * Seeking on the press is what defeated the first version of this: a click that drifts a pixel
332
+ * looked like a drag, so the playhead jumped onto unbuffered ground before its data existed, which
333
+ * is exactly the wedge. A scrub still follows the pointer, because a drag has never reproduced the
334
+ * fault (its seeks land ~28ms apart, far too fast for a drain to complete) and a frozen picture
335
+ * during a scrub would be a real regression. The settled position is handled on release.
336
+ */
275
337
  useEffect(() => {
276
338
  if (seekFraction === undefined || !duration) return
277
- const timestamp = seekFraction * duration
278
- player.seek(timestamp)
279
- }, [player, seekFraction, duration])
339
+ if (!dragging || !isScrub.current) return
340
+ player.seek(seekFraction * duration)
341
+ }, [player, dragging, seekFraction, duration])
280
342
 
281
343
  const scaleX = useMemo(() => {
282
344
  return !duration || typeof currentTime !== 'number'
@@ -35,7 +35,10 @@ export const useDragValue = ({ ref, onChange, orientation = 'horizontal', disabl
35
35
  // events the seek preview runs on, the second keeps the press from reaching the document
36
36
  // listeners that close the popover and refresh the auto-hide timer.
37
37
  activePointer.current = event.pointerId
38
- event.currentTarget.setPointerCapture?.(event.pointerId)
38
+ // Throws NotFoundError for a pointer id that is not active, and an exception here would abandon
39
+ // the gesture before it records anything. Capture is an optimisation for tracking outside the
40
+ // element, never a requirement.
41
+ try { event.currentTarget.setPointerCapture?.(event.pointerId) } catch {}
39
42
  setDragging(true)
40
43
  onChangeRef.current(fractionFor(event.clientX, event.clientY))
41
44
  }, [disabled, fractionFor])
@@ -48,7 +51,7 @@ export const useDragValue = ({ ref, onChange, orientation = 'horizontal', disabl
48
51
  const endDrag = useCallback((event: ReactPointerEvent<HTMLElement>) => {
49
52
  if (activePointer.current !== event.pointerId) return
50
53
  activePointer.current = null
51
- event.currentTarget.releasePointerCapture?.(event.pointerId)
54
+ try { event.currentTarget.releasePointerCapture?.(event.pointerId) } catch {}
52
55
  setDragging(false)
53
56
  }, [])
54
57
 
@@ -25,6 +25,14 @@ const RESTART_BACKOFF_MS = [0, 250, 1_000, 3_000, 10_000]
25
25
  // far enough back that an unrelated hiccup later in an episode starts from no delay again
26
26
  const RESTART_SETTLED_MS = 60_000
27
27
 
28
+ /**
29
+ * How long a seek may wait for its data before the playhead moves anyway.
30
+ *
31
+ * The prevention only works while the wait is honoured, and a read over a torrent has no ceiling, so
32
+ * this is the ceiling. Half a second is the point where a seek stops feeling like a seek.
33
+ */
34
+ const SEEK_PREPARE_BUDGET_MS = 500
35
+
28
36
  const messageOf = (error: unknown) =>
29
37
  error instanceof Error ? error.message : String(error)
30
38
 
@@ -66,6 +74,7 @@ export const usePlayback = (
66
74
  const {
67
75
  read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
68
76
  jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
77
+ seekPrepareBudgetMs = SEEK_PREPARE_BUDGET_MS,
69
78
  } = options ?? ({} as Partial<MediaPlayerLocalOptions>)
70
79
 
71
80
  // The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
@@ -131,6 +140,77 @@ export const usePlayback = (
131
140
  controllerRef.current?.selectSubtitleStream(streamIndex)
132
141
  }, [player])
133
142
 
143
+ /**
144
+ * Seek with the data already there, or after the deadline, whichever comes first.
145
+ *
146
+ * The prevention for the firefox decoder wedge. Seeking into a hole makes the reader drain the
147
+ * decoder and never flush it again, so the pipeline is asked for the target first. The deadline is
148
+ * what keeps that honest: a read over a torrent has no ceiling, and a seek bar that waits on one
149
+ * is worse than the fault it avoids. So the playhead moves either when the data lands or when the
150
+ * budget runs out, once, whichever happens first.
151
+ *
152
+ * A seek into buffered ground resolves immediately, so scrubbing inside the buffer is unaffected.
153
+ */
154
+ const requestSeek = useCallback((time: number) => {
155
+ const controller = controllerRef.current
156
+ // no budget means the feature is off: seek at once and do not spend a read preparing something
157
+ // nobody is going to wait for
158
+ if (!controller || seekPrepareBudgetMs <= 0) { player.seek(time); return }
159
+
160
+ /*
161
+ * Every call gets the data first. There is deliberately no "this looks like a drag" shortcut.
162
+ *
163
+ * The first version of this guessed at drags from the gap between calls, and that is what let
164
+ * the fault through in production: the seek bar reports a change on the PRESS and again on every
165
+ * move, so a click that drifts one pixel produced two calls milliseconds apart, the second was
166
+ * read as a drag, and the playhead jumped onto unbuffered ground before its data existed. Two
167
+ * quick taps of the arrow keys would have done the same.
168
+ *
169
+ * The chrome knows what gesture it is in and now says so by only calling this for settled seeks,
170
+ * which is knowledge rather than inference. Rapid calls are safe here anyway: each supersedes
171
+ * the last through the engine's seek generation.
172
+ */
173
+ /*
174
+ * A debug trace, off unless asked for.
175
+ *
176
+ * The fault this prevents is reproducible in seconds on some machines and not at all on others,
177
+ * so the only way to learn anything from someone else's reproduction is to have the seek say
178
+ * what it did. Set `window.__mediaPlayerSeekDebug = true` (the dev route does it for
179
+ * `?seekDebug=1`) and every seek reports whether the data was ready, how long it waited, and
180
+ * whether the playhead moved before the data arrived, which is the exact condition that wedges
181
+ * firefox.
182
+ */
183
+ const debug = typeof window !== 'undefined' && (window as { __mediaPlayerSeekDebug?: boolean }).__mediaPlayerSeekDebug
184
+ const startedAt = performance.now()
185
+
186
+ let moved = false
187
+ let movedBecause: 'prepared' | 'deadline' = 'prepared'
188
+ const move = () => {
189
+ if (moved) return
190
+ moved = true
191
+ player.seek(time)
192
+ if (debug) {
193
+ // eslint-disable-next-line no-console
194
+ const video = controller.videoElement
195
+ let runway = 0
196
+ if (video) {
197
+ for (let i = 0; i < video.buffered.length; i++) {
198
+ if (video.buffered.start(i) <= time + 1 && time < video.buffered.end(i)) runway = video.buffered.end(i) - time
199
+ }
200
+ }
201
+ // eslint-disable-next-line no-console
202
+ console.warn(`[media-player] seek to ${time.toFixed(2)} moved after ${Math.round(performance.now() - startedAt)}ms via ${movedBecause}, runway ${runway.toFixed(1)}s${movedBecause === 'deadline' ? ' (EXPOSED: moved before its data was ready)' : ''}`)
203
+ }
204
+ }
205
+ const deadline = setTimeout(() => { movedBecause = 'deadline'; move() }, seekPrepareBudgetMs)
206
+ void controller
207
+ .prepareSeek(time)
208
+ // a failed prepare is not a reason to refuse the seek: the pump and the existing recovery
209
+ // both still apply, and refusing would strand the viewer on a bar that does nothing
210
+ .catch(() => {})
211
+ .finally(() => { clearTimeout(deadline); move() })
212
+ }, [player, seekPrepareBudgetMs])
213
+
134
214
  const selectAudioTrack = useCallback((id: string | number) => {
135
215
  if (typeof id !== 'number') return
136
216
  player.setSourceState({ selectedAudioTrack: id })
@@ -143,8 +223,8 @@ export const usePlayback = (
143
223
  const setSourceState = usePlayer((state) => state.setSourceState)
144
224
 
145
225
  useEffect(() => {
146
- setSourceState({ selectSubtitleTrack, selectAudioTrack })
147
- }, [setSourceState, selectSubtitleTrack, selectAudioTrack])
226
+ setSourceState({ selectSubtitleTrack, selectAudioTrack, requestSeek })
227
+ }, [setSourceState, selectSubtitleTrack, selectAudioTrack, requestSeek])
148
228
 
149
229
  useEffect(() => {
150
230
  if (!video || !canvas || !size || !read) return
@@ -128,6 +128,19 @@ export type SourceState = {
128
128
  /** Burn-in only. True while the composite is the picture on screen. */
129
129
  burnedInSubtitles: boolean
130
130
 
131
+ /**
132
+ * Move the playhead, letting the pipeline get the data there first.
133
+ *
134
+ * The chrome calls this instead of the player's own `seek`, because an element that demuxes into a
135
+ * hole is what wedges firefox's decoder: the underrun drains it and nothing ever flushes it again.
136
+ * Waiting is bounded by a deadline, so a source that cannot answer in time costs that deadline and
137
+ * not the whole read.
138
+ *
139
+ * Undefined for a media this player does not own, where there is no pipeline to prepare and the
140
+ * caller should seek directly.
141
+ */
142
+ requestSeek?: (time: number) => void
143
+
131
144
  /** Set when the pipeline fails. Cleared when it recovers. */
132
145
  playbackError: unknown
133
146
  /**
@@ -109,6 +109,14 @@ export type MediaPlayerLocalOptions =
109
109
  /** Fallback face for `liberation sans`, used when a subtitle track names a font the file does not carry. */
110
110
  defaultFontUrl?: string
111
111
  bufferSize?: number
112
+ /**
113
+ * How long a seek waits for its own data before the playhead moves anyway, in ms. Default 500.
114
+ *
115
+ * Seeking into a hole is what wedges firefox's decoder, so the pipeline is asked for the target
116
+ * first. This is the ceiling on that wait, since a read over a torrent has none of its own. Set
117
+ * it to 0 to seek immediately and rely on the recovery instead.
118
+ */
119
+ seekPrepareBudgetMs?: number
112
120
 
113
121
  /** Byte spans available, painted on the seekbar and informing the thumbnail generator. */
114
122
  downloadedRanges?: DownloadedRange[]