@banou/media-player 0.8.3 → 0.8.5
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/dist/index.js +407 -203
- package/dist/react/components/chrome.d.ts +3 -1
- package/dist/react/components/icons.d.ts +4 -0
- package/dist/react/components/subtitles.d.ts +9 -0
- package/dist/react/components/track-menu.d.ts +47 -0
- package/dist/react/player.d.ts +3 -2
- package/dist/react/source-feature.d.ts +33 -4
- package/dist/react/video-player.d.ts +30 -1
- package/package.json +1 -1
- package/src/lib/react/components/chrome.tsx +4 -2
- package/src/lib/react/components/control-bar.tsx +4 -2
- package/src/lib/react/components/icons.tsx +38 -0
- package/src/lib/react/components/progress-bar.tsx +20 -4
- package/src/lib/react/components/settings.tsx +26 -206
- package/src/lib/react/components/subtitles.tsx +80 -0
- package/src/lib/react/components/track-menu.tsx +277 -0
- package/src/lib/react/source-feature.ts +20 -2
- package/src/lib/react/video-player.tsx +77 -3
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useState } from 'react'
|
|
5
3
|
import { css } from '@emotion/react'
|
|
6
4
|
import { ChevronLeft, ChevronRight, Settings } from 'react-feather'
|
|
7
5
|
|
|
8
6
|
import { usePlayer } from '../player'
|
|
9
7
|
import { TooltipDisplay } from './tooltip-display'
|
|
10
|
-
import { fonts } from '../../utils/fonts'
|
|
11
8
|
import PlaybackSlider from './playback-slider'
|
|
9
|
+
import { TrackMenu, popoverStyle, useTrackMenu } from './track-menu'
|
|
12
10
|
|
|
13
11
|
const style = css`
|
|
14
|
-
|
|
12
|
+
/* Deliberately NOT a containing block. The menu anchors to the control bar instead, which is exactly
|
|
13
|
+
as wide as the player box and can therefore be clamped to it. The outside-click check uses
|
|
14
|
+
\`contains\` on the ref, which is DOM containment and needs no position, and the bar's other
|
|
15
|
+
tooltips already resolve against the control bar with no positioned wrapper of their own. */
|
|
16
|
+
position: static;
|
|
15
17
|
|
|
16
18
|
.settings {
|
|
17
19
|
/* the icon keeps its size, the pressable box grows around it */
|
|
@@ -24,96 +26,7 @@ position: relative;
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
position: absolute;
|
|
29
|
-
/* the gear sits a couple of buttons in from the right edge, so a centred popover hangs off that
|
|
30
|
-
edge on a phone. It anchors to the button instead until the viewport has room to centre it. */
|
|
31
|
-
right: 0;
|
|
32
|
-
transform: none;
|
|
33
|
-
|
|
34
|
-
overflow-y: auto;
|
|
35
|
-
width: 180px;
|
|
36
|
-
max-width: calc(100vw - 24px);
|
|
37
|
-
height: 160px;
|
|
38
|
-
top: -180px;
|
|
39
|
-
@media (min-width: 768px) {
|
|
40
|
-
right: 50%;
|
|
41
|
-
transform: translateX(50%);
|
|
42
|
-
width: 250px;
|
|
43
|
-
height: 160px;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
display: flex;
|
|
47
|
-
flex-direction: column;
|
|
48
|
-
|
|
49
|
-
border-radius: 8px;
|
|
50
|
-
background-color: rgba(28,28,28,0.95);
|
|
51
|
-
${fonts.bMedium.regular}
|
|
52
|
-
|
|
53
|
-
z-index: 4;
|
|
54
|
-
|
|
55
|
-
> div {
|
|
56
|
-
display: flex;
|
|
57
|
-
align-items: center;
|
|
58
|
-
justify-content: space-between;
|
|
59
|
-
|
|
60
|
-
color: #fff;
|
|
61
|
-
outline: none;
|
|
62
|
-
|
|
63
|
-
padding: 8px 6px 8px 12px;
|
|
64
|
-
|
|
65
|
-
width: 100%;
|
|
66
|
-
/* rows keep their own height inside the fixed height column, so a long page scrolls rather than
|
|
67
|
-
squashing every row into it */
|
|
68
|
-
flex-shrink: 0;
|
|
69
|
-
@media (pointer: coarse) {
|
|
70
|
-
box-sizing: border-box;
|
|
71
|
-
min-height: 44px;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
:first-of-type {
|
|
75
|
-
border-radius: 8px 8px 0 0;
|
|
76
|
-
}
|
|
77
|
-
:last-of-type {
|
|
78
|
-
border-radius: 0 0 8px 8px;
|
|
79
|
-
}
|
|
80
|
-
:not(&.no-hover) {
|
|
81
|
-
cursor: pointer;
|
|
82
|
-
}
|
|
83
|
-
:not(&.no-hover):hover {
|
|
84
|
-
background-color: rgba(255,255,255,.1);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
> div {
|
|
88
|
-
display: flex;
|
|
89
|
-
align-items: center;
|
|
90
|
-
justify-content: center;
|
|
91
|
-
|
|
92
|
-
.secondary {
|
|
93
|
-
color: #eee;
|
|
94
|
-
${fonts.bSmall.regular}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.back {
|
|
100
|
-
display: flex;
|
|
101
|
-
align-items: center;
|
|
102
|
-
justify-content: flex-start;
|
|
103
|
-
border-bottom: 1px solid #4D4D4E;
|
|
104
|
-
|
|
105
|
-
svg {
|
|
106
|
-
transform: translateX(-4px);
|
|
107
|
-
transition: transform 0.2s ease-in-out;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
:hover {
|
|
111
|
-
svg {
|
|
112
|
-
transform: translateX(-8px);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
29
|
+
${popoverStyle}
|
|
117
30
|
|
|
118
31
|
.playback-rate {
|
|
119
32
|
.slider {
|
|
@@ -154,109 +67,35 @@ position: relative;
|
|
|
154
67
|
}
|
|
155
68
|
}
|
|
156
69
|
|
|
157
|
-
.track-list {
|
|
158
|
-
.description {
|
|
159
|
-
word-break: break-word;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
70
|
`
|
|
163
71
|
|
|
164
72
|
enum PopoverContent {
|
|
165
73
|
Default,
|
|
166
74
|
PlaybackRate,
|
|
167
|
-
Subtitles,
|
|
168
75
|
Audio
|
|
169
76
|
}
|
|
170
77
|
|
|
171
|
-
/** One track picker, shared by the subtitle and audio menus. */
|
|
172
|
-
const TrackMenu = (
|
|
173
|
-
{ title, tracks, selected, onSelect, onBack, allowDisable }: {
|
|
174
|
-
title: string
|
|
175
|
-
tracks: TrackChoice[]
|
|
176
|
-
selected: string | number | undefined
|
|
177
|
-
onSelect: (id: string | number | undefined) => void
|
|
178
|
-
onBack: () => void
|
|
179
|
-
allowDisable?: boolean
|
|
180
|
-
}
|
|
181
|
-
) => (
|
|
182
|
-
<div className='popover track-list'>
|
|
183
|
-
<div className="back" onClick={onBack}>
|
|
184
|
-
<ChevronLeft />
|
|
185
|
-
<span>{title}</span>
|
|
186
|
-
</div>
|
|
187
|
-
{
|
|
188
|
-
allowDisable
|
|
189
|
-
? (
|
|
190
|
-
<div onClick={() => onSelect(undefined)}>
|
|
191
|
-
<span>Disable</span>
|
|
192
|
-
<span>{selected === undefined ? '✓' : ''}</span>
|
|
193
|
-
</div>
|
|
194
|
-
)
|
|
195
|
-
: null
|
|
196
|
-
}
|
|
197
|
-
{
|
|
198
|
-
tracks.map(({ id, label }) => (
|
|
199
|
-
<div
|
|
200
|
-
key={id}
|
|
201
|
-
onClick={() => onSelect(id)}
|
|
202
|
-
className="description"
|
|
203
|
-
>
|
|
204
|
-
<span>{label}</span>
|
|
205
|
-
<span>{selected === id ? '✓' : ''}</span>
|
|
206
|
-
</div>
|
|
207
|
-
))
|
|
208
|
-
}
|
|
209
|
-
</div>
|
|
210
|
-
)
|
|
211
|
-
|
|
212
78
|
export const SettingsAction = () => {
|
|
213
79
|
const player = usePlayer()
|
|
214
80
|
const playbackRate = usePlayer((state) => state.playbackRate)
|
|
215
|
-
const subtitleTracks = usePlayer((state) => state.subtitleTracks)
|
|
216
|
-
const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
|
|
217
|
-
const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
|
|
218
81
|
const audioTracks = usePlayer((state) => state.audioTracks)
|
|
219
82
|
const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
|
|
220
83
|
const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
|
|
221
84
|
|
|
222
|
-
const [isOpenPopover, setIsOpenPopover] = useState(false)
|
|
223
85
|
const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
setIsOpenPopover(!isOpenPopover)
|
|
228
|
-
setPopoverContent(PopoverContent.Default)
|
|
229
|
-
}
|
|
86
|
+
const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu({
|
|
87
|
+
onReset: () => setPopoverContent(PopoverContent.Default),
|
|
88
|
+
})
|
|
230
89
|
|
|
231
90
|
const setPlaybackRate = (rate: number) => {
|
|
232
91
|
player.setPlaybackRate(rate)
|
|
233
92
|
setPopoverContent(PopoverContent.Default)
|
|
234
93
|
}
|
|
235
94
|
|
|
236
|
-
|
|
237
|
-
const handleClickOutside = (ev: PointerEvent) => {
|
|
238
|
-
if (
|
|
239
|
-
isOpenPopover &&
|
|
240
|
-
settingsContainerRef.current &&
|
|
241
|
-
!settingsContainerRef.current.contains(ev.target as Node)
|
|
242
|
-
) {
|
|
243
|
-
togglePopover()
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
// pointerdown, because a touch device synthesizes mousedown too late to close reliably
|
|
247
|
-
document.addEventListener('pointerdown', handleClickOutside)
|
|
248
|
-
return () => document.removeEventListener('pointerdown', handleClickOutside)
|
|
249
|
-
}, [isOpenPopover])
|
|
250
|
-
|
|
251
|
-
const chooseSubtitle = (id: string | number | undefined) => {
|
|
252
|
-
selectSubtitleTrack(id)
|
|
253
|
-
togglePopover()
|
|
254
|
-
}
|
|
255
|
-
|
|
95
|
+
// the audio menu offers no way off, so there is no undefined case to answer for
|
|
256
96
|
const chooseAudio = (id: string | number | undefined) => {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
togglePopover()
|
|
97
|
+
if (id === undefined) return
|
|
98
|
+
runSelect(id, () => selectAudioTrack(id))
|
|
260
99
|
}
|
|
261
100
|
|
|
262
101
|
const changePopoverContent = (newPopoverContent: PopoverContent) => () => {
|
|
@@ -264,15 +103,15 @@ export const SettingsAction = () => {
|
|
|
264
103
|
}
|
|
265
104
|
|
|
266
105
|
return (
|
|
267
|
-
<div css={style} ref={
|
|
106
|
+
<div css={style} ref={containerRef}>
|
|
268
107
|
<TooltipDisplay
|
|
269
108
|
id='settings'
|
|
270
|
-
disabled={
|
|
109
|
+
disabled={open}
|
|
271
110
|
text={
|
|
272
111
|
<button
|
|
273
112
|
className='settings'
|
|
274
113
|
type='button'
|
|
275
|
-
onClick={
|
|
114
|
+
onClick={toggle}
|
|
276
115
|
>
|
|
277
116
|
<Settings />
|
|
278
117
|
</button>
|
|
@@ -284,12 +123,13 @@ export const SettingsAction = () => {
|
|
|
284
123
|
}
|
|
285
124
|
/>
|
|
286
125
|
{
|
|
287
|
-
|
|
126
|
+
open && popoverContent === PopoverContent.Default && (
|
|
288
127
|
<div className='popover menu'>
|
|
289
128
|
{/*
|
|
290
|
-
A row is offered only where there is more than one thing behind it to choose between
|
|
291
|
-
|
|
292
|
-
|
|
129
|
+
A row is offered only where there is more than one thing behind it to choose between,
|
|
130
|
+
so audio needs two tracks. Subtitles is NOT here: it has its own button in the bar,
|
|
131
|
+
because it is the control in this player that gets reached for most and it does not
|
|
132
|
+
belong two clicks deep next to playback speed.
|
|
293
133
|
*/}
|
|
294
134
|
{audioTracks.length > 1
|
|
295
135
|
? (
|
|
@@ -301,16 +141,6 @@ export const SettingsAction = () => {
|
|
|
301
141
|
</div>
|
|
302
142
|
)
|
|
303
143
|
: null}
|
|
304
|
-
{subtitleTracks.length > 0
|
|
305
|
-
? (
|
|
306
|
-
<div onClick={changePopoverContent(PopoverContent.Subtitles)}>
|
|
307
|
-
<div>Subtitles</div>
|
|
308
|
-
<div>
|
|
309
|
-
<ChevronRight />
|
|
310
|
-
</div>
|
|
311
|
-
</div>
|
|
312
|
-
)
|
|
313
|
-
: null}
|
|
314
144
|
<div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
|
|
315
145
|
<div>Playback speed</div>
|
|
316
146
|
<div>
|
|
@@ -328,7 +158,7 @@ export const SettingsAction = () => {
|
|
|
328
158
|
)
|
|
329
159
|
}
|
|
330
160
|
{
|
|
331
|
-
|
|
161
|
+
open && popoverContent === PopoverContent.PlaybackRate && (
|
|
332
162
|
<div className='popover playback-rate'>
|
|
333
163
|
<div className="back" onClick={changePopoverContent(PopoverContent.Default)}>
|
|
334
164
|
<ChevronLeft />
|
|
@@ -355,25 +185,15 @@ export const SettingsAction = () => {
|
|
|
355
185
|
)
|
|
356
186
|
}
|
|
357
187
|
{
|
|
358
|
-
|
|
359
|
-
<TrackMenu
|
|
360
|
-
title='Subtitles'
|
|
361
|
-
tracks={subtitleTracks}
|
|
362
|
-
selected={selectedSubtitleTrack}
|
|
363
|
-
onSelect={chooseSubtitle}
|
|
364
|
-
onBack={changePopoverContent(PopoverContent.Default)}
|
|
365
|
-
allowDisable
|
|
366
|
-
/>
|
|
367
|
-
)
|
|
368
|
-
}
|
|
369
|
-
{
|
|
370
|
-
isOpenPopover && popoverContent === PopoverContent.Audio && (
|
|
188
|
+
open && popoverContent === PopoverContent.Audio && (
|
|
371
189
|
<TrackMenu
|
|
372
190
|
title='Audio'
|
|
373
191
|
tracks={audioTracks}
|
|
374
192
|
selected={selectedAudioTrack}
|
|
375
193
|
onSelect={chooseAudio}
|
|
376
194
|
onBack={changePopoverContent(PopoverContent.Default)}
|
|
195
|
+
pending={pending}
|
|
196
|
+
failed={failed}
|
|
377
197
|
/>
|
|
378
198
|
)
|
|
379
199
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import { css } from '@emotion/react'
|
|
3
|
+
|
|
4
|
+
import { usePlayer } from '../player'
|
|
5
|
+
import { TooltipDisplay } from './tooltip-display'
|
|
6
|
+
import { Captions, CaptionsOff } from './icons'
|
|
7
|
+
import { TrackMenu, popoverStyle, useTrackMenu } from './track-menu'
|
|
8
|
+
|
|
9
|
+
const style = css`
|
|
10
|
+
/* Not a containing block, for the reason given on popoverStyle: the menu anchors to the control bar,
|
|
11
|
+
which is the width of the player box, so it can be clamped to it. */
|
|
12
|
+
position: static;
|
|
13
|
+
|
|
14
|
+
.subtitles {
|
|
15
|
+
/* the icon keeps its size, the pressable box grows around it */
|
|
16
|
+
@media (pointer: coarse) {
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
|
|
20
|
+
min-width: 44px;
|
|
21
|
+
min-height: 44px;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
${popoverStyle}
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Subtitles, one click from the control bar.
|
|
30
|
+
*
|
|
31
|
+
* It used to be a row inside the settings menu, which put the most-reached control in the player two
|
|
32
|
+
* clicks deep behind a gear, next to playback speed. Audio stays in there: switching it is rare, and
|
|
33
|
+
* on a source that owns its own player it is slow enough to be a considered act.
|
|
34
|
+
*/
|
|
35
|
+
export const SubtitlesAction = () => {
|
|
36
|
+
const subtitleTracks = usePlayer((state) => state.subtitleTracks)
|
|
37
|
+
const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
|
|
38
|
+
const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
|
|
39
|
+
const subtitleOffLabel = usePlayer((state) => state.subtitleOffLabel)
|
|
40
|
+
|
|
41
|
+
const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu()
|
|
42
|
+
|
|
43
|
+
// After every hook, never before. One track is enough to offer the menu, because "off" is always a
|
|
44
|
+
// second option; a file carrying none should not offer it at all.
|
|
45
|
+
if (subtitleTracks.length === 0) return null
|
|
46
|
+
|
|
47
|
+
// Read from the store, never from `pending`: mid-switch the selection has not moved, and flipping
|
|
48
|
+
// the glyph early is the same lie a tick on the pending row would be.
|
|
49
|
+
const on = selectedSubtitleTrack !== undefined
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div css={style} ref={containerRef}>
|
|
53
|
+
<TooltipDisplay
|
|
54
|
+
id='subtitles'
|
|
55
|
+
disabled={open}
|
|
56
|
+
text={
|
|
57
|
+
<button className='subtitles' type='button' onClick={toggle}>
|
|
58
|
+
{on ? <Captions className='captions' /> : <CaptionsOff className='captions-off' />}
|
|
59
|
+
</button>
|
|
60
|
+
}
|
|
61
|
+
toolTipText={<span>Subtitles</span>}
|
|
62
|
+
/>
|
|
63
|
+
{
|
|
64
|
+
open && (
|
|
65
|
+
<TrackMenu
|
|
66
|
+
title='Subtitles'
|
|
67
|
+
tracks={subtitleTracks}
|
|
68
|
+
selected={selectedSubtitleTrack}
|
|
69
|
+
onSelect={(id) => runSelect(id ?? null, () => selectSubtitleTrack(id))}
|
|
70
|
+
offLabel={subtitleOffLabel ?? 'Disable'}
|
|
71
|
+
pending={pending}
|
|
72
|
+
failed={failed}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default SubtitlesAction
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import type { TrackChoice } from '../source-feature'
|
|
3
|
+
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { css } from '@emotion/react'
|
|
6
|
+
import { ChevronLeft } from 'react-feather'
|
|
7
|
+
|
|
8
|
+
import { fonts } from '../../utils/fonts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The popover surface and the track rows, shared by every menu in the control bar.
|
|
12
|
+
*
|
|
13
|
+
* Carries NO `position` of its own: the menu anchors to the control bar, which is the width of the
|
|
14
|
+
* player box, so each caller keeps `position: static` on its own wrapper. Anchoring to the button
|
|
15
|
+
* instead is what let the menu hang outside the box, where the root's `overflow: hidden` made it
|
|
16
|
+
* unreachable rather than merely ugly.
|
|
17
|
+
*/
|
|
18
|
+
export const popoverStyle = css`
|
|
19
|
+
.popover {
|
|
20
|
+
position: absolute;
|
|
21
|
+
/* Anchored to the PLAYER's right edge, never to the gear. The gear is not the last control in the
|
|
22
|
+
bar, so a menu centred on it puts its own edges wherever the button happens to sit: with no
|
|
23
|
+
picture-in-picture button, which is every source whose media the player does not own, an edge
|
|
24
|
+
landed outside the box, and the root's \`overflow: hidden\` cut it off, taking the rows under it
|
|
25
|
+
out of reach. The inset matches the bar's own padding so the menu lines up with the last button. */
|
|
26
|
+
right: calc(12px + env(safe-area-inset-right, 0px));
|
|
27
|
+
bottom: calc(100% + 8px);
|
|
28
|
+
|
|
29
|
+
overflow-y: auto;
|
|
30
|
+
width: 180px;
|
|
31
|
+
/* \`100%\` is the control bar, which is the width of the PLAYER. The old \`100vw\` was the viewport,
|
|
32
|
+
which equals the player only when the player is the whole document, and it is the wrong kind of
|
|
33
|
+
guard besides: a max-width shrinks the box, it never moves it back inside. */
|
|
34
|
+
max-width: calc(100% - 24px);
|
|
35
|
+
/* Sized to its rows, capped at what fits. A fixed height left the settings menu two thirds empty
|
|
36
|
+
once subtitles moved out to its own button, and a long track list scrolls at the cap either way. */
|
|
37
|
+
height: max-content;
|
|
38
|
+
/* The room actually above the bar. A short player had the top of the menu clipped by that same
|
|
39
|
+
\`overflow: hidden\`. With no size container in the ancestry this resolves against the small
|
|
40
|
+
viewport, so it can only ever shrink the box, never grow it. */
|
|
41
|
+
max-height: min(160px, calc(100cqh - 100% - 16px));
|
|
42
|
+
@media (min-width: 768px) {
|
|
43
|
+
right: calc(16px + env(safe-area-inset-right, 0px));
|
|
44
|
+
width: 250px;
|
|
45
|
+
max-width: calc(100% - 32px);
|
|
46
|
+
}
|
|
47
|
+
@media (min-width: 2560px) {
|
|
48
|
+
right: calc(24px + env(safe-area-inset-right, 0px));
|
|
49
|
+
max-width: calc(100% - 48px);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
|
|
55
|
+
border-radius: 8px;
|
|
56
|
+
background-color: rgba(28,28,28,0.95);
|
|
57
|
+
${fonts.bMedium.regular}
|
|
58
|
+
|
|
59
|
+
z-index: 4;
|
|
60
|
+
|
|
61
|
+
> div {
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
justify-content: space-between;
|
|
65
|
+
|
|
66
|
+
color: #fff;
|
|
67
|
+
outline: none;
|
|
68
|
+
|
|
69
|
+
padding: 8px 6px 8px 12px;
|
|
70
|
+
|
|
71
|
+
width: 100%;
|
|
72
|
+
/* rows keep their own height inside the fixed height column, so a long page scrolls rather than
|
|
73
|
+
squashing every row into it */
|
|
74
|
+
flex-shrink: 0;
|
|
75
|
+
@media (pointer: coarse) {
|
|
76
|
+
box-sizing: border-box;
|
|
77
|
+
min-height: 44px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
:first-of-type {
|
|
81
|
+
border-radius: 8px 8px 0 0;
|
|
82
|
+
}
|
|
83
|
+
:last-of-type {
|
|
84
|
+
border-radius: 0 0 8px 8px;
|
|
85
|
+
}
|
|
86
|
+
:not(&.no-hover) {
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
}
|
|
89
|
+
:not(&.no-hover):hover {
|
|
90
|
+
background-color: rgba(255,255,255,.1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
> div {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
|
|
98
|
+
.secondary {
|
|
99
|
+
color: #eee;
|
|
100
|
+
${fonts.bSmall.regular}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.back {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: flex-start;
|
|
109
|
+
border-bottom: 1px solid #4D4D4E;
|
|
110
|
+
|
|
111
|
+
svg {
|
|
112
|
+
transform: translateX(-4px);
|
|
113
|
+
transition: transform 0.2s ease-in-out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
:hover {
|
|
117
|
+
svg {
|
|
118
|
+
transform: translateX(-8px);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.track-list {
|
|
125
|
+
.description {
|
|
126
|
+
word-break: break-word;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Dimmed and inert, but still listed. A source that names a track it cannot serve right now is
|
|
130
|
+
saying the track exists, and dropping the row would read as the source having nothing. */
|
|
131
|
+
.unavailable {
|
|
132
|
+
opacity: 0.5;
|
|
133
|
+
cursor: default;
|
|
134
|
+
:hover {
|
|
135
|
+
background-color: transparent;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.failed {
|
|
140
|
+
border-bottom: 1px solid #4D4D4E;
|
|
141
|
+
color: #f66;
|
|
142
|
+
${fonts.bSmall.regular}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
`
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One track picker, used by the subtitles button and by the settings menu's audio page.
|
|
149
|
+
*
|
|
150
|
+
* `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
|
|
151
|
+
* is working, every row is inert and the one being switched to says so, because the selection has not
|
|
152
|
+
* moved yet and a tick next to it would be a lie.
|
|
153
|
+
*/
|
|
154
|
+
export const TrackMenu = (
|
|
155
|
+
{ title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
|
|
156
|
+
title: string
|
|
157
|
+
tracks: TrackChoice[]
|
|
158
|
+
selected: string | number | undefined
|
|
159
|
+
onSelect: (id: string | number | undefined) => void
|
|
160
|
+
/** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
|
|
161
|
+
onBack?: () => void
|
|
162
|
+
/** Absent means the menu offers no way off at all, which is what audio wants. */
|
|
163
|
+
offLabel?: string
|
|
164
|
+
/** The id currently being switched to, where `null` is the off row and undefined means idle. */
|
|
165
|
+
pending?: string | number | null
|
|
166
|
+
failed?: boolean
|
|
167
|
+
}
|
|
168
|
+
) => {
|
|
169
|
+
const busy = pending !== undefined
|
|
170
|
+
const row = (id: string | number | null, label: string, className?: string) => {
|
|
171
|
+
const isPending = busy && pending === id
|
|
172
|
+
const disabled = busy || tracks.find((track) => track.id === id)?.disabled
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
key={id ?? '__off__'}
|
|
176
|
+
onClick={() => { if (!disabled) onSelect(id ?? undefined) }}
|
|
177
|
+
className={[className, disabled ? 'unavailable' : null].filter(Boolean).join(' ') || undefined}
|
|
178
|
+
>
|
|
179
|
+
<span>{label}</span>
|
|
180
|
+
<span>{isPending ? '\u2026' : (selected ?? null) === id ? '\u2713' : ''}</span>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className='popover track-list'>
|
|
187
|
+
{/* The header keeps its name and its border either way. With no page to go back to there is
|
|
188
|
+
nothing for a chevron to point at, and `no-hover` stops the row offering a dead click. */}
|
|
189
|
+
<div className={onBack ? 'back' : 'back no-hover'} onClick={onBack}>
|
|
190
|
+
{onBack ? <ChevronLeft /> : null}
|
|
191
|
+
<span>{title}</span>
|
|
192
|
+
</div>
|
|
193
|
+
{failed ? <div className="no-hover failed">Could not switch. Try again.</div> : null}
|
|
194
|
+
{offLabel ? row(null, offLabel) : null}
|
|
195
|
+
{tracks.map(({ id, label }) => row(id, label, 'description'))}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The open/closed and in-flight state every track menu in the bar needs.
|
|
202
|
+
*
|
|
203
|
+
* One hook rather than two, because `runSelect` is what decides when the menu may close: splitting
|
|
204
|
+
* the popover state from the selection state would wire them in a circle.
|
|
205
|
+
*/
|
|
206
|
+
export const useTrackMenu = ({ onReset }: { onReset?: () => void } = {}) => {
|
|
207
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
208
|
+
const [open, setOpen] = useState(false)
|
|
209
|
+
// The id being switched to, or undefined when nothing is in flight. Null is the row that turns
|
|
210
|
+
// subtitles off, so this cannot be a plain id: undefined has to keep meaning idle.
|
|
211
|
+
const [pending, setPending] = useState<string | number | null | undefined>(undefined)
|
|
212
|
+
const [failed, setFailed] = useState(false)
|
|
213
|
+
|
|
214
|
+
// Held in a ref because the pointerdown listener and the select promise both outlive the render
|
|
215
|
+
// that handed the callback over.
|
|
216
|
+
const reset = useRef(onReset)
|
|
217
|
+
reset.current = onReset
|
|
218
|
+
|
|
219
|
+
const close = () => {
|
|
220
|
+
setOpen(false)
|
|
221
|
+
reset.current?.()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const toggle = () => {
|
|
225
|
+
setOpen((wasOpen) => !wasOpen)
|
|
226
|
+
setFailed(false)
|
|
227
|
+
reset.current?.()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const handleClickOutside = (ev: PointerEvent) => {
|
|
232
|
+
if (open && containerRef.current && !containerRef.current.contains(ev.target as Node)) close()
|
|
233
|
+
}
|
|
234
|
+
// pointerdown, because a touch device synthesizes mousedown too late to close reliably
|
|
235
|
+
document.addEventListener('pointerdown', handleClickOutside)
|
|
236
|
+
return () => document.removeEventListener('pointerdown', handleClickOutside)
|
|
237
|
+
}, [open])
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Runs a track switch and decides when the menu may close.
|
|
241
|
+
*
|
|
242
|
+
* A selector that returns nothing switched synchronously, so the click closes the menu as it always
|
|
243
|
+
* has. One that returns a promise owns a player this one cannot reach, and the menu stays open and
|
|
244
|
+
* inert until it answers: closing first would show a selection that has not happened yet, and
|
|
245
|
+
* dropping the promise would turn a failed switch into an unhandled rejection nobody ever sees.
|
|
246
|
+
*/
|
|
247
|
+
const runSelect = (id: string | number | null, select: () => void | Promise<void>) => {
|
|
248
|
+
if (pending !== undefined) return
|
|
249
|
+
setFailed(false)
|
|
250
|
+
let result: void | Promise<void>
|
|
251
|
+
try {
|
|
252
|
+
result = select()
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.warn('[media-player] track selection failed:', err)
|
|
255
|
+
setFailed(true)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (!(result instanceof Promise)) {
|
|
259
|
+
close()
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
setPending(id)
|
|
263
|
+
result.then(
|
|
264
|
+
() => {
|
|
265
|
+
setPending(undefined)
|
|
266
|
+
close()
|
|
267
|
+
},
|
|
268
|
+
(err) => {
|
|
269
|
+
console.warn('[media-player] track selection failed:', err)
|
|
270
|
+
setPending(undefined)
|
|
271
|
+
setFailed(true)
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { open, toggle, close, containerRef, pending, failed, runSelect }
|
|
277
|
+
}
|