@banou/media-player 0.8.21 → 0.9.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.
@@ -1,4 +1,4 @@
1
- import type { ASS_Event } from 'jassub'
1
+ import type { ASSEvent } from 'jassub/dist/worker/util'
2
2
  import type { Attachment, SubtitleFragment } from 'libav-wasm/build/worker'
3
3
 
4
4
  import JASSUB from 'jassub'
@@ -9,30 +9,32 @@ export type SubtitleStream = { streamIndex: number, title: string, language: str
9
9
  /** -1 turns subtitles off. It frees the track and matches no header, so nothing is set. */
10
10
  export const SUBTITLES_OFF = -1
11
11
 
12
- /**
13
- * jassub's `ASS_Event` types `Style` as a string, which the library it wraps does not agree with:
14
- * the field is written straight through to libass's `int Style`, an index into the track's style
15
- * list. Corrected once, here, so a name cannot end up in it again.
16
- */
17
- type StyledEvent = Omit<ASS_Event, 'Style'> & { Style: number }
18
-
19
- const createEvent = (jassub: JASSUB, event: StyledEvent) => jassub.createEvent(event as unknown as ASS_Event)
20
-
21
12
  type SubtitleHeaderPart = { type: 'header', streamIndex: number, content: string, eventsContent: string, styles: Map<string, number> }
22
- type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: StyledEvent }
13
+ type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: ASSEvent }
23
14
 
