@banou/media-player 0.8.12 → 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.
@@ -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.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()
@@ -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
@@ -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[]