@banou/media-player 0.8.13 → 0.8.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.13",
3
+ "version": "0.8.16",
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",
@@ -43,6 +43,8 @@ export type PlaybackController = {
43
43
  * because this can take as long as a read, which over a torrent has no ceiling.
44
44
  */
45
45
  prepareSeek: (time: number) => Promise<void>
46
+ /** The element this pipeline drives, so a caller can read what actually got buffered. */
47
+ videoElement: HTMLVideoElement
46
48
  selectSubtitleStream: (streamIndex: number | undefined) => void
47
49
  /** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
48
50
  indexes: MediaIndex[]
@@ -81,6 +83,16 @@ const MAX_APPEND_ATTEMPTS = 5
81
83
  const SOURCE_OPEN_TIMEOUT = 15_000
82
84
  // how far past the playhead a range may start and still count as the one holding it
83
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
84
96
  // the fastest a drag may move the consumer's download window
85
97
  const SEEK_REPORT_MS = 200
86
98
  // quiet time that ends a drag: pointermoves arrive every few ms, so this cannot cut one in half
@@ -219,6 +231,16 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
219
231
  let seeking = false
220
232
  // how many prepares are in flight, so eviction knows not to delete what they are fetching
221
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
222
244
  let finished = false
223
245
  // libav aborts the running task when a new one starts, so both flags need a generation token
224
246
  let readGeneration = 0
@@ -272,10 +294,20 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
272
294
  const needsData = () => {
273
295
  const ranges = getTimeRanges(sourceBuffer)
274
296
  if (!ranges.length) return true
275
- const ct = videoElement.currentTime
276
- // never read past what evict() keeps
277
- if (Math.max(...ranges.map((r) => r.end)) >= ct + POST_EVICT) return false
297
+ const ct = bufferAnchor()
278
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
279
311
  return !range || range.end < ct + BUFFER_TARGET
280
312
  }
281
313
 
@@ -417,14 +449,51 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
417
449
  * The CALLER owns the deadline: this can take as long as a read takes, and over a torrent that
418
450
  * is unbounded.
419
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
+
420
458
  const prepareSeek = async (time: number) => {
421
- if (destroyed || playableAt(time)) return
459
+ if (destroyed) return
460
+ if (runwayFrom(time) >= SEEK_RUNWAY) return
422
461
  finished = false
423
462
  preparing++
463
+ prepareTarget = time
424
464
  try {
425
- await seekTo(time)
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
+ }
426
494
  } finally {
427
495
  preparing--
496
+ if (prepareTarget === time) prepareTarget = undefined
428
497
  }
429
498
  }
430
499
 
@@ -564,6 +633,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
564
633
  return {
565
634
  destroy: runTeardown,
566
635
  prepareSeek,
636
+ videoElement,
567
637
  selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
568
638
  indexes: metadata.indexes ?? [],
569
639
  duration: metadata.info.input.duration,
@@ -1,4 +1,3 @@
1
- import type { ParsedASS, ParsedASSStyles } from 'ass-compiler'
2
1
  import type { ASS_Event } from 'jassub'
3
2
  import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
4
3
 
@@ -10,8 +9,17 @@ export type SubtitleStream = { streamIndex: number, title: string, language: str
10
9
  /** -1 turns subtitles off. It frees the track and matches no header, so nothing is set. */
11
10
  export const SUBTITLES_OFF = -1
12
11
 
13
- type SubtitleHeaderPart = { type: 'header', streamIndex: number, content: string, eventsContent: string, parsed: ParsedASS }
14
- type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: ASS_Event }
12
+ /**
13
+ * jassub's `ASS_Event` types `Style` as a string, which the library it wraps does not agree with:
14
+ * the field is written straight through to libass's `int Style`, an index into the track's style
15
+ * list. Corrected once, here, so a name cannot end up in it again.
16
+ */
17
+ type StyledEvent = Omit<ASS_Event, 'Style'> & { Style: number }
18
+
19
+ const createEvent = (jassub: JASSUB, event: StyledEvent) => jassub.createEvent(event as unknown as ASS_Event)
20
+
21
+ type SubtitleHeaderPart = { type: 'header', streamIndex: number, content: string, eventsContent: string, styles: Map<string, number> }
22
+ type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: StyledEvent }
15
23
 
