@banou/media-player 0.8.0 → 0.8.2
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 +86 -30
- package/dist/react/components/chrome.d.ts +1 -1
- package/dist/react/components/overlay.d.ts +6 -2
- package/dist/react/hooks/use-picture-in-picture.d.ts +1 -1
- package/dist/react/player.d.ts +1 -1
- package/dist/react/source-feature.d.ts +20 -4
- package/dist/react/video-player.d.ts +7 -2
- package/package.json +1 -1
- package/src/lib/react/components/chrome.tsx +2 -3
- package/src/lib/react/components/control-bar.tsx +21 -17
- package/src/lib/react/components/overlay.tsx +69 -21
- package/src/lib/react/hooks/use-picture-in-picture.ts +5 -1
- package/src/lib/react/source-feature.ts +11 -3
- package/src/lib/react/video-player.tsx +20 -2
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ var ee = {
|
|
|
18
18
|
selectAudioTrack: () => {},
|
|
19
19
|
hideUI: !1,
|
|
20
20
|
setHideUI: () => {},
|
|
21
|
-
togglePictureInPicture:
|
|
21
|
+
togglePictureInPicture: null,
|
|
22
22
|
playbackError: null,
|
|
23
23
|
ready: !1,
|
|
24
24
|
setSourceState: () => {}
|
|
@@ -191,7 +191,7 @@ var ee = {
|
|
|
191
191
|
}, [m]), u;
|
|
192
192
|
}, B = (e, t) => {
|
|
193
193
|
let n = P(), r = c(null);
|
|
194
|
-
|
|
194
|
+
o(() => {
|
|
195
195
|
if (!e || !t) return;
|
|
196
196
|
let a = i({
|
|
197
197
|
video: e,
|
|
@@ -205,11 +205,13 @@ var ee = {
|
|
|
205
205
|
e,
|
|
206
206
|
t,
|
|
207
207
|
n
|
|
208
|
-
])
|
|
208
|
+
]);
|
|
209
|
+
let s = a(() => {
|
|
209
210
|
r.current?.toggle().catch((e) => {
|
|
210
211
|
console.warn("picture in picture was refused", e);
|
|
211
212
|
});
|
|
212
213
|
}, []);
|
|
214
|
+
return e && t ? s : null;
|
|
213
215
|
}, V = {
|
|
214
216
|
headings: {
|
|
215
217
|
large: "\n font-weight: 600;\n font-size: calc(2.8 * var(--mp-unit));\n line-height: calc(3.4 * var(--mp-unit));\n @media (min-width: 960px) {\n font-size: calc(3.4 * var(--mp-unit));\n line-height: calc(4.1 * var(--mp-unit));\n }\n ",
|
|
@@ -249,30 +251,62 @@ var ee = {
|
|
|
249
251
|
top: 0;
|
|
250
252
|
left: 0;
|
|
251
253
|
width: 100%;
|
|
252
|
-
padding: calc(1.2 * var(--mp-unit)) calc(1.6 * var(--mp-unit));
|
|
253
|
-
/* clears a notch once the player is fullscreen; resolves to zero everywhere else */
|
|
254
|
-
padding-top: calc(calc(1.2 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
255
|
-
${V.headings.small}
|
|
256
|
-
color: white;
|
|
257
|
-
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
258
254
|
z-index: 2;
|
|
255
|
+
/* the video underneath keeps the click that toggles playback; anything in the slot that needs a
|
|
256
|
+
pointer takes it back for itself */
|
|
259
257
|
pointer-events: none;
|
|
260
258
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: flex-start;
|
|
261
|
+
gap: calc(1.6 * var(--mp-unit));
|
|
262
|
+
|
|
263
|
+
/* longhands per breakpoint, never the shorthand: a nested at-rule is hoisted after the rule it
|
|
264
|
+
sits in, so a shorthand in a media query would override the longhands above it and silently
|
|
265
|
+
drop the safe-area insets. Those clear a notch once the player is fullscreen and resolve to
|
|
266
|
+
zero everywhere else. */
|
|
267
|
+
padding-top: calc(calc(1.2 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
268
|
+
padding-bottom: calc(1.2 * var(--mp-unit));
|
|
269
|
+
padding-left: calc(calc(1.6 * var(--mp-unit)) + env(safe-area-inset-left, 0px));
|
|
270
|
+
padding-right: calc(calc(1.6 * var(--mp-unit)) + env(safe-area-inset-right, 0px));
|
|
266
271
|
|
|
267
272
|
@media (min-width: 768px) {
|
|
268
|
-
padding: calc(2.4 * var(--mp-unit));
|
|
269
273
|
padding-top: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
270
|
-
|
|
271
|
-
|
|
274
|
+
padding-bottom: calc(2.4 * var(--mp-unit));
|
|
275
|
+
padding-left: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-left, 0px));
|
|
276
|
+
padding-right: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-right, 0px));
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.3) 30%, rgba(0,0,0,0.2) 60%, rgba(0,0,0,0.1) 80%, transparent 100%);
|
|
275
|
-
|
|
280
|
+
/* visibility, not only opacity: a slot child that took pointer events back would otherwise stay
|
|
281
|
+
hoverable while faded out, popping a tooltip over nothing. It cascades where pointer-events
|
|
282
|
+
does not, and transitioning it holds the element visible for the length of the fade. */
|
|
283
|
+
transition: opacity 0.1s cubic-bezier(.4,0,1,1), visibility 0.1s;
|
|
284
|
+
|
|
285
|
+
.title {
|
|
286
|
+
${V.headings.small}
|
|
287
|
+
color: white;
|
|
288
|
+
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
289
|
+
|
|
290
|
+
/* one line with an ellipsis until there is width to wrap, since a release filename would
|
|
291
|
+
otherwise run to four lines on a phone */
|
|
292
|
+
flex: 1;
|
|
293
|
+
min-width: 0;
|
|
294
|
+
white-space: nowrap;
|
|
295
|
+
overflow: hidden;
|
|
296
|
+
text-overflow: ellipsis;
|
|
297
|
+
|
|
298
|
+
@media (min-width: 768px) {
|
|
299
|
+
white-space: normal;
|
|
300
|
+
overflow: visible;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* pinned right with or without a title, so an app's readout does not jump sideways when a
|
|
305
|
+
filename arrives late */
|
|
306
|
+
.app-slot {
|
|
307
|
+
margin-left: auto;
|
|
308
|
+
flex: none;
|
|
309
|
+
}
|
|
276
310
|
`, oe = u`
|
|
277
311
|
position: absolute;
|
|
278
312
|
inset: 0;
|
|
@@ -303,21 +337,28 @@ var ee = {
|
|
|
303
337
|
${V.bLarge.regular}
|
|
304
338
|
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
305
339
|
pointer-events: none;
|
|
306
|
-
`, ce = (e) => e instanceof Error ? e.message : typeof e == "string" ? e : "Playback failed", le = ({ onCanvasRef: e }) => {
|
|
307
|
-
let
|
|
340
|
+
`, ce = (e) => e instanceof Error ? e.message : typeof e == "string" ? e : "Playback failed", le = ({ onCanvasRef: e, overlay: t }) => {
|
|
341
|
+
let n = P((e) => e.title), r = P((e) => e.hideUI), i = P((e) => e.playbackError), a = P((e) => e.ready), o = P((e) => e.size), s = P((e) => e.waiting);
|
|
308
342
|
return /* @__PURE__ */ y(_, { children: [
|
|
309
|
-
t ? /* @__PURE__ */
|
|
343
|
+
n || t ? /* @__PURE__ */ y("div", {
|
|
310
344
|
css: ae,
|
|
311
|
-
style: { ...
|
|
345
|
+
style: { ...r ? {
|
|
312
346
|
opacity: "0",
|
|
347
|
+
visibility: "hidden",
|
|
313
348
|
pointerEvents: "none"
|
|
314
349
|
} : {} },
|
|
315
|
-
children:
|
|
350
|
+
children: [n ? /* @__PURE__ */ v("div", {
|
|
351
|
+
className: "title",
|
|
352
|
+
children: n
|
|
353
|
+
}) : void 0, t ? /* @__PURE__ */ v("div", {
|
|
354
|
+
className: "app-slot",
|
|
355
|
+
children: t
|
|
356
|
+
}) : void 0]
|
|
316
357
|
}) : void 0,
|
|
317
|
-
(
|
|
318
|
-
|
|
358
|
+
(o ? !a : s) && !i ? /* @__PURE__ */ v("div", { css: oe }) : void 0,
|
|
359
|
+
i ? /* @__PURE__ */ v("div", {
|
|
319
360
|
css: se,
|
|
320
|
-
children: ce(
|
|
361
|
+
children: ce(i)
|
|
321
362
|
}) : void 0,
|
|
322
363
|
/* @__PURE__ */ v("canvas", {
|
|
323
364
|
ref: e,
|
|
@@ -1384,7 +1425,7 @@ position: relative;
|
|
|
1384
1425
|
className: "right",
|
|
1385
1426
|
children: [
|
|
1386
1427
|
/* @__PURE__ */ v(ve, {}),
|
|
1387
|
-
/* @__PURE__ */ v(q, {
|
|
1428
|
+
c ? /* @__PURE__ */ v(q, {
|
|
1388
1429
|
id: "picture-in-picture",
|
|
1389
1430
|
text: /* @__PURE__ */ v("button", {
|
|
1390
1431
|
className: "picture-in-picture",
|
|
@@ -1393,7 +1434,7 @@ position: relative;
|
|
|
1393
1434
|
children: /* @__PURE__ */ v("img", { src: me })
|
|
1394
1435
|
}),
|
|
1395
1436
|
toolTipText: /* @__PURE__ */ v("span", { children: "Picture in picture" })
|
|
1396
|
-
}),
|
|
1437
|
+
}) : null,
|
|
1397
1438
|
/* @__PURE__ */ v(q, {
|
|
1398
1439
|
id: "full-screen",
|
|
1399
1440
|
tooltipPlace: "top-end",
|
|
@@ -1464,8 +1505,10 @@ position: relative;
|
|
|
1464
1505
|
},
|
|
1465
1506
|
className: s ? "hide" : "",
|
|
1466
1507
|
children: [
|
|
1467
|
-
/* @__PURE__ */ v(le, {
|
|
1468
|
-
|
|
1508
|
+
/* @__PURE__ */ v(le, {
|
|
1509
|
+
onCanvasRef: n,
|
|
1510
|
+
overlay: r
|
|
1511
|
+
}),
|
|
1469
1512
|
/* @__PURE__ */ v(Te, {}),
|
|
1470
1513
|
/* @__PURE__ */ y("div", {
|
|
1471
1514
|
className: "video",
|
|
@@ -1556,6 +1599,19 @@ position: relative;
|
|
|
1556
1599
|
*/
|
|
1557
1600
|
--mp-unit: 10px;
|
|
1558
1601
|
|
|
1602
|
+
/**
|
|
1603
|
+
* The other half of owning the chrome's own scale.
|
|
1604
|
+
*
|
|
1605
|
+
* Every control here is authored against a border box, which this library's dev app sets globally
|
|
1606
|
+
* and a host page has no reason to. Shipped without it, anything that is \`width: 100%\` AND carries
|
|
1607
|
+
* padding overflows its own box: the top bar ran 32px past the player and clipped the right end of
|
|
1608
|
+
* whatever the app had put there. Scoped to the player's subtree so it cannot reach the host's
|
|
1609
|
+
* layout, and only \`box-sizing\`, because zeroing margins here would reach the app's own content.
|
|
1610
|
+
*/
|
|
1611
|
+
&, & *, & *::before, & *::after {
|
|
1612
|
+
box-sizing: border-box;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1559
1615
|
display: flex;
|
|
1560
1616
|
justify-content: center;
|
|
1561
1617
|
background-color: #111;
|
|
@@ -4,7 +4,7 @@ export type ChromeProps = {
|
|
|
4
4
|
/** Absent means render no video element: the media belongs to someone else and arrives as children. */
|
|
5
5
|
onVideoRef?: (element: HTMLVideoElement | null) => void;
|
|
6
6
|
onCanvasRef: (element: HTMLCanvasElement | null) => void;
|
|
7
|
-
/**
|
|
7
|
+
/** The app's own content, drawn in the top bar beside the title, unlike `children`. */
|
|
8
8
|
overlay?: ReactNode;
|
|
9
9
|
children?: ReactNode;
|
|
10
10
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export type OverlayProps = {
|
|
2
3
|
onCanvasRef: (element: HTMLCanvasElement | null) => void;
|
|
3
|
-
|
|
4
|
+
/** The app's own content, drawn at the right of the top bar next to the title. */
|
|
5
|
+
overlay?: ReactNode;
|
|
6
|
+
};
|
|
7
|
+
export declare const Overlay: ({ onCanvasRef, overlay }: OverlayProps) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
4
8
|
export default Overlay;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Picture in picture with the subtitles composited in. */
|
|
2
|
-
export declare const usePictureInPicture: (video: HTMLVideoElement | null, canvas: HTMLCanvasElement | null) => () => void;
|
|
2
|
+
export declare const usePictureInPicture: (video: HTMLVideoElement | null, canvas: HTMLCanvasElement | null) => (() => void) | null;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
13
13
|
selectAudioTrack: (id: string | number) => void;
|
|
14
14
|
hideUI: boolean;
|
|
15
15
|
setHideUI: (hide: boolean) => void;
|
|
16
|
-
togglePictureInPicture: () => void;
|
|
16
|
+
togglePictureInPicture: (() => void) | null;
|
|
17
17
|
playbackError: unknown;
|
|
18
18
|
ready: boolean;
|
|
19
19
|
setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
|
|
@@ -50,8 +50,16 @@ export type SourceState = {
|
|
|
50
50
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
51
51
|
hideUI: boolean;
|
|
52
52
|
setHideUI: (hide: boolean) => void;
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
|
|
55
|
+
*
|
|
56
|
+
* null means the control is not offered at all. Locally that is "no element yet". For a media the
|
|
57
|
+
* player does not own it means the source cannot do it, and the difference matters: the compositing
|
|
58
|
+
* this does needs a local element to draw, and even a source that forwards the request cannot
|
|
59
|
+
* report the resulting STATE back, since `document.pictureInPictureElement` is never a proxy. A
|
|
60
|
+
* button that toggles nothing and never lights up is worse than no button.
|
|
61
|
+
*/
|
|
62
|
+
togglePictureInPicture: (() => void) | null;
|
|
55
63
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
56
64
|
playbackError: unknown;
|
|
57
65
|
/** Whether the engine has produced its first media segment. */
|
|
@@ -89,8 +97,16 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
89
97
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
90
98
|
hideUI: boolean;
|
|
91
99
|
setHideUI: (hide: boolean) => void;
|
|
92
|
-
/**
|
|
93
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
|
|
102
|
+
*
|
|
103
|
+
* null means the control is not offered at all. Locally that is "no element yet". For a media the
|
|
104
|
+
* player does not own it means the source cannot do it, and the difference matters: the compositing
|
|
105
|
+
* this does needs a local element to draw, and even a source that forwards the request cannot
|
|
106
|
+
* report the resulting STATE back, since `document.pictureInPictureElement` is never a proxy. A
|
|
107
|
+
* button that toggles nothing and never lights up is worse than no button.
|
|
108
|
+
*/
|
|
109
|
+
togglePictureInPicture: (() => void) | null;
|
|
94
110
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
95
111
|
playbackError: unknown;
|
|
96
112
|
/** Whether the engine has produced its first media segment. */
|
|
@@ -17,8 +17,13 @@ type CommonOptions = {
|
|
|
17
17
|
title?: string;
|
|
18
18
|
autoplay?: boolean;
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
* say over the video.
|
|
20
|
+
* The app's own readout, drawn at the right of the top bar beside `title`, for whatever it has to
|
|
21
|
+
* say over the video. It shares the title's gradient and fades with the rest of the chrome, so a
|
|
22
|
+
* running counter does not sit over the picture once the controls have hidden themselves.
|
|
23
|
+
*
|
|
24
|
+
* The slot itself takes no pointer events, so a click still reaches the video and toggles
|
|
25
|
+
* playback; content that needs a pointer (a tooltip anchor, a button) sets `pointer-events: auto`
|
|
26
|
+
* on itself. `children` land next to the media instead, below the chrome.
|
|
22
27
|
*/
|
|
23
28
|
overlay?: ReactNode;
|
|
24
29
|
onSeek?: (fraction: number) => void;
|
package/package.json
CHANGED
|
@@ -49,7 +49,7 @@ export type ChromeProps = {
|
|
|
49
49
|
/** Absent means render no video element: the media belongs to someone else and arrives as children. */
|
|
50
50
|
onVideoRef?: (element: HTMLVideoElement | null) => void
|
|
51
51
|
onCanvasRef: (element: HTMLCanvasElement | null) => void
|
|
52
|
-
/**
|
|
52
|
+
/** The app's own content, drawn in the top bar beside the title, unlike `children`. */
|
|
53
53
|
overlay?: ReactNode
|
|
54
54
|
children?: ReactNode
|
|
55
55
|
}
|
|
@@ -116,8 +116,7 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, children }: Chro
|
|
|
116
116
|
onMouseOut={onMouseOut}
|
|
117
117
|
className={hideUI ? 'hide' : ''}
|
|
118
118
|
>
|
|
119
|
-
<Overlay onCanvasRef={onCanvasRef} />
|
|
120
|
-
{overlay}
|
|
119
|
+
<Overlay onCanvasRef={onCanvasRef} overlay={overlay} />
|
|
121
120
|
<ControlBar />
|
|
122
121
|
<div className="video" onClick={onVideoClick}>
|
|
123
122
|
{onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
|
|
@@ -259,23 +259,27 @@ export const ControlBar = () => {
|
|
|
259
259
|
</div>
|
|
260
260
|
<div className='right'>
|
|
261
261
|
<SettingsAction />
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
262
|
+
{togglePictureInPicture
|
|
263
|
+
? (
|
|
264
|
+
<TooltipDisplay
|
|
265
|
+
id='picture-in-picture'
|
|
266
|
+
text={
|
|
267
|
+
<button
|
|
268
|
+
className='picture-in-picture'
|
|
269
|
+
type='button'
|
|
270
|
+
onClick={togglePictureInPicture}
|
|
271
|
+
>
|
|
272
|
+
<img src={pictureInPicture} />
|
|
273
|
+
</button>
|
|
274
|
+
}
|
|
275
|
+
toolTipText={
|
|
276
|
+
<span>
|
|
277
|
+
Picture in picture
|
|
278
|
+
</span>
|
|
279
|
+
}
|
|
280
|
+
/>
|
|
281
|
+
)
|
|
282
|
+
: null}
|
|
279
283
|
<TooltipDisplay
|
|
280
284
|
id='full-screen'
|
|
281
285
|
tooltipPlace='top-end'
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
2
4
|
import { css, keyframes } from '@emotion/react'
|
|
3
5
|
|
|
4
6
|
import { usePlayer } from '../player'
|
|
@@ -13,35 +15,74 @@ const style = css`
|
|
|
13
15
|
pointer-events: none;
|
|
14
16
|
`
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Title on the left, the app's own readout on the right, one gradient behind both.
|
|
20
|
+
*
|
|
21
|
+
* They share a row rather than sitting in two layers because they are the same band of screen: a
|
|
22
|
+
* filename wide enough to wrap would otherwise run under whatever the app put on the right, and two
|
|
23
|
+
* stacked gradients would double the darkening.
|
|
24
|
+
*/
|
|
25
|
+
const topBarStyle = css`
|
|
17
26
|
position: absolute;
|
|
18
27
|
top: 0;
|
|
19
28
|
left: 0;
|
|
20
29
|
width: 100%;
|
|
21
|
-
padding: calc(1.2 * var(--mp-unit)) calc(1.6 * var(--mp-unit));
|
|
22
|
-
/* clears a notch once the player is fullscreen; resolves to zero everywhere else */
|
|
23
|
-
padding-top: calc(calc(1.2 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
24
|
-
${fonts.headings.small}
|
|
25
|
-
color: white;
|
|
26
|
-
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
27
30
|
z-index: 2;
|
|
31
|
+
/* the video underneath keeps the click that toggles playback; anything in the slot that needs a
|
|
32
|
+
pointer takes it back for itself */
|
|
28
33
|
pointer-events: none;
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: flex-start;
|
|
37
|
+
gap: calc(1.6 * var(--mp-unit));
|
|
38
|
+
|
|
39
|
+
/* longhands per breakpoint, never the shorthand: a nested at-rule is hoisted after the rule it
|
|
40
|
+
sits in, so a shorthand in a media query would override the longhands above it and silently
|
|
41
|
+
drop the safe-area insets. Those clear a notch once the player is fullscreen and resolve to
|
|
42
|
+
zero everywhere else. */
|
|
43
|
+
padding-top: calc(calc(1.2 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
44
|
+
padding-bottom: calc(1.2 * var(--mp-unit));
|
|
45
|
+
padding-left: calc(calc(1.6 * var(--mp-unit)) + env(safe-area-inset-left, 0px));
|
|
46
|
+
padding-right: calc(calc(1.6 * var(--mp-unit)) + env(safe-area-inset-right, 0px));
|
|
35
47
|
|
|
36
48
|
@media (min-width: 768px) {
|
|
37
|
-
padding: calc(2.4 * var(--mp-unit));
|
|
38
49
|
padding-top: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
padding-bottom: calc(2.4 * var(--mp-unit));
|
|
51
|
+
padding-left: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-left, 0px));
|
|
52
|
+
padding-right: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-right, 0px));
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.3) 30%, rgba(0,0,0,0.2) 60%, rgba(0,0,0,0.1) 80%, transparent 100%);
|
|
44
|
-
|
|
56
|
+
/* visibility, not only opacity: a slot child that took pointer events back would otherwise stay
|
|
57
|
+
hoverable while faded out, popping a tooltip over nothing. It cascades where pointer-events
|
|
58
|
+
does not, and transitioning it holds the element visible for the length of the fade. */
|
|
59
|
+
transition: opacity 0.1s cubic-bezier(.4,0,1,1), visibility 0.1s;
|
|
60
|
+
|
|
61
|
+
.title {
|
|
62
|
+
${fonts.headings.small}
|
|
63
|
+
color: white;
|
|
64
|
+
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
65
|
+
|
|
66
|
+
/* one line with an ellipsis until there is width to wrap, since a release filename would
|
|
67
|
+
otherwise run to four lines on a phone */
|
|
68
|
+
flex: 1;
|
|
69
|
+
min-width: 0;
|
|
70
|
+
white-space: nowrap;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
text-overflow: ellipsis;
|
|
73
|
+
|
|
74
|
+
@media (min-width: 768px) {
|
|
75
|
+
white-space: normal;
|
|
76
|
+
overflow: visible;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* pinned right with or without a title, so an app's readout does not jump sideways when a
|
|
81
|
+
filename arrives late */
|
|
82
|
+
.app-slot {
|
|
83
|
+
margin-left: auto;
|
|
84
|
+
flex: none;
|
|
85
|
+
}
|
|
45
86
|
`
|
|
46
87
|
|
|
47
88
|
const spin = keyframes`
|
|
@@ -86,7 +127,13 @@ const errorMessage = (error: unknown) =>
|
|
|
86
127
|
? error.message
|
|
87
128
|
: typeof error === 'string' ? error : 'Playback failed'
|
|
88
129
|
|
|
89
|
-
export
|
|
130
|
+
export type OverlayProps = {
|
|
131
|
+
onCanvasRef: (element: HTMLCanvasElement | null) => void
|
|
132
|
+
/** The app's own content, drawn at the right of the top bar next to the title. */
|
|
133
|
+
overlay?: ReactNode
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const Overlay = ({ onCanvasRef, overlay }: OverlayProps) => {
|
|
90
137
|
const title = usePlayer((state) => state.title)
|
|
91
138
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
92
139
|
const playbackError = usePlayer((state) => state.playbackError)
|
|
@@ -97,13 +144,14 @@ export const Overlay = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElem
|
|
|
97
144
|
|
|
98
145
|
return (
|
|
99
146
|
<>
|
|
100
|
-
{title
|
|
147
|
+
{title || overlay
|
|
101
148
|
? (
|
|
102
149
|
<div
|
|
103
|
-
css={
|
|
104
|
-
style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}
|
|
150
|
+
css={topBarStyle}
|
|
151
|
+
style={{ ...hideUI ? { opacity: '0', visibility: 'hidden', pointerEvents: 'none' } : {} }}
|
|
105
152
|
>
|
|
106
|
-
{title}
|
|
153
|
+
{title ? <div className="title">{title}</div> : undefined}
|
|
154
|
+
{overlay ? <div className="app-slot">{overlay}</div> : undefined}
|
|
107
155
|
</div>
|
|
108
156
|
)
|
|
109
157
|
: undefined}
|
|
@@ -28,9 +28,13 @@ export const usePictureInPicture = (
|
|
|
28
28
|
}
|
|
29
29
|
}, [video, canvas, player])
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
const toggle = useCallback(() => {
|
|
32
32
|
void controller.current?.toggle().catch((error) => {
|
|
33
33
|
console.warn('picture in picture was refused', error)
|
|
34
34
|
})
|
|
35
35
|
}, [])
|
|
36
|
+
|
|
37
|
+
// null rather than a dead callback: the chrome hides the control instead of offering one that
|
|
38
|
+
// cannot work, and there is nothing to composite without both an element and a canvas.
|
|
39
|
+
return video && canvas ? toggle : null
|
|
36
40
|
}
|
|
@@ -60,8 +60,16 @@ export type SourceState = {
|
|
|
60
60
|
hideUI: boolean
|
|
61
61
|
setHideUI: (hide: boolean) => void
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
|
|
65
|
+
*
|
|
66
|
+
* null means the control is not offered at all. Locally that is "no element yet". For a media the
|
|
67
|
+
* player does not own it means the source cannot do it, and the difference matters: the compositing
|
|
68
|
+
* this does needs a local element to draw, and even a source that forwards the request cannot
|
|
69
|
+
* report the resulting STATE back, since `document.pictureInPictureElement` is never a proxy. A
|
|
70
|
+
* button that toggles nothing and never lights up is worse than no button.
|
|
71
|
+
*/
|
|
72
|
+
togglePictureInPicture: (() => void) | null
|
|
65
73
|
|
|
66
74
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
67
75
|
playbackError: unknown
|
|
@@ -89,7 +97,7 @@ const initialState: SourceState = {
|
|
|
89
97
|
selectAudioTrack: () => {},
|
|
90
98
|
hideUI: false,
|
|
91
99
|
setHideUI: () => {},
|
|
92
|
-
togglePictureInPicture:
|
|
100
|
+
togglePictureInPicture: null,
|
|
93
101
|
playbackError: null,
|
|
94
102
|
ready: false,
|
|
95
103
|
setSourceState: () => {},
|
|
@@ -27,8 +27,13 @@ type CommonOptions = {
|
|
|
27
27
|
autoplay?: boolean
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
* say over the video.
|
|
30
|
+
* The app's own readout, drawn at the right of the top bar beside `title`, for whatever it has to
|
|
31
|
+
* say over the video. It shares the title's gradient and fades with the rest of the chrome, so a
|
|
32
|
+
* running counter does not sit over the picture once the controls have hidden themselves.
|
|
33
|
+
*
|
|
34
|
+
* The slot itself takes no pointer events, so a click still reaches the video and toggles
|
|
35
|
+
* playback; content that needs a pointer (a tooltip anchor, a button) sets `pointer-events: auto`
|
|
36
|
+
* on itself. `children` land next to the media instead, below the chrome.
|
|
32
37
|
*/
|
|
33
38
|
overlay?: ReactNode
|
|
34
39
|
|
|
@@ -204,6 +209,19 @@ const rootStyle = css`
|
|
|
204
209
|
*/
|
|
205
210
|
--mp-unit: 10px;
|
|
206
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The other half of owning the chrome's own scale.
|
|
214
|
+
*
|
|
215
|
+
* Every control here is authored against a border box, which this library's dev app sets globally
|
|
216
|
+
* and a host page has no reason to. Shipped without it, anything that is \`width: 100%\` AND carries
|
|
217
|
+
* padding overflows its own box: the top bar ran 32px past the player and clipped the right end of
|
|
218
|
+
* whatever the app had put there. Scoped to the player's subtree so it cannot reach the host's
|
|
219
|
+
* layout, and only \`box-sizing\`, because zeroing margins here would reach the app's own content.
|
|
220
|
+
*/
|
|
221
|
+
&, & *, & *::before, & *::after {
|
|
222
|
+
box-sizing: border-box;
|
|
223
|
+
}
|
|
224
|
+
|
|
207
225
|
display: flex;
|
|
208
226
|
justify-content: center;
|
|
209
227
|
background-color: #111;
|