@banou/media-player 0.8.13 → 0.8.15
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.js +1 -1
- package/dist/engine/playback.d.ts +2 -0
- package/dist/{engine-BUcS6UrE.js → engine-CDdjRm_U.js} +99 -92
- package/dist/index.js +152 -130
- package/package.json +1 -1
- package/src/lib/engine/playback.ts +75 -5
- package/src/lib/react/components/progress-bar.tsx +64 -7
- package/src/lib/react/hooks/use-drag-value.ts +5 -2
- package/src/lib/react/hooks/use-playback.ts +35 -21
package/package.json
CHANGED
|
@@ -43,6 +43,8 @@ export type PlaybackController = {
|
|
|
43
43
|
* because this can take as long as a read, which over a torrent has no ceiling.
|
|
44
44
|
*/
|
|
45
45
|
prepareSeek: (time: number) => Promise<void>
|
|
46
|
+
/** The element this pipeline drives, so a caller can read what actually got buffered. */
|
|
47
|
+
videoElement: HTMLVideoElement
|
|
46
48
|
selectSubtitleStream: (streamIndex: number | undefined) => void
|
|
47
49
|
/** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
|
|
48
50
|
indexes: MediaIndex[]
|
|
@@ -81,6 +83,16 @@ const MAX_APPEND_ATTEMPTS = 5
|
|
|
81
83
|
const SOURCE_OPEN_TIMEOUT = 15_000
|
|
82
84
|
// how far past the playhead a range may start and still count as the one holding it
|
|
83
85
|
const BOUNDARY_SLACK = 1
|
|
86
|
+
/*
|
|
87
|
+
* Seconds of data a seek target needs behind it before the playhead is allowed to move there.
|
|
88
|
+
*
|
|
89
|
+
* Covering the target instant is not enough: the element plays through a one chunk island in well
|
|
90
|
+
* under a second and runs dry, and that underrun drains firefox's decoder just as a seek into a hole
|
|
91
|
+
* does. Measured in production, six seeks that all reported themselves prepared still wedged.
|
|
92
|
+
*/
|
|
93
|
+
const SEEK_RUNWAY = 3
|
|
94
|
+
// reads allowed to build that runway, so a source that answers with nothing cannot spin here
|
|
95
|
+
const SEEK_RUNWAY_READS = 12
|
|
84
96
|
// the fastest a drag may move the consumer's download window
|
|
85
97
|
const SEEK_REPORT_MS = 200
|
|
86
98
|
// quiet time that ends a drag: pointermoves arrive every few ms, so this cannot cut one in half
|
|
@@ -219,6 +231,16 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
219
231
|
let seeking = false
|
|
220
232
|
// how many prepares are in flight, so eviction knows not to delete what they are fetching
|
|
221
233
|
let preparing = 0
|
|
234
|
+
/*
|
|
235
|
+
* Where buffering should aim while a seek is being prepared.
|
|
236
|
+
*
|
|
237
|
+
* The playhead has not moved there yet, so anything anchored on `currentTime` is aiming at the
|
|
238
|
+
* position being left behind. `needsData` in particular would answer for the old position and
|
|
239
|
+
* stop reading, which is how a prepare came to leave a one chunk island: enough to cover the
|
|
240
|
+
* target instant, and nowhere near enough to play from.
|
|
241
|
+
*/
|
|
242
|
+
let prepareTarget: number | undefined
|
|
243
|
+
const bufferAnchor = () => prepareTarget ?? videoElement.currentTime
|
|
222
244
|
let finished = false
|
|
223
245
|
// libav aborts the running task when a new one starts, so both flags need a generation token
|
|
224
246
|
let readGeneration = 0
|
|
@@ -272,10 +294,20 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
272
294
|
const needsData = () => {
|
|
273
295
|
const ranges = getTimeRanges(sourceBuffer)
|
|
274
296
|
if (!ranges.length) return true
|
|
275
|
-
const ct =
|
|
276
|
-
// never read past what evict() keeps
|
|
277
|
-
if (Math.max(...ranges.map((r) => r.end)) >= ct + POST_EVICT) return false
|
|
297
|
+
const ct = bufferAnchor()
|
|
278
298
|
const range = ranges.find((r) => r.start <= ct + BOUNDARY_SLACK && ct < r.end)
|
|
299
|
+
/*
|
|
300
|
+
* Never read past what evict() keeps, measured on the run holding the anchor rather than
|
|
301
|
+
* across every island.
|
|
302
|
+
*
|
|
303
|
+
* Taking the max end over ALL ranges meant one stale island far ahead could refuse a read
|
|
304
|
+
* right where the playhead was about to be. Repeated seeking is exactly how those islands
|
|
305
|
+
* appear, and eviction is suppressed while a seek is being prepared, so they survive to do it.
|
|
306
|
+
* Seen in production as forward seeks getting a 4s runway while every BACKWARD seek collapsed
|
|
307
|
+
* to 1.0s, then 0.5s, then 0.3s, and wedged: the island left out at ~760s was past
|
|
308
|
+
* `target + POST_EVICT` for every one of them.
|
|
309
|
+
*/
|
|
310
|
+
if (range && range.end >= ct + POST_EVICT) return false
|
|
279
311
|
return !range || range.end < ct + BUFFER_TARGET
|
|
280
312
|
}
|
|
281
313
|
|
|
@@ -417,14 +449,51 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
417
449
|
* The CALLER owns the deadline: this can take as long as a read takes, and over a torrent that
|
|
418
450
|
* is unbounded.
|
|
419
451
|
*/
|
|
452
|
+
/** Seconds of contiguous data past a position that make it safe to play from. */
|
|
453
|
+
const runwayFrom = (time: number) => {
|
|
454
|
+
const range = getTimeRanges(sourceBuffer).find((r) => r.start <= time + BOUNDARY_SLACK && time < r.end)
|
|
455
|
+
return range ? range.end - time : 0
|
|
456
|
+
}
|
|
457
|
+
|
|
420
458
|
const prepareSeek = async (time: number) => {
|
|
421
|
-
if (destroyed
|
|
459
|
+
if (destroyed) return
|
|
460
|
+
if (runwayFrom(time) >= SEEK_RUNWAY) return
|
|
422
461
|
finished = false
|
|
423
462
|
preparing++
|
|
463
|
+
prepareTarget = time
|
|
424
464
|
try {
|
|
425
|
-
await seekTo(time)
|
|
465
|
+
if (!playableAt(time)) await seekTo(time)
|
|
466
|
+
/*
|
|
467
|
+
* Captured AFTER the seek, never before.
|
|
468
|
+
*
|
|
469
|
+
* `seekTo` bumps `seekGeneration` itself, so a generation read before it is stale the moment
|
|
470
|
+
* it returns and every staleness check below fails on its first pass. That silently disabled
|
|
471
|
+
* this entire loop: prepares came back in ~170ms with a 0.5s to 1.9s island and the runway
|
|
472
|
+
* was never built at all.
|
|
473
|
+
*/
|
|
474
|
+
const generation = seekGeneration
|
|
475
|
+
/*
|
|
476
|
+
* Then keep reading until there is something to PLAY, not merely something to land on.
|
|
477
|
+
*
|
|
478
|
+
* `remuxer.seek` returns a single chunk, so the first version of this left a small island:
|
|
479
|
+
* the element arrived on covered ground, played through it in well under a second, and ran
|
|
480
|
+
* dry there. That underrun drains the decoder exactly as a seek into a hole would, and it
|
|
481
|
+
* wedged in production with every seek reporting itself as prepared. Preparing a point was
|
|
482
|
+
* never the requirement; preparing a runway is.
|
|
483
|
+
*
|
|
484
|
+
* Bounded twice over: by the runway being reached, and by a read that returns nothing new.
|
|
485
|
+
* The caller's deadline bounds the wall clock on top of that.
|
|
486
|
+
*/
|
|
487
|
+
for (let attempt = 0; attempt < SEEK_RUNWAY_READS; attempt++) {
|
|
488
|
+
if (destroyed || generation !== seekGeneration || finished) break
|
|
489
|
+
if (runwayFrom(time) >= SEEK_RUNWAY) break
|
|
490
|
+
const before = runwayFrom(time)
|
|
491
|
+
await pump()
|
|
492
|
+
if (runwayFrom(time) <= before) break
|
|
493
|
+
}
|
|
426
494
|
} finally {
|
|
427
495
|
preparing--
|
|
496
|
+
if (prepareTarget === time) prepareTarget = undefined
|
|
428
497
|
}
|
|
429
498
|
}
|
|
430
499
|
|
|
@@ -564,6 +633,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
564
633
|
return {
|
|
565
634
|
destroy: runTeardown,
|
|
566
635
|
prepareSeek,
|
|
636
|
+
videoElement,
|
|
567
637
|
selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
|
|
568
638
|
indexes: metadata.indexes ?? [],
|
|
569
639
|
duration: metadata.info.input.duration,
|
|
@@ -181,6 +181,9 @@ const style = css`
|
|
|
181
181
|
}
|
|
182
182
|
`
|
|
183
183
|
|
|
184
|
+
/** Movement from the press, in px, past which a gesture is a scrub rather than a click. */
|
|
185
|
+
const SCRUB_THRESHOLD_PX = 4
|
|
186
|
+
|
|
184
187
|
export const ProgressBar = () => {
|
|
185
188
|
const player = usePlayer()
|
|
186
189
|
const currentTime = usePlayer((state) => state.currentTime)
|
|
@@ -200,7 +203,36 @@ export const ProgressBar = () => {
|
|
|
200
203
|
// onChange reports a bare fraction, so the device that opened the gesture is recorded on press
|
|
201
204
|
const dragPointerType = useRef<string | undefined>(undefined)
|
|
202
205
|
|
|
206
|
+
/*
|
|
207
|
+
* How many changes this gesture has produced, which is what tells a click from a scrub.
|
|
208
|
+
*
|
|
209
|
+
* `useDragValue` reports onChange on the PRESS and again on every move, so a click that drifts one
|
|
210
|
+
* pixel produces two. Counting them is exact where a timer is not: the first is the press, and
|
|
211
|
+
* anything after it is the pointer actually moving.
|
|
212
|
+
*/
|
|
213
|
+
const changesThisPress = useRef(0)
|
|
214
|
+
const latestFraction = useRef<number | undefined>(undefined)
|
|
215
|
+
const pressFraction = useRef<number | undefined>(undefined)
|
|
216
|
+
/*
|
|
217
|
+
* Whether this gesture has become a scrub, latched once it has.
|
|
218
|
+
*
|
|
219
|
+
* A pixel of drift is a click, not a drag: every real mouse produces some. Counting changes was
|
|
220
|
+
* not enough, because that pixel arrives as a pointermove and so looked like scrubbing, which put
|
|
221
|
+
* the playhead onto unbuffered ground before its data existed and wedged firefox exactly as
|
|
222
|
+
* before. Distance from the press is the honest test, and it latches so that dragging back toward
|
|
223
|
+
* the origin does not flip the gesture back into a click.
|
|
224
|
+
*/
|
|
225
|
+
const isScrub = useRef(false)
|
|
226
|
+
|
|
203
227
|
const onSeekDrag = (fraction: number) => {
|
|
228
|
+
changesThisPress.current += 1
|
|
229
|
+
latestFraction.current = fraction
|
|
230
|
+
if (pressFraction.current === undefined) {
|
|
231
|
+
pressFraction.current = fraction
|
|
232
|
+
} else if (!isScrub.current) {
|
|
233
|
+
const width = progressBarRef.current?.getBoundingClientRect().width ?? 0
|
|
234
|
+
if (Math.abs(fraction - pressFraction.current) * width > SCRUB_THRESHOLD_PX) isScrub.current = true
|
|
235
|
+
}
|
|
204
236
|
setSeekFraction(fraction)
|
|
205
237
|
if (dragPointerType.current === 'mouse') return
|
|
206
238
|
setProgressBarOverTime(fraction * duration)
|
|
@@ -210,11 +242,31 @@ export const ProgressBar = () => {
|
|
|
210
242
|
|
|
211
243
|
const onDragStart: DOMAttributes<HTMLDivElement>['onPointerDown'] = (ev) => {
|
|
212
244
|
dragPointerType.current = ev.pointerType
|
|
245
|
+
changesThisPress.current = 0
|
|
246
|
+
pressFraction.current = undefined
|
|
247
|
+
isScrub.current = false
|
|
213
248
|
handlers.onPointerDown(ev)
|
|
214
249
|
}
|
|
215
250
|
|
|
216
251
|
const onDragEnd: DOMAttributes<HTMLDivElement>['onPointerUp'] = (ev) => {
|
|
217
252
|
handlers.onPointerUp(ev)
|
|
253
|
+
/*
|
|
254
|
+
* The seek that counts, and the only one allowed to move the playhead onto new ground.
|
|
255
|
+
*
|
|
256
|
+
* Where the gesture ENDED is the position anyone is waiting for, and it is issued through
|
|
257
|
+
* `requestSeek` so the data is in place before the element demuxes there. A seek into a hole is
|
|
258
|
+
* what wedges firefox's decoder, and the whole gesture exists to arrive at this one moment.
|
|
259
|
+
*/
|
|
260
|
+
// a pointerup with no press behind it is not this gesture, and must not seek anywhere
|
|
261
|
+
const fraction = changesThisPress.current > 0 ? latestFraction.current : undefined
|
|
262
|
+
if (fraction !== undefined && duration) {
|
|
263
|
+
const timestamp = fraction * duration
|
|
264
|
+
if (requestSeek) requestSeek(timestamp)
|
|
265
|
+
else player.seek(timestamp)
|
|
266
|
+
}
|
|
267
|
+
changesThisPress.current = 0
|
|
268
|
+
isScrub.current = false
|
|
269
|
+
pressFraction.current = undefined
|
|
218
270
|
// a lifted finger leaves nothing over the bar, so the preview it opened closes with it
|
|
219
271
|
if (ev.pointerType === 'mouse') return
|
|
220
272
|
setProgressBarOverTime(undefined)
|
|
@@ -273,15 +325,20 @@ export const ProgressBar = () => {
|
|
|
273
325
|
return `${hoursString}${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
274
326
|
}, [progressBarHoverTime])
|
|
275
327
|
|
|
328
|
+
/*
|
|
329
|
+
* Follow the pointer while it is actually moving, and NOT on the press itself.
|
|
330
|
+
*
|
|
331
|
+
* Seeking on the press is what defeated the first version of this: a click that drifts a pixel
|
|
332
|
+
* looked like a drag, so the playhead jumped onto unbuffered ground before its data existed, which
|
|
333
|
+
* is exactly the wedge. A scrub still follows the pointer, because a drag has never reproduced the
|
|
334
|
+
* fault (its seeks land ~28ms apart, far too fast for a drain to complete) and a frozen picture
|
|
335
|
+
* during a scrub would be a real regression. The settled position is handled on release.
|
|
336
|
+
*/
|
|
276
337
|
useEffect(() => {
|
|
277
338
|
if (seekFraction === undefined || !duration) return
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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])
|
|
339
|
+
if (!dragging || !isScrub.current) return
|
|
340
|
+
player.seek(seekFraction * duration)
|
|
341
|
+
}, [player, dragging, seekFraction, duration])
|
|
285
342
|
|
|
286
343
|
const scaleX = useMemo(() => {
|
|
287
344
|
return !duration || typeof currentTime !== 'number'
|
|
@@ -35,7 +35,10 @@ export const useDragValue = ({ ref, onChange, orientation = 'horizontal', disabl
|
|
|
35
35
|
// events the seek preview runs on, the second keeps the press from reaching the document
|
|
36
36
|
// listeners that close the popover and refresh the auto-hide timer.
|
|
37
37
|
activePointer.current = event.pointerId
|
|
38
|
-
|
|
38
|
+
// Throws NotFoundError for a pointer id that is not active, and an exception here would abandon
|
|
39
|
+
// the gesture before it records anything. Capture is an optimisation for tracking outside the
|
|
40
|
+
// element, never a requirement.
|
|
41
|
+
try { event.currentTarget.setPointerCapture?.(event.pointerId) } catch {}
|
|
39
42
|
setDragging(true)
|
|
40
43
|
onChangeRef.current(fractionFor(event.clientX, event.clientY))
|
|
41
44
|
}, [disabled, fractionFor])
|
|
@@ -48,7 +51,7 @@ export const useDragValue = ({ ref, onChange, orientation = 'horizontal', disabl
|
|
|
48
51
|
const endDrag = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
|
49
52
|
if (activePointer.current !== event.pointerId) return
|
|
50
53
|
activePointer.current = null
|
|
51
|
-
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
|
54
|
+
try { event.currentTarget.releasePointerCapture?.(event.pointerId) } catch {}
|
|
52
55
|
setDragging(false)
|
|
53
56
|
}, [])
|
|
54
57
|
|
|
@@ -33,13 +33,6 @@ const RESTART_SETTLED_MS = 60_000
|
|
|
33
33
|
*/
|
|
34
34
|
const SEEK_PREPARE_BUDGET_MS = 500
|
|
35
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
|
-
|
|
43
36
|
const messageOf = (error: unknown) =>
|
|
44
37
|
error instanceof Error ? error.message : String(error)
|
|
45
38
|
|
|
@@ -127,8 +120,6 @@ export const usePlayback = (
|
|
|
127
120
|
* deliberately not, since a streaming consumer passes a fresh closure several times a second.
|
|
128
121
|
*/
|
|
129
122
|
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)
|
|
132
123
|
// The renderer turns the first track on by itself, so the menu has to mirror that or it shows
|
|
133
124
|
// "Disable" ticked over subtitles that are visibly on screen.
|
|
134
125
|
const subtitleChoiceMade = useRef(false)
|
|
@@ -167,28 +158,51 @@ export const usePlayback = (
|
|
|
167
158
|
if (!controller || seekPrepareBudgetMs <= 0) { player.seek(time); return }
|
|
168
159
|
|
|
169
160
|
/*
|
|
170
|
-
*
|
|
161
|
+
* Every call gets the data first. There is deliberately no "this looks like a drag" shortcut.
|
|
171
162
|
*
|
|
172
|
-
* The
|
|
173
|
-
*
|
|
174
|
-
* a
|
|
175
|
-
*
|
|
163
|
+
* The first version of this guessed at drags from the gap between calls, and that is what let
|
|
164
|
+
* the fault through in production: the seek bar reports a change on the PRESS and again on every
|
|
165
|
+
* move, so a click that drifts one pixel produced two calls milliseconds apart, the second was
|
|
166
|
+
* read as a drag, and the playhead jumped onto unbuffered ground before its data existed. Two
|
|
167
|
+
* quick taps of the arrow keys would have done the same.
|
|
176
168
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
169
|
+
* The chrome knows what gesture it is in and now says so by only calling this for settled seeks,
|
|
170
|
+
* which is knowledge rather than inference. Rapid calls are safe here anyway: each supersedes
|
|
171
|
+
* the last through the engine's seek generation.
|
|
179
172
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
173
|
+
/*
|
|
174
|
+
* A debug trace, off unless asked for.
|
|
175
|
+
*
|
|
176
|
+
* The fault this prevents is reproducible in seconds on some machines and not at all on others,
|
|
177
|
+
* so the only way to learn anything from someone else's reproduction is to have the seek say
|
|
178
|
+
* what it did. Set `window.__mediaPlayerSeekDebug = true` (the dev route does it for
|
|
179
|
+
* `?seekDebug=1`) and every seek reports whether the data was ready, how long it waited, and
|
|
180
|
+
* whether the playhead moved before the data arrived, which is the exact condition that wedges
|
|
181
|
+
* firefox.
|
|
182
|
+
*/
|
|
183
|
+
const debug = typeof window !== 'undefined' && (window as { __mediaPlayerSeekDebug?: boolean }).__mediaPlayerSeekDebug
|
|
184
|
+
const startedAt = performance.now()
|
|
184
185
|
|
|
185
186
|
let moved = false
|
|
187
|
+
let movedBecause: 'prepared' | 'deadline' = 'prepared'
|
|
186
188
|
const move = () => {
|
|
187
189
|
if (moved) return
|
|
188
190
|
moved = true
|
|
189
191
|
player.seek(time)
|
|
192
|
+
if (debug) {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
const video = controller.videoElement
|
|
195
|
+
let runway = 0
|
|
196
|
+
if (video) {
|
|
197
|
+
for (let i = 0; i < video.buffered.length; i++) {
|
|
198
|
+
if (video.buffered.start(i) <= time + 1 && time < video.buffered.end(i)) runway = video.buffered.end(i) - time
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.warn(`[media-player] seek to ${time.toFixed(2)} moved after ${Math.round(performance.now() - startedAt)}ms via ${movedBecause}, runway ${runway.toFixed(1)}s${movedBecause === 'deadline' ? ' (EXPOSED: moved before its data was ready)' : ''}`)
|
|
203
|
+
}
|
|
190
204
|
}
|
|
191
|
-
const deadline = setTimeout(move, seekPrepareBudgetMs)
|
|
205
|
+
const deadline = setTimeout(() => { movedBecause = 'deadline'; move() }, seekPrepareBudgetMs)
|
|
192
206
|
void controller
|
|
193
207
|
.prepareSeek(time)
|
|
194
208
|
// a failed prepare is not a reason to refuse the seek: the pump and the existing recovery
|