@banou/media-player 0.8.8 → 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.
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/playback.d.ts +13 -0
- package/dist/{engine-B6flRjLd.js → engine-Cownv8gD.js} +139 -98
- package/dist/index.js +377 -351
- package/dist/react/video-player.d.ts +9 -0
- package/package.json +1 -1
- package/src/lib/engine/index.ts +1 -1
- package/src/lib/engine/playback.ts +141 -7
- package/src/lib/react/hooks/use-playback.ts +38 -2
- package/src/lib/react/video-player.tsx +9 -0
|
@@ -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
package/src/lib/engine/index.ts
CHANGED
|
@@ -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
|
|
@@ -130,6 +153,28 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
130
153
|
const mediaSourceUrl = URL.createObjectURL(mediaSource)
|
|
131
154
|
teardown.push(() => URL.revokeObjectURL(mediaSourceUrl))
|
|
132
155
|
videoElement.src = mediaSourceUrl
|
|
156
|
+
/**
|
|
157
|
+
* Give the element back, before the url it is holding is revoked.
|
|
158
|
+
*
|
|
159
|
+
* Revoking alone detaches nothing: `src` still names the dead MediaSource, the old SourceBuffer
|
|
160
|
+
* is still attached to it, and the decoder keeps whatever state it was in. That matters because
|
|
161
|
+
* this pipeline is REBUILT in place, on the same element, whenever the audio track changes, and
|
|
162
|
+
* the rebuild awaits a wasm load plus a header read before it assigns a new `src`. On a torrent
|
|
163
|
+
* that is seconds of the element sitting on a corpse.
|
|
164
|
+
*
|
|
165
|
+
* `load()` is the only thing that actually resets it: the media element load algorithm aborts
|
|
166
|
+
* the current resource and clears the decoder. Without it the element goes from an ended source
|
|
167
|
+
* straight into fresh appends with no seek in between, which is exactly the sequence that makes
|
|
168
|
+
* firefox hand a real packet to a drained decoder (`avcodec_send_packet error: End of file`).
|
|
169
|
+
* Pushed AFTER the revoke so the reversed teardown runs it FIRST.
|
|
170
|
+
*/
|
|
171
|
+
teardown.push(() => {
|
|
172
|
+
try { videoElement.pause() } catch {}
|
|
173
|
+
videoElement.removeAttribute('src')
|
|
174
|
+
// no src and no <source> children means the load algorithm goes straight to NETWORK_EMPTY
|
|
175
|
+
// without firing `error`, so this cannot manufacture the failure it exists to prevent
|
|
176
|
+
videoElement.load()
|
|
177
|
+
})
|
|
133
178
|
|
|
134
179
|
const sourceBuffer = await new Promise<SourceBuffer>((resolve, reject) => {
|
|
135
180
|
const timeout = setTimeout(() => { cleanup(); reject(new Error('The MediaSource never opened')) }, SOURCE_OPEN_TIMEOUT)
|
|
@@ -177,11 +222,16 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
177
222
|
let lastSeekPosition = 0
|
|
178
223
|
|
|
179
224
|
// 'Cancelled' covers both an aborted task and a read that gave up, so it is only noise mid-seek
|
|
180
|
-
const reportError = (error: unknown, aborted: boolean) => {
|
|
225
|
+
const reportError = (error: unknown, aborted: boolean, terminal = false) => {
|
|
181
226
|
const cancelled = (error as Error)?.message === 'Cancelled'
|
|
182
227
|
if (aborted && cancelled) return
|
|
183
228
|
console.error(error)
|
|
184
|
-
|
|
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
|
|
185
235
|
outstandingError = true
|
|
186
236
|
onError?.(cancelled ? new Error('Reading the video file failed', { cause: error }) : error)
|
|
187
237
|
}
|
|
@@ -230,8 +280,13 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
230
280
|
lastSeekPosition = time
|
|
231
281
|
const generation = ++seekGeneration
|
|
232
282
|
try {
|
|
233
|
-
|
|
283
|
+
// `seek` ends by reading, and that read can hit the end of the file, which finalizes the
|
|
284
|
+
// muxer: libav writes the mp4 trailer and cannot be read from again. Dropping the flag left
|
|
285
|
+
// this side believing the stream was still open, so the next pump read straight back into a
|
|
286
|
+
// context that had already been torn down.
|
|
287
|
+
const { data, pts, subtitles: fragments, finished: done } = await remuxer.seek(time)
|
|
234
288
|
if (destroyed || generation !== seekGeneration) return
|
|
289
|
+
if (done) finished = true
|
|
235
290
|
if (fragments?.length) subtitles.pushFragments(fragments)
|
|
236
291
|
await updateTimestampOffset(pts)
|
|
237
292
|
if (destroyed || generation !== seekGeneration) return
|
|
@@ -278,7 +333,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
278
333
|
}
|
|
279
334
|
|
|
280
335
|
const pump = async () => {
|
|
281
|
-
|
|
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
|
|
282
339
|
if (!pending && (finished || !needsData())) return
|
|
283
340
|
const generation = ++readGeneration
|
|
284
341
|
const seekAtStart = seekGeneration
|
|
@@ -318,13 +375,84 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
318
375
|
*/
|
|
319
376
|
const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
|
|
320
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
|
+
|
|
321
430
|
const onSeeking = () => {
|
|
322
431
|
const time = videoElement.currentTime
|
|
323
432
|
const duration = metadata.info.input.duration || videoElement.duration
|
|
324
|
-
if (duration > 0)
|
|
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
|
|
325
439
|
if (alreadyPlayable(time)) return
|
|
326
440
|
finished = false
|
|
327
|
-
|
|
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)
|
|
328
456
|
}
|
|
329
457
|
videoElement.addEventListener('seeking', onSeeking)
|
|
330
458
|
teardown.push(() => videoElement.removeEventListener('seeking', onSeeking))
|
|
@@ -341,6 +469,11 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
341
469
|
*
|
|
342
470
|
* That cost a session: a decode failure presented as an append bug, which is a completely
|
|
343
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`.
|
|
344
477
|
*/
|
|
345
478
|
let elementFailed = false
|
|
346
479
|
const onElementError = () => {
|
|
@@ -348,8 +481,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
348
481
|
elementFailed = true
|
|
349
482
|
const error = videoElement.error
|
|
350
483
|
reportError(
|
|
351
|
-
new
|
|
484
|
+
new MediaElementError(`the media element failed: ${error?.message ?? 'unknown'}`, { cause: error }),
|
|
352
485
|
false,
|
|
486
|
+
true,
|
|
353
487
|
)
|
|
354
488
|
}
|
|
355
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
|
}
|