@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.
Files changed (119) hide show
  1. package/README.md +148 -3
  2. package/dist/engine/index.d.ts +10 -0
  3. package/dist/engine/index.js +2 -0
  4. package/dist/engine/picture-in-picture.d.ts +27 -0
  5. package/dist/engine/playback.d.ts +45 -0
  6. package/dist/engine/source-buffer.d.ts +12 -0
  7. package/dist/engine/subtitles.d.ts +36 -0
  8. package/dist/engine/thumbnails.d.ts +22 -0
  9. package/dist/engine-Dmc7JSd9.js +470 -0
  10. package/dist/index.d.ts +12 -0
  11. package/dist/index.js +1566 -0
  12. package/dist/react/components/chrome.d.ts +9 -0
  13. package/dist/react/components/control-bar.d.ts +2 -0
  14. package/dist/react/components/overlay.d.ts +4 -0
  15. package/dist/react/components/playback-slider.d.ts +2 -0
  16. package/dist/react/components/progress-bar.d.ts +2 -0
  17. package/dist/react/components/settings.d.ts +2 -0
  18. package/dist/react/components/sound.d.ts +7 -0
  19. package/{build → dist/react}/components/tooltip-display.d.ts +8 -8
  20. package/dist/react/components/volume-slider.d.ts +6 -0
  21. package/dist/react/hooks/use-drag-value.d.ts +23 -0
  22. package/dist/react/hooks/use-picture-in-picture.d.ts +2 -0
  23. package/dist/react/hooks/use-playback.d.ts +6 -0
  24. package/dist/react/hooks/use-thumbnails.d.ts +11 -0
  25. package/dist/react/player.d.ts +25 -0
  26. package/dist/react/source-feature.d.ts +84 -0
  27. package/dist/react/video-player.d.ts +43 -0
  28. package/dist/utils/source.d.ts +30 -0
  29. package/dist/utils/track-label.d.ts +19 -0
  30. package/dist/utils/volume-utils.d.ts +6 -0
  31. package/package.json +49 -37
  32. package/src/lib/engine/index.ts +14 -0
  33. package/src/lib/engine/picture-in-picture.ts +250 -0
  34. package/src/lib/engine/playback.ts +352 -0
  35. package/src/lib/engine/source-buffer.ts +61 -0
  36. package/src/lib/engine/subtitles.ts +210 -0
  37. package/src/lib/engine/thumbnails.ts +137 -0
  38. package/src/lib/globals.d.ts +8 -0
  39. package/src/lib/index.tsx +36 -0
  40. package/src/lib/react/components/chrome.tsx +126 -0
  41. package/src/{components → lib/react/components}/control-bar.tsx +74 -96
  42. package/src/lib/react/components/overlay.tsx +121 -0
  43. package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
  44. package/src/{components → lib/react/components}/progress-bar.tsx +128 -41
  45. package/src/lib/react/components/settings.tsx +391 -0
  46. package/src/lib/react/components/sound.tsx +133 -0
  47. package/src/{components → lib/react/components}/tooltip-display.tsx +11 -8
  48. package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
  49. package/src/lib/react/hooks/use-drag-value.ts +73 -0
  50. package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
  51. package/src/lib/react/hooks/use-playback.ts +134 -0
  52. package/src/lib/react/hooks/use-thumbnails.ts +77 -0
  53. package/src/lib/react/player.ts +20 -0
  54. package/src/lib/react/source-feature.ts +90 -0
  55. package/src/lib/react/video-player.tsx +119 -0
  56. package/src/lib/utils/source.ts +60 -0
  57. package/src/lib/utils/track-label.ts +36 -0
  58. package/src/lib/utils/volume-utils.ts +13 -0
  59. package/build/components/chrome.d.ts +0 -7
  60. package/build/components/control-bar.d.ts +0 -6
  61. package/build/components/overlay.d.ts +0 -4
  62. package/build/components/playback-slider.d.ts +0 -2
  63. package/build/components/progress-bar.d.ts +0 -1
  64. package/build/components/settings.d.ts +0 -2
  65. package/build/components/sound.d.ts +0 -4
  66. package/build/components/volume-slider.d.ts +0 -6
  67. package/build/index.d.ts +0 -26
  68. package/build/index.js +0 -3416
  69. package/build/main.d.ts +0 -1
  70. package/build/state-machines/data-source.d.ts +0 -27
  71. package/build/state-machines/index.d.ts +0 -34038
  72. package/build/state-machines/media-properties.d.ts +0 -45
  73. package/build/state-machines/media-source.d.ts +0 -34
  74. package/build/state-machines/media.d.ts +0 -8558
  75. package/build/state-machines/subtitles.d.ts +0 -56
  76. package/build/state-machines/thumbnails.d.ts +0 -24
  77. package/build/state-machines/utils.d.ts +0 -26
  78. package/build/utils/actor-utils.d.ts +0 -2
  79. package/build/utils/context.d.ts +0 -14
  80. package/build/utils/index.d.ts +0 -4
  81. package/build/utils/languages.d.ts +0 -260
  82. package/build/utils/mp4box.d.ts +0 -61
  83. package/build/utils/use-local-storage.d.ts +0 -3
  84. package/build/utils/use-scrub.d.ts +0 -11
  85. package/build/utils/volume-utils.d.ts +0 -17
  86. package/build/utils/window-height.d.ts +0 -2
  87. package/src/components/chrome.tsx +0 -95
  88. package/src/components/overlay.tsx +0 -91
  89. package/src/components/settings.tsx +0 -322
  90. package/src/components/sound.tsx +0 -101
  91. package/src/index.tsx +0 -195
  92. package/src/main.tsx +0 -169
  93. package/src/state-machines/data-source.ts +0 -84
  94. package/src/state-machines/index.ts +0 -5
  95. package/src/state-machines/media-properties.ts +0 -82
  96. package/src/state-machines/media-source.ts +0 -129
  97. package/src/state-machines/media.ts +0 -255
  98. package/src/state-machines/subtitles.ts +0 -285
  99. package/src/state-machines/thumbnails.ts +0 -123
  100. package/src/state-machines/utils.ts +0 -94
  101. package/src/utils/actor-utils.ts +0 -19
  102. package/src/utils/context.ts +0 -23
  103. package/src/utils/index.ts +0 -229
  104. package/src/utils/languages.ts +0 -262
  105. package/src/utils/mp4box.ts +0 -74
  106. package/src/utils/use-local-storage.ts +0 -30
  107. package/src/utils/use-scrub.ts +0 -40
  108. package/src/utils/volume-utils.ts +0 -39
  109. package/src/utils/window-height.ts +0 -19
  110. package/src/vite-env.d.ts +0 -1
  111. package/tsconfig.json +0 -28
  112. package/tsconfig.node.json +0 -9
  113. /package/{build → dist}/utils/colors.d.ts +0 -0
  114. /package/{build → dist}/utils/fonts.d.ts +0 -0
  115. /package/{build → dist}/utils/time.d.ts +0 -0
  116. /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
  117. /package/src/{utils → lib/utils}/colors.ts +0 -0
  118. /package/src/{utils → lib/utils}/fonts.ts +0 -0
  119. /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
+ }