@banou/media-player 0.10.2 → 0.10.4

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.
@@ -16,6 +16,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
16
16
  selectAudioTrack: (id: string | number) => void | Promise<void>;
17
17
  hideUI: boolean;
18
18
  setHideUI: (hide: boolean) => void;
19
+ hasMedia: boolean;
19
20
  togglePictureInPicture: (() => void) | null;
20
21
  pictureInPictureMode: import("../engine").PictureInPictureMode | null;
21
22
  burnedInSubtitles: boolean;
@@ -23,6 +24,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
23
24
  playbackError: unknown;
24
25
  playbackErrors: import("./source-feature").PlaybackErrorEntry[];
25
26
  ready: boolean;
27
+ seekingTo?: number;
26
28
  setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
27
29
  }>]>>;
28
30
  /**
@@ -116,6 +116,19 @@ export type SourceState = {
116
116
  /** Chrome auto-hide. True means the controls, title and cursor are hidden. */
117
117
  hideUI: boolean;
118
118
  setHideUI: (hide: boolean) => void;
119
+ /**
120
+ * Whether there is anything loaded to play at all.
121
+ *
122
+ * Not the same question as `ready`, which is about a source that HAS been handed over and is still
123
+ * booting. This one separates that from having been given nothing, and the chrome needs the
124
+ * difference: idle-hiding with no media leaves a black rectangle with no controls, no title and no
125
+ * cursor, and no way back, since the only thing that reveals the chrome again is a pointer move over
126
+ * a player the viewer has no reason to think is there.
127
+ *
128
+ * Written by the React layer, which is the only place both arms are visible: bytes for a local
129
+ * source, an element for a media this player does not own.
130
+ */
131
+ hasMedia: boolean;
119
132
  /**
120
133
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
121
134
  *
@@ -159,6 +172,18 @@ export type SourceState = {
159
172
  playbackErrors: PlaybackErrorEntry[];
160
173
  /** Whether the engine has produced its first media segment. */
161
174
  ready: boolean;
175
+ /**
176
+ * Where a seek is headed while the element has not arrived there yet, in seconds.
177
+ *
178
+ * The element's own `currentTime` does not move until it can present the frame, which on a long
179
+ * GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
180
+ * the clock sitting at the old position for that whole time, which reads as the player ignoring
181
+ * the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
182
+ * once and the spinner says the picture is still coming.
183
+ *
184
+ * Undefined whenever no seek is outstanding, which is almost always.
185
+ */
186
+ seekingTo?: number;
162
187
  /**
163
188
  * The write seam, wired in `attach`. Only the React layer calls it.
164
189
  *
@@ -222,6 +247,19 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
222
247
  /** Chrome auto-hide. True means the controls, title and cursor are hidden. */
223
248
  hideUI: boolean;
224
249
  setHideUI: (hide: boolean) => void;
250
+ /**
251
+ * Whether there is anything loaded to play at all.
252
+ *
253
+ * Not the same question as `ready`, which is about a source that HAS been handed over and is still
254
+ * booting. This one separates that from having been given nothing, and the chrome needs the
255
+ * difference: idle-hiding with no media leaves a black rectangle with no controls, no title and no
256
+ * cursor, and no way back, since the only thing that reveals the chrome again is a pointer move over
257
+ * a player the viewer has no reason to think is there.
258
+ *
259
+ * Written by the React layer, which is the only place both arms are visible: bytes for a local
260
+ * source, an element for a media this player does not own.
261
+ */
262
+ hasMedia: boolean;
225
263
  /**
226
264
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
227
265
  *
@@ -265,6 +303,18 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
265
303
  playbackErrors: PlaybackErrorEntry[];
266
304
  /** Whether the engine has produced its first media segment. */
267
305
  ready: boolean;
306
+ /**
307
+ * Where a seek is headed while the element has not arrived there yet, in seconds.
308
+ *
309
+ * The element's own `currentTime` does not move until it can present the frame, which on a long
310
+ * GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
311
+ * the clock sitting at the old position for that whole time, which reads as the player ignoring
312
+ * the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
313
+ * once and the spinner says the picture is still coming.
314
+ *
315
+ * Undefined whenever no seek is outstanding, which is almost always.
316
+ */
317
+ seekingTo?: number;
268
318
  /**
269
319
  * The write seam, wired in `attach`. Only the React layer calls it.
270
320
  *
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.4",
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)
@@ -119,6 +119,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
119
119
  const player = usePlayer()
120
120
  const hideUI = usePlayer((state) => state.hideUI)
121
121
  const setHideUI = usePlayer((state) => state.setHideUI)
122
+ const hasMedia = usePlayer((state) => state.hasMedia)
122
123
  const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
123
124
  // a tap and a click mean different things, so the last pointer kind is remembered
124
125
  const lastPointerType = useRef<string>('mouse')
@@ -128,6 +129,27 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
128
129
 
129
130
  useEffect(() => () => clearTimeout(autoHide.current), [])
130
131
 
132
+ /*
133
+ * With nothing loaded the chrome never hides, and anything already hidden comes back.
134
+ *
135
+ * Hiding is only ever reversed by a pointer move over the player, which is a fair bet while a video
136
+ * is playing and a bad one when the box is empty: three seconds after mount the controls, the title
137
+ * and the cursor all go, leaving a black rectangle that gives the viewer no reason to think there is
138
+ * a player there to wave at. The effect covers the media going AWAY, which the guard below cannot:
139
+ * its timer may already be in flight, holding a `hasMedia` that was true when it was scheduled.
140
+ */
141
+ useEffect(() => {
142
+ if (hasMedia) return
143
+ clearTimeout(autoHide.current)
144
+ setHideUI(false)
145
+ }, [hasMedia, setHideUI])
146
+
147
+ /** The single door to hiding, so no path can bypass the empty-player case. */
148
+ const hide = () => {
149
+ if (!hasMedia) return
150
+ setHideUI(true)
151
+ }
152
+
131
153
  // The caller's ref still gets the element: video.js attaches the fullscreen container through it,
