@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 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
- return o(() => {
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
- ]), a(() => {
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
- /* one line with an ellipsis until there is width to wrap, since a release filename would
262
- otherwise run to four lines on a phone */
263
- white-space: nowrap;
264
- overflow: hidden;
265
- text-overflow: ellipsis;
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
- white-space: normal;
271
- overflow: visible;
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
- transition: opacity 0.1s cubic-bezier(.4,0,1,1);
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 t = P((e) => e.title), n = P((e) => e.hideUI), r = P((e) => e.playbackError), i = P((e) => e.ready), a = P((e) => e.size), o = P((e) => e.waiting);
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__ */ v("div", {
343
+ n || t ? /* @__PURE__ */ y("div", {
310
344
  css: ae,
311
- style: { ...n ? {
345
+ style: { ...r ? {
312
346
  opacity: "0",
347
+ visibility: "hidden",
313
348
  pointerEvents: "none"
314
349
  } : {} },
315
- children: t
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
- (a ? !i : o) && !r ? /* @__PURE__ */ v("div", { css: oe }) : void 0,
318
- r ? /* @__PURE__ */ v("div", {
358
+ (o ? !a : s) && !i ? /* @__PURE__ */ v("div", { css: oe }) : void 0,
359
+ i ? /* @__PURE__ */ v("div", {
319
360
  css: se,
320
- children: ce(r)
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, { onCanvasRef: n }),
1468
- r,
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
- /** Above the control bar and outside the click-to-pause region, unlike `children`. */
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
- export declare const Overlay: ({ onCanvasRef }: {
1
+ import type { ReactNode } from 'react';
2
+ export type OverlayProps = {
2
3
  onCanvasRef: (element: HTMLCanvasElement | null) => void;
3
- }) => import("@emotion/react/jsx-runtime").JSX.Element;
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;
@@ -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
- /** Owned here, not by `pip`: that watches the media element, and the window holds a mirror. */
54
- togglePictureInPicture: () => void;
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
- /** Owned here, not by `pip`: that watches the media element, and the window holds a mirror. */
93
- togglePictureInPicture: () => void;
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
- * 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -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
- /** Above the control bar and outside the click-to-pause region, unlike `children`. */
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
- <TooltipDisplay
263
- id='picture-in-picture'
264
- text={
265
- <button
266
- className='picture-in-picture'
267
- type='button'
268
- onClick={togglePictureInPicture}
269
- >
270
- <img src={pictureInPicture} />
271
- </button>
272
- }
273
- toolTipText={
274
- <span>
275
- Picture in picture
276
- </span>
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
- const titleStyle = css`
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
- /* one line with an ellipsis until there is width to wrap, since a release filename would
31
- otherwise run to four lines on a phone */
32
- white-space: nowrap;
33
- overflow: hidden;
34
- text-overflow: ellipsis;
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
- white-space: normal;
40
- overflow: visible;
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
- transition: opacity 0.1s cubic-bezier(.4,0,1,1);
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 const Overlay = ({ onCanvasRef }: { onCanvasRef: (element: HTMLCanvasElement | null) => void }) => {
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={titleStyle}
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
- return useCallback(() => {
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
- /** Owned here, not by `pip`: that watches the media element, and the window holds a mirror. */
64
- togglePictureInPicture: () => void
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
- * 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 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;