@banou/media-player 0.8.17 → 0.8.19

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.
@@ -10,3 +10,12 @@ export declare const CaptionsOff: (props: SVGProps<SVGSVGElement>) => import("@e
10
10
  * captions glyph either, because the button beside it already is one.
11
11
  */
12
12
  export declare const SubtitlesInPicture: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
13
+ /**
14
+ * The same frame with the captions still on the MAIN picture: burn-in is available but off.
15
+ *
16
+ * The pair exists because that control is the one button in the bar whose glyph did not move with its
17
+ * state, so the state was carried by an accent colour and by nothing else. A pair, not a slash: a
18
+ * slashed resting state reads as unavailable, and the subtitles button beside it already draws
19
+ * Feather's slash for its own off state.
20
+ */
21
+ export declare const SubtitlesOutsidePicture: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
@@ -2,8 +2,6 @@ declare const colors: {
2
2
  primary: string;
3
3
  secondary: string;
4
4
  hover: string;
5
- /** A control that is ON. Distinct from `hover`, which the pointer is already painting. */
6
- accent: string;
7
5
  borderPrimary: string;
8
6
  backgroundTooltip: string;
9
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
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",
@@ -59,7 +59,7 @@ const style = css`
59
59
  flex: none;
60
60
  font-size: calc(2.6 * var(--mp-unit));
61
61
  line-height: 1;
62
- color: #6EA8FE;
62
+ color: #fff;
63
63
  }
64
64
  `
65
65
 
@@ -108,13 +108,56 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
108
108
  const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
109
109
  // a tap and a click mean different things, so the last pointer kind is remembered
110
110
  const lastPointerType = useRef<string>('mouse')
111
+ // where the mouse last was, which is where it still is when nothing has moved
112
+ const pointer = useRef({ x: -1, y: -1 })
113
+ const root = useRef<HTMLDivElement | null>(null)
111
114
 
112
115
  useEffect(() => () => clearTimeout(autoHide.current), [])
113
116
 
117
+ // The caller's ref still gets the element: video.js attaches the fullscreen container through it,
118
+ // and this needs the same node to hit test against.
119
+ const setRoot = (element: HTMLDivElement | null) => {
120
+ root.current = element
121
+ if (typeof ref === 'function') ref(element)
122
+ else if (ref) (ref as { current: HTMLDivElement | null }).current = element
123
+ }
124
+
125
+ /**
126
+ * Is the mouse sitting on something it could press right now?
127
+ *
128
+ * The hide is a timeout on the last MOVEMENT, so a pointer parked on a control still runs it out
129
+ * and the control disappears from under the cursor. Everything then falls through to the video:
130
+ * `elementFromPoint` at the button's own centre returns the `video`, so a click pauses playback
131
+ * and a right click offers the browser's video menu rather than the control's. That is how a link
132
+ * drawn in an overlay stops behaving like a link, which is what this exists to stop.
133
+ *
134
+ * Only what actually takes pointer events can be hit, so this needs no list of selectors: the
135
+ * chrome's own layers opt out, an overlay item opts out until an app opts a child back in, and the
136
+ * picture is excluded here because resting on the picture is exactly when hiding is right.
137
+ *
138
+ * The mouse only. A finger has no resting position, and `elementFromPoint` on the last tap would
139
+ * keep the controls up forever after one press.
140
+ */
141
+ const pointerRestsOnAControl = () => {
142
+ if (lastPointerType.current !== 'mouse') return false
143
+ const { x, y } = pointer.current
144
+ if (!root.current || x < 0) return false
145
+ const hit = document.elementFromPoint(x, y)
146
+ return !!hit && root.current.contains(hit) && !hit.closest('.video')
147
+ }
148
+
149
+ const hideUnlessTheMouseIsOnAControl = () => {
150
+ if (pointerRestsOnAControl()) {
151
+ autoHide.current = setTimeout(hideUnlessTheMouseIsOnAControl, AUTO_HIDE_DELAY)
152
+ return
153
+ }
154
+ setHideUI(true)
155
+ }
156
+
114
157
  const reveal = () => {
115
158
  setHideUI(false)
116
159
  clearTimeout(autoHide.current)
117
- autoHide.current = setTimeout(() => setHideUI(true), AUTO_HIDE_DELAY)
160
+ autoHide.current = setTimeout(hideUnlessTheMouseIsOnAControl, AUTO_HIDE_DELAY)
118
161
  }
119
162
 
120
163
  // Hides after the delay whether or not playback is running. A finger produces no move, so this is
@@ -122,6 +165,7 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
122
165
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
123
166
  lastPointerType.current = event.pointerType
124
167
  if (event.pointerType !== 'mouse') return
168
+ pointer.current = { x: event.clientX, y: event.clientY }
125
169
  reveal()
126
170
  }
127
171
 
@@ -157,7 +201,7 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
157
201
  return (
158
202
  <div
159
203
  css={style}
160
- ref={ref}
204
+ ref={setRoot}
161
205
  onPointerMove={onPointerMove}
162
206
  onPointerDown={onPointerDown}
163
207
  onMouseOut={onMouseOut}
@@ -10,7 +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
+ import { SubtitlesInPicture, SubtitlesOutsidePicture } from './icons'
14
14
  import ErrorsAction from './errors'
15
15
  import SettingsAction from './settings'
16
16
  import SubtitlesAction from './subtitles'
@@ -21,17 +21,15 @@ const VOLUME_STEP = 0.05
21
21
  const SEEK_STEP = 5
22
22
 
23
23
  /**
24
- * Two stacked lines inside a tooltip, with a width to wrap against.
24
+ * Two stacked lines inside a tooltip.
25
25
  *
26
- * Applied to the content rather than to the tooltip, because react-tooltip renders into a portal and
27
- * an unconstrained tooltip grows to one long line that runs off the side of the player.
26
+ * The width to wrap against is no longer here: TooltipDisplay bounds every chip it draws, at the
27
+ * same 26 units this had imposed by hand, so a call site only says what its content is.
28
28
  */
29
29
  const tooltipLinesStyle = css`
30
30
  display: flex;
31
31
  flex-direction: column;
32
32
  gap: calc(0.4 * var(--mp-unit));
33
- max-width: calc(26 * var(--mp-unit));
34
- white-space: normal;
35
33
 
36
34
  .hint {
37
35
  opacity: 0.72;
@@ -97,16 +95,6 @@ const style = css`
97
95
  }
98
96
  }
