@banou/media-player 0.8.21 → 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 +69 -19
- package/dist/engine/index.js +1 -1
- package/dist/engine/picture-in-picture.d.ts +9 -2
- package/dist/engine/playback.d.ts +8 -1
- package/dist/engine/subtitles.d.ts +16 -5
- package/dist/engine/thumbnails.d.ts +11 -0
- package/dist/{engine-B8CKGIng.js → engine-YeMWShCv.js} +243 -182
- package/dist/index.js +235 -205
- package/dist/react/components/chrome.d.ts +2 -2
- package/dist/react/components/overlay.d.ts +2 -2
- package/dist/react/hooks/use-picture-in-picture.d.ts +6 -1
- package/dist/react/hooks/use-playback.d.ts +1 -1
- 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 +3 -4
- package/src/lib/engine/picture-in-picture.ts +24 -9
- package/src/lib/engine/playback.ts +10 -3
- package/src/lib/engine/subtitles.ts +213 -61
- package/src/lib/engine/thumbnails.ts +80 -21
- package/src/lib/react/components/chrome.tsx +16 -3
- package/src/lib/react/components/overlay.tsx +27 -8
- package/src/lib/react/components/progress-bar.tsx +17 -4
- package/src/lib/react/hooks/use-picture-in-picture.ts +10 -6
- package/src/lib/react/hooks/use-playback.ts +4 -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 +8 -6
|
@@ -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)
|
|
@@ -49,6 +49,19 @@ const style = css`
|
|
|
49
49
|
z-index: 2;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/* The subtitle layer sits UNDER the title and the spinner, where the canvas itself used to sit.
|
|
53
|
+
As a plain child div it is matched by the rule above and raised to the overlay items' level,
|
|
54
|
+
which paints subtitles over the title's gradient, over the spinner and over the error text.
|
|
55
|
+
Naming the type as well is what takes it back: :not() carries the specificity of its argument,
|
|
56
|
+
so the rule above is (0,2,1) and a bare .subtitles at (0,2,0) loses to it whatever the source
|
|
57
|
+
order, while div.subtitles ties at (0,2,1) and second place in the same block then wins.
|
|
58
|
+
subtitle-layering.browser.test.tsx measures the computed value rather than trusting this. */
|
|
59
|
+
& > div.subtitles {
|
|
60
|
+
inset: 0;
|
|
61
|
+
z-index: 1;
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
canvas {
|
|
53
66
|
height: 100%;
|
|
54
67
|
width: 100%;
|
|
@@ -93,7 +106,7 @@ export type ChromeProps = {
|
|
|
93
106
|
ref?: Ref<HTMLDivElement> | ((element: HTMLDivElement | null) => void)
|
|
94
107
|
/** Absent means render no video element: the media belongs to someone else and arrives as children. */
|
|
95
108
|
onVideoRef?: (element: HTMLVideoElement | null) => void
|
|
96
|
-
|
|
109
|
+
onSubtitleRef: (element: HTMLDivElement | null) => void
|
|
97
110
|
/** The app's own content, over the video and outside the click-to-pause region, unlike `children`. */
|
|
98
111
|
overlay?: ReactNode
|
|
99
112
|
/** False draws no control bar at all, leaving the picture, the title and the overlay. */
|
|
@@ -101,7 +114,7 @@ export type ChromeProps = {
|
|
|
101
114
|
children?: ReactNode
|
|
102
115
|
}
|
|
103
116
|
|
|
104
|
-
export const Chrome = ({ ref, onVideoRef,
|
|
117
|
+
export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, children }: ChromeProps) => {
|
|
105
118
|
const player = usePlayer()
|
|
106
119
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
107
120
|
const setHideUI = usePlayer((state) => state.setHideUI)
|
|
@@ -207,7 +220,7 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
|
|
|
207
220
|
onMouseOut={onMouseOut}
|
|
208
221
|
className={hideUI ? 'hide' : ''}
|
|
209
222
|
>
|
|
210
|
-
<Overlay
|
|
223
|
+
<Overlay onSubtitleRef={onSubtitleRef} />
|
|
211
224
|
{overlayItems(overlay).map(({ key, item }) => (
|
|
212
225
|
<div
|
|
213
226
|
key={key}
|
|
@@ -6,13 +6,32 @@ import { css, keyframes } from '@emotion/react'
|
|
|
6
6
|
import { usePlayer } from '../player'
|
|
7
7
|
import { fonts } from '../../utils/fonts'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The subtitle layer.
|
|
11
|
+
*
|
|
12
|
+
* A container rather than the `<canvas>` this used to render. From jassub 2 the canvas belongs to
|
|
13
|
+
* the renderer: the constructor transfers it to a worker, which an element accepts exactly once for
|
|
14
|
+
* its whole life, and `destroy()` removes it from the document. React can own neither, and this
|
|
15
|
+
* pipeline is rebuilt in place on an audio track change and on an element recovery, so the canvas is
|
|
16
|
+
* created per jassub instance inside this box instead.
|
|
17
|
+
*
|
|
18
|
+
* The geometry is unchanged. The layer covers the picture and centres its child the way the chrome
|
|
19
|
+
* root centred the canvas directly, so jassub's inline pixel size still wins over the percentages
|
|
20
|
+
* and its inline `top` and `left` are still neutralised.
|
|
21
|
+
*/
|
|
9
22
|
const style = css`
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
display: flex;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
align-items: center;
|
|
26
|
+
|
|
27
|
+
canvas {
|
|
28
|
+
top: unset !important;
|
|
29
|
+
left: unset !important;
|
|
30
|
+
width: 100%;
|
|
31
|
+
height: 100%;
|
|
32
|
+
margin: auto;
|
|
33
|
+
pointer-events: none;
|
|
34
|
+
}
|
|
16
35
|
`
|
|
17
36
|
|
|
18
37
|
const titleStyle = css`
|
|
@@ -88,7 +107,7 @@ const errorMessage = (error: unknown) =>
|
|
|
88
107
|
? error.message
|
|
89
108
|
: typeof error === 'string' ? error : 'Playback failed'
|
|
90
109
|
|
|
91
|
-
export const Overlay = ({
|
|
110
|
+
export const Overlay = ({ onSubtitleRef }: { onSubtitleRef: (element: HTMLDivElement | null) => void }) => {
|
|
92
111
|
const title = usePlayer((state) => state.title)
|
|
93
112
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
94
113
|
const playbackError = usePlayer((state) => state.playbackError)
|
|
@@ -120,7 +139,7 @@ export const Overlay = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElem
|
|
|
120
139
|
{playbackError
|
|
121
140
|
? <div css={errorStyle}>{errorMessage(playbackError)}</div>
|
|
122
141
|
: undefined}
|
|
123
|
-
<
|
|
142
|
+
<div className="subtitles" ref={onSubtitleRef} css={style} />
|
|
124
143
|
</>
|
|
125
144
|
)
|
|
126
145
|
}
|
|
@@ -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
|
|
@@ -19,10 +19,14 @@ export type PictureInPicture = {
|
|
|
19
19
|
* hidden mirror. Where it does not, and the engine is Gecko, the same composite becomes the picture
|
|
20
20
|
* in the page so that the BROWSER'S own picture in picture control carries the subtitles with it,
|
|
21
21
|
* which it otherwise cannot: it takes a video element, and the subtitles live on a canvas above one.
|
|
22
|
+
*
|
|
23
|
+
* The second argument is the subtitle LAYER, not that canvas. From jassub 2 the canvas belongs to the
|
|
24
|
+
* renderer and is replaced on every pipeline rebuild, so only the layer lives long enough to hold.
|
|
22
25
|
*/
|
|
23
26
|
export const usePictureInPicture = (
|
|
24
27
|
video: HTMLVideoElement | null,
|
|
25
|
-
canvas:
|
|
28
|
+
/** The subtitle layer, not the canvas: see `PictureInPictureOptions.subtitles`. */
|
|
29
|
+
subtitles: HTMLElement | null,
|
|
26
30
|
): PictureInPicture => {
|
|
27
31
|
const controller = useRef<PictureInPictureController | null>(null)
|
|
28
32
|
const [burnedIn, setBurnedIn] = useState(false)
|
|
@@ -31,10 +35,10 @@ export const usePictureInPicture = (
|
|
|
31
35
|
const [mode] = useState<PictureInPictureMode | null>(() => pictureInPictureMode())
|
|
32
36
|
|
|
33
37
|
useEffect(() => {
|
|
34
|
-
if (!video || !
|
|
38
|
+
if (!video || !subtitles || !mode) return
|
|
35
39
|
const instance = createPictureInPicture({
|
|
36
40
|
video,
|
|
37
|
-
|
|
41
|
+
subtitles,
|
|
38
42
|
mode,
|
|
39
43
|
onBurnedInChange: setBurnedIn,
|
|
40
44
|
})
|
|
@@ -44,7 +48,7 @@ export const usePictureInPicture = (
|
|
|
44
48
|
controller.current = null
|
|
45
49
|
setBurnedIn(false)
|
|
46
50
|
}
|
|
47
|
-
}, [video,
|
|
51
|
+
}, [video, subtitles, mode])
|
|
48
52
|
|
|
49
53
|
const toggle = useCallback(() => {
|
|
50
54
|
void controller.current?.toggle().catch((error) => {
|
|
@@ -53,6 +57,6 @@ export const usePictureInPicture = (
|
|
|
53
57
|
}, [])
|
|
54
58
|
|
|
55
59
|
// null rather than a dead callback: the chrome hides the control instead of offering one that
|
|
56
|
-
// cannot work, and there is nothing to composite without both
|
|
57
|
-
return { toggle: video &&
|
|
60
|
+
// cannot work, and there is nothing to composite without both a video and a subtitle layer.
|
|
61
|
+
return { toggle: video && subtitles && mode ? toggle : null, mode, burnedIn }
|
|
58
62
|
}
|
|
@@ -64,7 +64,7 @@ const causeChain = (error: unknown) => {
|
|
|
64
64
|
*/
|
|
65
65
|
export const usePlayback = (
|
|
66
66
|
video: HTMLVideoElement | null,
|
|
67
|
-
|
|
67
|
+
subtitles: HTMLElement | null,
|
|
68
68
|
/** null when the media is remote: there are no bytes, so there is no pipeline to run. */
|
|
69
69
|
options: MediaPlayerLocalOptions | null,
|
|
70
70
|
) => {
|
|
@@ -227,7 +227,7 @@ export const usePlayback = (
|
|
|
227
227
|
}, [setSourceState, selectSubtitleTrack, selectAudioTrack, requestSeek])
|
|
228
228
|
|
|
229
229
|
useEffect(() => {
|
|
230
|
-
if (!video || !
|
|
230
|
+
if (!video || !subtitles || !size || !read) return
|
|
231
231
|
let cancelled = false
|
|
232
232
|
player.setSourceState({ playbackError: null, ready: false })
|
|
233
233
|
|
|
@@ -293,7 +293,7 @@ export const usePlayback = (
|
|
|
293
293
|
try {
|
|
294
294
|
const controller = await startPlayback({
|
|
295
295
|
videoElement: video,
|
|
296
|
-
|
|
296
|
+
subtitleContainer: subtitles,
|
|
297
297
|
read: (offset, length) => readRef.current!(offset, length),
|
|
298
298
|
length: size,
|
|
299
299
|
publicPath,
|
|
@@ -355,7 +355,7 @@ export const usePlayback = (
|
|
|
355
355
|
// identity. A streaming consumer passes a fresh closure on every state update, which is several
|
|
356
356
|
// times a second, and the restart loop reads as "Loading metadata" forever at a flat 0 B/s.
|
|
357
357
|
}, [
|
|
358
|
-
player, video,
|
|
358
|
+
player, video, subtitles, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
|
|
359
359
|
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay, restartToken,
|
|
360
360
|
])
|
|
361
361
|
}
|
|
@@ -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: () => {},
|
|
@@ -194,7 +194,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
194
194
|
const setContainer = useContainerAttach()
|
|
195
195
|
|
|
196
196
|
const [video, setVideo] = useState<HTMLVideoElement | null>(null)
|
|
197
|
-
const [
|
|
197
|
+
const [subtitleLayer, setSubtitleLayer] = useState<HTMLDivElement | null>(null)
|
|
198
198
|
|
|
199
199
|
// Attaching is not optional in either arm: the store installs `setSourceState` in `attach`, and
|
|
200
200
|
// video.js only runs attach once media is non-null, so skipping it would leave every write below a
|
|
@@ -235,9 +235,9 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
235
235
|
|
|
236
236
|
// Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
|
|
237
237
|
// so there is nothing for them to attach to and nothing to guard at the call site.
|
|
238
|
-
usePlayback(video,
|
|
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,
|
|
@@ -249,7 +249,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
249
249
|
toggle: togglePictureInPicture,
|
|
250
250
|
mode: pictureInPictureMode,
|
|
251
251
|
burnedIn: burnedInSubtitles,
|
|
252
|
-
} = usePictureInPicture(video,
|
|
252
|
+
} = usePictureInPicture(video, subtitleLayer)
|
|
253
253
|
|
|
254
254
|
// Subscribed rather than read off the store, because it is a no-op until the media element
|
|
255
255
|
// attaches: when attach swaps in the real setter the identity changes and these publish again.
|
|
@@ -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
|
|
@@ -305,7 +307,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
305
307
|
// No element in the remote arm: the media is somebody else's, and whatever renders it is
|
|
306
308
|
// passed in as children. Rendering an idle <video> here would sit over it.
|
|
307
309
|
onVideoRef={remote ? undefined : setVideo}
|
|
308
|
-
|
|
310
|
+
onSubtitleRef={setSubtitleLayer}
|
|
309
311
|
overlay={options.overlay}
|
|
310
312
|
controls={options.controls}
|
|
311
313
|
>
|