@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.
@@ -3,12 +3,12 @@ export type ChromeProps = {
3
3
  ref?: Ref<HTMLDivElement> | ((element: HTMLDivElement | null) => void);
4
4
  /** Absent means render no video element: the media belongs to someone else and arrives as children. */
5
5
  onVideoRef?: (element: HTMLVideoElement | null) => void;
6
- onCanvasRef: (element: HTMLCanvasElement | null) => void;
6
+ onSubtitleRef: (element: HTMLDivElement | null) => void;
7
7
  /** The app's own content, over the video and outside the click-to-pause region, unlike `children`. */
8
8
  overlay?: ReactNode;
9
9
  /** False draws no control bar at all, leaving the picture, the title and the overlay. */
10
10
  controls?: boolean;
11
11
  children?: ReactNode;
12
12
  };
13
- export declare const Chrome: ({ ref, onVideoRef, onCanvasRef, overlay, controls, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
13
+ export declare const Chrome: ({ ref, onVideoRef, onSubtitleRef, overlay, controls, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
14
14
  export default Chrome;
@@ -1,4 +1,4 @@
1
- export declare const Overlay: ({ onCanvasRef }: {
2
- onCanvasRef: (element: HTMLCanvasElement | null) => void;
1
+ export declare const Overlay: ({ onSubtitleRef }: {
2
+ onSubtitleRef: (element: HTMLDivElement | null) => void;
3
3
  }) => import("@emotion/react/jsx-runtime").JSX.Element;
4
4
  export default Overlay;
@@ -13,5 +13,10 @@ export type PictureInPicture = {
13
13
  * hidden mirror. Where it does not, and the engine is Gecko, the same composite becomes the picture
14
14
  * in the page so that the BROWSER'S own picture in picture control carries the subtitles with it,
15
15
  * which it otherwise cannot: it takes a video element, and the subtitles live on a canvas above one.
16
+ *
17
+ * The second argument is the subtitle LAYER, not that canvas. From jassub 2 the canvas belongs to the
18
+ * renderer and is replaced on every pipeline rebuild, so only the layer lives long enough to hold.
16
19
  */
17
- export declare const usePictureInPicture: (video: HTMLVideoElement | null, canvas: HTMLCanvasElement | null) => PictureInPicture;
20
+ export declare const usePictureInPicture: (video: HTMLVideoElement | null,
21
+ /** The subtitle layer, not the canvas: see `PictureInPictureOptions.subtitles`. */
22
+ subtitles: HTMLElement | null) => PictureInPicture;
@@ -3,6 +3,6 @@ import type { MediaPlayerLocalOptions } from '../video-player';
3
3
  * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
4
4
  * discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
5
5
  */
6
- export declare const usePlayback: (video: HTMLVideoElement | null, canvas: HTMLCanvasElement | null,
6
+ export declare const usePlayback: (video: HTMLVideoElement | null, subtitles: HTMLElement | null,
7
7
  /** null when the media is remote: there are no bytes, so there is no pipeline to run. */
8
8
  options: MediaPlayerLocalOptions | null) => void;
@@ -9,4 +9,15 @@ export type UseSeekThumbnailsOptions = {
9
9
  /** When omitted the whole file is treated as readable, which is the case for a local file. */
10
10
  downloadedRanges?: DownloadedRange[];
11
11
  };
12
- export declare const useSeekThumbnails: ({ publicPath, workerUrl, length, read, downloadedRanges, }: UseSeekThumbnailsOptions) => ThumbnailImage[];
12
+ export type SeekThumbnails = {
13
+ thumbnails: ThumbnailImage[];
14
+ /**
15
+ * Where the viewer is pointing on the seekbar, so that preview is decoded next.
16
+ *
17
+ * Stable for the life of the hook, so it can be published to the store once and called from a
18
+ * pointermove without re-rendering anything. A no-op until the generator boots, and on a source
19
+ * that brings its own storyboard.
20
+ */
21
+ requestThumbnail: (time: number | undefined) => void;
22
+ };
23
+ export declare const useSeekThumbnails: ({ publicPath, workerUrl, length, read, downloadedRanges, }: UseSeekThumbnailsOptions) => SeekThumbnails;
@@ -5,6 +5,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
5
5
  indexes: import("..").MediaIndex[];
6
6
  thumbnails: import("..").ThumbnailImage[];
7
7
  thumbnailAt?: (time: number) => import("..").ThumbnailImage | undefined;
8
+ requestThumbnail: (time: number | undefined) => void;
8
9
  subtitleTracks: import("./source-feature").TrackChoice[];
9
10
  selectedSubtitleTrack: string | number | undefined;
10
11
  selectSubtitleTrack: (id: string | number | undefined) => void | Promise<void>;
@@ -76,6 +76,17 @@ export type SourceState = {
76
76
  * Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
77
77
  */
78
78
  thumbnailAt?: (time: number) => ThumbnailImage | undefined;
79
+ /**
80
+ * Where the pointer is on the seekbar, so the preview under it is generated before the rest.
81
+ *
82
+ * Generation otherwise walks the file start to end, so pointing at the last third of a long video
83
+ * means waiting out everything before it. Called on every pointermove, which is why it is a
84
+ * callback on the store rather than a piece of state: a hover time held in the store would
85
+ * re-render every subscriber for each move.
86
+ *
87
+ * A no-op on a source that brings its own storyboard, and until the generator has booted.
88
+ */
89
+ requestThumbnail: (time: number | undefined) => void;
79
90
  /**
80
91
  * Both selectors may answer with a promise, and the menu waits on it.
81
92
  *
@@ -163,6 +174,17 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
163
174
  * Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
164
175
  */
165
176
  thumbnailAt?: (time: number) => ThumbnailImage | undefined;
177
+ /**
178
+ * Where the pointer is on the seekbar, so the preview under it is generated before the rest.
179
+ *
180
+ * Generation otherwise walks the file start to end, so pointing at the last third of a long video
181
+ * means waiting out everything before it. Called on every pointermove, which is why it is a
182
+ * callback on the store rather than a piece of state: a hover time held in the store would
183
+ * re-render every subscriber for each move.
184
+ *
185
+ * A no-op on a source that brings its own storyboard, and until the generator has booted.
186
+ */
187
+ requestThumbnail: (time: number | undefined) => void;
166
188
  /**
167
189
  * Both selectors may answer with a promise, and the menu waits on it.
168
190
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.21",
3
+ "version": "0.10.0",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,9 +34,8 @@
34
34
  "build-lib": "shx rm -rf dist/ && vp build -c vite.lib.config.ts && npm run types",
35
35
  "prepublishOnly": "npm run build-lib",
36
36
  "types": "tsc -p tsconfig.lib.json",
37
- "copy-assets": "shx mkdir -p public && npm run copy-libav && npm run copy-libass",
37
+ "copy-assets": "shx mkdir -p public && npm run copy-libav",
38
38
  "copy-libav": "shx cp node_modules/libav-wasm/build/worker.js public/libav-worker.js && shx cp node_modules/libav-wasm/build/libav.wasm public/libav.wasm && shx cp node_modules/libav-wasm/build/libav-jspi.wasm public/libav-jspi.wasm",
39
- "copy-libass": "shx cp node_modules/jassub/dist/jassub-worker.js public/jassub-worker.js && shx cp node_modules/jassub/dist/jassub-worker.wasm public/jassub-worker.wasm && shx cp node_modules/jassub/dist/jassub-worker-modern.wasm public/jassub-worker-modern.wasm && shx cp node_modules/jassub/dist/default.woff2 public/default.woff2",
40
39
  "test": "npm run fixtures && vp test run",
41
40
  "fixtures": "node scripts/fixture.mjs"
42
41
  },
@@ -45,7 +44,7 @@
45
44
  "@videojs/core": "10.0.0-beta.26",
46
45
  "@videojs/react": "10.0.0-beta.26",
47
46
  "ass-compiler": "^0.1.16",
48
- "jassub": "^1.8.6",
47
+ "jassub": "^2.5.16",
49
48
  "libav-wasm": "^0.8.4",
50
49
  "osra": "^0.6.12",
51
50
  "react-feather": "^2.0.10",
@@ -27,8 +27,15 @@ export type PictureInPictureMode = 'window' | 'burn-in'
27
27
 
28
28
  export type PictureInPictureOptions = {
29
29
  video: HTMLVideoElement
30
- /** The subtitle canvas. jassub sizes it to the video's content rect, so it maps 1:1. */
31
- canvas: HTMLCanvasElement
30
+ /**
31
+ * The subtitle LAYER, not the canvas inside it.
32
+ *
33
+ * From jassub 2 the canvas is created and removed by the subtitle renderer, one per pipeline
34
+ * build, so an element captured here would stop being the one being painted the moment the audio
35
+ * track changed. The layer is what React owns for the life of the player, so hiding and restoring
36
+ * it stays correct across a rebuild, and the canvas is looked up per frame.
37
+ */
38
+ subtitles: HTMLElement
32
39
  maxWidth?: number
33
40
  /** Where the mirror is mounted. It must be in the document. Defaults to the video's parent. */
34
41
  container?: HTMLElement
@@ -87,7 +94,7 @@ export const pictureInPictureMode = (): PictureInPictureMode | null => {
87
94
 
88
95
  /** Takes over the document's Media Session play/pause handlers while a session is open. */
89
96
  export const createPictureInPicture = (options: PictureInPictureOptions): PictureInPictureController => {
90
- const { video, canvas } = options
97
+ const { video, subtitles } = options
91
98
  const mode = options.mode ?? pictureInPictureMode() ?? 'window'
92
99
  const maxWidth = options.maxWidth ?? (mode === 'burn-in' ? BURN_IN_MAX_WIDTH : DEFAULT_MAX_WIDTH)
93
100
 
@@ -112,9 +119,12 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
112
119
 
113
120
  const draw = (composite: HTMLCanvasElement, context: CanvasRenderingContext2D) => {
114
121
  context.drawImage(video, 0, 0, composite.width, composite.height)
115
- // drawImage throws on a zero-sized source, which the canvas is until jassub has painted
116
- if (canvas.width > 0 && canvas.height > 0) {
117
- context.drawImage(canvas, 0, 0, composite.width, composite.height)
122
+ // Resolved per frame: the renderer replaces this element on every pipeline rebuild. A canvas
123
+ // jassub has transferred reports the size of the last frame its worker committed, so the zero
124
+ // check still keeps drawImage off a source with no dimensions to read.
125
+ const surface = subtitles.querySelector('canvas')
126
+ if (surface && surface.width > 0 && surface.height > 0) {
127
+ context.drawImage(surface, 0, 0, composite.width, composite.height)
118
128
  }
119
129
  }
120
130
 
@@ -254,18 +264,23 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
254
264
  * The real element is dimmed, never `display: none`: jassub sizes the subtitle canvas from its
255
265
  * `offsetWidth`/`offsetHeight`, and a collapsed box silently yields a composite with no subtitles
256
266
  * in it, which is exactly the thing being asked for.
267
+ *
268
+ * The LAYER is what gets hidden, rather than the canvas inside it. The canvas is replaced whenever
269
+ * the pipeline is rebuilt, so hiding that would leave the replacement visible over the composite
270
+ * (doubled subtitles) and would restore `display` onto an element that is no longer in the tree
271
+ * (subtitles gone for the rest of the session). The layer outlives both.
257
272
  */
258
273
  const present = (mirror: HTMLVideoElement) => {
259
274
  const videoOpacity = video.style.opacity
260
- const canvasDisplay = canvas.style.display
275
+ const subtitlesDisplay = subtitles.style.display
261
276
  mirror.style.cssText =
262
277
  'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;z-index:1'
263
278
  video.style.opacity = '0'
264
- canvas.style.display = 'none'
279
+ subtitles.style.display = 'none'
265
280
  restore = () => {
266
281
  restore = undefined
267
282
  video.style.opacity = videoOpacity
268
- canvas.style.display = canvasDisplay
283
+ subtitles.style.display = subtitlesDisplay
269
284
  }
270
285
  }
271
286
 
@@ -13,7 +13,14 @@ export type MediaIndex = { pos: number, timestamp: number }
13
13
 
14
14
  export type PlaybackOptions = {
15
15
  videoElement: HTMLVideoElement
16
- canvasElement: HTMLCanvasElement
16
+ /**
17
+ * The subtitle layer. The renderer creates its own canvas inside it, one per jassub instance.
18
+ *
19
+ * Renamed from `canvasElement` in the jassub 2 migration, deliberately: passing a canvas here now
20
+ * means jassub transfers it to a worker and deletes it on teardown, so a caller that kept handing
21
+ * over an element it owns has to see a type error rather than a runtime one.
22
+ */
23
+ subtitleContainer: HTMLElement
17
24
  read: (offset: number, size: number) => Promise<ArrayBuffer>
18
25
  length: number
19
26
  /** serves BOTH `libav.wasm` and `libav-jspi.wasm`; libav-wasm picks one on `WebAssembly.Suspending` */
@@ -114,7 +121,7 @@ export const terminateRemuxer = (remuxer: { worker: Worker, destroy: () => Promi
114
121
 
115
122
  export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackController> => {
116
123
  const {
117
- videoElement, canvasElement, read, length, publicPath, libavWorkerUrl,
124
+ videoElement, subtitleContainer, read, length, publicPath, libavWorkerUrl,
118
125
  jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl, bufferSize = DEFAULT_BUFFER_SIZE,
119
126
  audioStreamIndex, onReady, onError, onRecovered, onSeek, onSubtitleStreams,
120
127
  onAudioStreams,
@@ -152,7 +159,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
152
159
 
153
160
  const subtitles = createSubtitleRenderer({
154
161
  video: videoElement,
155
- canvas: canvasElement,
162
+ container: subtitleContainer,
156
163
  workerUrl: jassubWorkerUrl,
157
164
  wasmUrl: jassubWasmUrl,
158
165
  legacyWasmUrl: jassubLegacyWasmUrl,
@@ -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
  }