@banou/media-player 0.6.2 → 0.8.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 +149 -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 +14 -0
- package/dist/index.js +1610 -0
- package/dist/react/components/chrome.d.ts +12 -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 +8 -0
- package/dist/react/hooks/use-thumbnails.d.ts +12 -0
- package/dist/react/media.d.ts +98 -0
- package/dist/react/player.d.ts +26 -0
- package/dist/react/source-feature.d.ts +106 -0
- package/dist/react/video-player.d.ts +83 -0
- package/dist/utils/source.d.ts +30 -0
- package/dist/utils/track-label.d.ts +31 -0
- package/dist/utils/volume-utils.d.ts +6 -0
- package/package.json +58 -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 +51 -0
- package/src/lib/react/components/chrome.tsx +130 -0
- package/src/{components → lib/react/components}/control-bar.tsx +74 -96
- package/src/lib/react/components/overlay.tsx +125 -0
- package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
- package/src/lib/react/components/progress-bar.tsx +341 -0
- package/src/lib/react/components/settings.tsx +384 -0
- package/src/lib/react/components/sound.tsx +133 -0
- package/src/{components → lib/react/components}/tooltip-display.tsx +15 -12
- 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 +146 -0
- package/src/lib/react/hooks/use-thumbnails.ts +78 -0
- package/src/lib/react/media.ts +124 -0
- package/src/lib/react/player.ts +20 -0
- package/src/lib/react/source-feature.ts +108 -0
- package/src/lib/react/video-player.tsx +223 -0
- package/src/lib/utils/fonts.ts +160 -0
- package/src/lib/utils/source.ts +60 -0
- package/src/lib/utils/track-label.ts +51 -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/progress-bar.tsx +0 -251
- 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/fonts.ts +0 -160
- 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}/time.ts +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import { useRef, useEffect, useMemo } from 'react'
|
|
2
3
|
import { css } from '@emotion/react'
|
|
3
4
|
|
|
4
5
|
import { TooltipDisplay } from './tooltip-display'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { usePlayer } from '../player'
|
|
7
|
+
import { useDragValue } from '../hooks/use-drag-value'
|
|
8
|
+
import { logToLinearVolume } from '../../utils/volume-utils'
|
|
7
9
|
|
|
8
10
|
const style = css`
|
|
9
11
|
display: flex;
|
|
@@ -11,6 +13,10 @@ const style = css`
|
|
|
11
13
|
|
|
12
14
|
padding: 16px 4px;
|
|
13
15
|
margin: 0 2px;
|
|
16
|
+
/* a finger needs a 44px band to press, so the padding grows while the track stays 3px */
|
|
17
|
+
@media (pointer: coarse) {
|
|
18
|
+
padding: 22px 4px;
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
cursor: pointer;
|
|
16
22
|
|
|
@@ -22,6 +28,14 @@ const style = css`
|
|
|
22
28
|
|
|
23
29
|
width: 80px;
|
|
24
30
|
height: 3px;
|
|
31
|
+
/* the control bar has under 360px to fit every button once the slider is always open, so the
|
|
32
|
+
track lends some of its length back on a phone and takes it again on anything wider */
|
|
33
|
+
@media (pointer: coarse) {
|
|
34
|
+
width: 64px;
|
|
35
|
+
}
|
|
36
|
+
@media (pointer: coarse) and (min-width: 768px) {
|
|
37
|
+
width: 80px;
|
|
38
|
+
}
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
.volume-handle {
|
|
@@ -31,7 +45,7 @@ const style = css`
|
|
|
31
45
|
background: #ffffff;
|
|
32
46
|
border-radius: 50%;
|
|
33
47
|
transform: translateX(-50%);
|
|
34
|
-
pointer-events: none;
|
|
48
|
+
pointer-events: none;
|
|
35
49
|
|
|
36
50
|
width: 10px;
|
|
37
51
|
height: 10px;
|
|
@@ -43,60 +57,31 @@ type VolumeSliderType = {
|
|
|
43
57
|
onChange: (v: number) => void
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
|
|
47
|
-
const volume =
|
|
48
|
-
const muted =
|
|
60
|
+
export const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
|
|
61
|
+
const volume = usePlayer((state) => state.volume)
|
|
62
|
+
const muted = usePlayer((state) => state.muted)
|
|
49
63
|
|
|
50
64
|
const sliderRef = useRef<HTMLDivElement>(null)
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const getNewVolumeFromEvent = (clientX: number): number => {
|
|
54
|
-
if (!sliderRef.current) return value
|
|
55
|
-
const rect = sliderRef.current.getBoundingClientRect()
|
|
56
|
-
const xPos = clientX - rect.left
|
|
57
|
-
const clamped = Math.max(0, Math.min(xPos, rect.width))
|
|
58
|
-
return clamped / rect.width
|
|
59
|
-
}
|
|
65
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
onChange
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const handlePointerMove = (e: MouseEvent) => {
|
|
68
|
-
if (!isDragging) return
|
|
69
|
-
const newVolume = getNewVolumeFromEvent(e.clientX)
|
|
70
|
-
onChange(newVolume)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const handlePointerUp = () => {
|
|
74
|
-
setIsDragging(false)
|
|
75
|
-
}
|
|
67
|
+
// the padded row owns the gesture, but the fraction is measured across the track the handle draws in
|
|
68
|
+
const { handlers } = useDragValue({
|
|
69
|
+
ref: trackRef,
|
|
70
|
+
onChange
|
|
71
|
+
})
|
|
76
72
|
|
|
77
73
|
const handleWheel = (e: WheelEvent) => {
|
|
78
74
|
e.preventDefault()
|
|
79
75
|
const step = 0.05
|
|
80
76
|
let newVol = value
|
|
81
77
|
if (e.deltaY < 0) {
|
|
82
|
-
// scroll up
|
|
83
78
|
newVol = Math.min(1, newVol + step)
|
|
84
79
|
} else {
|
|
85
|
-
// scroll down
|
|
86
80
|
newVol = Math.max(0, newVol - step)
|
|
87
81
|
}
|
|
88
82
|
onChange(newVol)
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
window.addEventListener('mousemove', handlePointerMove)
|
|
93
|
-
window.addEventListener('mouseup', handlePointerUp)
|
|
94
|
-
return () => {
|
|
95
|
-
window.removeEventListener('mousemove', handlePointerMove)
|
|
96
|
-
window.removeEventListener('mouseup', handlePointerUp)
|
|
97
|
-
}
|
|
98
|
-
}, [isDragging])
|
|
99
|
-
|
|
100
85
|
useEffect(() => {
|
|
101
86
|
const slider = sliderRef.current
|
|
102
87
|
if (slider) {
|
|
@@ -115,7 +100,7 @@ const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
|
|
|
115
100
|
|
|
116
101
|
const displayVolumePercent = useMemo(
|
|
117
102
|
() => Math.round(muted ? 0 : logToLinearVolume(volume) * 100),
|
|
118
|
-
[volume]
|
|
103
|
+
[volume, muted]
|
|
119
104
|
)
|
|
120
105
|
|
|
121
106
|
return (
|
|
@@ -125,12 +110,13 @@ const VolumeSlider = ({ value, onChange }: VolumeSliderType) => {
|
|
|
125
110
|
<div
|
|
126
111
|
ref={sliderRef}
|
|
127
112
|
css={style}
|
|
128
|
-
|
|
113
|
+
{...handlers}
|
|
129
114
|
>
|
|
130
115
|
<div
|
|
116
|
+
ref={trackRef}
|
|
131
117
|
style={{
|
|
132
|
-
background: `linear-gradient(to right,
|
|
133
|
-
${fillColor} 0% ${fillPercent}%,
|
|
118
|
+
background: `linear-gradient(to right,
|
|
119
|
+
${fillColor} 0% ${fillPercent}%,
|
|
134
120
|
${emptyColor} ${fillPercent}% 100%
|
|
135
121
|
)`
|
|
136
122
|
}}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export type UseDragValueOptions = {
|
|
6
|
+
/** The element the fraction is measured across. */
|
|
7
|
+
ref: RefObject<HTMLElement | null>
|
|
8
|
+
/** Called with a 0..1 fraction on press and on every move, and once more on release. */
|
|
9
|
+
onChange: (fraction: number) => void
|
|
10
|
+
orientation?: 'horizontal' | 'vertical'
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Press-and-drag over an element, reported as a 0..1 fraction. */
|
|
15
|
+
export const useDragValue = ({ ref, onChange, orientation = 'horizontal', disabled = false }: UseDragValueOptions) => {
|
|
16
|
+
const [dragging, setDragging] = useState(false)
|
|
17
|
+
const onChangeRef = useRef(onChange)
|
|
18
|
+
onChangeRef.current = onChange
|
|
19
|
+
const activePointer = useRef<number | null>(null)
|
|
20
|
+
|
|
21
|
+
const fractionFor = useCallback((clientX: number, clientY: number) => {
|
|
22
|
+
const element = ref.current
|
|
23
|
+
if (!element) return 0
|
|
24
|
+
const { left, right, top, bottom } = element.getBoundingClientRect()
|
|
25
|
+
const fraction = orientation === 'horizontal'
|
|
26
|
+
? (clientX - left) / (right - left)
|
|
27
|
+
// a vertical track fills upwards, so the top of the box is 1
|
|
28
|
+
: (bottom - clientY) / (bottom - top)
|
|
29
|
+
return Math.min(Math.max(fraction, 0), 1)
|
|
30
|
+
}, [ref, orientation])
|
|
31
|
+
|
|
32
|
+
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
|
33
|
+
if (disabled || event.button > 0) return
|
|
34
|
+
// Do not add preventDefault or stopPropagation here. The first kills the compatibility mouse
|
|
35
|
+
// events the seek preview runs on, the second keeps the press from reaching the document
|
|
36
|
+
// listeners that close the popover and refresh the auto-hide timer.
|
|
37
|
+
activePointer.current = event.pointerId
|
|
38
|
+
event.currentTarget.setPointerCapture?.(event.pointerId)
|
|
39
|
+
setDragging(true)
|
|
40
|
+
onChangeRef.current(fractionFor(event.clientX, event.clientY))
|
|
41
|
+
}, [disabled, fractionFor])
|
|
42
|
+
|
|
43
|
+
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
|
44
|
+
if (activePointer.current !== event.pointerId) return
|
|
45
|
+
onChangeRef.current(fractionFor(event.clientX, event.clientY))
|
|
46
|
+
}, [fractionFor])
|
|
47
|
+
|
|
48
|
+
const endDrag = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
|
49
|
+
if (activePointer.current !== event.pointerId) return
|
|
50
|
+
activePointer.current = null
|
|
51
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId)
|
|
52
|
+
setDragging(false)
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!disabled) return
|
|
57
|
+
activePointer.current = null
|
|
58
|
+
setDragging(false)
|
|
59
|
+
}, [disabled])
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
dragging,
|
|
63
|
+
/** Spread onto the element that owns the gesture. */
|
|
64
|
+
handlers: {
|
|
65
|
+
onPointerDown,
|
|
66
|
+
onPointerMove,
|
|
67
|
+
onPointerUp: endDrag,
|
|
68
|
+
onPointerCancel: endDrag,
|
|
69
|
+
// without this the browser scrolls the page instead of moving the slider
|
|
70
|
+
style: { touchAction: 'none' as const },
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PictureInPictureController } from '../../engine'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import { createPictureInPicture } from '../../engine'
|
|
6
|
+
import { usePlayer } from '../player'
|
|
7
|
+
|
|
8
|
+
/** Picture in picture with the subtitles composited in. */
|
|
9
|
+
export const usePictureInPicture = (
|
|
10
|
+
video: HTMLVideoElement | null,
|
|
11
|
+
canvas: HTMLCanvasElement | null,
|
|
12
|
+
) => {
|
|
13
|
+
const player = usePlayer()
|
|
14
|
+
const controller = useRef<PictureInPictureController | null>(null)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!video || !canvas) return
|
|
18
|
+
const instance = createPictureInPicture({
|
|
19
|
+
video,
|
|
20
|
+
canvas,
|
|
21
|
+
// the store's action handles Safari and exits fullscreen first
|
|
22
|
+
fallback: () => player.togglePictureInPicture(),
|
|
23
|
+
})
|
|
24
|
+
controller.current = instance
|
|
25
|
+
return () => {
|
|
26
|
+
instance.destroy()
|
|
27
|
+
controller.current = null
|
|
28
|
+
}
|
|
29
|
+
}, [video, canvas, player])
|
|
30
|
+
|
|
31
|
+
return useCallback(() => {
|
|
32
|
+
void controller.current?.toggle().catch((error) => {
|
|
33
|
+
console.warn('picture in picture was refused', error)
|
|
34
|
+
})
|
|
35
|
+
}, [])
|
|
36
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { PlaybackController } from '../../engine'
|
|
2
|
+
import type { MediaPlayerLocalOptions } from '../video-player'
|
|
3
|
+
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { startPlayback } from '../../engine'
|
|
7
|
+
import { usePlayer } from '../player'
|
|
8
|
+
import { toNamedTracks } from '../../utils/track-label'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
|
|
12
|
+
* discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
|
|
13
|
+
*/
|
|
14
|
+
export const usePlayback = (
|
|
15
|
+
video: HTMLVideoElement | null,
|
|
16
|
+
canvas: HTMLCanvasElement | null,
|
|
17
|
+
/** null when the media is remote: there are no bytes, so there is no pipeline to run. */
|
|
18
|
+
options: MediaPlayerLocalOptions | null,
|
|
19
|
+
) => {
|
|
20
|
+
const player = usePlayer()
|
|
21
|
+
// The four asset urls are required on the local arm, so a default only ever applies when options is
|
|
22
|
+
// null, and the effect below returns before touching them in that case.
|
|
23
|
+
const {
|
|
24
|
+
read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
|
|
25
|
+
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
|
|
26
|
+
} = options ?? ({} as Partial<MediaPlayerLocalOptions>)
|
|
27
|
+
|
|
28
|
+
// The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
|
|
29
|
+
// `selectedAudioStream`, which is whatever is playing right now.
|
|
30
|
+
const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
|
|
31
|
+
|
|
32
|
+
const controllerRef = useRef<PlaybackController | null>(null)
|
|
33
|
+
const resumeTimeRef = useRef(0)
|
|
34
|
+
// The renderer turns the first track on by itself, so the menu has to mirror that or it shows
|
|
35
|
+
// "Disable" ticked over subtitles that are visibly on screen.
|
|
36
|
+
const subtitleChoiceMade = useRef(false)
|
|
37
|
+
|
|
38
|
+
const readRef = useRef(read)
|
|
39
|
+
readRef.current = read
|
|
40
|
+
const onSeekRef = useRef(options?.onSeek)
|
|
41
|
+
onSeekRef.current = options?.onSeek
|
|
42
|
+
const onPlaybackErrorRef = useRef(options?.onPlaybackError)
|
|
43
|
+
onPlaybackErrorRef.current = options?.onPlaybackError
|
|
44
|
+
|
|
45
|
+
// The store's ids are opaque, but everything this hook writes into it came from libav, so every id
|
|
46
|
+
// that comes back is one of its own stream indices.
|
|
47
|
+
const selectSubtitleTrack = useCallback((id: string | number | undefined) => {
|
|
48
|
+
const streamIndex = typeof id === 'number' ? id : undefined
|
|
49
|
+
subtitleChoiceMade.current = true
|
|
50
|
+
player.setSourceState({ selectedSubtitleTrack: streamIndex })
|
|
51
|
+
controllerRef.current?.selectSubtitleStream(streamIndex)
|
|
52
|
+
}, [player])
|
|
53
|
+
|
|
54
|
+
const selectAudioTrack = useCallback((id: string | number) => {
|
|
55
|
+
if (typeof id !== 'number') return
|
|
56
|
+
player.setSourceState({ selectedAudioTrack: id })
|
|
57
|
+
setAudioStreamIndex(id)
|
|
58
|
+
}, [player])
|
|
59
|
+
|
|
60
|
+
// Subscribed rather than read off the store, because it is a no-op until the media element
|
|
61
|
+
// attaches. These deps are otherwise all stable, so a mount-time publish would be the only one and
|
|
62
|
+
// both actions would stay dead defaults.
|
|
63
|
+
const setSourceState = usePlayer((state) => state.setSourceState)
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setSourceState({ selectSubtitleTrack, selectAudioTrack })
|
|
67
|
+
}, [setSourceState, selectSubtitleTrack, selectAudioTrack])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!video || !canvas || !size || !read) return
|
|
71
|
+
let cancelled = false
|
|
72
|
+
player.setSourceState({ playbackError: null, ready: false })
|
|
73
|
+
const fail = (error: unknown) => {
|
|
74
|
+
if (cancelled) return
|
|
75
|
+
console.error('playback failed', error)
|
|
76
|
+
player.setSourceState({ playbackError: error })
|
|
77
|
+
onPlaybackErrorRef.current?.(error)
|
|
78
|
+
}
|
|
79
|
+
void (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const controller = await startPlayback({
|
|
82
|
+
videoElement: video,
|
|
83
|
+
canvasElement: canvas,
|
|
84
|
+
read: (offset, length) => readRef.current!(offset, length),
|
|
85
|
+
length: size,
|
|
86
|
+
publicPath,
|
|
87
|
+
libavWorkerUrl,
|
|
88
|
+
jassubWorkerUrl,
|
|
89
|
+
jassubWasmUrl,
|
|
90
|
+
jassubLegacyWasmUrl,
|
|
91
|
+
defaultFontUrl,
|
|
92
|
+
bufferSize,
|
|
93
|
+
audioStreamIndex,
|
|
94
|
+
onReady: () => {
|
|
95
|
+
if (cancelled) return
|
|
96
|
+
player.setSourceState({ ready: true })
|
|
97
|
+
if (resumeTimeRef.current > 0) {
|
|
98
|
+
video.currentTime = resumeTimeRef.current
|
|
99
|
+
resumeTimeRef.current = 0
|
|
100
|
+
}
|
|
101
|
+
if (autoplay) video.play().catch(() => {})
|
|
102
|
+
},
|
|
103
|
+
onError: fail,
|
|
104
|
+
onRecovered: () => { if (!cancelled) player.setSourceState({ playbackError: null }) },
|
|
105
|
+
onSeek: (fraction) => onSeekRef.current?.(fraction),
|
|
106
|
+
onSubtitleStreams: (streams) => {
|
|
107
|
+
if (cancelled) return
|
|
108
|
+
player.setSourceState({
|
|
109
|
+
subtitleTracks: toNamedTracks(streams),
|
|
110
|
+
...subtitleChoiceMade.current ? {} : { selectedSubtitleTrack: streams[0]?.streamIndex },
|
|
111
|
+
})
|
|
112
|
+
},
|
|
113
|
+
onAudioStreams: (streams, selected) => {
|
|
114
|
+
if (cancelled) return
|
|
115
|
+
player.setSourceState({ audioTracks: toNamedTracks(streams), selectedAudioTrack: selected })
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
if (cancelled) {
|
|
119
|
+
controller.destroy()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
controllerRef.current = controller
|
|
123
|
+
player.setSourceState({ indexes: controller.indexes })
|
|
124
|
+
// a track chosen before this pipeline existed has to be re-applied to the new renderer
|
|
125
|
+
const chosen = player.selectedSubtitleTrack
|
|
126
|
+
if (typeof chosen === 'number') controller.selectSubtitleStream(chosen)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
fail(error)
|
|
129
|
+
}
|
|
130
|
+
})()
|
|
131
|
+
return () => {
|
|
132
|
+
cancelled = true
|
|
133
|
+
resumeTimeRef.current = video.currentTime
|
|
134
|
+
controllerRef.current?.destroy()
|
|
135
|
+
controllerRef.current = null
|
|
136
|
+
player.setSourceState({ ready: false })
|
|
137
|
+
}
|
|
138
|
+
// `read` is deliberately absent: the effect only ever calls `readRef.current`, so listing it here
|
|
139
|
+
// would tear the whole pipeline down and rebuild it whenever the caller's reader changed
|
|
140
|
+
// identity. A streaming consumer passes a fresh closure on every state update, which is several
|
|
141
|
+
// times a second, and the restart loop reads as "Loading metadata" forever at a flat 0 B/s.
|
|
142
|
+
}, [
|
|
143
|
+
player, video, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
|
|
144
|
+
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
|
|
145
|
+
])
|
|
146
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ThumbnailGenerator, ThumbnailImage } from '../../engine'
|
|
2
|
+
import type { DownloadedRange } from '../source-feature'
|
|
3
|
+
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { createThumbnailGenerator } from '../../engine'
|
|
7
|
+
|
|
8
|
+
// backs off because each failed init costs a wasm worker
|
|
9
|
+
const RETRY_DELAY = 5_000
|
|
10
|
+
const MAX_RETRY_DELAY = 60_000
|
|
11
|
+
|
|
12
|
+
export type UseSeekThumbnailsOptions = {
|
|
13
|
+
/** Optional alongside `length`/`read`: with no bytes there is nothing to generate from. */
|
|
14
|
+
publicPath: string | undefined
|
|
15
|
+
workerUrl: string | undefined
|
|
16
|
+
length: number | undefined
|
|
17
|
+
read: ((offset: number, size: number) => Promise<ArrayBuffer>) | undefined
|
|
18
|
+
/** When omitted the whole file is treated as readable, which is the case for a local file. */
|
|
19
|
+
downloadedRanges?: DownloadedRange[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const useSeekThumbnails = ({
|
|
23
|
+
publicPath, workerUrl, length, read, downloadedRanges,
|
|
24
|
+
}: UseSeekThumbnailsOptions): ThumbnailImage[] => {
|
|
25
|
+
const [thumbnails, setThumbnails] = useState<ThumbnailImage[]>([])
|
|
26
|
+
const generatorRef = useRef<ThumbnailGenerator | null>(null)
|
|
27
|
+
const readRef = useRef(read)
|
|
28
|
+
readRef.current = read
|
|
29
|
+
|
|
30
|
+
const ranges = useMemo(
|
|
31
|
+
() => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
|
|
32
|
+
// a streaming consumer changes the array identity every tick, so compare contents
|
|
33
|
+
[downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
|
|
34
|
+
)
|
|
35
|
+
const rangesRef = useRef(ranges)
|
|
36
|
+
rangesRef.current = ranges
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!length || !read || !publicPath || !workerUrl) return
|
|
40
|
+
let cancelled = false
|
|
41
|
+
let generator: ThumbnailGenerator | null = null
|
|
42
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
43
|
+
let delay = RETRY_DELAY
|
|
44
|
+
const boot = () => {
|
|
45
|
+
createThumbnailGenerator({
|
|
46
|
+
publicPath,
|
|
47
|
+
workerUrl,
|
|
48
|
+
length,
|
|
49
|
+
read: (offset, size) => readRef.current!(offset, size),
|
|
50
|
+
onThumbnails: (next) => { if (!cancelled) setThumbnails(next) },
|
|
51
|
+
}).then((created) => {
|
|
52
|
+
if (cancelled) {
|
|
53
|
+
created.destroy()
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
generator = created
|
|
57
|
+
generatorRef.current = created
|
|
58
|
+
created.update(rangesRef.current)
|
|
59
|
+
}, () => {
|
|
60
|
+
if (cancelled) return
|
|
61
|
+
retry = setTimeout(boot, delay)
|
|
62
|
+
delay = Math.min(delay * 2, MAX_RETRY_DELAY)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
boot()
|
|
66
|
+
return () => {
|
|
67
|
+
cancelled = true
|
|
68
|
+
clearTimeout(retry)
|
|
69
|
+
generatorRef.current = null
|
|
70
|
+
generator?.destroy()
|
|
71
|
+
setThumbnails([])
|
|
72
|
+
}
|
|
73
|
+
}, [length, publicPath, workerUrl])
|
|
74
|
+
|
|
75
|
+
useEffect(() => { generatorRef.current?.update(ranges) }, [ranges])
|
|
76
|
+
|
|
77
|
+
return thumbnails
|
|
78
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ThumbnailImage } from '../engine'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A media object the player drives but does not own.
|
|
5
|
+
*
|
|
6
|
+
* video.js v10 never asks for an `HTMLVideoElement`. Its `PlayerTarget` is `{ media, container }` and
|
|
7
|
+
* every feature gates on property presence alone, with no `instanceof` anywhere, so a plain object
|
|
8
|
+
* carrying these fields lights the whole chrome up. Upstream relies on the same thing: `VimeoMedia`
|
|
9
|
+
* implements `Partial<Video>` over a cross-origin iframe.
|
|
10
|
+
*
|
|
11
|
+
* Declared here rather than imported from `@videojs/media` on purpose. That package is transitive
|
|
12
|
+
* under `@videojs/core`, its `Media` type moved out of `@videojs/core/dom`'s exports between betas,
|
|
13
|
+
* and its shape changed across them, so importing it would pin every consumer to one beta. The
|
|
14
|
+
* assertion at the bottom of this file keeps this type honest against the real one at build time,
|
|
15
|
+
* where `@videojs/media` does exist.
|
|
16
|
+
*
|
|
17
|
+
* OMITTING a property is how you say "not supported". The feature owning it returns out of its
|
|
18
|
+
* `attach` and stays at its defaults, which is why this is mostly optional.
|
|
19
|
+
*/
|
|
20
|
+
export type TimeRangesLike = {
|
|
21
|
+
readonly length: number
|
|
22
|
+
start: (index: number) => number
|
|
23
|
+
end: (index: number) => number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PlayerMedia = EventTarget & {
|
|
27
|
+
/* MediaPlaybackCapability: the only genuinely required part */
|
|
28
|
+
play: () => Promise<void>
|
|
29
|
+
pause: () => void
|
|
30
|
+
paused: boolean
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Seek and source capability. Both are needed or the store goes quiet: `playbackFeature` refuses
|
|
34
|
+
* to attach without them, and `seek` returns without doing anything. A remote media that loads
|
|
35
|
+
* nothing still has to answer `src`/`currentSrc`/`readyState`/`load`, and `readyState` has to
|
|
36
|
+
* reach HAVE_FUTURE_DATA (3) or `waiting` stays true forever and the spinner never clears.
|
|
37
|
+
*/
|
|
38
|
+
currentTime: number
|
|
39
|
+
duration: number
|
|
40
|
+
seeking: boolean
|
|
41
|
+
src?: string
|
|
42
|
+
currentSrc?: string
|
|
43
|
+
readyState: number
|
|
44
|
+
load?: () => void
|
|
45
|
+
|
|
46
|
+
volume?: number
|
|
47
|
+
muted?: boolean
|
|
48
|
+
playbackRate?: number
|
|
49
|
+
ended?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Structural, not the DOM `TimeRanges`, because a remote source transports these as plain pairs and
|
|
52
|
+
* has nothing to hand back that passes an `instanceof`. Note that video.js decides "no buffer
|
|
53
|
+
* capability" by comparing against its own empty sentinel BY IDENTITY, so a hand-rolled empty
|
|
54
|
+
* `{ length: 0 }` reads as a genuinely empty buffer rather than as an absent capability. Omit the
|
|
55
|
+
* field entirely to mean absent.
|
|
56
|
+
*/
|
|
57
|
+
buffered?: TimeRangesLike
|
|
58
|
+
seekable?: TimeRangesLike
|
|
59
|
+
/** Structurally what video.js reads off a failed element, so a remote source can report one too. */
|
|
60
|
+
error?: { readonly code: number, readonly message: string } | null
|
|
61
|
+
|
|
62
|
+
videoWidth?: number
|
|
63
|
+
videoHeight?: number
|
|
64
|
+
|
|
65
|
+
requestPictureInPicture?: () => Promise<unknown>
|
|
66
|
+
requestFullscreen?: () => Promise<unknown>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the seekbar's hover previews come from.
|
|
71
|
+
*
|
|
72
|
+
* With bytes in hand the player generates them itself with a second libav worker. A media it does not
|
|
73
|
+
* own has no bytes, but a commercial source usually publishes its own storyboard, so it can answer
|
|
74
|
+
* for a time directly. Narrowed on `at`, the field that carries the difference, rather than on a tag
|
|
75
|
+
* repeating what the shape already says.
|
|
76
|
+
*/
|
|
77
|
+
export type ExternalThumbnails = {
|
|
78
|
+
at: (time: number) => ThumbnailImage | undefined
|
|
79
|
+
/** Shown all at once on the seekbar if the source can enumerate them; previews work without it. */
|
|
80
|
+
all?: ThumbnailImage[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const isExternalThumbnails = (value: unknown): value is ExternalThumbnails =>
|
|
84
|
+
!!value && typeof value === 'object' && 'at' in value
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One choice among several the player presents but does not fulfil: the source owns the switch.
|
|
88
|
+
*
|
|
89
|
+
* This is what a subtitle or audio track becomes when the media is remote. The player draws the menu
|
|
90
|
+
* and reports the pick; whoever owns the document does the work and renders the result.
|
|
91
|
+
*/
|
|
92
|
+
export type DelegatedSelection = {
|
|
93
|
+
options: readonly { id: string, label: string, disabled?: boolean }[]
|
|
94
|
+
selectedId: string | null
|
|
95
|
+
select: (id: string | null) => void | Promise<void>
|
|
96
|
+
/** Label for the entry that turns the feature off, when the source offers one. */
|
|
97
|
+
offLabel?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type DelegatedTracks = {
|
|
101
|
+
selection: DelegatedSelection
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const isDelegatedTracks = (value: unknown): value is DelegatedTracks =>
|
|
105
|
+
!!value && typeof value === 'object' && 'selection' in value
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Keeps `PlayerMedia` honest.
|
|
109
|
+
*
|
|
110
|
+
* `@videojs/media` is present in this repo but deliberately not in the published type surface, so the
|
|
111
|
+
* check lives here as a compile-time assertion instead of an import consumers inherit. If a beta bump
|
|
112
|
+
* changes the real interface in a way that matters, this stops compiling.
|
|
113
|
+
*/
|
|
114
|
+
// Deliberately NOT exported: an exported alias is emitted into the .d.ts, which would put
|
|
115
|
+
// `import('@videojs/media')` back into the published surface and undo the point of declaring this
|
|
116
|
+
// type. Unexported, it is checked here and emitted nowhere.
|
|
117
|
+
type AssertAssignable<T extends U, U> = T
|
|
118
|
+
type PlayerMediaMatchesVideoJs = AssertAssignable<
|
|
119
|
+
PlayerMedia,
|
|
120
|
+
Partial<import('@videojs/media').Video>
|
|
121
|
+
>
|
|
122
|
+
// One use, so the alias is not dead code. `never` carries nothing into the output.
|
|
123
|
+
const _assertPlayerMedia: PlayerMediaMatchesVideoJs | undefined = undefined
|
|
124
|
+
void _assertPlayerMedia
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { videoFeatures } from '@videojs/core/dom'
|
|
2
|
+
import { createPlayer } from '@videojs/react'
|
|
3
|
+
|
|
4
|
+
import { sourceFeature } from './source-feature'
|
|
5
|
+
|
|
6
|
+
// The preset plus our own source state, so the chrome reads one store rather than a store and a
|
|
7
|
+
// context. createPlayer's generic overload infers the state from the whole tuple, so the extra
|
|
8
|
+
// feature's fields are as typed as the built-in ones.
|
|
9
|
+
export const Player = createPlayer({
|
|
10
|
+
features: [...videoFeatures, sourceFeature],
|
|
11
|
+
displayName: 'MediaPlayer',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Typed player state, to be used instead of the `usePlayer` from `@videojs/react`, whose fields come
|
|
16
|
+
* back as `unknown`. With no selector it returns the store without subscribing; with one it subscribes.
|
|
17
|
+
*/
|
|
18
|
+
export const usePlayer: typeof Player.usePlayer = Player.usePlayer
|
|
19
|
+
|
|
20
|
+
export type PlayerStore = ReturnType<typeof Player.usePlayer>
|