@banou/media-player 0.10.6 → 0.10.8

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.
@@ -102,6 +139,13 @@ export type SourceState = {
102
139
  * A no-op on a source that brings its own storyboard, and until the generator has booted.
103
140
  */
104
141
  requestThumbnail: (time: number | undefined) => void
142
+ /**
143
+ * True while the seekbar shows what is under the pointer: the time, the chapter and the thumbnail.
144
+ *
145
+ * Written by the seekbar when that opens and closes, never per move, for the reason above. Read by
146
+ * whatever else the chrome draws in the space above the bar, which steps aside meanwhile.
147
+ */
148
+ seekPreview: boolean
105
149
 
106
150
  /**
107
151
  * Both selectors may answer with a promise, and the menu waits on it.
@@ -145,12 +189,17 @@ export type SourceState = {
145
189
  * Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
146
190
  *
147
191
  * 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.
192
+ * player does not own it is always null, because a request made from this document is refused: the
193
+ * compositing this does needs a local element to draw, and the browser wants a gesture in the
194
+ * media's own document. `passThroughPictureInPicture` is that arm's control instead.
152
195
  */
153
196
  togglePictureInPicture: (() => void) | null
197
+ /**
198
+ * The picture in picture control for a media the player does not own, when the host opted in with
199
+ * `pictureInPicture` and the viewer points with something that hovers. null otherwise, which is
200
+ * always the case for a local source.
201
+ */
202
+ passThroughPictureInPicture: PassThroughControl | null
154
203
 
155
204
  /**
156
205
  * Which shape the control takes. `window` opens one. `burn-in` cannot, and instead paints the
@@ -216,6 +265,7 @@ const initialState: SourceState = {
216
265
  chapters: [],
217
266
  thumbnails: [],
218
267
  requestThumbnail: () => {},
268
+ seekPreview: false,
219
269
  subtitleTracks: [],
220
270
  selectedSubtitleTrack: undefined,
221
271
  selectSubtitleTrack: () => {},
@@ -226,6 +276,7 @@ const initialState: SourceState = {
226
276
  setHideUI: () => {},
227
277
  hasMedia: false,
228
278
  togglePictureInPicture: null,
279
+ passThroughPictureInPicture: null,
229
280
  pictureInPictureMode: null,
230
281
  burnedInSubtitles: false,
231
282
  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.
@@ -151,7 +151,7 @@ const LABEL_SEPARATORS = /[-:|~/\\[\]()「」『』【】,.!?"'’]|\s+by\s+/u
151
151
  /** Titles a container writes when it has nothing to say, which must never look like a marker. */
152
152
  const isPlaceholder = (title: string): boolean =>
153
153
  // "Chapter 07", and the timestamps one muxer writes as titles, which are 300 markers in the sample
154
- /^chapter\s*\d+$/i.test(title.trim()) || /^\d{1,2}:\d{2}:\d{2}([.,]\d+)?$/.test(title.trim())
154
+ !title.trim() || /^chapter\s*\d+$/i.test(title.trim()) || /^\d{1,2}:\d{2}:\d{2}([.,]\d+)?$/.test(title.trim())
155
155
 
156
156
  const normalise = (title: string): string =>
157
157
  (title.trim().toLowerCase().split(LABEL_SEPARATORS)[0] ?? '').trim().replace(/\s+/gu, ' ')
@@ -297,6 +297,10 @@ export const classifyChapters = (chapters: MediaChapter[]): (ChapterKind | undef
297
297
  return guess && !plain.includes(guess) ? guess : undefined
298
298
  })
299
299
 
300
- // shape is the last resort, and only for a file whose titles said nothing whatsoever
301
- return named.some(Boolean) ? named : guessFromShape(chapters)
300
+ /*
301
+ * Shape is the last resort, and only for a file where no person named anything. A name that is not
302
+ * a theme still answers for its chapter: a theme-length Recap in the first half would otherwise be
303
+ * offered as the opening, and a Preview in the second as the ending.
304
+ */
305
+ return chapters.every(({ title }) => isPlaceholder(title)) ? guessFromShape(chapters) : named
302
306
  }