@banou/media-player 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -3
- package/dist/engine/index.d.ts +10 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/picture-in-picture.d.ts +27 -0
- package/dist/engine/playback.d.ts +45 -0
- package/dist/engine/source-buffer.d.ts +12 -0
- package/dist/engine/subtitles.d.ts +36 -0
- package/dist/engine/thumbnails.d.ts +22 -0
- package/dist/engine-Dmc7JSd9.js +470 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1566 -0
- package/dist/react/components/chrome.d.ts +9 -0
- package/dist/react/components/control-bar.d.ts +2 -0
- package/dist/react/components/overlay.d.ts +4 -0
- package/dist/react/components/playback-slider.d.ts +2 -0
- package/dist/react/components/progress-bar.d.ts +2 -0
- package/dist/react/components/settings.d.ts +2 -0
- package/dist/react/components/sound.d.ts +7 -0
- package/{build → dist/react}/components/tooltip-display.d.ts +8 -8
- package/dist/react/components/volume-slider.d.ts +6 -0
- package/dist/react/hooks/use-drag-value.d.ts +23 -0
- package/dist/react/hooks/use-picture-in-picture.d.ts +2 -0
- package/dist/react/hooks/use-playback.d.ts +6 -0
- package/dist/react/hooks/use-thumbnails.d.ts +11 -0
- package/dist/react/player.d.ts +25 -0
- package/dist/react/source-feature.d.ts +84 -0
- package/dist/react/video-player.d.ts +43 -0
- package/dist/utils/source.d.ts +30 -0
- package/dist/utils/track-label.d.ts +19 -0
- package/dist/utils/volume-utils.d.ts +6 -0
- package/package.json +49 -37
- package/src/lib/engine/index.ts +14 -0
- package/src/lib/engine/picture-in-picture.ts +250 -0
- package/src/lib/engine/playback.ts +352 -0
- package/src/lib/engine/source-buffer.ts +61 -0
- package/src/lib/engine/subtitles.ts +210 -0
- package/src/lib/engine/thumbnails.ts +137 -0
- package/src/lib/globals.d.ts +8 -0
- package/src/lib/index.tsx +36 -0
- package/src/lib/react/components/chrome.tsx +126 -0
- package/src/{components → lib/react/components}/control-bar.tsx +74 -96
- package/src/lib/react/components/overlay.tsx +121 -0
- package/src/{components → lib/react/components}/playback-slider.tsx +30 -52
- package/src/{components → lib/react/components}/progress-bar.tsx +128 -41
- package/src/lib/react/components/settings.tsx +391 -0
- package/src/lib/react/components/sound.tsx +133 -0
- package/src/{components → lib/react/components}/tooltip-display.tsx +11 -8
- package/src/{components → lib/react/components}/volume-slider.tsx +32 -46
- package/src/lib/react/hooks/use-drag-value.ts +73 -0
- package/src/lib/react/hooks/use-picture-in-picture.ts +36 -0
- package/src/lib/react/hooks/use-playback.ts +134 -0
- package/src/lib/react/hooks/use-thumbnails.ts +77 -0
- package/src/lib/react/player.ts +20 -0
- package/src/lib/react/source-feature.ts +90 -0
- package/src/lib/react/video-player.tsx +119 -0
- package/src/lib/utils/source.ts +60 -0
- package/src/lib/utils/track-label.ts +36 -0
- package/src/lib/utils/volume-utils.ts +13 -0
- package/build/components/chrome.d.ts +0 -7
- package/build/components/control-bar.d.ts +0 -6
- package/build/components/overlay.d.ts +0 -4
- package/build/components/playback-slider.d.ts +0 -2
- package/build/components/progress-bar.d.ts +0 -1
- package/build/components/settings.d.ts +0 -2
- package/build/components/sound.d.ts +0 -4
- package/build/components/volume-slider.d.ts +0 -6
- package/build/index.d.ts +0 -26
- package/build/index.js +0 -3416
- package/build/main.d.ts +0 -1
- package/build/state-machines/data-source.d.ts +0 -27
- package/build/state-machines/index.d.ts +0 -34038
- package/build/state-machines/media-properties.d.ts +0 -45
- package/build/state-machines/media-source.d.ts +0 -34
- package/build/state-machines/media.d.ts +0 -8558
- package/build/state-machines/subtitles.d.ts +0 -56
- package/build/state-machines/thumbnails.d.ts +0 -24
- package/build/state-machines/utils.d.ts +0 -26
- package/build/utils/actor-utils.d.ts +0 -2
- package/build/utils/context.d.ts +0 -14
- package/build/utils/index.d.ts +0 -4
- package/build/utils/languages.d.ts +0 -260
- package/build/utils/mp4box.d.ts +0 -61
- package/build/utils/use-local-storage.d.ts +0 -3
- package/build/utils/use-scrub.d.ts +0 -11
- package/build/utils/volume-utils.d.ts +0 -17
- package/build/utils/window-height.d.ts +0 -2
- package/src/components/chrome.tsx +0 -95
- package/src/components/overlay.tsx +0 -91
- package/src/components/settings.tsx +0 -322
- package/src/components/sound.tsx +0 -101
- package/src/index.tsx +0 -195
- package/src/main.tsx +0 -169
- package/src/state-machines/data-source.ts +0 -84
- package/src/state-machines/index.ts +0 -5
- package/src/state-machines/media-properties.ts +0 -82
- package/src/state-machines/media-source.ts +0 -129
- package/src/state-machines/media.ts +0 -255
- package/src/state-machines/subtitles.ts +0 -285
- package/src/state-machines/thumbnails.ts +0 -123
- package/src/state-machines/utils.ts +0 -94
- package/src/utils/actor-utils.ts +0 -19
- package/src/utils/context.ts +0 -23
- package/src/utils/index.ts +0 -229
- package/src/utils/languages.ts +0 -262
- package/src/utils/mp4box.ts +0 -74
- package/src/utils/use-local-storage.ts +0 -30
- package/src/utils/use-scrub.ts +0 -40
- package/src/utils/volume-utils.ts +0 -39
- package/src/utils/window-height.ts +0 -19
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.json +0 -28
- package/tsconfig.node.json +0 -9
- /package/{build → dist}/utils/colors.d.ts +0 -0
- /package/{build → dist}/utils/fonts.d.ts +0 -0
- /package/{build → dist}/utils/time.d.ts +0 -0
- /package/src/{assets → lib/assets}/picture-in-picture.svg +0 -0
- /package/src/{utils → lib/utils}/colors.ts +0 -0
- /package/src/{utils → lib/utils}/fonts.ts +0 -0
- /package/src/{utils → lib/utils}/time.ts +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import type { Ref } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useMemo } from 'react'
|
|
5
|
+
import { css } from '@emotion/react'
|
|
6
|
+
import { Volume1, Volume2, VolumeX } from 'react-feather'
|
|
7
|
+
|
|
8
|
+
import { usePlayer } from '../player'
|
|
9
|
+
import { TooltipDisplay } from './tooltip-display'
|
|
10
|
+
import { fonts } from '../../utils/fonts'
|
|
11
|
+
import VolumeSlider from './volume-slider'
|
|
12
|
+
import { linearToLogVolume, logToLinearVolume } from '../../utils/volume-utils'
|
|
13
|
+
|
|
14
|
+
const style = css`
|
|
15
|
+
position: relative;
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
|
|
19
|
+
.volume-slider-container {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
|
|
23
|
+
width: 0;
|
|
24
|
+
margin-right: 0;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
|
|
28
|
+
transition: margin .2s cubic-bezier(0.4,0,1,1), width .2s cubic-bezier(0.4,0,1,1);
|
|
29
|
+
-webkit-transition: margin .2s cubic-bezier(0.4,0,1,1), width .2s cubic-bezier(0.4,0,1,1);
|
|
30
|
+
|
|
31
|
+
.volume-slider-background {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: row;
|
|
34
|
+
align-items: center;
|
|
35
|
+
|
|
36
|
+
border-radius: 4px;
|
|
37
|
+
|
|
38
|
+
.volume-value {
|
|
39
|
+
${fonts.bMedium.regular};
|
|
40
|
+
color: #ffffff;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&:hover .volume-slider-container {
|
|
46
|
+
width: 9rem;
|
|
47
|
+
|
|
48
|
+
transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
|
|
49
|
+
-webkit-transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
|
|
50
|
+
|
|
51
|
+
pointer-events: auto;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* a touch screen has no hover, so the slider stays open there and the button only mutes */
|
|
55
|
+
@media (pointer: coarse) {
|
|
56
|
+
/* auto, not a fixed rem: the container keeps overflow: hidden, so a narrower fixed width
|
|
57
|
+
silently clips the track and makes the clipped part unhittable */
|
|
58
|
+
.volume-slider-container,
|
|
59
|
+
&:hover .volume-slider-container {
|
|
60
|
+
width: auto;
|
|
61
|
+
|
|
62
|
+
pointer-events: auto;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* a 44px press target around an 18px icon, floored here because the control bar sets the
|
|
66
|
+
padding at a higher specificity */
|
|
67
|
+
.sound {
|
|
68
|
+
/* border-box explicitly, since a consumer reset cannot be assumed */
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
|
|
72
|
+
min-width: 44px;
|
|
73
|
+
min-height: 44px;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
`
|
|
78
|
+
|
|
79
|
+
export type SoundProps = {
|
|
80
|
+
/** Lands on the button, which is what the control bar attaches its wheel listener to. */
|
|
81
|
+
ref?: Ref<HTMLButtonElement> | ((element: HTMLButtonElement | null) => void)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const Sound = ({ ref }: SoundProps) => {
|
|
85
|
+
const player = usePlayer()
|
|
86
|
+
const volume = usePlayer((state) => state.volume)
|
|
87
|
+
const muted = usePlayer((state) => state.muted)
|
|
88
|
+
|
|
89
|
+
// the store holds gain, the slider holds linear position, so every read and write converts
|
|
90
|
+
const linearVolume = useMemo(() => logToLinearVolume(volume), [volume])
|
|
91
|
+
|
|
92
|
+
// linearToLogVolume floors its input, so the slider never produces a zero and the store's own
|
|
93
|
+
// unmute-above-zero is enough
|
|
94
|
+
const setVolume = (newLinearVolume: number) => {
|
|
95
|
+
player.setVolume(linearToLogVolume(newLinearVolume))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div css={style}>
|
|
100
|
+
<TooltipDisplay
|
|
101
|
+
id='sound'
|
|
102
|
+
text={
|
|
103
|
+
<button
|
|
104
|
+
className='sound'
|
|
105
|
+
type='button'
|
|
106
|
+
onClick={() => player.toggleMuted()}
|
|
107
|
+
ref={ref}
|
|
108
|
+
>
|
|
109
|
+
{muted || volume === 0
|
|
110
|
+
? <VolumeX size={18} color='#fff' />
|
|
111
|
+
: volume <= 0.5
|
|
112
|
+
? <Volume1 size={18} color='#fff' />
|
|
113
|
+
: <Volume2 size={18} color='#fff' />
|
|
114
|
+
}
|
|
115
|
+
</button>
|
|
116
|
+
}
|
|
117
|
+
toolTipText={
|
|
118
|
+
<span>{muted ? 'Unmute (m)' : 'Mute (m)'}</span>
|
|
119
|
+
}
|
|
120
|
+
/>
|
|
121
|
+
<div className='volume-slider-container'>
|
|
122
|
+
<div className='volume-slider-background'>
|
|
123
|
+
<VolumeSlider
|
|
124
|
+
value={muted ? 0 : linearVolume}
|
|
125
|
+
onChange={setVolume}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default Sound
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { ReactNode } from 'react'
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
2
3
|
import { css } from '@emotion/react'
|
|
3
4
|
import { PlacesType, Tooltip } from 'react-tooltip'
|
|
4
5
|
|
|
5
|
-
import { fonts } from '
|
|
6
|
+
import { fonts } from '../../utils/fonts'
|
|
7
|
+
|
|
8
|
+
export enum buttonSize {
|
|
9
|
+
sm = 'sm',
|
|
10
|
+
md = 'md',
|
|
11
|
+
lg = 'lg'
|
|
12
|
+
}
|
|
6
13
|
|
|
7
14
|
const style = (size: buttonSize) => css`
|
|
8
15
|
display: flex;
|
|
@@ -40,12 +47,6 @@ interface TooltipDisplayProps {
|
|
|
40
47
|
disabled?: boolean
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
export enum buttonSize {
|
|
44
|
-
sm = 'sm',
|
|
45
|
-
md = 'md',
|
|
46
|
-
lg = 'lg'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
50
|
export const TooltipDisplay = ({
|
|
50
51
|
id,
|
|
51
52
|
toolTipText,
|
|
@@ -81,3 +82,5 @@ export const TooltipDisplay = ({
|
|
|
81
82
|
}
|
|
82
83
|
</>
|
|
83
84
|
)
|
|
85
|
+
|
|
86
|
+
export default TooltipDisplay
|
|
@@ -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,134 @@
|
|
|
1
|
+
import type { PlaybackController } from '../../engine'
|
|
2
|
+
import type { MediaPlayerOptions } from '../video-player'
|
|
3
|
+
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { startPlayback } from '../../engine'
|
|
7
|
+
import { usePlayer } from '../player'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
|
|
11
|
+
* discovers. Nothing is mirrored in React state, so the store is the only place any of it lives.
|
|
12
|
+
*/
|
|
13
|
+
export const usePlayback = (
|
|
14
|
+
video: HTMLVideoElement | null,
|
|
15
|
+
canvas: HTMLCanvasElement | null,
|
|
16
|
+
options: MediaPlayerOptions,
|
|
17
|
+
) => {
|
|
18
|
+
const player = usePlayer()
|
|
19
|
+
const {
|
|
20
|
+
read, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl,
|
|
21
|
+
bufferSize, autoplay = false,
|
|
22
|
+
} = options
|
|
23
|
+
|
|
24
|
+
// The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
|
|
25
|
+
// `selectedAudioStream`, which is whatever is playing right now.
|
|
26
|
+
const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
|
|
27
|
+
|
|
28
|
+
const controllerRef = useRef<PlaybackController | null>(null)
|
|
29
|
+
const resumeTimeRef = useRef(0)
|
|
30
|
+
// The renderer turns the first track on by itself, so the menu has to mirror that or it shows
|
|
31
|
+
// "Disable" ticked over subtitles that are visibly on screen.
|
|
32
|
+
const subtitleChoiceMade = useRef(false)
|
|
33
|
+
|
|
34
|
+
const readRef = useRef(read)
|
|
35
|
+
readRef.current = read
|
|
36
|
+
const onSeekRef = useRef(options.onSeek)
|
|
37
|
+
onSeekRef.current = options.onSeek
|
|
38
|
+
const onPlaybackErrorRef = useRef(options.onPlaybackError)
|
|
39
|
+
onPlaybackErrorRef.current = options.onPlaybackError
|
|
40
|
+
|
|
41
|
+
const selectSubtitleStream = useCallback((streamIndex: number | undefined) => {
|
|
42
|
+
subtitleChoiceMade.current = true
|
|
43
|
+
player.setSourceState({ selectedSubtitleStream: streamIndex })
|
|
44
|
+
controllerRef.current?.selectSubtitleStream(streamIndex)
|
|
45
|
+
}, [player])
|
|
46
|
+
|
|
47
|
+
const selectAudioStream = useCallback((streamIndex: number) => {
|
|
48
|
+
player.setSourceState({ selectedAudioStream: streamIndex })
|
|
49
|
+
setAudioStreamIndex(streamIndex)
|
|
50
|
+
}, [player])
|
|
51
|
+
|
|
52
|
+
// Subscribed rather than read off the store, because it is a no-op until the media element
|
|
53
|
+
// attaches. These deps are otherwise all stable, so a mount-time publish would be the only one and
|
|
54
|
+
// both actions would stay dead defaults.
|
|
55
|
+
const setSourceState = usePlayer((state) => state.setSourceState)
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setSourceState({ selectSubtitleStream, selectAudioStream })
|
|
59
|
+
}, [setSourceState, selectSubtitleStream, selectAudioStream])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!video || !canvas || !size || !read) return
|
|
63
|
+
let cancelled = false
|
|
64
|
+
player.setSourceState({ playbackError: null, ready: false })
|
|
65
|
+
const fail = (error: unknown) => {
|
|
66
|
+
if (cancelled) return
|
|
67
|
+
console.error('playback failed', error)
|
|
68
|
+
player.setSourceState({ playbackError: error })
|
|
69
|
+
onPlaybackErrorRef.current?.(error)
|
|
70
|
+
}
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const controller = await startPlayback({
|
|
74
|
+
videoElement: video,
|
|
75
|
+
canvasElement: canvas,
|
|
76
|
+
read: (offset, length) => readRef.current!(offset, length),
|
|
77
|
+
length: size,
|
|
78
|
+
publicPath,
|
|
79
|
+
libavWorkerUrl,
|
|
80
|
+
jassubWorkerUrl,
|
|
81
|
+
jassubWasmUrl,
|
|
82
|
+
jassubLegacyWasmUrl,
|
|
83
|
+
defaultFontUrl,
|
|
84
|
+
bufferSize,
|
|
85
|
+
audioStreamIndex,
|
|
86
|
+
onReady: () => {
|
|
87
|
+
if (cancelled) return
|
|
88
|
+
player.setSourceState({ ready: true })
|
|
89
|
+
if (resumeTimeRef.current > 0) {
|
|
90
|
+
video.currentTime = resumeTimeRef.current
|
|
91
|
+
resumeTimeRef.current = 0
|
|
92
|
+
}
|
|
93
|
+
if (autoplay) video.play().catch(() => {})
|
|
94
|
+
},
|
|
95
|
+
onError: fail,
|
|
96
|
+
onRecovered: () => { if (!cancelled) player.setSourceState({ playbackError: null }) },
|
|
97
|
+
onSeek: (fraction) => onSeekRef.current?.(fraction),
|
|
98
|
+
onSubtitleStreams: (streams) => {
|
|
99
|
+
if (cancelled) return
|
|
100
|
+
player.setSourceState({
|
|
101
|
+
subtitleStreams: streams,
|
|
102
|
+
...subtitleChoiceMade.current ? {} : { selectedSubtitleStream: streams[0]?.streamIndex },
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
onAudioStreams: (streams, selected) => {
|
|
106
|
+
if (cancelled) return
|
|
107
|
+
player.setSourceState({ audioStreams: streams, selectedAudioStream: selected })
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
if (cancelled) {
|
|
111
|
+
controller.destroy()
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
controllerRef.current = controller
|
|
115
|
+
player.setSourceState({ indexes: controller.indexes })
|
|
116
|
+
// a track chosen before this pipeline existed has to be re-applied to the new renderer
|
|
117
|
+
const chosen = player.selectedSubtitleStream
|
|
118
|
+
if (chosen !== undefined) controller.selectSubtitleStream(chosen)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
fail(error)
|
|
121
|
+
}
|
|
122
|
+
})()
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true
|
|
125
|
+
resumeTimeRef.current = video.currentTime
|
|
126
|
+
controllerRef.current?.destroy()
|
|
127
|
+
controllerRef.current = null
|
|
128
|
+
player.setSourceState({ ready: false })
|
|
129
|
+
}
|
|
130
|
+
}, [
|
|
131
|
+
player, video, canvas, size, read, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
|
|
132
|
+
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
|
|
133
|
+
])
|
|
134
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
publicPath: string
|
|
14
|
+
workerUrl: string
|
|
15
|
+
length: number | undefined
|
|
16
|
+
read: ((offset: number, size: number) => Promise<ArrayBuffer>) | undefined
|
|
17
|
+
/** When omitted the whole file is treated as readable, which is the case for a local file. */
|
|
18
|
+
downloadedRanges?: DownloadedRange[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const useSeekThumbnails = ({
|
|
22
|
+
publicPath, workerUrl, length, read, downloadedRanges,
|
|
23
|
+
}: UseSeekThumbnailsOptions): ThumbnailImage[] => {
|
|
24
|
+
const [thumbnails, setThumbnails] = useState<ThumbnailImage[]>([])
|
|
25
|
+
const generatorRef = useRef<ThumbnailGenerator | null>(null)
|
|
26
|
+
const readRef = useRef(read)
|
|
27
|
+
readRef.current = read
|
|
28
|
+
|
|
29
|
+
const ranges = useMemo(
|
|
30
|
+
() => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
|
|
31
|
+
// a streaming consumer changes the array identity every tick, so compare contents
|
|
32
|
+
[downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}-${endByteOffset}`).join(',')],
|
|
33
|
+
)
|
|
34
|
+
const rangesRef = useRef(ranges)
|
|
35
|
+
rangesRef.current = ranges
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!length || !read) return
|
|
39
|
+
let cancelled = false
|
|
40
|
+
let generator: ThumbnailGenerator | null = null
|
|
41
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
42
|
+
let delay = RETRY_DELAY
|
|
43
|
+
const boot = () => {
|
|
44
|
+
createThumbnailGenerator({
|
|
45
|
+
publicPath,
|
|
46
|
+
workerUrl,
|
|
47
|
+
length,
|
|
48
|
+
read: (offset, size) => readRef.current!(offset, size),
|
|
49
|
+
onThumbnails: (next) => { if (!cancelled) setThumbnails(next) },
|
|
50
|
+
}).then((created) => {
|
|
51
|
+
if (cancelled) {
|
|
52
|
+
created.destroy()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
generator = created
|
|
56
|
+
generatorRef.current = created
|
|
57
|
+
created.update(rangesRef.current)
|
|
58
|
+
}, () => {
|
|
59
|
+
if (cancelled) return
|
|
60
|
+
retry = setTimeout(boot, delay)
|
|
61
|
+
delay = Math.min(delay * 2, MAX_RETRY_DELAY)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
boot()
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true
|
|
67
|
+
clearTimeout(retry)
|
|
68
|
+
generatorRef.current = null
|
|
69
|
+
generator?.destroy()
|
|
70
|
+
setThumbnails([])
|
|
71
|
+
}
|
|
72
|
+
}, [length, publicPath, workerUrl])
|
|
73
|
+
|
|
74
|
+
useEffect(() => { generatorRef.current?.update(ranges) }, [ranges])
|
|
75
|
+
|
|
76
|
+
return thumbnails
|
|
77
|
+
}
|
|
@@ -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>
|