@banou/media-player 0.0.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/src/index.tsx ADDED
@@ -0,0 +1,597 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { ClassAttributes, ReactNode, SyntheticEvent, VideoHTMLAttributes } from 'react'
3
+ import type { MP4Info } from 'mp4box'
4
+
5
+ import { forwardRef, useEffect, useRef, useState } from 'react'
6
+ import { css } from '@emotion/react'
7
+ import { createFile } from 'mp4box'
8
+ import { makeTransmuxer as libavMakeTransmuxer, SEEK_WHENCE_FLAG } from 'libav-wasm'
9
+
10
+ import { queuedDebounceWithLastCall } from './utils'
11
+ import Chrome from './chrome'
12
+ import PQueue from 'p-queue'
13
+
14
+ export type TransmuxError = {
15
+ critical: boolean
16
+ message: string
17
+ count: number
18
+ }
19
+
20
+ export type Subtitle = {
21
+ title: string
22
+ language: string
23
+ data: string
24
+ }
25
+
26
+ export type Attachment = {
27
+ filename: string
28
+ mimetype: string
29
+ data: Uint8Array
30
+ }
31
+
32
+ type Chunk = {
33
+ offset: number
34
+ buffer: Uint8Array
35
+ pts: number
36
+ duration: number
37
+ pos: number
38
+ }
39
+
40
+ const BASE_BUFFER_SIZE = 5_000_000
41
+ const PRE_SEEK_NEEDED_BUFFERS_IN_SECONDS = 10
42
+ const POST_SEEK_NEEDED_BUFFERS_IN_SECONDS = 20
43
+ const POST_SEEK_REMOVE_BUFFERS_IN_SECONDS = 60
44
+
45
+ const style = css`
46
+ display: grid;
47
+ justify-content: center;
48
+ background-color: #111;
49
+
50
+ video {
51
+ pointer-events: none;
52
+ grid-column: 1;
53
+ grid-row: 1;
54
+
55
+ height: 100%;
56
+ max-height: 100vh;
57
+ max-width: 100%;
58
+ background-color: black;
59
+ }
60
+
61
+ .chrome {
62
+ grid-column: 1;
63
+ grid-row: 1;
64
+ }
65
+ `
66
+
67
+ export type FKNVideoControlOptions = {
68
+
69
+ }
70
+
71
+ export type FKNVideoControl = (args: FKNVideoControlOptions) => JSX.Element
72
+
73
+ export type FKNVideoOptions = {
74
+ customOverlay?: ReactNode
75
+ baseBufferSize?: number
76
+ size?: number
77
+ fetch: (offset: number, size: number) => Promise<Response>
78
+ customControls?: FKNVideoControl[]
79
+ publicPath: string
80
+ wasmUrl: string
81
+ libavWorkerUrl: string
82
+ libavWorkerOptions?: WorkerOptions
83
+ libassWorkerUrl: string
84
+ makeTransmuxer?: typeof libavMakeTransmuxer
85
+ }
86
+
87
+ export type HeaderChunk = Chunk & { buffer: { buffer: { fileStart: number } } }
88
+
89
+ const FKNVideo = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLInputElement> & FKNVideoOptions>(({
90
+ customOverlay,
91
+ baseBufferSize = BASE_BUFFER_SIZE,
92
+ size: contentLength,
93
+ fetch,
94
+ customControls,
95
+ publicPath,
96
+ wasmUrl,
97
+ libavWorkerUrl,
98
+ libavWorkerOptions,
99
+ libassWorkerUrl,
100
+ makeTransmuxer = libavMakeTransmuxer
101
+ }, ref) => {
102
+ const [loading, setLoading] = useState(true)
103
+ const containerRef = useRef<HTMLDivElement>(null)
104
+ const videoRef = useRef<HTMLVideoElement>()
105
+ const [videoElement, setVideoElement] = useState<HTMLVideoElement>()
106
+ const [isPlaying, setIsPlaying] = useState(!(videoRef?.current?.paused ?? true))
107
+ const [currentTime, setCurrentTime] = useState(0)
108
+ const [attachments, setAttachments] = useState<Attachment[] | undefined>(undefined)
109
+ const [tracks, setTracks] = useState<Subtitle[]>([])
110
+ const [errors, setErrors] = useState<TransmuxError[]>([])
111
+ const [duration, setDuration] = useState<number>()
112
+ const [currentLoadedRange, setCurrentLoadedRange] = useState<[number, number]>([0, 0])
113
+ const seekRef = useRef<(time: number) => any>()
114
+ const [needsInitialInteraction, setNeedsInitialInteraction] = useState(false)
115
+
116
+ const fetchRef = useRef(fetch)
117
+
118
+ useEffect(() => {
119
+ fetchRef.current = fetch
120
+ }, [fetch])
121
+
122
+ useEffect(() => {
123
+ if (!contentLength || !videoElement) return
124
+ let _transmuxer: ReturnType<typeof makeTransmuxer>
125
+ let rangeUpdateInterval: number
126
+ ;(async () => {
127
+ let mp4boxfile = createFile()
128
+ mp4boxfile.onError = (error) => console.error('mp4box error', error)
129
+
130
+ let _resolveInfo: (value: unknown) => void
131
+ const infoPromise = new Promise((resolve) => { _resolveInfo = resolve })
132
+
133
+ let mime = 'video/mp4; codecs=\"'
134
+ let info: any | undefined
135
+ mp4boxfile.onReady = (_info: MP4Info) => {
136
+ info = _info
137
+ for (let i = 0; i < info.tracks.length; i++) {
138
+ if (i !== 0) mime += ','
139
+ mime += info.tracks[i].codec
140
+ }
141
+ mime += '\"'
142
+ _resolveInfo(info)
143
+ }
144
+
145
+ let headerChunk: HeaderChunk | undefined
146
+ let chunks: Chunk[] = []
147
+
148
+ _transmuxer = makeTransmuxer({
149
+ publicPath,
150
+ workerUrl: libavWorkerUrl,
151
+ workerOptions: libavWorkerOptions,
152
+ bufferSize: baseBufferSize,
153
+ length: contentLength,
154
+ read: (offset, size) =>
155
+ fetchRef
156
+ .current(offset, Math.min(offset + size, contentLength) - 1)
157
+ .then(res => res.arrayBuffer()),
158
+ seek: async (currentOffset, offset, whence) => {
159
+ if (whence === SEEK_WHENCE_FLAG.SEEK_CUR) {
160
+ return currentOffset + offset
161
+ }
162
+ if (whence === SEEK_WHENCE_FLAG.SEEK_END) {
163
+ return -1
164
+ }
165
+ if (whence === SEEK_WHENCE_FLAG.SEEK_SET) {
166
+ // little trick to prevent libav from requesting end of file data on init that might take a while to fetch
167
+ // if (!initDone && offset > (contentLength - 1_000_000)) return -1
168
+ return offset
169
+ }
170
+ if (whence === SEEK_WHENCE_FLAG.AVSEEK_SIZE) {
171
+ return contentLength
172
+ }
173
+ return -1
174
+ },
175
+ subtitle: (title, language, subtitle) => {
176
+ setTracks(tracks =>
177
+ tracks.find(({ title: _title }) => _title === title)
178
+ ? tracks.map((track) => track.title === title ? { title, language, data: subtitle } : track)
179
+ : [...tracks, { title, language, data: subtitle }]
180
+ )
181
+ },
182
+ attachment: (filename: string, mimetype: string, buffer: ArrayBuffer) => {
183
+ if (attachments?.find(({ filename: _filename }) => filename === _filename)) return
184
+ setAttachments(attachments => [
185
+ ...attachments ?? [],
186
+ { filename, mimetype, data: new Uint8Array(buffer) }
187
+ ])
188
+ },
189
+ write: ({ isHeader, offset, buffer, pts, duration, pos }) => {
190
+ if (isHeader) {
191
+ if (!headerChunk) {
192
+ headerChunk = {
193
+ offset,
194
+ buffer: new Uint8Array(buffer) as HeaderChunk['buffer'],
195
+ pts,
196
+ duration,
197
+ pos
198
+ }
199
+ }
200
+ return
201
+ }
202
+ chunks = [
203
+ ...chunks,
204
+ {
205
+ offset,
206
+ buffer: new Uint8Array(buffer),
207
+ pts,
208
+ duration,
209
+ pos
210
+ }
211
+ ]
212
+ }
213
+ })
214
+
215
+ const processingQueue = new PQueue({ concurrency: 1 })
216
+
217
+ const process = (timeToProcess = POST_SEEK_NEEDED_BUFFERS_IN_SECONDS) =>
218
+ processingQueue.add(
219
+ () => transmuxer.process(timeToProcess),
220
+ { throwOnTimeout: true }
221
+ )
222
+
223
+ const transmuxer = await _transmuxer
224
+
225
+ await transmuxer.init()
226
+
227
+ if (!headerChunk) throw new Error('No header chunk found after transmuxer init')
228
+
229
+ headerChunk.buffer.buffer.fileStart = 0
230
+ mp4boxfile.appendBuffer(headerChunk.buffer.buffer)
231
+
232
+ const duration = (await transmuxer.getInfo()).input.duration / 1_000_000
233
+ setDuration(duration)
234
+
235
+ await infoPromise
236
+
237
+ const video = videoElement
238
+ video.addEventListener('error', ev => {
239
+ // @ts-ignore
240
+ console.error(ev.target?.error)
241
+ })
242
+
243
+ const mediaSource = new MediaSource()
244
+ videoElement.src = URL.createObjectURL(mediaSource)
245
+
246
+ const sourceBuffer: SourceBuffer =
247
+ await new Promise(resolve =>
248
+ mediaSource.addEventListener(
249
+ 'sourceopen',
250
+ () => resolve(mediaSource.addSourceBuffer(mime)),
251
+ { once: true }
252
+ )
253
+ )
254
+
255
+ mediaSource.duration = duration
256
+ sourceBuffer.mode = 'segments'
257
+
258
+ const queue = new PQueue({ concurrency: 1 })
259
+
260
+ const getTimeRanges = () =>
261
+ Array(sourceBuffer.buffered.length)
262
+ .fill(undefined)
263
+ .map((_, index) => ({
264
+ index,
265
+ start: sourceBuffer.buffered.start(index),
266
+ end: sourceBuffer.buffered.end(index)
267
+ }))
268
+
269
+ const setupListeners = (resolve: (value: Event) => void, reject: (reason: Event) => void) => {
270
+ const updateEndListener = (ev: Event) => {
271
+ resolve(ev)
272
+ unregisterListeners()
273
+ }
274
+ const abortListener = (ev: Event) => {
275
+ resolve(ev)
276
+ unregisterListeners()
277
+ }
278
+ const errorListener = (ev: Event) => {
279
+ console.error(ev)
280
+ reject(ev)
281
+ unregisterListeners()
282
+ }
283
+ const unregisterListeners = () => {
284
+ sourceBuffer.removeEventListener('updateend', updateEndListener)
285
+ sourceBuffer.removeEventListener('abort', abortListener)
286
+ sourceBuffer.removeEventListener('error', errorListener)
287
+ }
288
+ sourceBuffer.addEventListener('updateend', updateEndListener, { once: true })
289
+ sourceBuffer.addEventListener('abort', abortListener, { once: true })
290
+ sourceBuffer.addEventListener('error', errorListener, { once: true })
291
+ }
292
+
293
+ const appendBuffer = (buffer: ArrayBuffer) =>
294
+ queue.add(() =>
295
+ new Promise<Event>((resolve, reject) => {
296
+ setupListeners(resolve, reject)
297
+ sourceBuffer.appendBuffer(buffer)
298
+ })
299
+ )
300
+
301
+ const bufferChunk = (chunk: Chunk) => appendBuffer(chunk.buffer.buffer)
302
+
303
+ const unbufferRange = async (start: number, end: number) =>
304
+ queue.add(() =>
305
+ new Promise((resolve, reject) => {
306
+ setupListeners(resolve, reject)
307
+ sourceBuffer.remove(start, end)
308
+ })
309
+ )
310
+
311
+ const unbufferChunk = (chunk: Chunk) =>
312
+ unbufferRange(chunk.pts, chunk.pts + chunk.duration)
313
+
314
+ const removeChunk = async (chunk: Chunk) => {
315
+ const chunkIndex = chunks.indexOf(chunk)
316
+ if (chunkIndex === -1) throw new RangeError('No chunk found')
317
+ await unbufferChunk(chunk)
318
+ chunks = chunks.filter(_chunk => _chunk !== chunk)
319
+ }
320
+
321
+ let isSeeking = false
322
+
323
+ // todo: add error checker & retry to seek a bit earlier
324
+ const seek = queuedDebounceWithLastCall(500, async (time: number) => {
325
+ const isPlaying = !video.paused
326
+ videoElement?.pause()
327
+ isSeeking = true
328
+ setCurrentTime(time)
329
+ const ranges = getTimeRanges()
330
+ if (ranges.some(({ start, end }) => time >= start && time <= end)) {
331
+ video.currentTime = time
332
+ isSeeking = false
333
+ if (isPlaying) video.play()
334
+ return
335
+ }
336
+ const allTasksDone = new Promise(resolve => {
337
+ processingQueue.size && processingQueue.pending
338
+ ? (
339
+ processingQueue.on(
340
+ 'next',
341
+ () =>
342
+ processingQueue.pending === 0
343
+ ? resolve(undefined)
344
+ : undefined
345
+ )
346
+ )
347
+ : resolve(undefined)
348
+ })
349
+ processingQueue.pause()
350
+ processingQueue.clear()
351
+ await allTasksDone
352
+ processingQueue.start()
353
+
354
+ const seekTime = Math.max(0, time - PRE_SEEK_NEEDED_BUFFERS_IN_SECONDS)
355
+ await transmuxer.seek(seekTime)
356
+ await process(POST_SEEK_NEEDED_BUFFERS_IN_SECONDS + POST_SEEK_NEEDED_BUFFERS_IN_SECONDS)
357
+ for (const range of ranges) {
358
+ await unbufferRange(range.start, range.end)
359
+ }
360
+ for (const chunk of chunks) {
361
+ if (chunk.pts <= seekTime) continue
362
+ await bufferChunk(chunk)
363
+ }
364
+ // updateBufferedRanges(time)
365
+ video.currentTime = time
366
+ isSeeking = false
367
+ if (isPlaying) video.play()
368
+ })
369
+
370
+ seekRef.current = seek
371
+
372
+ const updateBufferedRanges = async (time: number) => {
373
+ const ranges1 = getTimeRanges()
374
+ const neededChunks =
375
+ chunks
376
+ .filter(({ pts, duration }) =>
377
+ ((time - PRE_SEEK_NEEDED_BUFFERS_IN_SECONDS) < pts)
378
+ && ((time + POST_SEEK_REMOVE_BUFFERS_IN_SECONDS) > (pts + duration))
379
+ )
380
+
381
+ const shouldBeBufferedChunks =
382
+ neededChunks
383
+ .filter(({ pts, duration }) =>
384
+ ((time - PRE_SEEK_NEEDED_BUFFERS_IN_SECONDS) < pts)
385
+ && ((time + POST_SEEK_NEEDED_BUFFERS_IN_SECONDS) > (pts + duration))
386
+ )
387
+
388
+ const shouldBeUnbufferedChunks =
389
+ chunks
390
+ .filter(({ pts, duration }) => ranges1.some(({ start, end }) => start < (pts + (duration / 2)) && (pts + (duration / 2)) < end))
391
+ .filter((chunk) => !shouldBeBufferedChunks.includes(chunk))
392
+
393
+ const nonNeededChunks =
394
+ chunks
395
+ .filter((chunk) => !neededChunks.includes(chunk))
396
+
397
+ for (const shouldBeUnbufferedChunk of shouldBeUnbufferedChunks) {
398
+ await unbufferChunk(shouldBeUnbufferedChunk)
399
+ }
400
+ for (const nonNeededChunk of nonNeededChunks) {
401
+ await removeChunk(nonNeededChunk)
402
+ }
403
+ const firstChunk = neededChunks.sort(({ pts }, { pts: pts2 }) => pts - pts2).at(0)
404
+ const lastChunk = neededChunks.sort(({ pts, duration }, { pts: pts2, duration: duration2 }) => (pts + duration) - (pts2 + duration2)).at(-1)
405
+
406
+ for (const chunk of shouldBeBufferedChunks) {
407
+ try {
408
+ await bufferChunk(chunk)
409
+ } catch (err) {
410
+ console.error(err)
411
+ if (!(err instanceof Event)) throw err
412
+ break
413
+ }
414
+ }
415
+
416
+ const lowestAllowedStart =
417
+ firstChunk
418
+ ? Math.max(firstChunk?.pts - PRE_SEEK_NEEDED_BUFFERS_IN_SECONDS, 0)
419
+ : undefined
420
+ const highestAllowedEnd =
421
+ lastChunk
422
+ ? Math.min(lastChunk.pts + lastChunk.duration + POST_SEEK_NEEDED_BUFFERS_IN_SECONDS, duration)
423
+ : undefined
424
+ const ranges = getTimeRanges()
425
+ for (const { start, end } of ranges) {
426
+ if (!lowestAllowedStart || !highestAllowedEnd) continue
427
+ if (lowestAllowedStart !== undefined && start < lowestAllowedStart) {
428
+ await unbufferRange(start, lowestAllowedStart)
429
+ }
430
+ if (highestAllowedEnd !== undefined && end > highestAllowedEnd) {
431
+ await unbufferRange(highestAllowedEnd, end)
432
+ }
433
+ }
434
+ }
435
+
436
+ const loadedMetadataPromise = new Promise(resolve => {
437
+ video.addEventListener('loadedmetadata', () => resolve(undefined), { once: true })
438
+ })
439
+
440
+ video.addEventListener(
441
+ 'canplay',
442
+ () =>
443
+ video
444
+ .play()
445
+ // Catch error if user denied autoplay
446
+ .catch(err => {
447
+ if (!(err instanceof DOMException) || err.name !== 'NotAllowedError') return
448
+ setNeedsInitialInteraction(true)
449
+ setLoading(false)
450
+ }),
451
+ { once: true }
452
+ )
453
+
454
+ await appendBuffer(headerChunk.buffer)
455
+ await loadedMetadataPromise
456
+
457
+ await process(20)
458
+ await updateBufferedRanges(0)
459
+
460
+ const timeUpdateWork = queuedDebounceWithLastCall(500, async (time: number) => {
461
+ const lastChunk = chunks.sort(({ pts }, { pts: pts2 }) => pts - pts2).at(-1)
462
+ if (lastChunk && lastChunk.pts < time + POST_SEEK_NEEDED_BUFFERS_IN_SECONDS) {
463
+ await process()
464
+ }
465
+ await updateBufferedRanges(time)
466
+ })
467
+
468
+ video.addEventListener('timeupdate', () => {
469
+ if (isSeeking) return
470
+ timeUpdateWork(video.currentTime)
471
+ })
472
+
473
+ rangeUpdateInterval = window.setInterval(() => {
474
+ const ranges = getTimeRanges()
475
+ const firstRange = ranges.sort(({ start }, { start: start2 }) => start - start2).at(0)
476
+ const lastRange = ranges.sort(({ end }, { end: end2 }) => end - end2).at(-1)
477
+ if (!firstRange || !lastRange) return
478
+ let firstPts = chunks.filter(({ pts, duration }) => pts + (duration / 2) > firstRange.start).sort(({ pts }, { pts: pts2 }) => pts - pts2).at(0)?.pts
479
+ let lastPts = chunks.filter(({ pts, duration }) => pts + (duration / 2) < lastRange.end).sort(({ pts }, { pts: pts2 }) => pts - pts2).at(-1)?.pts
480
+ if (firstPts === undefined || lastPts === undefined) return
481
+ setCurrentLoadedRange([firstPts, lastPts])
482
+ }, 200)
483
+ })()
484
+
485
+ return () => {
486
+ _transmuxer.then(transmuxer => transmuxer.destroy(true))
487
+ window.clearInterval(rangeUpdateInterval)
488
+ }
489
+ }, [contentLength, videoElement])
490
+
491
+ const waiting: React.DOMAttributes<HTMLVideoElement>['onWaiting'] = (ev) => {
492
+ setLoading(true)
493
+ }
494
+
495
+ const [isSeeking, setIsSeeking] = useState(false)
496
+
497
+ const seeked: React.DOMAttributes<HTMLVideoElement>['onSeeked'] = (ev) => {
498
+ setIsSeeking(false)
499
+ }
500
+
501
+ const seeking: React.DOMAttributes<HTMLVideoElement>['onSeeking'] = (ev) => {
502
+ if (!videoRef.current) return
503
+ setIsSeeking(true)
504
+ setCurrentTime(videoRef.current?.currentTime ?? 0)
505
+ }
506
+
507
+ const seek = (time: number) => {
508
+ setIsSeeking(true)
509
+ seekRef.current?.(time)
510
+ }
511
+
512
+ const timeUpdate: React.DOMAttributes<HTMLVideoElement>['onTimeUpdate'] = (ev) => {
513
+ if (isSeeking) return
514
+ setCurrentTime(videoRef.current?.currentTime ?? 0)
515
+ setLoading(false)
516
+ }
517
+
518
+ const playbackUpdate = (playing: boolean) => (ev: SyntheticEvent<HTMLVideoElement, Event>) => {
519
+ setIsPlaying(playing)
520
+ }
521
+
522
+ // todo: implement subtitles in PiP using https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream
523
+ const pictureInPicture = () => {
524
+ if (document.pictureInPictureElement) return document.exitPictureInPicture()
525
+ videoRef.current?.requestPictureInPicture()
526
+ }
527
+
528
+ const fullscreen = () => {
529
+ if (document.fullscreenElement) return document.exitFullscreen()
530
+ // @ts-ignore
531
+ containerRef.current?.requestFullscreen()
532
+ }
533
+
534
+ const play = async () => {
535
+ setNeedsInitialInteraction(false)
536
+ const isPaused = videoRef.current?.paused
537
+ if (isPaused) await videoRef.current?.play()
538
+ else await videoRef.current?.pause()
539
+ }
540
+
541
+ const setVolume = (volume: number) => {
542
+ if (!videoRef.current) return
543
+ videoRef.current.volume = volume
544
+ }
545
+
546
+ const getVolume = () => {
547
+ if (!videoRef.current) return
548
+ return videoRef.current.volume
549
+ }
550
+
551
+ const refFunction: ClassAttributes<HTMLVideoElement>['ref'] = (element) => {
552
+ if (typeof ref === 'function') ref(element)
553
+ if (ref && 'current' in ref) ref.current = element
554
+ videoRef.current = element ?? undefined
555
+ setVideoElement(videoRef.current)
556
+ }
557
+
558
+ return (
559
+ <div css={style} ref={containerRef}>
560
+ <video
561
+ ref={refFunction}
562
+ onWaiting={waiting}
563
+ onSeeking={seeking}
564
+ onSeeked={seeked}
565
+ onTimeUpdate={timeUpdate}
566
+ onPlay={playbackUpdate(true)}
567
+ onPause={playbackUpdate(false)}
568
+ // autoPlay={true}
569
+ />
570
+ <Chrome
571
+ className="chrome"
572
+ customOverlay={customOverlay}
573
+ isPlaying={isPlaying}
574
+ video={videoRef}
575
+ needsInitialInteraction={needsInitialInteraction}
576
+ loading={loading}
577
+ duration={duration}
578
+ currentTime={currentTime}
579
+ loadedTime={currentLoadedRange}
580
+ pictureInPicture={pictureInPicture}
581
+ fullscreen={fullscreen}
582
+ play={play}
583
+ seek={seek}
584
+ getVolume={getVolume}
585
+ setVolume={setVolume}
586
+ attachments={attachments}
587
+ tracks={tracks}
588
+ errors={errors}
589
+ customControls={customControls}
590
+ libassWorkerUrl={libassWorkerUrl}
591
+ wasmUrl={wasmUrl}
592
+ />
593
+ </div>
594
+ )
595
+ })
596
+
597
+ export default FKNVideo