@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.
- package/README.md +24 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +618 -474
- package/dist/react/hooks/use-pass-through-picture-in-picture.d.ts +10 -0
- package/dist/react/media.d.ts +48 -0
- package/dist/react/player.d.ts +2 -0
- package/dist/react/source-feature.d.ts +67 -8
- package/dist/react/video-player.d.ts +7 -1
- package/package.json +1 -1
- package/src/lib/index.tsx +4 -1
- package/src/lib/react/components/chrome.tsx +31 -0
- package/src/lib/react/components/control-bar.tsx +79 -4
- package/src/lib/react/components/progress-bar.tsx +30 -0
- package/src/lib/react/components/skip-chapter.tsx +7 -1
- package/src/lib/react/hooks/use-pass-through-picture-in-picture.ts +113 -0
- package/src/lib/react/media.ts +49 -0
- package/src/lib/react/source-feature.ts +55 -4
- package/src/lib/react/video-player.tsx +17 -1
- package/src/lib/utils/chapters.ts +7 -3
package/src/lib/react/media.ts
CHANGED
|
@@ -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
|
|
149
|
-
* this does needs a local element to draw, and
|
|
150
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
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
|
}
|