@banou/media-player 0.10.1 → 0.10.3

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.
@@ -23,6 +23,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
23
23
  playbackError: unknown;
24
24
  playbackErrors: import("./source-feature").PlaybackErrorEntry[];
25
25
  ready: boolean;
26
+ seekingTo?: number;
26
27
  setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
27
28
  }>]>>;
28
29
  /**
@@ -159,6 +159,18 @@ export type SourceState = {
159
159
  playbackErrors: PlaybackErrorEntry[];
160
160
  /** Whether the engine has produced its first media segment. */
161
161
  ready: boolean;
162
+ /**
163
+ * Where a seek is headed while the element has not arrived there yet, in seconds.
164
+ *
165
+ * The element's own `currentTime` does not move until it can present the frame, which on a long
166
+ * GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
167
+ * the clock sitting at the old position for that whole time, which reads as the player ignoring
168
+ * the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
169
+ * once and the spinner says the picture is still coming.
170
+ *
171
+ * Undefined whenever no seek is outstanding, which is almost always.
172
+ */
173
+ seekingTo?: number;
162
174
  /**
163
175
  * The write seam, wired in `attach`. Only the React layer calls it.
164
176
  *
@@ -265,6 +277,18 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
265
277
  playbackErrors: PlaybackErrorEntry[];
266
278
  /** Whether the engine has produced its first media segment. */
267
279
  ready: boolean;
280
+ /**
281
+ * Where a seek is headed while the element has not arrived there yet, in seconds.
282
+ *
283
+ * The element's own `currentTime` does not move until it can present the frame, which on a long
284
+ * GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
285
+ * the clock sitting at the old position for that whole time, which reads as the player ignoring
286
+ * the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
287
+ * once and the spinner says the picture is still coming.
288
+ *
289
+ * Undefined whenever no seek is outstanding, which is almost always.
290
+ */
291
+ seekingTo?: number;
268
292
  /**
269
293
  * The write seam, wired in `attach`. Only the React layer calls it.
270
294
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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",
@@ -25,6 +25,26 @@ const READAHEAD = 1_000_000
25
25
  const MAX_ATTEMPTS = 3
26
26
  // a keyframe decode can hang without ever settling
27
27
  const KEYFRAME_TIMEOUT = 10_000
28
+ /**
29
+ * How much more of the file has to become readable before the index is walked again.
30
+ *
31
+ * An index is only as complete as the bytes behind it, and over a torrent those arrive for minutes
32
+ * after the player starts. Re-walking on every range change would demux the file over and over; a
33
+ * multiplier makes the number of walks logarithmic in the file size instead, so a download that
34
+ * starts at 2% re-indexes around eight times on its way to whole rather than hundreds.
35
+ */
36
+ const REINDEX_GROWTH = 1.5
37
+ /** And never re-walk for a trickle, however early. */
38
+ const REINDEX_MIN_BYTES = 4_000_000
39
+ /**
40
+ * A bound on the re-walk, because this one holds the worker.
41
+ *
42
+ * The walk at boot can hang without costing anything that was working: there are no previews yet. A
43
+ * re-walk is different, since the pump waits for it, so a reader that stops answering would take
44
+ * the previews the current index CAN still produce down with it. Longer than a keyframe decode
45
+ * because a walk reads far more of the file.
46
+ */
47
+ const REINDEX_TIMEOUT = 30_000
28
48
 
