@banou/media-player 0.8.4 → 0.8.6
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/engine/index.d.ts +2 -2
- package/dist/engine/index.js +2 -2
- package/dist/engine/picture-in-picture.d.ts +18 -6
- package/dist/{engine-Dmc7JSd9.js → engine--OlGG-Ve.js} +128 -97
- package/dist/index.js +757 -448
- package/dist/react/components/burn-in-hint.d.ts +9 -0
- package/dist/react/components/icons.d.ts +12 -0
- package/dist/react/components/subtitles.d.ts +9 -0
- package/dist/react/components/track-menu.d.ts +47 -0
- package/dist/react/hooks/use-picture-in-picture.d.ts +17 -2
- package/dist/react/player.d.ts +2 -0
- package/dist/react/source-feature.d.ts +17 -1
- package/dist/utils/colors.d.ts +2 -0
- package/package.json +1 -1
- package/src/lib/engine/index.ts +2 -2
- package/src/lib/engine/picture-in-picture.ts +183 -49
- package/src/lib/react/components/burn-in-hint.tsx +105 -0
- package/src/lib/react/components/chrome.tsx +3 -0
- package/src/lib/react/components/control-bar.tsx +80 -5
- package/src/lib/react/components/icons.tsx +53 -0
- package/src/lib/react/components/progress-bar.tsx +20 -4
- package/src/lib/react/components/settings.tsx +21 -279
- package/src/lib/react/components/subtitles.tsx +80 -0
- package/src/lib/react/components/track-menu.tsx +277 -0
- package/src/lib/react/hooks/use-picture-in-picture.ts +30 -12
- package/src/lib/react/source-feature.ts +12 -1
- package/src/lib/react/video-player.tsx +54 -3
- package/src/lib/utils/colors.ts +2 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Says what to do next, because turning burn-in on looks like nothing happening.
|
|
3
|
+
*
|
|
4
|
+
* The composite is pixel identical to the picture it replaces, so without this the click has no
|
|
5
|
+
* visible result at all and the viewer has no way to learn that the browser's own picture in picture
|
|
6
|
+
* control is now the thing to press.
|
|
7
|
+
*/
|
|
8
|
+
export declare const BurnInHint: () => import("@emotion/react/jsx-runtime").JSX.Element | null;
|
|
9
|
+
export default BurnInHint;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
export declare const Captions: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
3
|
+
/** The slash is Feather's own convention for an off state, the line `MicOff` and `BellOff` draw. */
|
|
4
|
+
export declare const CaptionsOff: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
5
|
+
/**
|
|
6
|
+
* Subtitles inside the picture: the picture-in-picture frame with caption bars in the inset.
|
|
7
|
+
*
|
|
8
|
+
* A separate glyph on purpose. The control means something different on a browser that cannot open
|
|
9
|
+
* a window, and the same icon doing two things silently is the thing to avoid. It is not the plain
|
|
10
|
+
* captions glyph either, because the button beside it already is one.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SubtitlesInPicture: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitles, one click from the control bar.
|
|
3
|
+
*
|
|
4
|
+
* It used to be a row inside the settings menu, which put the most-reached control in the player two
|
|
5
|
+
* clicks deep behind a gear, next to playback speed. Audio stays in there: switching it is rare, and
|
|
6
|
+
* on a source that owns its own player it is slow enough to be a considered act.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SubtitlesAction: () => import("@emotion/react/jsx-runtime").JSX.Element | null;
|
|
9
|
+
export default SubtitlesAction;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TrackChoice } from '../source-feature';
|
|
2
|
+
/**
|
|
3
|
+
* The popover surface and the track rows, shared by every menu in the control bar.
|
|
4
|
+
*
|
|
5
|
+
* Carries NO `position` of its own: the menu anchors to the control bar, which is the width of the
|
|
6
|
+
* player box, so each caller keeps `position: static` on its own wrapper. Anchoring to the button
|
|
7
|
+
* instead is what let the menu hang outside the box, where the root's `overflow: hidden` made it
|
|
8
|
+
* unreachable rather than merely ugly.
|
|
9
|
+
*/
|
|
10
|
+
export declare const popoverStyle: import("@emotion/utils").SerializedStyles;
|
|
11
|
+
/**
|
|
12
|
+
* One track picker, used by the subtitles button and by the settings menu's audio page.
|
|
13
|
+
*
|
|
14
|
+
* `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
|
|
15
|
+
* is working, every row is inert and the one being switched to says so, because the selection has not
|
|
16
|
+
* moved yet and a tick next to it would be a lie.
|
|
17
|
+
*/
|
|
18
|
+
export declare const TrackMenu: ({ title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
|
|
19
|
+
title: string;
|
|
20
|
+
tracks: TrackChoice[];
|
|
21
|
+
selected: string | number | undefined;
|
|
22
|
+
onSelect: (id: string | number | undefined) => void;
|
|
23
|
+
/** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
|
|
24
|
+
onBack?: () => void;
|
|
25
|
+
/** Absent means the menu offers no way off at all, which is what audio wants. */
|
|
26
|
+
offLabel?: string;
|
|
27
|
+
/** The id currently being switched to, where `null` is the off row and undefined means idle. */
|
|
28
|
+
pending?: string | number | null;
|
|
29
|
+
failed?: boolean;
|
|
30
|
+
}) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
31
|
+
/**
|
|
32
|
+
* The open/closed and in-flight state every track menu in the bar needs.
|
|
33
|
+
*
|
|
34
|
+
* One hook rather than two, because `runSelect` is what decides when the menu may close: splitting
|
|
35
|
+
* the popover state from the selection state would wire them in a circle.
|
|
36
|
+
*/
|
|
37
|
+
export declare const useTrackMenu: ({ onReset }?: {
|
|
38
|
+
onReset?: () => void;
|
|
39
|
+
}) => {
|
|
40
|
+
open: boolean;
|
|
41
|
+
toggle: () => void;
|
|
42
|
+
close: () => void;
|
|
43
|
+
containerRef: import("react").RefObject<HTMLDivElement | null>;
|
|
44
|
+
pending: string | number | null | undefined;
|
|
45
|
+
failed: boolean;
|
|
46
|
+
runSelect: (id: string | number | null, select: () => void | Promise<void>) => void;
|
|
47
|
+
};
|
|
@@ -1,2 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
1
|
+
import type { PictureInPictureMode } from '../../engine';
|
|
2
|
+
export type PictureInPicture = {
|
|
3
|
+
/** null when nothing here can work, and the chrome then offers no control at all. */
|
|
4
|
+
toggle: (() => void) | null;
|
|
5
|
+
mode: PictureInPictureMode | null;
|
|
6
|
+
/** Burn-in only. True while the composite is the picture on screen. */
|
|
7
|
+
burnedIn: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Picture in picture with the subtitles composited in.
|
|
11
|
+
*
|
|
12
|
+
* Two shapes, because two kinds of browser. Where the W3C API exists this opens a real window off a
|
|
13
|
+
* hidden mirror. Where it does not, and the engine is Gecko, the same composite becomes the picture
|
|
14
|
+
* in the page so that the BROWSER'S own picture in picture control carries the subtitles with it,
|
|
15
|
+
* which it otherwise cannot: it takes a video element, and the subtitles live on a canvas above one.
|
|
16
|
+
*/
|
|
17
|
+
export declare const usePictureInPicture: (video: HTMLVideoElement | null, canvas: HTMLCanvasElement | null) => PictureInPicture;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
15
15
|
hideUI: boolean;
|
|
16
16
|
setHideUI: (hide: boolean) => void;
|
|
17
17
|
togglePictureInPicture: (() => void) | null;
|
|
18
|
+
pictureInPictureMode: import("../engine").PictureInPictureMode | null;
|
|
19
|
+
burnedInSubtitles: boolean;
|
|
18
20
|
playbackError: unknown;
|
|
19
21
|
ready: boolean;
|
|
20
22
|
setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MediaIndex, ThumbnailImage } from '../engine';
|
|
1
|
+
import type { MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine';
|
|
2
2
|
/**
|
|
3
3
|
* One row of a track menu, already named.
|
|
4
4
|
*
|
|
@@ -78,6 +78,14 @@ export type SourceState = {
|
|
|
78
78
|
* button that toggles nothing and never lights up is worse than no button.
|
|
79
79
|
*/
|
|
80
80
|
togglePictureInPicture: (() => void) | null;
|
|
81
|
+
/**
|
|
82
|
+
* Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
|
|
83
|
+
* subtitles into the picture so the BROWSER'S own control carries them; the viewer presses that
|
|
84
|
+
* one afterwards, so the button has to say something different. null means no control.
|
|
85
|
+
*/
|
|
86
|
+
pictureInPictureMode: PictureInPictureMode | null;
|
|
87
|
+
/** Burn-in only. True while the composite is the picture on screen. */
|
|
88
|
+
burnedInSubtitles: boolean;
|
|
81
89
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
82
90
|
playbackError: unknown;
|
|
83
91
|
/** Whether the engine has produced its first media segment. */
|
|
@@ -136,6 +144,14 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
136
144
|
* button that toggles nothing and never lights up is worse than no button.
|
|
137
145
|
*/
|
|
138
146
|
togglePictureInPicture: (() => void) | null;
|
|
147
|
+
/**
|
|
148
|
+
* Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
|
|
149
|
+
* subtitles into the picture so the BROWSER'S own control carries them; the viewer presses that
|
|
150
|
+
* one afterwards, so the button has to say something different. null means no control.
|
|
151
|
+
*/
|
|
152
|
+
pictureInPictureMode: PictureInPictureMode | null;
|
|
153
|
+
/** Burn-in only. True while the composite is the picture on screen. */
|
|
154
|
+
burnedInSubtitles: boolean;
|
|
139
155
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
140
156
|
playbackError: unknown;
|
|
141
157
|
/** Whether the engine has produced its first media segment. */
|
package/dist/utils/colors.d.ts
CHANGED
package/package.json
CHANGED
package/src/lib/engine/index.ts
CHANGED
|
@@ -10,5 +10,5 @@ export type { ThumbnailGenerator, ThumbnailGeneratorOptions, ThumbnailImage } fr
|
|
|
10
10
|
export { getTimeRanges, updateSourceBuffer } from './source-buffer'
|
|
11
11
|
export type { TimeRange } from './source-buffer'
|
|
12
12
|
|
|
13
|
-
export { createPictureInPicture } from './picture-in-picture'
|
|
14
|
-
export type { PictureInPictureController, PictureInPictureOptions } from './picture-in-picture'
|
|
13
|
+
export { createPictureInPicture, pictureInPictureMode } from './picture-in-picture'
|
|
14
|
+
export type { PictureInPictureController, PictureInPictureOptions, PictureInPictureMode } from './picture-in-picture'
|
|
@@ -8,26 +8,40 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const DEFAULT_MAX_WIDTH = 1280
|
|
11
|
+
/**
|
|
12
|
+
* Burn-in becomes the picture the viewer actually watches, so it gets a higher ceiling than the
|
|
13
|
+
* thumbnail-sized window does. A 4K source is still halved; see the note on `compositeWidth`.
|
|
14
|
+
*/
|
|
15
|
+
const BURN_IN_MAX_WIDTH = 1920
|
|
11
16
|
/** The frame callback fires only for presented frames, so a paused video needs its own repaint. */
|
|
12
17
|
const PAUSED_REPAINT_INTERVAL = 250
|
|
18
|
+
/** Long enough for a paused mirror to present one frame of a fresh seek before it stops again. */
|
|
19
|
+
const FLUSH_FRAME_MS = 100
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `window` opens a real picture in picture window. `burn-in` cannot, and instead paints the
|
|
23
|
+
* composite into the player itself so the browser's OWN picture in picture control carries the
|
|
24
|
+
* subtitles with it.
|
|
25
|
+
*/
|
|
26
|
+
export type PictureInPictureMode = 'window' | 'burn-in'
|
|
13
27
|
|
|
14
28
|
export type PictureInPictureOptions = {
|
|
15
29
|
video: HTMLVideoElement
|
|
16
30
|
/** The subtitle canvas. jassub sizes it to the video's content rect, so it maps 1:1. */
|
|
17
31
|
canvas: HTMLCanvasElement
|
|
18
32
|
maxWidth?: number
|
|
19
|
-
/** Where the
|
|
33
|
+
/** Where the mirror is mounted. It must be in the document. Defaults to the video's parent. */
|
|
20
34
|
container?: HTMLElement
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
fallback?: () => Promise<void>
|
|
35
|
+
/** Overrides detection. Passing a mode the browser cannot honour is the caller's problem. */
|
|
36
|
+
mode?: PictureInPictureMode
|
|
37
|
+
/** Burn-in only, so the chrome can light the control up and say what happened. */
|
|
38
|
+
onBurnedInChange?: (burnedIn: boolean) => void
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export type PictureInPictureController = {
|
|
29
42
|
toggle: () => Promise<void>
|
|
30
43
|
destroy: () => void
|
|
44
|
+
mode: PictureInPictureMode
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
type Session = {
|
|
@@ -37,26 +51,64 @@ type Session = {
|
|
|
37
51
|
mirror: HTMLVideoElement
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
const
|
|
41
|
-
typeof HTMLCanvasElement !== 'undefined' &&
|
|
42
|
-
|
|
54
|
+
const canComposite = () =>
|
|
55
|
+
typeof HTMLCanvasElement !== 'undefined' && 'captureStream' in HTMLCanvasElement.prototype
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `pictureInPictureEnabled` alone is not enough: a permissions policy can withhold it on a browser
|
|
59
|
+
* that implements the API perfectly well, and the prototype method is not suppressible. Requiring
|
|
60
|
+
* both keeps a restricted frame out of `burn-in`, which is for browsers that CANNOT open a window
|
|
61
|
+
* rather than for ones that are merely not allowed to.
|
|
62
|
+
*/
|
|
63
|
+
const canOpenWindow = () =>
|
|
43
64
|
typeof document !== 'undefined' &&
|
|
44
|
-
|
|
65
|
+
typeof HTMLVideoElement !== 'undefined' &&
|
|
66
|
+
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function' &&
|
|
67
|
+
!!document.pictureInPictureEnabled
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gecko, by a capability no other engine has and no policy can take away. A UA string would be
|
|
71
|
+
* wrong twice over: it lies, and this has to stop applying the day Firefox ships the real API.
|
|
72
|
+
*/
|
|
73
|
+
const isGecko = () =>
|
|
74
|
+
typeof HTMLVideoElement !== 'undefined' && 'mozCaptureStream' in HTMLVideoElement.prototype
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* null means offer no control at all. That covers Safari, whose picture in picture is
|
|
78
|
+
* `webkitSetPresentationMode` and not the W3C API, so neither arm here can drive it. A dead button
|
|
79
|
+
* is worse than no button.
|
|
80
|
+
*/
|
|
81
|
+
export const pictureInPictureMode = (): PictureInPictureMode | null => {
|
|
82
|
+
if (!canComposite()) return null
|
|
83
|
+
if (canOpenWindow()) return 'window'
|
|
84
|
+
if (isGecko()) return 'burn-in'
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
45
87
|
|
|
46
88
|
/** Takes over the document's Media Session play/pause handlers while a session is open. */
|
|
47
89
|
export const createPictureInPicture = (options: PictureInPictureOptions): PictureInPictureController => {
|
|
48
|
-
const { video, canvas
|
|
90
|
+
const { video, canvas } = options
|
|
91
|
+
const mode = options.mode ?? pictureInPictureMode() ?? 'window'
|
|
92
|
+
const maxWidth = options.maxWidth ?? (mode === 'burn-in' ? BURN_IN_MAX_WIDTH : DEFAULT_MAX_WIDTH)
|
|
49
93
|
|
|
50
94
|
let session: Session | undefined
|
|
51
95
|
let handle: number | undefined
|
|
52
96
|
let pausedRepaint: ReturnType<typeof setInterval> | undefined
|
|
53
97
|
let syncing = false
|
|
98
|
+
/** Burn-in only: a deliberate play/pause pair to push one frame, which must not reach the video. */
|
|
99
|
+
let flushing = false
|
|
54
100
|
// Nothing can tell a session is being built until `enter` resolves, so without this a second click
|
|
55
|
-
// starts a second pipeline whose timer and mirror are then unreachable.
|
|
56
|
-
|
|
101
|
+
// starts a second pipeline whose timer and mirror are then unreachable. It covers EVERY branch of
|
|
102
|
+
// `toggle`, not only the one that builds a session.
|
|
103
|
+
let busy = false
|
|
57
104
|
let destroyed = false
|
|
105
|
+
let restore: (() => void) | undefined
|
|
58
106
|
|
|
59
|
-
|
|
107
|
+
// In burn-in there is no window to be in: the session itself IS the mode being on.
|
|
108
|
+
const active = () =>
|
|
109
|
+
mode === 'burn-in'
|
|
110
|
+
? !!session
|
|
111
|
+
: !!session && document.pictureInPictureElement === session.mirror
|
|
60
112
|
|
|
61
113
|
const draw = (composite: HTMLCanvasElement, context: CanvasRenderingContext2D) => {
|
|
62
114
|
context.drawImage(video, 0, 0, composite.width, composite.height)
|
|
@@ -98,15 +150,23 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
98
150
|
}
|
|
99
151
|
|
|
100
152
|
const onMirrorPlay = () => {
|
|
101
|
-
if (syncing || !video.paused) return
|
|
153
|
+
if (syncing || flushing || !video.paused) return
|
|
102
154
|
void video.play().catch(() => {})
|
|
103
155
|
}
|
|
104
156
|
|
|
105
|
-
|
|
106
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Reached on a browser whose window controls drive the popped out element directly instead of going
|
|
159
|
+
* through the Media Session. Firefox is that browser, and in burn-in its window is driving the
|
|
160
|
+
* element the viewer is watching, so its pause IS the intent and has to stick.
|
|
161
|
+
*
|
|
162
|
+
* Both arms are idempotent by comparing against the video rather than by holding a flag: if the
|
|
163
|
+
* video is already paused this event is the echo of our own `mirror.pause()` and means nothing.
|
|
164
|
+
*/
|
|
107
165
|
const onMirrorPause = () => {
|
|
108
|
-
if (syncing) return
|
|
166
|
+
if (syncing || flushing) return
|
|
109
167
|
if (!video.paused) video.pause()
|
|
168
|
+
// In window mode the mirror must never be left paused; see `onVideoPause`.
|
|
169
|
+
if (mode === 'burn-in') return
|
|
110
170
|
syncing = true
|
|
111
171
|
void session?.mirror.play().catch(() => {}).finally(() => { syncing = false })
|
|
112
172
|
}
|
|
@@ -117,12 +177,34 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
117
177
|
}
|
|
118
178
|
|
|
119
179
|
/**
|
|
120
|
-
*
|
|
121
|
-
* its last frame: measured at 0 of 57,600 pixels moving on a seek. Transport
|
|
122
|
-
* the Media Session instead, which is what the window reads for its button.
|
|
180
|
+
* In WINDOW mode, never pause the mirror. A paused element stops rendering its MediaStream, so the
|
|
181
|
+
* window freezes on its last frame: measured at 0 of 57,600 pixels moving on a seek. Transport
|
|
182
|
+
* state travels through the Media Session instead, which is what the window reads for its button.
|
|
183
|
+
*
|
|
184
|
+
* In BURN-IN the mirror must follow the video exactly, or the browser's own window shows "playing"
|
|
185
|
+
* over a picture that is not moving and its pause button can never resume. A frozen mirror is
|
|
186
|
+
* correct there because the picture is not moving either; `onVideoSeeked` covers the one case where
|
|
187
|
+
* the frame has to change while paused.
|
|
123
188
|
*/
|
|
124
189
|
const onVideoPause = () => {
|
|
125
190
|
publishState()
|
|
191
|
+
if (mode === 'burn-in') session?.mirror.pause()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A paused mirror renders nothing, so a scrub would leave the old frame on screen forever. */
|
|
195
|
+
const onVideoSeeked = () => {
|
|
196
|
+
if (mode !== 'burn-in' || !session || !video.paused || flushing) return
|
|
197
|
+
flushing = true
|
|
198
|
+
const { mirror } = session
|
|
199
|
+
paint()
|
|
200
|
+
void mirror.play()
|
|
201
|
+
.then(() => new Promise<void>((resolve) => { setTimeout(resolve, FLUSH_FRAME_MS) }))
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
.finally(() => {
|
|
204
|
+
mirror.pause()
|
|
205
|
+
// the pause event is queued, so the guard has to outlive this tick
|
|
206
|
+
setTimeout(() => { flushing = false }, FLUSH_FRAME_MS)
|
|
207
|
+
})
|
|
126
208
|
}
|
|
127
209
|
|
|
128
210
|
const mediaSessionActions: [MediaSessionAction, MediaSessionActionHandler][] = [
|
|
@@ -146,7 +228,12 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
146
228
|
bindMediaSession(false)
|
|
147
229
|
video.removeEventListener('play', onVideoPlay)
|
|
148
230
|
video.removeEventListener('pause', onVideoPause)
|
|
149
|
-
|
|
231
|
+
video.removeEventListener('seeked', onVideoSeeked)
|
|
232
|
+
if (!session) {
|
|
233
|
+
// a burn-in that never built a session can still have swapped the picture
|
|
234
|
+
restore?.()
|
|
235
|
+
return
|
|
236
|
+
}
|
|
150
237
|
const { stream, mirror } = session
|
|
151
238
|
mirror.removeEventListener('play', onMirrorPlay)
|
|
152
239
|
mirror.removeEventListener('pause', onMirrorPause)
|
|
@@ -154,14 +241,33 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
154
241
|
for (const track of stream.getTracks()) track.stop()
|
|
155
242
|
mirror.remove()
|
|
156
243
|
session = undefined
|
|
244
|
+
// AFTER the mirror is gone. Restoring first puts the real picture and the overlay back while the
|
|
245
|
+
// composite is still mounted and painting, which is the doubled subtitles this mode exists to avoid.
|
|
246
|
+
restore?.()
|
|
157
247
|
}
|
|
158
248
|
|
|
159
249
|
const onLeave = () => teardown()
|
|
160
250
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Swap the composite in as the player's picture.
|
|
253
|
+
*
|
|
254
|
+
* The real element is dimmed, never `display: none`: jassub sizes the subtitle canvas from its
|
|
255
|
+
* `offsetWidth`/`offsetHeight`, and a collapsed box silently yields a composite with no subtitles
|
|
256
|
+
* in it, which is exactly the thing being asked for.
|
|
257
|
+
*/
|
|
258
|
+
const present = (mirror: HTMLVideoElement) => {
|
|
259
|
+
const videoOpacity = video.style.opacity
|
|
260
|
+
const canvasDisplay = canvas.style.display
|
|
261
|
+
mirror.style.cssText =
|
|
262
|
+
'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;z-index:1'
|
|
263
|
+
video.style.opacity = '0'
|
|
264
|
+
canvas.style.display = 'none'
|
|
265
|
+
restore = () => {
|
|
266
|
+
restore = undefined
|
|
267
|
+
video.style.opacity = videoOpacity
|
|
268
|
+
canvas.style.display = canvasDisplay
|
|
269
|
+
}
|
|
270
|
+
}
|
|
165
271
|
|
|
166
272
|
const enter = async () => {
|
|
167
273
|
// Sized off the intrinsic dimensions, never the layout, so a resize cannot resize a canvas that a
|
|
@@ -180,13 +286,17 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
180
286
|
const mirror = document.createElement('video')
|
|
181
287
|
mirror.muted = true
|
|
182
288
|
mirror.playsInline = true
|
|
183
|
-
|
|
289
|
+
// Burn-in gets its real size from `present`. Firefox will not offer its own control below 140px
|
|
290
|
+
// in either dimension, so the 1px parking spot the window arm uses can never qualify.
|
|
291
|
+
if (mode === 'burn-in') present(mirror)
|
|
292
|
+
else mirror.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;left:-1px;top:-1px'
|
|
184
293
|
|
|
185
294
|
// one frame before the capture starts, so the stream has content from its first moment
|
|
186
295
|
draw(composite, context)
|
|
187
296
|
|
|
188
|
-
|
|
189
|
-
|
|
297
|
+
const mine: Session = { composite, context, stream: composite.captureStream(), mirror }
|
|
298
|
+
session = mine
|
|
299
|
+
mirror.srcObject = mine.stream
|
|
190
300
|
;(options.container ?? video.parentElement ?? document.body).appendChild(mirror)
|
|
191
301
|
|
|
192
302
|
schedule()
|
|
@@ -203,48 +313,72 @@ export const createPictureInPicture = (options: PictureInPictureOptions): Pictur
|
|
|
203
313
|
})
|
|
204
314
|
}
|
|
205
315
|
|
|
316
|
+
// A destroy or a re-entered toggle can land inside that wait. Binding anyway would leave listeners
|
|
317
|
+
// and a Media Session handler pointing at a dead session for the rest of the page's life.
|
|
318
|
+
if (destroyed || session !== mine) {
|
|
319
|
+
mirror.remove()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
206
323
|
mirror.addEventListener('play', onMirrorPlay)
|
|
207
324
|
mirror.addEventListener('pause', onMirrorPause)
|
|
208
325
|
mirror.addEventListener('leavepictureinpicture', onLeave)
|
|
209
326
|
video.addEventListener('play', onVideoPlay)
|
|
210
327
|
video.addEventListener('pause', onVideoPause)
|
|
328
|
+
video.addEventListener('seeked', onVideoSeeked)
|
|
211
329
|
bindMediaSession(true)
|
|
212
330
|
publishState()
|
|
213
331
|
|
|
332
|
+
if (mode === 'burn-in') {
|
|
333
|
+
// No window to open: the browser's own control does that, on the element now on screen.
|
|
334
|
+
if (video.paused) mirror.pause()
|
|
335
|
+
options.onBurnedInChange?.(true)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
214
339
|
await mirror.requestPictureInPicture()
|
|
215
340
|
}
|
|
216
341
|
|
|
217
342
|
const toggle = async () => {
|
|
218
|
-
if (destroyed ||
|
|
219
|
-
|
|
220
|
-
await document.exitPictureInPicture().catch(() => {})
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
if (!supportsCompositing()) {
|
|
224
|
-
await fallback()
|
|
225
|
-
return
|
|
226
|
-
}
|
|
227
|
-
// another element, including our own last mirror, may still hold the single slot
|
|
228
|
-
if (document.pictureInPictureElement) await document.exitPictureInPicture().catch(() => {})
|
|
229
|
-
|
|
230
|
-
entering = true
|
|
343
|
+
if (destroyed || busy) return
|
|
344
|
+
busy = true
|
|
231
345
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
346
|
+
if (active()) {
|
|
347
|
+
if (mode === 'burn-in') {
|
|
348
|
+
teardown()
|
|
349
|
+
options.onBurnedInChange?.(false)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
await document.exitPictureInPicture().catch(() => {})
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
// another element, including our own last mirror, may still hold the single slot
|
|
356
|
+
if (mode !== 'burn-in' && document.pictureInPictureElement) {
|
|
357
|
+
await document.exitPictureInPicture().catch(() => {})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
await enter()
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// There is nowhere left to fall back TO. The old fallback called the React layer's own
|
|
364
|
+
// toggle, which is this function, so an unsupported browser recursed until the stack blew.
|
|
365
|
+
console.warn('subtitle compositing for picture in picture failed', error)
|
|
366
|
+
teardown()
|
|
367
|
+
if (mode === 'burn-in') options.onBurnedInChange?.(false)
|
|
368
|
+
}
|
|
237
369
|
} finally {
|
|
238
|
-
|
|
370
|
+
busy = false
|
|
239
371
|
}
|
|
240
372
|
}
|
|
241
373
|
|
|
242
374
|
return {
|
|
243
375
|
toggle,
|
|
376
|
+
mode,
|
|
244
377
|
destroy: () => {
|
|
245
378
|
destroyed = true
|
|
246
|
-
if (active()) void document.exitPictureInPicture().catch(() => {})
|
|
379
|
+
if (mode !== 'burn-in' && active()) void document.exitPictureInPicture().catch(() => {})
|
|
247
380
|
teardown()
|
|
381
|
+
if (mode === 'burn-in') options.onBurnedInChange?.(false)
|
|
248
382
|
},
|
|
249
383
|
}
|
|
250
384
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { css } from '@emotion/react'
|
|
3
|
+
|
|
4
|
+
import { fonts } from '../../utils/fonts'
|
|
5
|
+
import { usePlayer } from '../player'
|
|
6
|
+
|
|
7
|
+
/** Long enough to read twice, short enough not to sit over the picture. */
|
|
8
|
+
const VISIBLE_MS = 9000
|
|
9
|
+
|
|
10
|
+
const style = css`
|
|
11
|
+
position: absolute;
|
|
12
|
+
inset: 0;
|
|
13
|
+
z-index: 2;
|
|
14
|
+
pointer-events: none;
|
|
15
|
+
|
|
16
|
+
.card {
|
|
17
|
+
position: absolute;
|
|
18
|
+
/* Where Firefox draws its own toggle: right aligned, a little past the middle of the height. */
|
|
19
|
+
top: 52%;
|
|
20
|
+
right: calc(7 * var(--mp-unit));
|
|
21
|
+
transform: translateY(-50%);
|
|
22
|
+
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: calc(1.2 * var(--mp-unit));
|
|
26
|
+
|
|
27
|
+
max-width: min(calc(42 * var(--mp-unit)), 60%);
|
|
28
|
+
padding: calc(1.4 * var(--mp-unit)) calc(1.8 * var(--mp-unit));
|
|
29
|
+
border-radius: calc(0.8 * var(--mp-unit));
|
|
30
|
+
background-color: rgba(20, 20, 22, 0.94);
|
|
31
|
+
box-shadow: 0 0 calc(2 * var(--mp-unit)) rgba(0, 0, 0, 0.6);
|
|
32
|
+
|
|
33
|
+
opacity: 0;
|
|
34
|
+
transition: opacity 0.25s ease;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&.show .card {
|
|
38
|
+
opacity: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.words {
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
gap: calc(0.3 * var(--mp-unit));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.title {
|
|
48
|
+
${fonts.bMedium.bold}
|
|
49
|
+
color: #fff;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.body {
|
|
53
|
+
${fonts.bSmall.regular}
|
|
54
|
+
color: rgba(255, 255, 255, 0.75);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Points at the edge the browser's control lives on, which is the whole message. */
|
|
58
|
+
.arrow {
|
|
59
|
+
flex: none;
|
|
60
|
+
font-size: calc(2.6 * var(--mp-unit));
|
|
61
|
+
line-height: 1;
|
|
62
|
+
color: #6EA8FE;
|
|
63
|
+
}
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Says what to do next, because turning burn-in on looks like nothing happening.
|
|
68
|
+
*
|
|
69
|
+
* The composite is pixel identical to the picture it replaces, so without this the click has no
|
|
70
|
+
* visible result at all and the viewer has no way to learn that the browser's own picture in picture
|
|
71
|
+
* control is now the thing to press.
|
|
72
|
+
*/
|
|
73
|
+
export const BurnInHint = () => {
|
|
74
|
+
const burnedInSubtitles = usePlayer((state) => state.burnedInSubtitles)
|
|
75
|
+
const mode = usePlayer((state) => state.pictureInPictureMode)
|
|
76
|
+
const [show, setShow] = useState(false)
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (mode !== 'burn-in' || !burnedInSubtitles) {
|
|
80
|
+
setShow(false)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
setShow(true)
|
|
84
|
+
const timer = setTimeout(() => setShow(false), VISIBLE_MS)
|
|
85
|
+
return () => clearTimeout(timer)
|
|
86
|
+
}, [mode, burnedInSubtitles])
|
|
87
|
+
|
|
88
|
+
if (mode !== 'burn-in' || !burnedInSubtitles) return null
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div css={style} className={show ? 'show' : ''} role='status'>
|
|
92
|
+
<div className='card'>
|
|
93
|
+
<div className='words'>
|
|
94
|
+
<div className='title'>Subtitles are in the picture</div>
|
|
95
|
+
<div className='body'>
|
|
96
|
+
Now hover the video and click your browser's own pop out button
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div className='arrow' aria-hidden='true'>→</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default BurnInHint
|
|
@@ -7,6 +7,7 @@ import { css } from '@emotion/react'
|
|
|
7
7
|
import { usePlayer } from '../player'
|
|
8
8
|
import { Overlay } from './overlay'
|
|
9
9
|
import ControlBar from './control-bar'
|
|
10
|
+
import BurnInHint from './burn-in-hint'
|
|
10
11
|
|
|
11
12
|
const AUTO_HIDE_DELAY = 3_000
|
|
12
13
|
|
|
@@ -173,6 +174,8 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
|
|
|
173
174
|
</div>
|
|
174
175
|
))}
|
|
175
176
|
{controls === false ? null : <ControlBar />}
|
|
177
|
+
{/* Not tied to `hideUI`: it says what to do next, and it is on screen for nine seconds. */}
|
|
178
|
+
<BurnInHint />
|
|
176
179
|
<div className="video" onClick={onVideoClick}>
|
|
177
180
|
{onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
|
|
178
181
|
{children}
|