@banou/media-player 0.10.6 → 0.10.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.
@@ -0,0 +1,10 @@
1
+ import type { PassThroughPictureInPicture, PlayerMedia } from '../media';
2
+ import type { PassThroughControl } from '../source-feature';
3
+ /**
4
+ * The state behind a remote media's picture in picture control: whether the media is in picture in
5
+ * picture, and whether a click on the control is currently let through to the host.
6
+ *
7
+ * null unless the host opted in AND there is a media to follow AND the viewer points with something
8
+ * that hovers, so the chrome offers nothing otherwise.
9
+ */
10
+ export declare const usePassThroughPictureInPicture: (media: PlayerMedia | null, options: PassThroughPictureInPicture | undefined) => PassThroughControl | null;
@@ -59,8 +59,56 @@ export type PlayerMedia = EventTarget & {
59
59
  videoWidth?: number;
60
60
  videoHeight?: number;
61
61
  requestPictureInPicture?: () => Promise<unknown>;
62
+ /** What the control calls while the media is in picture in picture: see `PassThroughPictureInPicture`. */
63
+ exitPictureInPicture?: () => Promise<unknown>;
62
64
  requestFullscreen?: () => Promise<unknown>;
63
65
  };
66
+ /**
67
+ * Picture in picture for a media whose own document has to ask for it, opted into by the host.
68
+ *
69
+ * A browser opens picture in picture only for a request made while the document that OWNS the
70
+ * `<video>` is handling a user gesture, and a click in this document never counts for another one:
71
+ * Chrome 153 refused a request relayed on the click with `NotAllowedError`, and refused delegating
72
+ * the activation with `NotSupportedError` (measured 2026-09-26). So the click itself has to land in
73
+ * the media's document. While the pointer is over the control, the chrome lets a click through at the
74
+ * control's box, and the host makes whatever renders the media take that click and enter picture in
75
+ * picture from inside, where the click counts.
76
+ *
77
+ * Passing this is the host's promise that it does both. Without it the control is not offered at all
78
+ * for a media the player does not own, since a button whose click lands nowhere is worse than none.
79
+ *
80
+ * What the player promises in return:
81
+ * - The control follows the media's `enterpictureinpicture` and `leavepictureinpicture` events,
82
+ * which is the only state it reads.
83
+ * - While the media is in picture in picture a click on the control is an ordinary click here and
84
+ * calls the media's `exitPictureInPicture`, since leaving needs no gesture. Nothing is let through.
85
+ * - Once a click has gone through and taken focus into a frame inside the player, the control takes
86
+ * focus back, so the chrome's keyboard shortcuts keep working in this document rather than in the
87
+ * host's. This holds with the player itself nested in another page. Focus anywhere else is left
88
+ * alone.
89
+ *
90
+ * What it refuses:
91
+ * - A keyboard press on the control, which cannot reach the host's document, does nothing until the
92
+ * media is in picture in picture.
93
+ * - A media with no metadata yet (`readyState` below 1) or with an `error`: the control does not arm,
94
+ * so its click lands on the control and does nothing.
95
+ * - A finger. Only a pointer resting on the control arms it, and a tap presses as it arrives, so its
96
+ * click lands on the control and goes nowhere. No control is offered where the primary pointer
97
+ * cannot hover (`(hover: hover) and (pointer: fine)` fails: phones, tablets), and on a device that
98
+ * has both, a touch hides it until a pointer that hovers moves again.
99
+ */
100
+ export type PassThroughPictureInPicture = {
101
+ /**
102
+ * True while the pointer is over the control, and a click there goes to whatever the host renders
103
+ * under it. False once the pointer is back over the rest of the player, the media enters picture in
104
+ * picture, the host withdraws this option or the player unmounts. Called on a change only, and
105
+ * synchronously, so the host is ready before the next pointer event.
106
+ *
107
+ * The host makes its own element take pointer events while this is true (an iframe with
108
+ * `pointer-events: none` gets `auto`) and gives them back when it is false.
109
+ */
110
+ onArmedChange: (armed: boolean) => void;
111
+ };
64
112
  /**
65
113
  * Where the seekbar's hover previews come from.
66
114
  *
@@ -18,6 +18,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
18
18
  setHideUI: (hide: boolean) => void;
19
19
  hasMedia: boolean;
20
20
  togglePictureInPicture: (() => void) | null;
21
+ passThroughPictureInPicture: import("./source-feature").PassThroughControl | null;
21
22
  pictureInPictureMode: import("../engine").PictureInPictureMode | null;
22
23
  burnedInSubtitles: boolean;
23
24
  requestSeek?: (time: number) => void;
@@ -55,6 +55,41 @@ export type DownloadedRange = {
55
55
  startByteOffset: number;
56
56
  endByteOffset: number;
57
57
  };
58
+ /** A box in viewport coordinates, as `getBoundingClientRect` reports it. */
59
+ export type ViewportBox = {
60
+ left: number;
61
+ top: number;
62
+ width: number;
63
+ height: number;
64
+ };
65
+ /**
66
+ * The picture in picture control of a media the player does not own, which the host enters from its
67
+ * own document (see `PassThroughPictureInPicture`).
68
+ *
69
+ * Armed means a click on the control goes through it to whatever the host renders below, so the
70
+ * chrome lays a layer over everything else with a hole at `armed`: inside the hole every pointer event
71
+ * belongs to the host's document, and the layer is how this one learns the pointer has left it.
72
+ */
73
+ export type PassThroughControl = {
74
+ /** Between the media's `enterpictureinpicture` and `leavepictureinpicture`. */
75
+ active: boolean;
76
+ /** The control's box while a click on it is let through, undefined otherwise. */
77
+ armed?: ViewportBox;
78
+ /**
79
+ * Lets a click at `box` through to the host, and tells the host it is armed. For the pointer
80
+ * arriving on the control. Does nothing while the media has no metadata yet (`readyState` below 1)
81
+ * or has an `error`, since the host's request would be refused and the click lost with it.
82
+ */
83
+ arm: (box: ViewportBox) => void;
84
+ /**
85
+ * Takes the click back, and tells the host it is disarmed if it had been told it was armed. For the
86
+ * pointer leaving the control; also done here on entering picture in picture, on a touch, and when
87
+ * the control stops being offered.
88
+ */
89
+ disarm: () => void;
90
+ /** Leaves picture in picture through the media. */
91
+ exit: () => void;
92
+ };
58
93
  /**
59
94
  * Everything about the source that the chrome reads, carried on the player store next to the built-in
60
95
  * playback state so a component never has to know which of two channels owns a field.
@@ -133,12 +168,17 @@ export type SourceState = {
133
168
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
134
169
  *
135
170
  * null means the control is not offered at all. Locally that is "no element yet". For a media the
136
- * player does not own it means the source cannot do it, and the difference matters: the compositing
137
- * this does needs a local element to draw, and even a source that forwards the request cannot
138
- * report the resulting STATE back, since `document.pictureInPictureElement` is never a proxy. A
139
- * button that toggles nothing and never lights up is worse than no button.
171
+ * player does not own it is always null, because a request made from this document is refused: the
172
+ * compositing this does needs a local element to draw, and the browser wants a gesture in the
173
+ * media's own document. `passThroughPictureInPicture` is that arm's control instead.
140
174
  */
