@banou/media-player 0.8.11 → 0.8.13

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.
@@ -1,4 +1,6 @@
1
1
  import type { PlaybackErrorEntry } from '../source-feature';
2
+ /** How many failures there have been, which is not how many rows: a repeat folds into its row. */
3
+ export declare const countErrors: (errors: PlaybackErrorEntry[]) => number;
2
4
  /** What lands on the clipboard: the same thing the panel shows, in the order it happened. */
3
5
  export declare const formatErrors: (errors: PlaybackErrorEntry[]) => string;
4
6
  /**
@@ -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;
@@ -26,9 +26,20 @@ export type TrackChoice = {
26
26
  * `cause` may be a `MediaError` that reads differently once the element has moved on.
27
27
  */
28
28
  export type PlaybackErrorEntry = {
29
- /** Wall clock, so the report can be read next to a console log or a torrent's timeline. */
29
+ /** Wall clock of the FIRST of them, so the report can be read next to a console log. */
30
30
  at: number;
31
- /** Seconds into the media, which is usually the first question asked of a playback failure. */
31
+ /** Wall clock of the most recent one. Equal to `at` until a repeat has folded into this row. */
32
+ lastAt: number;
33
+ /**
34
+ * How many times in a row this exact failure happened.
35
+ *
36
+ * A source that stays broken reports the same sentence every few seconds for as long as it stays
37
+ * broken, so consecutive identical failures fold into one row rather than filling the panel with a
38
+ * wall of one message. This is also what keeps the list from growing without end in that case,
39
+ * since there is no ceiling on how many failures are kept.
40
+ */
41
+ count: number;
42
+ /** Seconds into the media, at the first of them, which is usually the first question asked. */
32
43
  atMediaTime?: number;
33
44
  message: string;
34
45
  /** The `cause` chain, already unwound, one line per level. */
@@ -104,6 +115,18 @@ export type SourceState = {
104
115
  pictureInPictureMode: PictureInPictureMode | null;
105
116
  /** Burn-in only. True while the composite is the picture on screen. */
106
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;
107
130
  /** Set when the pipeline fails. Cleared when it recovers. */
108
131
  playbackError: unknown;
109
132
  /**
@@ -179,6 +202,18 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
179
202
  pictureInPictureMode: PictureInPictureMode | null;
180
203
  /** Burn-in only. True while the composite is the picture on screen. */
181
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;
182
217
  /** Set when the pipeline fails. Cleared when it recovers. */
183
218
  playbackError: unknown;
184
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.11",
3
+ "version": "0.8.13",
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,13 @@ 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>
39
46
  selectSubtitleStream: (streamIndex: number | undefined) => void
40
47
  /** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
41
48
  indexes: MediaIndex[]
@@ -210,6 +217,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
210
217
 
211
218
  let reading = false
212
219
  let seeking = false
220
+ // how many prepares are in flight, so eviction knows not to delete what they are fetching
221
+ let preparing = 0
213
222
  let finished = false
214
223
  // libav aborts the running task when a new one starts, so both flags need a generation token
215
224
  let readGeneration = 0
@@ -237,6 +246,18 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
237
246
  }
238
247
 
239
248
  const evict = async (tight = false) => {
249
+ /*
250
+ * Hold off while data is being put in place for a position the playhead has not reached.
251
+ *
252
+ * Eviction is anchored on the playhead, and `prepareSeek` deliberately appends far from it, so
253
+ * running now deletes exactly what was just fetched. The 100ms interval below made that a
254
+ * certainty. The benchmark caught it as prepared seeks being the SLOWEST arm, which is the
255
+ * opposite of the point.
256
+ *
257
+ * `tight` still runs, because that one is the answer to a quota refusal and has to be able to
258
+ * free space no matter what else is happening.
259
+ */
260
+ if (preparing > 0 && !tight) return
240
261
  const ct = videoElement.currentTime
241
262
  const pre = tight ? PRE_EVICT_TIGHT : PRE_EVICT
242
263
  const post = tight ? POST_EVICT_TIGHT : POST_EVICT
@@ -375,6 +396,38 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
375
396
  */
376
397
  const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
377
398
 
399
+ /** Whether a GIVEN time has data behind it, as opposed to whether the playhead does. */
400
+ const playableAt = (time: number) =>
401
+ getTimeRanges(sourceBuffer).some((r) => r.start <= time + BOUNDARY_SLACK && time < r.end)
402
+
403
+ /**
404
+ * Put the data in place for a position the playhead has NOT moved to yet.
405
+ *
406
+ * Firefox wedges its own decoder when the element demuxes into a hole: the underrun requests a
407
+ * drain, the drain completes, the re-prime that would flush the decoder never runs, and every
408
+ * packet after that comes back `avcodec_send_packet error: End of file`. Seeking only after the
409
+ * target has data removes the hole, and with it the drain and the whole failure.
410
+ *
411
+ * Measured on a standalone rig against a real 1080p stream: 7 wedges in 7 runs seeking the
412
+ * ordinary way, 0 in 4 seeking this way. The control matters, because appending first also
413
+ * delays the seek: delaying the seek by the same amount while appending nothing still wedged 4
414
+ * out of 4, so it is the data and not the delay.
415
+ *
416
+ * Returns as soon as there is nothing to wait for, so a seek into buffered ground costs nothing.
417
+ * The CALLER owns the deadline: this can take as long as a read takes, and over a torrent that
418
+ * is unbounded.
419
+ */
420
+ const prepareSeek = async (time: number) => {
421
+ if (destroyed || playableAt(time)) return
422
+ finished = false
423
+ preparing++
424
+ try {
425
+ await seekTo(time)
426
+ } finally {
427
+ preparing--
428
+ }
429
+ }
430
+
378
431
  /**
379
432
  * Where a drag reaches the consumer, at a rate a consumer can act on.
380
433
  *
@@ -437,6 +490,15 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
437
490
  const dragging = now - lastSeekingAt < DRAG_SETTLE_MS
438
491
  lastSeekingAt = now
439
492
  if (alreadyPlayable(time)) return
493
+ /*
494
+ * A seek for exactly this position is already running, so let it finish.
495
+ *
496
+ * `prepareSeek` starts one BEFORE the playhead moves, and the caller moves the playhead anyway
497
+ * once its budget runs out. Without this, the move fires `seeking`, which starts the identical
498
+ * read again and bumps the generation, throwing away everything the first one had done. The
499
+ * benchmark caught it as a seek that took twice as long as seeking the old way.
500
+ */
501
+ if (seeking && Math.abs(time - lastSeekPosition) < 0.001) return
440
502
  finished = false
441
503
  if (dragTimer) clearTimeout(dragTimer)
442
504
  if (!dragging) {
@@ -501,6 +563,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
501
563
 
502
564
  return {
503
565
  destroy: runTeardown,
566
+ prepareSeek,
504
567
  selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
505
568
  indexes: metadata.indexes ?? [],
506
569
  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()
@@ -77,6 +77,12 @@ ${popoverStyle}
77
77
  .when {
78
78
  color: #bbb;
79
79
  ${fonts.bSmall.regular}
80
+
81
+ /* the one part of the line worth scanning for, so it is the one part at full brightness */
82
+ .count {
83
+ margin-left: 6px;
84
+ color: #fff;
85
+ }
80
86
  }
81
87
 
82
88
  .what {
@@ -101,12 +107,21 @@ const clock = (at: number) => new Date(at).toLocaleTimeString()
101
107
  const mediaTime = (seconds: number | undefined) =>
102
108
  seconds === undefined || !Number.isFinite(seconds) ? undefined : formatTime(Math.max(0, seconds))
103
109
 
110
+ /** How many failures there have been, which is not how many rows: a repeat folds into its row. */
111
+ export const countErrors = (errors: PlaybackErrorEntry[]) =>
112
+ errors.reduce((total, entry) => total + entry.count, 0)
113
+
104
114
  /** What lands on the clipboard: the same thing the panel shows, in the order it happened. */
105
115
  export const formatErrors = (errors: PlaybackErrorEntry[]) =>
106
116
  errors
107
117
  .map((entry, index) => {
108
118
  const at = mediaTime(entry.atMediaTime)
109
- const head = `${index + 1}. ${new Date(entry.at).toISOString()}${at ? ` (at ${at})` : ''}${entry.recovered ? ' [recovered]' : ''}`
119
+ // A repeat carries the span it covers, so "40 times over two minutes" and "40 times in one
120
+ // second" are distinguishable in a report. Both happen, and they are different faults.
121
+ const when = entry.count > 1
122
+ ? `${new Date(entry.at).toISOString()} to ${new Date(entry.lastAt).toISOString()} ×${entry.count}`
123
+ : new Date(entry.at).toISOString()
124
+ const head = `${index + 1}. ${when}${at ? ` (at ${at})` : ''}${entry.recovered ? ' [recovered]' : ''}`
110
125
  return [head, entry.message, entry.detail].filter(Boolean).join('\n')
111
126
  })
112
127
  .join('\n\n')
@@ -142,7 +157,8 @@ export const ErrorsAction = () => {
142
157
  )
143
158
  }
144
159
 
145
- const label = `Playback errors (${errors.length})`
160
+ // counted in failures rather than in rows, because a row that says ×40 is forty of them
161
+ const label = `Playback errors (${countErrors(errors)})`
146
162
 
147
163
  return (
148
164
  <div css={style} ref={containerRef}>
@@ -177,8 +193,11 @@ export const ErrorsAction = () => {
177
193
  <div className='entry no-hover' key={`${entry.at}-${index}`}>
178
194
  <span className='when'>
179
195
  {clock(entry.at)}
196
+ {/* the span, so a row that is still growing is distinguishable from one that stopped */}
197
+ {entry.count > 1 ? ` to ${clock(entry.lastAt)}` : ''}
180
198
  {mediaTime(entry.atMediaTime) ? ` at ${mediaTime(entry.atMediaTime)}` : ''}
181
199
  {entry.recovered ? ' recovered' : ''}
200
+ {entry.count > 1 ? <span className='count'>{`×${entry.count}`}</span> : null}
182
201
  </span>
183
202
  <span className='what'>{entry.message}</span>
184
203
  {entry.detail ? <span className='cause'>{entry.detail}</span> : null}
@@ -184,6 +184,7 @@ const style = css`
184
184
  export const ProgressBar = () => {
185
185
  const player = usePlayer()
186
186
  const currentTime = usePlayer((state) => state.currentTime)
187
+ const requestSeek = usePlayer((state) => state.requestSeek)
187
188
  const duration = usePlayer((state) => state.duration)
188
189
  const size = usePlayer((state) => state.size)
189
190
  const downloadedRanges = usePlayer((state) => state.downloadedRanges)
@@ -275,8 +276,12 @@ export const ProgressBar = () => {
275
276
  useEffect(() => {
276
277
  if (seekFraction === undefined || !duration) return
277
278
  const timestamp = seekFraction * duration
278
- player.seek(timestamp)
279
- }, [player, 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])
280
285
 
281
286
  const scaleX = useMemo(() => {
282
287
  return !duration || typeof currentTime !== 'number'
@@ -25,6 +25,21 @@ 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
+
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
+
28
43
  const messageOf = (error: unknown) =>
29
44
  error instanceof Error ? error.message : String(error)
30
45
 
@@ -66,6 +81,7 @@ export const usePlayback = (
66
81
  const {
67
82
  read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
68
83
  jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
84
+ seekPrepareBudgetMs = SEEK_PREPARE_BUDGET_MS,
69
85
  } = options ?? ({} as Partial<MediaPlayerLocalOptions>)
70
86
 
71
87
  // The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
@@ -111,6 +127,8 @@ export const usePlayback = (
111
127
  * deliberately not, since a streaming consumer passes a fresh closure several times a second.
112
128
  */
113
129
  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)
114
132
  // The renderer turns the first track on by itself, so the menu has to mirror that or it shows
115
133
  // "Disable" ticked over subtitles that are visibly on screen.
116
134
  const subtitleChoiceMade = useRef(false)
@@ -131,6 +149,54 @@ export const usePlayback = (
131
149
  controllerRef.current?.selectSubtitleStream(streamIndex)
132
150
  }, [player])
133
151
 
152
+ /**
153
+ * Seek with the data already there, or after the deadline, whichever comes first.
154
+ *
155
+ * The prevention for the firefox decoder wedge. Seeking into a hole makes the reader drain the
156
+ * decoder and never flush it again, so the pipeline is asked for the target first. The deadline is
157
+ * what keeps that honest: a read over a torrent has no ceiling, and a seek bar that waits on one
158
+ * is worse than the fault it avoids. So the playhead moves either when the data lands or when the
159
+ * budget runs out, once, whichever happens first.
160
+ *
161
+ * A seek into buffered ground resolves immediately, so scrubbing inside the buffer is unaffected.
162
+ */
163
+ const requestSeek = useCallback((time: number) => {
164
+ const controller = controllerRef.current
165
+ // no budget means the feature is off: seek at once and do not spend a read preparing something
166
+ // nobody is going to wait for
167
+ if (!controller || seekPrepareBudgetMs <= 0) { player.seek(time); return }
168
+
169
+ /*
170
+ * A drag is left exactly as it was.
171
+ *
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.
176
+ *
177
+ * Discrete seeks are the ones that wedge it, at a few hundred ms apart, and those get the data
178
+ * first.
179
+ */
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 }
184
+
185
+ let moved = false
186
+ const move = () => {
187
+ if (moved) return
188
+ moved = true
189
+ player.seek(time)
190
+ }
191
+ const deadline = setTimeout(move, seekPrepareBudgetMs)
192
+ void controller
193
+ .prepareSeek(time)
194
+ // a failed prepare is not a reason to refuse the seek: the pump and the existing recovery
195
+ // both still apply, and refusing would strand the viewer on a bar that does nothing
196
+ .catch(() => {})
197
+ .finally(() => { clearTimeout(deadline); move() })
198
+ }, [player, seekPrepareBudgetMs])
199
+
134
200
  const selectAudioTrack = useCallback((id: string | number) => {
135
201
  if (typeof id !== 'number') return
136
202
  player.setSourceState({ selectedAudioTrack: id })
@@ -143,8 +209,8 @@ export const usePlayback = (
143
209
  const setSourceState = usePlayer((state) => state.setSourceState)
144
210
 
145
211
  useEffect(() => {
146
- setSourceState({ selectSubtitleTrack, selectAudioTrack })
147
- }, [setSourceState, selectSubtitleTrack, selectAudioTrack])
212
+ setSourceState({ selectSubtitleTrack, selectAudioTrack, requestSeek })
213
+ }, [setSourceState, selectSubtitleTrack, selectAudioTrack, requestSeek])
148
214
 
149
215
  useEffect(() => {
150
216
  if (!video || !canvas || !size || !read) return
@@ -158,14 +224,33 @@ export const usePlayback = (
158
224
  * `playbackError` null and would otherwise erase the only evidence that anything went wrong.
159
225
  */
160
226
  const record = (error: unknown, recovered: boolean) => {
227
+ const at = Date.now()
228
+ const message = messageOf(error)
229
+ const detail = causeChain(error)
230
+ const errors = player.playbackErrors
231
+ const last = errors[errors.length - 1]
232
+
233
+ // A source that stays broken repeats one sentence for as long as it stays broken, so a
234
+ // consecutive repeat folds into the row it repeats rather than adding another. Only
235
+ // CONSECUTIVE ones: an identical failure either side of a different one is a second episode,
236
+ // and collapsing the two would lose the order that makes a report readable.
237
+ if (last && last.message === message && last.detail === detail && last.recovered === recovered) {
238
+ const folded: PlaybackErrorEntry = { ...last, count: last.count + 1, lastAt: at }
239
+ player.setSourceState({ playbackErrors: [...errors.slice(0, -1), folded] })
240
+ return
241
+ }
242
+
161
243
  const entry: PlaybackErrorEntry = {
162
- at: Date.now(),
244
+ at,
245
+ lastAt: at,
246
+ count: 1,
247
+ // where it STARTED, kept as the row grows, because that is the position worth reporting
163
248
  atMediaTime: Number.isFinite(video.currentTime) ? video.currentTime : undefined,
164
- message: messageOf(error),
165
- detail: causeChain(error),
249
+ message,
250
+ detail,
166
251
  recovered,
167
252
  }
168
- player.setSourceState({ playbackErrors: [...player.playbackErrors, entry] })
253
+ player.setSourceState({ playbackErrors: [...errors, entry] })
169
254
  }
170
255
  const fail = (error: unknown) => {
171
256
  if (cancelled) return
@@ -30,9 +30,20 @@ export type TrackChoice = {
30
30
  * `cause` may be a `MediaError` that reads differently once the element has moved on.
31
31
  */
32
32
  export type PlaybackErrorEntry = {
33
- /** Wall clock, so the report can be read next to a console log or a torrent's timeline. */
33
+ /** Wall clock of the FIRST of them, so the report can be read next to a console log. */
34
34
  at: number
35
- /** Seconds into the media, which is usually the first question asked of a playback failure. */
35
+ /** Wall clock of the most recent one. Equal to `at` until a repeat has folded into this row. */
36
+ lastAt: number
37
+ /**
38
+ * How many times in a row this exact failure happened.
39
+ *
40
+ * A source that stays broken reports the same sentence every few seconds for as long as it stays
41
+ * broken, so consecutive identical failures fold into one row rather than filling the panel with a
42
+ * wall of one message. This is also what keeps the list from growing without end in that case,
43
+ * since there is no ceiling on how many failures are kept.
44
+ */
45
+ count: number
46
+ /** Seconds into the media, at the first of them, which is usually the first question asked. */
36
47
  atMediaTime?: number
37
48
  message: string
38
49
  /** The `cause` chain, already unwound, one line per level. */
@@ -117,6 +128,19 @@ export type SourceState = {
117
128
  /** Burn-in only. True while the composite is the picture on screen. */
118
129
  burnedInSubtitles: boolean
119
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
+
120
144
  /** Set when the pipeline fails. Cleared when it recovers. */
121
145
  playbackError: unknown
122
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[]