@banou/media-player 0.8.12 → 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 +9 -0
- package/dist/{engine-Cownv8gD.js → engine-CDdjRm_U.js} +130 -112
- package/dist/index.js +305 -259
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +24 -0
- package/dist/react/video-player.d.ts +8 -0
- package/package.json +1 -1
- package/src/lib/engine/playback.ts +136 -3
- package/src/lib/react/components/control-bar.tsx +7 -3
- package/src/lib/react/components/progress-bar.tsx +65 -3
- package/src/lib/react/hooks/use-drag-value.ts +5 -2
- package/src/lib/react/hooks/use-playback.ts +82 -2
- package/src/lib/react/source-feature.ts +13 -0
- package/src/lib/react/video-player.tsx +8 -0
package/dist/react/player.d.ts
CHANGED
|
@@ -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
|
@@ -36,6 +36,15 @@ 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>
|
|
46
|
+
/** The element this pipeline drives, so a caller can read what actually got buffered. */
|
|
47
|
+
videoElement: HTMLVideoElement
|
|
39
48
|
selectSubtitleStream: (streamIndex: number | undefined) => void
|
|
40
49
|
/** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
|
|
41
50
|
indexes: MediaIndex[]
|
|
@@ -74,6 +83,16 @@ const MAX_APPEND_ATTEMPTS = 5
|
|
|
74
83
|
const SOURCE_OPEN_TIMEOUT = 15_000
|
|
75
84
|
// how far past the playhead a range may start and still count as the one holding it
|
|
76
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
|
|
77
96
|
// the fastest a drag may move the consumer's download window
|
|
78
97
|
const SEEK_REPORT_MS = 200
|
|
79
98
|
// quiet time that ends a drag: pointermoves arrive every few ms, so this cannot cut one in half
|
|
@@ -210,6 +229,18 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
210
229
|
|
|
211
230
|
let reading = false
|
|
212
231
|
let seeking = false
|
|
232
|
+
// how many prepares are in flight, so eviction knows not to delete what they are fetching
|
|
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
|
|
213
244
|
let finished = false
|
|
214
245
|
// libav aborts the running task when a new one starts, so both flags need a generation token
|
|
215
246
|
let readGeneration = 0
|
|
@@ -237,6 +268,18 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
237
268
|
}
|
|
238
269
|
|
|
239
270
|
const evict = async (tight = false) => {
|
|
271
|
+
/*
|
|
272
|
+
* Hold off while data is being put in place for a position the playhead has not reached.
|
|
273
|
+
*
|
|
274
|
+
* Eviction is anchored on the playhead, and `prepareSeek` deliberately appends far from it, so
|
|
275
|
+
* running now deletes exactly what was just fetched. The 100ms interval below made that a
|
|
276
|
+
* certainty. The benchmark caught it as prepared seeks being the SLOWEST arm, which is the
|
|
277
|
+
* opposite of the point.
|
|
278
|
+
*
|
|
279
|
+
* `tight` still runs, because that one is the answer to a quota refusal and has to be able to
|
|
280
|
+
* free space no matter what else is happening.
|
|
281
|
+
*/
|
|
282
|
+
if (preparing > 0 && !tight) return
|
|
240
283
|
const ct = videoElement.currentTime
|
|
241
284
|
const pre = tight ? PRE_EVICT_TIGHT : PRE_EVICT
|
|
242
285
|
const post = tight ? POST_EVICT_TIGHT : POST_EVICT
|
|
@@ -251,10 +294,20 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
251
294
|
const needsData = () => {
|
|
252
295
|
const ranges = getTimeRanges(sourceBuffer)
|
|
253
296
|
if (!ranges.length) return true
|
|
254
|
-
const ct =
|
|
255
|
-
// never read past what evict() keeps
|
|
256
|
-
if (Math.max(...ranges.map((r) => r.end)) >= ct + POST_EVICT) return false
|
|
297
|
+
const ct = bufferAnchor()
|
|
257
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
|
|
258
311
|
return !range || range.end < ct + BUFFER_TARGET
|
|
259
312
|
}
|
|
260
313
|
|
|
@@ -375,6 +428,75 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
375
428
|
*/
|
|
376
429
|
const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
|
|
377
430
|
|
|
431
|
+
/** Whether a GIVEN time has data behind it, as opposed to whether the playhead does. */
|
|
432
|
+
const playableAt = (time: number) =>
|
|
433
|
+
getTimeRanges(sourceBuffer).some((r) => r.start <= time + BOUNDARY_SLACK && time < r.end)
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Put the data in place for a position the playhead has NOT moved to yet.
|
|
437
|
+
*
|
|
438
|
+
* Firefox wedges its own decoder when the element demuxes into a hole: the underrun requests a
|
|
439
|
+
* drain, the drain completes, the re-prime that would flush the decoder never runs, and every
|
|
440
|
+
* packet after that comes back `avcodec_send_packet error: End of file`. Seeking only after the
|
|
441
|
+
* target has data removes the hole, and with it the drain and the whole failure.
|
|
442
|
+
*
|
|
443
|
+
* Measured on a standalone rig against a real 1080p stream: 7 wedges in 7 runs seeking the
|
|
444
|
+
* ordinary way, 0 in 4 seeking this way. The control matters, because appending first also
|
|
445
|
+
* delays the seek: delaying the seek by the same amount while appending nothing still wedged 4
|
|
446
|
+
* out of 4, so it is the data and not the delay.
|
|
447
|
+
*
|
|
448
|
+
* Returns as soon as there is nothing to wait for, so a seek into buffered ground costs nothing.
|
|
449
|
+
* The CALLER owns the deadline: this can take as long as a read takes, and over a torrent that
|
|
450
|
+
* is unbounded.
|
|
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
|
+
|
|
458
|
+
const prepareSeek = async (time: number) => {
|
|
459
|
+
if (destroyed) return
|
|
460
|
+
if (runwayFrom(time) >= SEEK_RUNWAY) return
|
|
461
|
+
finished = false
|
|
462
|
+
preparing++
|
|
463
|
+
prepareTarget = time
|
|
464
|
+
try {
|
|
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
|
+
}
|
|
494
|
+
} finally {
|
|
495
|
+
preparing--
|
|
496
|
+
if (prepareTarget === time) prepareTarget = undefined
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
378
500
|
/**
|
|
379
501
|
* Where a drag reaches the consumer, at a rate a consumer can act on.
|
|
380
502
|
*
|
|
@@ -437,6 +559,15 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
437
559
|
const dragging = now - lastSeekingAt < DRAG_SETTLE_MS
|
|
438
560
|
lastSeekingAt = now
|
|
439
561
|
if (alreadyPlayable(time)) return
|
|
562
|
+
/*
|
|
563
|
+
* A seek for exactly this position is already running, so let it finish.
|
|
564
|
+
*
|
|
565
|
+
* `prepareSeek` starts one BEFORE the playhead moves, and the caller moves the playhead anyway
|
|
566
|
+
* once its budget runs out. Without this, the move fires `seeking`, which starts the identical
|
|
567
|
+
* read again and bumps the generation, throwing away everything the first one had done. The
|
|
568
|
+
* benchmark caught it as a seek that took twice as long as seeking the old way.
|
|
569
|
+
*/
|
|
570
|
+
if (seeking && Math.abs(time - lastSeekPosition) < 0.001) return
|
|
440
571
|
finished = false
|
|
441
572
|
if (dragTimer) clearTimeout(dragTimer)
|
|
442
573
|
if (!dragging) {
|
|
@@ -501,6 +632,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
501
632
|
|
|
502
633
|
return {
|
|
503
634
|
destroy: runTeardown,
|
|
635
|
+
prepareSeek,
|
|
636
|
+
videoElement,
|
|
504
637
|
selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
|
|
505
638
|
indexes: metadata.indexes ?? [],
|
|
506
639
|
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
|
-
|
|
239
|
+
seek(Math.min(player.currentTime + SEEK_STEP, player.duration))
|
|
236
240
|
}
|
|
237
241
|
else if (ev.key === 'ArrowLeft') {
|
|
238
|
-
|
|
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()
|
|
@@ -181,9 +181,13 @@ 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)
|
|
190
|
+
const requestSeek = usePlayer((state) => state.requestSeek)
|
|
187
191
|
const duration = usePlayer((state) => state.duration)
|
|
188
192
|
const size = usePlayer((state) => state.size)
|
|
189
193
|
const downloadedRanges = usePlayer((state) => state.downloadedRanges)
|
|
@@ -199,7 +203,36 @@ export const ProgressBar = () => {
|
|
|
199
203
|
// onChange reports a bare fraction, so the device that opened the gesture is recorded on press
|
|
200
204
|
const dragPointerType = useRef<string | undefined>(undefined)
|
|
201
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
|
+
|
|
202
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
|
+
}
|
|
203
236
|
setSeekFraction(fraction)
|
|
204
237
|
if (dragPointerType.current === 'mouse') return
|
|
205
238
|
setProgressBarOverTime(fraction * duration)
|
|
@@ -209,11 +242,31 @@ export const ProgressBar = () => {
|
|
|
209
242
|
|
|
210
243
|
const onDragStart: DOMAttributes<HTMLDivElement>['onPointerDown'] = (ev) => {
|
|
211
244
|
dragPointerType.current = ev.pointerType
|
|
245
|
+
changesThisPress.current = 0
|
|
246
|
+
pressFraction.current = undefined
|
|
247
|
+
isScrub.current = false
|
|
212
248
|
handlers.onPointerDown(ev)
|
|
213
249
|
}
|
|
214
250
|
|
|
215
251
|
const onDragEnd: DOMAttributes<HTMLDivElement>['onPointerUp'] = (ev) => {
|
|
216
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
|
|
217
270
|
// a lifted finger leaves nothing over the bar, so the preview it opened closes with it
|
|
218
271
|
if (ev.pointerType === 'mouse') return
|
|
219
272
|
setProgressBarOverTime(undefined)
|
|
@@ -272,11 +325,20 @@ export const ProgressBar = () => {
|
|
|
272
325
|
return `${hoursString}${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
273
326
|
}, [progressBarHoverTime])
|
|
274
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
|
+
*/
|
|
275
337
|
useEffect(() => {
|
|
276
338
|
if (seekFraction === undefined || !duration) return
|
|
277
|
-
|
|
278
|
-
player.seek(
|
|
279
|
-
}, [player, seekFraction, duration])
|
|
339
|
+
if (!dragging || !isScrub.current) return
|
|
340
|
+
player.seek(seekFraction * duration)
|
|
341
|
+
}, [player, dragging, seekFraction, duration])
|
|
280
342
|
|
|
281
343
|
const scaleX = useMemo(() => {
|
|
282
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
|
|
|
@@ -25,6 +25,14 @@ 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
|
+
|
|
28
36
|
const messageOf = (error: unknown) =>
|
|
29
37
|
error instanceof Error ? error.message : String(error)
|
|
30
38
|
|
|
@@ -66,6 +74,7 @@ export const usePlayback = (
|
|
|
66
74
|
const {
|
|
67
75
|
read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
|
|
68
76
|
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
|
|
77
|
+
seekPrepareBudgetMs = SEEK_PREPARE_BUDGET_MS,
|
|
69
78
|
} = options ?? ({} as Partial<MediaPlayerLocalOptions>)
|
|
70
79
|
|
|
71
80
|
// The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
|
|
@@ -131,6 +140,77 @@ export const usePlayback = (
|
|
|
131
140
|
controllerRef.current?.selectSubtitleStream(streamIndex)
|
|
132
141
|
}, [player])
|
|
133
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Seek with the data already there, or after the deadline, whichever comes first.
|
|
145
|
+
*
|
|
146
|
+
* The prevention for the firefox decoder wedge. Seeking into a hole makes the reader drain the
|
|
147
|
+
* decoder and never flush it again, so the pipeline is asked for the target first. The deadline is
|
|
148
|
+
* what keeps that honest: a read over a torrent has no ceiling, and a seek bar that waits on one
|
|
149
|
+
* is worse than the fault it avoids. So the playhead moves either when the data lands or when the
|
|
150
|
+
* budget runs out, once, whichever happens first.
|
|
151
|
+
*
|
|
152
|
+
* A seek into buffered ground resolves immediately, so scrubbing inside the buffer is unaffected.
|
|
153
|
+
*/
|
|
154
|
+
const requestSeek = useCallback((time: number) => {
|
|
155
|
+
const controller = controllerRef.current
|
|
156
|
+
// no budget means the feature is off: seek at once and do not spend a read preparing something
|
|
157
|
+
// nobody is going to wait for
|
|
158
|
+
if (!controller || seekPrepareBudgetMs <= 0) { player.seek(time); return }
|
|
159
|
+
|
|
160
|
+
/*
|
|
161
|
+
* Every call gets the data first. There is deliberately no "this looks like a drag" shortcut.
|
|
162
|
+
*
|
|
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.
|
|
168
|
+
*
|
|
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.
|
|
172
|
+
*/
|
|
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()
|
|
185
|
+
|
|
186
|
+
let moved = false
|
|
187
|
+
let movedBecause: 'prepared' | 'deadline' = 'prepared'
|
|
188
|
+
const move = () => {
|
|
189
|
+
if (moved) return
|
|
190
|
+
moved = true
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const deadline = setTimeout(() => { movedBecause = 'deadline'; move() }, seekPrepareBudgetMs)
|
|
206
|
+
void controller
|
|
207
|
+
.prepareSeek(time)
|
|
208
|
+
// a failed prepare is not a reason to refuse the seek: the pump and the existing recovery
|
|
209
|
+
// both still apply, and refusing would strand the viewer on a bar that does nothing
|
|
210
|
+
.catch(() => {})
|
|
211
|
+
.finally(() => { clearTimeout(deadline); move() })
|
|
212
|
+
}, [player, seekPrepareBudgetMs])
|
|
213
|
+
|
|
134
214
|
const selectAudioTrack = useCallback((id: string | number) => {
|
|
135
215
|
if (typeof id !== 'number') return
|
|
136
216
|
player.setSourceState({ selectedAudioTrack: id })
|
|
@@ -143,8 +223,8 @@ export const usePlayback = (
|
|
|
143
223
|
const setSourceState = usePlayer((state) => state.setSourceState)
|
|
144
224
|
|
|
145
225
|
useEffect(() => {
|
|
146
|
-
setSourceState({ selectSubtitleTrack, selectAudioTrack })
|
|
147
|
-
}, [setSourceState, selectSubtitleTrack, selectAudioTrack])
|
|
226
|
+
setSourceState({ selectSubtitleTrack, selectAudioTrack, requestSeek })
|
|
227
|
+
}, [setSourceState, selectSubtitleTrack, selectAudioTrack, requestSeek])
|
|
148
228
|
|
|
149
229
|
useEffect(() => {
|
|
150
230
|
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[]
|