@banou/media-player 0.8.9 → 0.8.11
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-BwK7ycv0.js → engine-Cownv8gD.js} +134 -98
- package/dist/index.d.ts +1 -1
- package/dist/index.js +652 -453
- package/dist/react/components/errors.d.ts +15 -0
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +36 -0
- 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 +113 -6
- package/src/lib/index.tsx +1 -1
- package/src/lib/react/components/control-bar.tsx +4 -2
- package/src/lib/react/components/errors.tsx +194 -0
- package/src/lib/react/components/track-menu.tsx +2 -2
- package/src/lib/react/hooks/use-playback.ts +105 -2
- package/src/lib/react/source-feature.ts +29 -0
- package/src/lib/react/video-player.tsx +9 -0
|
@@ -1,12 +1,55 @@
|
|
|
1
1
|
import type { PlaybackController } from '../../engine'
|
|
2
|
+
import type { PlaybackErrorEntry } from '../source-feature'
|
|
2
3
|
import type { MediaPlayerLocalOptions } from '../video-player'
|
|
3
4
|
|
|
4
5
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
6
|
|
|
6
|
-
import { startPlayback } from '../../engine'
|
|
7
|
+
import { isMediaElementError, startPlayback } from '../../engine'
|
|
7
8
|
import { usePlayer } from '../player'
|
|
8
9
|
import { toNamedTracks } from '../../utils/track-label'
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* A wedged element is rebuilt, however many times it takes.
|
|
13
|
+
*
|
|
14
|
+
* There is deliberately no ceiling. The failure this exists for is a firefox decoder that will not
|
|
15
|
+
* take another packet, it is not the file's fault, and a viewer forty minutes into an episode is
|
|
16
|
+
* not helped by a budget running out. Every rebuild is recorded instead, and the control bar offers
|
|
17
|
+
* the record, so a file that genuinely cannot play is visible as a wall of identical entries rather
|
|
18
|
+
* than hidden behind a counter.
|
|
19
|
+
*
|
|
20
|
+
* The backoff is the whole guard: rebuilds that keep failing immediately slow down, so a source that
|
|
21
|
+
* fails deterministically settles into a slow retry instead of a hot loop that pins a core and
|
|
22
|
+
* reloads libav as fast as it can.
|
|
23
|
+
*/
|
|
24
|
+
const RESTART_BACKOFF_MS = [0, 250, 1_000, 3_000, 10_000]
|
|
25
|
+
// far enough back that an unrelated hiccup later in an episode starts from no delay again
|
|
26
|
+
const RESTART_SETTLED_MS = 60_000
|
|
27
|
+
|
|
28
|
+
const messageOf = (error: unknown) =>
|
|
29
|
+
error instanceof Error ? error.message : String(error)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `cause` chain, unwound to text at the moment it happened.
|
|
33
|
+
*
|
|
34
|
+
* The reason a decode failure is worth copying out at all is almost always one level down: the top
|
|
35
|
+
* line says the media element failed, and the cause carries what the decoder actually said. A
|
|
36
|
+
* `MediaError` is not an `Error`, so it is read for its own two fields rather than skipped.
|
|
37
|
+
*/
|
|
38
|
+
const causeChain = (error: unknown) => {
|
|
39
|
+
const lines: string[] = []
|
|
40
|
+
let cause: unknown = (error as { cause?: unknown })?.cause
|
|
41
|
+
// bounded, because a cause chain can be circular and this runs inside an error path
|
|
42
|
+
for (let depth = 0; cause != null && depth < 8; depth++) {
|
|
43
|
+
if (typeof MediaError !== 'undefined' && cause instanceof MediaError) {
|
|
44
|
+
lines.push(`MediaError code ${cause.code}${cause.message ? `: ${cause.message}` : ''}`)
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
lines.push(messageOf(cause))
|
|
48
|
+
cause = (cause as { cause?: unknown })?.cause
|
|
49
|
+
}
|
|
50
|
+
return lines.length ? lines.join('\n') : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
10
53
|
/**
|
|
11
54
|
* Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
|
|
12
55
|
* discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
|
|
@@ -29,6 +72,32 @@ export const usePlayback = (
|
|
|
29
72
|
// `selectedAudioStream`, which is whatever is playing right now.
|
|
30
73
|
const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
|
|
31
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Bumped to rebuild the pipeline after the media element itself has failed.
|
|
77
|
+
*
|
|
78
|
+
* Firefox can wedge its own decoder: when the source buffer runs dry it drains the decoder so the
|
|
79
|
+
* frames still inside it get shown, and clearing that drain needs a decoded sample to resume
|
|
80
|
+
* from. A seek into an empty buffer has none, so the drain is never cleared and every packet
|
|
81
|
+
* after it comes back `avcodec_send_packet error: End of file`. The element is finished at that
|
|
82
|
+
* point and no append can revive it. It is not ours: it reproduces on this player as it stood in
|
|
83
|
+
* October 2025, on a local file, and on every version since.
|
|
84
|
+
*
|
|
85
|
+
* Rebuilding is the cure, and this hook already does exactly that for an audio track change,
|
|
86
|
+
* position and all, so the recovery is a dep rather than a second teardown path.
|
|
87
|
+
*/
|
|
88
|
+
const [restartToken, setRestartToken] = useState(0)
|
|
89
|
+
const restarts = useRef({ count: 0, at: 0 })
|
|
90
|
+
const restartTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
91
|
+
// The streak belongs to one media, not to the player, and a pending rebuild of the old one must
|
|
92
|
+
// not land on the new one.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
restarts.current = { count: 0, at: 0 }
|
|
95
|
+
player.setSourceState({ playbackErrors: [] })
|
|
96
|
+
return () => { if (restartTimer.current) clearTimeout(restartTimer.current) }
|
|
97
|
+
// `player` is stable; listing it would not re-run this, and the reset belongs to the media
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, [size])
|
|
100
|
+
|
|
32
101
|
const controllerRef = useRef<PlaybackController | null>(null)
|
|
33
102
|
/**
|
|
34
103
|
* Where to pick playback back up, and WHICH media that position belongs to.
|
|
@@ -81,8 +150,42 @@ export const usePlayback = (
|
|
|
81
150
|
if (!video || !canvas || !size || !read) return
|
|
82
151
|
let cancelled = false
|
|
83
152
|
player.setSourceState({ playbackError: null, ready: false })
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Keep the failure, whether or not the viewer is about to be told about it.
|
|
156
|
+
*
|
|
157
|
+
* Appended rather than replaced, and never cleared by a recovery: a rebuild that works leaves
|
|
158
|
+
* `playbackError` null and would otherwise erase the only evidence that anything went wrong.
|
|
159
|
+
*/
|
|
160
|
+
const record = (error: unknown, recovered: boolean) => {
|
|
161
|
+
const entry: PlaybackErrorEntry = {
|
|
162
|
+
at: Date.now(),
|
|
163
|
+
atMediaTime: Number.isFinite(video.currentTime) ? video.currentTime : undefined,
|
|
164
|
+
message: messageOf(error),
|
|
165
|
+
detail: causeChain(error),
|
|
166
|
+
recovered,
|
|
167
|
+
}
|
|
168
|
+
player.setSourceState({ playbackErrors: [...player.playbackErrors, entry] })
|
|
169
|
+
}
|
|
84
170
|
const fail = (error: unknown) => {
|
|
85
171
|
if (cancelled) return
|
|
172
|
+
const recoverable = isMediaElementError(error)
|
|
173
|
+
record(error, recoverable)
|
|
174
|
+
// Not something the viewer can act on and not something an append can survive: rebuild the
|
|
175
|
+
// element instead of putting a dead player behind an error message.
|
|
176
|
+
if (recoverable) {
|
|
177
|
+
const now = performance.now()
|
|
178
|
+
// a failure long after the last one is not part of a streak, so it pays no delay
|
|
179
|
+
if (now - restarts.current.at > RESTART_SETTLED_MS) restarts.current = { count: 0, at: now }
|
|
180
|
+
const delay = RESTART_BACKOFF_MS[Math.min(restarts.current.count, RESTART_BACKOFF_MS.length - 1)]!
|
|
181
|
+
restarts.current = { count: restarts.current.count + 1, at: now }
|
|
182
|
+
console.warn(`the media element failed; rebuilding the pipeline${delay ? ` in ${delay}ms` : ''}`, error)
|
|
183
|
+
if (restartTimer.current) clearTimeout(restartTimer.current)
|
|
184
|
+
// still queued through a timer at zero delay, so the rebuild never runs inside the callback
|
|
185
|
+
// that reported the failure
|
|
186
|
+
restartTimer.current = setTimeout(() => setRestartToken((token) => token + 1), delay)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
86
189
|
console.error('playback failed', error)
|
|
87
190
|
player.setSourceState({ playbackError: error })
|
|
88
191
|
onPlaybackErrorRef.current?.(error)
|
|
@@ -154,6 +257,6 @@ export const usePlayback = (
|
|
|
154
257
|
// times a second, and the restart loop reads as "Loading metadata" forever at a flat 0 B/s.
|
|
155
258
|
}, [
|
|
156
259
|
player, video, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
|
|
157
|
-
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
|
|
260
|
+
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay, restartToken,
|
|
158
261
|
])
|
|
159
262
|
}
|
|
@@ -22,6 +22,25 @@ export type TrackChoice = {
|
|
|
22
22
|
disabled?: boolean
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* One failure, kept so it can be read back and copied out long after playback recovered from it.
|
|
27
|
+
*
|
|
28
|
+
* Flattened to strings at the moment it happens rather than held as the `Error`: this is a report,
|
|
29
|
+
* the cause chain is most of what makes it useful, and an `Error` in a store is a live object whose
|
|
30
|
+
* `cause` may be a `MediaError` that reads differently once the element has moved on.
|
|
31
|
+
*/
|
|
32
|
+
export type PlaybackErrorEntry = {
|
|
33
|
+
/** Wall clock, so the report can be read next to a console log or a torrent's timeline. */
|
|
34
|
+
at: number
|
|
35
|
+
/** Seconds into the media, which is usually the first question asked of a playback failure. */
|
|
36
|
+
atMediaTime?: number
|
|
37
|
+
message: string
|
|
38
|
+
/** The `cause` chain, already unwound, one line per level. */
|
|
39
|
+
detail?: string
|
|
40
|
+
/** Whether the pipeline came back from it by itself. */
|
|
41
|
+
recovered: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
/**
|
|
26
45
|
* A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
|
|
27
46
|
* index, because a file's download percentage is not its playback percentage.
|
|
@@ -100,6 +119,15 @@ export type SourceState = {
|
|
|
100
119
|
|
|
101
120
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
102
121
|
playbackError: unknown
|
|
122
|
+
/**
|
|
123
|
+
* Every failure this source has had, oldest first, whether or not the viewer ever saw one.
|
|
124
|
+
*
|
|
125
|
+
* `playbackError` is the CURRENT state and is cleared on recovery, so on its own it hides exactly
|
|
126
|
+
* the failures worth knowing about: a media element that firefox wedged is rebuilt and playback
|
|
127
|
+
* carries on, leaving no trace anywhere. This is the record, and the control bar offers it only
|
|
128
|
+
* once there is something in it.
|
|
129
|
+
*/
|
|
130
|
+
playbackErrors: PlaybackErrorEntry[]
|
|
103
131
|
/** Whether the engine has produced its first media segment. */
|
|
104
132
|
ready: boolean
|
|
105
133
|
|
|
@@ -128,6 +156,7 @@ const initialState: SourceState = {
|
|
|
128
156
|
pictureInPictureMode: null,
|
|
129
157
|
burnedInSubtitles: false,
|
|
130
158
|
playbackError: null,
|
|
159
|
+
playbackErrors: [],
|
|
131
160
|
ready: false,
|
|
132
161
|
setSourceState: () => {},
|
|
133
162
|
}
|
|
@@ -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
|
}
|