@banou/media-player 0.6.2 → 0.7.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 +148 -3
- package/dist/engine/index.d.ts +10 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/picture-in-picture.d.ts +27 -0
- package/dist/engine/playback.d.ts +45 -0
- package/dist/engine/source-buffer.d.ts +12 -0
- package/dist/engine/subtitles.d.ts +36 -0
- package/dist/engine/thumbnails.d.ts +22 -0
- package/dist/engine-Dmc7JSd9.js +470 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1566 -0
- package/dist/react/components/chrome.d.ts +9 -0
- package/dist/react/components/control-bar.d.ts +2 -0
- package/dist/react/components/overlay.d.ts +4 -0
- package/dist/react/components/playback-slider.d.ts +2 -0
- package/dist/react/components/progress-bar.d.ts +2 -0
- package/dist/react/components/settings.d.ts +2 -0
- package/dist/react/components/sound.d.ts +7 -0
- package/{build → dist/react}/components/tooltip-display.d.ts +8 -8
- package/dist/react/components/volume-slider.d.ts +6 -0
- package/dist/react/hooks/use-drag-value.d.ts +23 -0
- package/dist/react/hooks/use-picture-in-picture.d.ts +2 -0
- package/dist/react/hooks/use-playback.d.ts +6 -0
- package/dist/react/hooks/use-thumbnails.d.ts +11 -0
- package/dist/react/player.d.ts +25 -0
- package/dist/react/source-feature.d.ts +84 -0
- package/dist/react/video-player.d.ts +43 -0
- package/dist/utils/source.d.ts +30 -0
- package/dist/utils/track-label.d.ts +19 -0
- package/dist/utils/volume-utils.d.ts +6 -0
- package/package.json +49 -37
- package/src/lib/engine/index.ts +14 -0
- package/src/lib/engine/picture-in-picture.ts +250 -0
- package/src/lib/engine/playback.ts +352 -0
- package/src/lib/engine/source-buffer.ts +61 -0
- package/src/lib/engine/subtitles.ts +210 -0
- package/src/lib/engine/thumbnails.ts +137 -0
- package/src/lib/globals.d.ts +8 -0
- package/src/lib/index.tsx +36 -0
- package/src/lib/react/components/chrome.tsx +126 -0
- package/src/{components → lib/react/components}/control-bar.tsx +74 -96
- package/src/lib/react/components/overlay.tsx +121 -0
- package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
- package/src/{components → lib/react/components}/progress-bar.tsx +128 -41
- package/src/lib/react/components/settings.tsx +391 -0
- package/src/lib/react/components/sound.tsx +133 -0
- package/src/{components → lib/react/components}/tooltip-display.tsx +11 -8
- package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
- package/src/lib/react/hooks/use-drag-value.ts +73 -0
- package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
- package/src/lib/react/hooks/use-playback.ts +134 -0
- package/src/lib/react/hooks/use-thumbnails.ts +77 -0
- package/src/lib/react/player.ts +20 -0
- package/src/lib/react/source-feature.ts +90 -0
- package/src/lib/react/video-player.tsx +119 -0
- package/src/lib/utils/source.ts +60 -0
- package/src/lib/utils/track-label.ts +36 -0
- package/src/lib/utils/volume-utils.ts +13 -0
- package/build/components/chrome.d.ts +0 -7
- package/build/components/control-bar.d.ts +0 -6
- package/build/components/overlay.d.ts +0 -4
- package/build/components/playback-slider.d.ts +0 -2
- package/build/components/progress-bar.d.ts +0 -1
- package/build/components/settings.d.ts +0 -2
- package/build/components/sound.d.ts +0 -4
- package/build/components/volume-slider.d.ts +0 -6
- package/build/index.d.ts +0 -26
- package/build/index.js +0 -3416
- package/build/main.d.ts +0 -1
- package/build/state-machines/data-source.d.ts +0 -27
- package/build/state-machines/index.d.ts +0 -34038
- package/build/state-machines/media-properties.d.ts +0 -45
- package/build/state-machines/media-source.d.ts +0 -34
- package/build/state-machines/media.d.ts +0 -8558
- package/build/state-machines/subtitles.d.ts +0 -56
- package/build/state-machines/thumbnails.d.ts +0 -24
- package/build/state-machines/utils.d.ts +0 -26
- package/build/utils/actor-utils.d.ts +0 -2
- package/build/utils/context.d.ts +0 -14
- package/build/utils/index.d.ts +0 -4
- package/build/utils/languages.d.ts +0 -260
- package/build/utils/mp4box.d.ts +0 -61
- package/build/utils/use-local-storage.d.ts +0 -3
- package/build/utils/use-scrub.d.ts +0 -11
- package/build/utils/volume-utils.d.ts +0 -17
- package/build/utils/window-height.d.ts +0 -2
- package/src/components/chrome.tsx +0 -95
- package/src/components/overlay.tsx +0 -91
- package/src/components/settings.tsx +0 -322
- package/src/components/sound.tsx +0 -101
- package/src/index.tsx +0 -195
- package/src/main.tsx +0 -169
- package/src/state-machines/data-source.ts +0 -84
- package/src/state-machines/index.ts +0 -5
- package/src/state-machines/media-properties.ts +0 -82
- package/src/state-machines/media-source.ts +0 -129
- package/src/state-machines/media.ts +0 -255
- package/src/state-machines/subtitles.ts +0 -285
- package/src/state-machines/thumbnails.ts +0 -123
- package/src/state-machines/utils.ts +0 -94
- package/src/utils/actor-utils.ts +0 -19
- package/src/utils/context.ts +0 -23
- package/src/utils/index.ts +0 -229
- package/src/utils/languages.ts +0 -262
- package/src/utils/mp4box.ts +0 -74
- package/src/utils/use-local-storage.ts +0 -30
- package/src/utils/use-scrub.ts +0 -40
- package/src/utils/volume-utils.ts +0 -39
- package/src/utils/window-height.ts +0 -19
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.json +0 -28
- package/tsconfig.node.json +0 -9
- /package/{build → dist}/utils/colors.d.ts +0 -0
- /package/{build → dist}/utils/fonts.d.ts +0 -0
- /package/{build → dist}/utils/time.d.ts +0 -0
- /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
- /package/src/{utils → lib/utils}/colors.ts +0 -0
- /package/src/{utils → lib/utils}/fonts.ts +0 -0
- /package/src/{utils → lib/utils}/time.ts +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picture in picture with the subtitles burned in.
|
|
3
|
+
*
|
|
4
|
+
* The window takes a video element and nothing else, so subtitles painted on a canvas over the video
|
|
5
|
+
* cannot come along. Each frame is composited with the subtitle canvas onto an offscreen canvas, and
|
|
6
|
+
* `captureStream()` backs a hidden mirror element, which is the one that enters the window. The
|
|
7
|
+
* original element keeps playing and stays the only audio source.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_WIDTH = 1280
|
|
11
|
+
/** The frame callback fires only for presented frames, so a paused video needs its own repaint. */
|
|
12
|
+
const PAUSED_REPAINT_INTERVAL = 250
|
|
13
|
+
|
|
14
|
+
export type PictureInPictureOptions = {
|
|
15
|
+
video: HTMLVideoElement
|
|
16
|
+
/** The subtitle canvas. jassub sizes it to the video's content rect, so it maps 1:1. */
|
|
17
|
+
canvas: HTMLCanvasElement
|
|
18
|
+
maxWidth?: number
|
|
19
|
+
/** Where the hidden mirror is mounted. It must be in the document. Defaults to the video's parent. */
|
|
20
|
+
container?: HTMLElement
|
|
21
|
+
/**
|
|
22
|
+
* Toggle used when compositing is unavailable or fails. The React layer passes the player store's
|
|
23
|
+
* own action, which handles Safari and exits fullscreen first; the default here does neither.
|
|
24
|
+
*/
|
|
25
|
+
fallback?: () => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PictureInPictureController = {
|
|
29
|
+
toggle: () => Promise<void>
|
|
30
|
+
destroy: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Session = {
|
|
34
|
+
composite: HTMLCanvasElement
|
|
35
|
+
context: CanvasRenderingContext2D
|
|
36
|
+
stream: MediaStream
|
|
37
|
+
mirror: HTMLVideoElement
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const supportsCompositing = () =>
|
|
41
|
+
typeof HTMLCanvasElement !== 'undefined' &&
|
|
42
|
+
'captureStream' in HTMLCanvasElement.prototype &&
|
|
43
|
+
typeof document !== 'undefined' &&
|
|
44
|
+
document.pictureInPictureEnabled
|
|
45
|
+
|
|
46
|
+
/** Takes over the document's Media Session play/pause handlers while a session is open. */
|
|
47
|
+
export const createPictureInPicture = (options: PictureInPictureOptions): PictureInPictureController => {
|
|
48
|
+
const { video, canvas, maxWidth = DEFAULT_MAX_WIDTH } = options
|
|
49
|
+
|
|
50
|
+
let session: Session | undefined
|
|
51
|
+
let handle: number | undefined
|
|
52
|
+
let pausedRepaint: ReturnType<typeof setInterval> | undefined
|
|
53
|
+
let syncing = false
|
|
54
|
+
// Nothing can tell a session is being built until `enter` resolves, so without this a second click
|
|
55
|
+
// starts a second pipeline whose timer and mirror are then unreachable.
|
|
56
|
+
let entering = false
|
|
57
|
+
let destroyed = false
|
|
58
|
+
|
|
59
|
+
const active = () => !!session && document.pictureInPictureElement === session.mirror
|
|
60
|
+
|
|
61
|
+
const draw = (composite: HTMLCanvasElement, context: CanvasRenderingContext2D) => {
|
|
62
|
+
context.drawImage(video, 0, 0, composite.width, composite.height)
|
|
63
|
+
// drawImage throws on a zero-sized source, which the canvas is until jassub has painted
|
|
64
|
+
if (canvas.width > 0 && canvas.height > 0) {
|
|
65
|
+
context.drawImage(canvas, 0, 0, composite.width, composite.height)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const paint = () => {
|
|
70
|
+
if (session) draw(session.composite, session.context)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const useFrameCallback = 'requestVideoFrameCallback' in video
|
|
74
|
+
|
|
75
|
+
const loop = () => {
|
|
76
|
+
paint()
|
|
77
|
+
schedule()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const schedule = () => {
|
|
81
|
+
if (destroyed || !session) return
|
|
82
|
+
handle = useFrameCallback ? video.requestVideoFrameCallback(loop) : requestAnimationFrame(loop)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stopLoop = () => {
|
|
86
|
+
if (handle !== undefined) {
|
|
87
|
+
if (useFrameCallback) video.cancelVideoFrameCallback(handle)
|
|
88
|
+
else cancelAnimationFrame(handle)
|
|
89
|
+
}
|
|
90
|
+
if (pausedRepaint !== undefined) clearInterval(pausedRepaint)
|
|
91
|
+
handle = undefined
|
|
92
|
+
pausedRepaint = undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const publishState = () => {
|
|
96
|
+
if (!('mediaSession' in navigator)) return
|
|
97
|
+
navigator.mediaSession.playbackState = video.paused ? 'paused' : 'playing'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onMirrorPlay = () => {
|
|
101
|
+
if (syncing || !video.paused) return
|
|
102
|
+
void video.play().catch(() => {})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Reached on a browser whose window controls pause the mirror directly instead of going through the
|
|
106
|
+
// Media Session. Forward the intent, then put the mirror back to work.
|
|
107
|
+
const onMirrorPause = () => {
|
|
108
|
+
if (syncing) return
|
|
109
|
+
if (!video.paused) video.pause()
|
|
110
|
+
syncing = true
|
|
111
|
+
void session?.mirror.play().catch(() => {}).finally(() => { syncing = false })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const onVideoPlay = () => {
|
|
115
|
+
publishState()
|
|
116
|
+
void session?.mirror.play().catch(() => {})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* NEVER pause the mirror. A paused element stops rendering its MediaStream, so the window freezes on
|
|
121
|
+
* its last frame: measured at 0 of 57,600 pixels moving on a seek. Transport state travels through
|
|
122
|
+
* the Media Session instead, which is what the window reads for its button.
|
|
123
|
+
*/
|
|
124
|
+
const onVideoPause = () => {
|
|
125
|
+
publishState()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const mediaSessionActions: [MediaSessionAction, MediaSessionActionHandler][] = [
|
|
129
|
+
['play', () => { void video.play().catch(() => {}) }],
|
|
130
|
+
['pause', () => video.pause()],
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
const bindMediaSession = (bind: boolean) => {
|
|
134
|
+
if (!('mediaSession' in navigator)) return
|
|
135
|
+
for (const [action, handler] of mediaSessionActions) {
|
|
136
|
+
try {
|
|
137
|
+
navigator.mediaSession.setActionHandler(action, bind ? handler : null)
|
|
138
|
+
} catch {
|
|
139
|
+
// an action the browser does not know is not worth surfacing
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const teardown = () => {
|
|
145
|
+
stopLoop()
|
|
146
|
+
bindMediaSession(false)
|
|
147
|
+
video.removeEventListener('play', onVideoPlay)
|
|
148
|
+
video.removeEventListener('pause', onVideoPause)
|
|
149
|
+
if (!session) return
|
|
150
|
+
const { stream, mirror } = session
|
|
151
|
+
mirror.removeEventListener('play', onMirrorPlay)
|
|
152
|
+
mirror.removeEventListener('pause', onMirrorPause)
|
|
153
|
+
mirror.removeEventListener('leavepictureinpicture', onLeave)
|
|
154
|
+
for (const track of stream.getTracks()) track.stop()
|
|
155
|
+
mirror.remove()
|
|
156
|
+
session = undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const onLeave = () => teardown()
|
|
160
|
+
|
|
161
|
+
const fallback = options.fallback ?? (async () => {
|
|
162
|
+
if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
|
|
163
|
+
else await video.requestPictureInPicture()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const enter = async () => {
|
|
167
|
+
// Sized off the intrinsic dimensions, never the layout, so a resize cannot resize a canvas that a
|
|
168
|
+
// MediaStream is already capturing.
|
|
169
|
+
const width = Math.min(video.videoWidth || maxWidth, maxWidth)
|
|
170
|
+
const height = Math.round(width * ((video.videoHeight || 9) / (video.videoWidth || 16)))
|
|
171
|
+
|
|
172
|
+
const composite = document.createElement('canvas')
|
|
173
|
+
composite.width = width
|
|
174
|
+
composite.height = height
|
|
175
|
+
// The alpha channel is unused but has to stay: with `{ alpha: false }` Chrome's captureStream
|
|
176
|
+
// delivers the first frame and then nothing, with the track still "live" and no error anywhere.
|
|
177
|
+
const context = composite.getContext('2d')
|
|
178
|
+
if (!context) throw new Error('the picture in picture composite has no 2d context')
|
|
179
|
+
|
|
180
|
+
const mirror = document.createElement('video')
|
|
181
|
+
mirror.muted = true
|
|
182
|
+
mirror.playsInline = true
|
|
183
|
+
mirror.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;left:-1px;top:-1px'
|
|
184
|
+
|
|
185
|
+
// one frame before the capture starts, so the stream has content from its first moment
|
|
186
|
+
draw(composite, context)
|
|
187
|
+
|
|
188
|
+
session = { composite, context, stream: composite.captureStream(), mirror }
|
|
189
|
+
mirror.srcObject = session.stream
|
|
190
|
+
;(options.container ?? video.parentElement ?? document.body).appendChild(mirror)
|
|
191
|
+
|
|
192
|
+
schedule()
|
|
193
|
+
pausedRepaint = setInterval(() => { if (video.paused) paint() }, PAUSED_REPAINT_INTERVAL)
|
|
194
|
+
void mirror.play().catch(() => {})
|
|
195
|
+
|
|
196
|
+
// requestPictureInPicture rejects while readyState is 0, and the wait is short enough that the
|
|
197
|
+
// click's transient user activation survives it.
|
|
198
|
+
if (mirror.readyState === 0) {
|
|
199
|
+
await new Promise<void>((resolve) => {
|
|
200
|
+
const done = () => { clearTimeout(timer); resolve() }
|
|
201
|
+
const timer = setTimeout(done, 1000)
|
|
202
|
+
mirror.addEventListener('loadedmetadata', done, { once: true })
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
mirror.addEventListener('play', onMirrorPlay)
|
|
207
|
+
mirror.addEventListener('pause', onMirrorPause)
|
|
208
|
+
mirror.addEventListener('leavepictureinpicture', onLeave)
|
|
209
|
+
video.addEventListener('play', onVideoPlay)
|
|
210
|
+
video.addEventListener('pause', onVideoPause)
|
|
211
|
+
bindMediaSession(true)
|
|
212
|
+
publishState()
|
|
213
|
+
|
|
214
|
+
await mirror.requestPictureInPicture()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const toggle = async () => {
|
|
218
|
+
if (destroyed || entering) return
|
|
219
|
+
if (active()) {
|
|
220
|
+
await document.exitPictureInPicture().catch(() => {})
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (!supportsCompositing()) {
|
|
224
|
+
await fallback()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
// another element, including our own last mirror, may still hold the single slot
|
|
228
|
+
if (document.pictureInPictureElement) await document.exitPictureInPicture().catch(() => {})
|
|
229
|
+
|
|
230
|
+
entering = true
|
|
231
|
+
try {
|
|
232
|
+
await enter()
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.warn('subtitle compositing for picture in picture failed, falling back', error)
|
|
235
|
+
teardown()
|
|
236
|
+
await fallback().catch(() => {})
|
|
237
|
+
} finally {
|
|
238
|
+
entering = false
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
toggle,
|
|
244
|
+
destroy: () => {
|
|
245
|
+
destroyed = true
|
|
246
|
+
if (active()) void document.exitPictureInPicture().catch(() => {})
|
|
247
|
+
teardown()
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { AudioStream } from 'libav-wasm/build/worker'
|
|
2
|
+
|
|
3
|
+
import { makeRemuxer } from 'libav-wasm'
|
|
4
|
+
|
|
5
|
+
import { getTimeRanges, updateSourceBuffer } from './source-buffer'
|
|
6
|
+
import { createSubtitleRenderer } from './subtitles'
|
|
7
|
+
import type { SubtitleStream } from './subtitles'
|
|
8
|
+
|
|
9
|
+
export type { AudioStream }
|
|
10
|
+
|
|
11
|
+
/** A keyframe index entry: the byte offset a keyframe starts at, and the time it plays at. */
|
|
12
|
+
export type MediaIndex = { pos: number, timestamp: number }
|
|
13
|
+
|
|
14
|
+
export type PlaybackOptions = {
|
|
15
|
+
videoElement: HTMLVideoElement
|
|
16
|
+
canvasElement: HTMLCanvasElement
|
|
17
|
+
read: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
18
|
+
length: number
|
|
19
|
+
/** serves BOTH `libav.wasm` and `libav-jspi.wasm`; libav-wasm picks one on `WebAssembly.Suspending` */
|
|
20
|
+
publicPath: string
|
|
21
|
+
libavWorkerUrl: string
|
|
22
|
+
jassubWorkerUrl: string
|
|
23
|
+
jassubWasmUrl: string
|
|
24
|
+
/** jassub's non-SIMD build; without it subtitles fail rather than degrade on Safari before 16.4 */
|
|
25
|
+
jassubLegacyWasmUrl?: string
|
|
26
|
+
defaultFontUrl?: string
|
|
27
|
+
bufferSize?: number
|
|
28
|
+
audioStreamIndex?: number
|
|
29
|
+
onReady?: () => void
|
|
30
|
+
onError?: (error: unknown) => void
|
|
31
|
+
onRecovered?: () => void
|
|
32
|
+
onSeek?: (fraction: number) => void
|
|
33
|
+
onSubtitleStreams?: (streams: SubtitleStream[]) => void
|
|
34
|
+
onAudioStreams?: (streams: AudioStream[], selected: number) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PlaybackController = {
|
|
38
|
+
destroy: () => void
|
|
39
|
+
selectSubtitleStream: (streamIndex: number | undefined) => void
|
|
40
|
+
/** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
|
|
41
|
+
indexes: MediaIndex[]
|
|
42
|
+
duration: number
|
|
43
|
+
videoMimeType: string
|
|
44
|
+
audioMimeType: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ~20s behind and ~60s ahead of the playhead, refilled when the forward buffer dips under 30s
|
|
48
|
+
const PRE_EVICT = -20
|
|
49
|
+
const POST_EVICT = 60
|
|
50
|
+
const BUFFER_TARGET = 30
|
|
51
|
+
// the tighter window used after the browser refuses an append
|
|
52
|
+
const PRE_EVICT_TIGHT = -5
|
|
53
|
+
const POST_EVICT_TIGHT = 20
|
|
54
|
+
const MAX_APPEND_ATTEMPTS = 5
|
|
55
|
+
const SOURCE_OPEN_TIMEOUT = 15_000
|
|
56
|
+
// how far past the playhead a range may start and still count as the one holding it
|
|
57
|
+
const BOUNDARY_SLACK = 1
|
|
58
|
+
export const DEFAULT_BUFFER_SIZE = 2_500_000
|
|
59
|
+
|
|
60
|
+
// destroy() only terminates after a round trip into the wasm, so terminate on our own clock too
|
|
61
|
+
export const terminateRemuxer = (remuxer: { worker: Worker, destroy: () => Promise<void> }) => {
|
|
62
|
+
const { worker } = remuxer
|
|
63
|
+
const bail = setTimeout(() => worker.terminate(), 2_000)
|
|
64
|
+
void remuxer
|
|
65
|
+
.destroy()
|
|
66
|
+
.catch(() => {})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
clearTimeout(bail)
|
|
69
|
+
worker.terminate()
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackController> => {
|
|
74
|
+
const {
|
|
75
|
+
videoElement, canvasElement, read, length, publicPath, libavWorkerUrl,
|
|
76
|
+
jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl, bufferSize = DEFAULT_BUFFER_SIZE,
|
|
77
|
+
audioStreamIndex, onReady, onError, onRecovered, onSeek, onSubtitleStreams,
|
|
78
|
+
onAudioStreams,
|
|
79
|
+
} = options
|
|
80
|
+
|
|
81
|
+
const remuxer = await makeRemuxer({
|
|
82
|
+
publicPath,
|
|
83
|
+
workerUrl: libavWorkerUrl,
|
|
84
|
+
workerOptions: { type: 'module' },
|
|
85
|
+
bufferSize,
|
|
86
|
+
length,
|
|
87
|
+
audioStreamIndex,
|
|
88
|
+
read,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const teardown: (() => void)[] = []
|
|
92
|
+
let destroyed = false
|
|
93
|
+
const runTeardown = () => {
|
|
94
|
+
if (destroyed) return
|
|
95
|
+
destroyed = true
|
|
96
|
+
for (const step of [...teardown].reverse()) {
|
|
97
|
+
try { step() } catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
teardown.push(() => terminateRemuxer(remuxer))
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const metadata = await remuxer.init()
|
|
104
|
+
|
|
105
|
+
const audioStreams = metadata.audioStreams ?? []
|
|
106
|
+
const selectedAudio = audioStreams.some((s) => s.streamIndex === audioStreamIndex)
|
|
107
|
+
? audioStreamIndex!
|
|
108
|
+
: audioStreams[0]?.streamIndex ?? -1
|
|
109
|
+
onAudioStreams?.(audioStreams, selectedAudio)
|
|
110
|
+
|
|
111
|
+
const subtitles = createSubtitleRenderer({
|
|
112
|
+
video: videoElement,
|
|
113
|
+
canvas: canvasElement,
|
|
114
|
+
workerUrl: jassubWorkerUrl,
|
|
115
|
+
wasmUrl: jassubWasmUrl,
|
|
116
|
+
legacyWasmUrl: jassubLegacyWasmUrl,
|
|
117
|
+
defaultFontUrl,
|
|
118
|
+
onStreams: onSubtitleStreams,
|
|
119
|
+
})
|
|
120
|
+
teardown.push(() => subtitles.destroy())
|
|
121
|
+
if (metadata.attachments?.length) subtitles.pushAttachments(metadata.attachments)
|
|
122
|
+
|
|
123
|
+
// an unparseable codec is an empty string, which isTypeSupported would accept as audio-only
|
|
124
|
+
if (!metadata.info.output.videoMimeType) throw new Error('The video codec in this file could not be identified')
|
|
125
|
+
const codecs = [metadata.info.output.videoMimeType, metadata.info.output.audioMimeType].filter(Boolean).join(',')
|
|
126
|
+
const mime = `video/mp4; codecs="${codecs}"`
|
|
127
|
+
if (!MediaSource.isTypeSupported(mime)) throw new Error(`This browser cannot play the codecs in this file: ${codecs}`)
|
|
128
|
+
|
|
129
|
+
const mediaSource = new MediaSource()
|
|
130
|
+
const mediaSourceUrl = URL.createObjectURL(mediaSource)
|
|
131
|
+
teardown.push(() => URL.revokeObjectURL(mediaSourceUrl))
|
|
132
|
+
videoElement.src = mediaSourceUrl
|
|
133
|
+
|
|
134
|
+
const sourceBuffer = await new Promise<SourceBuffer>((resolve, reject) => {
|
|
135
|
+
const timeout = setTimeout(() => { cleanup(); reject(new Error('The MediaSource never opened')) }, SOURCE_OPEN_TIMEOUT)
|
|
136
|
+
const cleanup = () => {
|
|
137
|
+
clearTimeout(timeout)
|
|
138
|
+
mediaSource.removeEventListener('sourceopen', onSourceOpen)
|
|
139
|
+
mediaSource.removeEventListener('sourceclose', onFail)
|
|
140
|
+
mediaSource.removeEventListener('error', onFail)
|
|
141
|
+
}
|
|
142
|
+
const onSourceOpen = () => {
|
|
143
|
+
try {
|
|
144
|
+
const sb = mediaSource.addSourceBuffer(mime)
|
|
145
|
+
sb.mode = 'segments'
|
|
146
|
+
const duration = metadata.info.input.duration
|
|
147
|
+
if (Number.isFinite(duration) && duration > 0) mediaSource.duration = duration
|
|
148
|
+
cleanup()
|
|
149
|
+
resolve(sb)
|
|
150
|
+
} catch (error) {
|
|
151
|
+
cleanup()
|
|
152
|
+
reject(error)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const onFail = () => { cleanup(); reject(new Error('The MediaSource closed before a SourceBuffer could be created')) }
|
|
156
|
+
mediaSource.addEventListener('sourceopen', onSourceOpen, { once: true })
|
|
157
|
+
mediaSource.addEventListener('sourceclose', onFail, { once: true })
|
|
158
|
+
mediaSource.addEventListener('error', onFail, { once: true })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const { appendBuffer, unbufferRange, updateTimestampOffset, endOfStream } = updateSourceBuffer(sourceBuffer, mediaSource)
|
|
162
|
+
await appendBuffer(metadata.data)
|
|
163
|
+
if (metadata.subtitles?.length) subtitles.pushFragments(metadata.subtitles)
|
|
164
|
+
onReady?.()
|
|
165
|
+
|
|
166
|
+
let reading = false
|
|
167
|
+
let seeking = false
|
|
168
|
+
let finished = false
|
|
169
|
+
// libav aborts the running task when a new one starts, so both flags need a generation token
|
|
170
|
+
let readGeneration = 0
|
|
171
|
+
let seekGeneration = 0
|
|
172
|
+
let pending: ArrayBuffer | null = null
|
|
173
|
+
let pendingAttempts = 0
|
|
174
|
+
let lastAppendedEnd = 0
|
|
175
|
+
let outstandingError = false
|
|
176
|
+
// where the remuxer was last positioned, which is the floor of what it can still read forward from
|
|
177
|
+
let lastSeekPosition = 0
|
|
178
|
+
|
|
179
|
+
// 'Cancelled' covers both an aborted task and a read that gave up, so it is only noise mid-seek
|
|
180
|
+
const reportError = (error: unknown, aborted: boolean) => {
|
|
181
|
+
const cancelled = (error as Error)?.message === 'Cancelled'
|
|
182
|
+
if (aborted && cancelled) return
|
|
183
|
+
console.error(error)
|
|
184
|
+
if (outstandingError) return
|
|
185
|
+
outstandingError = true
|
|
186
|
+
onError?.(cancelled ? new Error('Reading the video file failed', { cause: error }) : error)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const evict = async (tight = false) => {
|
|
190
|
+
const ct = videoElement.currentTime
|
|
191
|
+
const pre = tight ? PRE_EVICT_TIGHT : PRE_EVICT
|
|
192
|
+
const post = tight ? POST_EVICT_TIGHT : POST_EVICT
|
|
193
|
+
for (const { start, end } of getTimeRanges(sourceBuffer)) {
|
|
194
|
+
if (start < ct + pre) await unbufferRange(start, ct + pre)
|
|
195
|
+
if (end > ct + post) await unbufferRange(ct + post, end)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The ceiling on the furthest buffered end stops a backward-seek hole becoming a runaway, and the
|
|
200
|
+
// slack covers a first segment that starts just after zero from audio priming.
|
|
201
|
+
const needsData = () => {
|
|
202
|
+
const ranges = getTimeRanges(sourceBuffer)
|
|
203
|
+
if (!ranges.length) return true
|
|
204
|
+
const ct = videoElement.currentTime
|
|
205
|
+
// never read past what evict() keeps
|
|
206
|
+
if (Math.max(...ranges.map((r) => r.end)) >= ct + POST_EVICT) return false
|
|
207
|
+
const range = ranges.find((r) => r.start <= ct + BOUNDARY_SLACK && ct < r.end)
|
|
208
|
+
return !range || range.end < ct + BUFFER_TARGET
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const playheadRange = () => {
|
|
212
|
+
const ct = videoElement.currentTime
|
|
213
|
+
return getTimeRanges(sourceBuffer).find((r) => r.start <= ct + BOUNDARY_SLACK && ct < r.end)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// the end may only be signalled once the playhead's own range covers it
|
|
217
|
+
const atEnd = () => {
|
|
218
|
+
const range = playheadRange()
|
|
219
|
+
return !!range && range.end >= lastAppendedEnd - 0.1
|
|
220
|
+
}
|
|
221
|
+
const trackAppendedEnd = () => {
|
|
222
|
+
const ends = getTimeRanges(sourceBuffer).map((r) => r.end)
|
|
223
|
+
if (ends.length) lastAppendedEnd = Math.max(lastAppendedEnd, ...ends)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const seekTo = async (time: number) => {
|
|
227
|
+
seeking = true
|
|
228
|
+
pending = null
|
|
229
|
+
pendingAttempts = 0
|
|
230
|
+
lastSeekPosition = time
|
|
231
|
+
const generation = ++seekGeneration
|
|
232
|
+
try {
|
|
233
|
+
const { data, pts, subtitles: fragments } = await remuxer.seek(time)
|
|
234
|
+
if (destroyed || generation !== seekGeneration) return
|
|
235
|
+
if (fragments?.length) subtitles.pushFragments(fragments)
|
|
236
|
+
await updateTimestampOffset(pts)
|
|
237
|
+
if (destroyed || generation !== seekGeneration) return
|
|
238
|
+
if (data.byteLength) {
|
|
239
|
+
await appendBuffer(data)
|
|
240
|
+
trackAppendedEnd()
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
reportError(error, destroyed || generation !== seekGeneration)
|
|
244
|
+
} finally {
|
|
245
|
+
if (generation === seekGeneration) seeking = false
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const flushPending = async () => {
|
|
250
|
+
const segment = pending
|
|
251
|
+
if (!segment) return
|
|
252
|
+
try {
|
|
253
|
+
await appendBuffer(segment)
|
|
254
|
+
trackAppendedEnd()
|
|
255
|
+
if (pending === segment) {
|
|
256
|
+
pending = null
|
|
257
|
+
pendingAttempts = 0
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (pending !== segment) return
|
|
261
|
+
// Chrome refuses appends past a per-element video budget of roughly 150MB
|
|
262
|
+
if ((error as DOMException)?.name === 'QuotaExceededError') {
|
|
263
|
+
const evicted = await evict(true).then(() => true, () => false)
|
|
264
|
+
if (pending !== segment) return
|
|
265
|
+
if (evicted) {
|
|
266
|
+
// Reads only move forward, so restart at the far edge of what was kept rather than at
|
|
267
|
+
// the playhead, which would walk straight back into the same refusal.
|
|
268
|
+
finished = false
|
|
269
|
+
await seekTo(playheadRange()?.end ?? videoElement.currentTime)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
pendingAttempts += 1
|
|
274
|
+
if (pendingAttempts < MAX_APPEND_ATTEMPTS) return
|
|
275
|
+
pending = null
|
|
276
|
+
throw error
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const pump = async () => {
|
|
281
|
+
if (reading || seeking || destroyed) return
|
|
282
|
+
if (!pending && (finished || !needsData())) return
|
|
283
|
+
const generation = ++readGeneration
|
|
284
|
+
const seekAtStart = seekGeneration
|
|
285
|
+
const stale = () => destroyed || generation !== readGeneration || seekAtStart !== seekGeneration
|
|
286
|
+
reading = true
|
|
287
|
+
try {
|
|
288
|
+
if (!pending) {
|
|
289
|
+
const { data, subtitles: fragments, finished: done } = await remuxer.read()
|
|
290
|
+
if (stale()) return
|
|
291
|
+
if (done) finished = true
|
|
292
|
+
if (fragments?.length) subtitles.pushFragments(fragments)
|
|
293
|
+
if (!data.byteLength) return
|
|
294
|
+
pending = data
|
|
295
|
+
pendingAttempts = 0
|
|
296
|
+
}
|
|
297
|
+
await flushPending()
|
|
298
|
+
if (!stale() && outstandingError) {
|
|
299
|
+
outstandingError = false
|
|
300
|
+
onRecovered?.()
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
reportError(error, stale())
|
|
304
|
+
} finally {
|
|
305
|
+
if (generation === readGeneration) reading = false
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A remuxer seek is expensive out of proportion to what it looks like: libav tears down and rebuilds
|
|
311
|
+
* BOTH the input and the output context and re-runs write_header, because the fragmented mp4 muxer
|
|
312
|
+
* cannot rewind. Skipping it when the target is already playable is most of what a seek costs.
|
|
313
|
+
*
|
|
314
|
+
* Both conditions are needed. Buffered alone is not enough: landing before the last remuxer position
|
|
315
|
+
* means the reader is somewhere ahead, so the gap between here and it can never be filled without a
|
|
316
|
+
* seek, and playback would stall at the end of this range instead. At or after it, the bytes from
|
|
317
|
+
* here to the read cursor are contiguous and reading simply carries on.
|
|
318
|
+
*/
|
|
319
|
+
const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
|
|
320
|
+
|
|
321
|
+
const onSeeking = () => {
|
|
322
|
+
const time = videoElement.currentTime
|
|
323
|
+
const duration = metadata.info.input.duration || videoElement.duration
|
|
324
|
+
if (duration > 0) onSeek?.(Math.min(Math.max(time / duration, 0), 1))
|
|
325
|
+
if (alreadyPlayable(time)) return
|
|
326
|
+
finished = false
|
|
327
|
+
void seekTo(time)
|
|
328
|
+
}
|
|
329
|
+
videoElement.addEventListener('seeking', onSeeking)
|
|
330
|
+
teardown.push(() => videoElement.removeEventListener('seeking', onSeeking))
|
|
331
|
+
|
|
332
|
+
const interval = setInterval(() => {
|
|
333
|
+
evict().catch(() => {})
|
|
334
|
+
pump().catch((error) => reportError(error, false))
|
|
335
|
+
// every remove() puts the MediaSource back to 'open', so the end has to be re-armed
|
|
336
|
+
if (finished && !pending && atEnd() && mediaSource.readyState === 'open') endOfStream().catch(() => {})
|
|
337
|
+
}, 100)
|
|
338
|
+
teardown.push(() => clearInterval(interval))
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
destroy: runTeardown,
|
|
342
|
+
selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
|
|
343
|
+
indexes: metadata.indexes ?? [],
|
|
344
|
+
duration: metadata.info.input.duration,
|
|
345
|
+
videoMimeType: metadata.info.output.videoMimeType,
|
|
346
|
+
audioMimeType: metadata.info.output.audioMimeType,
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
runTeardown()
|
|
350
|
+
throw error
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// appendBuffer, remove and timestampOffset must never overlap an in-flight update, so every op
|
|
2
|
+
// goes through one chain
|
|
3
|
+
|
|
4
|
+
export type TimeRange = { index: number, start: number, end: number }
|
|
5
|
+
|
|
6
|
+
export const getTimeRanges = (sourceBuffer: SourceBuffer): TimeRange[] =>
|
|
7
|
+
Array(sourceBuffer.buffered.length)
|
|
8
|
+
.fill(undefined)
|
|
9
|
+
.map((_, index) => ({
|
|
10
|
+
index,
|
|
11
|
+
start: sourceBuffer.buffered.start(index),
|
|
12
|
+
end: sourceBuffer.buffered.end(index),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const waitForUpdate = (sourceBuffer: SourceBuffer) =>
|
|
16
|
+
new Promise<void>((resolve, reject) => {
|
|
17
|
+
const cleanup = () => {
|
|
18
|
+
sourceBuffer.removeEventListener('updateend', onEnd)
|
|
19
|
+
sourceBuffer.removeEventListener('abort', onEnd)
|
|
20
|
+
sourceBuffer.removeEventListener('error', onError)
|
|
21
|
+
}
|
|
22
|
+
const onEnd = () => { cleanup(); resolve() }
|
|
23
|
+
const onError = (ev: Event) => { cleanup(); reject(new Error('The browser rejected a media segment', { cause: ev })) }
|
|
24
|
+
sourceBuffer.addEventListener('updateend', onEnd, { once: true })
|
|
25
|
+
sourceBuffer.addEventListener('abort', onEnd, { once: true })
|
|
26
|
+
sourceBuffer.addEventListener('error', onError, { once: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const updateSourceBuffer = (sourceBuffer: SourceBuffer, mediaSource: MediaSource) => {
|
|
30
|
+
let chain: Promise<unknown> = Promise.resolve()
|
|
31
|
+
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
|
|
32
|
+
const run = chain.then(task, task)
|
|
33
|
+
chain = run.catch(() => {})
|
|
34
|
+
return run as Promise<T>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const appendBuffer = (buffer: ArrayBuffer | Uint8Array) =>
|
|
38
|
+
enqueue(() => {
|
|
39
|
+
sourceBuffer.appendBuffer(buffer as BufferSource)
|
|
40
|
+
return waitForUpdate(sourceBuffer)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const unbufferRange = (start: number, end: number) =>
|
|
44
|
+
enqueue(() => {
|
|
45
|
+
if (start >= end) return Promise.resolve()
|
|
46
|
+
sourceBuffer.remove(start, end)
|
|
47
|
+
return waitForUpdate(sourceBuffer)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const updateTimestampOffset = (timestampOffset: number) =>
|
|
51
|
+
enqueue(async () => { sourceBuffer.timestampOffset = timestampOffset })
|
|
52
|
+
|
|
53
|
+
// endOfStream throws mid-update and is undone by the next remove(), so the caller re-arms it
|
|
54
|
+
const endOfStream = () =>
|
|
55
|
+
enqueue(async () => {
|
|
56
|
+
if (mediaSource.readyState !== 'open' || sourceBuffer.updating) return
|
|
57
|
+
try { mediaSource.endOfStream() } catch {}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return { appendBuffer, unbufferRange, updateTimestampOffset, endOfStream }
|
|
61
|
+
}
|