@oxyhq/bloom 1.3.0 → 1.5.0
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/docs/media-flight.mdx +77 -1
- package/lib/commonjs/media-flight/MediaFlightHost.js +2 -0
- package/lib/commonjs/media-flight/MediaFlightHost.js.map +1 -1
- package/lib/commonjs/media-flight/MediaFlightHost.web.js +3 -1
- package/lib/commonjs/media-flight/MediaFlightHost.web.js.map +1 -1
- package/lib/commonjs/media-flight/MediaFlightLayer.web.js +49 -24
- package/lib/commonjs/media-flight/MediaFlightLayer.web.js.map +1 -1
- package/lib/commonjs/media-flight/MediaSurface.js +38 -5
- package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
- package/lib/commonjs/media-flight/constants.js +23 -1
- package/lib/commonjs/media-flight/constants.js.map +1 -1
- package/lib/commonjs/media-flight/media-node.web.js +29 -6
- package/lib/commonjs/media-flight/media-node.web.js.map +1 -1
- package/lib/commonjs/media-flight/store.js +4 -1
- package/lib/commonjs/media-flight/store.js.map +1 -1
- package/lib/module/media-flight/MediaFlightHost.js +2 -0
- package/lib/module/media-flight/MediaFlightHost.js.map +1 -1
- package/lib/module/media-flight/MediaFlightHost.web.js +3 -1
- package/lib/module/media-flight/MediaFlightHost.web.js.map +1 -1
- package/lib/module/media-flight/MediaFlightLayer.web.js +50 -25
- package/lib/module/media-flight/MediaFlightLayer.web.js.map +1 -1
- package/lib/module/media-flight/MediaSurface.js +39 -6
- package/lib/module/media-flight/MediaSurface.js.map +1 -1
- package/lib/module/media-flight/constants.js +22 -0
- package/lib/module/media-flight/constants.js.map +1 -1
- package/lib/module/media-flight/media-node.web.js +29 -6
- package/lib/module/media-flight/media-node.web.js.map +1 -1
- package/lib/module/media-flight/store.js +4 -1
- package/lib/module/media-flight/store.js.map +1 -1
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts +1 -1
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts +1 -1
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +13 -2
- package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/constants.d.ts +20 -0
- package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/media-node.web.d.ts +17 -3
- package/lib/typescript/commonjs/media-flight/media-node.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/store.d.ts +4 -1
- package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/types.d.ts +49 -0
- package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/MediaFlightHost.d.ts +1 -1
- package/lib/typescript/module/media-flight/MediaFlightHost.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts +1 -1
- package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/MediaSurface.d.ts +13 -2
- package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/constants.d.ts +20 -0
- package/lib/typescript/module/media-flight/constants.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/media-node.web.d.ts +17 -3
- package/lib/typescript/module/media-flight/media-node.web.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/store.d.ts +4 -1
- package/lib/typescript/module/media-flight/store.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/types.d.ts +49 -0
- package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/media-flight/MediaFlightHost.tsx +2 -0
- package/src/media-flight/MediaFlightHost.web.tsx +3 -2
- package/src/media-flight/MediaFlightLayer.web.tsx +58 -38
- package/src/media-flight/MediaSurface.tsx +62 -8
- package/src/media-flight/constants.ts +22 -0
- package/src/media-flight/media-node.web.ts +48 -10
- package/src/media-flight/store.ts +4 -1
- package/src/media-flight/types.ts +51 -0
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ export function MediaFlightHost({
|
|
|
23
23
|
content,
|
|
24
24
|
style,
|
|
25
25
|
contentFit = 'cover',
|
|
26
|
+
renderVideo,
|
|
26
27
|
nativeControls = false,
|
|
27
28
|
accessibilityLabel,
|
|
28
29
|
surfaceType,
|
|
@@ -41,6 +42,7 @@ export function MediaFlightHost({
|
|
|
41
42
|
<MediaSurface
|
|
42
43
|
content={content}
|
|
43
44
|
contentFit={contentFit}
|
|
45
|
+
renderVideo={renderVideo}
|
|
44
46
|
nativeControls={nativeControls}
|
|
45
47
|
accessibilityLabel={accessibilityLabel}
|
|
46
48
|
surfaceType={surfaceType}
|
|
@@ -30,6 +30,7 @@ export function MediaFlightHost({
|
|
|
30
30
|
content,
|
|
31
31
|
style,
|
|
32
32
|
contentFit = 'cover',
|
|
33
|
+
renderVideo,
|
|
33
34
|
nativeControls = false,
|
|
34
35
|
accessibilityLabel,
|
|
35
36
|
pointerEvents,
|
|
@@ -48,8 +49,8 @@ export function MediaFlightHost({
|
|
|
48
49
|
);
|
|
49
50
|
|
|
50
51
|
const render = useMemo<MediaNodeRender>(
|
|
51
|
-
() => ({ content, contentFit, nativeControls, accessibilityLabel, flightId }),
|
|
52
|
-
[content, contentFit, nativeControls, accessibilityLabel, flightId],
|
|
52
|
+
() => ({ content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId }),
|
|
53
|
+
[content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId],
|
|
53
54
|
);
|
|
54
55
|
const renderRef = useRef(render);
|
|
55
56
|
|
|
@@ -30,8 +30,6 @@ import {
|
|
|
30
30
|
useCallback,
|
|
31
31
|
useEffect,
|
|
32
32
|
useLayoutEffect,
|
|
33
|
-
useMemo,
|
|
34
|
-
useRef,
|
|
35
33
|
useState,
|
|
36
34
|
useSyncExternalStore,
|
|
37
35
|
} from 'react';
|
|
@@ -39,6 +37,7 @@ import { StyleSheet, type View } from 'react-native';
|
|
|
39
37
|
import { createPortal } from 'react-dom';
|
|
40
38
|
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
|
|
41
39
|
|
|
40
|
+
import { SLOT_FILL_TOLERANCE_PX } from './constants';
|
|
42
41
|
import { OverlayRoot } from '../overlay';
|
|
43
42
|
import { Portal } from '../portal/index.web';
|
|
44
43
|
import { MediaSurface, type MediaSurfaceStyle } from './MediaSurface';
|
|
@@ -48,7 +47,6 @@ import {
|
|
|
48
47
|
getMediaNodes,
|
|
49
48
|
releaseMediaNode,
|
|
50
49
|
subscribeToMediaNodes,
|
|
51
|
-
type MediaNodeRender,
|
|
52
50
|
type MediaNodeView,
|
|
53
51
|
} from './media-node.web';
|
|
54
52
|
import { getFlights, notifySurfaceMounted, subscribeToFlights } from './store';
|
|
@@ -91,11 +89,28 @@ MediaFlightLayer.displayName = 'MediaFlightLayer';
|
|
|
91
89
|
|
|
92
90
|
/** One media element, painted into the wrapper its id owns, and never again. */
|
|
93
91
|
function SharedMediaNode({ node }: { node: MediaNodeView }) {
|
|
94
|
-
const { content, contentFit, nativeControls, accessibilityLabel, flightId } = node.render;
|
|
92
|
+
const { content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId } = node.render;
|
|
93
|
+
|
|
94
|
+
// DOES THE MEDIA ACTUALLY FILL ITS BOX?
|
|
95
|
+
//
|
|
96
|
+
// Bloom hands a `renderVideo` slot a style that sizes a replaced element, and
|
|
97
|
+
// a slot that forgets to spread it paints a 300x150 `<video>` inside whatever
|
|
98
|
+
// box it was given — correct in every other respect, wrong on screen, and
|
|
99
|
+
// reported by nothing. That defect shipped once from Bloom's own view and
|
|
100
|
+
// cost a day to find; a consumer writing the slot can reproduce it in a line.
|
|
101
|
+
//
|
|
102
|
+
// Measured one frame after the commit, because layout has to have happened.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (process.env.NODE_ENV === 'production' || renderVideo === undefined) return undefined;
|
|
105
|
+
const frame = requestAnimationFrame(() => warnIfNotFilling(node.wrapper));
|
|
106
|
+
return () => cancelAnimationFrame(frame);
|
|
107
|
+
}, [node.wrapper, renderVideo]);
|
|
108
|
+
|
|
95
109
|
return createPortal(
|
|
96
110
|
<MediaSurface
|
|
97
111
|
content={content}
|
|
98
112
|
contentFit={contentFit}
|
|
113
|
+
renderVideo={renderVideo}
|
|
99
114
|
nativeControls={nativeControls}
|
|
100
115
|
accessibilityLabel={accessibilityLabel}
|
|
101
116
|
flightId={flightId}
|
|
@@ -105,6 +120,37 @@ function SharedMediaNode({ node }: { node: MediaNodeView }) {
|
|
|
105
120
|
);
|
|
106
121
|
}
|
|
107
122
|
|
|
123
|
+
let hasWarnedAboutFill = false;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One warning per module lifetime, and none in production — same mechanism as
|
|
127
|
+
* `warnExpoVideoUnavailable`.
|
|
128
|
+
*
|
|
129
|
+
* It compares the media element with the WRAPPER, not with a fixed size: the
|
|
130
|
+
* invariant is "the media fills the box it was given", and a box that happens
|
|
131
|
+
* to be 300x150 is not a defect.
|
|
132
|
+
*/
|
|
133
|
+
function warnIfNotFilling(wrapper: HTMLElement): void {
|
|
134
|
+
if (hasWarnedAboutFill || !wrapper.isConnected) return;
|
|
135
|
+
const media = wrapper.querySelector('video');
|
|
136
|
+
if (media === null) return;
|
|
137
|
+
const box = wrapper.getBoundingClientRect();
|
|
138
|
+
const painted = media.getBoundingClientRect();
|
|
139
|
+
if (box.width === 0 || box.height === 0) return;
|
|
140
|
+
const off =
|
|
141
|
+
Math.abs(painted.width - box.width) > SLOT_FILL_TOLERANCE_PX ||
|
|
142
|
+
Math.abs(painted.height - box.height) > SLOT_FILL_TOLERANCE_PX;
|
|
143
|
+
if (!off) return;
|
|
144
|
+
hasWarnedAboutFill = true;
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.warn(
|
|
147
|
+
`[Bloom] A \`renderVideo\` element is ${Math.round(painted.width)}x${Math.round(painted.height)} ` +
|
|
148
|
+
`inside a ${Math.round(box.width)}x${Math.round(box.height)} box. A DOM \`<video>\` is a ` +
|
|
149
|
+
'REPLACED element: without an explicit size it paints at its intrinsic 300x150 whatever ' +
|
|
150
|
+
'its inset says. Spread the `style` Bloom passes the slot.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
108
154
|
/**
|
|
109
155
|
* One flying surface. See `MediaFlightLayer.tsx` for why the size is animated
|
|
110
156
|
* rather than scaled.
|
|
@@ -137,21 +183,9 @@ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: b
|
|
|
137
183
|
|
|
138
184
|
const box = [styles.box, { left: to.x, top: to.y, borderRadius: cornerRadius }, boxStyle];
|
|
139
185
|
|
|
140
|
-
const render = useMemo<MediaNodeRender>(
|
|
141
|
-
() => ({
|
|
142
|
-
content,
|
|
143
|
-
contentFit,
|
|
144
|
-
nativeControls: false,
|
|
145
|
-
accessibilityLabel: undefined,
|
|
146
|
-
// A flying surface never hands off to itself.
|
|
147
|
-
flightId: undefined,
|
|
148
|
-
}),
|
|
149
|
-
[content, contentFit],
|
|
150
|
-
);
|
|
151
|
-
|
|
152
186
|
// The shared node is already painting this media. The flight is then a BOX
|
|
153
187
|
// that borrows it for the length of the leg, never a second copy.
|
|
154
|
-
if (shared) return <FlyingSharedNode id={id} style={box}
|
|
188
|
+
if (shared) return <FlyingSharedNode id={id} style={box} />;
|
|
155
189
|
|
|
156
190
|
return (
|
|
157
191
|
<MediaSurface
|
|
@@ -178,33 +212,19 @@ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: b
|
|
|
178
212
|
* is inert here — a descendant asking for `auto` does not override a `none`
|
|
179
213
|
* ancestor.
|
|
180
214
|
*/
|
|
181
|
-
function FlyingSharedNode({
|
|
182
|
-
id,
|
|
183
|
-
style,
|
|
184
|
-
render,
|
|
185
|
-
}: {
|
|
186
|
-
id: string;
|
|
187
|
-
style: MediaSurfaceStyle;
|
|
188
|
-
render: MediaNodeRender;
|
|
189
|
-
}) {
|
|
215
|
+
function FlyingSharedNode({ id, style }: { id: string; style: MediaSurfaceStyle }) {
|
|
190
216
|
const [node, setNode] = useState<View | null>(null);
|
|
191
217
|
const setBox = useCallback((next: View | null) => setNode(next), []);
|
|
192
|
-
const renderRef = useRef(render);
|
|
193
|
-
|
|
194
|
-
// Declared first so it runs first on mount, and afterwards is what pushes
|
|
195
|
-
// prop changes into a live claim IN PLACE — releasing and re-claiming would
|
|
196
|
-
// move the node away and back, and a move away is a removal.
|
|
197
|
-
useLayoutEffect(() => {
|
|
198
|
-
renderRef.current = render;
|
|
199
|
-
const el = node as unknown as HTMLElement | null;
|
|
200
|
-
if (el !== null) claimMediaNode(id, el, FLIGHT_RANK, render);
|
|
201
|
-
}, [id, node, render]);
|
|
202
218
|
|
|
203
|
-
//
|
|
219
|
+
// A POSITION-ONLY claim: it says where the node lives for the length of the
|
|
220
|
+
// leg and nothing about what is painted in it. A flight that published a
|
|
221
|
+
// render of its own would replace the hosts' — and swap a consumer's own
|
|
222
|
+
// element out and back, which is two remounts in the middle of the one
|
|
223
|
+
// operation whose whole point is that the element is never rebuilt.
|
|
204
224
|
useLayoutEffect(() => {
|
|
205
225
|
const el = node as unknown as HTMLElement | null;
|
|
206
226
|
if (el === null) return undefined;
|
|
207
|
-
claimMediaNode(id, el, FLIGHT_RANK
|
|
227
|
+
claimMediaNode(id, el, FLIGHT_RANK);
|
|
208
228
|
return () => releaseMediaNode(id, el, FLIGHT_RANK);
|
|
209
229
|
}, [id, node]);
|
|
210
230
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* platform video surface the way it rounds an image, and the transform lands on
|
|
14
14
|
* a node reanimated is allowed to drive (expo-video's own host is not one).
|
|
15
15
|
*/
|
|
16
|
-
import { memo, useCallback, useState, type ComponentProps } from 'react';
|
|
16
|
+
import { memo, useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
|
|
17
17
|
import {
|
|
18
18
|
Platform,
|
|
19
19
|
StyleSheet,
|
|
@@ -25,8 +25,9 @@ import {
|
|
|
25
25
|
import { Image } from 'expo-image';
|
|
26
26
|
import Animated from 'react-native-reanimated';
|
|
27
27
|
|
|
28
|
+
import { SLOT_IDENTITY_CHURN_LIMIT } from './constants';
|
|
28
29
|
import { handOffFlight } from './store';
|
|
29
|
-
import type { MediaSurfaceContent } from './types';
|
|
30
|
+
import type { MediaSurfaceContent, MediaVideoSlot } from './types';
|
|
30
31
|
import {
|
|
31
32
|
loadExpoVideo,
|
|
32
33
|
warnExpoVideoUnavailable,
|
|
@@ -70,6 +71,14 @@ export interface MediaSurfaceProps {
|
|
|
70
71
|
* is every property a flying box relies on.
|
|
71
72
|
*/
|
|
72
73
|
surfaceType?: VideoSurfaceType;
|
|
74
|
+
/**
|
|
75
|
+
* Paint the video yourself, keeping your own `ref` and expo-video props.
|
|
76
|
+
*
|
|
77
|
+
* When this is given, Bloom builds no `VideoView` at all and the optional
|
|
78
|
+
* peer is never loaded on this path — the consumer already imported
|
|
79
|
+
* expo-video to write the slot. See {@link MediaVideoSlot}.
|
|
80
|
+
*/
|
|
81
|
+
renderVideo?: MediaVideoSlot;
|
|
73
82
|
/** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
|
|
74
83
|
nativeControls?: boolean;
|
|
75
84
|
accessibilityLabel?: string;
|
|
@@ -107,7 +116,10 @@ export interface MediaSurfaceProps {
|
|
|
107
116
|
* algorithm pauses WITHOUT firing `pause` (measured in real Chrome, with
|
|
108
117
|
* controls), so the element goes quiet and its later removal fires nothing.
|
|
109
118
|
* Unbinding alone does not help: `unmountVideoView` never clears the handler
|
|
110
|
-
* it installed. See `releaseFlight
|
|
119
|
+
* it installed. See `releaseFlight`, and
|
|
120
|
+
* `scripts/probe-expo-video-listener-leak.mjs`, which measures that in real
|
|
121
|
+
* Chrome against expo-video's real class — including that grafting the
|
|
122
|
+
* missing `_removeListeners` into it makes the leak go away.
|
|
111
123
|
*/
|
|
112
124
|
detached?: boolean;
|
|
113
125
|
}
|
|
@@ -125,6 +137,7 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
125
137
|
style,
|
|
126
138
|
contentFit = 'contain',
|
|
127
139
|
surfaceType = 'textureView',
|
|
140
|
+
renderVideo,
|
|
128
141
|
nativeControls = false,
|
|
129
142
|
accessibilityLabel,
|
|
130
143
|
pointerEvents,
|
|
@@ -136,6 +149,18 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
136
149
|
// supports.
|
|
137
150
|
const [mountedSurfaceType] = useState(surfaceType);
|
|
138
151
|
|
|
152
|
+
// A slot rebuilt on every render republishes this surface to the layer on
|
|
153
|
+
// every render, which nothing at runtime reports — the picture is correct and
|
|
154
|
+
// the app is doing work in proportion to how often its rows re-render. The
|
|
155
|
+
// counter lives in an effect rather than in render: a render-phase ref write
|
|
156
|
+
// makes the React Compiler bail on the whole component.
|
|
157
|
+
const slotChurn = useRef(0);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (renderVideo === undefined) return;
|
|
160
|
+
slotChurn.current += 1;
|
|
161
|
+
if (slotChurn.current === SLOT_IDENTITY_CHURN_LIMIT) warnSlotNotMemoised();
|
|
162
|
+
}, [renderVideo]);
|
|
163
|
+
|
|
139
164
|
// Both arms report the same fact — "there is a picture here now" — because the
|
|
140
165
|
// destination of a flight can be either, and a caller should not have to know
|
|
141
166
|
// which one it wired. expo-video raises `onFirstFrameRender` from `loadeddata`
|
|
@@ -145,10 +170,12 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
145
170
|
}, [flightId]);
|
|
146
171
|
|
|
147
172
|
const still = content.kind === 'video' ? content.poster : content.uri;
|
|
148
|
-
// Loaded for the video arm only, and only when
|
|
149
|
-
// surface must never make an app resolve an optional
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
// Loaded for the video arm only, and only when Bloom is the one building the
|
|
174
|
+
// element — an image surface must never make an app resolve an optional
|
|
175
|
+
// native peer, and neither must a consumer that brought its own view.
|
|
176
|
+
const expoVideo =
|
|
177
|
+
content.kind === 'video' && renderVideo === undefined ? loadExpoVideo() : null;
|
|
178
|
+
if (content.kind === 'video' && renderVideo === undefined && expoVideo === null) {
|
|
152
179
|
// Degrade to the poster rather than to nothing: a black hole where a video
|
|
153
180
|
// should be reads as a broken app, a still frame reads as a video that has
|
|
154
181
|
// not started. The warning is what makes the difference visible to the
|
|
@@ -178,7 +205,16 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
178
205
|
{...webDraggableProps}
|
|
179
206
|
/>
|
|
180
207
|
)}
|
|
181
|
-
{content.kind === 'video' &&
|
|
208
|
+
{content.kind === 'video' && renderVideo !== undefined
|
|
209
|
+
? renderVideo({
|
|
210
|
+
player: detached ? null : content.player,
|
|
211
|
+
// The same style Bloom's own view gets, for the same reason: a
|
|
212
|
+
// replaced element that is not told its size paints at 300x150.
|
|
213
|
+
style: [StyleSheet.absoluteFill, styles.fillReplaced],
|
|
214
|
+
contentFit,
|
|
215
|
+
})
|
|
216
|
+
: null}
|
|
217
|
+
{content.kind === 'video' && renderVideo === undefined && expoVideo !== null ? (
|
|
182
218
|
<expoVideo.VideoView
|
|
183
219
|
player={detached ? null : content.player}
|
|
184
220
|
contentFit={contentFit}
|
|
@@ -205,6 +241,24 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
205
241
|
|
|
206
242
|
MediaSurface.displayName = 'MediaSurface';
|
|
207
243
|
|
|
244
|
+
let hasWarnedAboutSlotChurn = false;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* One warning per module lifetime, and none in production — same mechanism as
|
|
248
|
+
* `warnExpoVideoUnavailable`.
|
|
249
|
+
*/
|
|
250
|
+
function warnSlotNotMemoised(): void {
|
|
251
|
+
if (process.env.NODE_ENV === 'production' || hasWarnedAboutSlotChurn) return;
|
|
252
|
+
hasWarnedAboutSlotChurn = true;
|
|
253
|
+
// eslint-disable-next-line no-console
|
|
254
|
+
console.warn(
|
|
255
|
+
`[Bloom] A \`renderVideo\` slot changed identity ${SLOT_IDENTITY_CHURN_LIMIT} times. ` +
|
|
256
|
+
'It is part of what a media host publishes to the flight layer and is compared by ' +
|
|
257
|
+
'identity, so a slot rebuilt on every render republishes the surface on every render. ' +
|
|
258
|
+
'Wrap it in `useCallback` with the props it actually reads.',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
208
262
|
/**
|
|
209
263
|
* The STILL of a media item — a video's poster, or the image itself — and never
|
|
210
264
|
* a video view.
|
|
@@ -30,3 +30,25 @@ export const SURFACE_MOUNT_TIMEOUT_MS = 250;
|
|
|
30
30
|
* `flyBack(id)` and `releaseFlight(id)` are the escape hatches, and they are
|
|
31
31
|
* the caller's to use.
|
|
32
32
|
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How many times a video slot may change identity before Bloom says so.
|
|
36
|
+
*
|
|
37
|
+
* `renderVideo` is part of what a host publishes to the layer and is compared
|
|
38
|
+
* by identity, so an unmemoised slot republishes on every render of the row it
|
|
39
|
+
* lives in — silently, and only under load. Five is well past any legitimate
|
|
40
|
+
* cause (a mute toggle, a fit change) and far below the churn of a slot rebuilt
|
|
41
|
+
* per render in a scrolling feed.
|
|
42
|
+
*/
|
|
43
|
+
export const SLOT_IDENTITY_CHURN_LIMIT = 5;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How far a media element may sit from the box it is supposed to fill, in px,
|
|
47
|
+
* before Bloom says so.
|
|
48
|
+
*
|
|
49
|
+
* Two, not zero: subpixel layout and a box mid-animation both land within a
|
|
50
|
+
* pixel, and the defect this catches is not subtle — a `<video>` that never
|
|
51
|
+
* received Bloom's sizing style paints at its intrinsic 300x150 inside whatever
|
|
52
|
+
* box it was given.
|
|
53
|
+
*/
|
|
54
|
+
export const SLOT_FILL_TOLERANCE_PX = 2;
|
|
@@ -60,12 +60,14 @@
|
|
|
60
60
|
* DETACHED would pause.
|
|
61
61
|
*/
|
|
62
62
|
import { hasFlight } from './store';
|
|
63
|
-
import type { MediaSurfaceContent } from './types';
|
|
63
|
+
import type { MediaSurfaceContent, MediaVideoSlot } from './types';
|
|
64
64
|
|
|
65
65
|
/** What the layer paints into a shared node, as its current holder wants it. */
|
|
66
66
|
export interface MediaNodeRender {
|
|
67
67
|
content: MediaSurfaceContent;
|
|
68
68
|
contentFit: 'contain' | 'cover';
|
|
69
|
+
/** The consumer's own video element, if it brought one. Compared by identity. */
|
|
70
|
+
renderVideo: MediaVideoSlot | undefined;
|
|
69
71
|
nativeControls: boolean;
|
|
70
72
|
accessibilityLabel: string | undefined;
|
|
71
73
|
/** Forwarded to the media, so a consumer keeps its own first-frame signal. */
|
|
@@ -81,7 +83,19 @@ interface MediaNodeClaim {
|
|
|
81
83
|
rank: number;
|
|
82
84
|
/** Registration order, so the most recent host wins among equals. */
|
|
83
85
|
seq: number;
|
|
84
|
-
|
|
86
|
+
/**
|
|
87
|
+
* What this claimant wants painted — or `undefined` for a claim that decides
|
|
88
|
+
* only WHERE the node lives.
|
|
89
|
+
*
|
|
90
|
+
* The flight is the second kind, and that distinction is load-bearing. A
|
|
91
|
+
* flight that published a render of its own would replace whatever the hosts
|
|
92
|
+
* were painting for the length of the leg, and if a consumer brought its own
|
|
93
|
+
* element (`renderVideo`) the flight would swap it out and back — two
|
|
94
|
+
* remounts, in the middle of the one operation whose entire purpose is that
|
|
95
|
+
* the element is never rebuilt. Measured: the consumer's `<video>` ended the
|
|
96
|
+
* flight as a different node, with the original left disconnected.
|
|
97
|
+
*/
|
|
98
|
+
render: MediaNodeRender | undefined;
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
interface MediaNodeRecord {
|
|
@@ -168,20 +182,42 @@ function makeWrapper(): HTMLDivElement {
|
|
|
168
182
|
return wrapper;
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
function recordFor(id: string, render: MediaNodeRender): MediaNodeRecord {
|
|
185
|
+
function recordFor(id: string, render: MediaNodeRender | undefined): MediaNodeRecord {
|
|
172
186
|
const reg = registry();
|
|
173
187
|
let record = reg.nodes.get(id);
|
|
174
188
|
if (!record) {
|
|
175
|
-
|
|
189
|
+
// A position-only claim can be the first one in: a flight can take off
|
|
190
|
+
// before the host it flies from has committed. `EMPTY_RENDER` paints
|
|
191
|
+
// nothing until a host says what, which is the honest state.
|
|
192
|
+
record = { wrapper: makeWrapper(), claims: [], render: render ?? EMPTY_RENDER };
|
|
176
193
|
reg.nodes.set(id, record);
|
|
177
194
|
}
|
|
178
195
|
return record;
|
|
179
196
|
}
|
|
180
197
|
|
|
198
|
+
/** Nothing to paint yet. Replaced by the first host claim that arrives. */
|
|
199
|
+
const EMPTY_RENDER: MediaNodeRender = {
|
|
200
|
+
content: { uri: '' },
|
|
201
|
+
contentFit: 'cover',
|
|
202
|
+
renderVideo: undefined,
|
|
203
|
+
nativeControls: false,
|
|
204
|
+
accessibilityLabel: undefined,
|
|
205
|
+
flightId: undefined,
|
|
206
|
+
};
|
|
207
|
+
|
|
181
208
|
/** The claim that currently holds the node: highest rank, then most recent. */
|
|
182
209
|
function topClaim(record: MediaNodeRecord): MediaNodeClaim | null {
|
|
210
|
+
return pick(record.claims);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The highest-ranked claim that says what to PAINT. See `MediaNodeClaim.render`. */
|
|
214
|
+
function topRender(record: MediaNodeRecord): MediaNodeClaim | null {
|
|
215
|
+
return pick(record.claims.filter((claim) => claim.render !== undefined));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pick(claims: readonly MediaNodeClaim[]): MediaNodeClaim | null {
|
|
183
219
|
let best: MediaNodeClaim | null = null;
|
|
184
|
-
for (const claim of
|
|
220
|
+
for (const claim of claims) {
|
|
185
221
|
if (best === null || claim.rank > best.rank || (claim.rank === best.rank && claim.seq > best.seq)) {
|
|
186
222
|
best = claim;
|
|
187
223
|
}
|
|
@@ -206,6 +242,7 @@ function place(record: MediaNodeRecord): void {
|
|
|
206
242
|
|
|
207
243
|
function sameRender(a: MediaNodeRender, b: MediaNodeRender): boolean {
|
|
208
244
|
if (a.contentFit !== b.contentFit) return false;
|
|
245
|
+
if (a.renderVideo !== b.renderVideo) return false;
|
|
209
246
|
if (a.nativeControls !== b.nativeControls) return false;
|
|
210
247
|
if (a.accessibilityLabel !== b.accessibilityLabel) return false;
|
|
211
248
|
if (a.flightId !== b.flightId) return false;
|
|
@@ -221,7 +258,7 @@ function sameRender(a: MediaNodeRender, b: MediaNodeRender): boolean {
|
|
|
221
258
|
function publish(reg: NodeRegistry): void {
|
|
222
259
|
const next: MediaNodeView[] = [];
|
|
223
260
|
for (const [id, record] of reg.nodes) {
|
|
224
|
-
record.render =
|
|
261
|
+
record.render = topRender(record)?.render ?? record.render;
|
|
225
262
|
next.push({ id, wrapper: record.wrapper, render: record.render });
|
|
226
263
|
}
|
|
227
264
|
const previous = reg.snapshot;
|
|
@@ -256,15 +293,16 @@ export function claimMediaNode(
|
|
|
256
293
|
id: string,
|
|
257
294
|
el: HTMLElement,
|
|
258
295
|
rank: number,
|
|
259
|
-
render
|
|
296
|
+
render?: MediaNodeRender,
|
|
260
297
|
): void {
|
|
261
298
|
const reg = registry();
|
|
262
299
|
const record = recordFor(id, render);
|
|
263
300
|
const existing = record.claims.find((claim) => claim.el === el && claim.rank === rank);
|
|
264
301
|
if (existing !== undefined) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
302
|
+
const unchanged =
|
|
303
|
+
existing.render === render ||
|
|
304
|
+
(existing.render !== undefined && render !== undefined && sameRender(existing.render, render));
|
|
305
|
+
if (unchanged && record.wrapper.parentElement === topClaim(record)?.el) return;
|
|
268
306
|
existing.render = render;
|
|
269
307
|
} else {
|
|
270
308
|
reg.seq += 1;
|
|
@@ -432,7 +432,10 @@ export function flyBack(id: string): void {
|
|
|
432
432
|
*
|
|
433
433
|
* DO NOT replace this with a direct `unmountVideoView` call. It looks
|
|
434
434
|
* equivalent, it is the half that does not matter, and the fix would disappear
|
|
435
|
-
* silently. `MediaFlight.test.tsx` makes that substitution its control
|
|
435
|
+
* silently. `MediaFlight.test.tsx` makes that substitution its control, and
|
|
436
|
+
* `scripts/probe-expo-video-listener-leak.mjs` measures the upstream defect
|
|
437
|
+
* this steps around — run it before touching any of this. The day it prints
|
|
438
|
+
* LEAK GONE, this commit and the tests that pin it can go.
|
|
436
439
|
*
|
|
437
440
|
* The cost is one frame in which the outgoing surface shows its poster instead
|
|
438
441
|
* of video. It is already on its way out and the destination is live by then —
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
3
|
import type { SharedValue } from 'react-native-reanimated';
|
|
3
4
|
|
|
@@ -223,6 +224,51 @@ export interface MediaFlightController {
|
|
|
223
224
|
progress: SharedValue<number>;
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
/**
|
|
228
|
+
* What Bloom hands a video slot: the three things it must decide, and nothing
|
|
229
|
+
* else.
|
|
230
|
+
*
|
|
231
|
+
* `style` is not advisory. A DOM `<video>` is a REPLACED element, and
|
|
232
|
+
* `position:absolute; inset:0` with `width/height:auto` resolves it to its
|
|
233
|
+
* INTRINSIC size (300x150) instead of stretching — so a slot that drops this
|
|
234
|
+
* style paints a 300x150 video inside whatever box it was given. Spread it.
|
|
235
|
+
*/
|
|
236
|
+
export interface MediaVideoSlotProps {
|
|
237
|
+
/**
|
|
238
|
+
* The player Bloom was given — never one Bloom created.
|
|
239
|
+
*
|
|
240
|
+
* `null` means UNBIND THIS ELEMENT NOW, and it must be passed straight
|
|
241
|
+
* through. expo-video answers a null player by emptying the source, which
|
|
242
|
+
* pauses the element without an event; that silence is what stops a surface
|
|
243
|
+
* on its way out from pausing the one the viewer just landed on. Substituting
|
|
244
|
+
* anything else here — keeping the old player, unmounting instead — brings
|
|
245
|
+
* that defect back. See `releaseFlight`.
|
|
246
|
+
*/
|
|
247
|
+
player: VideoPlayerLike | null;
|
|
248
|
+
/** Fills the box. Spread it — see above. */
|
|
249
|
+
style: StyleProp<ViewStyle>;
|
|
250
|
+
contentFit: 'contain' | 'cover';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Paint the video yourself.
|
|
255
|
+
*
|
|
256
|
+
* Bloom mounts a `VideoView` with the handful of props it knows about, which is
|
|
257
|
+
* enough until a consumer needs something only its OWN element can give it: a
|
|
258
|
+
* `ref` for `startPictureInPicture()` or `enterFullscreen()`, expo-video's
|
|
259
|
+
* picture-in-picture callbacks, `onFirstFrameRender`, anything expo-video adds
|
|
260
|
+
* later. Widening Bloom's props to cover those would drag expo-video's surface
|
|
261
|
+
* into Bloom's API, and it would be behind again on the next release.
|
|
262
|
+
*
|
|
263
|
+
* So the consumer may build the element instead, with Bloom deciding only what
|
|
264
|
+
* it has to: which player, and a style that fills the box.
|
|
265
|
+
*
|
|
266
|
+
* MEMOISE IT (`useCallback`). The slot is part of what a host publishes to the
|
|
267
|
+
* layer, compared by identity — a new function every render republishes every
|
|
268
|
+
* render. Correct either way, wasteful if you skip it.
|
|
269
|
+
*/
|
|
270
|
+
export type MediaVideoSlot = (props: MediaVideoSlotProps) => ReactNode;
|
|
271
|
+
|
|
226
272
|
/**
|
|
227
273
|
* A place a media surface may live: the feed row it starts in, the fullscreen
|
|
228
274
|
* player it lands in, and every other end of a flight.
|
|
@@ -242,6 +288,11 @@ export interface MediaFlightHostProps {
|
|
|
242
288
|
style?: StyleProp<ViewStyle>;
|
|
243
289
|
/** How the media fills the box. Defaults to `'cover'`. */
|
|
244
290
|
contentFit?: 'contain' | 'cover';
|
|
291
|
+
/**
|
|
292
|
+
* Paint the video yourself, keeping your own `ref` and expo-video props. See
|
|
293
|
+
* {@link MediaVideoSlot}; memoise it.
|
|
294
|
+
*/
|
|
295
|
+
renderVideo?: MediaVideoSlot;
|
|
245
296
|
/** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
|
|
246
297
|
nativeControls?: boolean;
|
|
247
298
|
accessibilityLabel?: string;
|