141
175
  togglePictureInPicture: (() => void) | null;
176
+ /**
177
+ * The picture in picture control for a media the player does not own, when the host opted in with
178
+ * `pictureInPicture` and the viewer points with something that hovers. null otherwise, which is
179
+ * always the case for a local source.
180
+ */
181
+ passThroughPictureInPicture: PassThroughControl | null;
142
182
  /**
143
183
  * Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
144
184
  * subtitles into the picture so the BROWSER'S own control carries them; the viewer presses that
@@ -264,12 +304,17 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
264
304
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
265
305
  *
266
306
  * null means the control is not offered at all. Locally that is "no element yet". For a media the
267
- * player does not own it means the source cannot do it, and the difference matters: the compositing
268
- * this does needs a local element to draw, and even a source that forwards the request cannot
269
- * report the resulting STATE back, since `document.pictureInPictureElement` is never a proxy. A
270
- * button that toggles nothing and never lights up is worse than no button.
307
+ * player does not own it is always null, because a request made from this document is refused: the
308
+ * compositing this does needs a local element to draw, and the browser wants a gesture in the
309
+ * media's own document. `passThroughPictureInPicture` is that arm's control instead.
271
310
  */
272
311
  togglePictureInPicture: (() => void) | null;
312
+ /**
313
+ * The picture in picture control for a media the player does not own, when the host opted in with
314
+ * `pictureInPicture` and the viewer points with something that hovers. null otherwise, which is
315
+ * always the case for a local source.
316
+ */
317
+ passThroughPictureInPicture: PassThroughControl | null;
273
318
  /**
274
319
  * Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
275
320
  * subtitles into the picture so the BROWSER'S own control carries them; the viewer presses that
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { MediaChapter } from '../engine';
3
3
  import type { DownloadedRange } from './source-feature';
4
- import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media';
4
+ import type { DelegatedTracks, ExternalThumbnails, PassThroughPictureInPicture, PlayerMedia } from './media';
5
5
  import type { ExposePlayerOptions } from '../remote';
6
6
  /**
7
7
  * The source, read a range at a time: the player never downloads the whole file. Both fields travel
@@ -162,6 +162,12 @@ export type MediaPlayerRemoteOptions = CommonOptions & {
162
162
  /** The source renders these; the player draws the menu and reports the pick. */
