@banou/media-player 0.10.5 → 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.
@@ -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
  *
@@ -61,6 +61,43 @@ export type DownloadedRange = {
61
61
  endByteOffset: number
62
62
  }
63
63
 
64
+ /** A box in viewport coordinates, as `getBoundingClientRect` reports it. */
65
+ export type ViewportBox = {
66
+ left: number
67
+ top: number
68
+ width: number
69
+ height: number
70
+ }
71
+
72
+ /**
73
+ * The picture in picture control of a media the player does not own, which the host enters from its
74
+ * own document (see `PassThroughPictureInPicture`).
75
+ *
76
+ * Armed means a click on the control goes through it to whatever the host renders below, so the
77
+ * chrome lays a layer over everything else with a hole at `armed`: inside the hole every pointer event
78
+ * belongs to the host's document, and the layer is how this one learns the pointer has left it.
79
+ */
80
+ export type PassThroughControl = {
81
+ /** Between the media's `enterpictureinpicture` and `leavepictureinpicture`. */
82
+ active: boolean
83
+ /** The control's box while a click on it is let through, undefined otherwise. */
84
+ armed?: ViewportBox
85
+ /**
86
+ * Lets a click at `box` through to the host, and tells the host it is armed. For the pointer
87
+ * arriving on the control. Does nothing while the media has no metadata yet (`readyState` below 1)
88
+ * or has an `error`, since the host's request would be refused and the click lost with it.
89
+ */
90
+ arm: (box: ViewportBox) => void
91
+ /**
92
+ * Takes the click back, and tells the host it is disarmed if it had been told it was armed. For the
93
+ * pointer leaving the control; also done here on entering picture in picture, on a touch, and when
94
+ * the control stops being offered.
95
+ */
96
+ disarm: () => void
97
+ /** Leaves picture in picture through the media. */
98
+ exit: () => void
99
+ }
100
+
64
101
  /**
65
102
  * Everything about the source that the chrome reads, carried on the player store next to the built-in
66
103
  * playback state so a component never has to know which of two channels owns a field.
@@ -145,12 +182,17 @@ export type SourceState = {
145
182
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
146
183
  *
147
184
  * null means the control is not offered at all. Locally that is "no element yet". For a media the
148
- * player does not own it means the source cannot do it, and the difference matters: the compositing
149
- * this does needs a local element to draw, and even a source that forwards the request cannot
150
- * report the resulting STATE back, since `document.pictureInPictureElement` is never a proxy. A
151
- * button that toggles nothing and never lights up is worse than no button.
185
+ * player does not own it is always null, because a request made from this document is refused: the
186
+ * compositing this does needs a local element to draw, and the browser wants a gesture in the
187
+ * media's own document. `passThroughPictureInPicture` is that arm's control instead.
152
188
  */
153
189
  togglePictureInPicture: (() => void) | null
190
+ /**
191
+ * The picture in picture control for a media the player does not own, when the host opted in with
192
+ * `pictureInPicture` and the viewer points with something that hovers. null otherwise, which is
193
+ * always the case for a local source.
194
+ */
195
+ passThroughPictureInPicture: PassThroughControl | null
154
196
 
155
197
  /**
156
198
  * Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
@@ -226,6 +268,7 @@ const initialState: SourceState = {
226
268
  setHideUI: () => {},
227
269
  hasMedia: false,
228
270
  togglePictureInPicture: null,
271
+ passThroughPictureInPicture: null,
229
272
  pictureInPictureMode: null,
230
273
  burnedInSubtitles: false,
231
274
  playbackError: null,
@@ -2,7 +2,7 @@
2
2
  import type { ReactNode } from 'react'
3
3
  import type { MediaChapter } from '../engine'
4
4
  import type { DownloadedRange } from './source-feature'
5
- import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media'
5
+ import type { DelegatedTracks, ExternalThumbnails, PassThroughPictureInPicture, PlayerMedia } from './media'
6
6
  import type { ExposePlayerOptions } from '../remote'
7
7
 
8
8
  import { useEffect, useRef, useState } from 'react'
@@ -13,6 +13,7 @@ import { Player, usePlayer } from './player'
13
13
  import { usePlayback } from './hooks/use-playback'
14
14
  import { useSeekThumbnails } from './hooks/use-thumbnails'
15
15
  import { usePictureInPicture } from './hooks/use-picture-in-picture'
16
+ import { usePassThroughPictureInPicture } from './hooks/use-pass-through-picture-in-picture'
16
17
  import Chrome from './components/chrome'
17
18
 
18
19
  /**
@@ -185,6 +186,12 @@ export type MediaPlayerRemoteOptions =
185
186
  /** The source renders these; the player draws the menu and reports the pick. */
186
187
  subtitles?: DelegatedTracks
187
188
  audioTracks?: DelegatedTracks
189
+ /**
190
+ * Offer picture in picture, entered by the host from inside the media's own document. Off by
191
+ * default, and then there is no control at all. See `PassThroughPictureInPicture` for what the
192
+ * host takes on by passing it. Read by value, so it may be written inline.
193
+ */
194
+ pictureInPicture?: PassThroughPictureInPicture
188
195
  }
189
196
 
190
197
  export type MediaPlayerOptions = MediaPlayerLocalOptions | MediaPlayerRemoteOptions
@@ -264,6 +271,10 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
264
271
  mode: pictureInPictureMode,
265
272
  burnedIn: burnedInSubtitles,
266
273
  } = usePictureInPicture(video, subtitleLayer)
274
+ const passThroughPictureInPicture = usePassThroughPictureInPicture(
275
+ remote?.media ?? null,
276
+ remote?.pictureInPicture,
277
+ )
267
278
 
268
279
  // Subscribed rather than read off the store, because it is a no-op until the media element
269
280
  // attaches: when attach swaps in the real setter the identity changes and these publish again.
@@ -295,6 +306,11 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
295
306
  burnedInSubtitles,
296
307
  ])
297
308
 
309
+ // its own effect: it changes on every hover of the control, and nothing else here does
310
+ useEffect(() => {
311
+ setSourceState({ passThroughPictureInPicture })
312
+ }, [setSourceState, passThroughPictureInPicture])
313
+
298
314
  // A delegated track list writes the same store fields the engine writes, so the menus never learn
299
315
  // which arm they are showing. Only the writer differs: here the pick is forwarded to whoever owns
300
316
  // the document, and it renders the result itself.