29
49
  export type ThumbnailGenerator = {
30
50
  /** Report which byte ranges are readable. Called with no argument when the whole file is. */
@@ -60,22 +80,37 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
60
80
  const interval = Math.max(options.interval ?? INTERVAL, duration / MAX_THUMBNAILS)
61
81
 
62
82
  type Slot = { timestamp: number, endTime: number, startByte: number, endByte: number, done: boolean, attempts: number }
63
- const slots: Slot[] = []
64
- for (const [i, index] of metadata.indexes.entries()) {
65
- const last = slots.at(-1)
66
- if (last && index.timestamp - last.timestamp < interval) continue
67
- slots.push({
68
- timestamp: index.timestamp,
69
- endTime: duration,
70
- startByte: index.pos,
71
- endByte: Math.min((metadata.indexes[i + 1]?.pos ?? length) + READAHEAD, length),
72
- done: false,
73
- attempts: 0,
74
- })
83
+ let slots: Slot[] = []
84
+
85
+ /**
86
+ * The slot list for one index, which is only ever as complete as the bytes it was built from.
87
+ *
88
+ * libav walks the clusters it can read and stops, WITHOUT failing: on a file whose first tenth is
89
+ * readable it reports a single keyframe and a correct duration, because the duration comes from
90
+ * the header. That single entry then becomes a single slot whose endTime falls through to the
91
+ * duration, which is one preview covering the whole seekbar.
92
+ */
93
+ const buildSlots = (indexes: typeof metadata.indexes): Slot[] => {
94
+ const built: Slot[] = []
95
+ for (const [i, index] of indexes.entries()) {
96
+ const last = built.at(-1)
97
+ if (last && index.timestamp - last.timestamp < interval) continue
98
+ built.push({
99
+ timestamp: index.timestamp,
100
+ endTime: duration,
101
+ startByte: index.pos,
102
+ endByte: Math.min((indexes[i + 1]?.pos ?? length) + READAHEAD, length),
103
+ done: false,
104
+ attempts: 0,
105
+ })
106
+ }
107
+ for (const [i, slot] of built.entries()) slot.endTime = built[i + 1]?.timestamp ?? duration
108
+ // reading the last keyframe runs the demuxer into EOF, which crashes the libav build
109
+ if (built.length > 1 && (built.at(-1)!.timestamp > duration - interval * 2)) built.pop()
110
+ return built
75
111
  }
76
- for (const [i, slot] of slots.entries()) slot.endTime = slots[i + 1]?.timestamp ?? duration
77
- // reading the last keyframe runs the demuxer into EOF, which crashes the libav build
78
- if (slots.length > 1 && (slots.at(-1)!.timestamp > duration - interval * 2)) slots.pop()
112
+
113
+ slots = buildSlots(metadata.indexes)
79
114
 
80
115
  let thumbnails: ThumbnailImage[] = []
81
116
  let destroyed = false
@@ -93,6 +128,19 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
93
128
  /** Where the pointer is on the seekbar, or undefined when it is off it. */
94
129
  let priorityTime: number | undefined
95
130
 
131
+ /**
132
+ * How much of the file was readable when the current index was built.
133
+ *
134
+ * Negative until the first update, which establishes it: the boot walk has just happened against
135
+ * whatever was readable then, so the first report of the ranges is the baseline rather than a
136
+ * reason to walk again.
137
+ */
138
+ let indexedBytes = -1
139
+ let reindexWanted = false
140
+ let reindexing = false
141
+ /** The ranges last reported, so a walk deferred behind a decode can still claim against them. */
142
+ let lastRanges: [number, number][] | undefined
143
+
96
144
  /*
97
145
  * The slot to decode next: the one under the pointer when it is still waiting, else the oldest claim.
98
146
  *
@@ -146,7 +194,25 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
146
194
 
147
195
  // one decode at a time, because there is one wasm worker behind them all
148
196
  const pump = () => {
149
- if (running || destroyed || !pending.length) return
197
+ if (running || destroyed || reindexing) return
198
+ /*
199
+ * A deferred index walk goes first.
200
+ *
201
+ * There is one worker behind both, and a walk started while a decode holds it would have them
202
+ * interleaved on the same demuxer. Taking it here means the walk happens at the one moment
203
+ * nothing else is using it, and it happens BEFORE the next decode so that decode comes from
204
+ * the new slot list rather than the stale one.
205
+ */
206
+ if (reindexWanted) {
207
+ reindexWanted = false
208
+ const ranges = lastRanges
209
+ void reindex(readableTo(ranges)).then(() => {
210
+ claimReadable(ranges)
211
+ pump()
212
+ })
213
+ return
214
+ }
215
+ if (!pending.length) return
150
216
  const slot = pending.splice(nextIndex(), 1)[0]!
151
217
  running = true
152
218
  void decode(slot)
@@ -167,15 +233,77 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
167
233
  pump()
168
234
  }
169
235
 
236
+ /**
237
+ * Walk the index again now that more of the file can be read, and keep what is already drawn.
238
+ *
239
+ * A preview survives if its slot survives, which is why the previous timestamps are matched
240
+ * rather than the list being thrown away: the first slot is almost always still the first slot,
241
+ * and re-decoding it would throw away work and flicker the seekbar for no reason. What DOES
242
+ * change is its endTime, since a slot that used to run to the end of the file now runs only as
243
+ * far as the neighbour the new index revealed.
244
+ */
245
+ const reindex = async (readable: number) => {
246
+ reindexing = true
247
+ try {
248
+ const next = await Promise.race([
249
+ remuxer.init(),
250
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), REINDEX_TIMEOUT)),
251
+ ])
252
+ if (destroyed) return
253
+ indexedBytes = readable
254
+ const rebuilt = buildSlots(next.indexes)
255
+ if (!rebuilt.length) return
256
+
257
+ const drawn = new Map(thumbnails.map((t) => [t.startTime, t]))
258
+ for (const slot of rebuilt) {
259
+ if (drawn.has(slot.timestamp)) slot.done = true
260
+ }
261
+ slots = rebuilt
262
+ // a preview whose slot is gone is no longer addressable, and its blob would leak
263
+ const kept = new Set(rebuilt.map((slot) => slot.timestamp))
264
+ for (const t of thumbnails) if (!kept.has(t.startTime)) URL.revokeObjectURL(t.url)
265
+ thumbnails = rebuilt
266
+ .filter((slot) => drawn.has(slot.timestamp))
267
+ .map((slot) => ({ url: drawn.get(slot.timestamp)!.url, startTime: slot.timestamp, endTime: slot.endTime }))
268
+ // anything claimed against the old list is stale, and the update below re-claims from the new one
269
+ pending.length = 0
270
+ emit()
271
+ } catch {
272
+ // an index that could not be re-read leaves the old one in place, and the next update retries
273
+ } finally {
274
+ reindexing = false
275
+ }
276
+ }
277
+
278
+ /** The end of what can be read, which is what an index walk can reach. */
279
+ const readableTo = (ranges?: [number, number][]) =>
280
+ ranges ? ranges.reduce((most, [, to]) => Math.max(most, to), 0) : length
281
+
282
+ const claimReadable = (ranges?: [number, number][]) => {
283
+ for (const slot of slots) {
284
+ if (slot.done) continue
285
+ if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) claim(slot)
286
+ }
287
+ }
288
+
170
289
  emit()
