@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/README.md +13 -0
- package/build/chrome/bottom.d.ts +20 -0
- package/build/chrome/index.d.ts +26 -0
- package/build/chrome/overlay.d.ts +9 -0
- package/build/index.d.ts +48 -0
- package/build/index.js +2725 -0
- package/build/languages.d.ts +260 -0
- package/build/main.d.ts +1 -0
- package/build/mp4box.d.ts +61 -0
- package/build/use-local-storage.d.ts +6 -0
- package/build/use-scrub.d.ts +10 -0
- package/build/utils.d.ts +13 -0
- package/package.json +49 -0
- package/src/chrome/bottom.tsx +651 -0
- package/src/chrome/index.tsx +197 -0
- package/src/chrome/overlay.tsx +104 -0
- package/src/index.tsx +597 -0
- package/src/languages.ts +262 -0
- package/src/main.tsx +187 -0
- package/src/mp4box.ts +74 -0
- package/src/use-local-storage.ts +32 -0
- package/src/use-scrub.ts +39 -0
- package/src/utils.ts +385 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +26 -0
- package/tsconfig.node.json +9 -0
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
|