@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.
- package/README.md +62 -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-B8CKGIng.js → engine-DZrYu66u.js} +209 -164
- package/dist/index.js +34 -14
- 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/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/react/components/chrome.tsx +16 -3
- package/src/lib/react/components/overlay.tsx +27 -8
- 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/video-player.tsx +4 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
13
|
+
type SubtitleDialoguePart = { type: 'dialogue', streamIndex: number, index: number, assEvent: ASSEvent }
|
|
23
14
|
|
|
24
15
|
export type SubtitleRendererOptions = {
|
|
25
16
|
video: HTMLVideoElement
|
|
26
|
-
|
|
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
|
|
33
|
+
* The non-SIMD build, `jassub-worker.wasm`, for anything without WebAssembly SIMD.
|
|
32
34
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
Duration: (event.End - event.Start) * 1000,
|
|
133
145
|
ReadOrder: dialogueIndex,
|
|
134
|
-
|
|
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,
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -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,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,
|
|
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,
|
|
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
|
-
|
|
308
|
+
onSubtitleRef={setSubtitleLayer}
|
|
309
309
|
overlay={options.overlay}
|
|
310
310
|
controls={options.controls}
|
|
311
311
|
>
|