@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.
Files changed (49) hide show
  1. package/docs/getting-started.mdx +29 -0
  2. package/docs/media-flight.mdx +45 -14
  3. package/lib/commonjs/media-flight/MediaFlightLayer.js +7 -1
  4. package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -1
  5. package/lib/commonjs/media-flight/MediaSurface.js +24 -4
  6. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
  7. package/lib/commonjs/media-flight/expo-video-module.js +32 -0
  8. package/lib/commonjs/media-flight/expo-video-module.js.map +1 -1
  9. package/lib/commonjs/media-flight/index.js +6 -0
  10. package/lib/commonjs/media-flight/index.js.map +1 -1
  11. package/lib/commonjs/media-flight/store.js +46 -2
  12. package/lib/commonjs/media-flight/store.js.map +1 -1
  13. package/lib/module/media-flight/MediaFlightLayer.js +7 -1
  14. package/lib/module/media-flight/MediaFlightLayer.js.map +1 -1
  15. package/lib/module/media-flight/MediaSurface.js +24 -4
  16. package/lib/module/media-flight/MediaSurface.js.map +1 -1
  17. package/lib/module/media-flight/expo-video-module.js +31 -0
  18. package/lib/module/media-flight/expo-video-module.js.map +1 -1
  19. package/lib/module/media-flight/index.js +1 -1
  20. package/lib/module/media-flight/index.js.map +1 -1
  21. package/lib/module/media-flight/store.js +46 -2
  22. package/lib/module/media-flight/store.js.map +1 -1
  23. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +13 -0
  24. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts +22 -0
  26. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/media-flight/index.d.ts +1 -1
  28. package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/media-flight/store.d.ts +24 -1
  30. package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/media-flight/types.d.ts +13 -0
  32. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
  33. package/lib/typescript/module/media-flight/MediaSurface.d.ts +13 -0
  34. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
  35. package/lib/typescript/module/media-flight/expo-video-module.d.ts +22 -0
  36. package/lib/typescript/module/media-flight/expo-video-module.d.ts.map +1 -1
  37. package/lib/typescript/module/media-flight/index.d.ts +1 -1
  38. package/lib/typescript/module/media-flight/index.d.ts.map +1 -1
  39. package/lib/typescript/module/media-flight/store.d.ts +24 -1
  40. package/lib/typescript/module/media-flight/store.d.ts.map +1 -1
  41. package/lib/typescript/module/media-flight/types.d.ts +13 -0
  42. package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/media-flight/MediaFlightLayer.tsx +5 -1
  45. package/src/media-flight/MediaSurface.tsx +34 -2
  46. package/src/media-flight/expo-video-module.ts +31 -0
  47. package/src/media-flight/index.ts +1 -0
  48. package/src/media-flight/store.ts +46 -1
  49. 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
- style={StyleSheet.absoluteFill}
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
  }
@@ -13,6 +13,7 @@ export { SURFACE_MOUNT_TIMEOUT_MS } from './constants';
13
13
  export {
14
14
  loadExpoVideo,
15
15
  warnExpoVideoUnavailable,
16
+ provideExpoVideo,
16
17
  resetExpoVideoModule,
17
18
  type ExpoVideoLike,
18
19
  type VideoPlayerLike,
@@ -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
- /** Drop the surface for `id`. Called when a landing leg settles, and on teardown. */
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
  }