@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.
@@ -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)
@@ -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
- onCanvasRef: (element: HTMLCanvasElement | null) => void
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, onCanvasRef, overlay, controls, children }: ChromeProps) => {
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 onCanvasRef={onCanvasRef} />
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
- top: unset !important;
11
- left: unset !important;
12
- width: 100%;
13
- height: 100%;
14
- margin: auto;
15
- pointer-events: none;
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 = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElement | null) => void }) => {
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
- <canvas ref={onCanvasRef} css={style} />
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
- 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
@@ -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: HTMLCanvasElement | null,
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 || !canvas || !mode) return
38
+ if (!video || !subtitles || !mode) return
35
39
  const instance = createPictureInPicture({
36
40
  video,
37
- canvas,
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, canvas, mode])
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 an element and a canvas.
57
- return { toggle: video && canvas && mode ? toggle : null, mode, burnedIn }
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
- canvas: HTMLCanvasElement | null,
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 || !canvas || !size || !read) return
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
- canvasElement: canvas,
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, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
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): 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: () => {},
@@ -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 [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null)
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, canvas, local)
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, canvas)
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, 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
@@ -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
- onCanvasRef={setCanvas}
310
+ onSubtitleRef={setSubtitleLayer}
309
311
  overlay={options.overlay}
310
312
  controls={options.controls}
311
313
  >