@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,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
|
+
}
|
|
@@ -257,6 +257,45 @@ const rootStyle = css`
|
|
|
257
257
|
*/
|
|
258
258
|
--mp-unit: 10px;
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* The chrome's own typeface, for exactly the reason the unit above exists.
|
|
262
|
+
*
|
|
263
|
+
* \`font-family\` inherits, and this library declared it nowhere, so every label drew in whatever the
|
|
264
|
+
* embedding document happened to set, and in the UA's serif where it set nothing. Not hypothetical:
|
|
265
|
+
* stub mounts the player in a second document, \`embed.html\`, which sets no font and loads no face,
|
|
266
|
+
* and the entire settings menu came out in Times New Roman.
|
|
267
|
+
*
|
|
268
|
+
* Named faces only, no bundled webfont. The chrome is a handful of short labels, so a font request
|
|
269
|
+
* on the critical path of a document whose job is to paint video buys a round trip and a flash of
|
|
270
|
+
* unstyled text for nothing.
|
|
271
|
+
*
|
|
272
|
+
* Read through \`var()\` so a host with a brand face sets \`--mp-font-family\` on any ancestor and it
|
|
273
|
+
* inherits in. A bare \`font-family\` on the host can no longer reach the chrome: this declaration is
|
|
274
|
+
* on the player element itself and beats anything inherited.
|
|
275
|
+
*/
|
|
276
|
+
font-family: var(
|
|
277
|
+
--mp-font-family,
|
|
278
|
+
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif,
|
|
279
|
+
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji'
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The chrome's default ink, same argument again.
|
|
284
|
+
*
|
|
285
|
+
* Most text here names its own colour, but the two time readouts do not: the elapsed/duration pair
|
|
286
|
+
* and the seekbar's hover time carry a font token and a black text-shadow and nothing else.
|
|
287
|
+
* Inherited from a host that declares none that resolves to \`canvastext\`, which is black text under
|
|
288
|
+
* a black halo on a dark gradient.
|
|
289
|
+
*/
|
|
290
|
+
color: #fff;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The settings menu scrolls, and a UA that has not been told the surface is dark paints that
|
|
294
|
+
* scrollbar in its light theme: a white track down the side of a #1c1c1c menu. Declared here rather
|
|
295
|
+
* than asked of the host, so it reaches the menu and nothing outside the player.
|
|
296
|
+
*/
|
|
297
|
+
color-scheme: dark;
|
|
298
|
+
|
|
260
299
|
/**
|
|
261
300
|
* The other half of owning the chrome's own scale.
|
|
262
301
|
*
|