@oxyhq/bloom 1.2.0 → 1.2.2
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/getting-started.mdx +29 -0
- package/docs/media-flight.mdx +45 -14
- package/lib/commonjs/media-flight/MediaFlightLayer.js +7 -1
- package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -1
- package/lib/commonjs/media-flight/MediaSurface.js +24 -4
- package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
- package/lib/commonjs/media-flight/expo-video-module.js +32 -0
- package/lib/commonjs/media-flight/expo-video-module.js.map +1 -1
- package/lib/commonjs/media-flight/index.js +6 -0
- package/lib/commonjs/media-flight/index.js.map +1 -1
- package/lib/commonjs/media-flight/store.js +46 -2
- package/lib/commonjs/media-flight/store.js.map +1 -1
- package/lib/module/media-flight/MediaFlightLayer.js +7 -1
- package/lib/module/media-flight/MediaFlightLayer.js.map +1 -1
- package/lib/module/media-flight/MediaSurface.js +24 -4
- package/lib/module/media-flight/MediaSurface.js.map +1 -1
- package/lib/module/media-flight/expo-video-module.js +31 -0
- package/lib/module/media-flight/expo-video-module.js.map +1 -1
- package/lib/module/media-flight/index.js +1 -1
- package/lib/module/media-flight/index.js.map +1 -1
- package/lib/module/media-flight/store.js +46 -2
- package/lib/module/media-flight/store.js.map +1 -1
- package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +13 -0
- package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts +22 -0
- package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/index.d.ts +1 -1
- package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/store.d.ts +24 -1
- package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/types.d.ts +13 -0
- package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/MediaSurface.d.ts +13 -0
- package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/expo-video-module.d.ts +22 -0
- package/lib/typescript/module/media-flight/expo-video-module.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/index.d.ts +1 -1
- package/lib/typescript/module/media-flight/index.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/store.d.ts +24 -1
- package/lib/typescript/module/media-flight/store.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/types.d.ts +13 -0
- package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/media-flight/MediaFlightLayer.tsx +5 -1
- package/src/media-flight/MediaSurface.tsx +34 -2
- package/src/media-flight/expo-video-module.ts +31 -0
- package/src/media-flight/index.ts +1 -0
- package/src/media-flight/store.ts +46 -1
- package/src/media-flight/types.ts +13 -0
|
@@ -93,6 +93,19 @@ export interface MediaSurfaceProps {
|
|
|
93
93
|
* surface that handed off to itself would release on its own first frame.
|
|
94
94
|
*/
|
|
95
95
|
flightId?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Render the video arm with NO player, unbinding this element from it while
|
|
98
|
+
* the element is still in the DOM.
|
|
99
|
+
*
|
|
100
|
+
* expo-video's web player mirrors pause across every element bound to it, and
|
|
101
|
+
* a `<video>` removed from the DOM is auto-paused by the browser — but
|
|
102
|
+
* `unmountVideoView` only runs in a PASSIVE effect cleanup, so it happens
|
|
103
|
+
* after removal and after that auto-pause. A dying element therefore pauses
|
|
104
|
+
* the one the viewer is watching. Setting this for one commit before unmount
|
|
105
|
+
* runs expo-video's own `[props.player]` effect early, whose cleanup unbinds,
|
|
106
|
+
* so the later pause reaches nobody.
|
|
107
|
+
*/
|
|
108
|
+
detached?: boolean;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
/**
|
|
@@ -112,6 +125,7 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
112
125
|
accessibilityLabel,
|
|
113
126
|
pointerEvents,
|
|
114
127
|
flightId,
|
|
128
|
+
detached = false,
|
|
115
129
|
}: MediaSurfaceProps) {
|
|
116
130
|
// Captured once — see `surfaceType` above. A consumer changing it later gets
|
|
117
131
|
// the value the view was mounted with, which is the only value expo-video
|
|
@@ -162,11 +176,21 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
162
176
|
)}
|
|
163
177
|
{content.kind === 'video' && expoVideo !== null ? (
|
|
164
178
|
<expoVideo.VideoView
|
|
165
|
-
player={content.player}
|
|
179
|
+
player={detached ? null : content.player}
|
|
166
180
|
contentFit={contentFit}
|
|
167
181
|
surfaceType={mountedSurfaceType}
|
|
168
182
|
nativeControls={nativeControls}
|
|
169
|
-
|
|
183
|
+
// `absoluteFill` ALONE does not size this, and the reason is a CSS
|
|
184
|
+
// rule rather than a bug: expo-video renders a DOM `<video>`, which is
|
|
185
|
+
// a REPLACED element, and `position:absolute; inset:0` with
|
|
186
|
+
// `width/height:auto` resolves a replaced element to its INTRINSIC
|
|
187
|
+
// size — 300x150, the `<video>` default — instead of stretching it.
|
|
188
|
+
// Measured: inside a 320x200 box the element computed
|
|
189
|
+
// `left/right/top/bottom: 0px` and `width: 300px; height: 150px`, so
|
|
190
|
+
// it sat at the default while the box animated around it and then
|
|
191
|
+
// jumped when metadata arrived. The poster escapes this only because
|
|
192
|
+
// expo-image sizes its own element.
|
|
193
|
+
style={[StyleSheet.absoluteFill, styles.fillReplaced]}
|
|
170
194
|
accessibilityLabel={accessibilityLabel}
|
|
171
195
|
onFirstFrameRender={reportLive}
|
|
172
196
|
/>
|
|
@@ -222,6 +246,14 @@ export function EmptyMediaSurface({ style }: { style?: MediaPosterStyle }) {
|
|
|
222
246
|
}
|
|
223
247
|
|
|
224
248
|
const styles = StyleSheet.create({
|
|
249
|
+
/**
|
|
250
|
+
* Explicit size for a REPLACED element. Redundant for a `<div>` and
|
|
251
|
+
* load-bearing for a `<video>` — see the note at the call site.
|
|
252
|
+
*/
|
|
253
|
+
fillReplaced: {
|
|
254
|
+
width: '100%',
|
|
255
|
+
height: '100%',
|
|
256
|
+
},
|
|
225
257
|
box: {
|
|
226
258
|
// What makes the box's corner radius actually clip a video, and the other
|
|
227
259
|
// half of why the Android surface has to be a `textureView`.
|
|
@@ -104,12 +104,42 @@ export interface ExpoVideoLike {
|
|
|
104
104
|
|
|
105
105
|
/** `undefined` until the first load attempt, then the module or `null`. */
|
|
106
106
|
let videoModule: ExpoVideoLike | null | undefined;
|
|
107
|
+
/** Set by {@link provideExpoVideo}; wins over the `require` below. */
|
|
108
|
+
let providedModule: ExpoVideoLike | null | undefined;
|
|
107
109
|
/** Why the load failed, quoted verbatim in the dev warning. */
|
|
108
110
|
let unavailableReason = '';
|
|
109
111
|
let hasWarned = false;
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Hand Bloom the expo-video module explicitly.
|
|
115
|
+
*
|
|
116
|
+
* REQUIRED ON ANY ESM WEB BUNDLER, and measured rather than assumed: in a Vite
|
|
117
|
+
* bundle `typeof require` is `undefined`, so the optional-peer `require` below
|
|
118
|
+
* cannot run and video silently degrades to its poster **even when expo-video
|
|
119
|
+
* is installed**. Metro has a real `require` and is unaffected, which is why
|
|
120
|
+
* this went unnoticed — the app that uses this is Metro-built.
|
|
121
|
+
*
|
|
122
|
+
* There is no bundler-agnostic sync alternative: `import()` is async and this is
|
|
123
|
+
* called during render, so making it async would mean the surface renders
|
|
124
|
+
* without video for a frame and then upgrades. Handing the module over is
|
|
125
|
+
* explicit, synchronous, and costs the consumer one line at startup:
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* import * as ExpoVideo from 'expo-video';
|
|
129
|
+
* provideExpoVideo(ExpoVideo);
|
|
130
|
+
* ```
|
|
131
|
+
*
|
|
132
|
+
* Pass `null` to clear it.
|
|
133
|
+
*/
|
|
134
|
+
export function provideExpoVideo(module: ExpoVideoLike | null): void {
|
|
135
|
+
providedModule = module;
|
|
136
|
+
// Drop any cached `require` result so the next load reflects this decision.
|
|
137
|
+
videoModule = undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
/** The expo-video module, or `null` when the optional peer is not installed. */
|
|
112
141
|
export function loadExpoVideo(): ExpoVideoLike | null {
|
|
142
|
+
if (providedModule !== undefined && providedModule !== null) return providedModule;
|
|
113
143
|
if (videoModule !== undefined) return videoModule;
|
|
114
144
|
videoModule = null;
|
|
115
145
|
|
|
@@ -176,6 +206,7 @@ export function warnExpoVideoUnavailable(): void {
|
|
|
176
206
|
/** Test seam — drops the cached module handle so a suite can load it again. */
|
|
177
207
|
export function resetExpoVideoModule(): void {
|
|
178
208
|
videoModule = undefined;
|
|
209
|
+
providedModule = undefined;
|
|
179
210
|
unavailableReason = '';
|
|
180
211
|
hasWarned = false;
|
|
181
212
|
}
|
|
@@ -77,6 +77,8 @@ interface FlightStatus {
|
|
|
77
77
|
/** Waiters on `flyTo`'s promise, resolved once `mounted` turns true. */
|
|
78
78
|
mountWaiters: Array<() => void>;
|
|
79
79
|
mountTimer: ReturnType<typeof setTimeout> | null;
|
|
80
|
+
/** Pending final removal while the surface unbinds from its player. */
|
|
81
|
+
unbindTimer: ReturnType<typeof setTimeout> | null;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
interface Registry {
|
|
@@ -119,6 +121,7 @@ function statusFor(id: string): FlightStatus {
|
|
|
119
121
|
handedOff: false,
|
|
120
122
|
mountWaiters: [],
|
|
121
123
|
mountTimer: null,
|
|
124
|
+
unbindTimer: null,
|
|
122
125
|
};
|
|
123
126
|
reg.status.set(id, status);
|
|
124
127
|
}
|
|
@@ -127,7 +130,9 @@ function statusFor(id: string): FlightStatus {
|
|
|
127
130
|
|
|
128
131
|
function clearTimers(status: FlightStatus): void {
|
|
129
132
|
if (status.mountTimer !== null) clearTimeout(status.mountTimer);
|
|
133
|
+
if (status.unbindTimer !== null) clearTimeout(status.unbindTimer);
|
|
130
134
|
status.mountTimer = null;
|
|
135
|
+
status.unbindTimer = null;
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
function resolveMountWaiters(status: FlightStatus): void {
|
|
@@ -263,6 +268,7 @@ export function flyTo(
|
|
|
263
268
|
// silently ignored by the view it is meant to configure.
|
|
264
269
|
surfaceType: existing.surfaceType,
|
|
265
270
|
landing: false,
|
|
271
|
+
unbinding: false,
|
|
266
272
|
generation: existing.generation + 1,
|
|
267
273
|
};
|
|
268
274
|
reg.flights.set(id, next);
|
|
@@ -285,6 +291,7 @@ export function flyTo(
|
|
|
285
291
|
surfaceType: options?.surfaceType ?? 'textureView',
|
|
286
292
|
progress: makeMutable(0),
|
|
287
293
|
landing: false,
|
|
294
|
+
unbinding: false,
|
|
288
295
|
generation: 0,
|
|
289
296
|
};
|
|
290
297
|
reg.flights.set(id, flight);
|
|
@@ -394,9 +401,47 @@ export function flyBack(id: string): void {
|
|
|
394
401
|
});
|
|
395
402
|
}
|
|
396
403
|
|
|
397
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* Drop the surface for `id`. Called when a landing leg settles, and on teardown.
|
|
406
|
+
*
|
|
407
|
+
* A VIDEO surface does not go straight out. It renders for one more commit with
|
|
408
|
+
* NO player first, because expo-video's web player mirrors pause across every
|
|
409
|
+
* element bound to it and a `<video>` removed from the DOM is auto-paused by the
|
|
410
|
+
* browser — while `unmountVideoView` only runs in a PASSIVE effect cleanup,
|
|
411
|
+
* i.e. after removal and after that auto-pause. So the dying element pauses the
|
|
412
|
+
* one the viewer is now watching. Measured: the reel's video paused 1 ms after
|
|
413
|
+
* the flying surface left the DOM, with `currentTime` still advancing and
|
|
414
|
+
* nobody having called `pause()`.
|
|
415
|
+
*
|
|
416
|
+
* Rendering `player={null}` for one commit runs expo-video's own `[props.player]`
|
|
417
|
+
* effect while the node is STILL in the DOM: its cleanup calls
|
|
418
|
+
* `unmountVideoView`, the element leaves `_mountedVideos`, and its later pause
|
|
419
|
+
* reaches nobody. That is the documented mount/unmount pair, not a patch of
|
|
420
|
+
* somebody else's module.
|
|
421
|
+
*
|
|
422
|
+
* The cost is one frame in which the outgoing surface shows its poster instead
|
|
423
|
+
* of video. It is already on its way out and the destination is live by then —
|
|
424
|
+
* `handOff` is what got us here — so that frame is covered.
|
|
425
|
+
*/
|
|
398
426
|
export function releaseFlight(id: string): void {
|
|
399
427
|
const reg = registry();
|
|
428
|
+
const flight = reg.flights.get(id);
|
|
429
|
+
|
|
430
|
+
if (flight !== undefined && flight.content.kind === 'video' && !flight.unbinding) {
|
|
431
|
+
reg.flights.set(id, { ...flight, unbinding: true });
|
|
432
|
+
const unbinding = statusFor(id);
|
|
433
|
+
if (unbinding.unbindTimer === null) {
|
|
434
|
+
// After the commit AND expo-video's passive effect, which is the whole
|
|
435
|
+
// point — a microtask would run before either.
|
|
436
|
+
unbinding.unbindTimer = setTimeout(() => {
|
|
437
|
+
unbinding.unbindTimer = null;
|
|
438
|
+
releaseFlight(id);
|
|
439
|
+
}, 0);
|
|
440
|
+
}
|
|
441
|
+
publish(reg);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
400
445
|
const status = reg.status.get(id);
|
|
401
446
|
if (status) {
|
|
402
447
|
// A waiter still parked on `flyTo`'s promise would otherwise hang until its
|
|
@@ -94,6 +94,19 @@ export interface MediaFlight {
|
|
|
94
94
|
* settles, the surface is released instead of parked.
|
|
95
95
|
*/
|
|
96
96
|
landing: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* True for the ONE commit between "release requested" and the surface
|
|
99
|
+
* actually going away, during which a video surface renders with no player.
|
|
100
|
+
*
|
|
101
|
+
* expo-video's web player MIRRORS pause across every element bound to it
|
|
102
|
+
* (`VideoPlayer.web.js`: `video.onpause` pauses all the others), and a
|
|
103
|
+
* `<video>` removed from the DOM is auto-paused by the browser. Its
|
|
104
|
+
* `unmountVideoView` runs in a PASSIVE effect cleanup — after removal, after
|
|
105
|
+
* the auto-pause — so a dying element pauses the one the viewer is watching.
|
|
106
|
+
* Unbinding first takes this element out of `_mountedVideos` while it is
|
|
107
|
+
* still in the DOM, so its later pause reaches nobody.
|
|
108
|
+
*/
|
|
109
|
+
unbinding: boolean;
|
|
97
110
|
/** Bumped on every retarget, so React remounts nothing but the layer re-reads. */
|
|
98
111
|
generation: number;
|
|
99
112
|
}
|