@banou/media-player 0.8.5 → 0.8.7
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-B6flRjLd.js} +149 -111
- package/dist/index.js +521 -364
- package/dist/react/components/burn-in-hint.d.ts +9 -0
- package/dist/react/components/icons.d.ts +8 -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/engine/playback.ts +28 -0
- 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 +76 -3
- package/src/lib/react/components/icons.tsx +15 -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 +15 -3
- package/src/lib/utils/colors.ts +2 -0
|
@@ -10,6 +10,7 @@ import { usePlayer } from '../player'
|
|
|
10
10
|
import { TooltipDisplay } from './tooltip-display'
|
|
11
11
|
import { ProgressBar } from './progress-bar'
|
|
12
12
|
import pictureInPicture from '../../assets/picture-in-picture.svg'
|
|
13
|
+
import { SubtitlesInPicture } from './icons'
|
|
13
14
|
import SettingsAction from './settings'
|
|
14
15
|
import SubtitlesAction from './subtitles'
|
|
15
16
|
import colors from '../../utils/colors'
|
|
@@ -18,6 +19,25 @@ import Sound from './sound'
|
|
|
18
19
|
const VOLUME_STEP = 0.05
|
|
19
20
|
const SEEK_STEP = 5
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Two stacked lines inside a tooltip, with a width to wrap against.
|
|
24
|
+
*
|
|
25
|
+
* Applied to the content rather than to the tooltip, because react-tooltip renders into a portal and
|
|
26
|
+
* an unconstrained tooltip grows to one long line that runs off the side of the player.
|
|
27
|
+
*/
|
|
28
|
+
const tooltipLinesStyle = css`
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
gap: calc(0.4 * var(--mp-unit));
|
|
32
|
+
max-width: calc(26 * var(--mp-unit));
|
|
33
|
+
white-space: normal;
|
|
34
|
+
|
|
35
|
+
.hint {
|
|
36
|
+
opacity: 0.72;
|
|
37
|
+
font-size: 0.9em;
|
|
38
|
+
}
|
|
39
|
+
`
|
|
40
|
+
|
|
21
41
|
const style = css`
|
|
22
42
|
position: absolute;
|
|
23
43
|
bottom: 0;
|
|
@@ -68,6 +88,22 @@ const style = css`
|
|
|
68
88
|
svg {
|
|
69
89
|
stroke: #fff;
|
|
70
90
|
}
|
|
91
|
+
|
|
92
|
+
/* Reads as unavailable rather than absent, so the bar does not reflow when tracks land. */
|
|
93
|
+
&:disabled {
|
|
94
|
+
cursor: default;
|
|
95
|
+
opacity: .4;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The burn-in control's own on state.
|
|
101
|
+
*
|
|
102
|
+
* Not \`colors.hover\`: the pointer is on the button at the moment of the click, so an on state
|
|
103
|
+
* drawn in the hover colour is invisible exactly when it is being looked for.
|
|
104
|
+
*/
|
|
105
|
+
button[aria-pressed='true'] svg {
|
|
106
|
+
stroke: ${colors.accent};
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
.play, .sound, .time, .subtitles, .settings, .picture-in-picture, .full-screen {
|
|
@@ -150,8 +186,17 @@ export const ControlBar = () => {
|
|
|
150
186
|
const fullscreen = usePlayer((state) => state.fullscreen)
|
|
151
187
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
152
188
|
const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
|
|
189
|
+
const pictureInPictureMode = usePlayer((state) => state.pictureInPictureMode)
|
|
190
|
+
const burnedInSubtitles = usePlayer((state) => state.burnedInSubtitles)
|
|
191
|
+
const subtitleTracks = usePlayer((state) => state.subtitleTracks)
|
|
153
192
|
const [volumeElement, setVolumeElement] = useState<HTMLButtonElement | null>(null)
|
|
154
193
|
|
|
194
|
+
const burnIn = pictureInPictureMode === 'burn-in'
|
|
195
|
+
// Burning nothing in is a mode with no effect, so the control stays visible and goes dead rather
|
|
196
|
+
// than vanishing: the track list arrives from libav a moment after playback starts, and a button
|
|
197
|
+
// that pops in late shifts every control beside it a second time.
|
|
198
|
+
const hasSubtitles = subtitleTracks.length > 0
|
|
199
|
+
|
|
155
200
|
// duration is 0 until metadata lands, so a bare equality would show replay before playback starts
|
|
156
201
|
const ended = duration > 0 && currentTime === duration
|
|
157
202
|
|
|
@@ -265,18 +310,46 @@ export const ControlBar = () => {
|
|
|
265
310
|
? (
|
|
266
311
|
<TooltipDisplay
|
|
267
312
|
id='picture-in-picture'
|
|
313
|
+
// anchored to its end, or the two-line burn-in copy runs off the right of the player
|
|
314
|
+
tooltipPlace='top-end'
|
|
268
315
|
text={
|
|
269
316
|
<button
|
|
270
317
|
className='picture-in-picture'
|
|
271
318
|
type='button'
|
|
272
319
|
onClick={togglePictureInPicture}
|
|
320
|
+
disabled={burnIn && !hasSubtitles}
|
|
321
|
+
// Static name with the state on `aria-pressed`, rather than a name that changes
|
|
322
|
+
// on activation: one announces the state once, the other announces it twice and
|
|
323
|
+
// renames the control while the pointer is on it.
|
|
324
|
+
aria-label={burnIn ? 'Put the subtitles in the video' : 'Picture in picture'}
|
|
325
|
+
aria-pressed={burnIn ? burnedInSubtitles : undefined}
|
|
273
326
|
>
|
|
274
|
-
|
|
327
|
+
{burnIn
|
|
328
|
+
? <SubtitlesInPicture />
|
|
329
|
+
: <img src={pictureInPicture} alt='' />}
|
|
275
330
|
</button>
|
|
276
331
|
}
|
|
277
332
|
toolTipText={
|
|
278
|
-
|
|
279
|
-
|
|
333
|
+
// The tooltip is portaled out of this subtree, so the control bar's own rules
|
|
334
|
+
// never reach it: a bare `small` stays inline and runs straight on from the line
|
|
335
|
+
// above it. Both lines carry their layout themselves.
|
|
336
|
+
<span css={tooltipLinesStyle}>
|
|
337
|
+
<span className='lead'>
|
|
338
|
+
{!burnIn
|
|
339
|
+
? 'Picture in picture'
|
|
340
|
+
: !hasSubtitles
|
|
341
|
+
? 'This file has no subtitles'
|
|
342
|
+
: burnedInSubtitles
|
|
343
|
+
? 'Subtitles are in the picture'
|
|
344
|
+
: 'Put the subtitles in the picture'}
|
|
345
|
+
</span>
|
|
346
|
+
{burnIn && hasSubtitles
|
|
347
|
+
? (
|
|
348
|
+
<span className='hint'>
|
|
349
|
+
Then hover the video and click your browser's own pop out button
|
|
350
|
+
</span>
|
|
351
|
+
)
|
|
352
|
+
: null}
|
|
280
353
|
</span>
|
|
281
354
|
}
|
|
282
355
|
/>
|
|
@@ -36,3 +36,18 @@ export const CaptionsOff = (props: SVGProps<SVGSVGElement>) => (
|
|
|
36
36
|
<line x1='2' y1='2' x2='22' y2='22' />
|
|
37
37
|
</svg>
|
|
38
38
|
)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subtitles inside the picture: the picture-in-picture frame with caption bars in the inset.
|
|
42
|
+
*
|
|
43
|
+
* A separate glyph on purpose. The control means something different on a browser that cannot open
|
|
44
|
+
* a window, and the same icon doing two things silently is the thing to avoid. It is not the plain
|
|
45
|
+
* captions glyph either, because the button beside it already is one.
|
|
46
|
+
*/
|
|
47
|
+
export const SubtitlesInPicture = (props: SVGProps<SVGSVGElement>) => (
|
|
48
|
+
<svg {...iconProps} {...props}>
|
|
49
|
+
<path d='M21 11V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h5' />
|
|
50
|
+
<rect x='12' y='13' width='10' height='8' rx='1' ry='1' />
|
|
51
|
+
<path d='M14.5 18.5h2m2 0h1' />
|
|
52
|
+
</svg>
|
|
53
|
+
)
|
|
@@ -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
|
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
|
}
|