@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.
- package/dist/index.js +233 -161
- package/dist/react/components/icons.d.ts +9 -0
- package/dist/utils/colors.d.ts +0 -2
- package/package.json +1 -1
- package/src/lib/react/components/burn-in-hint.tsx +1 -1
- package/src/lib/react/components/chrome.tsx +46 -2
- package/src/lib/react/components/control-bar.tsx +13 -20
- package/src/lib/react/components/icons.tsx +16 -0
- package/src/lib/react/components/tooltip-display.tsx +116 -26
- package/src/lib/utils/colors.ts +0 -2
|
@@ -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;
|
package/dist/utils/colors.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -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(
|
|
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={
|
|
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
|
|
24
|
+
* Two stacked lines inside a tooltip.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
?
|
|
328
|
+
? burnedInSubtitles
|
|
329
|
+
? <SubtitlesInPicture />
|
|
330
|
+
: <SubtitlesOutsidePicture />
|
|
338
331
|
: <img src={pictureInPicture} alt='' />}
|
|
339
332
|
</button>
|
|
340
333
|
}
|
|
341
334
|
toolTipText={
|
|
342
|
-
// The
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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-
|
|
18
|
+
justify-content: flex-start;
|
|
17
19
|
|
|
18
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
package/src/lib/utils/colors.ts
CHANGED
|
@@ -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
|
}
|