@banou/media-player 0.8.4 → 0.8.6
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/engine/index.d.ts +2 -2
- package/dist/engine/index.js +2 -2
- package/dist/engine/picture-in-picture.d.ts +18 -6
- package/dist/{engine-Dmc7JSd9.js → engine--OlGG-Ve.js} +128 -97
- package/dist/index.js +757 -448
- package/dist/react/components/burn-in-hint.d.ts +9 -0
- package/dist/react/components/icons.d.ts +12 -0
- package/dist/react/components/subtitles.d.ts +9 -0
- package/dist/react/components/track-menu.d.ts +47 -0
- package/dist/react/hooks/use-picture-in-picture.d.ts +17 -2
- package/dist/react/player.d.ts +2 -0
- package/dist/react/source-feature.d.ts +17 -1
- package/dist/utils/colors.d.ts +2 -0
- package/package.json +1 -1
- package/src/lib/engine/index.ts +2 -2
- package/src/lib/engine/picture-in-picture.ts +183 -49
- package/src/lib/react/components/burn-in-hint.tsx +105 -0
- package/src/lib/react/components/chrome.tsx +3 -0
- package/src/lib/react/components/control-bar.tsx +80 -5
- package/src/lib/react/components/icons.tsx +53 -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/hooks/use-picture-in-picture.ts +30 -12
- package/src/lib/react/source-feature.ts +12 -1
- package/src/lib/react/video-player.tsx +54 -3
- package/src/lib/utils/colors.ts +2 -0
|
@@ -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
|
+
}
|
|
@@ -1,32 +1,50 @@
|
|
|
1
|
-
import type { PictureInPictureController } from '../../engine'
|
|
1
|
+
import type { PictureInPictureController, PictureInPictureMode } from '../../engine'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
4
|
|
|
5
|
-
import { createPictureInPicture } from '../../engine'
|
|
6
|
-
import { usePlayer } from '../player'
|
|
5
|
+
import { createPictureInPicture, pictureInPictureMode } from '../../engine'
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
export type PictureInPicture = {
|
|
8
|
+
/** null when nothing here can work, and the chrome then offers no control at all. */
|
|
9
|
+
toggle: (() => void) | null
|
|
10
|
+
mode: PictureInPictureMode | null
|
|
11
|
+
/** Burn-in only. True while the composite is the picture on screen. */
|
|
12
|
+
burnedIn: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Picture in picture with the subtitles composited in.
|
|
17
|
+
*
|
|
18
|
+
* Two shapes, because two kinds of browser. Where the W3C API exists this opens a real window off a
|
|
19
|
+
* hidden mirror. Where it does not, and the engine is Gecko, the same composite becomes the picture
|
|
20
|
+
* in the page so that the BROWSER'S own picture in picture control carries the subtitles with it,
|
|
21
|
+
* which it otherwise cannot: it takes a video element, and the subtitles live on a canvas above one.
|
|
22
|
+
*/
|
|
9
23
|
export const usePictureInPicture = (
|
|
10
24
|
video: HTMLVideoElement | null,
|
|
11
25
|
canvas: HTMLCanvasElement | null,
|
|
12
|
-
) => {
|
|
13
|
-
const player = usePlayer()
|
|
26
|
+
): PictureInPicture => {
|
|
14
27
|
const controller = useRef<PictureInPictureController | null>(null)
|
|
28
|
+
const [burnedIn, setBurnedIn] = useState(false)
|
|
29
|
+
// Detected once. It cannot change for the life of the document, and recomputing it per render
|
|
30
|
+
// would churn the identity of everything downstream.
|
|
31
|
+
const [mode] = useState<PictureInPictureMode | null>(() => pictureInPictureMode())
|
|
15
32
|
|
|
16
33
|
useEffect(() => {
|
|
17
|
-
if (!video || !canvas) return
|
|
34
|
+
if (!video || !canvas || !mode) return
|
|
18
35
|
const instance = createPictureInPicture({
|
|
19
36
|
video,
|
|
20
37
|
canvas,
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
mode,
|
|
39
|
+
onBurnedInChange: setBurnedIn,
|
|
23
40
|
})
|
|
24
41
|
controller.current = instance
|
|
25
42
|
return () => {
|
|
26
43
|
instance.destroy()
|
|
27
44
|
controller.current = null
|
|
45
|
+
setBurnedIn(false)
|
|
28
46
|
}
|
|
29
|
-
}, [video, canvas,
|
|
47
|
+
}, [video, canvas, mode])
|
|
30
48
|
|
|
31
49
|
const toggle = useCallback(() => {
|
|
32
50
|
void controller.current?.toggle().catch((error) => {
|
|
@@ -36,5 +54,5 @@ export const usePictureInPicture = (
|
|
|
36
54
|
|
|
37
55
|
// null rather than a dead callback: the chrome hides the control instead of offering one that
|
|
38
56
|
// cannot work, and there is nothing to composite without both an element and a canvas.
|
|
39
|
-
return video && canvas ? toggle : null
|
|
57
|
+
return { toggle: video && canvas && mode ? toggle : null, mode, burnedIn }
|
|
40
58
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MediaIndex, ThumbnailImage } from '../engine'
|
|
1
|
+
import type { MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
|
|
2
2
|
|
|
3
3
|
import { definePlayerFeature } from '@videojs/core/dom'
|
|
4
4
|
|
|
@@ -89,6 +89,15 @@ export type SourceState = {
|
|
|
89
89
|
*/
|
|
90
90
|
togglePictureInPicture: (() => void) | null
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
|
|
94
|
+
* subtitles into the picture so the BROWSER'S own control carries them; the viewer presses that
|
|
95
|
+
* one afterwards, so the button has to say something different. null means no control.
|
|
96
|
+
*/
|
|
97
|
+
pictureInPictureMode: PictureInPictureMode | null
|
|
98
|
+
/** Burn-in only. True while the composite is the picture on screen. */
|
|
99
|
+
burnedInSubtitles: boolean
|
|
100
|
+
|
|
92
101
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
93
102
|
playbackError: unknown
|
|
94
103
|
/** Whether the engine has produced its first media segment. */
|
|
@@ -116,6 +125,8 @@ const initialState: SourceState = {
|
|
|
116
125
|
hideUI: false,
|
|
117
126
|
setHideUI: () => {},
|
|
118
127
|
togglePictureInPicture: null,
|
|
128
|
+
pictureInPictureMode: null,
|
|
129
|
+
burnedInSubtitles: false,
|
|
119
130
|
playbackError: null,
|
|
120
131
|
ready: false,
|
|
121
132
|
setSourceState: () => {},
|
|
@@ -187,7 +187,11 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
187
187
|
downloadedRanges,
|
|
188
188
|
})
|
|
189
189
|
const thumbnails = remote?.thumbnails?.all ?? generatedThumbnails
|
|
190
|
-
const
|
|
190
|
+
const {
|
|
191
|
+
toggle: togglePictureInPicture,
|
|
192
|
+
mode: pictureInPictureMode,
|
|
193
|
+
burnedIn: burnedInSubtitles,
|
|
194
|
+
} = usePictureInPicture(video, canvas)
|
|
191
195
|
|
|
192
196
|
// Subscribed rather than read off the store, because it is a no-op until the media element
|
|
193
197
|
// attaches: when attach swaps in the real setter the identity changes and these publish again.
|
|
@@ -199,8 +203,16 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
199
203
|
|
|
200
204
|
const thumbnailAt = remote?.thumbnails?.at
|
|
201
205
|
useEffect(() => {
|
|
202
|
-
setSourceState({
|
|
203
|
-
|
|
206
|
+
setSourceState({
|
|
207
|
+
thumbnails,
|
|
208
|
+
thumbnailAt,
|
|
209
|
+
togglePictureInPicture,
|
|
210
|
+
pictureInPictureMode,
|
|
211
|
+
burnedInSubtitles,
|
|
212
|
+
})
|
|
213
|
+
}, [
|
|
214
|
+
setSourceState, thumbnails, thumbnailAt, togglePictureInPicture, pictureInPictureMode, burnedInSubtitles,
|
|
215
|
+
])
|
|
204
216
|
|
|
205
217
|
// A delegated track list writes the same store fields the engine writes, so the menus never learn
|
|
206
218
|
// which arm they are showing. Only the writer differs: here the pick is forwarded to whoever owns
|
|
@@ -257,6 +269,45 @@ const rootStyle = css`
|
|
|
257
269
|
*/
|
|
258
270
|
--mp-unit: 10px;
|
|
259
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The chrome's own typeface, for exactly the reason the unit above exists.
|
|
274
|
+
*
|
|
275
|
+
* \`font-family\` inherits, and this library declared it nowhere, so every label drew in whatever the
|
|
276
|
+
* embedding document happened to set, and in the UA's serif where it set nothing. Not hypothetical:
|
|
277
|
+
* stub mounts the player in a second document, \`embed.html\`, which sets no font and loads no face,
|
|
278
|
+
* and the entire settings menu came out in Times New Roman.
|
|
279
|
+
*
|
|
280
|
+
* Named faces only, no bundled webfont. The chrome is a handful of short labels, so a font request
|
|
281
|
+
* on the critical path of a document whose job is to paint video buys a round trip and a flash of
|
|
282
|
+
* unstyled text for nothing.
|
|
283
|
+
*
|
|
284
|
+
* Read through \`var()\` so a host with a brand face sets \`--mp-font-family\` on any ancestor and it
|
|
285
|
+
* inherits in. A bare \`font-family\` on the host can no longer reach the chrome: this declaration is
|
|
286
|
+
* on the player element itself and beats anything inherited.
|
|
287
|
+
*/
|
|
288
|
+
font-family: var(
|
|
289
|
+
--mp-font-family,
|
|
290
|
+
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif,
|
|
291
|
+
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji'
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The chrome's default ink, same argument again.
|
|
296
|
+
*
|
|
297
|
+
* Most text here names its own colour, but the two time readouts do not: the elapsed/duration pair
|
|
298
|
+
* and the seekbar's hover time carry a font token and a black text-shadow and nothing else.
|
|
299
|
+
* Inherited from a host that declares none that resolves to \`canvastext\`, which is black text under
|
|
300
|
+
* a black halo on a dark gradient.
|
|
301
|
+
*/
|
|
302
|
+
color: #fff;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The settings menu scrolls, and a UA that has not been told the surface is dark paints that
|
|
306
|
+
* scrollbar in its light theme: a white track down the side of a #1c1c1c menu. Declared here rather
|
|
307
|
+
* than asked of the host, so it reaches the menu and nothing outside the player.
|
|
308
|
+
*/
|
|
309
|
+
color-scheme: dark;
|
|
310
|
+
|
|
260
311
|
/**
|
|
261
312
|
* The other half of owning the chrome's own scale.
|
|
262
313
|
*
|
package/src/lib/utils/colors.ts
CHANGED
|
@@ -2,6 +2,8 @@ const colors = {
|
|
|
2
2
|
primary: '#EAEBEE',
|
|
3
3
|
secondary: '#D0D0D9',
|
|
4
4
|
hover: 'rgba(255, 255, 255, 0.13)',
|
|
5
|
+
/** A control that is ON. Distinct from `hover`, which the pointer is already painting. */
|
|
6
|
+
accent: '#6EA8FE',
|
|
5
7
|
borderPrimary: '#384F70',
|
|
6
8
|
backgroundTooltip: '#222222',
|
|
7
9
|
}
|