16
24
  export type SubtitleRendererOptions = {
17
25
  video: HTMLVideoElement
@@ -34,35 +42,36 @@ export type SubtitleRendererOptions = {
34
42
 
35
43
  const convertTimestamp = (ms: number) => new Date(ms).toISOString().slice(11, 22)
36
44
 
37
- const appendParsedStyle = (jassub: JASSUB, style: ParsedASSStyles['style'][number]) =>
38
- jassub.createStyle({
39
- ...style,
40
- treat_fontname_as_pattern: 0,
41
- Blur: 0,
42
- Justify: 0,
43
- FontName: style.Fontname,
44
- FontSize: Number(style.Fontsize),
45
- PrimaryColour: Number(style.PrimaryColour),
46
- SecondaryColour: Number(style.SecondaryColour),
47
- OutlineColour: Number(style.OutlineColour),
48
- BackColour: Number(style.BackColour),
49
- Bold: Number(style.Bold),
50
- Italic: Number(style.Italic),
51
- Underline: Number(style.Underline),
52
- StrikeOut: Number(style.StrikeOut),
53
- ScaleX: Number(style.ScaleX),
54
- ScaleY: Number(style.ScaleY),
55
- Spacing: Number(style.Spacing),
56
- Angle: Number(style.Angle),
57
- BorderStyle: Number(style.BorderStyle),
58
- Outline: Number(style.Outline),
59
- Shadow: Number(style.Shadow),
60
- Alignment: Number(style.Alignment),
61
- MarginL: Number(style.MarginL),
62
- MarginR: Number(style.MarginR),
63
- MarginV: Number(style.MarginV),
64
- Encoding: Number(style.Encoding),
65
- } as Parameters<JASSUB['createStyle']>[0])
45
+ /**
46
+ * How many styles libass keeps in front of the ones the file declares.
47
+ *
48
+ * `ass_new_track` always allocates its own "Default" at index 0 before parsing a line of the header,
49
+ * so the file's first style lands at 1. That number is what an event's `Style` field holds: libass
50
+ * reads it as an index into the track's style list and nothing else. `subtitle-scale.browser.test.tsx`
51
+ * is what pins this, by measuring the rendered text against what libass itself draws.
52
+ */
53
+ const LIBASS_OWN_STYLES = 1
54
+
55
+ const headerStyles = (content: string) =>
56
+ // a duplicated name resolves to the LAST one, the way libass's own lookup scans the list backwards
57
+ new Map(parse(content).styles.style.map((style, index) => [style.Name, index + LIBASS_OWN_STYLES]))
58
+
59
+ /**
60
+ * The index libass will resolve this event's style to.
61
+ *
62
+ * jassub types `ASS_Event.Style` as a string and writes it straight through to an `int`, so handing
63
+ * it a style NAME stores 0, which is libass's own default: Arial at size 18, with margins and a drop
64
+ * shadow of its own. Subtitles still appear, in a face and a size the file never asked for.
65
+ *
66
+ * Falling back to the header's own "Default", then to 0, is what libass does for a name it cannot
67
+ * find. The leading-`*` strip and the case fold on "Default" are its normalisation, kept so a
68
+ * `*Default` written by an older tool resolves here too.
69
+ */
70
+ const styleIndex = (header: SubtitleHeaderPart, name: string) => {
71
+ const stripped = name.replace(/^\*+/, '')
72
+ const key = stripped.toLowerCase() === 'default' ? 'Default' : stripped
73
+ return header.styles.get(key) ?? header.styles.get('Default') ?? 0
74
+ }
66
75
 
67
76
  // cleared so jassub scales the script to the canvas, not to the authored resolution
68
77
  const renderable = (content: string) => {
@@ -84,7 +93,7 @@ const toHeaderPart = (fragment: SubtitleFragment & { type: 'header' }): Subtitle
84
93
  console.warn(`subtitle stream ${fragment.streamIndex} has no Events format, ignoring the track`)
85
94
  return null
86
95
  }
87
- return { type: 'header', streamIndex: fragment.streamIndex, content: fragment.content, eventsContent, parsed: parse(fragment.content) }
96
+ return { type: 'header', streamIndex: fragment.streamIndex, content: fragment.content, eventsContent, styles: headerStyles(fragment.content) }
88
97
  }
89
98
 
90
99
  const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment & { type: 'dialogue' }): SubtitleDialoguePart => {
@@ -102,6 +111,7 @@ const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment &
102
111
  index: dialogueIndex,
103
112
  assEvent: {
104
113
  ...event,
114
+ Style: styleIndex(header, event.Style),
105
115
  Effect: event.Effect ?? '',
106
116
  Text: event.Text.raw,
107
117
  Duration: (event.End - event.Start) * 1000,
@@ -109,7 +119,7 @@ const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment &
109
119
  End: event.End * 1000,
110
120
  ReadOrder: dialogueIndex,
111
121
  _index: dialogueIndex,
112
- } as ASS_Event,
122
+ } as StyledEvent,
113
123
  }
