@banou/media-player 0.9.0 → 0.10.0

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.
@@ -9,4 +9,15 @@ export type UseSeekThumbnailsOptions = {
9
9
  /** When omitted the whole file is treated as readable, which is the case for a local file. */
10
10
  downloadedRanges?: DownloadedRange[];
11
11
  };
12
- export declare const useSeekThumbnails: ({ publicPath, workerUrl, length, read, downloadedRanges, }: UseSeekThumbnailsOptions) => ThumbnailImage[];
12
+ export type SeekThumbnails = {
13
+ thumbnails: ThumbnailImage[];
14
+ /**
15
+ * Where the viewer is pointing on the seekbar, so that preview is decoded next.
16
+ *
17
+ * Stable for the life of the hook, so it can be published to the store once and called from a
18
+ * pointermove without re-rendering anything. A no-op until the generator boots, and on a source
19
+ * that brings its own storyboard.
20
+ */
21
+ requestThumbnail: (time: number | undefined) => void;
22
+ };
23
+ export declare const useSeekThumbnails: ({ publicPath, workerUrl, length, read, downloadedRanges, }: UseSeekThumbnailsOptions) => SeekThumbnails;
@@ -5,6 +5,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
5
5
  indexes: import("..").MediaIndex[];
6
6
  thumbnails: import("..").ThumbnailImage[];
7
7
  thumbnailAt?: (time: number) => import("..").ThumbnailImage | undefined;
8
+ requestThumbnail: (time: number | undefined) => void;
8
9
  subtitleTracks: import("./source-feature").TrackChoice[];
9
10
  selectedSubtitleTrack: string | number | undefined;
10
11
  selectSubtitleTrack: (id: string | number | undefined) => void | Promise<void>;
@@ -76,6 +76,17 @@ export type SourceState = {
76
76
  * Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
77
77
  */
78
78
  thumbnailAt?: (time: number) => ThumbnailImage | undefined;
79
+ /**
80
+ * Where the pointer is on the seekbar, so the preview under it is generated before the rest.
81
+ *
82
+ * Generation otherwise walks the file start to end, so pointing at the last third of a long video
83
+ * means waiting out everything before it. Called on every pointermove, which is why it is a
84
+ * callback on the store rather than a piece of state: a hover time held in the store would
85
+ * re-render every subscriber for each move.
86
+ *
87
+ * A no-op on a source that brings its own storyboard, and until the generator has booted.
88
+ */
89
+ requestThumbnail: (time: number | undefined) => void;
79
90
  /**
80
91
  * Both selectors may answer with a promise, and the menu waits on it.
81
92
  *
@@ -163,6 +174,17 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
163
174
  * Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
164
175
  */
165
176
  thumbnailAt?: (time: number) => ThumbnailImage | undefined;
177
+ /**
178
+ * Where the pointer is on the seekbar, so the preview under it is generated before the rest.
179
+ *
180
+ * Generation otherwise walks the file start to end, so pointing at the last third of a long video
181
+ * means waiting out everything before it. Called on every pointermove, which is why it is a
182
+ * callback on the store rather than a piece of state: a hover time held in the store would
183
+ * re-render every subscriber for each move.
184
+ *
185
+ * A no-op on a source that brings its own storyboard, and until the generator has booted.
186
+ */
187
+ requestThumbnail: (time: number | undefined) => void;
166
188
  /**
167
189
  * Both selectors may answer with a promise, and the menu waits on it.
168
190
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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",
@@ -29,6 +29,17 @@ const KEYFRAME_TIMEOUT = 10_000
29
29
  export type ThumbnailGenerator = {
30
30
  /** Report which byte ranges are readable. Called with no argument when the whole file is. */
31
31
  update: (ranges?: [number, number][]) => void
32
+ /**
33
+ * Where the viewer is pointing, so that preview is decoded next. `undefined` when they stop.
34
+ *
35
+ * Only the slot covering `time` jumps the queue, and only for as long as it is still waiting, so
36
+ * this moves one preview forward rather than re-ordering the run. Everything behind it keeps the
37
+ * order it was claimed in and carries on the moment the jumped slot is done.
38
+ *
39
+ * It cannot interrupt a decode that has already started, so the wait is the tail of the one in
40
+ * flight and not the whole backlog.
41
+ */
42
+ prioritize: (time: number | undefined) => void
32
43
  destroy: () => void
33
44
  }