163
163
  subtitles?: DelegatedTracks;
164
164
  audioTracks?: DelegatedTracks;
165
+ /**
166
+ * Offer picture in picture, entered by the host from inside the media's own document. Off by
167
+ * default, and then there is no control at all. See `PassThroughPictureInPicture` for what the
168
+ * host takes on by passing it. Read by value, so it may be written inline.
169
+ */
170
+ pictureInPicture?: PassThroughPictureInPicture;
165
171
  };
166
172
  export type MediaPlayerOptions = MediaPlayerLocalOptions | MediaPlayerRemoteOptions;
167
173
  export declare const MediaPlayer: (options: MediaPlayerOptions & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.6",
3
+ "version": "0.10.7",
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",
package/src/lib/index.tsx CHANGED
@@ -14,6 +14,7 @@ export type {
14
14
  DelegatedSelection,
15
15
  DelegatedTracks,
16
16
  ExternalThumbnails,
17
+ PassThroughPictureInPicture,
17
18
  PlayerMedia,
18
19
  TimeRangesLike,
19
20
  } from './react/media'
@@ -24,7 +25,9 @@ export type { PlayerStore } from './react/player'
24
25
  // Source state lives on the player store next to the built-in playback state, so `usePlayer` is the
25
26
  // only hook the chrome needs.
26
27
  export { sourceFeature } from './react/source-feature'
27
- export type { DownloadedRange, PlaybackErrorEntry, SourceState } from './react/source-feature'
28
+ export type {
29
+ DownloadedRange, PassThroughControl, PlaybackErrorEntry, SourceState, ViewportBox,
30
+ } from './react/source-feature'
28
31
 
29
32
  export { useSeekThumbnails } from './react/hooks/use-thumbnails'
30
33
  export { usePictureInPicture } from './react/hooks/use-picture-in-picture'
@@ -1,5 +1,6 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
2
  import type { ReactNode, Ref } from 'react'
3
+ import type { ViewportBox } from '../source-feature'
3
4
 
4
5
  import { Children, Fragment, isValidElement, useEffect, useRef } from 'react'
5
6
  import { css } from '@emotion/react'
@@ -36,6 +37,13 @@ const overlayItemStyle = css`
36
37
  transition: opacity 0.1s cubic-bezier(.4,0,1,1), visibility 0.1s;
37
38
  `
38
39
 
40
+ /** The layer minus the control's box, both measured against the viewport. */
41
+ const holeAt = (hole: ViewportBox, layer: DOMRect) => {
42
+ const left = hole.left - layer.left
43
+ const top = hole.top - layer.top
44
+ return `path(evenodd, 'M0 0H${layer.width}V${layer.height}H0Z M${left} ${top}H${left + hole.width}V${top + hole.height}H${left}Z')`
45
+ }
46
+
39
47
  const style = css`
40
48
  position: relative;
41
49
  background-color: black;
@@ -63,6 +71,15 @@ const style = css`
63
71
  pointer-events: none;
64
72
  }
65
73
 
