@banou/media-player 0.8.1 → 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.
@@ -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
- /** Above the control bar and outside the click-to-pause region, unlike `children`. */
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
  };
@@ -17,8 +17,26 @@ type CommonOptions = {
17
17
  title?: string;
18
18
  autoplay?: boolean;
19
19
  /**
20
- * Drawn above the control bar and outside the click-to-pause region, for whatever the app has to
21
- * say over the video. `children` land next to the media instead, below the chrome.
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.
22
+ *
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.
22
40
  */
23
41
  overlay?: ReactNode;
24
42
  onSeek?: (fraction: number) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
- /** Above the control bar and outside the click-to-pause region, unlike `children`. */
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
  }
@@ -117,7 +161,15 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, children }: Chro
117
161
  className={hideUI ? 'hide' : ''}
118
162
  >
119
163
  <Overlay onCanvasRef={onCanvasRef} />
120
- {overlay}
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
+ ))}
121
173
  <ControlBar />
122
174
  <div className="video" onClick={onVideoClick}>
123
175
  {onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
@@ -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'
@@ -100,6 +102,7 @@ export const Overlay = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElem
100
102
  {title
101
103
  ? (
102
104
  <div
105
+ className="title"
103
106
  css={titleStyle}
104
107
  style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}
105
108
  >
@@ -27,8 +27,26 @@ type CommonOptions = {
27
27
  autoplay?: boolean
28
28
 
29
29
  /**
30
- * Drawn above the control bar and outside the click-to-pause region, for whatever the app has to
31
- * say over the video. `children` land next to the media instead, below the chrome.
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.
32
+ *
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.
32
50
  */
33
51
  overlay?: ReactNode
34
52
 
@@ -204,6 +222,19 @@ const rootStyle = css`
204
222
  */
205
223
  --mp-unit: 10px;
206
224
 
225
+ /**
226
+ * The other half of owning the chrome's own scale.
227
+ *
228
+ * Every control here is authored against a border box, which this library's dev app sets globally
229
+ * and a host page has no reason to. Shipped without it, anything that is \`width: 100%\` AND carries
230
+ * padding overflows its own box: the top bar ran 32px past the player and clipped the right end of
231
+ * whatever the app had put there. Scoped to the player's subtree so it cannot reach the host's
232
+ * layout, and only \`box-sizing\`, because zeroing margins here would reach the app's own content.
233
+ */
234
+ &, & *, & *::before, & *::after {
235
+ box-sizing: border-box;
236
+ }
237
+
207
238
  display: flex;
208
239
  justify-content: center;
209
240
  background-color: #111;