@banou/media-player 0.8.4 → 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 +424 -272
- 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/package.json +1 -1
- 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 +21 -279
- package/src/lib/react/components/subtitles.tsx +80 -0
- package/src/lib/react/components/track-menu.tsx +277 -0
- package/src/lib/react/video-player.tsx +39 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
export declare const Captions: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
3
|
+
/** The slash is Feather's own convention for an off state, the line `MicOff` and `BellOff` draw. */
|
|
4
|
+
export declare const CaptionsOff: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitles, one click from the control bar.
|
|
3
|
+
*
|
|
4
|
+
* It used to be a row inside the settings menu, which put the most-reached control in the player two
|
|
5
|
+
* clicks deep behind a gear, next to playback speed. Audio stays in there: switching it is rare, and
|
|
6
|
+
* on a source that owns its own player it is slow enough to be a considered act.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SubtitlesAction: () => import("@emotion/react/jsx-runtime").JSX.Element | null;
|
|
9
|
+
export default SubtitlesAction;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TrackChoice } from '../source-feature';
|
|
2
|
+
/**
|
|
3
|
+
* The popover surface and the track rows, shared by every menu in the control bar.
|
|
4
|
+
*
|
|
5
|
+
* Carries NO `position` of its own: the menu anchors to the control bar, which is the width of the
|
|
6
|
+
* player box, so each caller keeps `position: static` on its own wrapper. Anchoring to the button
|
|
7
|
+
* instead is what let the menu hang outside the box, where the root's `overflow: hidden` made it
|
|
8
|
+
* unreachable rather than merely ugly.
|
|
9
|
+
*/
|
|
10
|
+
export declare const popoverStyle: import("@emotion/utils").SerializedStyles;
|
|
11
|
+
/**
|
|
12
|
+
* One track picker, used by the subtitles button and by the settings menu's audio page.
|
|
13
|
+
*
|
|
14
|
+
* `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
|
|
15
|
+
* is working, every row is inert and the one being switched to says so, because the selection has not
|
|
16
|
+
* moved yet and a tick next to it would be a lie.
|
|
17
|
+
*/
|
|
18
|
+
export declare const TrackMenu: ({ title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
|
|
19
|
+
title: string;
|
|
20
|
+
tracks: TrackChoice[];
|
|
21
|
+
selected: string | number | undefined;
|
|
22
|
+
onSelect: (id: string | number | undefined) => void;
|
|
23
|
+
/** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
|
|
24
|
+
onBack?: () => void;
|
|
25
|
+
/** Absent means the menu offers no way off at all, which is what audio wants. */
|
|
26
|
+
offLabel?: string;
|
|
27
|
+
/** The id currently being switched to, where `null` is the off row and undefined means idle. */
|
|
28
|
+
pending?: string | number | null;
|
|
29
|
+
failed?: boolean;
|
|
30
|
+
}) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
31
|
+
/**
|
|
32
|
+
* The open/closed and in-flight state every track menu in the bar needs.
|
|
33
|
+
*
|
|
34
|
+
* One hook rather than two, because `runSelect` is what decides when the menu may close: splitting
|
|
35
|
+
* the popover state from the selection state would wire them in a circle.
|
|
36
|
+
*/
|
|
37
|
+
export declare const useTrackMenu: ({ onReset }?: {
|
|
38
|
+
onReset?: () => void;
|
|
39
|
+
}) => {
|
|
40
|
+
open: boolean;
|
|
41
|
+
toggle: () => void;
|
|
42
|
+
close: () => void;
|
|
43
|
+
containerRef: import("react").RefObject<HTMLDivElement | null>;
|
|
44
|
+
pending: string | number | null | undefined;
|
|
45
|
+
failed: boolean;
|
|
46
|
+
runSelect: (id: string | number | null, select: () => void | Promise<void>) => void;
|
|
47
|
+
};
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import { TooltipDisplay } from './tooltip-display'
|
|
|
11
11
|
import { ProgressBar } from './progress-bar'
|
|
12
12
|
import pictureInPicture from '../../assets/picture-in-picture.svg'
|
|
13
13
|
import SettingsAction from './settings'
|
|
14
|
+
import SubtitlesAction from './subtitles'
|
|
14
15
|
import colors from '../../utils/colors'
|
|
15
16
|
import Sound from './sound'
|
|
16
17
|
|
|
@@ -69,7 +70,7 @@ const style = css`
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
.play, .sound, .time, .settings, .picture-in-picture, .full-screen {
|
|
73
|
+
.play, .sound, .time, .subtitles, .settings, .picture-in-picture, .full-screen {
|
|
73
74
|
display: flex;
|
|
74
75
|
align-items: center;
|
|
75
76
|
|
|
@@ -87,7 +88,7 @@ const style = css`
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
.play, .sound, .settings, .picture-in-picture, .full-screen {
|
|
91
|
+
.play, .sound, .subtitles, .settings, .picture-in-picture, .full-screen {
|
|
91
92
|
border-radius: 4px;
|
|
92
93
|
|
|
93
94
|
padding: 8px;
|
|
@@ -258,6 +259,7 @@ export const ControlBar = () => {
|
|
|
258
259
|
</div>
|
|
259
260
|
</div>
|
|
260
261
|
<div className='right'>
|
|
262
|
+
<SubtitlesAction />
|
|
261
263
|
<SettingsAction />
|
|
262
264
|
{togglePictureInPicture
|
|
263
265
|
? (
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SVGProps } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The captions glyph, drawn here because react-feather does not have one.
|
|
5
|
+
*
|
|
6
|
+
* Checked against react-feather 2.0.10's 287 icons: there is no captions, subtitles or CC glyph, and
|
|
7
|
+
* nothing it does have reads as one (`Type` is a text-formatting T, `MessageSquare` is a chat bubble,
|
|
8
|
+
* `FileText` is a document). Drawn on Feather's own 24 grid at stroke-width 2 with round caps and
|
|
9
|
+
* carrying no size or colour of its own, so the control bar's `svg` rules size and stroke it exactly
|
|
10
|
+
* as they do `Play` and `Settings`.
|
|
11
|
+
*/
|
|
12
|
+
const iconProps = {
|
|
13
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
14
|
+
width: 24,
|
|
15
|
+
height: 24,
|
|
16
|
+
viewBox: '0 0 24 24',
|
|
17
|
+
fill: 'none',
|
|
18
|
+
stroke: 'currentColor',
|
|
19
|
+
strokeWidth: 2,
|
|
20
|
+
strokeLinecap: 'round',
|
|
21
|
+
strokeLinejoin: 'round',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export const Captions = (props: SVGProps<SVGSVGElement>) => (
|
|
25
|
+
<svg {...iconProps} {...props}>
|
|
26
|
+
<rect x='3' y='5' width='18' height='14' rx='2' ry='2' />
|
|
27
|
+
<path d='M7 15h4m4 0h2M7 11h2m4 0h4' />
|
|
28
|
+
</svg>
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/** The slash is Feather's own convention for an off state, the line `MicOff` and `BellOff` draw. */
|
|
32
|
+
export const CaptionsOff = (props: SVGProps<SVGSVGElement>) => (
|
|
33
|
+
<svg {...iconProps} {...props}>
|
|
34
|
+
<rect x='3' y='5' width='18' height='14' rx='2' ry='2' />
|
|
35
|
+
<path d='M7 15h4m4 0h2M7 11h2m4 0h4' />
|
|
36
|
+
<line x1='2' y1='2' x2='22' y2='22' />
|
|
37
|
+
</svg>
|
|
38
|
+
)
|
|
@@ -52,14 +52,28 @@ const style = css`
|
|
|
52
52
|
display: flex;
|
|
53
53
|
justify-content: center;
|
|
54
54
|
|
|
55
|
+
/* The seekbar's other hover affordance, the thumbnail below, gets a rounded box and a drop
|
|
56
|
+
shadow. A source that ships no thumbnails, which is every source whose media the player does
|
|
57
|
+
not own, leaves this readout as the entire preview, so it carries the same treatment instead
|
|
58
|
+
of sitting as bare text on the picture. The ink comes from the player root. */
|
|
59
|
+
background-color: rgba(28, 28, 28, .95);
|
|
60
|
+
border-radius: calc(.4 * var(--mp-unit));
|
|
61
|
+
padding: 0 calc(.6 * var(--mp-unit));
|
|
62
|
+
box-shadow: 0 0 calc(1 * var(--mp-unit)) rgba(0, 0, 0, .5);
|
|
63
|
+
|
|
55
64
|
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
56
65
|
${fonts.bMedium.bold}
|
|
57
66
|
|
|
58
67
|
position: absolute;
|
|
59
|
-
top:
|
|
60
|
-
|
|
68
|
+
/* Anchored on its bottom edge rather than its top: now that it has a background its height
|
|
69
|
+
follows the font size, and a top-anchored box would grow downward into the track. */
|
|
70
|
+
bottom: calc(1.2 * var(--mp-unit));
|
|
71
|
+
/* Sized to its content, replacing a fixed 5 unit box that \`1:04:09\` overflows. Transparent, that
|
|
72
|
+
overflow was invisible; filled, it would not be. */
|
|
73
|
+
width: max-content;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
transform: translateX(-50%);
|
|
61
76
|
|
|
62
|
-
margin-left: calc(-2.5 * var(--mp-unit));
|
|
63
77
|
pointer-events: none;
|
|
64
78
|
}
|
|
65
79
|
|
|
@@ -297,7 +311,9 @@ export const ProgressBar = () => {
|
|
|
297
311
|
? (
|
|
298
312
|
<div
|
|
299
313
|
className="cursor-time"
|
|
300
|
-
|
|
314
|
+
/* the inset grew with the pill: content sized and centred, its half width is now the
|
|
315
|
+
padding plus the text, so the old 18px let a filled box hang past both ends */
|
|
316
|
+
style={{ left: `clamp(calc(3 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(3 * var(--mp-unit))))` }}
|
|
301
317
|
>
|
|
302
318
|
{cusorTimeString}
|
|
303
319
|
</div>
|
|
@@ -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,179 +67,31 @@ position: relative;
|
|
|
154
67
|
}
|
|
155
68
|
}
|
|
156
69
|
|
|
157
|
-
.track-list {
|
|
158
|
-
.description {
|
|
159
|
-
word-break: break-word;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/* Dimmed and inert, but still listed. A source that names a track it cannot serve right now is
|
|
163
|
-
saying the track exists, and dropping the row would read as the source having nothing. */
|
|
164
|
-
.unavailable {
|
|
165
|
-
opacity: 0.5;
|
|
166
|
-
cursor: default;
|
|
167
|
-
:hover {
|
|
168
|
-
background-color: transparent;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
.failed {
|
|
173
|
-
border-bottom: 1px solid #4D4D4E;
|
|
174
|
-
color: #f66;
|
|
175
|
-
${fonts.bSmall.regular}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
70
|
`
|
|
179
71
|
|
|
180
72
|
enum PopoverContent {
|
|
181
73
|
Default,
|
|
182
74
|
PlaybackRate,
|
|
183
|
-
Subtitles,
|
|
184
75
|
Audio
|
|
185
76
|
}
|
|
186
77
|
|
|
187
|
-
/**
|
|
188
|
-
* One track picker, shared by the subtitle and audio menus.
|
|
189
|
-
*
|
|
190
|
-
* `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
|
|
191
|
-
* is working, every row is inert and the one being switched to says so, because the selection has
|
|
192
|
-
* not moved yet and a tick next to it would be a lie.
|
|
193
|
-
*/
|
|
194
|
-
const TrackMenu = (
|
|
195
|
-
{ title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
|
|
196
|
-
title: string
|
|
197
|
-
tracks: TrackChoice[]
|
|
198
|
-
selected: string | number | undefined
|
|
199
|
-
onSelect: (id: string | number | undefined) => void
|
|
200
|
-
onBack: () => void
|
|
201
|
-
/** Absent means the menu offers no way off at all, which is what audio wants. */
|
|
202
|
-
offLabel?: string
|
|
203
|
-
/** The id currently being switched to, where `null` is the off row and undefined means idle. */
|
|
204
|
-
pending?: string | number | null
|
|
205
|
-
failed?: boolean
|
|
206
|
-
}
|
|
207
|
-
) => {
|
|
208
|
-
const busy = pending !== undefined
|
|
209
|
-
const row = (id: string | number | null, label: string, className?: string) => {
|
|
210
|
-
const isPending = busy && pending === id
|
|
211
|
-
const disabled = busy || tracks.find((track) => track.id === id)?.disabled
|
|
212
|
-
return (
|
|
213
|
-
<div
|
|
214
|
-
key={id ?? '__off__'}
|
|
215
|
-
onClick={() => { if (!disabled) onSelect(id ?? undefined) }}
|
|
216
|
-
className={[className, disabled ? 'unavailable' : null].filter(Boolean).join(' ') || undefined}
|
|
217
|
-
>
|
|
218
|
-
<span>{label}</span>
|
|
219
|
-
<span>{isPending ? '…' : (selected ?? null) === id ? '✓' : ''}</span>
|
|
220
|
-
</div>
|
|
221
|
-
)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
<div className='popover track-list'>
|
|
226
|
-
<div className="back" onClick={onBack}>
|
|
227
|
-
<ChevronLeft />
|
|
228
|
-
<span>{title}</span>
|
|
229
|
-
</div>
|
|
230
|
-
{failed ? <div className="no-hover failed">Could not switch. Try again.</div> : null}
|
|
231
|
-
{offLabel ? row(null, offLabel) : null}
|
|
232
|
-
{tracks.map(({ id, label }) => row(id, label, 'description'))}
|
|
233
|
-
</div>
|
|
234
|
-
)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
78
|
export const SettingsAction = () => {
|
|
238
79
|
const player = usePlayer()
|
|
239
80
|
const playbackRate = usePlayer((state) => state.playbackRate)
|
|
240
|
-
const subtitleTracks = usePlayer((state) => state.subtitleTracks)
|
|
241
|
-
const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
|
|
242
|
-
const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
|
|
243
81
|
const audioTracks = usePlayer((state) => state.audioTracks)
|
|
244
82
|
const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
|
|
245
83
|
const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
|
|
246
84
|
|
|
247
|
-
const subtitleOffLabel = usePlayer((state) => state.subtitleOffLabel)
|
|
248
|
-
|
|
249
|
-
const [isOpenPopover, setIsOpenPopover] = useState(false)
|
|
250
85
|
const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const [pending, setPending] = useState<string | number | null | undefined>(undefined)
|
|
255
|
-
const [failed, setFailed] = useState(false)
|
|
256
|
-
|
|
257
|
-
const togglePopover = () => {
|
|
258
|
-
setIsOpenPopover(!isOpenPopover)
|
|
259
|
-
setPopoverContent(PopoverContent.Default)
|
|
260
|
-
setFailed(false)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Not `togglePopover`: this runs from a promise, where the captured `isOpenPopover` is whatever it
|
|
264
|
-
// was at click time, so a toggle could reopen a menu the viewer has already dismissed.
|
|
265
|
-
const closePopover = () => {
|
|
266
|
-
setIsOpenPopover(false)
|
|
267
|
-
setPopoverContent(PopoverContent.Default)
|
|
268
|
-
}
|
|
86
|
+
const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu({
|
|
87
|
+
onReset: () => setPopoverContent(PopoverContent.Default),
|
|
88
|
+
})
|
|
269
89
|
|
|
270
90
|
const setPlaybackRate = (rate: number) => {
|
|
271
91
|
player.setPlaybackRate(rate)
|
|
272
92
|
setPopoverContent(PopoverContent.Default)
|
|
273
93
|
}
|
|
274
94
|
|
|
275
|
-
useEffect(() => {
|
|
276
|
-
const handleClickOutside = (ev: PointerEvent) => {
|
|
277
|
-
if (
|
|
278
|
-
isOpenPopover &&
|
|
279
|
-
settingsContainerRef.current &&
|
|
280
|
-
!settingsContainerRef.current.contains(ev.target as Node)
|
|
281
|
-
) {
|
|
282
|
-
togglePopover()
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
// pointerdown, because a touch device synthesizes mousedown too late to close reliably
|
|
286
|
-
document.addEventListener('pointerdown', handleClickOutside)
|
|
287
|
-
return () => document.removeEventListener('pointerdown', handleClickOutside)
|
|
288
|
-
}, [isOpenPopover])
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Runs a track switch and decides when the menu may close.
|
|
292
|
-
*
|
|
293
|
-
* A selector that returns nothing switched synchronously, so the click closes the menu as it always
|
|
294
|
-
* has. One that returns a promise owns a player this one cannot reach, and the menu stays open and
|
|
295
|
-
* inert until it answers: closing first would show a selection that has not happened yet, and
|
|
296
|
-
* dropping the promise would turn a failed switch into an unhandled rejection nobody ever sees.
|
|
297
|
-
*/
|
|
298
|
-
const runSelect = (id: string | number | null, select: () => void | Promise<void>) => {
|
|
299
|
-
if (pending !== undefined) return
|
|
300
|
-
setFailed(false)
|
|
301
|
-
let result: void | Promise<void>
|
|
302
|
-
try {
|
|
303
|
-
result = select()
|
|
304
|
-
} catch (err) {
|
|
305
|
-
console.warn('[media-player] track selection failed:', err)
|
|
306
|
-
setFailed(true)
|
|
307
|
-
return
|
|
308
|
-
}
|
|
309
|
-
if (!(result instanceof Promise)) {
|
|
310
|
-
closePopover()
|
|
311
|
-
return
|
|
312
|
-
}
|
|
313
|
-
setPending(id)
|
|
314
|
-
result.then(
|
|
315
|
-
() => {
|
|
316
|
-
setPending(undefined)
|
|
317
|
-
closePopover()
|
|
318
|
-
},
|
|
319
|
-
(err) => {
|
|
320
|
-
console.warn('[media-player] track selection failed:', err)
|
|
321
|
-
setPending(undefined)
|
|
322
|
-
setFailed(true)
|
|
323
|
-
},
|
|
324
|
-
)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const chooseSubtitle = (id: string | number | undefined) =>
|
|
328
|
-
runSelect(id ?? null, () => selectSubtitleTrack(id))
|
|
329
|
-
|
|
330
95
|
// the audio menu offers no way off, so there is no undefined case to answer for
|
|
331
96
|
const chooseAudio = (id: string | number | undefined) => {
|
|
332
97
|
if (id === undefined) return
|
|
@@ -338,15 +103,15 @@ export const SettingsAction = () => {
|
|
|
338
103
|
}
|
|
339
104
|
|
|
340
105
|
return (
|
|
341
|
-
<div css={style} ref={
|
|
106
|
+
<div css={style} ref={containerRef}>
|
|
342
107
|
<TooltipDisplay
|
|
343
108
|
id='settings'
|
|
344
|
-
disabled={
|
|
109
|
+
disabled={open}
|
|
345
110
|
text={
|
|
346
111
|
<button
|
|
347
112
|
className='settings'
|
|
348
113
|
type='button'
|
|
349
|
-
onClick={
|
|
114
|
+
onClick={toggle}
|
|
350
115
|
>
|
|
351
116
|
<Settings />
|
|
352
117
|
</button>
|
|
@@ -358,12 +123,13 @@ export const SettingsAction = () => {
|
|
|
358
123
|
}
|
|
359
124
|
/>
|
|
360
125
|
{
|
|
361
|
-
|
|
126
|
+
open && popoverContent === PopoverContent.Default && (
|
|
362
127
|
<div className='popover menu'>
|
|
363
128
|
{/*
|
|
364
|
-
A row is offered only where there is more than one thing behind it to choose between
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
367
133
|
*/}
|
|
368
134
|
{audioTracks.length > 1
|
|
369
135
|
? (
|
|
@@ -375,16 +141,6 @@ export const SettingsAction = () => {
|
|
|
375
141
|
</div>
|
|
376
142
|
)
|
|
377
143
|
: null}
|
|
378
|
-
{subtitleTracks.length > 0
|
|
379
|
-
? (
|
|
380
|
-
<div onClick={changePopoverContent(PopoverContent.Subtitles)}>
|
|
381
|
-
<div>Subtitles</div>
|
|
382
|
-
<div>
|
|
383
|
-
<ChevronRight />
|
|
384
|
-
</div>
|
|
385
|
-
</div>
|
|
386
|
-
)
|
|
387
|
-
: null}
|
|
388
144
|
<div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
|
|
389
145
|
<div>Playback speed</div>
|
|
390
146
|
<div>
|
|
@@ -402,7 +158,7 @@ export const SettingsAction = () => {
|
|
|
402
158
|
)
|
|
403
159
|
}
|
|
404
160
|
{
|
|
405
|
-
|
|
161
|
+
open && popoverContent === PopoverContent.PlaybackRate && (
|
|
406
162
|
<div className='popover playback-rate'>
|
|
407
163
|
<div className="back" onClick={changePopoverContent(PopoverContent.Default)}>
|
|
408
164
|
<ChevronLeft />
|
|
@@ -429,21 +185,7 @@ export const SettingsAction = () => {
|
|
|
429
185
|
)
|
|
430
186
|
}
|
|
431
187
|
{
|
|
432
|
-
|
|
433
|
-
<TrackMenu
|
|
434
|
-
title='Subtitles'
|
|
435
|
-
tracks={subtitleTracks}
|
|
436
|
-
selected={selectedSubtitleTrack}
|
|
437
|
-
onSelect={chooseSubtitle}
|
|
438
|
-
onBack={changePopoverContent(PopoverContent.Default)}
|
|
439
|
-
offLabel={subtitleOffLabel ?? 'Disable'}
|
|
440
|
-
pending={pending}
|
|
441
|
-
failed={failed}
|
|
442
|
-
/>
|
|
443
|
-
)
|
|
444
|
-
}
|
|
445
|
-
{
|
|
446
|
-
isOpenPopover && popoverContent === PopoverContent.Audio && (
|
|
188
|
+
open && popoverContent === PopoverContent.Audio && (
|
|
447
189
|
<TrackMenu
|
|
448
190
|
title='Audio'
|
|
449
191
|
tracks={audioTracks}
|
|
@@ -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
|