114
124
  }
115
125
 
@@ -149,7 +159,6 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
149
159
  // jassub 1.8.x binds setRate as the ratechange listener, so the Event becomes the rate
150
160
  video.removeEventListener('ratechange', (jassub as unknown as { _boundSetRate: EventListener })._boundSetRate)
151
161
  video.addEventListener('ratechange', onRateChange)
152
- for (const style of header.parsed.styles.style) appendParsedStyle(jassub, style)
153
162
  }
154
163
 
155
164
  const pushAttachments = (incoming: Attachment[]) => {
@@ -175,7 +184,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
175
184
  const part = toDialoguePart(header, fragment)
176
185
  if (byIndex.has(part.index)) continue
177
186
  byIndex.set(part.index, part)
178
- if (selected === fragment.streamIndex) jassub?.createEvent(part.assEvent)
187
+ if (selected === fragment.streamIndex && jassub) createEvent(jassub, part.assEvent)
179
188
  }
180
189
  }
181
190
  }
@@ -188,8 +197,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
188
197
  const header = headers.get(next)
189
198
  if (!header) return
190
199
  jassub.setTrack(renderable(header.content))
191
- for (const style of header.parsed.styles.style) appendParsedStyle(jassub, style)
192
- for (const part of dialogues.get(next)?.values() ?? []) jassub.createEvent(part.assEvent)
200
+ for (const part of dialogues.get(next)?.values() ?? []) createEvent(jassub, part.assEvent)
193
201
  jassub.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
194
202
  }
195
203
 
@@ -181,6 +181,9 @@ 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)
@@ -200,7 +203,36 @@ export const ProgressBar = () => {
200
203
  // onChange reports a bare fraction, so the device that opened the gesture is recorded on press
201
204
  const dragPointerType = useRef<string | undefined>(undefined)
202
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
+
203
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
+ }
204
236
  setSeekFraction(fraction)
205
237
  if (dragPointerType.current === 'mouse') return
206
238
  setProgressBarOverTime(fraction * duration)