171
290
 
172
291
  return {
173
292
  update: (ranges) => {
174
293
  if (destroyed) return
175
- for (const slot of slots) {
176
- if (slot.done) continue
177
- if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) claim(slot)
294
+ lastRanges = ranges
295
+ const readable = readableTo(ranges)
296
+ if (indexedBytes < 0) indexedBytes = readable
297
+ else {
298
+ const grown = readable >= Math.max(indexedBytes * REINDEX_GROWTH, indexedBytes + REINDEX_MIN_BYTES)
299
+ // and one last walk when the file becomes whole, so a finished download is fully indexed
300
+ const whole = readable >= length && indexedBytes < length
301
+ if (grown || whole) reindexWanted = true
178
302
  }
303
+ claimReadable(ranges)
304
+ // claims first, so a walk that has to wait for a decode does not hold up the previews that
305
+ // the current index can already produce
306
+ pump()
179
307
  },
180
308
  prioritize: (time) => {
181
309
  if (destroyed) return
@@ -172,6 +172,8 @@ export const ControlBar = () => {
172
172
  const paused = usePlayer((state) => state.paused)
173
173
  const currentTime = usePlayer((state) => state.currentTime)
174
174
  const duration = usePlayer((state) => state.duration)
175
+ // a seek in flight reads as its destination, so the clock answers the click at once
176
+ const seekingTo = usePlayer((state) => state.seekingTo)
175
177
  const fullscreen = usePlayer((state) => state.fullscreen)
176
178
  const hideUI = usePlayer((state) => state.hideUI)
177
179
  const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
@@ -296,7 +298,7 @@ export const ControlBar = () => {
296
298
  />
297
299
  <Sound ref={setVolumeElement}/>
298
300
  <div className='time'>
299
- {formatMediaTime(currentTime, duration)}
301
+ {formatMediaTime(seekingTo ?? currentTime, duration)}
300
302
  </div>
301
303
  </div>
302
304
  <div className='right'>
@@ -115,6 +115,9 @@ export const Overlay = ({ onSubtitleRef }: { onSubtitleRef: (element: HTMLDivEle
115
115
  const size = usePlayer((state) => state.size)
116
116
  // video.js's own: readyState below HAVE_FUTURE_DATA while not paused
117
117
  const waiting = usePlayer((state) => state.waiting)
118
+ // a seek that has not presented its frame yet is a wait like any other, and the one most likely to
119
+ // be mistaken for the player having ignored the click
120
+ const seekingTo = usePlayer((state) => state.seekingTo)
118
121
 
119
122
  return (
120
123
  <>
@@ -129,11 +132,11 @@ export const Overlay = ({ onSubtitleRef }: { onSubtitleRef: (element: HTMLDivEle
129
132
  </div>
130
133
  )
131
134
  : undefined}
132
- {/* Two different waits, one spinner. With bytes it is pre-metadata rather than buffering: the
135
+ {/* Three waits, one spinner. With bytes it is pre-metadata rather than buffering: the
133
136
  store reports 0 both before metadata and for a genuinely unknown duration, so `size` is what
134
137
  tells whether a source was handed over at all. A media this player does not own has neither
135
138
  `size` nor `ready`, and reports the ordinary `waiting` every element does. */}
136
- {(size ? !ready : waiting) && !playbackError
139
+ {((size ? !ready : waiting) || seekingTo !== undefined) && !playbackError
137
140
  ? <div css={loadingStyle} />
138
141
  : undefined}
139
142
  {playbackError
@@ -246,6 +246,7 @@ export const ProgressBar = () => {
246
246
  const thumbnailAt = usePlayer((state) => state.thumbnailAt)
247
247
  const requestThumbnail = usePlayer((state) => state.requestThumbnail)
248
248
  const chapters = usePlayer((state) => state.chapters)
249
+ const seekingTo = usePlayer((state) => state.seekingTo)
249
250
 
250
251
  const progressBarRef = useRef<HTMLDivElement>(null)
251
252
 
@@ -393,11 +394,22 @@ export const ProgressBar = () => {
393
394
  player.seek(seekFraction * duration)
394
395
  }, [player, dragging, seekFraction, duration])
395
396
 
397
+ /*
398
+ * Where the bar is drawn, which is not always where the element is.
399
+ *
400
+ * Three sources, in the order they beat each other. A drag is the pointer's own position, so the
401
+ * bar tracks the finger exactly rather than trailing the element through a seek per move. A
402
+ * settled seek is its target, held until the element gets there. Everything else is the element.
403
+ *
404
+ * The point of the first two is that a seek takes a few hundred milliseconds to present a frame,
405
+ * and a bar that waits for it looks like it ignored the click.
406
+ */
396
407
  const scaleX = useMemo(() => {
397
- return !duration || typeof currentTime !== 'number'
398
- ? 0
399
- : currentTime / duration
400
- }, [duration, currentTime])
408
+ if (!duration) return 0
409
+ if (dragging && seekFraction !== undefined) return seekFraction
410
+ const at = seekingTo ?? currentTime
411
+ return typeof at === 'number' ? at / duration : 0
412
+ }, [duration, currentTime, dragging, seekFraction, seekingTo])
401
413
 
402
414
  const bounds = useMemo(() => segmentBounds(chapters, duration), [chapters, duration])
403
415
  const segmented = bounds.length > 0
@@ -32,6 +32,13 @@ const RESTART_SETTLED_MS = 60_000
32
32
  * this is the ceiling. Half a second is the point where a seek stops feeling like a seek.
33
33
  */
34
34
  const SEEK_PREPARE_BUDGET_MS = 500
35
+ /**
36
+ * How long the chrome may show a seek that has not landed, in ms.
37
+ *
38
+ * Only a backstop. The element firing `seeked` is what normally ends it, and a seek that never
39
+ * completes at all would otherwise leave the clock reading a time the picture never reached.
40
+ */
41
+ const SEEK_DISPLAY_LIMIT_MS = 15_000
35
42
 
36
43
  const messageOf = (error: unknown) =>
37
44
  error instanceof Error ? error.message : String(error)
@@ -212,6 +219,26 @@ export const usePlayback = (
212
219
  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)' : ''}`)
213
220
  }
214
221
  }
222
+ /*
223
+ * Say where the seek is going before anything has gone there.
224
+ *
225
+ * Everything below this line takes time the viewer is watching: the prepare, the element's own
226
+ * seek, and the decode from the preceding keyframe. Until all of it lands the element still
227
+ * reports the OLD position, so a chrome reading it directly sits still and looks broken. This is
228
+ * cleared by the element arriving, not by the prepare finishing, because the frame is what the
229
+ * viewer is actually waiting for.
230
+ */
231
+ player.setSourceState({ seekingTo: time })
232
+ const video = controller.videoElement
233
+ const settled = () => {
234
+ video?.removeEventListener('seeked', settled)
235
+ clearTimeout(giveUp)
236
+ player.setSourceState({ seekingTo: undefined })
237
+ }
238
+ // a seek the element never completes must not leave the clock stuck on a time it never reached
239
+ const giveUp = setTimeout(settled, SEEK_DISPLAY_LIMIT_MS)
240
+ video?.addEventListener('seeked', settled)
241
+
215
242
  const deadline = setTimeout(() => { movedBecause = 'deadline'; move() }, seekPrepareBudgetMs)
216
243
  void controller
217
244
  .prepareSeek(time)
@@ -174,6 +174,19 @@ export type SourceState = {
174
174
  /** Whether the engine has produced its first media segment. */
175
175
  ready: boolean
176
176
 
177
+ /**
178
+ * Where a seek is headed while the element has not arrived there yet, in seconds.
179
+ *
180
+ * The element's own `currentTime` does not move until it can present the frame, which on a long
181
+ * GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
182
+ * the clock sitting at the old position for that whole time, which reads as the player ignoring
183
+ * the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
184
+ * once and the spinner says the picture is still coming.
185
+ *
186
+ * Undefined whenever no seek is outstanding, which is almost always.
187
+ */
188
+ seekingTo?: number
189
+
177
190
  /**
178
191
  * The write seam, wired in `attach`. Only the React layer calls it.
179
192
  *