@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
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import type { DOMAttributes } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
5
|
import { css } from '@emotion/react'
|
|
3
6
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import { fonts } from '../utils/fonts'
|
|
7
|
+
import { usePlayer } from '../player'
|
|
8
|
+
import { useDragValue } from '../hooks/use-drag-value'
|
|
9
|
+
import { fonts } from '../../utils/fonts'
|
|
8
10
|
|
|
9
11
|
const style = css`
|
|
10
12
|
position: relative;
|
|
11
13
|
height: .4rem;
|
|
12
14
|
|
|
15
|
+
--thumbnail-width: 25rem;
|
|
16
|
+
--thumbnail-half: 12.5rem;
|
|
17
|
+
|
|
13
18
|
transition: transform .2s ease-in-out;
|
|
14
19
|
|
|
15
20
|
margin: 0 12px;
|
|
@@ -36,6 +41,7 @@ const style = css`
|
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
|
|
39
45
|
.background-bar {
|
|
40
46
|
position: absolute;
|
|
41
47
|
inset: 0;
|
|
@@ -88,7 +94,7 @@ const style = css`
|
|
|
88
94
|
height: .4rem;
|
|
89
95
|
width: 100%;
|
|
90
96
|
}
|
|
91
|
-
|
|
97
|
+
|
|
92
98
|
.padding {
|
|
93
99
|
position: absolute;
|
|
94
100
|
bottom: -7.5px;
|
|
@@ -101,11 +107,11 @@ const style = css`
|
|
|
101
107
|
justify-content: center;
|
|
102
108
|
|
|
103
109
|
position: absolute;
|
|
104
|
-
top: -
|
|
105
|
-
height: calc(
|
|
106
|
-
width:
|
|
110
|
+
top: calc(-3.4375rem - var(--thumbnail-width) * 9/16);
|
|
111
|
+
height: calc(var(--thumbnail-width) * 9/16);
|
|
112
|
+
width: var(--thumbnail-width);
|
|
107
113
|
|
|
108
|
-
margin-left: -
|
|
114
|
+
margin-left: calc(var(--thumbnail-half) * -1);
|
|
109
115
|
pointer-events: none;
|
|
110
116
|
|
|
111
117
|
img {
|
|
@@ -117,26 +123,97 @@ const style = css`
|
|
|
117
123
|
object-fit: cover;
|
|
118
124
|
}
|
|
119
125
|
}
|
|
126
|
+
|
|
127
|
+
/* a thumb is far wider than a cursor, so the hit strip grows to 44px and the track to .6rem */
|
|
128
|
+
@media (pointer: coarse) {
|
|
129
|
+
height: .6rem;
|
|
130
|
+
|
|
131
|
+
/* press feedback, coarse only: a finger has no hover, so without this the bar gives no sign
|
|
132
|
+
it is being dragged */
|
|
133
|
+
&.dragging {
|
|
134
|
+
.background-bar, .loaded, .play-container {
|
|
135
|
+
transform: scaleY(1.5);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.loaded {
|
|
140
|
+
height: .6rem;
|
|
141
|
+
.loaded-part {
|
|
142
|
+
height: .6rem;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.play-container {
|
|
147
|
+
height: .6rem;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.play {
|
|
151
|
+
height: .6rem;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* grows upward into the video: centred, it would hang over the control row and win hit
|
|
155
|
+
testing against taps meant for play, mute and fullscreen */
|
|
156
|
+
.padding {
|
|
157
|
+
height: 44px;
|
|
158
|
+
bottom: 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* the clamp only holds the preview inside the bar while the bar is the wider of the two, so
|
|
163
|
+
below that the preview is narrowed instead */
|
|
164
|
+
@media (max-width: 480px) {
|
|
165
|
+
--thumbnail-width: 18rem;
|
|
166
|
+
--thumbnail-half: 9rem;
|
|
167
|
+
}
|
|
120
168
|
`
|
|
121
169
|
|
|
122
170
|
export const ProgressBar = () => {
|
|
123
|
-
const
|
|
124
|
-
const currentTime =
|
|
125
|
-
const duration =
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
const
|
|
171
|
+
const player = usePlayer()
|
|
172
|
+
const currentTime = usePlayer((state) => state.currentTime)
|
|
173
|
+
const duration = usePlayer((state) => state.duration)
|
|
174
|
+
const size = usePlayer((state) => state.size)
|
|
175
|
+
const downloadedRanges = usePlayer((state) => state.downloadedRanges)
|
|
176
|
+
const indexes = usePlayer((state) => state.indexes)
|
|
177
|
+
const thumbnails = usePlayer((state) => state.thumbnails)
|
|
130
178
|
|
|
131
179
|
const progressBarRef = useRef<HTMLDivElement>(null)
|
|
132
|
-
|
|
133
|
-
|
|
180
|
+
|
|
181
|
+
const [seekFraction, setSeekFraction] = useState<number | undefined>(undefined)
|
|
134
182
|
const [progressBarHoverTime, setProgressBarOverTime] = useState<number | undefined>(undefined)
|
|
135
183
|
|
|
184
|
+
// onChange reports a bare fraction, so the device that opened the gesture is recorded on press
|
|
185
|
+
const dragPointerType = useRef<string | undefined>(undefined)
|
|
186
|
+
|
|
187
|
+
const onSeekDrag = (fraction: number) => {
|
|
188
|
+
setSeekFraction(fraction)
|
|
189
|
+
if (dragPointerType.current === 'mouse') return
|
|
190
|
+
setProgressBarOverTime(fraction * duration)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const { dragging, handlers } = useDragValue({ ref: progressBarRef, onChange: onSeekDrag })
|
|
194
|
+
|
|
195
|
+
const onDragStart: DOMAttributes<HTMLDivElement>['onPointerDown'] = (ev) => {
|
|
196
|
+
dragPointerType.current = ev.pointerType
|
|
197
|
+
handlers.onPointerDown(ev)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const onDragEnd: DOMAttributes<HTMLDivElement>['onPointerUp'] = (ev) => {
|
|
201
|
+
handlers.onPointerUp(ev)
|
|
202
|
+
// a lifted finger leaves nothing over the bar, so the preview it opened closes with it
|
|
203
|
+
if (ev.pointerType === 'mouse') return
|
|
204
|
+
setProgressBarOverTime(undefined)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// offsetX is relative to whichever child is under the pointer, and a captured pointer has none
|
|
208
|
+
const timeAtClientX = (clientX: number) => {
|
|
209
|
+
if (!progressBarRef.current) return undefined
|
|
210
|
+
const { left, right } = progressBarRef.current.getBoundingClientRect()
|
|
211
|
+
const fraction = Math.min(Math.max((clientX - left) / (right - left), 0), 1)
|
|
212
|
+
return fraction * duration
|
|
213
|
+
}
|
|
214
|
+
|
|
136
215
|
const onProgressBarOver: DOMAttributes<HTMLDivElement>['onMouseMove'] = (ev) => {
|
|
137
|
-
|
|
138
|
-
const time = percentage * (duration ?? 0)
|
|
139
|
-
setProgressBarOverTime(time)
|
|
216
|
+
setProgressBarOverTime(timeAtClientX(ev.clientX))
|
|
140
217
|
}
|
|
141
218
|
|
|
142
219
|
const hideProgressBarTime = () => {
|
|
@@ -144,12 +221,14 @@ export const ProgressBar = () => {
|
|
|
144
221
|
setProgressBarOverTime(undefined)
|
|
145
222
|
}
|
|
146
223
|
|
|
147
|
-
//
|
|
224
|
+
// duration is 0 until metadata lands, which is never a divisor
|
|
225
|
+
const timePercentage = (time: number) => duration ? (time / duration) * 100 : 0
|
|
226
|
+
|
|
227
|
+
// download percentage is not time percentage: headers and fonts can run to tens of megabytes
|
|
148
228
|
const loadedParts = useMemo(() =>
|
|
149
|
-
|
|
150
|
-
.downloadedRanges
|
|
229
|
+
downloadedRanges
|
|
151
230
|
?.map((range, i) => {
|
|
152
|
-
if (!
|
|
231
|
+
if (!size || !duration) return null
|
|
153
232
|
const matchingIndexes = indexes.filter(index => range.startByteOffset <= index.pos && index.pos <= range.endByteOffset)
|
|
154
233
|
const firstIndex = matchingIndexes.at(0)
|
|
155
234
|
const lastIndex = matchingIndexes.at(-1)
|
|
@@ -163,7 +242,7 @@ export const ProgressBar = () => {
|
|
|
163
242
|
)
|
|
164
243
|
})
|
|
165
244
|
?? [],
|
|
166
|
-
[duration, indexes.length,
|
|
245
|
+
[duration, indexes.length, downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
|
|
167
246
|
)
|
|
168
247
|
|
|
169
248
|
const cusorTimeString = useMemo(() => {
|
|
@@ -179,23 +258,24 @@ export const ProgressBar = () => {
|
|
|
179
258
|
}, [progressBarHoverTime])
|
|
180
259
|
|
|
181
260
|
useEffect(() => {
|
|
182
|
-
if (
|
|
183
|
-
const timestamp =
|
|
184
|
-
|
|
185
|
-
}, [
|
|
261
|
+
if (seekFraction === undefined || !duration) return
|
|
262
|
+
const timestamp = seekFraction * duration
|
|
263
|
+
player.seek(timestamp)
|
|
264
|
+
}, [player, seekFraction, duration])
|
|
186
265
|
|
|
187
266
|
const scaleX = useMemo(() => {
|
|
188
|
-
return
|
|
267
|
+
return !duration || typeof currentTime !== 'number'
|
|
189
268
|
? 0
|
|
190
|
-
:
|
|
269
|
+
: currentTime / duration
|
|
191
270
|
}, [duration, currentTime])
|
|
192
271
|
|
|
272
|
+
// an empty url is a gap sentinel, so it renders nothing
|
|
193
273
|
const thumbnail = useMemo(() => {
|
|
194
274
|
if (!thumbnails.length || !progressBarHoverTime) return undefined
|
|
195
275
|
return (
|
|
196
276
|
thumbnails
|
|
197
|
-
.find(({
|
|
198
|
-
|
|
277
|
+
.find(({ startTime, endTime }) =>
|
|
278
|
+
startTime <= progressBarHoverTime && progressBarHoverTime < endTime
|
|
199
279
|
)
|
|
200
280
|
)
|
|
201
281
|
}, [thumbnails.length, progressBarHoverTime])
|
|
@@ -204,7 +284,7 @@ export const ProgressBar = () => {
|
|
|
204
284
|
<div
|
|
205
285
|
css={style}
|
|
206
286
|
ref={progressBarRef}
|
|
207
|
-
className=
|
|
287
|
+
className={dragging ? 'progress-bar dragging' : 'progress-bar'}
|
|
208
288
|
onMouseMove={onProgressBarOver}
|
|
209
289
|
onMouseOut={hideProgressBarTime}
|
|
210
290
|
>
|
|
@@ -214,7 +294,7 @@ export const ProgressBar = () => {
|
|
|
214
294
|
? (
|
|
215
295
|
<div
|
|
216
296
|
className="cursor-time"
|
|
217
|
-
style={{ left: `clamp(1.8rem, ${
|
|
297
|
+
style={{ left: `clamp(1.8rem, ${timePercentage(progressBarHoverTime)}%, calc(100% - 1.8rem))` }}
|
|
218
298
|
>
|
|
219
299
|
{cusorTimeString}
|
|
220
300
|
</div>
|
|
@@ -226,7 +306,6 @@ export const ProgressBar = () => {
|
|
|
226
306
|
<div className="loaded">
|
|
227
307
|
{loadedParts}
|
|
228
308
|
</div>
|
|
229
|
-
{/* <div className="load" style={{ transform: `scaleX(${1 / ((duration ?? 0) / (loadedTime?.[1] ?? 0))})` }}></div> */}
|
|
230
309
|
{/* bar to show when hovering to potentially seek */}
|
|
231
310
|
<div className="hover"></div>
|
|
232
311
|
{/* bar displaying the current playback progress */}
|
|
@@ -235,13 +314,19 @@ export const ProgressBar = () => {
|
|
|
235
314
|
</div>
|
|
236
315
|
<div className="chapters"></div>
|
|
237
316
|
<div className="scrubber"></div>
|
|
238
|
-
<div
|
|
317
|
+
<div
|
|
318
|
+
className="padding"
|
|
319
|
+
{...handlers}
|
|
320
|
+
onPointerDown={onDragStart}
|
|
321
|
+
onPointerUp={onDragEnd}
|
|
322
|
+
onPointerCancel={onDragEnd}
|
|
323
|
+
></div>
|
|
239
324
|
<div
|
|
240
325
|
className="thumbnail"
|
|
241
|
-
style={{ left: `clamp(
|
|
326
|
+
style={{ left: `clamp(var(--thumbnail-half), ${timePercentage(progressBarHoverTime ?? 1)}%, calc(100% - var(--thumbnail-half)))` }}
|
|
242
327
|
>
|
|
243
328
|
{
|
|
244
|
-
thumbnail
|
|
329
|
+
thumbnail?.url
|
|
245
330
|
? <img src={thumbnail.url}/>
|
|
246
331
|
: undefined
|
|
247
332
|
}
|
|
@@ -249,3 +334,5 @@ export const ProgressBar = () => {
|
|
|
249
334
|
</div>
|
|
250
335
|
)
|
|
251
336
|
}
|
|
337
|
+
|
|
338
|
+
export default ProgressBar
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import { css } from '@emotion/react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, Settings } from 'react-feather'
|
|
5
|
+
|
|
6
|
+
import { usePlayer } from '../player'
|
|
7
|
+
import { TooltipDisplay } from './tooltip-display'
|
|
8
|
+
import { fonts } from '../../utils/fonts'
|
|
9
|
+
import { labelTracks, type LabelledTrack } from '../../utils/track-label'
|
|
10
|
+
import PlaybackSlider from './playback-slider'
|
|
11
|
+
|
|
12
|
+
const style = css`
|
|
13
|
+
position: relative;
|
|
14
|
+
|
|
15
|
+
.settings {
|
|
16
|
+
/* the icon keeps its size, the pressable box grows around it */
|
|
17
|
+
@media (pointer: coarse) {
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
|
|
21
|
+
min-width: 44px;
|
|
22
|
+
min-height: 44px;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.popover {
|
|
27
|
+
position: absolute;
|
|
28
|
+
/* the gear sits a couple of buttons in from the right edge, so a centred popover hangs off that
|
|
29
|
+
edge on a phone. It anchors to the button instead until the viewport has room to centre it. */
|
|
30
|
+
right: 0;
|
|
31
|
+
transform: none;
|
|
32
|
+
|
|
33
|
+
overflow-y: auto;
|
|
34
|
+
width: 180px;
|
|
35
|
+
max-width: calc(100vw - 24px);
|
|
36
|
+
height: 160px;
|
|
37
|
+
top: -180px;
|
|
38
|
+
@media (min-width: 768px) {
|
|
39
|
+
right: 50%;
|
|
40
|
+
transform: translateX(50%);
|
|
41
|
+
width: 250px;
|
|
42
|
+
height: 160px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
|
|
48
|
+
border-radius: 8px;
|
|
49
|
+
background-color: rgba(28,28,28,0.95);
|
|
50
|
+
${fonts.bMedium.regular}
|
|
51
|
+
|
|
52
|
+
z-index: 4;
|
|
53
|
+
|
|
54
|
+
> div {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: space-between;
|
|
58
|
+
|
|
59
|
+
color: #fff;
|
|
60
|
+
outline: none;
|
|
61
|
+
|
|
62
|
+
padding: 8px 6px 8px 12px;
|
|
63
|
+
|
|
64
|
+
width: 100%;
|
|
65
|
+
/* rows keep their own height inside the fixed height column, so a long page scrolls rather than
|
|
66
|
+
squashing every row into it */
|
|
67
|
+
flex-shrink: 0;
|
|
68
|
+
@media (pointer: coarse) {
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
min-height: 44px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
:first-of-type {
|
|
74
|
+
border-radius: 8px 8px 0 0;
|
|
75
|
+
}
|
|
76
|
+
:last-of-type {
|
|
77
|
+
border-radius: 0 0 8px 8px;
|
|
78
|
+
}
|
|
79
|
+
:not(&.no-hover) {
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
:not(&.no-hover):hover {
|
|
83
|
+
background-color: rgba(255,255,255,.1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
> div {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
|
|
91
|
+
.secondary {
|
|
92
|
+
color: #eee;
|
|
93
|
+
${fonts.bSmall.regular}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.back {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: flex-start;
|
|
102
|
+
border-bottom: 1px solid #4D4D4E;
|
|
103
|
+
|
|
104
|
+
svg {
|
|
105
|
+
transform: translateX(-4px);
|
|
106
|
+
transition: transform 0.2s ease-in-out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
:hover {
|
|
110
|
+
svg {
|
|
111
|
+
transform: translateX(-8px);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.playback-rate {
|
|
118
|
+
.slider {
|
|
119
|
+
width: 100%;
|
|
120
|
+
}
|
|
121
|
+
.options {
|
|
122
|
+
display: grid;
|
|
123
|
+
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
124
|
+
align-items: center;
|
|
125
|
+
|
|
126
|
+
padding: 8px 12px;
|
|
127
|
+
/* four presets across a 180px popover leave targets 34px wide, so a finger gets two rows of two
|
|
128
|
+
until the popover is wide enough for the row of four */
|
|
129
|
+
@media (pointer: coarse) {
|
|
130
|
+
grid-template-columns: 1fr 1fr;
|
|
131
|
+
}
|
|
132
|
+
@media (pointer: coarse) and (min-width: 768px) {
|
|
133
|
+
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
> div {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
border-radius: 4px;
|
|
140
|
+
|
|
141
|
+
padding: 8px 6px;
|
|
142
|
+
height: 100%;
|
|
143
|
+
@media (pointer: coarse) {
|
|
144
|
+
box-sizing: border-box;
|
|
145
|
+
min-height: 44px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
:hover {
|
|
150
|
+
background-color: rgba(255,255,255,.1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.track-list {
|
|
157
|
+
.description {
|
|
158
|
+
word-break: break-word;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
`
|
|
162
|
+
|
|
163
|
+
enum PopoverContent {
|
|
164
|
+
Default,
|
|
165
|
+
PlaybackRate,
|
|
166
|
+
Subtitles,
|
|
167
|
+
Audio
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
/** One track picker, shared by the subtitle and audio menus. */
|
|
176
|
+
const TrackMenu = (
|
|
177
|
+
{ title, tracks, selected, onSelect, onBack, allowDisable }: {
|
|
178
|
+
title: string
|
|
179
|
+
tracks: TrackRow[]
|
|
180
|
+
selected: number | undefined
|
|
181
|
+
onSelect: (streamIndex: number | undefined) => void
|
|
182
|
+
onBack: () => void
|
|
183
|
+
allowDisable?: boolean
|
|
184
|
+
}
|
|
185
|
+
) => (
|
|
186
|
+
<div className='popover track-list'>
|
|
187
|
+
<div className="back" onClick={onBack}>
|
|
188
|
+
<ChevronLeft />
|
|
189
|
+
<span>{title}</span>
|
|
190
|
+
</div>
|
|
191
|
+
{
|
|
192
|
+
allowDisable
|
|
193
|
+
? (
|
|
194
|
+
<div onClick={() => onSelect(undefined)}>
|
|
195
|
+
<span>Disable</span>
|
|
196
|
+
<span>{selected === undefined ? '✓' : ''}</span>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
: null
|
|
200
|
+
}
|
|
201
|
+
{
|
|
202
|
+
tracks.map(({ streamIndex, label }) => (
|
|
203
|
+
<div
|
|
204
|
+
key={streamIndex}
|
|
205
|
+
onClick={() => onSelect(streamIndex)}
|
|
206
|
+
className="description"
|
|
207
|
+
>
|
|
208
|
+
<span>{label}</span>
|
|
209
|
+
<span>{selected === streamIndex ? '✓' : ''}</span>
|
|
210
|
+
</div>
|
|
211
|
+
))
|
|
212
|
+
}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
export const SettingsAction = () => {
|
|
217
|
+
const player = usePlayer()
|
|
218
|
+
const playbackRate = usePlayer((state) => state.playbackRate)
|
|
219
|
+
const subtitleStreams = usePlayer((state) => state.subtitleStreams)
|
|
220
|
+
const selectedSubtitleStream = usePlayer((state) => state.selectedSubtitleStream)
|
|
221
|
+
const selectSubtitleStream = usePlayer((state) => state.selectSubtitleStream)
|
|
222
|
+
const audioStreams = usePlayer((state) => state.audioStreams)
|
|
223
|
+
const selectedAudioStream = usePlayer((state) => state.selectedAudioStream)
|
|
224
|
+
const selectAudioStream = usePlayer((state) => state.selectAudioStream)
|
|
225
|
+
|
|
226
|
+
const [isOpenPopover, setIsOpenPopover] = useState(false)
|
|
227
|
+
const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
|
|
228
|
+
const settingsContainerRef = useRef<HTMLDivElement>(null)
|
|
229
|
+
|
|
230
|
+
const togglePopover = () => {
|
|
231
|
+
setIsOpenPopover(!isOpenPopover)
|
|
232
|
+
setPopoverContent(PopoverContent.Default)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const setPlaybackRate = (rate: number) => {
|
|
236
|
+
player.setPlaybackRate(rate)
|
|
237
|
+
setPopoverContent(PopoverContent.Default)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const handleClickOutside = (ev: PointerEvent) => {
|
|
242
|
+
if (
|
|
243
|
+
isOpenPopover &&
|
|
244
|
+
settingsContainerRef.current &&
|
|
245
|
+
!settingsContainerRef.current.contains(ev.target as Node)
|
|
246
|
+
) {
|
|
247
|
+
togglePopover()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// pointerdown, because a touch device synthesizes mousedown too late to close reliably
|
|
251
|
+
document.addEventListener('pointerdown', handleClickOutside)
|
|
252
|
+
return () => document.removeEventListener('pointerdown', handleClickOutside)
|
|
253
|
+
}, [isOpenPopover])
|
|
254
|
+
|
|
255
|
+
const subtitleTracks = useMemo(() => toTrackRows(subtitleStreams), [subtitleStreams])
|
|
256
|
+
const audioTracks = useMemo(() => toTrackRows(audioStreams), [audioStreams])
|
|
257
|
+
|
|
258
|
+
const chooseSubtitle = (streamIndex: number | undefined) => {
|
|
259
|
+
selectSubtitleStream(streamIndex)
|
|
260
|
+
togglePopover()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const chooseAudio = (streamIndex: number | undefined) => {
|
|
264
|
+
// the audio menu never offers "Disable"
|
|
265
|
+
if (streamIndex !== undefined) selectAudioStream(streamIndex)
|
|
266
|
+
togglePopover()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const changePopoverContent = (newPopoverContent: PopoverContent) => () => {
|
|
270
|
+
setPopoverContent(newPopoverContent)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div css={style} ref={settingsContainerRef}>
|
|
275
|
+
<TooltipDisplay
|
|
276
|
+
id='settings'
|
|
277
|
+
disabled={isOpenPopover}
|
|
278
|
+
text={
|
|
279
|
+
<button
|
|
280
|
+
className='settings'
|
|
281
|
+
type='button'
|
|
282
|
+
onClick={togglePopover}
|
|
283
|
+
>
|
|
284
|
+
<Settings />
|
|
285
|
+
</button>
|
|
286
|
+
}
|
|
287
|
+
toolTipText={
|
|
288
|
+
<span>
|
|
289
|
+
Settings
|
|
290
|
+
</span>
|
|
291
|
+
}
|
|
292
|
+
/>
|
|
293
|
+
{
|
|
294
|
+
isOpenPopover && popoverContent === PopoverContent.Default && (
|
|
295
|
+
<div className='popover menu'>
|
|
296
|
+
{/*
|
|
297
|
+
A row is offered only where there is more than one thing behind it to choose between.
|
|
298
|
+
Audio needs two tracks for that; subtitles need one, because "Disable" is always a
|
|
299
|
+
second option, and a file with no subtitles at all should not offer the menu.
|
|
300
|
+
*/}
|
|
301
|
+
{audioTracks.length > 1
|
|
302
|
+
? (
|
|
303
|
+
<div onClick={changePopoverContent(PopoverContent.Audio)}>
|
|
304
|
+
<div>Audio</div>
|
|
305
|
+
<div>
|
|
306
|
+
<ChevronRight />
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
)
|
|
310
|
+
: null}
|
|
311
|
+
{subtitleTracks.length > 0
|
|
312
|
+
? (
|
|
313
|
+
<div onClick={changePopoverContent(PopoverContent.Subtitles)}>
|
|
314
|
+
<div>Subtitles</div>
|
|
315
|
+
<div>
|
|
316
|
+
<ChevronRight />
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
: null}
|
|
321
|
+
<div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
|
|
322
|
+
<div>Playback speed</div>
|
|
323
|
+
<div>
|
|
324
|
+
<span className='secondary'>
|
|
325
|
+
{
|
|
326
|
+
playbackRate === 1
|
|
327
|
+
? '(Default)'
|
|
328
|
+
: playbackRate.toFixed(2)
|
|
329
|
+
}
|
|
330
|
+
</span>
|
|
331
|
+
<ChevronRight />
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
{
|
|
338
|
+
isOpenPopover && popoverContent === PopoverContent.PlaybackRate && (
|
|
339
|
+
<div className='popover playback-rate'>
|
|
340
|
+
<div className="back" onClick={changePopoverContent(PopoverContent.Default)}>
|
|
341
|
+
<ChevronLeft />
|
|
342
|
+
<span>Playback speed</span>
|
|
343
|
+
</div>
|
|
344
|
+
<div className="slider no-hover">
|
|
345
|
+
<PlaybackSlider />
|
|
346
|
+
</div>
|
|
347
|
+
<div className="options no-hover">
|
|
348
|
+
<div onClick={() => setPlaybackRate(0.5)}>
|
|
349
|
+
0.5x
|
|
350
|
+
</div>
|
|
351
|
+
<div onClick={() => setPlaybackRate(1)}>
|
|
352
|
+
1x
|
|
353
|
+
</div>
|
|
354
|
+
<div onClick={() => setPlaybackRate(1.5)}>
|
|
355
|
+
1.5x
|
|
356
|
+
</div>
|
|
357
|
+
<div onClick={() => setPlaybackRate(2)}>
|
|
358
|
+
2x
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
{
|
|
365
|
+
isOpenPopover && popoverContent === PopoverContent.Subtitles && (
|
|
366
|
+
<TrackMenu
|
|
367
|
+
title='Subtitles'
|
|
368
|
+
tracks={subtitleTracks}
|
|
369
|
+
selected={selectedSubtitleStream}
|
|
370
|
+
onSelect={chooseSubtitle}
|
|
371
|
+
onBack={changePopoverContent(PopoverContent.Default)}
|
|
372
|
+
allowDisable
|
|
373
|
+
/>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
{
|
|
377
|
+
isOpenPopover && popoverContent === PopoverContent.Audio && (
|
|
378
|
+
<TrackMenu
|
|
379
|
+
title='Audio'
|
|
380
|
+
tracks={audioTracks}
|
|
381
|
+
selected={selectedAudioStream}
|
|
382
|
+
onSelect={chooseAudio}
|
|
383
|
+
onBack={changePopoverContent(PopoverContent.Default)}
|
|
384
|
+
/>
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export default SettingsAction
|