@banou/media-player 0.8.9 → 0.8.10

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.
@@ -59,6 +59,15 @@ type CommonOptions = {
59
59
  * `pointer-events: auto` on itself. `children` land next to the media instead, below the chrome.
60
60
  */
61
61
  overlay?: ReactNode;
62
+ /**
63
+ * Where the viewer is heading, as a 0..1 fraction, for a source that fetches on demand.
64
+ *
65
+ * Throttled, and deliberately: the chrome moves the element on every pointermove, so a drag
66
+ * across the bar is dozens of positions a second, and a consumer that reprioritises its download
67
+ * window on each one never finishes anything it starts. This fires on the leading edge, so a
68
+ * single seek moves the window at once, and again on the trailing edge, so the position the drag
69
+ * ended on is the one that sticks. It is a heading, not an event log.
70
+ */
62
71
  onSeek?: (fraction: number) => void;
63
72
  onPlaybackError?: (error: unknown) => void;
64
73
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
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",
@@ -1,4 +1,4 @@
1
- export { startPlayback, terminateRemuxer, DEFAULT_BUFFER_SIZE } from './playback'
1
+ export { startPlayback, terminateRemuxer, MediaElementError, isMediaElementError, DEFAULT_BUFFER_SIZE } from './playback'
2
2
  export type { PlaybackOptions, PlaybackController, MediaIndex, AudioStream } from './playback'
3
3
 
4
4
  export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles'
@@ -44,6 +44,25 @@ export type PlaybackController = {
44
44
  audioMimeType: string
45
45
  }
46
46
 
47
+ /**
48
+ * A terminal failure of the media element itself, as opposed to anything this pipeline did.
49
+ *
50
+ * It is worth its own type because it is the one error class that says something about the CURE:
51
+ * the element is finished and no append will ever succeed against it again, so reporting it to the
52
+ * viewer is pointless and only a rebuilt element clears it. The flag rather than an `instanceof`
53
+ * is what survives the error crossing a module boundary.
54
+ */
55
+ export class MediaElementError extends Error {
56
+ readonly mediaElement = true
57
+ constructor(message: string, options?: ErrorOptions) {
58
+ super(message, options)
59
+ this.name = 'MediaElementError'
60
+ }
61
+ }
62
+
63
+ export const isMediaElementError = (error: unknown): boolean =>
64
+ !!error && typeof error === 'object' && (error as { mediaElement?: boolean }).mediaElement === true
65
+
47
66
  // ~20s behind and ~60s ahead of the playhead, refilled when the forward buffer dips under 30s
48
67
  const PRE_EVICT = -20
49
68
  const POST_EVICT = 60
@@ -55,6 +74,10 @@ const MAX_APPEND_ATTEMPTS = 5
55
74
  const SOURCE_OPEN_TIMEOUT = 15_000
56
75
  // how far past the playhead a range may start and still count as the one holding it
57
76
  const BOUNDARY_SLACK = 1
77
+ // the fastest a drag may move the consumer's download window
78
+ const SEEK_REPORT_MS = 200
79
+ // quiet time that ends a drag: pointermoves arrive every few ms, so this cannot cut one in half
80
+ const DRAG_SETTLE_MS = 250
58
81
  export const DEFAULT_BUFFER_SIZE = 2_500_000
59
82
 
60
83
  // destroy() only terminates after a round trip into the wasm, so terminate on our own clock too
@@ -199,11 +222,16 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
199
222
  let lastSeekPosition = 0
200
223
 
201
224
  // 'Cancelled' covers both an aborted task and a read that gave up, so it is only noise mid-seek
202
- const reportError = (error: unknown, aborted: boolean) => {
225
+ const reportError = (error: unknown, aborted: boolean, terminal = false) => {
203
226
  const cancelled = (error as Error)?.message === 'Cancelled'
204
227
  if (aborted && cancelled) return
205
228
  console.error(error)
206
- if (outstandingError) return
229
+ // A terminal failure of the element outranks whatever was already outstanding. It is the one
230
+ // error whose HANDLING differs, and the sequence that produces it starts with a starved
231
+ // buffer, which is also when a read is most likely to have reported first. Swallowing it
232
+ // behind that earlier report would leave the caller holding a dead element, able to fix it
233
+ // and never told to.
234
+ if (outstandingError && !terminal) return
207
235
  outstandingError = true
208
236
  onError?.(cancelled ? new Error('Reading the video file failed', { cause: error }) : error)
209
237
  }
@@ -305,7 +333,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
305
333
  }
306
334
 
307
335
  const pump = async () => {
308
- if (reading || seeking || destroyed) return
336
+ // a settling drag is about to reposition the remuxer, so reading forward from where it
337
+ // happens to sit is throwing a read away
338
+ if (reading || seeking || dragTimer !== undefined || destroyed) return
309
339
  if (!pending && (finished || !needsData())) return
310
340
  const generation = ++readGeneration
311
341
  const seekAtStart = seekGeneration
@@ -345,13 +375,84 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
345
375
  */
346
376
  const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
347
377
 
378
+ /**
379
+ * Where a drag reaches the consumer, at a rate a consumer can act on.
380
+ *
381
+ * `onSeek` moves the reader's download window, and the chrome moves the element on every
382
+ * pointermove, so one drag across the bar used to reprioritise the source dozens of times a
383
+ * second. Nothing a torrent starts survives being re-anchored at that rate, which is most of
384
+ * why a scrub could leave the buffer empty for half a minute. Leading edge so a single seek
385
+ * still moves the window at once, trailing edge so the position the drag ENDED on is the one
386
+ * that sticks.
387
+ */
388
+ let lastSeekReport = 0
389
+ let trailingFraction: number | null = null
390
+ let trailingTimer: ReturnType<typeof setTimeout> | undefined
391
+ const reportSeek = (fraction: number) => {
392
+ const now = performance.now()
393
+ const since = now - lastSeekReport
394
+ if (since >= SEEK_REPORT_MS) {
395
+ lastSeekReport = now
396
+ trailingFraction = null
397
+ onSeek?.(fraction)
398
+ return
399
+ }
400
+ trailingFraction = fraction
401
+ if (trailingTimer) return
402
+ trailingTimer = setTimeout(() => {
403
+ trailingTimer = undefined
404
+ const pendingFraction = trailingFraction
405
+ trailingFraction = null
406
+ if (pendingFraction === null || destroyed) return
407
+ lastSeekReport = performance.now()
408
+ onSeek?.(pendingFraction)
409
+ }, SEEK_REPORT_MS - since)
410
+ }
411
+ teardown.push(() => { if (trailingTimer) clearTimeout(trailingTimer) })
412
+
413
+ /**
414
+ * A drag is not a seek per pointermove, however many the element reports.
415
+ *
416
+ * The chrome moves the element on every pointermove, and every one of those used to start a
417
+ * remuxer seek, which ABORTS the one already running. So during a drag none of them ever
418
+ * finished: a measured drag over a torrent produced 315 seeks, zero `seeked`, and thirty
419
+ * seconds in which not one byte reached the source buffer. An empty buffer for that long is
420
+ * also what wedges firefox's decoder, so this is not only wasted work.
421
+ *
422
+ * A move that arrives on its own still seeks AT ONCE, so a click on the bar costs nothing and
423
+ * nothing waits out a read that can run for tens of seconds over a torrent. Only a run of
424
+ * moves is a drag, and a drag gets one seek when it settles, to wherever it actually stopped.
425
+ */
426
+ let lastSeekingAt = 0
427
+ let dragTimer: ReturnType<typeof setTimeout> | undefined
428
+ teardown.push(() => { if (dragTimer) clearTimeout(dragTimer) })
429
+
348
430
  const onSeeking = () => {
349
431
  const time = videoElement.currentTime
350
432
  const duration = metadata.info.input.duration || videoElement.duration
351
- if (duration > 0) onSeek?.(Math.min(Math.max(time / duration, 0), 1))
433
+ if (duration > 0) reportSeek(Math.min(Math.max(time / duration, 0), 1))
434
+ // stamped before the playable check, so a drag that crosses buffered ground and comes out
435
+ // the far side is still recognised as one drag rather than as a fresh click
436
+ const now = performance.now()
437
+ const dragging = now - lastSeekingAt < DRAG_SETTLE_MS
438
+ lastSeekingAt = now
352
439
  if (alreadyPlayable(time)) return
353
440
  finished = false
354
- void seekTo(time)
441
+ if (dragTimer) clearTimeout(dragTimer)
442
+ if (!dragging) {
443
+ dragTimer = undefined
444
+ void seekTo(time)
445
+ return
446
+ }
447
+ dragTimer = setTimeout(() => {
448
+ dragTimer = undefined
449
+ if (destroyed) return
450
+ // where the drag ENDED, which is the only position anyone is waiting on
451
+ const settled = videoElement.currentTime
452
+ if (alreadyPlayable(settled)) return
453
+ finished = false
454
+ void seekTo(settled)
455
+ }, DRAG_SETTLE_MS)
355
456
  }
356
457
  videoElement.addEventListener('seeking', onSeeking)
357
458
  teardown.push(() => videoElement.removeEventListener('seeking', onSeeking))
@@ -368,6 +469,11 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
368
469
  *
369
470
  * That cost a session: a decode failure presented as an append bug, which is a completely
370
471
  * different place to look. Report what actually happened, then stop.
472
+ *
473
+ * It is reported as a MediaElementError because the caller can do something about this one that
474
+ * it cannot do about any other: nothing appended here will ever decode again, but a rebuilt
475
+ * element gets a fresh decoder, and the pipeline already knows how to come back at the same
476
+ * position. See `isMediaElementError`.
371
477
  */
372
478
  let elementFailed = false
373
479
  const onElementError = () => {
@@ -375,8 +481,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
375
481
  elementFailed = true
376
482
  const error = videoElement.error
377
483
  reportError(
378
- new Error(`the media element failed: ${error?.message ?? 'unknown'}`, { cause: error }),
484
+ new MediaElementError(`the media element failed: ${error?.message ?? 'unknown'}`, { cause: error }),
379
485
  false,
486
+ true,
380
487
  )
381
488
  }
382
489
  videoElement.addEventListener('error', onElementError)
@@ -3,10 +3,15 @@ import type { MediaPlayerLocalOptions } from '../video-player'
3
3
 
4
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
5
 
6
- import { startPlayback } from '../../engine'
6
+ import { isMediaElementError, startPlayback } from '../../engine'
7
7
  import { usePlayer } from '../player'
8
8
  import { toNamedTracks } from '../../utils/track-label'
9
9
 
10
+ // A wedged element is rebuilt rather than reported, but a file that wedges over and over is a real
11
+ // failure and has to reach the viewer instead of looping forever.
12
+ const MAX_RESTARTS = 3
13
+ const RESTART_WINDOW_MS = 60_000
14
+
10
15
  /**
11
16
  * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
12
17
  * discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
@@ -29,6 +34,24 @@ export const usePlayback = (
29
34
  // `selectedAudioStream`, which is whatever is playing right now.
30
35
  const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
31
36
 
37
+ /**
38
+ * Bumped to rebuild the pipeline after the media element itself has failed.
39
+ *
40
+ * Firefox can wedge its own decoder: when the source buffer runs dry it drains the decoder so the
41
+ * frames still inside it get shown, and clearing that drain needs a decoded sample to resume
42
+ * from. A seek into an empty buffer over a slow source has none, so the drain is never cleared
43
+ * and every packet after it comes back `avcodec_send_packet error: End of file`. The element is
44
+ * finished at that point and no append can revive it.
45
+ *
46
+ * Rebuilding is the cure, and this hook already does exactly that for an audio track change,
47
+ * position and all, so the recovery is a dep rather than a second teardown path.
48
+ */
49
+ const [restartToken, setRestartToken] = useState(0)
50
+ const restarts = useRef({ count: 0, at: 0 })
51
+ // The budget belongs to one media, not to the player: a file that used it up must not leave the
52
+ // next one with no recovery at all.
53
+ useEffect(() => { restarts.current = { count: 0, at: 0 } }, [size])
54
+
32
55
  const controllerRef = useRef<PlaybackController | null>(null)
33
56
  /**
34
57
  * Where to pick playback back up, and WHICH media that position belongs to.
@@ -83,6 +106,19 @@ export const usePlayback = (
83
106
  player.setSourceState({ playbackError: null, ready: false })
84
107
  const fail = (error: unknown) => {
85
108
  if (cancelled) return
109
+ // Not something the viewer can act on and not something an append can survive: rebuild the
110
+ // element instead of putting a dead player behind an error message. Counted in a window, so
111
+ // a file that wedges again and again still reaches the viewer rather than looping.
112
+ if (isMediaElementError(error)) {
113
+ const now = performance.now()
114
+ if (now - restarts.current.at > RESTART_WINDOW_MS) restarts.current = { count: 0, at: now }
115
+ if (restarts.current.count < MAX_RESTARTS) {
116
+ restarts.current = { count: restarts.current.count + 1, at: now }
117
+ console.warn('the media element failed; rebuilding the pipeline', error)
118
+ setRestartToken((token) => token + 1)
119
+ return
120
+ }
121
+ }
86
122
  console.error('playback failed', error)
87
123
  player.setSourceState({ playbackError: error })
88
124
  onPlaybackErrorRef.current?.(error)
@@ -154,6 +190,6 @@ export const usePlayback = (
154
190
  // times a second, and the restart loop reads as "Loading metadata" forever at a flat 0 B/s.
155
191
  }, [
156
192
  player, video, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
157
- jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
193
+ jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay, restartToken,
158
194
  ])
159
195
  }
@@ -71,6 +71,15 @@ type CommonOptions = {
71
71
  */
72
72
  overlay?: ReactNode
73
73
 
74
+ /**
75
+ * Where the viewer is heading, as a 0..1 fraction, for a source that fetches on demand.
76
+ *
77
+ * Throttled, and deliberately: the chrome moves the element on every pointermove, so a drag
78
+ * across the bar is dozens of positions a second, and a consumer that reprioritises its download
79
+ * window on each one never finishes anything it starts. This fires on the leading edge, so a
80
+ * single seek moves the window at once, and again on the trailing edge, so the position the drag
81
+ * ended on is the one that sticks. It is a heading, not an event log.
82
+ */
74
83
  onSeek?: (fraction: number) => void
75
84
  onPlaybackError?: (error: unknown) => void
76
85
  }