24
15
  export type SubtitleRendererOptions = {
25
16
  video: HTMLVideoElement
26
- canvas: HTMLCanvasElement
17
+ /**
18
+ * Where the subtitle surface is mounted.
19
+ *
20
+ * A container, not the canvas itself. From jassub 2 the canvas belongs to the renderer: the
21
+ * constructor transfers it to a worker, which an element accepts exactly once for its whole life,
22
+ * and `destroy()` removes it from the document. Neither is survivable for an element someone else
23
+ * owns, and this pipeline is rebuilt in place on an audio track change and on an element recovery.
24
+ * So one canvas is created here per jassub instance, inside this container.
25
+ *
26
+ * The live canvas is `container.querySelector('canvas')`, and it is replaced on every rebuild.
27
+ */
28
+ container: HTMLElement
27
29
  workerUrl: string
28
30
  /** The SIMD build, `jassub-worker-modern.wasm`. Used wherever WebAssembly SIMD is available. */
29
31
  wasmUrl: string
30
32
  /**
31
- * The non-SIMD build, `jassub-worker.wasm`, for Safari before 16.4 and anything else without SIMD.
33
+ * The non-SIMD build, `jassub-worker.wasm`, for anything without WebAssembly SIMD.
32
34
  *
33
- * Not optional in practice, only in the type. jassub picks `wasmUrl ?? 'jassub-worker.wasm'` when SIMD
34
- * is missing, and that bare relative name resolves against the blob: url the worker is built from,
35
- * which throws. So leaving this unset does not degrade to the slower build, it fails outright.
35
+ * Optional in practice as well as in the type, unlike under jassub 1: v2 defaults both wasm urls to
36
+ * its own files resolved against `import.meta.url`, so leaving this unset degrades to whatever the
37
+ * bundler emitted rather than failing outright.
36
38
  */
37
39
  legacyWasmUrl?: string
38
40
  /**
@@ -48,10 +50,10 @@ export type SubtitleRendererOptions = {
48
50
  /**
49
51
  * A url the WORKER will fetch, resolved against the document first.
50
52
  *
51
- * jassub's worker is built from a `blob:` url, so a relative url handed to it resolves against that
52
- * blob inside the worker and the fetch never lands. For the wasm that fails loudly. For the fallback
53
- * font it fails in SILENCE: libass is left with no face to draw with, so the track renders nothing at
54
- * all and the picture simply has no subtitles on it, with no error anywhere to say why.
53
+ * A relative url handed to the worker resolves against the worker's own url inside it, which is not
54
+ * the document's. For the wasm that fails loudly. For the fallback font it fails in SILENCE: libass
55
+ * is left with no face to draw with, so the track renders nothing at all and the picture simply has
56
+ * no subtitles on it, with no error anywhere to say why.
55
57
  *
56
58
  * `workerUrl` is deliberately not passed through here. `new Worker()` is called on the main thread,
57
59
  * where a relative url already resolves against the document.
@@ -78,9 +80,11 @@ const headerStyles = (content: string) =>
78
80
  /**
79
81
  * The index libass will resolve this event's style to.
80
82
  *
81
- * jassub types `ASS_Event.Style` as a string and writes it straight through to an `int`, so handing
82
- * it a style NAME stores 0, which is libass's own default: Arial at size 18, with margins and a drop
83
- * shadow of its own. Subtitles still appear, in a face and a size the file never asked for.
83
+ * `ASSEvent.Style` is written straight through to an `int`, so handing it a style NAME stores 0,
84
+ * which is libass's own default: Arial at size 18, with margins and a drop shadow of its own.
85
+ * Subtitles still appear, in a face and a size the file never asked for. jassub 1 typed the field as
86
+ * a string, which is what made that easy to do; jassub 2 types it `number` and agrees with libass,
87
+ * but the resolution still has to happen somewhere and this is it.
84
88
  *
85
89
  * Falling back to the header's own "Default", then to 0, is what libass does for a name it cannot
86
90
  * find. The leading-`*` strip and the case fold on "Default" are its normalisation, kept so a
@@ -123,23 +127,30 @@ const toDialoguePart = (header: SubtitleHeaderPart, fragment: SubtitleFragment &
123
127
  streamIndex: fragment.streamIndex,
124
128
  index: dialogueIndex,
125
129
  assEvent: {
126
- ...event,
130
+ Name: event.Name ?? '',
131
+ // libass wants the raw effect field and ass-compiler has already destructured it into an
132
+ // object, so only the name survives the round trip. That is parity rather than a regression:
133
+ // jassub 1 was handed the object itself, which reached libass's `char*` as "[object Object]",
134
+ // and libass matches an effect by a "Banner;" or "Scroll up;" prefix, so neither form has ever
135
+ // selected one.
136
+ Effect: event.Effect?.name ?? '',
137
+ Layer: event.Layer,
138
+ MarginL: event.MarginL,
139
+ MarginR: event.MarginR,
140
+ MarginV: event.MarginV,
127
141
  Style: styleIndex(header, event.Style),
128
- Effect: event.Effect ?? '',
129
142
  Text: event.Text.raw,
130
- Duration: (event.End - event.Start) * 1000,
131
143
  Start: event.Start * 1000,
132
- End: event.End * 1000,
144
+ Duration: (event.End - event.Start) * 1000,
133
145
  ReadOrder: dialogueIndex,
134
- _index: dialogueIndex,
135
- } as StyledEvent,
146
+ },
136
147
  }
137
148
  }
138
149
 
139
150
  export type SubtitleRenderer = ReturnType<typeof createSubtitleRenderer>
140
151
 
141
152
  export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
142
- const { video, canvas, workerUrl } = options
153
+ const { video, container, workerUrl } = options
143
154
  const wasmUrl = workerFetched(options.wasmUrl)!
144
155
  const legacyWasmUrl = workerFetched(options.legacyWasmUrl)
145
156
  const defaultFontUrl = workerFetched(options.defaultFontUrl)
@@ -150,31 +161,157 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
150
161
  const dialogues = new Map<number, Map<number, SubtitleDialoguePart>>()
151
162
  let selected: number | undefined
152
163
  let onStreams = options.onStreams
164
+ /**
165
+ * Terminal, unlike a null `jassub`.
166
+ *
167
+ * `pushFragments` boots on the first header it sees and tests `!jassub` to decide, so a fragment
168
+ * that lands after teardown (a demuxer read resolving into a torn-down pipeline, which is ordinary
169
+ * on the `./engine` surface) would start a SECOND worker with a second canvas inside a container
170
+ * nobody is watching any more. The live canvas is found with `querySelector`, so the stale one
171
+ * would win and picture in picture would composite a surface that is never painted again.
172
+ */
173
+ let destroyed = false
174
+
175
+ const rendererFailed = (error: unknown) => {
176
+ console.warn('the subtitle renderer failed; the file plays without subtitles', error)
177
+ }
178
+
179
+ /**
180
+ * Run something against the worker, once there is a worker to run it against.
181
+ *
182
+ * `instance.renderer` is assigned by the promise `ready` resolves, so nothing can be called before
183
+ * then and a call made too early throws on `undefined`. Ordering after that is free rather than
184
+ * something this has to arrange: abslink posts each call synchronously from its proxy's `apply`
185
+ * trap, and the worker runs the method synchronously in its own message handler, so calls land in
186
+ * the order they were made whether or not each one is awaited. Reactions registered on one
187
+ * already-settled promise also run in registration order, so this preserves it too.
188
+ *
189
+ * The identity check is what stops a teardown that happened during the boot from talking to a
190
+ * worker that is on its way out.
191
+ *
192
+ * `run` RETURNS its calls rather than dropping them. Every proxied call is a promise that rejects
193
+ * when the worker throws, which libass does on a header it will not take and once it is over its
194
+ * glyph or memory limit, and a dropped one surfaces as a bare `unhandledrejection` in the host app
195
+ * with nothing naming subtitles in it.
196
+ */
197
+ const onRenderer = (run: (renderer: JASSUB['renderer']) => unknown) => {
198
+ const instance = jassub
199
+ if (!instance) return
200
+ void instance.ready
201
+ .then(() => (jassub === instance ? run(instance.renderer) : undefined))
202
+ .catch(rendererFailed)
203
+ }
153
204
 
154
- const tick = setInterval(() => {
155
- jassub?.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
156
- }, 100)
205
+ let repaintQueued = false
206
+ /**
207
+ * Ask for one frame.
208
+ *
209
+ * jassub 2 draws only from `requestVideoFrameCallback`, so a PAUSED video presents no frames and
210
+ * nothing repaints. Anything that changes what libass would draw has to ask for a frame itself, or
211
+ * a track switch and a seek while paused both leave the previous line on screen until playback
212
+ * resumes. jassub 1 needed none of this because the 100ms `setCurrentTime` tick this replaces
213
+ * redrew unconditionally.
214
+ *
215
+ * Coalesced through a microtask because events arrive in bursts of hundreds and one frame covers
216
+ * the whole burst. The burst is synchronous, so the microtask always runs after all of it, and a
217
+ * playing video therefore costs at most one extra draw per burst.
218
+ */
219
+ const repaint = () => {
220
+ if (repaintQueued) return
221
+ repaintQueued = true
222
+ queueMicrotask(() => {
223
+ repaintQueued = false
224
+ const instance = jassub
225
+ // `videoWidth` is 0 until metadata arrives, and asking jassub to draw then sizes its surface to
226
+ // nothing. Nothing else is required: this deliberately does NOT skip the draw when the element
227
+ // is playing. "Playing" is not "presenting frames", and the gap between them is exactly where
228
+ // subtitles get stuck. A torrent-backed source whose SourceBuffer runs dry keeps `paused` false
229
+ // while presenting nothing, so a viewer who turns subtitles off mid-stall would watch the line
230
+ // stay on screen until the buffer refilled. One forced draw per change is far cheaper than
231
+ // reasoning about which stalls count.
232
+ if (!instance || !video.videoWidth) return
233
+ void instance.ready
234
+ .then(() => {
235
+ if (jassub !== instance) return undefined
236
+ return instance.manualRender({
237
+ expectedDisplayTime: performance.now(),
238
+ width: video.videoWidth,
239
+ height: video.videoHeight,
240
+ mediaTime: video.currentTime,
241
+ }, true)
242
+ })
243
+ .catch(rendererFailed)
244
+ })
245
+ }
157
246
 
158
- const onRateChange = () => jassub?.setRate(video.playbackRate)
247
+ /**
248
+ * The element events that change what should be on screen without presenting a frame.
249
+ *
250
+ * `loadedmetadata` is the one that is easy to miss and fatal to leave out: the first subtitle
251
+ * fragments arrive BEFORE the element has parsed metadata, so the repaint they ask for is skipped
252
+ * for having no `videoWidth` to size a surface from, and if nothing asks again a paused player
253
+ * shows no subtitles at all. `seeked` covers scrubbing while paused, and `pause` covers landing on
254
+ * a frame whose line arrived after it was presented.
255
+ *
256
+ * Trimming this list was tried and reverted. `['loadedmetadata']` alone fails
257
+ * `subtitle-paused-repaint.browser.test.tsx`'s turn-off case four runs out of four, while adding
258
+ * back EITHER of the other two passes. Neither of those events fires in that test, so what the
259
+ * extra listener buys is timing rather than a trigger, and the honest conclusion is that the clear
260
+ * after a `freeTrack` is more delicate than it looks. Do not shorten this list without running that
261
+ * case several times.
262
+ */
263
+ const REPAINT_ON = ['loadedmetadata', 'seeked', 'pause'] as const
264
+ const onRepaintEvent = () => repaint()
159
265
 
160
266
  const bootJassub = (header: SubtitleHeaderPart) => {
161
- jassub = new JASSUB({
162
- onDemandRender: false,
163
- video,
164
- canvas,
165
- subContent: header.content,
166
- workerUrl,
167
- modernWasmUrl: wasmUrl,
168
- ...legacyWasmUrl ? { wasmUrl: legacyWasmUrl } : {},
169
- fonts: attachments.map(([, data]) => data),
170
- availableFonts: {
171
- ...Object.fromEntries(attachments),
172
- ...(defaultFontUrl ? { 'liberation sans': defaultFontUrl } : {}),
173
- },
174
- })
175
- // jassub 1.8.x binds setRate as the ratechange listener, so the Event becomes the rate
176
- video.removeEventListener('ratechange', (jassub as unknown as { _boundSetRate: EventListener })._boundSetRate)
177
- video.addEventListener('ratechange', onRateChange)
267
+ const canvas = document.createElement('canvas')
268
+ container.append(canvas)
269
+ try {
270
+ jassub = new JASSUB({
271
+ video,
272
+ canvas,
273
+ subContent: header.content,
274
+ workerUrl,
275
+ modernWasmUrl: wasmUrl,
276
+ ...legacyWasmUrl ? { wasmUrl: legacyWasmUrl } : {},
277
+ fonts: attachments.map(([, data]) => data),
278
+ availableFonts: {
279
+ ...Object.fromEntries(attachments),
280
+ ...(defaultFontUrl ? { 'liberation sans': defaultFontUrl } : {}),
281
+ },
282
+ // jassub 2 defaults this to 'local', which asks the browser for the local-fonts permission
283
+ // the first time libass reports a face it does not have. The container ships its own fonts
284
+ // and a player has no business raising that prompt.
285
+ queryFonts: false,
286
+ })
287
+ } catch (error) {
288
+ canvas.remove()
289
+ rendererFailed(error)
290
+ return
291
+ }
292
+ // A boot that fails worker-side rejects `ready`, and jassub's own ResizeObserver awaits `ready`
293
+ // on every layout change, so an instance left in that state throws a fresh unhandled rejection
294
+ // for the rest of the session.
295
+ void jassub.ready.catch(rendererFailed)
296
+ for (const type of REPAINT_ON) video.addEventListener(type, onRepaintEvent)
297
+ /*
298
+ * The default face is the one font jassub loads LAZILY. Attachments are handed to the constructor
299
+ * and `ready` waits for them, but `availableFonts` is consulted only when libass reports a
300
+ * missing face part way through a draw, and the fetch that follows lands after the frame that
301
+ * needed it. Driven by rVFC the next frame picks it up and the cost is one frame of unstyled
302
+ * text; PAUSED there is no next frame, so the first line stays unpainted until the viewer presses
303
+ * play. Measured as a RACE rather than a certainty: three runs of subtitle-header.browser.test.tsx
304
+ * against one repaint scored two blank and one painted.
305
+ *
306
+ * `addFonts` is the fix because it is a signal rather than a delay: it resolves when the face is
307
+ * in libass, which is the moment a repaint is worth asking for.
308
+ */
309
+ if (defaultFontUrl) {
310
+ onRenderer((renderer) => {
311
+ void Promise.resolve(renderer.addFonts([defaultFontUrl])).then(repaint, rendererFailed)
312
+ })
313
+ }
314
+ repaint()
178
315
  }
179
316
 
180
317
  const pushAttachments = (incoming: Attachment[]) => {
@@ -182,6 +319,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
182
319
  }
183
320
 
184
321
  const pushFragments = (fragments: SubtitleFragment[]) => {
322
+ const incoming: ASSEvent[] = []
185
323
  for (const fragment of fragments) {
186
324
  if (fragment.type === 'header') {
187
325
  if (headers.has(fragment.streamIndex)) continue
@@ -191,7 +329,7 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
191
329
  streams.push({ streamIndex: fragment.streamIndex, title: fragment.title, language: fragment.language })
192
330
  onStreams?.([...streams])
193
331
  if (selected === undefined) selected = fragment.streamIndex
194
- if (!jassub) bootJassub(header)
332
+ if (!jassub && !destroyed) bootJassub(header)
195
333
  } else {
196
334
  const header = headers.get(fragment.streamIndex)
197
335
  if (!header) continue
@@ -200,21 +338,31 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
200
338
  const part = toDialoguePart(header, fragment)
201
339
  if (byIndex.has(part.index)) continue
202
340
  byIndex.set(part.index, part)
203
- if (selected === fragment.streamIndex && jassub) createEvent(jassub, part.assEvent)
341
+ if (selected === fragment.streamIndex) incoming.push(part.assEvent)
204
342
  }
205
343
  }
344
+ if (!incoming.length) return
345
+ // one hop for the whole burst rather than one per event
346
+ onRenderer((renderer) => Promise.all(incoming.map((event) => renderer.createEvent(event))))
347
+ repaint()
206
348
  }
207
349
 
208
350
  const selectStream = (streamIndex: number | undefined) => {
209
351
  const next = streamIndex ?? SUBTITLES_OFF
210
352
  if (next === selected || !jassub) return
211
353
  selected = next
212
- jassub.freeTrack()
213
354
  const header = headers.get(next)
214
- if (!header) return
215
- jassub.setTrack(header.content)
216
- for (const part of dialogues.get(next)?.values() ?? []) createEvent(jassub, part.assEvent)
217
- jassub.setCurrentTime(video.paused, video.currentTime, video.playbackRate)
355
+ const events = [...dialogues.get(next)?.values() ?? []].map((part) => part.assEvent)
356
+ onRenderer((renderer) => {
357
+ const calls: unknown[] = [renderer.freeTrack()]
358
+ if (header) {
359
+ calls.push(renderer.setTrack(header.content))
360
+ for (const event of events) calls.push(renderer.createEvent(event))
361
+ }
362
+ return Promise.all(calls)
363
+ })
364
+ // outside the callback so that turning subtitles OFF while paused clears the picture too
365
+ repaint()
218
366
  }
219
367
 
220
368
  return {
@@ -225,10 +373,14 @@ export const createSubtitleRenderer = (options: SubtitleRendererOptions) => {
225
373
  getSelectedStream: () => selected,
226
374
  setOnStreams: (cb: (streams: SubtitleStream[]) => void) => { onStreams = cb },
227
375
  destroy: () => {
228
- clearInterval(tick)
229
- video.removeEventListener('ratechange', onRateChange)
230
- jassub?.destroy()
376
+ destroyed = true
377
+ const instance = jassub
231
378
  jassub = undefined
379
+ for (const type of REPAINT_ON) video.removeEventListener(type, onRepaintEvent)
380
+ // jassub removes its own canvas at the top of destroy(), before it awaits anything, so the
381
+ // element is gone synchronously. The rest is a worker round trip that a dead worker never
382
+ // answers, which is why nothing here waits for it.
383
+ void instance?.destroy().catch(() => {})
232
384
  },
233
385
  }
234
386
  }
@@ -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
  }
@@ -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
  }
@@ -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,7 +235,7 @@ 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
240
  const generatedThumbnails = useSeekThumbnails({
241
241
  publicPath,
@@ -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.
@@ -305,7 +305,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
305
305
  // No element in the remote arm: the media is somebody else's, and whatever renders it is
306
306
  // passed in as children. Rendering an idle <video> here would sit over it.
307
307
  onVideoRef={remote ? undefined : setVideo}
308
- onCanvasRef={setCanvas}
308
+ onSubtitleRef={setSubtitleLayer}
309
309
  overlay={options.overlay}
310
310
  controls={options.controls}
311
311
  >