@@ -210,11 +242,31 @@ export const ProgressBar = () => {
210
242
 
211
243
  const onDragStart: DOMAttributes<HTMLDivElement>['onPointerDown'] = (ev) => {
212
244
  dragPointerType.current = ev.pointerType
245
+ changesThisPress.current = 0
246
+ pressFraction.current = undefined
247
+ isScrub.current = false
213
248
  handlers.onPointerDown(ev)
214
249
  }
215
250
 
216
251
  const onDragEnd: DOMAttributes<HTMLDivElement>['onPointerUp'] = (ev) => {
217
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
218
270
  // a lifted finger leaves nothing over the bar, so the preview it opened closes with it
219
271
  if (ev.pointerType === 'mouse') return
220
272
  setProgressBarOverTime(undefined)
@@ -273,15 +325,20 @@ export const ProgressBar = () => {
273
325
  return `${hoursString}${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
274
326
  }, [progressBarHoverTime])
275
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
+ */
276
337
  useEffect(() => {
277
338
  if (seekFraction === undefined || !duration) return
278
- const timestamp = seekFraction * duration
279
- // `requestSeek` gets the data in place before the playhead moves, which is what stops firefox
280
- // wedging its decoder on a seek into a hole. Absent for a media this player does not own, where
281
- // there is no pipeline to ask and the element's own seek is all there is.
282
- if (requestSeek) requestSeek(timestamp)
283
- else player.seek(timestamp)
284
- }, [player, requestSeek, seekFraction, duration])
339
+ if (!dragging || !isScrub.current) return
340
+ player.seek(seekFraction * duration)
341
+ }, [player, dragging, seekFraction, duration])
285
342
 
286
343
  const scaleX = useMemo(() => {
287
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
 
@@ -33,13 +33,6 @@ const RESTART_SETTLED_MS = 60_000
33
33
  */
34
34
  const SEEK_PREPARE_BUDGET_MS = 500
35
35
 
36
- /**
37
- * Under this gap between seek requests, it is a drag rather than a series of decisions.
38
- *
39
- * Kept equal to the engine's own settle window, so both layers agree on what a drag is.
40
- */
41
- const DRAG_SETTLE_MS = 250
42
-
43
36
  const messageOf = (error: unknown) =>
44
37
  error instanceof Error ? error.message : String(error)
45
38
 
@@ -127,8 +120,6 @@ export const usePlayback = (
127
120
  * deliberately not, since a streaming consumer passes a fresh closure several times a second.
128
121
  */
129
122
  const resumeRef = useRef<{ time: number, size: number } | null>(null)
130
- // when the seek bar last asked for a position, which is how a drag is told from a click
131
- const lastSeekRequestAt = useRef(0)
132
123
  // The renderer turns the first track on by itself, so the menu has to mirror that or it shows
133
124
  // "Disable" ticked over subtitles that are visibly on screen.
134
125
  const subtitleChoiceMade = useRef(false)
@@ -167,28 +158,51 @@ export const usePlayback = (
167
158
  if (!controller || seekPrepareBudgetMs <= 0) { player.seek(time); return }
168
159
 
169
160
  /*
170
- * A drag is left exactly as it was.
161
+ * Every call gets the data first. There is deliberately no "this looks like a drag" shortcut.
171
162
  *
172
- * The seek bar reports a fraction on every pointermove, so preparing each one would remux per
173
- * move, and waiting on each would make a scrub feel like treacle. Neither is worth paying:
174
- * a drag never reproduced this fault (its seeks land ~28ms apart, far too fast for a drain to
175
- * complete), and the engine already coalesces the remux to wherever the drag stops.
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.
176
168
  *
177
- * Discrete seeks are the ones that wedge it, at a few hundred ms apart, and those get the data
178
- * first.
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.
179
172
  */
180
- const now = performance.now()
181
- const dragging = now - lastSeekRequestAt.current < DRAG_SETTLE_MS
182
- lastSeekRequestAt.current = now
183
- if (dragging) { player.seek(time); return }
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()
184
185
 
185
186
  let moved = false
187
+ let movedBecause: 'prepared' | 'deadline' = 'prepared'
186
188
  const move = () => {
187
189
  if (moved) return
188
190
  moved = true
189
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
+ }
190
204
  }
191
- const deadline = setTimeout(move, seekPrepareBudgetMs)
205
+ const deadline = setTimeout(() => { movedBecause = 'deadline'; move() }, seekPrepareBudgetMs)
192
206
  void controller
193
207
  .prepareSeek(time)
194
208
  // a failed prepare is not a reason to refuse the seek: the pump and the existing recovery