34
45
 
@@ -68,7 +79,35 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
68
79
 
69
80
  let thumbnails: ThumbnailImage[] = []
70
81
  let destroyed = false
71
- let queue = Promise.resolve()
82
+
83
+ /*
84
+ * Slots claimed for decoding but not yet started, in the order they were claimed.
85
+ *
86
+ * A promise chain used to be this queue, which fixed the running order at the moment each slot
87
+ * was chained on and left nothing a hover could reach: the preview under the pointer waited
88
+ * behind every slot already queued, which on a long file is the rest of the run. The order is
89
+ * the same, it is just held somewhere a pick can look into.
90
+ */
91
+ const pending: Slot[] = []
92
+ let running = false
93
+ /** Where the pointer is on the seekbar, or undefined when it is off it. */
94
+ let priorityTime: number | undefined
95
+
96
+ /*
97
+ * The slot to decode next: the one under the pointer when it is still waiting, else the oldest claim.
98
+ *
99
+ * Requiring the slot to COVER the time is what keeps this a single jump rather than a re-sort
100
+ * around the cursor. Once that slot is decoded nothing covers the pointer any more, so the very
101
+ * next pick is the oldest claim again and the sequential walk carries on where it left off.
102
+ */
103
+ const nextIndex = () => {
104
+ const at = priorityTime
105
+ if (at !== undefined) {
106
+ const hit = pending.findIndex(({ timestamp, endTime }) => timestamp <= at && at < endTime)
107
+ if (hit >= 0) return hit
108
+ }
109
+ return 0
110
+ }
72
111
 
73
112
  // the slider assumes a gapless storyboard, so gaps get sentinels the UI hides
74
113
  const emit = () => {
@@ -88,29 +127,44 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
88
127
  onThumbnails(display)
89
128
  }
90
129
 
91
- const generate = (slot: Slot) => {
92
- slot.done = true
93
- queue = queue
94
- .then(async () => {
95
- if (destroyed) return
96
- const png = await Promise.race([
97
- remuxer.readKeyframe(slot.timestamp),
98
- new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), KEYFRAME_TIMEOUT)),
99
- ])
100
- const bitmap = await createImageBitmap(new Blob([png], { type: 'image/png' }))
101
- const canvas = new OffscreenCanvas(width, Math.max(1, Math.round(bitmap.height * (width / bitmap.width))))
102
- canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
103
- bitmap.close()
104
- const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.7 })
105
- if (destroyed) return
106
- thumbnails = [...thumbnails, { url: URL.createObjectURL(blob), startTime: slot.timestamp, endTime: slot.endTime }]
107
- .sort((a, b) => a.startTime - b.startTime)
108
- emit()
109
- })
130
+ const decode = async (slot: Slot) => {
131
+ if (destroyed) return
132
+ const png = await Promise.race([
133
+ remuxer.readKeyframe(slot.timestamp),
134
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), KEYFRAME_TIMEOUT)),
135
+ ])
136
+ const bitmap = await createImageBitmap(new Blob([png], { type: 'image/png' }))
137
+ const canvas = new OffscreenCanvas(width, Math.max(1, Math.round(bitmap.height * (width / bitmap.width))))
138
+ canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
139
+ bitmap.close()
140
+ const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.7 })
141
+ if (destroyed) return
142
+ thumbnails = [...thumbnails, { url: URL.createObjectURL(blob), startTime: slot.timestamp, endTime: slot.endTime }]
143
+ .sort((a, b) => a.startTime - b.startTime)
144
+ emit()
145
+ }
146
+
147
+ // one decode at a time, because there is one wasm worker behind them all
148
+ const pump = () => {
149
+ if (running || destroyed || !pending.length) return
150
+ const slot = pending.splice(nextIndex(), 1)[0]!
151
+ running = true
152
+ void decode(slot)
110
153
  .catch(() => {
111
154
  slot.attempts += 1
155
+ // left claimable again, so a later `update` retries it
112
156
  slot.done = slot.attempts >= MAX_ATTEMPTS
113
157
  })
158
+ .finally(() => {
159
+ running = false
160
+ pump()
161
+ })
162
+ }
163
+
164
+ const claim = (slot: Slot) => {
165
+ slot.done = true
166
+ pending.push(slot)
167
+ pump()
114
168
  }
