@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.
- package/README.md +25 -1
- package/dist/engine/index.js +1 -1
- package/dist/{engine-CB5J5kah.js → engine-C6PdWBah.js} +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +600 -464
- 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 +1 -0
- package/dist/react/source-feature.d.ts +53 -8
- package/dist/react/video-player.d.ts +7 -1
- package/dist/remote/index.js +2 -2
- package/package.json +7 -6
- 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 +23 -17
- 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 +47 -4
- package/src/lib/react/video-player.tsx +17 -1
|
@@ -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;
|
package/dist/react/media.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/react/player.d.ts
CHANGED
|
@@ -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
|
|
137
|
-
* this does needs a local element to draw, and
|
|
138
|
-
*
|
|
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
|
|
268
|
-
* this does needs a local element to draw, and
|
|
269
|
-
*
|
|
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/dist/remote/index.js
CHANGED
|
@@ -134,13 +134,13 @@ var t = [
|
|
|
134
134
|
},
|
|
135
135
|
set: (e, t, r) => {
|
|
136
136
|
let i = p.get(e);
|
|
137
|
-
if (
|
|
137
|
+
if (i && n.includes(t)) try {
|
|
138
138
|
i[t] = r;
|
|
139
139
|
} catch {}
|
|
140
140
|
},
|
|
141
141
|
call: async (e, t) => {
|
|
142
142
|
let n = p.get(e);
|
|
143
|
-
if (
|
|
143
|
+
if (n && r.includes(t)) try {
|
|
144
144
|
t === "play" ? await n.play() : t === "pause" ? n.pause() : n.load?.();
|
|
145
145
|
} finally {
|
|
146
146
|
C(e, "timeupdate");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banou/media-player",
|
|
3
|
-
"version": "0.10.
|
|
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",
|
|
@@ -60,21 +60,22 @@
|
|
|
60
60
|
"@types/react": "^19.0.0",
|
|
61
61
|
"@types/react-dom": "^19.0.0",
|
|
62
62
|
"@vitejs/plugin-react": "^6.0.3",
|
|
63
|
-
"@vitest/browser-playwright": "
|
|
63
|
+
"@vitest/browser-playwright": "4.1.11",
|
|
64
64
|
"playwright": "^1.62.1",
|
|
65
65
|
"react": "^19.0.0",
|
|
66
66
|
"react-dom": "^19.0.0",
|
|
67
67
|
"shx": "^0.3.4",
|
|
68
68
|
"typescript": "^7.0.2",
|
|
69
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.
|
|
70
|
-
"vite-plus": "0.
|
|
69
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
|
|
70
|
+
"vite-plus": "0.3.3",
|
|
71
71
|
"vitest-browser-react": "^2.2.0"
|
|
72
72
|
},
|
|
73
73
|
"overrides": {
|
|
74
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.
|
|
74
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
|
|
75
|
+
"vitest": "4.1.11"
|
|
75
76
|
},
|
|
76
77
|
"repository": {
|
|
77
78
|
"type": "git",
|
|
78
|
-
"url": "git+https://github.com/
|
|
79
|
+
"url": "git+https://github.com/banou26/media-player.git"
|
|
79
80
|
}
|
|
80
81
|
}
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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'
|
|
@@ -241,6 +241,7 @@ export const ProgressBar = () => {
|
|
|
241
241
|
const duration = usePlayer((state) => state.duration)
|
|
242
242
|
const size = usePlayer((state) => state.size)
|
|
243
243
|
const downloadedRanges = usePlayer((state) => state.downloadedRanges)
|
|
244
|
+
const buffered = usePlayer((state) => state.buffered)
|
|
244
245
|
const indexes = usePlayer((state) => state.indexes)
|
|
245
246
|
const thumbnails = usePlayer((state) => state.thumbnails)
|
|
246
247
|
const thumbnailAt = usePlayer((state) => state.thumbnailAt)
|
|
@@ -357,25 +358,30 @@ export const ProgressBar = () => {
|
|
|
357
358
|
// duration is 0 until metadata lands, which is never a divisor
|
|
358
359
|
const timePercentage = (time: number) => duration ? (time / duration) * 100 : 0
|
|
359
360
|
|
|
361
|
+
const loadedPart = (key: number, start: number, end: number) => {
|
|
362
|
+
const rangeDuration = Number((end - start).toFixed(4)) // to prevent cases like `0.9995140832939585`
|
|
363
|
+
const left = start * 100
|
|
364
|
+
return (
|
|
365
|
+
<div key={key} className="loaded-part" style={{ transform: `scaleX(${rangeDuration})`, marginLeft: `${left}%` }}></div>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
360
369
|
// download percentage is not time percentage: headers and fonts can run to tens of megabytes
|
|
361
370
|
const loadedParts = useMemo(() =>
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
})
|
|
377
|
-
?? [],
|
|
378
|
-
[duration, indexes.length, downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
|
|
371
|
+
// a media the player does not own brings no bytes, so the element's own buffered time is all there is
|
|
372
|
+
size === undefined
|
|
373
|
+
? (duration ? buffered.map(([start, end], i) => loadedPart(i, start / duration, end / duration)) : [])
|
|
374
|
+
: downloadedRanges
|
|
375
|
+
?.map((range, i) => {
|
|
376
|
+
if (!size || !duration) return null
|
|
377
|
+
const matchingIndexes = indexes.filter(index => range.startByteOffset <= index.pos && index.pos <= range.endByteOffset)
|
|
378
|
+
const firstIndex = matchingIndexes.at(0)
|
|
379
|
+
const lastIndex = matchingIndexes.at(-1)
|
|
380
|
+
if (!firstIndex || !lastIndex) return null
|
|
381
|
+
return loadedPart(i, firstIndex.timestamp / duration, lastIndex.timestamp / duration)
|
|
382
|
+
})
|
|
383
|
+
?? [],
|
|
384
|
+
[duration, size, size === undefined ? buffered : undefined, indexes.length, downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
|
|
379
385
|
)
|
|
380
386
|
|
|
381
387
|
|
|
@@ -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
|
+
}
|