@banou/media-player 0.8.2 → 0.8.4
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 +430 -393
- package/dist/react/components/chrome.d.ts +4 -2
- package/dist/react/components/overlay.d.ts +2 -6
- package/dist/react/player.d.ts +3 -2
- package/dist/react/source-feature.d.ts +33 -4
- package/dist/react/video-player.d.ts +49 -7
- package/package.json +1 -1
- package/src/lib/react/components/chrome.tsx +60 -5
- package/src/lib/react/components/overlay.tsx +22 -67
- package/src/lib/react/components/settings.tsx +117 -39
- package/src/lib/react/source-feature.ts +20 -2
- package/src/lib/react/video-player.tsx +57 -9
|
@@ -4,9 +4,11 @@ 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
|
+
/** False draws no control bar at all, leaving the picture, the title and the overlay. */
|
|
10
|
+
controls?: boolean;
|
|
9
11
|
children?: ReactNode;
|
|
10
12
|
};
|
|
11
|
-
export declare const Chrome: ({ ref, onVideoRef, onCanvasRef, overlay, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
13
|
+
export declare const Chrome: ({ ref, onVideoRef, onCanvasRef, overlay, controls, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
12
14
|
export default Chrome;
|
|
@@ -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;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -7,10 +7,11 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
7
7
|
thumbnailAt?: (time: number) => import("..").ThumbnailImage | undefined;
|
|
8
8
|
subtitleTracks: import("./source-feature").TrackChoice[];
|
|
9
9
|
selectedSubtitleTrack: string | number | undefined;
|
|
10
|
-
selectSubtitleTrack: (id: string | number | undefined) => void
|
|
10
|
+
selectSubtitleTrack: (id: string | number | undefined) => void | Promise<void>;
|
|
11
|
+
subtitleOffLabel?: string;
|
|
11
12
|
audioTracks: import("./source-feature").TrackChoice[];
|
|
12
13
|
selectedAudioTrack: string | number | undefined;
|
|
13
|
-
selectAudioTrack: (id: string | number) => void
|
|
14
|
+
selectAudioTrack: (id: string | number) => void | Promise<void>;
|
|
14
15
|
hideUI: boolean;
|
|
15
16
|
setHideUI: (hide: boolean) => void;
|
|
16
17
|
togglePictureInPicture: (() => void) | null;
|
|
@@ -10,6 +10,13 @@ import type { MediaIndex, ThumbnailImage } from '../engine';
|
|
|
10
10
|
export type TrackChoice = {
|
|
11
11
|
id: string | number;
|
|
12
12
|
label: string;
|
|
13
|
+
/**
|
|
14
|
+
* Offered but not selectable, so the menu shows it and refuses the click.
|
|
15
|
+
*
|
|
16
|
+
* Hiding it instead would be worse: a source that lists a dub it cannot currently serve is telling
|
|
17
|
+
* the viewer the dub exists, and a menu that silently omits it looks like the source has nothing.
|
|
18
|
+
*/
|
|
19
|
+
disabled?: boolean;
|
|
13
20
|
};
|
|
14
21
|
/**
|
|
15
22
|
* A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
|
|
@@ -40,13 +47,24 @@ export type SourceState = {
|
|
|
40
47
|
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
41
48
|
*/
|
|
42
49
|
thumbnailAt?: (time: number) => ThumbnailImage | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Both selectors may answer with a promise, and the menu waits on it.
|
|
52
|
+
*
|
|
53
|
+
* The engine switches a track by pointing the pipeline at another stream, which is immediate and
|
|
54
|
+
* cannot fail, so locally these return nothing. A source that owns its own player is the opposite
|
|
55
|
+
* case: the switch is a round trip through somebody else's UI and takes seconds, and it can lose.
|
|
56
|
+
* A menu that closed on the click would report a selection that has not happened, and a rejection
|
|
57
|
+
* with nothing awaiting it is an unhandled rejection rather than an error the viewer ever sees.
|
|
58
|
+
*/
|
|
43
59
|
subtitleTracks: TrackChoice[];
|
|
44
60
|
/** undefined means subtitles are off. */
|
|
45
61
|
selectedSubtitleTrack: string | number | undefined;
|
|
46
|
-
selectSubtitleTrack: (id: string | number | undefined) => void
|
|
62
|
+
selectSubtitleTrack: (id: string | number | undefined) => void | Promise<void>;
|
|
63
|
+
/** What the row that turns subtitles off is called, when the source would rather name it itself. */
|
|
64
|
+
subtitleOffLabel?: string;
|
|
47
65
|
audioTracks: TrackChoice[];
|
|
48
66
|
selectedAudioTrack: string | number | undefined;
|
|
49
|
-
selectAudioTrack: (id: string | number) => void
|
|
67
|
+
selectAudioTrack: (id: string | number) => void | Promise<void>;
|
|
50
68
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
51
69
|
hideUI: boolean;
|
|
52
70
|
setHideUI: (hide: boolean) => void;
|
|
@@ -87,13 +105,24 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
87
105
|
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
88
106
|
*/
|
|
89
107
|
thumbnailAt?: (time: number) => ThumbnailImage | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Both selectors may answer with a promise, and the menu waits on it.
|
|
110
|
+
*
|
|
111
|
+
* The engine switches a track by pointing the pipeline at another stream, which is immediate and
|
|
112
|
+
* cannot fail, so locally these return nothing. A source that owns its own player is the opposite
|
|
113
|
+
* case: the switch is a round trip through somebody else's UI and takes seconds, and it can lose.
|
|
114
|
+
* A menu that closed on the click would report a selection that has not happened, and a rejection
|
|
115
|
+
* with nothing awaiting it is an unhandled rejection rather than an error the viewer ever sees.
|
|
116
|
+
*/
|
|
90
117
|
subtitleTracks: TrackChoice[];
|
|
91
118
|
/** undefined means subtitles are off. */
|
|
92
119
|
selectedSubtitleTrack: string | number | undefined;
|
|
93
|
-
selectSubtitleTrack: (id: string | number | undefined) => void
|
|
120
|
+
selectSubtitleTrack: (id: string | number | undefined) => void | Promise<void>;
|
|
121
|
+
/** What the row that turns subtitles off is called, when the source would rather name it itself. */
|
|
122
|
+
subtitleOffLabel?: string;
|
|
94
123
|
audioTracks: TrackChoice[];
|
|
95
124
|
selectedAudioTrack: string | number | undefined;
|
|
96
|
-
selectAudioTrack: (id: string | number) => void
|
|
125
|
+
selectAudioTrack: (id: string | number) => void | Promise<void>;
|
|
97
126
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
98
127
|
hideUI: boolean;
|
|
99
128
|
setHideUI: (hide: boolean) => void;
|
|
@@ -14,16 +14,49 @@ export type MediaPlayerSource = {
|
|
|
14
14
|
};
|
|
15
15
|
/** Shared by both arms: nothing here depends on who owns the media. */
|
|
16
16
|
type CommonOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* Drawn across the top of the picture, in a layer of its own.
|
|
19
|
+
*
|
|
20
|
+
* That layer cannot see an `overlay` item, so an app that also places something along the top has
|
|
21
|
+
* two elements competing for one band of screen: on a narrow viewport the title ellipsizes against
|
|
22
|
+
* the FULL width and then runs underneath whatever is painted over it. An app in that position
|
|
23
|
+
* should leave this unset and draw the title as part of its own overlay row, where the two can
|
|
24
|
+
* share a flex line and the filename can be the one that gives way.
|
|
25
|
+
*/
|
|
17
26
|
title?: string;
|
|
18
27
|
autoplay?: boolean;
|
|
19
28
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
29
|
+
* Draw the control bar. Defaults to true.
|
|
30
|
+
*
|
|
31
|
+
* False leaves the picture, the title and the overlay and takes away only the chrome, for a host
|
|
32
|
+
* that has to put its own interactive UI over the media for a while. A source whose sign-in form
|
|
33
|
+
* lives inside its own document is the case this exists for: the form is the thing the viewer has
|
|
34
|
+
* to reach, and a control bar for a media that has not loaded yet sits on top of it and eats the
|
|
35
|
+
* clicks. Hiding it from the host side is not an option, since `setHideUI` is installed by the
|
|
36
|
+
* store's `attach` and does nothing at all until a media arrives.
|
|
37
|
+
*/
|
|
38
|
+
controls?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The app's own content over the video: a download readout, a badge, a logo, anything the player
|
|
41
|
+
* itself has no opinion about.
|
|
23
42
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
43
|
+
* Pass one node, or several, and EACH TOP-LEVEL ITEM gets its own layer covering the whole player.
|
|
44
|
+
* That layer is the coordinate space, so an item is placed with ordinary CSS against the picture:
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* <MediaPlayer overlay={[
|
|
48
|
+
* <div key="stats" css={css`position: absolute; top: 0; right: 0;`}>82 peers</div>,
|
|
49
|
+
* <div key="badge" css={css`position: absolute; inset: auto auto 0 0;`}>4K</div>,
|
|
50
|
+
* ]} />
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* A fragment works the same way. Items never share a containing block, so one item's CSS cannot
|
|
54
|
+
* move another, and each keeps its own DOM as the list changes.
|
|
55
|
+
*
|
|
56
|
+
* Items fade with the rest of the chrome, so a running counter does not sit over the picture once
|
|
57
|
+
* the controls have hidden themselves. A layer takes no pointer events, so a click still reaches
|
|
58
|
+
* the video and toggles playback; content that needs a pointer (a tooltip anchor, a button) sets
|
|
59
|
+
* `pointer-events: auto` on itself. `children` land next to the media instead, below the chrome.
|
|
27
60
|
*/
|
|
28
61
|
overlay?: ReactNode;
|
|
29
62
|
onSeek?: (fraction: number) => void;
|
|
@@ -72,7 +105,16 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
|
|
|
72
105
|
* works: the key session belongs to whoever owns the element.
|
|
73
106
|
*/
|
|
74
107
|
export type MediaPlayerRemoteOptions = CommonOptions & {
|
|
75
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Null means the media has not been found yet: the chrome renders and attaches when it arrives.
|
|
110
|
+
*
|
|
111
|
+
* A source usually has to mount its own document before it can hand over an element, and that
|
|
112
|
+
* document is passed as `children`, so it can only exist once this component has rendered. The
|
|
113
|
+
* key is still REQUIRED even when the value is null, because its presence is what selects this
|
|
114
|
+
* arm: omitting it falls through to the local arm, which draws an idle `<video>` over whatever
|
|
115
|
+
* the children put there.
|
|
116
|
+
*/
|
|
117
|
+
media: PlayerMedia | null;
|
|
76
118
|
read?: never;
|
|
77
119
|
size?: never;
|
|
78
120
|
/** The source's own storyboard, since there are no bytes to generate previews from. */
|
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,17 +68,39 @@ 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
|
|
98
|
+
/** False draws no control bar at all, leaving the picture, the title and the overlay. */
|
|
99
|
+
controls?: boolean
|
|
54
100
|
children?: ReactNode
|
|
55
101
|
}
|
|
56
102
|
|
|
57
|
-
export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, children }: ChromeProps) => {
|
|
103
|
+
export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, children }: ChromeProps) => {
|
|
58
104
|
const player = usePlayer()
|
|
59
105
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
60
106
|
const setHideUI = usePlayer((state) => state.setHideUI)
|
|
@@ -116,8 +162,17 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, children }: Chro
|
|
|
116
162
|
onMouseOut={onMouseOut}
|
|
117
163
|
className={hideUI ? 'hide' : ''}
|
|
118
164
|
>
|
|
119
|
-
<Overlay onCanvasRef={onCanvasRef}
|
|
120
|
-
|
|
165
|
+
<Overlay onCanvasRef={onCanvasRef} />
|
|
166
|
+
{overlayItems(overlay).map(({ key, item }) => (
|
|
167
|
+
<div
|
|
168
|
+
key={key}
|
|
169
|
+
css={overlayItemStyle}
|
|
170
|
+
style={{ ...hideUI ? { opacity: '0', visibility: 'hidden', pointerEvents: 'none' } : {} }}
|
|
171
|
+
>
|
|
172
|
+
{item}
|
|
173
|
+
</div>
|
|
174
|
+
))}
|
|
175
|
+
{controls === false ? null : <ControlBar />}
|
|
121
176
|
<div className="video" onClick={onVideoClick}>
|
|
122
177
|
{onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
|
|
123
178
|
{children}
|
|
@@ -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}
|
|
@@ -158,6 +158,22 @@ position: relative;
|
|
|
158
158
|
.description {
|
|
159
159
|
word-break: break-word;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/* Dimmed and inert, but still listed. A source that names a track it cannot serve right now is
|
|
163
|
+
saying the track exists, and dropping the row would read as the source having nothing. */
|
|
164
|
+
.unavailable {
|
|
165
|
+
opacity: 0.5;
|
|
166
|
+
cursor: default;
|
|
167
|
+
:hover {
|
|
168
|
+
background-color: transparent;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.failed {
|
|
173
|
+
border-bottom: 1px solid #4D4D4E;
|
|
174
|
+
color: #f66;
|
|
175
|
+
${fonts.bSmall.regular}
|
|
176
|
+
}
|
|
161
177
|
}
|
|
162
178
|
`
|
|
163
179
|
|
|
@@ -168,46 +184,55 @@ enum PopoverContent {
|
|
|
168
184
|
Audio
|
|
169
185
|
}
|
|
170
186
|
|
|
171
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* One track picker, shared by the subtitle and audio menus.
|
|
189
|
+
*
|
|
190
|
+
* `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
|
|
191
|
+
* is working, every row is inert and the one being switched to says so, because the selection has
|
|
192
|
+
* not moved yet and a tick next to it would be a lie.
|
|
193
|
+
*/
|
|
172
194
|
const TrackMenu = (
|
|
173
|
-
{ title, tracks, selected, onSelect, onBack,
|
|
195
|
+
{ title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
|
|
174
196
|
title: string
|
|
175
197
|
tracks: TrackChoice[]
|
|
176
198
|
selected: string | number | undefined
|
|
177
199
|
onSelect: (id: string | number | undefined) => void
|
|
178
200
|
onBack: () => void
|
|
179
|
-
|
|
201
|
+
/** Absent means the menu offers no way off at all, which is what audio wants. */
|
|
202
|
+
offLabel?: string
|
|
203
|
+
/** The id currently being switched to, where `null` is the off row and undefined means idle. */
|
|
204
|
+
pending?: string | number | null
|
|
205
|
+
failed?: boolean
|
|
206
|
+
}
|
|
207
|
+
) => {
|
|
208
|
+
const busy = pending !== undefined
|
|
209
|
+
const row = (id: string | number | null, label: string, className?: string) => {
|
|
210
|
+
const isPending = busy && pending === id
|
|
211
|
+
const disabled = busy || tracks.find((track) => track.id === id)?.disabled
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
key={id ?? '__off__'}
|
|
215
|
+
onClick={() => { if (!disabled) onSelect(id ?? undefined) }}
|
|
216
|
+
className={[className, disabled ? 'unavailable' : null].filter(Boolean).join(' ') || undefined}
|
|
217
|
+
>
|
|
218
|
+
<span>{label}</span>
|
|
219
|
+
<span>{isPending ? '…' : (selected ?? null) === id ? '✓' : ''}</span>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
180
222
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<div className=
|
|
184
|
-
<
|
|
185
|
-
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className='popover track-list'>
|
|
226
|
+
<div className="back" onClick={onBack}>
|
|
227
|
+
<ChevronLeft />
|
|
228
|
+
<span>{title}</span>
|
|
229
|
+
</div>
|
|
230
|
+
{failed ? <div className="no-hover failed">Could not switch. Try again.</div> : null}
|
|
231
|
+
{offLabel ? row(null, offLabel) : null}
|
|
232
|
+
{tracks.map(({ id, label }) => row(id, label, 'description'))}
|
|
186
233
|
</div>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
? (
|
|
190
|
-
<div onClick={() => onSelect(undefined)}>
|
|
191
|
-
<span>Disable</span>
|
|
192
|
-
<span>{selected === undefined ? '✓' : ''}</span>
|
|
193
|
-
</div>
|
|
194
|
-
)
|
|
195
|
-
: null
|
|
196
|
-
}
|
|
197
|
-
{
|
|
198
|
-
tracks.map(({ id, label }) => (
|
|
199
|
-
<div
|
|
200
|
-
key={id}
|
|
201
|
-
onClick={() => onSelect(id)}
|
|
202
|
-
className="description"
|
|
203
|
-
>
|
|
204
|
-
<span>{label}</span>
|
|
205
|
-
<span>{selected === id ? '✓' : ''}</span>
|
|
206
|
-
</div>
|
|
207
|
-
))
|
|
208
|
-
}
|
|
209
|
-
</div>
|
|
210
|
-
)
|
|
234
|
+
)
|
|
235
|
+
}
|
|
211
236
|
|
|
212
237
|
export const SettingsAction = () => {
|
|
213
238
|
const player = usePlayer()
|
|
@@ -219,13 +244,27 @@ export const SettingsAction = () => {
|
|
|
219
244
|
const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
|
|
220
245
|
const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
|
|
221
246
|
|
|
247
|
+
const subtitleOffLabel = usePlayer((state) => state.subtitleOffLabel)
|
|
248
|
+
|
|
222
249
|
const [isOpenPopover, setIsOpenPopover] = useState(false)
|
|
223
250
|
const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
|
|
224
251
|
const settingsContainerRef = useRef<HTMLDivElement>(null)
|
|
252
|
+
// The id being switched to, or undefined when nothing is in flight. Null is the row that turns
|
|
253
|
+
// subtitles off, so this cannot be a plain id: undefined has to keep meaning idle.
|
|
254
|
+
const [pending, setPending] = useState<string | number | null | undefined>(undefined)
|
|
255
|
+
const [failed, setFailed] = useState(false)
|
|
225
256
|
|
|
226
257
|
const togglePopover = () => {
|
|
227
258
|
setIsOpenPopover(!isOpenPopover)
|
|
228
259
|
setPopoverContent(PopoverContent.Default)
|
|
260
|
+
setFailed(false)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Not `togglePopover`: this runs from a promise, where the captured `isOpenPopover` is whatever it
|
|
264
|
+
// was at click time, so a toggle could reopen a menu the viewer has already dismissed.
|
|
265
|
+
const closePopover = () => {
|
|
266
|
+
setIsOpenPopover(false)
|
|
267
|
+
setPopoverContent(PopoverContent.Default)
|
|
229
268
|
}
|
|
230
269
|
|
|
231
270
|
const setPlaybackRate = (rate: number) => {
|
|
@@ -248,15 +287,50 @@ export const SettingsAction = () => {
|
|
|
248
287
|
return () => document.removeEventListener('pointerdown', handleClickOutside)
|
|
249
288
|
}, [isOpenPopover])
|
|
250
289
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Runs a track switch and decides when the menu may close.
|
|
292
|
+
*
|
|
293
|
+
* A selector that returns nothing switched synchronously, so the click closes the menu as it always
|
|
294
|
+
* has. One that returns a promise owns a player this one cannot reach, and the menu stays open and
|
|
295
|
+
* inert until it answers: closing first would show a selection that has not happened yet, and
|
|
296
|
+
* dropping the promise would turn a failed switch into an unhandled rejection nobody ever sees.
|
|
297
|
+
*/
|
|
298
|
+
const runSelect = (id: string | number | null, select: () => void | Promise<void>) => {
|
|
299
|
+
if (pending !== undefined) return
|
|
300
|
+
setFailed(false)
|
|
301
|
+
let result: void | Promise<void>
|
|
302
|
+
try {
|
|
303
|
+
result = select()
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.warn('[media-player] track selection failed:', err)
|
|
306
|
+
setFailed(true)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (!(result instanceof Promise)) {
|
|
310
|
+
closePopover()
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
setPending(id)
|
|
314
|
+
result.then(
|
|
315
|
+
() => {
|
|
316
|
+
setPending(undefined)
|
|
317
|
+
closePopover()
|
|
318
|
+
},
|
|
319
|
+
(err) => {
|
|
320
|
+
console.warn('[media-player] track selection failed:', err)
|
|
321
|
+
setPending(undefined)
|
|
322
|
+
setFailed(true)
|
|
323
|
+
},
|
|
324
|
+
)
|
|
254
325
|
}
|
|
255
326
|
|
|
327
|
+
const chooseSubtitle = (id: string | number | undefined) =>
|
|
328
|
+
runSelect(id ?? null, () => selectSubtitleTrack(id))
|
|
329
|
+
|
|
330
|
+
// the audio menu offers no way off, so there is no undefined case to answer for
|
|
256
331
|
const chooseAudio = (id: string | number | undefined) => {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
togglePopover()
|
|
332
|
+
if (id === undefined) return
|
|
333
|
+
runSelect(id, () => selectAudioTrack(id))
|
|
260
334
|
}
|
|
261
335
|
|
|
262
336
|
const changePopoverContent = (newPopoverContent: PopoverContent) => () => {
|
|
@@ -362,7 +436,9 @@ export const SettingsAction = () => {
|
|
|
362
436
|
selected={selectedSubtitleTrack}
|
|
363
437
|
onSelect={chooseSubtitle}
|
|
364
438
|
onBack={changePopoverContent(PopoverContent.Default)}
|
|
365
|
-
|
|
439
|
+
offLabel={subtitleOffLabel ?? 'Disable'}
|
|
440
|
+
pending={pending}
|
|
441
|
+
failed={failed}
|
|
366
442
|
/>
|
|
367
443
|
)
|
|
368
444
|
}
|
|
@@ -374,6 +450,8 @@ export const SettingsAction = () => {
|
|
|
374
450
|
selected={selectedAudioTrack}
|
|
375
451
|
onSelect={chooseAudio}
|
|
376
452
|
onBack={changePopoverContent(PopoverContent.Default)}
|
|
453
|
+
pending={pending}
|
|
454
|
+
failed={failed}
|
|
377
455
|
/>
|
|
378
456
|
)
|
|
379
457
|
}
|