@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.
@@ -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
- /** Picture in picture with the subtitles composited in. */
2
- export declare const usePictureInPicture: (video: HTMLVideoElement | null, canvas: HTMLCanvasElement | null) => (() => void) | null;
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;
@@ -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. */
@@ -2,6 +2,8 @@ declare const colors: {
2
2
  primary: string;
3
3
  secondary: string;
4
4
  hover: string;
5
+ /** A control that is ON. Distinct from `hover`, which the pointer is already painting. */
6
+ accent: string;
5
7
  borderPrimary: string;
6
8
  backgroundTooltip: string;
7
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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 hidden mirror is mounted. It must be in the document. Defaults to the video's parent. */
33
+ /** Where the mirror is mounted. It must be in the document. Defaults to the video's parent. */
20
34
  container?: HTMLElement
21
- /**
22
- * Toggle used when compositing is unavailable or fails. The React layer passes the player store's
23
- * own action, which handles Safari and exits fullscreen first; the default here does neither.
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 supportsCompositing = () =>
41
- typeof HTMLCanvasElement !== 'undefined' &&
42
- 'captureStream' in HTMLCanvasElement.prototype &&
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
- document.pictureInPictureEnabled
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, maxWidth = DEFAULT_MAX_WIDTH } = options
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
- let entering = false
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
- const active = () => !!session && document.pictureInPictureElement === session.mirror
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
- // Reached on a browser whose window controls pause the mirror directly instead of going through the
106
- // Media Session. Forward the intent, then put the mirror back to work.
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
- * NEVER pause the mirror. A paused element stops rendering its MediaStream, so the window freezes on
121
- * its last frame: measured at 0 of 57,600 pixels moving on a seek. Transport state travels through
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
- if (!session) return
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
- const fallback = options.fallback ?? (async () => {
162
- if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
163
- else await video.requestPictureInPicture()
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
- mirror.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;left:-1px;top:-1px'
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
- session = { composite, context, stream: composite.captureStream(), mirror }
189
- mirror.srcObject = session.stream
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 || entering) return
219
- if (active()) {
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
- await enter()
233
- } catch (error) {
234
- console.warn('subtitle compositing for picture in picture failed, falling back', error)
235
- teardown()
236
- await fallback().catch(() => {})
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
- entering = false
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&apos;s own pop out button
97
+ </div>
98
+ </div>
99
+ <div className='arrow' aria-hidden='true'>&rarr;</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}