99
97
 
100
- /**
101
- * The burn-in control's own on state.
102
- *
103
- * Not \`colors.hover\`: the pointer is on the button at the moment of the click, so an on state
104
- * drawn in the hover colour is invisible exactly when it is being looked for.
105
- */
106
- button[aria-pressed='true'] svg {
107
- stroke: ${colors.accent};
108
- }
109
-
110
98
  .play, .sound, .time, .errors, .subtitles, .settings, .picture-in-picture, .full-screen {
111
99
  display: flex;
112
100
  align-items: center;
@@ -333,15 +321,20 @@ export const ControlBar = () => {
333
321
  aria-label={burnIn ? 'Put the subtitles in the video' : 'Picture in picture'}
334
322
  aria-pressed={burnIn ? burnedInSubtitles : undefined}
335
323
  >
324
+ {/* The glyph carries the on state, which is what every other toggle in this bar
325
+ does. It used to be carried by an accent stroke instead, and that was the only
326
+ blue in the chrome. */}
336
327
  {burnIn
337
- ? <SubtitlesInPicture />
328
+ ? burnedInSubtitles
329
+ ? <SubtitlesInPicture />
330
+ : <SubtitlesOutsidePicture />
338
331
  : <img src={pictureInPicture} alt='' />}
339
332
  </button>
340
333
  }
341
334
  toolTipText={
342
- // The tooltip is portaled out of this subtree, so the control bar's own rules
343
- // never reach it: a bare `small` stays inline and runs straight on from the line
344
- // above it. Both lines carry their layout themselves.
335
+ // The chip is drawn in its own subtree, where the control bar's rules never
336
+ // reach it: a bare `small` stays inline and runs straight on from the line above
337
+ // it. Both lines carry their layout themselves.
345
338
  <span css={tooltipLinesStyle}>
346
339
  <span className='lead'>
347
340
  {!burnIn
@@ -51,3 +51,19 @@ export const SubtitlesInPicture = (props: SVGProps<SVGSVGElement>) => (
51
51
  <path d='M14.5 18.5h2m2 0h1' />
52
52
  </svg>
53
53
  )
54
+
55
+ /**
56
+ * The same frame with the captions still on the MAIN picture: burn-in is available but off.
57
+ *
58
+ * The pair exists because that control is the one button in the bar whose glyph did not move with its
59
+ * state, so the state was carried by an accent colour and by nothing else. A pair, not a slash: a
60
+ * slashed resting state reads as unavailable, and the subtitles button beside it already draws
61
+ * Feather's slash for its own off state.
62
+ */
63
+ export const SubtitlesOutsidePicture = (props: SVGProps<SVGSVGElement>) => (
64
+ <svg {...iconProps} {...props}>
65
+ <path d='M21 11V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h5' />
66
+ <rect x='12' y='13' width='10' height='8' rx='1' ry='1' />
67
+ <path d='M6 9h3m2 0h2' />
68
+ </svg>
69
+ )
@@ -1,5 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
+ import type { TooltipRefProps } from 'react-tooltip'
2
3
 
4
+ import { useEffect, useRef } from 'react'
3
5
  import { css } from '@emotion/react'
4
6
  import { PlacesType, Tooltip } from 'react-tooltip'
5
7
 
@@ -13,9 +15,31 @@ export enum buttonSize {
13
15
 
14
16
  const style = (size: buttonSize) => css`
15
17
  display: flex;
16
- justify-content: flex-end;
18
+ justify-content: flex-start;
17
19
 
18
- border-radius: calc(0.4 * var(--mp-unit));
20
+ /*
21
+ * Bounded here rather than at each call site.
22
+ *
23
+ * react-tooltip's own chip rule carries width: max-content, so with nothing opposing it a chip
24
+ * grows to the widest UNWRAPPED line of its content: a two sentence tooltip measured 1178px
25
+ * through this component. Both declarations are load bearing, since a bound with no wrapping only
26
+ * moves the overflow inside a narrow box. 26 units is the width control-bar.tsx was already
27
+ * imposing on its own two line tooltip by hand, so nothing in the bar changes shape.
28
+ */
29
+ max-width: min(calc(26 * var(--mp-unit)), calc(100vw - calc(2.4 * var(--mp-unit))));
30
+ white-space: normal;
31
+ overflow-wrap: anywhere;
32
+ text-align: left;
33
+
34
+ /*
35
+ * The radius carries !important for the same reason the paddings below do.
36
+ *
37
+ * react-tooltip injects its stylesheet from a passive effect while emotion inserts through
38
+ * useInsertionEffect, which runs earlier, so react-tooltip's sheet lands in the head LAST at
39
+ * exactly this specificity and wins every tie. Its own radius is 3px, which is what was drawn
40
+ * here until this was stated.
41
+ */
42
+ border-radius: calc(0.4 * var(--mp-unit))!important;
19
43
  user-select: none;
20
44
 
21
45
  z-index: 3;
@@ -35,6 +59,25 @@ const style = (size: buttonSize) => css`
35
59
  `}
36
60
  `
37
61
 
62
+ /**
63
+ * Where the pointer is, shared by every anchor rather than tracked once per tooltip.
64
+ *
65
+ * The chrome mounts eight of these, and each only needs to answer one question at one moment, so a
66
+ * single refcounted listener answers it for all of them. Installed on the first mount rather than at
67
+ * module scope, so importing this library still does nothing to the document.
68
+ */
69
+ const pointer = { x: -1, y: -1, anchors: 0 }
70
+ const trackPointer = (event: PointerEvent) => { pointer.x = event.clientX; pointer.y = event.clientY }
71
+
72
+ const watchPointer = () => {
73
+ if (pointer.anchors++ === 0) {
74
+ window.addEventListener('pointermove', trackPointer, { capture: true, passive: true })
75
+ }
76
+ return () => {
77
+ if (--pointer.anchors === 0) window.removeEventListener('pointermove', trackPointer, { capture: true })
78
+ }
79
+ }
80
+
38
81
  interface TooltipDisplayProps {
39
82
  id: string
40
83
  toolTipText: ReactNode
@@ -57,30 +100,77 @@ export const TooltipDisplay = ({
57
100
  tooltipPlace = 'top',
58
101
  disabled = false,
59
102
  size = buttonSize.md
60
- }: TooltipDisplayProps) => (
61
- <>
62
- <div
63
- data-tooltip-id={id}
64
- data-open={true}
65
- data-tooltip-offset={offset}
66
- data-tooltip-delay-show={delayShow}
67
- data-tooltip-delay-hide={closeDelay}
68
- data-tooltip-place={tooltipPlace}
69
- >
70
- {text}
71
- </div>
72
- {
73
- !disabled && (
74
- <Tooltip
75
- css={style(size)}
76
- id={id}
77
- noArrow={true}
78
- >
79
- {toolTipText}
80
- </Tooltip>
81
- )
103
+ }: TooltipDisplayProps) => {
104
+ const anchor = useRef<HTMLDivElement>(null)
105
+ const tooltip = useRef<TooltipRefProps>(null)
106
+
107
+ /**
108
+ * Close on a fullscreen transition, unless the pointer really is still on the anchor.
109
+ *
110
+ * Going fullscreen relays the whole chrome out from under a pointer that never moved, and the
111
+ * browser recomputes the hover chain for that SILENTLY: the :hover flag flips a frame later, so
112
+ * the grey pill corrects itself in about 14ms, but no boundary event is dispatched at all.
113
+ * react-tooltip closes on mouseout and on nothing else, so it never learns the pointer left and
114
+ * the chip stays painted for the rest of the session. It survives arbitrary pointer movement,
115
+ * because the browser has already updated its own element-under-pointer, and it survives the
116
+ * chrome's auto-hide, coming back the moment the controls wake.
117
+ *
118
+ * Scoped to the fullscreen transition on purpose: the same layout move made by ordinary CSS does
119
+ * dispatch mouseout and closes the chip by itself, so no other reflow needs this and widening it
120
+ * would only add ways to close a tooltip that should be open.
121
+ *
122
+ * Tested against the anchor's rect rather than closed outright, because a player that already
123
+ * fills the window moves nothing, and the tooltip under the pointer is then legitimately open.
124
+ * That is the same test chrome.tsx's onMouseOut makes when relatedTarget comes back null. The rect
125
+ * is already the post-transition one when fullscreenchange fires, where matches(':hover') is not,
126
+ * which is why the pointer is tracked rather than asked for.
127
+ */
128
+ useEffect(() => {
129
+ const untrack = watchPointer()
130
+ const closeIfPointerLeft = () => {
131
+ const element = anchor.current
132
+ if (!element) return
133
+ const { left, right, top, bottom } = element.getBoundingClientRect()
134
+ if (pointer.x >= left && pointer.x < right && pointer.y >= top && pointer.y < bottom) return
135
+ tooltip.current?.close()
136
+ }
137
+ // webkit's own name as well, the way @videojs/core's fullscreen feature listens for both. Typed
138
+ // as string so the prefixed name checks against the DOM lib's event map.
139
+ const changeEvents: string[] = ['fullscreenchange', 'webkitfullscreenchange']
140
+ for (const type of changeEvents) document.addEventListener(type, closeIfPointerLeft)
141
+ return () => {
142
+ untrack()
143
+ for (const type of changeEvents) document.removeEventListener(type, closeIfPointerLeft)
82
144
  }
83
- </>
84
- )
145
+ }, [])
146
+
147
+ return (
148
+ <>
149
+ <div
150
+ ref={anchor}
151
+ data-tooltip-id={id}
152
+ data-open={true}
153
+ data-tooltip-offset={offset}
154
+ data-tooltip-delay-show={delayShow}
155
+ data-tooltip-delay-hide={closeDelay}
156
+ data-tooltip-place={tooltipPlace}
157
+ >
158
+ {text}
159
+ </div>
160
+ {
161
+ !disabled && (
162
+ <Tooltip
163
+ ref={tooltip}
164
+ css={style(size)}
165
+ id={id}
166
+ noArrow={true}
167
+ >
168
+ {toolTipText}
169
+ </Tooltip>
170
+ )
171
+ }
172
+ </>
173
+ )
174
+ }
85
175
 
86
176
  export default TooltipDisplay
@@ -2,8 +2,6 @@ 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',
7
5
  borderPrimary: '#384F70',
8
6
  backgroundTooltip: '#222222',
9
7
  }