@banou/media-player 0.10.2 → 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.2",
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",
@@ -36,6 +36,15 @@ const KEYFRAME_TIMEOUT = 10_000
36
36
  const REINDEX_GROWTH = 1.5
37
37
  /** And never re-walk for a trickle, however early. */
38
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
39
48
 
40
49
  export type ThumbnailGenerator = {
41
50
  /** Report which byte ranges are readable. Called with no argument when the whole file is. */
@@ -236,7 +245,10 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
236
245
  const reindex = async (readable: number) => {
237
246
  reindexing = true
238
247
  try {
239
- const next = await remuxer.init()
248
+ const next = await Promise.race([
249
+ remuxer.init(),
250
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), REINDEX_TIMEOUT)),
251
+ ])
240
252
  if (destroyed) return
241
253
  indexedBytes = readable
242
254
  const rebuilt = buildSlots(next.indexes)
@@ -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
  *