@banou/media-player 0.8.5 → 0.8.7
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-B6flRjLd.js} +149 -111
- package/dist/index.js +521 -364
- package/dist/react/components/burn-in-hint.d.ts +9 -0
- package/dist/react/components/icons.d.ts +8 -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/engine/playback.ts +28 -0
- 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 +76 -3
- package/src/lib/react/components/icons.tsx +15 -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 +15 -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;
|
|
@@ -2,3 +2,11 @@ import type { SVGProps } from 'react';
|
|
|
2
2
|
export declare const Captions: (props: SVGProps<SVGSVGElement>) => import("@emotion/react/jsx-runtime").JSX.Element;
|
|
3
3
|
/** The slash is Feather's own convention for an off state, the line `MicOff` and `BellOff` draw. */
|
|
4
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;
|
|
@@ -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
|
}
|
|
@@ -329,7 +329,35 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
329
329
|
videoElement.addEventListener('seeking', onSeeking)
|
|
330
330
|
teardown.push(() => videoElement.removeEventListener('seeking', onSeeking))
|
|
331
331
|
|
|
332
|
+
/**
|
|
333
|
+
* A terminal failure of the media element, which NOTHING here used to observe.
|
|
334
|
+
*
|
|
335
|
+
* `MediaError` is set once and the element never plays again, but the pump below kept running,
|
|
336
|
+
* and every `appendBuffer` after it throws `InvalidStateError: The HTMLMediaElement.error
|
|
337
|
+
* attribute is not null`. `updateSourceBuffer`'s chain deliberately survives a rejection, so that
|
|
338
|
+
* repeated forever, and `flushPending` swallows four attempts and rethrows the fifth. What
|
|
339
|
+
* reached the viewer was therefore the FIFTH `InvalidStateError` rather than the real cause, and
|
|
340
|
+
* the real cause never appeared anywhere at all.
|
|
341
|
+
*
|
|
342
|
+
* That cost a session: a decode failure presented as an append bug, which is a completely
|
|
343
|
+
* different place to look. Report what actually happened, then stop.
|
|
344
|
+
*/
|
|
345
|
+
let elementFailed = false
|
|
346
|
+
const onElementError = () => {
|
|
347
|
+
if (elementFailed) return
|
|
348
|
+
elementFailed = true
|
|
349
|
+
const error = videoElement.error
|
|
350
|
+
reportError(
|
|
351
|
+
new Error(`the media element failed: ${error?.message ?? 'unknown'}`, { cause: error }),
|
|
352
|
+
false,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
videoElement.addEventListener('error', onElementError)
|
|
356
|
+
teardown.push(() => videoElement.removeEventListener('error', onElementError))
|
|
357
|
+
|
|
332
358
|
const interval = setInterval(() => {
|
|
359
|
+
// Nothing below can succeed against a failed element, and retrying only buries the real error.
|
|
360
|
+
if (elementFailed || videoElement.error) return
|
|
333
361
|
evict().catch(() => {})
|
|
334
362
|
pump().catch((error) => reportError(error, false))
|
|
335
363
|
// every remove() puts the MediaSource back to 'open', so the end has to be re-armed
|
|
@@ -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}
|