115
169
 
116
170
  emit()
@@ -120,11 +174,16 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
120
174
  if (destroyed) return
121
175
  for (const slot of slots) {
122
176
  if (slot.done) continue
123
- if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) generate(slot)
177
+ if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) claim(slot)
124
178
  }
125
179
  },
180
+ prioritize: (time) => {
181
+ if (destroyed) return
182
+ priorityTime = time
183
+ },
126
184
  destroy: () => {
127
185
  destroyed = true
186
+ pending.length = 0
128
187
  for (const t of thumbnails) URL.revokeObjectURL(t.url)
129
188
  thumbnails = []
130
189
  terminateRemuxer(remuxer)
@@ -194,12 +194,25 @@ export const ProgressBar = () => {
194
194
  const indexes = usePlayer((state) => state.indexes)
195
195
  const thumbnails = usePlayer((state) => state.thumbnails)
196
196
  const thumbnailAt = usePlayer((state) => state.thumbnailAt)
197
+ const requestThumbnail = usePlayer((state) => state.requestThumbnail)
197
198
 
198
199
  const progressBarRef = useRef<HTMLDivElement>(null)
199
200
 
200
201
  const [seekFraction, setSeekFraction] = useState<number | undefined>(undefined)
201
202
  const [progressBarHoverTime, setProgressBarOverTime] = useState<number | undefined>(undefined)
202
203
 
204
+ /*
205
+ * Move the preview and the generator's next pick together.
206
+ *
207
+ * The frame under the pointer is both the one drawn and the one worth decoding first, and every
208
+ * place that opens or closes the preview goes through here so the two can never disagree. The
209
+ * request is a bare assignment inside the generator, so a pointermove costs nothing extra.
210
+ */
211
+ const showPreviewAt = (time: number | undefined) => {
212
+ setProgressBarOverTime(time)
213
+ requestThumbnail(time)
214
+ }
215
+
203
216
  // onChange reports a bare fraction, so the device that opened the gesture is recorded on press
204
217
  const dragPointerType = useRef<string | undefined>(undefined)
205
218
 
@@ -235,7 +248,7 @@ export const ProgressBar = () => {
235
248
  }
236
249
  setSeekFraction(fraction)
237
250
  if (dragPointerType.current === 'mouse') return
238
- setProgressBarOverTime(fraction * duration)
251
+ showPreviewAt(fraction * duration)
239
252
  }
240
253
 
241
254
  const { dragging, handlers } = useDragValue({ ref: progressBarRef, onChange: onSeekDrag })
@@ -269,7 +282,7 @@ export const ProgressBar = () => {
269
282
  pressFraction.current = undefined
270
283
  // a lifted finger leaves nothing over the bar, so the preview it opened closes with it
271
284
  if (ev.pointerType === 'mouse') return
272
- setProgressBarOverTime(undefined)
285
+ showPreviewAt(undefined)
273
286
  }
274
287
 
275
288
  // offsetX is relative to whichever child is under the pointer, and a captured pointer has none
@@ -281,12 +294,12 @@ export const ProgressBar = () => {
281
294
  }
282
295
 
283
296
  const onProgressBarOver: DOMAttributes<HTMLDivElement>['onMouseMove'] = (ev) => {
284
- setProgressBarOverTime(timeAtClientX(ev.clientX))
297
+ showPreviewAt(timeAtClientX(ev.clientX))
285
298
  }
286
299
 
287
300
  const hideProgressBarTime = () => {
288
301
  if (!progressBarRef.current) return
289
- setProgressBarOverTime(undefined)
302
+ showPreviewAt(undefined)
290
303
  }
291
304
 
292
305
  // duration is 0 until metadata lands, which is never a divisor
@@ -1,7 +1,7 @@
1
1
  import type { ThumbnailGenerator, ThumbnailImage } from '../../engine'
2
2
  import type { DownloadedRange } from '../source-feature'
3
3
 
4
- import { useEffect, useMemo, useRef, useState } from 'react'
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
5
 
6
6
  import { createThumbnailGenerator } from '../../engine'
7
7
 
@@ -19,9 +19,21 @@ export type UseSeekThumbnailsOptions = {
19
19
  downloadedRanges?: DownloadedRange[]
20
20
  }
21
21
 
22
+ export type SeekThumbnails = {
23
+ thumbnails: ThumbnailImage[]
24
+ /**
25
+ * Where the viewer is pointing on the seekbar, so that preview is decoded next.
26
+ *
27
+ * Stable for the life of the hook, so it can be published to the store once and called from a
28
+ * pointermove without re-rendering anything. A no-op until the generator boots, and on a source
29
+ * that brings its own storyboard.
30
+ */
31
+ requestThumbnail: (time: number | undefined) => void
32
+ }
33
+
22
34
  export const useSeekThumbnails = ({
23
35
  publicPath, workerUrl, length, read, downloadedRanges,
24
- }: UseSeekThumbnailsOptions): ThumbnailImage[] => {
36
+ }: UseSeekThumbnailsOptions): SeekThumbnails => {
25
37
  const [thumbnails, setThumbnails] = useState<ThumbnailImage[]>([])
26
38
  const generatorRef = useRef<ThumbnailGenerator | null>(null)
27
39
  const readRef = useRef(read)
@@ -74,5 +86,9 @@ export const useSeekThumbnails = ({
74
86
 
75
87
  useEffect(() => { generatorRef.current?.update(ranges) }, [ranges])
76
88
 
77
- return thumbnails
89
+ const requestThumbnail = useCallback((time: number | undefined) => {
90
+ generatorRef.current?.prioritize(time)
91
+ }, [])
92
+
93
+ return { thumbnails, requestThumbnail }
78
94
  }
@@ -83,6 +83,17 @@ export type SourceState = {
83
83
  * Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
84
84
  */
85
85
  thumbnailAt?: (time: number) => ThumbnailImage | undefined
86
+ /**
87
+ * Where the pointer is on the seekbar, so the preview under it is generated before the rest.
88
+ *
89
+ * Generation otherwise walks the file start to end, so pointing at the last third of a long video
90
+ * means waiting out everything before it. Called on every pointermove, which is why it is a
91
+ * callback on the store rather than a piece of state: a hover time held in the store would
92
+ * re-render every subscriber for each move.
93
+ *
94
+ * A no-op on a source that brings its own storyboard, and until the generator has booted.
95
+ */
96
+ requestThumbnail: (time: number | undefined) => void
86
97
 
87
98
  /**
88
99
  * Both selectors may answer with a promise, and the menu waits on it.
@@ -168,6 +179,7 @@ export type SourceState = {
168
179
  const initialState: SourceState = {
169
180
  indexes: [],
170
181
  thumbnails: [],
182
+ requestThumbnail: () => {},
171
183
  subtitleTracks: [],
172
184
  selectedSubtitleTrack: undefined,
173
185
  selectSubtitleTrack: () => {},
@@ -237,7 +237,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
237
237
  // so there is nothing for them to attach to and nothing to guard at the call site.
238
238
  usePlayback(video, subtitleLayer, local)
239
239
 
240
- const generatedThumbnails = useSeekThumbnails({
240
+ const { thumbnails: generatedThumbnails, requestThumbnail } = useSeekThumbnails({
241
241
  publicPath,
242
242
  workerUrl: libavWorkerUrl,
243
243
  length: thumbnailsEnabled === false ? undefined : size,
@@ -264,12 +264,14 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
264
264
  setSourceState({
265
265
  thumbnails,
266
266
  thumbnailAt,
267
+ requestThumbnail,
267
268
  togglePictureInPicture,
268
269
  pictureInPictureMode,
269
270
  burnedInSubtitles,
270
271
  })
271
272
  }, [
272
- setSourceState, thumbnails, thumbnailAt, togglePictureInPicture, pictureInPictureMode, burnedInSubtitles,
273
+ setSourceState, thumbnails, thumbnailAt, requestThumbnail, togglePictureInPicture, pictureInPictureMode,
274
+ burnedInSubtitles,
273
275
  ])
274
276
 
275
277
  // A delegated track list writes the same store fields the engine writes, so the menus never learn