@banou/media-player 0.7.0 → 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 +3 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +329 -285
- package/dist/react/components/chrome.d.ts +5 -2
- package/dist/react/hooks/use-playback.d.ts +4 -2
- package/dist/react/hooks/use-thumbnails.d.ts +3 -2
- package/dist/react/media.d.ts +98 -0
- package/dist/react/player.d.ts +7 -6
- package/dist/react/source-feature.d.ts +35 -13
- package/dist/react/video-player.d.ts +45 -5
- package/dist/utils/track-label.d.ts +12 -0
- package/package.json +13 -4
- package/src/lib/index.tsx +16 -1
- package/src/lib/react/components/chrome.tsx +7 -3
- package/src/lib/react/components/overlay.tsx +14 -10
- package/src/lib/react/components/progress-bar.tsx +29 -26
- package/src/lib/react/components/settings.tsx +22 -29
- package/src/lib/react/components/sound.tsx +1 -1
- package/src/lib/react/components/tooltip-display.tsx +4 -4
- package/src/lib/react/hooks/use-playback.ts +34 -22
- package/src/lib/react/hooks/use-thumbnails.ts +4 -3
- package/src/lib/react/media.ts +124 -0
- package/src/lib/react/source-feature.ts +31 -13
- package/src/lib/react/video-player.tsx +118 -14
- package/src/lib/utils/fonts.ts +66 -66
- package/src/lib/utils/track-label.ts +15 -0
|
@@ -10,10 +10,10 @@ import { fonts } from '../../utils/fonts'
|
|
|
10
10
|
|
|
11
11
|
const style = css`
|
|
12
12
|
position: relative;
|
|
13
|
-
height: .
|
|
13
|
+
height: calc(.4 * var(--mp-unit));
|
|
14
14
|
|
|
15
|
-
--thumbnail-width:
|
|
16
|
-
--thumbnail-half: 12.
|
|
15
|
+
--thumbnail-width: calc(25 * var(--mp-unit));
|
|
16
|
+
--thumbnail-half: calc(12.5 * var(--mp-unit));
|
|
17
17
|
|
|
18
18
|
transition: transform .2s ease-in-out;
|
|
19
19
|
|
|
@@ -34,7 +34,7 @@ const style = css`
|
|
|
34
34
|
}
|
|
35
35
|
.loaded {
|
|
36
36
|
transform: scaleY(1.5);
|
|
37
|
-
top: -.
|
|
37
|
+
top: calc(-.1 * var(--mp-unit));
|
|
38
38
|
}
|
|
39
39
|
.play-container {
|
|
40
40
|
transform: scaleY(1.5);
|
|
@@ -56,10 +56,10 @@ const style = css`
|
|
|
56
56
|
${fonts.bMedium.bold}
|
|
57
57
|
|
|
58
58
|
position: absolute;
|
|
59
|
-
top: -2.
|
|
60
|
-
width:
|
|
59
|
+
top: calc(-2.5 * var(--mp-unit));
|
|
60
|
+
width: calc(5 * var(--mp-unit));
|
|
61
61
|
|
|
62
|
-
margin-left: -2.
|
|
62
|
+
margin-left: calc(-2.5 * var(--mp-unit));
|
|
63
63
|
pointer-events: none;
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -67,12 +67,12 @@ const style = css`
|
|
|
67
67
|
transform-origin: 0 0;
|
|
68
68
|
position: absolute;
|
|
69
69
|
bottom: 0;
|
|
70
|
-
height: .
|
|
70
|
+
height: calc(.4 * var(--mp-unit));
|
|
71
71
|
width: 100%;
|
|
72
72
|
.loaded-part {
|
|
73
73
|
transform-origin: 0 0;
|
|
74
74
|
bottom: 0;
|
|
75
|
-
height: .
|
|
75
|
+
height: calc(.4 * var(--mp-unit));
|
|
76
76
|
width: 100%;
|
|
77
77
|
position: absolute;
|
|
78
78
|
background-color: hsla(0, 100%, 100%, .4);
|
|
@@ -83,7 +83,7 @@ const style = css`
|
|
|
83
83
|
position: absolute;
|
|
84
84
|
bottom: 0;
|
|
85
85
|
width: 100%;
|
|
86
|
-
height: .
|
|
86
|
+
height: calc(.4 * var(--mp-unit));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
.play {
|
|
@@ -91,14 +91,14 @@ const style = css`
|
|
|
91
91
|
background-color: #f03;
|
|
92
92
|
position: absolute;
|
|
93
93
|
bottom: 0;
|
|
94
|
-
height: .
|
|
94
|
+
height: calc(.4 * var(--mp-unit));
|
|
95
95
|
width: 100%;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
.padding {
|
|
99
99
|
position: absolute;
|
|
100
100
|
bottom: -7.5px;
|
|
101
|
-
height:
|
|
101
|
+
height: calc(2 * var(--mp-unit));
|
|
102
102
|
width: 100%;
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -107,7 +107,7 @@ const style = css`
|
|
|
107
107
|
justify-content: center;
|
|
108
108
|
|
|
109
109
|
position: absolute;
|
|
110
|
-
top: calc(-3.
|
|
110
|
+
top: calc(calc(-3.4375 * var(--mp-unit)) - var(--thumbnail-width) * 9/16);
|
|
111
111
|
height: calc(var(--thumbnail-width) * 9/16);
|
|
112
112
|
width: var(--thumbnail-width);
|
|
113
113
|
|
|
@@ -115,8 +115,8 @@ const style = css`
|
|
|
115
115
|
pointer-events: none;
|
|
116
116
|
|
|
117
117
|
img {
|
|
118
|
-
border-radius: .
|
|
119
|
-
box-shadow: 0 0
|
|
118
|
+
border-radius: calc(.4 * var(--mp-unit));
|
|
119
|
+
box-shadow: 0 0 calc(1 * var(--mp-unit)) rgba(0, 0, 0, .5);
|
|
120
120
|
|
|
121
121
|
width: 100%;
|
|
122
122
|
height: 100%;
|
|
@@ -124,9 +124,9 @@ const style = css`
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/* a thumb is far wider than a cursor, so the hit strip grows to 44px and the track
|
|
127
|
+
/* a thumb is far wider than a cursor, so the hit strip grows to 44px and the track thickens */
|
|
128
128
|
@media (pointer: coarse) {
|
|
129
|
-
height: .
|
|
129
|
+
height: calc(.6 * var(--mp-unit));
|
|
130
130
|
|
|
131
131
|
/* press feedback, coarse only: a finger has no hover, so without this the bar gives no sign
|
|
132
132
|
it is being dragged */
|
|
@@ -137,18 +137,18 @@ const style = css`
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
.loaded {
|
|
140
|
-
height: .
|
|
140
|
+
height: calc(.6 * var(--mp-unit));
|
|
141
141
|
.loaded-part {
|
|
142
|
-
height: .
|
|
142
|
+
height: calc(.6 * var(--mp-unit));
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
.play-container {
|
|
147
|
-
height: .
|
|
147
|
+
height: calc(.6 * var(--mp-unit));
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
.play {
|
|
151
|
-
height: .
|
|
151
|
+
height: calc(.6 * var(--mp-unit));
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/* grows upward into the video: centred, it would hang over the control row and win hit
|
|
@@ -162,8 +162,8 @@ const style = css`
|
|
|
162
162
|
/* the clamp only holds the preview inside the bar while the bar is the wider of the two, so
|
|
163
163
|
below that the preview is narrowed instead */
|
|
164
164
|
@media (max-width: 480px) {
|
|
165
|
-
--thumbnail-width:
|
|
166
|
-
--thumbnail-half:
|
|
165
|
+
--thumbnail-width: calc(18 * var(--mp-unit));
|
|
166
|
+
--thumbnail-half: calc(9 * var(--mp-unit));
|
|
167
167
|
}
|
|
168
168
|
`
|
|
169
169
|
|
|
@@ -175,6 +175,7 @@ export const ProgressBar = () => {
|
|
|
175
175
|
const downloadedRanges = usePlayer((state) => state.downloadedRanges)
|
|
176
176
|
const indexes = usePlayer((state) => state.indexes)
|
|
177
177
|
const thumbnails = usePlayer((state) => state.thumbnails)
|
|
178
|
+
const thumbnailAt = usePlayer((state) => state.thumbnailAt)
|
|
178
179
|
|
|
179
180
|
const progressBarRef = useRef<HTMLDivElement>(null)
|
|
180
181
|
|
|
@@ -271,14 +272,16 @@ export const ProgressBar = () => {
|
|
|
271
272
|
|
|
272
273
|
// an empty url is a gap sentinel, so it renders nothing
|
|
273
274
|
const thumbnail = useMemo(() => {
|
|
274
|
-
if (!
|
|
275
|
+
if (!progressBarHoverTime) return undefined
|
|
276
|
+
if (thumbnailAt) return thumbnailAt(progressBarHoverTime)
|
|
277
|
+
if (!thumbnails.length) return undefined
|
|
275
278
|
return (
|
|
276
279
|
thumbnails
|
|
277
280
|
.find(({ startTime, endTime }) =>
|
|
278
281
|
startTime <= progressBarHoverTime && progressBarHoverTime < endTime
|
|
279
282
|
)
|
|
280
283
|
)
|
|
281
|
-
}, [thumbnails
|
|
284
|
+
}, [thumbnails, thumbnailAt, progressBarHoverTime])
|
|
282
285
|
|
|
283
286
|
return (
|
|
284
287
|
<div
|
|
@@ -294,7 +297,7 @@ export const ProgressBar = () => {
|
|
|
294
297
|
? (
|
|
295
298
|
<div
|
|
296
299
|
className="cursor-time"
|
|
297
|
-
style={{ left: `clamp(1.
|
|
300
|
+
style={{ left: `clamp(calc(1.8 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(1.8 * var(--mp-unit))))` }}
|
|
298
301
|
>
|
|
299
302
|
{cusorTimeString}
|
|
300
303
|
</div>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
-
import {
|
|
2
|
+
import type { TrackChoice } from '../source-feature'
|
|
3
|
+
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
5
|
import { css } from '@emotion/react'
|
|
4
6
|
import { ChevronLeft, ChevronRight, Settings } from 'react-feather'
|
|
5
7
|
|
|
6
8
|
import { usePlayer } from '../player'
|
|
7
9
|
import { TooltipDisplay } from './tooltip-display'
|
|
8
10
|
import { fonts } from '../../utils/fonts'
|
|
9
|
-
import { labelTracks, type LabelledTrack } from '../../utils/track-label'
|
|
10
11
|
import PlaybackSlider from './playback-slider'
|
|
11
12
|
|
|
12
13
|
const style = css`
|
|
@@ -167,18 +168,13 @@ enum PopoverContent {
|
|
|
167
168
|
Audio
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
type TrackRow = { streamIndex: number, label: string }
|
|
171
|
-
|
|
172
|
-
const toTrackRows = <T extends LabelledTrack>(streams: T[]): TrackRow[] =>
|
|
173
|
-
labelTracks(streams).map(({ track, label }) => ({ streamIndex: track.streamIndex, label }))
|
|
174
|
-
|
|
175
171
|
/** One track picker, shared by the subtitle and audio menus. */
|
|
176
172
|
const TrackMenu = (
|
|
177
173
|
{ title, tracks, selected, onSelect, onBack, allowDisable }: {
|
|
178
174
|
title: string
|
|
179
|
-
tracks:
|
|
180
|
-
selected: number | undefined
|
|
181
|
-
onSelect: (
|
|
175
|
+
tracks: TrackChoice[]
|
|
176
|
+
selected: string | number | undefined
|
|
177
|
+
onSelect: (id: string | number | undefined) => void
|
|
182
178
|
onBack: () => void
|
|
183
179
|
allowDisable?: boolean
|
|
184
180
|
}
|
|
@@ -199,14 +195,14 @@ const TrackMenu = (
|
|
|
199
195
|
: null
|
|
200
196
|
}
|
|
201
197
|
{
|
|
202
|
-
tracks.map(({
|
|
198
|
+
tracks.map(({ id, label }) => (
|
|
203
199
|
<div
|
|
204
|
-
key={
|
|
205
|
-
onClick={() => onSelect(
|
|
200
|
+
key={id}
|
|
201
|
+
onClick={() => onSelect(id)}
|
|
206
202
|
className="description"
|
|
207
203
|
>
|
|
208
204
|
<span>{label}</span>
|
|
209
|
-
<span>{selected ===
|
|
205
|
+
<span>{selected === id ? '✓' : ''}</span>
|
|
210
206
|
</div>
|
|
211
207
|
))
|
|
212
208
|
}
|
|
@@ -216,12 +212,12 @@ const TrackMenu = (
|
|
|
216
212
|
export const SettingsAction = () => {
|
|
217
213
|
const player = usePlayer()
|
|
218
214
|
const playbackRate = usePlayer((state) => state.playbackRate)
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
const
|
|
215
|
+
const subtitleTracks = usePlayer((state) => state.subtitleTracks)
|
|
216
|
+
const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
|
|
217
|
+
const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
|
|
218
|
+
const audioTracks = usePlayer((state) => state.audioTracks)
|
|
219
|
+
const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
|
|
220
|
+
const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
|
|
225
221
|
|
|
226
222
|
const [isOpenPopover, setIsOpenPopover] = useState(false)
|
|
227
223
|
const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
|
|
@@ -252,17 +248,14 @@ export const SettingsAction = () => {
|
|
|
252
248
|
return () => document.removeEventListener('pointerdown', handleClickOutside)
|
|
253
249
|
}, [isOpenPopover])
|
|
254
250
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const chooseSubtitle = (streamIndex: number | undefined) => {
|
|
259
|
-
selectSubtitleStream(streamIndex)
|
|
251
|
+
const chooseSubtitle = (id: string | number | undefined) => {
|
|
252
|
+
selectSubtitleTrack(id)
|
|
260
253
|
togglePopover()
|
|
261
254
|
}
|
|
262
255
|
|
|
263
|
-
const chooseAudio = (
|
|
256
|
+
const chooseAudio = (id: string | number | undefined) => {
|
|
264
257
|
// the audio menu never offers "Disable"
|
|
265
|
-
if (
|
|
258
|
+
if (id !== undefined) selectAudioTrack(id)
|
|
266
259
|
togglePopover()
|
|
267
260
|
}
|
|
268
261
|
|
|
@@ -366,7 +359,7 @@ export const SettingsAction = () => {
|
|
|
366
359
|
<TrackMenu
|
|
367
360
|
title='Subtitles'
|
|
368
361
|
tracks={subtitleTracks}
|
|
369
|
-
selected={
|
|
362
|
+
selected={selectedSubtitleTrack}
|
|
370
363
|
onSelect={chooseSubtitle}
|
|
371
364
|
onBack={changePopoverContent(PopoverContent.Default)}
|
|
372
365
|
allowDisable
|
|
@@ -378,7 +371,7 @@ export const SettingsAction = () => {
|
|
|
378
371
|
<TrackMenu
|
|
379
372
|
title='Audio'
|
|
380
373
|
tracks={audioTracks}
|
|
381
|
-
selected={
|
|
374
|
+
selected={selectedAudioTrack}
|
|
382
375
|
onSelect={chooseAudio}
|
|
383
376
|
onBack={changePopoverContent(PopoverContent.Default)}
|
|
384
377
|
/>
|
|
@@ -43,7 +43,7 @@ const style = css`
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
&:hover .volume-slider-container {
|
|
46
|
-
width:
|
|
46
|
+
width: calc(9 * var(--mp-unit));
|
|
47
47
|
|
|
48
48
|
transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
|
|
49
49
|
-webkit-transition: margin .2s cubic-bezier(0,0,0.2,1), width .2s cubic-bezier(0,0,0.2,1);
|
|
@@ -15,7 +15,7 @@ const style = (size: buttonSize) => css`
|
|
|
15
15
|
display: flex;
|
|
16
16
|
justify-content: flex-end;
|
|
17
17
|
|
|
18
|
-
border-radius: 0.
|
|
18
|
+
border-radius: calc(0.4 * var(--mp-unit));
|
|
19
19
|
user-select: none;
|
|
20
20
|
|
|
21
21
|
z-index: 3;
|
|
@@ -25,13 +25,13 @@ const style = (size: buttonSize) => css`
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
${size === buttonSize.sm && css`
|
|
28
|
-
padding: 0.
|
|
28
|
+
padding: calc(0.4 * var(--mp-unit))!important;
|
|
29
29
|
`}
|
|
30
30
|
${size === buttonSize.md && css`
|
|
31
|
-
padding: 0.
|
|
31
|
+
padding: calc(0.6 * var(--mp-unit))!important;
|
|
32
32
|
`}
|
|
33
33
|
${size === buttonSize.lg && css`
|
|
34
|
-
padding: 1.
|
|
34
|
+
padding: calc(1.2 * var(--mp-unit))!important;
|
|
35
35
|
`}
|
|
36
36
|
`
|
|
37
37
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { PlaybackController } from '../../engine'
|
|
2
|
-
import type {
|
|
2
|
+
import type { MediaPlayerLocalOptions } from '../video-player'
|
|
3
3
|
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
5
|
|
|
6
6
|
import { startPlayback } from '../../engine'
|
|
7
7
|
import { usePlayer } from '../player'
|
|
8
|
+
import { toNamedTracks } from '../../utils/track-label'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
|
|
@@ -13,13 +14,16 @@ import { usePlayer } from '../player'
|
|
|
13
14
|
export const usePlayback = (
|
|
14
15
|
video: HTMLVideoElement | null,
|
|
15
16
|
canvas: HTMLCanvasElement | null,
|
|
16
|
-
|
|
17
|
+
/** null when the media is remote: there are no bytes, so there is no pipeline to run. */
|
|
18
|
+
options: MediaPlayerLocalOptions | null,
|
|
17
19
|
) => {
|
|
18
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.
|
|
19
23
|
const {
|
|
20
|
-
read, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl
|
|
21
|
-
bufferSize, autoplay = false,
|
|
22
|
-
} = options
|
|
24
|
+
read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
|
|
25
|
+
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
|
|
26
|
+
} = options ?? ({} as Partial<MediaPlayerLocalOptions>)
|
|
23
27
|
|
|
24
28
|
// The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
|
|
25
29
|
// `selectedAudioStream`, which is whatever is playing right now.
|
|
@@ -33,20 +37,24 @@ export const usePlayback = (
|
|
|
33
37
|
|
|
34
38
|
const readRef = useRef(read)
|
|
35
39
|
readRef.current = read
|
|
36
|
-
const onSeekRef = useRef(options
|
|
37
|
-
onSeekRef.current = options
|
|
38
|
-
const onPlaybackErrorRef = useRef(options
|
|
39
|
-
onPlaybackErrorRef.current = options
|
|
40
|
+
const onSeekRef = useRef(options?.onSeek)
|
|
41
|
+
onSeekRef.current = options?.onSeek
|
|
42
|
+
const onPlaybackErrorRef = useRef(options?.onPlaybackError)
|
|
43
|
+
onPlaybackErrorRef.current = options?.onPlaybackError
|
|
40
44
|
|
|
41
|
-
|
|
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
|
|
42
49
|
subtitleChoiceMade.current = true
|
|
43
|
-
player.setSourceState({
|
|
50
|
+
player.setSourceState({ selectedSubtitleTrack: streamIndex })
|
|
44
51
|
controllerRef.current?.selectSubtitleStream(streamIndex)
|
|
45
52
|
}, [player])
|
|
46
53
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
const selectAudioTrack = useCallback((id: string | number) => {
|
|
55
|
+
if (typeof id !== 'number') return
|
|
56
|
+
player.setSourceState({ selectedAudioTrack: id })
|
|
57
|
+
setAudioStreamIndex(id)
|
|
50
58
|
}, [player])
|
|
51
59
|
|
|
52
60
|
// Subscribed rather than read off the store, because it is a no-op until the media element
|
|
@@ -55,8 +63,8 @@ export const usePlayback = (
|
|
|
55
63
|
const setSourceState = usePlayer((state) => state.setSourceState)
|
|
56
64
|
|
|
57
65
|
useEffect(() => {
|
|
58
|
-
setSourceState({
|
|
59
|
-
}, [setSourceState,
|
|
66
|
+
setSourceState({ selectSubtitleTrack, selectAudioTrack })
|
|
67
|
+
}, [setSourceState, selectSubtitleTrack, selectAudioTrack])
|
|
60
68
|
|
|
61
69
|
useEffect(() => {
|
|
62
70
|
if (!video || !canvas || !size || !read) return
|
|
@@ -98,13 +106,13 @@ export const usePlayback = (
|
|
|
98
106
|
onSubtitleStreams: (streams) => {
|
|
99
107
|
if (cancelled) return
|
|
100
108
|
player.setSourceState({
|
|
101
|
-
|
|
102
|
-
...subtitleChoiceMade.current ? {} : {
|
|
109
|
+
subtitleTracks: toNamedTracks(streams),
|
|
110
|
+
...subtitleChoiceMade.current ? {} : { selectedSubtitleTrack: streams[0]?.streamIndex },
|
|
103
111
|
})
|
|
104
112
|
},
|
|
105
113
|
onAudioStreams: (streams, selected) => {
|
|
106
114
|
if (cancelled) return
|
|
107
|
-
player.setSourceState({
|
|
115
|
+
player.setSourceState({ audioTracks: toNamedTracks(streams), selectedAudioTrack: selected })
|
|
108
116
|
},
|
|
109
117
|
})
|
|
110
118
|
if (cancelled) {
|
|
@@ -114,8 +122,8 @@ export const usePlayback = (
|
|
|
114
122
|
controllerRef.current = controller
|
|
115
123
|
player.setSourceState({ indexes: controller.indexes })
|
|
116
124
|
// a track chosen before this pipeline existed has to be re-applied to the new renderer
|
|
117
|
-
const chosen = player.
|
|
118
|
-
if (chosen
|
|
125
|
+
const chosen = player.selectedSubtitleTrack
|
|
126
|
+
if (typeof chosen === 'number') controller.selectSubtitleStream(chosen)
|
|
119
127
|
} catch (error) {
|
|
120
128
|
fail(error)
|
|
121
129
|
}
|
|
@@ -127,8 +135,12 @@ export const usePlayback = (
|
|
|
127
135
|
controllerRef.current = null
|
|
128
136
|
player.setSourceState({ ready: false })
|
|
129
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.
|
|
130
142
|
}, [
|
|
131
|
-
player, video, canvas, size,
|
|
143
|
+
player, video, canvas, size, publicPath, libavWorkerUrl, jassubWorkerUrl, jassubWasmUrl,
|
|
132
144
|
jassubLegacyWasmUrl, defaultFontUrl, bufferSize, audioStreamIndex, autoplay,
|
|
133
145
|
])
|
|
134
146
|
}
|
|
@@ -10,8 +10,9 @@ const RETRY_DELAY = 5_000
|
|
|
10
10
|
const MAX_RETRY_DELAY = 60_000
|
|
11
11
|
|
|
12
12
|
export type UseSeekThumbnailsOptions = {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** Optional alongside `length`/`read`: with no bytes there is nothing to generate from. */
|
|
14
|
+
publicPath: string | undefined
|
|
15
|
+
workerUrl: string | undefined
|
|
15
16
|
length: number | undefined
|
|
16
17
|
read: ((offset: number, size: number) => Promise<ArrayBuffer>) | undefined
|
|
17
18
|
/** When omitted the whole file is treated as readable, which is the case for a local file. */
|
|
@@ -35,7 +36,7 @@ export const useSeekThumbnails = ({
|
|
|
35
36
|
rangesRef.current = ranges
|
|
36
37
|
|
|
37
38
|
useEffect(() => {
|
|
38
|
-
if (!length || !read) return
|
|
39
|
+
if (!length || !read || !publicPath || !workerUrl) return
|
|
39
40
|
let cancelled = false
|
|
40
41
|
let generator: ThumbnailGenerator | null = null
|
|
41
42
|
let retry: ReturnType<typeof setTimeout> | undefined
|
|
@@ -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
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { MediaIndex, ThumbnailImage } from '../engine'
|
|
2
2
|
|
|
3
3
|
import { definePlayerFeature } from '@videojs/core/dom'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* One row of a track menu, already named.
|
|
7
|
+
*
|
|
8
|
+
* Labelled by whoever writes it rather than by the menu, because the two writers know different
|
|
9
|
+
* things: the engine has a language tag and a title to disambiguate between, while a source that
|
|
10
|
+
* owns its own player hands over a label it has already decided on. The id is opaque here for the
|
|
11
|
+
* same reason, a libav stream index one way and a site's own track id the other.
|
|
12
|
+
*/
|
|
13
|
+
export type TrackChoice = {
|
|
14
|
+
id: string | number
|
|
15
|
+
label: string
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/**
|
|
6
19
|
* A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
|
|
7
20
|
* index, because a file's download percentage is not its playback percentage.
|
|
@@ -28,15 +41,20 @@ export type SourceState = {
|
|
|
28
41
|
/** Keyframe index of the input, which turns a downloaded byte range into a time range. */
|
|
29
42
|
indexes: MediaIndex[]
|
|
30
43
|
thumbnails: ThumbnailImage[]
|
|
44
|
+
/**
|
|
45
|
+
* Answers for one time directly, when the source has a storyboard it can index but not enumerate.
|
|
46
|
+
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
47
|
+
*/
|
|
48
|
+
thumbnailAt?: (time: number) => ThumbnailImage | undefined
|
|
31
49
|
|
|
32
|
-
|
|
50
|
+
subtitleTracks: TrackChoice[]
|
|
33
51
|
/** undefined means subtitles are off. */
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
selectedSubtitleTrack: string | number | undefined
|
|
53
|
+
selectSubtitleTrack: (id: string | number | undefined) => void
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
audioTracks: TrackChoice[]
|
|
56
|
+
selectedAudioTrack: string | number | undefined
|
|
57
|
+
selectAudioTrack: (id: string | number) => void
|
|
40
58
|
|
|
41
59
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
42
60
|
hideUI: boolean
|
|
@@ -63,12 +81,12 @@ export type SourceState = {
|
|
|
63
81
|
const initialState: SourceState = {
|
|
64
82
|
indexes: [],
|
|
65
83
|
thumbnails: [],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
subtitleTracks: [],
|
|
85
|
+
selectedSubtitleTrack: undefined,
|
|
86
|
+
selectSubtitleTrack: () => {},
|
|
87
|
+
audioTracks: [],
|
|
88
|
+
selectedAudioTrack: undefined,
|
|
89
|
+
selectAudioTrack: () => {},
|
|
72
90
|
hideUI: false,
|
|
73
91
|
setHideUI: () => {},
|
|
74
92
|
togglePictureInPicture: () => {},
|