74
+ /* Over everything, the control bar included, while a click on the picture in picture control is let
75
+ through, with a hole at the control. Inside the hole every pointer event goes to the host's
76
+ document and this one hears nothing, so the pointer arriving on this layer is the only sign here
77
+ that it left the control. Named with its type for the specificity reason just above. */
78
+ & > div.pass-through {
79
+ inset: 0;
80
+ z-index: 5;
81
+ }
82
+
66
83
  canvas {
67
84
  height: 100%;
68
85
  width: 100%;
@@ -120,6 +137,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
120
137
  const hideUI = usePlayer((state) => state.hideUI)
121
138
  const setHideUI = usePlayer((state) => state.setHideUI)
122
139
  const hasMedia = usePlayer((state) => state.hasMedia)
140
+ const passThrough = usePlayer((state) => state.passThroughPictureInPicture)
123
141
  const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
124
142
  // a tap and a click mean different things, so the last pointer kind is remembered
125
143
  const lastPointerType = useRef<string>('mouse')
@@ -173,8 +191,12 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
173
191
  *
174
192
  * The mouse only. A finger has no resting position, and `elementFromPoint` on the last tap would
175
193
  * keep the controls up forever after one press.
194
+ *
195
+ * Armed, the pointer is on the picture in picture control by definition, and `elementFromPoint`
196
+ * answers with whatever the host put under it, since that is what takes the pointer meanwhile.
176
197
  */
177
198
  const pointerRestsOnAControl = () => {
199
+ if (player.passThroughPictureInPicture?.armed) return true
178
200
  if (lastPointerType.current !== 'mouse') return false
179
201
  const { x, y } = pointer.current
180
202
  if (!root.current || x < 0) return false
@@ -257,6 +279,15 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
257
279
  {/* Not tied to `hideUI`: it says what to do next, and it is on screen for nine seconds. */}
258
280
  <BurnInHint />
259
281
  <SkipChapter />
282
+ {passThrough?.armed && root.current
283
+ ? (
284
+ <div
285
+ className='pass-through'
286
+ style={{ clipPath: holeAt(passThrough.armed, root.current.getBoundingClientRect()) }}
287
+ onPointerOver={passThrough.disarm}
288
+ />
289
+ )
290
+ : null}
260
291
  <div className="video" onClick={onVideoClick}>
261
292
  {onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
262
293
  {children}
@@ -1,5 +1,5 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
- import { useCallback, useEffect, useState } from 'react'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
3
  import { css } from '@emotion/react'
4
4
  import { Maximize, Minimize, Pause, Play, RotateCcw } from 'react-feather'
5
5
 
@@ -19,6 +19,11 @@ import Sound from './sound'
19
19
 
20
20
  const VOLUME_STEP = 0.05
21
21
  const SEEK_STEP = 5
22
+ // Whether the click let through took focus into the host's frame is asked, not heard: nested in
23
+ // another page (stub's embed), this window never had focus, and the click fired neither a blur nor a
24
+ // focusin here (Chrome 153, 2026-09-26). So while armed, and for a second after, it is polled.
25
+ const REFOCUS_POLL = 50
26
+ const REFOCUS_AFTER = 1_000
22
27
 
23
28
  /**
24
29
  * Two stacked lines inside a tooltip.
@@ -126,7 +131,9 @@ const style = css`
126
131
 
127
132
  cursor: pointer;
128
133
 
129
- :hover {
134
+ /* .armed is the hover of a control letting its click through, which takes no pointer events
135
+ of its own and so is never :hover */
136
+ :hover, &.armed {
130
137
  background-color: ${colors.hover};
131
138
  }
132
139
 
@@ -179,11 +186,14 @@ export const ControlBar = () => {
179
186
  const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
180
187
  const pictureInPictureMode = usePlayer((state) => state.pictureInPictureMode)
181
188
  const burnedInSubtitles = usePlayer((state) => state.burnedInSubtitles)
189
+ const passThrough = usePlayer((state) => state.passThroughPictureInPicture)
182
190
  const subtitleTracks = usePlayer((state) => state.subtitleTracks)
183
191
  // Keyboard seeks go through the same door as the seek bar: data first, then the playhead. See
184
192
  // `requestSeek` on the source state for why an element that seeks into a hole is the problem.
185
193
  const requestSeek = usePlayer((state) => state.requestSeek)
186
194
  const [volumeElement, setVolumeElement] = useState<HTMLButtonElement | null>(null)
195
+ const passThroughButton = useRef<HTMLButtonElement>(null)
196
+ const refocus = useRef<ReturnType<typeof setInterval>>(undefined)
187
197
 
188
198
  const burnIn = pictureInPictureMode === 'burn-in'
189
199
  // Burning nothing in is a mode with no effect, so the control stays visible and goes dead rather
@@ -258,8 +268,47 @@ export const ControlBar = () => {
258
268
  return () => volumeElement.removeEventListener('wheel', handleWheel)
259
269
  }, [volumeElement, handleWheel])
260
270
 
271
+ // Moving too (not only entering): after leaving picture in picture the pointer is often still on
272
+ // the control, and entering fired long ago. Never for a finger, whose tap is already on its way to
273
+ // this button when it arrives, so arming would only flip the host's frame for nothing.
274
+ const armPassThrough = (event: React.PointerEvent<HTMLButtonElement>) => {
275
+ if (!passThrough || passThrough.active || passThrough.armed || event.pointerType === 'touch') return
276
+ const { left, top, width, height } = event.currentTarget.getBoundingClientRect()
277
+ passThrough.arm({ left, top, width, height })
278
+ }
279
+
280
+ // The click that went through took focus into the host's frame with it, and the shortcuts above
281
+ // listen on this window. Only a frame inside this player's picture counts, so a window blur while
282
+ // armed (Alt+Tab) or focus the viewer put elsewhere, a chat input say, is left where it is.
283
+ const armed = !!passThrough?.armed
284
+ useEffect(() => {
285
+ if (!armed) return
286
+ clearInterval(refocus.current)
287
+ let until = Infinity
288
+ const poll = setInterval(() => {
289
+ if (Date.now() > until) {
290
+ clearInterval(poll)
291
+ return
292
+ }
293
+ const button = passThroughButton.current
294
+ const focused = document.activeElement
295
+ if (button && focused?.tagName === 'IFRAME' && focused.closest('.video')?.parentElement?.contains(button)) {
296
+ button.focus({ preventScroll: true })
297
+ }
298
+ }, REFOCUS_POLL)
299
+ refocus.current = poll
300
+ // entering picture in picture disarms, which can come before the next tick
301
+ return () => { until = Date.now() + REFOCUS_AFTER }
302
+ }, [armed])
303
+ useEffect(() => () => clearInterval(refocus.current), [])
304
+
261
305
  return (
262
- <div css={style} style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : {} }}>
306
+ // Armed, the whole bar takes no pointer events, or the rows around the control would still catch
307
+ // the click at its box. The chrome's layer covers everything but the control meanwhile.
308
+ <div
309
+ css={style}
310
+ style={{ ...hideUI ? { opacity: '0', pointerEvents: 'none' } : armed ? { pointerEvents: 'none' } : {} }}
311
+ >
263
312
  <ProgressBar />
264
313
  <div className='actions'>
265
314
  <div className='left'>
@@ -305,7 +354,33 @@ export const ControlBar = () => {
305
354
  <ErrorsAction />
306
355
  <SubtitlesAction />
307
356
  <SettingsAction />
308
- {togglePictureInPicture
357
+ {passThrough
358
+ ? (
359
+ <TooltipDisplay
360
+ id='picture-in-picture'
361
+ tooltipPlace='top-end'
362
+ text={
363
+ <button
364
+ ref={passThroughButton}
365
+ className={passThrough.armed ? 'picture-in-picture armed' : 'picture-in-picture'}
366
+ type='button'
367
+ onPointerEnter={armPassThrough}
368
+ onPointerMove={armPassThrough}
369
+ // Out of picture in picture the click was meant for the host's document, so one
370
+ // that lands here instead (a key press) has nothing it could do.
371
+ onClick={passThrough.active ? passThrough.exit : undefined}
372
+ aria-label='Picture in picture'
373
+ aria-pressed={passThrough.active}
374
+ >
375
+ <img src={pictureInPicture} alt='' />
376
+ </button>
377
+ }
378
+ toolTipText={
379
+ <span>{passThrough.active ? 'Exit picture in picture' : 'Picture in picture'}</span>
380
+ }
381
+ />
382
+ )
383
+ : togglePictureInPicture
309
384
  ? (
310
385
  <TooltipDisplay
311
386
  id='picture-in-picture'
@@ -0,0 +1,113 @@
1
+ import type { PassThroughPictureInPicture, PlayerMedia } from '../media'
2
+ import type { PassThroughControl, ViewportBox } from '../source-feature'
3
+
4
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
5
+
6
+ // Arming needs a pointer that rests over the control before it presses, and a finger arrives and
7
+ // presses at once: tapped, the control armed and disarmed 11 to 33 ms later and the host's frame
8
+ // heard nothing (Chrome 153 with touch emulated, 2026-09-26). Touch emulation flips this query.
9
+ const HOVERING_POINTER = '(hover: hover) and (pointer: fine)'
10
+
11
+ // Asked on every render of every player, and jsdom has no matchMedia: stub's unit suite renders this
12
+ // player there and crashed with a ReferenceError (2026-09-26).
13
+ const hoveringPointerQuery = () => (typeof matchMedia === 'function' ? matchMedia(HOVERING_POINTER) : undefined)
14
+ const watchHoveringPointer = (onChange: () => void) => {
15
+ const query = hoveringPointerQuery()
16
+ query?.addEventListener('change', onChange)
17
+ return () => query?.removeEventListener('change', onChange)
18
+ }
19
+ const primaryPointerHovers = () => !!hoveringPointerQuery()?.matches
20
+ const noHoverOnTheServer = () => false
21
+
22
+ /**
23
+ * The state behind a remote media's picture in picture control: whether the media is in picture in
24
+ * picture, and whether a click on the control is currently let through to the host.
25
+ *
26
+ * null unless the host opted in AND there is a media to follow AND the viewer points with something
27
+ * that hovers, so the chrome offers nothing otherwise.
28
+ */
29
+ export const usePassThroughPictureInPicture = (
30
+ media: PlayerMedia | null,
31
+ options: PassThroughPictureInPicture | undefined,
32
+ ): PassThroughControl | null => {
33
+ const [active, setActive] = useState(false)
34
+ const [armed, setArmed] = useState<ViewportBox>()
35
+
36
+ // The LAST option given rather than the current one: a host that withdraws the option while armed
37
+ // still has to hear the false, or whatever it flipped to take the click stays flipped.
38
+ const host = useRef(options)
39
+ if (options) host.current = options
40
+ const told = useRef(false)
41
+ const tell = useCallback((on: boolean) => {
42
+ if (told.current === on) return
43
+ told.current = on
44
+ host.current?.onArmedChange(on)
45
+ }, [])
46
+
47
+ // Nothing to show yet, or a media that failed: the host's request would be refused, and the click
48
+ // lost with it, so it lands on the control instead.
49
+ const arm = useCallback((box: ViewportBox) => {
50
+ if (!media || media.readyState < 1 || media.error) return
51
+ setArmed(box)
52
+ tell(true)
53
+ }, [media, tell])
54
+ const disarm = useCallback(() => {
55
+ setArmed(undefined)
56
+ tell(false)
57
+ }, [tell])
58
+
59
+ // Followed while the control is hidden too, so it shows the right state when it comes back.
60
+ const followed = !!options && !!media
61
+ useEffect(() => {
62
+ if (!followed || !media) return
63
+ const enter = () => {
64
+ setActive(true)
65
+ disarm()
66
+ }
67
+ const leave = () => setActive(false)
68
+ media.addEventListener('enterpictureinpicture', enter)
69
+ media.addEventListener('leavepictureinpicture', leave)
70
+ return () => {
71
+ media.removeEventListener('enterpictureinpicture', enter)
72
+ media.removeEventListener('leavepictureinpicture', leave)
73
+ setActive(false)
74
+ disarm()
75
+ }
76
+ }, [media, followed, disarm])
77
+
78
+ // A touch screen laptop reports the hovering pointer as primary and takes taps as well, so a touch
79
+ // hides the control until a pointer that hovers moves again.
80
+ const hovers = useSyncExternalStore(watchHoveringPointer, primaryPointerHovers, noHoverOnTheServer)
81
+ const [touching, setTouching] = useState(false)
82
+ useEffect(() => {
83
+ if (!followed) return
84
+ const down = (event: PointerEvent) => {
85
+ if (event.pointerType === 'touch') setTouching(true)
86
+ }
87
+ const move = (event: PointerEvent) => {
88
+ if (event.pointerType !== 'touch') setTouching(false)
89
+ }
90
+ window.addEventListener('pointerdown', down, { capture: true, passive: true })
91
+ window.addEventListener('pointermove', move, { capture: true, passive: true })
92
+ return () => {
93
+ window.removeEventListener('pointerdown', down, { capture: true })
94
+ window.removeEventListener('pointermove', move, { capture: true })
95
+ }
96
+ }, [followed])
97
+
98
+ const offered = followed && hovers && !touching
99
+ useEffect(() => {
100
+ if (!offered) disarm()
101
+ }, [offered, disarm])
102
+
103
+ const exit = useCallback(() => {
104
+ Promise.resolve(media?.exitPictureInPicture?.()).catch((error) => {
105
+ console.warn('leaving picture in picture was refused', error)
106
+ })
107
+ }, [media])
108
+
109
+ return useMemo(
110
+ () => (offered ? { active, armed, arm, disarm, exit } : null),
111
+ [offered, active, armed, arm, disarm, exit],
112
+ )
113
+ }
@@ -63,9 +63,58 @@ export type PlayerMedia = EventTarget & {
63
63
  videoHeight?: number
64
64
 
65
65
  requestPictureInPicture?: () => Promise<unknown>
66
+ /** What the control calls while the media is in picture in picture: see `PassThroughPictureInPicture`. */
67
+ exitPictureInPicture?: () => Promise<unknown>
66
68
  requestFullscreen?: () => Promise<unknown>
67
69
  }
68
70
 
71
+ /**
72
+ * Picture in picture for a media whose own document has to ask for it, opted into by the host.
73
+ *
74
+ * A browser opens picture in picture only for a request made while the document that OWNS the
75
+ * `<video>` is handling a user gesture, and a click in this document never counts for another one:
76
+ * Chrome 153 refused a request relayed on the click with `NotAllowedError`, and refused delegating
77
+ * the activation with `NotSupportedError` (measured 2026-09-26). So the click itself has to land in
78
+ * the media's document. While the pointer is over the control, the chrome lets a click through at the
79
+ * control's box, and the host makes whatever renders the media take that click and enter picture in
80
+ * picture from inside, where the click counts.
81
+ *
82
+ * Passing this is the host's promise that it does both. Without it the control is not offered at all
83
+ * for a media the player does not own, since a button whose click lands nowhere is worse than none.
84
+ *
85
+ * What the player promises in return:
86
+ * - The control follows the media's `enterpictureinpicture` and `leavepictureinpicture` events,
87
+ * which is the only state it reads.
88
+ * - While the media is in picture in picture a click on the control is an ordinary click here and
89
+ * calls the media's `exitPictureInPicture`, since leaving needs no gesture. Nothing is let through.
90
+ * - Once a click has gone through and taken focus into a frame inside the player, the control takes
91
+ * focus back, so the chrome's keyboard shortcuts keep working in this document rather than in the
92
+ * host's. This holds with the player itself nested in another page. Focus anywhere else is left
93
+ * alone.
94
+ *
95
+ * What it refuses:
96
+ * - A keyboard press on the control, which cannot reach the host's document, does nothing until the
97
+ * media is in picture in picture.
98
+ * - A media with no metadata yet (`readyState` below 1) or with an `error`: the control does not arm,
99
+ * so its click lands on the control and does nothing.
100
+ * - A finger. Only a pointer resting on the control arms it, and a tap presses as it arrives, so its
101
+ * click lands on the control and goes nowhere. No control is offered where the primary pointer
102
+ * cannot hover (`(hover: hover) and (pointer: fine)` fails: phones, tablets), and on a device that
103
+ * has both, a touch hides it until a pointer that hovers moves again.
104
+ */
105
+ export type PassThroughPictureInPicture = {
106
+ /**
107
+ * True while the pointer is over the control, and a click there goes to whatever the host renders
108
+ * under it. False once the pointer is back over the rest of the player, the media enters picture in
109
+ * picture, the host withdraws this option or the player unmounts. Called on a change only, and
110
+ * synchronously, so the host is ready before the next pointer event.
111
+ *
112
+ * The host makes its own element take pointer events while this is true (an iframe with
113
+ * `pointer-events: none` gets `auto`) and gives them back when it is false.
114
+ */
115
+ onArmedChange: (armed: boolean) => void
116
+ }
117
+
69
118
  /**
70
119
  * Where the seekbar's hover previews come from.
71
120
  *