@banou/media-player 0.8.2 → 0.8.3
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 +360 -375
- package/dist/react/components/chrome.d.ts +1 -1
- package/dist/react/components/overlay.d.ts +2 -6
- package/dist/react/video-player.d.ts +19 -6
- package/package.json +1 -1
- package/src/lib/react/components/chrome.tsx +56 -3
- package/src/lib/react/components/overlay.tsx +22 -67
- package/src/lib/react/video-player.tsx +19 -6
|
@@ -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
|
-
/** The app's own content,
|
|
7
|
+
/** The app's own content, over the video and outside the click-to-pause region, unlike `children`. */
|
|
8
8
|
overlay?: ReactNode;
|
|
9
9
|
children?: ReactNode;
|
|
10
10
|
};
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export type OverlayProps = {
|
|
1
|
+
export declare const Overlay: ({ onCanvasRef }: {
|
|
3
2
|
onCanvasRef: (element: HTMLCanvasElement | null) => void;
|
|
4
|
-
|
|
5
|
-
overlay?: ReactNode;
|
|
6
|
-
};
|
|
7
|
-
export declare const Overlay: ({ onCanvasRef, overlay }: OverlayProps) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
3
|
+
}) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
8
4
|
export default Overlay;
|
|
@@ -17,13 +17,26 @@ type CommonOptions = {
|
|
|
17
17
|
title?: string;
|
|
18
18
|
autoplay?: boolean;
|
|
19
19
|
/**
|
|
20
|
-
* The app's own
|
|
21
|
-
*
|
|
22
|
-
* running counter does not sit over the picture once the controls have hidden themselves.
|
|
20
|
+
* The app's own content over the video: a download readout, a badge, a logo, anything the player
|
|
21
|
+
* itself has no opinion about.
|
|
23
22
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* Pass one node, or several, and EACH TOP-LEVEL ITEM gets its own layer covering the whole player.
|
|
24
|
+
* That layer is the coordinate space, so an item is placed with ordinary CSS against the picture:
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <MediaPlayer overlay={[
|
|
28
|
+
* <div key="stats" css={css`position: absolute; top: 0; right: 0;`}>82 peers</div>,
|
|
29
|
+
* <div key="badge" css={css`position: absolute; inset: auto auto 0 0;`}>4K</div>,
|
|
30
|
+
* ]} />
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* A fragment works the same way. Items never share a containing block, so one item's CSS cannot
|
|
34
|
+
* move another, and each keeps its own DOM as the list changes.
|
|
35
|
+
*
|
|
36
|
+
* Items fade with the rest of the chrome, so a running counter does not sit over the picture once
|
|
37
|
+
* the controls have hidden themselves. A layer takes no pointer events, so a click still reaches
|
|
38
|
+
* the video and toggles playback; content that needs a pointer (a tooltip anchor, a button) sets
|
|
39
|
+
* `pointer-events: auto` on itself. `children` land next to the media instead, below the chrome.
|
|
27
40
|
*/
|
|
28
41
|
overlay?: ReactNode;
|
|
29
42
|
onSeek?: (fraction: number) => void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference types="@emotion/react/types/css-prop" />
|
|
2
2
|
import type { ReactNode, Ref } from 'react'
|
|
3
3
|
|
|
4
|
-
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { Children, Fragment, isValidElement, useEffect, useRef } from 'react'
|
|
5
5
|
import { css } from '@emotion/react'
|
|
6
6
|
|
|
7
7
|
import { usePlayer } from '../player'
|
|
@@ -10,6 +10,30 @@ import ControlBar from './control-bar'
|
|
|
10
10
|
|
|
11
11
|
const AUTO_HIDE_DELAY = 3_000
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* One overlay item's own layer: the whole player box, and nothing else in it.
|
|
15
|
+
*
|
|
16
|
+
* The box is what makes an item positionable at all. Dropped straight into the chrome an item is
|
|
17
|
+
* absolutely positioned with no inset, which resolves to its static position, and the chrome centres
|
|
18
|
+
* its children, so a download readout came out painted across the middle of the picture. With this
|
|
19
|
+
* the app writes ordinary CSS against the picture: `top: 0; right: 0` is the top right corner of the
|
|
20
|
+
* video and of nothing else.
|
|
21
|
+
*
|
|
22
|
+
* One layer per item rather than one for all of them, so an item's own CSS can never move a sibling.
|
|
23
|
+
*/
|
|
24
|
+
const overlayItemStyle = css`
|
|
25
|
+
position: absolute;
|
|
26
|
+
inset: 0;
|
|
27
|
+
z-index: 2;
|
|
28
|
+
/* Never eats a click: click-to-pause still reaches the video underneath, and an item that needs a
|
|
29
|
+
pointer (a tooltip anchor, a button) sets \`pointer-events: auto\` on itself. */
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
/* visibility, not only opacity: an item that took pointer events back would otherwise stay
|
|
32
|
+
hoverable while faded out, popping a tooltip over nothing. It cascades where pointer-events does
|
|
33
|
+
not, and transitioning it holds the item on screen for the length of the fade. */
|
|
34
|
+
transition: opacity 0.1s cubic-bezier(.4,0,1,1), visibility 0.1s;
|
|
35
|
+
`
|
|
36
|
+
|
|
13
37
|
const style = css`
|
|
14
38
|
position: relative;
|
|
15
39
|
background-color: black;
|
|
@@ -44,12 +68,32 @@ const style = css`
|
|
|
44
68
|
}
|
|
45
69
|
`
|
|
46
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The overlay's top-level items, one entry per layer to draw.
|
|
73
|
+
*
|
|
74
|
+
* `Children.toArray` alone is not enough. It flattens an ARRAY and keys what it returns, but a
|
|
75
|
+
* fragment stays one child, and `<>{a}{b}</>` is the shorthand an app reaches for before it reaches
|
|
76
|
+
* for an array. Left unflattened both items land in one layer, where the first item's CSS decides
|
|
77
|
+
* where the second one goes, which is exactly what having a layer each is for.
|
|
78
|
+
*
|
|
79
|
+
* Keys are built from the path rather than taken from each level, because `Children.toArray` numbers
|
|
80
|
+
* from zero inside every call it makes: two fragments each holding one unkeyed item both hand back
|
|
81
|
+
* `.0`, and React would treat the second layer as the first one re-rendered.
|
|
82
|
+
*/
|
|
83
|
+
const overlayItems = (node: ReactNode, prefix = ''): { key: string, item: ReactNode }[] =>
|
|
84
|
+
Children.toArray(node).flatMap((child, index) => {
|
|
85
|
+
const key = `${prefix}${isValidElement(child) && child.key != null ? child.key : index}`
|
|
86
|
+
return isValidElement(child) && child.type === Fragment
|
|
87
|
+
? overlayItems((child.props as { children?: ReactNode }).children, `${key}/`)
|
|
88
|
+
: [{ key, item: child }]
|
|
89
|
+
})
|
|
90
|
+
|
|
47
91
|
export type ChromeProps = {
|
|
48
92
|
ref?: Ref<HTMLDivElement> | ((element: HTMLDivElement | null) => void)
|
|
49
93
|
/** Absent means render no video element: the media belongs to someone else and arrives as children. */
|
|
50
94
|
onVideoRef?: (element: HTMLVideoElement | null) => void
|
|
51
95
|
onCanvasRef: (element: HTMLCanvasElement | null) => void
|
|
52
|
-
/** The app's own content,
|
|
96
|
+
/** The app's own content, over the video and outside the click-to-pause region, unlike `children`. */
|
|
53
97
|
overlay?: ReactNode
|
|
54
98
|
children?: ReactNode
|
|
55
99
|
}
|
|
@@ -116,7 +160,16 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, children }: Chro
|
|
|
116
160
|
onMouseOut={onMouseOut}
|
|
117
161
|
className={hideUI ? 'hide' : ''}
|
|
118
162
|
>
|
|
119
|
-
<Overlay onCanvasRef={onCanvasRef}
|
|
163
|
+
<Overlay onCanvasRef={onCanvasRef} />
|
|
164
|
+
{overlayItems(overlay).map(({ key, item }) => (
|
|
165
|
+
<div
|
|
166
|
+
key={key}
|
|
167
|
+
css={overlayItemStyle}
|
|
168
|
+
style={{ ...hideUI ? { opacity: '0', visibility: 'hidden', pointerEvents: 'none' } : {} }}
|
|
169
|
+
>
|
|
170
|
+
{item}
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
120
173
|
<ControlBar />
|
|
121
174
|
<div className="video" onClick={onVideoClick}>
|
|
122
175
|
{onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
|
|
@@ -15,74 +15,35 @@ const style = css`
|
|
|
15
15
|
pointer-events: none;
|
|
16
16
|
`
|
|
17
17
|
|
|
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`
|
|
18
|
+
const titleStyle = css`
|
|
26
19
|
position: absolute;
|
|
27
20
|
top: 0;
|
|
28
21
|
left: 0;
|
|
29
22
|
width: 100%;
|
|
23
|
+
padding: calc(1.2 * var(--mp-unit)) calc(1.6 * var(--mp-unit));
|
|
24
|
+
/* clears a notch once the player is fullscreen; resolves to zero everywhere else */
|
|
25
|
+
padding-top: calc(calc(1.2 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
26
|
+
${fonts.headings.small}
|
|
27
|
+
color: white;
|
|
28
|
+
text-shadow: 0 0 4px rgba(0, 0, 0, 1);
|
|
30
29
|
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 */
|
|
33
30
|
pointer-events: none;
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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));
|
|
32
|
+
/* one line with an ellipsis until there is width to wrap, since a release filename would
|
|
33
|
+
otherwise run to four lines on a phone */
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
text-overflow: ellipsis;
|
|
47
37
|
|
|
48
38
|
@media (min-width: 768px) {
|
|
39
|
+
padding: calc(2.4 * var(--mp-unit));
|
|
49
40
|
padding-top: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-top, 0px));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
padding-right: calc(calc(2.4 * var(--mp-unit)) + env(safe-area-inset-right, 0px));
|
|
41
|
+
white-space: normal;
|
|
42
|
+
overflow: visible;
|
|
53
43
|
}
|
|
54
44
|
|
|
55
45
|
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%);
|
|
56
|
-
|
|
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
|
-
}
|
|
46
|
+
transition: opacity 0.1s cubic-bezier(.4,0,1,1);
|
|
86
47
|
`
|
|
87
48
|
|
|
88
49
|
const spin = keyframes`
|
|
@@ -127,13 +88,7 @@ const errorMessage = (error: unknown) =>
|
|
|
127
88
|
? error.message
|
|
128
89
|
: typeof error === 'string' ? error : 'Playback failed'
|
|
129
90
|
|
|
130
|
-
export
|
|
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) => {
|
|
91
|
+
export const Overlay = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElement | null) => void }) => {
|
|
137
92
|
const title = usePlayer((state) => state.title)
|
|
138
93
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
139
94
|
const playbackError = usePlayer((state) => state.playbackError)
|
|
@@ -144,14 +99,14 @@ export const Overlay = ({ onCanvasRef, overlay }: OverlayProps) => {
|
|
|
144
99
|
|
|
145
100
|
return (
|
|
146
101
|
<>
|
|
147
|
-
{title
|
|
102
|
+
{title
|
|
148
103
|
? (
|
|
149
104
|
<div
|
|
150
|
-
|
|
151
|
-
|
|
105
|
+
className="title"
|
|
106
|
+
css={titleStyle}
|
|
107
|
+
style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}
|
|
152
108
|
>
|
|
153
|
-
{title
|
|
154
|
-
{overlay ? <div className="app-slot">{overlay}</div> : undefined}
|
|
109
|
+
{title}
|
|
155
110
|
</div>
|
|
156
111
|
)
|
|
157
112
|
: undefined}
|
|
@@ -27,13 +27,26 @@ type CommonOptions = {
|
|
|
27
27
|
autoplay?: boolean
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* The app's own
|
|
31
|
-
*
|
|
32
|
-
* running counter does not sit over the picture once the controls have hidden themselves.
|
|
30
|
+
* The app's own content over the video: a download readout, a badge, a logo, anything the player
|
|
31
|
+
* itself has no opinion about.
|
|
33
32
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Pass one node, or several, and EACH TOP-LEVEL ITEM gets its own layer covering the whole player.
|
|
34
|
+
* That layer is the coordinate space, so an item is placed with ordinary CSS against the picture:
|
|
35
|
+
*
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <MediaPlayer overlay={[
|
|
38
|
+
* <div key="stats" css={css`position: absolute; top: 0; right: 0;`}>82 peers</div>,
|
|
39
|
+
* <div key="badge" css={css`position: absolute; inset: auto auto 0 0;`}>4K</div>,
|
|
40
|
+
* ]} />
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* A fragment works the same way. Items never share a containing block, so one item's CSS cannot
|
|
44
|
+
* move another, and each keeps its own DOM as the list changes.
|
|
45
|
+
*
|
|
46
|
+
* Items fade with the rest of the chrome, so a running counter does not sit over the picture once
|
|
47
|
+
* the controls have hidden themselves. A layer takes no pointer events, so a click still reaches
|
|
48
|
+
* the video and toggles playback; content that needs a pointer (a tooltip anchor, a button) sets
|
|
49
|
+
* `pointer-events: auto` on itself. `children` land next to the media instead, below the chrome.
|
|
37
50
|
*/
|
|
38
51
|
overlay?: ReactNode
|
|
39
52
|
|