132
154
  // and this needs the same node to hit test against.
133
155
  const setRoot = (element: HTMLDivElement | null) => {
@@ -165,7 +187,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
165
187
  autoHide.current = setTimeout(hideUnlessTheMouseIsOnAControl, AUTO_HIDE_DELAY)
166
188
  return
167
189
  }
168
- setHideUI(true)
190
+ hide()
169
191
  }
170
192
 
171
193
  const reveal = () => {
@@ -196,7 +218,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
196
218
  if (hideUI) reveal()
197
219
  else {
198
220
  clearTimeout(autoHide.current)
199
- setHideUI(true)
221
+ hide()
200
222
  }
201
223
  }
202
224
 
@@ -209,7 +231,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
209
231
  const inside = ev.clientX >= left && ev.clientX < right && ev.clientY >= top && ev.clientY < bottom
210
232
  if (inside) return
211
233
  clearTimeout(autoHide.current)
212
- setHideUI(true)
234
+ hide()
213
235
  }
214
236
 
215
237
  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)
@@ -127,6 +127,20 @@ export type SourceState = {
127
127
  hideUI: boolean
128
128
  setHideUI: (hide: boolean) => void
129
129
 
130
+ /**
131
+ * Whether there is anything loaded to play at all.
132
+ *
133
+ * Not the same question as `ready`, which is about a source that HAS been handed over and is still
134
+ * booting. This one separates that from having been given nothing, and the chrome needs the
135
+ * difference: idle-hiding with no media leaves a black rectangle with no controls, no title and no
136
+ * cursor, and no way back, since the only thing that reveals the chrome again is a pointer move over
137
+ * a player the viewer has no reason to think is there.
138
+ *
139
+ * Written by the React layer, which is the only place both arms are visible: bytes for a local
140
+ * source, an element for a media this player does not own.
141
+ */
142
+ hasMedia: boolean
143
+
130
144
  /**
131
145
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
132
146
  *
@@ -174,6 +188,19 @@ export type SourceState = {
174
188
  /** Whether the engine has produced its first media segment. */
175
189
  ready: boolean
176
190
 
191
+ /**
192
+ * Where a seek is headed while the element has not arrived there yet, in seconds.
193
+ *
194
+ * The element's own `currentTime` does not move until it can present the frame, which on a long
195
+ * GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
196
+ * the clock sitting at the old position for that whole time, which reads as the player ignoring
197
+ * the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
198
+ * once and the spinner says the picture is still coming.
199
+ *
200
+ * Undefined whenever no seek is outstanding, which is almost always.
201
+ */
202
+ seekingTo?: number
203
+
177
204
  /**
178
205
  * The write seam, wired in `attach`. Only the React layer calls it.
179
206
  *
@@ -197,6 +224,7 @@ const initialState: SourceState = {
197
224
  selectAudioTrack: () => {},
198
225
  hideUI: false,
199
226
  setHideUI: () => {},
227
+ hasMedia: false,
200
228
  togglePictureInPicture: null,
201
229
  pictureInPictureMode: null,
202
230
  burnedInSubtitles: false,
@@ -269,9 +269,16 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
269
269
  // attaches: when attach swaps in the real setter the identity changes and these publish again.
270
270
  const setSourceState = usePlayer((state) => state.setSourceState)
271
271
 
272
+ /*
273
+ * Read here because this is the only place both arms are in scope: a local source is bytes, and a
274
+ * remote one is an element somebody else owns. `size` alone cannot answer it, since the remote arm
275
+ * legitimately has none, and the chrome would then treat every remote player as having no media.
276
+ */
277
+ const hasMedia = remote ? !!remote.media : size !== undefined
278
+
272
279
  useEffect(() => {
273
- setSourceState({ title, size, downloadedRanges })
274
- }, [setSourceState, title, size, downloadedRanges])
280
+ setSourceState({ title, size, downloadedRanges, hasMedia })
281
+ }, [setSourceState, title, size, downloadedRanges, hasMedia])
275
282
 
276
283
  const thumbnailAt = remote?.thumbnails?.at
277
284
  useEffect(() => {