@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.
- package/README.md +7 -0
- package/dist/engine/index.js +1 -1
- package/dist/engine/thumbnails.d.ts +11 -0
- package/dist/{engine-DZrYu66u.js → engine-YeMWShCv.js} +35 -19
- package/dist/index.js +204 -194
- package/dist/react/hooks/use-thumbnails.d.ts +12 -1
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +22 -0
- package/package.json +1 -1
- package/src/lib/engine/thumbnails.ts +80 -21
- package/src/lib/react/components/progress-bar.tsx +17 -4
- package/src/lib/react/hooks/use-thumbnails.ts +19 -3
- package/src/lib/react/source-feature.ts +12 -0
- package/src/lib/react/video-player.tsx +4 -2
|
@@ -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
|
|
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;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
+
showPreviewAt(timeAtClientX(ev.clientX))
|
|
285
298
|
}
|
|
286
299
|
|
|
287
300
|
const hideProgressBarTime = () => {
|
|
288
301
|
if (!progressBarRef.current) return
|
|
289
|
-
|
|
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):
|
|
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
|
-
|
|
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,
|
|
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
|