@oxyhq/bloom 1.2.2 → 1.4.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.
Files changed (84) hide show
  1. package/docs/media-flight.mdx +133 -0
  2. package/lib/commonjs/media-flight/MediaFlightHost.js +58 -0
  3. package/lib/commonjs/media-flight/MediaFlightHost.js.map +1 -0
  4. package/lib/commonjs/media-flight/MediaFlightHost.web.js +87 -0
  5. package/lib/commonjs/media-flight/MediaFlightHost.web.js.map +1 -0
  6. package/lib/commonjs/media-flight/MediaFlightLayer.js +4 -2
  7. package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -1
  8. package/lib/commonjs/media-flight/MediaFlightLayer.web.js +198 -0
  9. package/lib/commonjs/media-flight/MediaFlightLayer.web.js.map +1 -0
  10. package/lib/commonjs/media-flight/MediaSurface.js +13 -5
  11. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
  12. package/lib/commonjs/media-flight/index.js +8 -1
  13. package/lib/commonjs/media-flight/index.js.map +1 -1
  14. package/lib/commonjs/media-flight/index.web.js +115 -0
  15. package/lib/commonjs/media-flight/index.web.js.map +1 -0
  16. package/lib/commonjs/media-flight/media-node.web.js +314 -0
  17. package/lib/commonjs/media-flight/media-node.web.js.map +1 -0
  18. package/lib/commonjs/media-flight/store.js +19 -4
  19. package/lib/commonjs/media-flight/store.js.map +1 -1
  20. package/lib/module/media-flight/MediaFlightHost.js +53 -0
  21. package/lib/module/media-flight/MediaFlightHost.js.map +1 -0
  22. package/lib/module/media-flight/MediaFlightHost.web.js +82 -0
  23. package/lib/module/media-flight/MediaFlightHost.web.js.map +1 -0
  24. package/lib/module/media-flight/MediaFlightLayer.js +4 -2
  25. package/lib/module/media-flight/MediaFlightLayer.js.map +1 -1
  26. package/lib/module/media-flight/MediaFlightLayer.web.js +192 -0
  27. package/lib/module/media-flight/MediaFlightLayer.web.js.map +1 -0
  28. package/lib/module/media-flight/MediaSurface.js +13 -5
  29. package/lib/module/media-flight/MediaSurface.js.map +1 -1
  30. package/lib/module/media-flight/index.js +2 -1
  31. package/lib/module/media-flight/index.js.map +1 -1
  32. package/lib/module/media-flight/index.web.js +19 -0
  33. package/lib/module/media-flight/index.web.js.map +1 -0
  34. package/lib/module/media-flight/media-node.web.js +304 -0
  35. package/lib/module/media-flight/media-node.web.js.map +1 -0
  36. package/lib/module/media-flight/store.js +19 -4
  37. package/lib/module/media-flight/store.js.map +1 -1
  38. package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts +6 -0
  39. package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts +6 -0
  41. package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts +5 -0
  43. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts.map +1 -0
  44. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +19 -7
  45. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/media-flight/index.d.ts +2 -1
  47. package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/media-flight/index.web.d.ts +19 -0
  49. package/lib/typescript/commonjs/media-flight/index.web.d.ts.map +1 -0
  50. package/lib/typescript/commonjs/media-flight/media-node.web.d.ts +93 -0
  51. package/lib/typescript/commonjs/media-flight/media-node.web.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/media-flight/store.d.ts +19 -4
  53. package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/media-flight/types.d.ts +93 -0
  55. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
  56. package/lib/typescript/module/media-flight/MediaFlightHost.d.ts +6 -0
  57. package/lib/typescript/module/media-flight/MediaFlightHost.d.ts.map +1 -0
  58. package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts +6 -0
  59. package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts.map +1 -0
  60. package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts +5 -0
  61. package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts.map +1 -0
  62. package/lib/typescript/module/media-flight/MediaSurface.d.ts +19 -7
  63. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
  64. package/lib/typescript/module/media-flight/index.d.ts +2 -1
  65. package/lib/typescript/module/media-flight/index.d.ts.map +1 -1
  66. package/lib/typescript/module/media-flight/index.web.d.ts +19 -0
  67. package/lib/typescript/module/media-flight/index.web.d.ts.map +1 -0
  68. package/lib/typescript/module/media-flight/media-node.web.d.ts +93 -0
  69. package/lib/typescript/module/media-flight/media-node.web.d.ts.map +1 -0
  70. package/lib/typescript/module/media-flight/store.d.ts +19 -4
  71. package/lib/typescript/module/media-flight/store.d.ts.map +1 -1
  72. package/lib/typescript/module/media-flight/types.d.ts +93 -0
  73. package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
  74. package/package.json +6 -1
  75. package/src/media-flight/MediaFlightHost.tsx +56 -0
  76. package/src/media-flight/MediaFlightHost.web.tsx +85 -0
  77. package/src/media-flight/MediaFlightLayer.tsx +4 -2
  78. package/src/media-flight/MediaFlightLayer.web.tsx +191 -0
  79. package/src/media-flight/MediaSurface.tsx +36 -12
  80. package/src/media-flight/index.ts +2 -0
  81. package/src/media-flight/index.web.ts +45 -0
  82. package/src/media-flight/media-node.web.ts +373 -0
  83. package/src/media-flight/store.ts +19 -4
  84. package/src/media-flight/types.ts +96 -0
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
1
3
  import type { SharedValue } from 'react-native-reanimated';
2
4
  import type { VideoPlayerLike, VideoSurfaceType } from './expo-video-module';
3
5
  /**
@@ -205,4 +207,95 @@ export interface MediaFlightController {
205
207
  */
206
208
  progress: SharedValue<number>;
207
209
  }
210
+ /**
211
+ * What Bloom hands a video slot: the three things it must decide, and nothing
212
+ * else.
213
+ *
214
+ * `style` is not advisory. A DOM `<video>` is a REPLACED element, and
215
+ * `position:absolute; inset:0` with `width/height:auto` resolves it to its
216
+ * INTRINSIC size (300x150) instead of stretching — so a slot that drops this
217
+ * style paints a 300x150 video inside whatever box it was given. Spread it.
218
+ */
219
+ export interface MediaVideoSlotProps {
220
+ /**
221
+ * The player Bloom was given — never one Bloom created.
222
+ *
223
+ * `null` means UNBIND THIS ELEMENT NOW, and it must be passed straight
224
+ * through. expo-video answers a null player by emptying the source, which
225
+ * pauses the element without an event; that silence is what stops a surface
226
+ * on its way out from pausing the one the viewer just landed on. Substituting
227
+ * anything else here — keeping the old player, unmounting instead — brings
228
+ * that defect back. See `releaseFlight`.
229
+ */
230
+ player: VideoPlayerLike | null;
231
+ /** Fills the box. Spread it — see above. */
232
+ style: StyleProp<ViewStyle>;
233
+ contentFit: 'contain' | 'cover';
234
+ }
235
+ /**
236
+ * Paint the video yourself.
237
+ *
238
+ * Bloom mounts a `VideoView` with the handful of props it knows about, which is
239
+ * enough until a consumer needs something only its OWN element can give it: a
240
+ * `ref` for `startPictureInPicture()` or `enterFullscreen()`, expo-video's
241
+ * picture-in-picture callbacks, `onFirstFrameRender`, anything expo-video adds
242
+ * later. Widening Bloom's props to cover those would drag expo-video's surface
243
+ * into Bloom's API, and it would be behind again on the next release.
244
+ *
245
+ * So the consumer may build the element instead, with Bloom deciding only what
246
+ * it has to: which player, and a style that fills the box.
247
+ *
248
+ * MEMOISE IT (`useCallback`). The slot is part of what a host publishes to the
249
+ * layer, compared by identity — a new function every render republishes every
250
+ * render. Correct either way, wasteful if you skip it.
251
+ */
252
+ export type MediaVideoSlot = (props: MediaVideoSlotProps) => ReactNode;
253
+ /**
254
+ * A place a media surface may live: the feed row it starts in, the fullscreen
255
+ * player it lands in, and every other end of a flight.
256
+ *
257
+ * A host DECLARES a box; it does not own the media. On web that distinction is
258
+ * the whole feature — the media is ONE DOM node the layer paints once and moves
259
+ * between hosts, so it survives the origin route unmounting mid-flight without
260
+ * ever leaving the document. On native a host renders its media inline, which
261
+ * is what it has always done and what the platform makes correct there.
262
+ */
263
+ export interface MediaFlightHostProps {
264
+ /** The id this host shares with the flight and with the other end. */
265
+ id: string;
266
+ /** What to paint: a still image, or a consumer-owned expo-video player. */
267
+ content: MediaSurfaceContent;
268
+ /** Style of the host BOX. Give it the radius and `overflow: 'hidden'` you want. */
269
+ style?: StyleProp<ViewStyle>;
270
+ /** How the media fills the box. Defaults to `'cover'`. */
271
+ contentFit?: 'contain' | 'cover';
272
+ /**
273
+ * Paint the video yourself, keeping your own `ref` and expo-video props. See
274
+ * {@link MediaVideoSlot}; memoise it.
275
+ */
276
+ renderVideo?: MediaVideoSlot;
277
+ /** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
278
+ nativeControls?: boolean;
279
+ accessibilityLabel?: string;
280
+ /**
281
+ * Android's rendering surface for the video arm. See `MediaSurfaceProps`.
282
+ * Ignored on web, where there is no such choice.
283
+ */
284
+ surfaceType?: VideoSurfaceType;
285
+ /**
286
+ * Passed through as a PROP, never as a style key — react-native-web resolves
287
+ * `box-none`/`box-only` from the prop path only.
288
+ */
289
+ pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only';
290
+ /**
291
+ * Set on a DESTINATION host, to the id its flight was started with, when the
292
+ * destination paints its media ITSELF rather than through the shared node —
293
+ * which on native is always. The web host does not need it: a flight there
294
+ * ends when the destination host claims the node, because by then the node it
295
+ * is claiming has been showing the media all along.
296
+ *
297
+ * Leave it unset on an origin host, which would otherwise hand off to itself.
298
+ */
299
+ flightId?: string;
300
+ }
208
301
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/media-flight/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/media-flight/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE7E;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,eAAe,CACb,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,GACtE,IAAI,CAAC;CACT;AAED,kDAAkD;AAClD,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,kEAAkE;IAClE,MAAM,EAAE,eAAe,CAAC;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,GAAG,iBAAiB,CAAC;AAExE;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,mBAAmB,CAAC;IAC7B,wCAAwC;IACxC,IAAI,EAAE,YAAY,CAAC;IACnB,oCAAoC;IACpC,EAAE,EAAE,YAAY,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,SAAS,GAAG,OAAO,CAAC;IAChC,gFAAgF;IAChF,WAAW,EAAE,gBAAgB,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC9B;;;OAGG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;OAWG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wCAAwC;AACxC,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB,GAAG,IAAI,KAAK,IAAI,CAAC;IACzE;;;;OAIG;IACH,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC5D;;;;;;;;;;;;;OAaG;IACH,KAAK,EAAE,CACL,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,kBAAkB,KACzB,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB;;;;OAIG;IACH,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B;;;;;;;;;OASG;IACH,OAAO,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;OASG;IACH,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC;IAC/B,4CAA4C;IAC5C,KAAK,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5B,UAAU,EAAE,SAAS,GAAG,OAAO,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,mBAAmB,KAAK,SAAS,CAAC;AAEvE;;;;;;;;;GASG;AACH,MAAM,WAAW,oBAAoB;IACnC,sEAAsE;IACtE,EAAE,EAAE,MAAM,CAAC;IACX,2EAA2E;IAC3E,OAAO,EAAE,mBAAmB,CAAC;IAC7B,mFAAmF;IACnF,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,0DAA0D;IAC1D,UAAU,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC;IACjC;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B,kFAAkF;IAClF,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;IAC/B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;IAC1D;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -610,6 +610,11 @@
610
610
  "types": "./lib/typescript/module/media-flight/index.d.ts",
611
611
  "default": "./src/media-flight/index.ts"
612
612
  },
613
+ "browser": {
614
+ "types": "./lib/typescript/module/media-flight/index.web.d.ts",
615
+ "import": "./lib/module/media-flight/index.web.js",
616
+ "require": "./lib/commonjs/media-flight/index.web.js"
617
+ },
613
618
  "import": {
614
619
  "types": "./lib/typescript/module/media-flight/index.d.ts",
615
620
  "default": "./lib/module/media-flight/index.js"
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A place a media surface lives — NATIVE.
3
+ *
4
+ * Here a host is exactly what it looks like: a box that renders its own
5
+ * `MediaSurface`. Native has no equivalent of the web problem this component
6
+ * exists for (a DOM `<video>` that must survive its React owner), and expo-video
7
+ * on native hands a player between views without the pause mirroring that makes
8
+ * two web elements mutually destructive. So this is the plain implementation,
9
+ * and `MediaFlightHost.web.tsx` is the one that re-parents.
10
+ *
11
+ * ONE API, two implementations. A consumer writes the same JSX on both
12
+ * platforms and never learns which one it got.
13
+ */
14
+ import { useCallback } from 'react';
15
+ import { StyleSheet, View } from 'react-native';
16
+
17
+ import { MediaSurface } from './MediaSurface';
18
+ import { registerAnchor } from './store';
19
+ import type { MediaFlightHostProps } from './types';
20
+
21
+ export function MediaFlightHost({
22
+ id,
23
+ content,
24
+ style,
25
+ contentFit = 'cover',
26
+ renderVideo,
27
+ nativeControls = false,
28
+ accessibilityLabel,
29
+ surfaceType,
30
+ pointerEvents,
31
+ flightId,
32
+ }: MediaFlightHostProps) {
33
+ // A callback ref rather than an effect: the anchor must be measurable the
34
+ // moment the box is on screen, and a tap can come one frame later.
35
+ const setNode = useCallback(
36
+ (node: View | null) => registerAnchor(id, node),
37
+ [id],
38
+ );
39
+
40
+ return (
41
+ <View ref={setNode} style={style} pointerEvents={pointerEvents}>
42
+ <MediaSurface
43
+ content={content}
44
+ contentFit={contentFit}
45
+ renderVideo={renderVideo}
46
+ nativeControls={nativeControls}
47
+ accessibilityLabel={accessibilityLabel}
48
+ surfaceType={surfaceType}
49
+ flightId={flightId}
50
+ style={StyleSheet.absoluteFill}
51
+ />
52
+ </View>
53
+ );
54
+ }
55
+
56
+ MediaFlightHost.displayName = 'MediaFlightHost';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A place a media surface lives — WEB, where it does NOT render the media.
3
+ *
4
+ * This host renders an empty box and CLAIMS the shared node for its id. The
5
+ * media itself is one DOM node the layer paints once and moves between hosts
6
+ * with `appendChild`, so a video keeps its element, its decoder and its
7
+ * position when the origin route unmounts mid-flight. See `media-node.web.ts`
8
+ * for why one element is the only topology that keeps both the position and the
9
+ * playback.
10
+ *
11
+ * The consumer writes the same JSX as on native and never learns the
12
+ * difference — including that here, `flightId` is not needed: a flight ends
13
+ * when a destination host claims the node, and the node it claims has been
14
+ * showing the media all along.
15
+ */
16
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
17
+ import { View } from 'react-native';
18
+
19
+ import {
20
+ HOST_RANK,
21
+ claimMediaNode,
22
+ releaseMediaNode,
23
+ type MediaNodeRender,
24
+ } from './media-node.web';
25
+ import { handOffFlight, hasFlight, registerAnchor } from './store';
26
+ import type { MediaFlightHostProps } from './types';
27
+
28
+ export function MediaFlightHost({
29
+ id,
30
+ content,
31
+ style,
32
+ contentFit = 'cover',
33
+ renderVideo,
34
+ nativeControls = false,
35
+ accessibilityLabel,
36
+ pointerEvents,
37
+ flightId,
38
+ }: MediaFlightHostProps) {
39
+ // State rather than a ref, because the claim runs in a layout effect and an
40
+ // effect cannot depend on a ref's mutation. One extra render at mount.
41
+ const [node, setNode] = useState<View | null>(null);
42
+
43
+ const setHost = useCallback(
44
+ (next: View | null) => {
45
+ registerAnchor(id, next);
46
+ setNode(next);
47
+ },
48
+ [id],
49
+ );
50
+
51
+ const render = useMemo<MediaNodeRender>(
52
+ () => ({ content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId }),
53
+ [content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId],
54
+ );
55
+ const renderRef = useRef(render);
56
+
57
+ // Declared FIRST so it runs first on mount: it seeds the ref the claim below
58
+ // reads, and afterwards it is what pushes prop changes into a live claim —
59
+ // in place, without releasing, because a release would move the node and a
60
+ // move away and back is a removal, which is what pauses a video.
61
+ useLayoutEffect(() => {
62
+ renderRef.current = render;
63
+ const el = node as unknown as HTMLElement | null;
64
+ if (el !== null) claimMediaNode(id, el, HOST_RANK, render);
65
+ }, [id, node, render]);
66
+
67
+ // Owns the claim's LIFETIME, and only that: its cleanup must run when the
68
+ // host really goes away, never on a prop change.
69
+ useLayoutEffect(() => {
70
+ const el = node as unknown as HTMLElement | null;
71
+ if (el === null) return undefined;
72
+ claimMediaNode(id, el, HOST_RANK, renderRef.current);
73
+ // A new host appearing mid-flight IS the hand-off. On native the
74
+ // destination has to decode a first frame before it can say this; here the
75
+ // node it just claimed is the one that has been playing since the origin,
76
+ // so mounting is the whole signal. An origin host does not reach this with
77
+ // a flight live — it claimed before the flight began.
78
+ if (hasFlight(id)) handOffFlight(id);
79
+ return () => releaseMediaNode(id, el, HOST_RANK);
80
+ }, [id, node]);
81
+
82
+ return <View ref={setHost} style={style} pointerEvents={pointerEvents} />;
83
+ }
84
+
85
+ MediaFlightHost.displayName = 'MediaFlightHost';
@@ -129,8 +129,10 @@ function MediaFlightSurface({ flight }: { flight: MediaFlight }) {
129
129
  contentFit={contentFit}
130
130
  surfaceType={surfaceType}
131
131
  // One commit with no player before this node leaves the DOM — see
132
- // `releaseFlight`. Without it the dying element's auto-pause mirrors
133
- // through expo-video and stops the video the viewer just landed on.
132
+ // `releaseFlight`, and note there that what it buys is expo-video
133
+ // emptying the source, not the unbind. Without it the dying element's
134
+ // auto-pause mirrors through expo-video and stops the video the viewer
135
+ // just landed on.
134
136
  detached={unbinding}
135
137
  // A flying surface is a picture, never a control: it must not intercept
136
138
  // the press that is already travelling to whatever is underneath it.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The layer that survives navigation — WEB, where it also OWNS the media.
3
+ *
4
+ * Everything in `MediaFlightLayer.tsx` still holds: mounted once at the app
5
+ * root, painting through `Portal` so a flying surface belongs to neither route.
6
+ * What this fork adds is the other half of the web story.
7
+ *
8
+ * ## It paints each shared node exactly once, forever
9
+ *
10
+ * `createPortal(<MediaSurface …/>, wrapper)` for every id a host has claimed.
11
+ * The wrapper is a plain `<div>` created OUTSIDE React by `media-node.web.ts`,
12
+ * so React reconciles the media against a container whose identity never
13
+ * changes while the registry moves that container between hosts with
14
+ * `appendChild`. React never sees the move; the browser never sees a removal;
15
+ * the video never loses its element, its decoder or its position.
16
+ *
17
+ * That is why the layer, and not the host, renders the media on web: the media
18
+ * has to be rendered by a tree that outlives every host, and the app root is
19
+ * the only such tree.
20
+ *
21
+ * ## A flying surface with a shared node paints nothing itself
22
+ *
23
+ * It becomes a positioned, animated BOX that claims the node — one more host,
24
+ * outranking the others for the length of the leg. A flight for an id with no
25
+ * shared node (an image gallery, any consumer that has not adopted a host)
26
+ * falls back to painting its own `MediaSurface`, which is what every flight did
27
+ * before hosts existed.
28
+ */
29
+ import {
30
+ useCallback,
31
+ useEffect,
32
+ useLayoutEffect,
33
+ useState,
34
+ useSyncExternalStore,
35
+ } from 'react';
36
+ import { StyleSheet, type View } from 'react-native';
37
+ import { createPortal } from 'react-dom';
38
+ import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
39
+
40
+ import { OverlayRoot } from '../overlay';
41
+ import { Portal } from '../portal/index.web';
42
+ import { MediaSurface, type MediaSurfaceStyle } from './MediaSurface';
43
+ import {
44
+ FLIGHT_RANK,
45
+ claimMediaNode,
46
+ getMediaNodes,
47
+ releaseMediaNode,
48
+ subscribeToMediaNodes,
49
+ type MediaNodeView,
50
+ } from './media-node.web';
51
+ import { getFlights, notifySurfaceMounted, subscribeToFlights } from './store';
52
+ import type { MediaFlight } from './types';
53
+
54
+ export function MediaFlightLayer() {
55
+ const flights = useSyncExternalStore(subscribeToFlights, getFlights, getFlights);
56
+ const nodes = useSyncExternalStore(subscribeToMediaNodes, getMediaNodes, getMediaNodes);
57
+
58
+ if (flights.length === 0 && nodes.length === 0) return null;
59
+
60
+ return (
61
+ <>
62
+ {nodes.map((node) => (
63
+ <SharedMediaNode key={node.id} node={node} />
64
+ ))}
65
+ {flights.length === 0 ? null : (
66
+ <Portal>
67
+ {/* `OverlayRoot` fills the viewport, is `box-none`, and takes this
68
+ surface's place in the open-order overlay stack. */}
69
+ <OverlayRoot>
70
+ {flights.map((flight) => (
71
+ <MediaFlightSurface
72
+ key={flight.id}
73
+ flight={flight}
74
+ // Read from the SNAPSHOT, never from the registry: a lookup
75
+ // during render is external mutable state in a memoized
76
+ // position, which the React Compiler is allowed to freeze.
77
+ shared={nodes.some((node) => node.id === flight.id)}
78
+ />
79
+ ))}
80
+ </OverlayRoot>
81
+ </Portal>
82
+ )}
83
+ </>
84
+ );
85
+ }
86
+
87
+ MediaFlightLayer.displayName = 'MediaFlightLayer';
88
+
89
+ /** One media element, painted into the wrapper its id owns, and never again. */
90
+ function SharedMediaNode({ node }: { node: MediaNodeView }) {
91
+ const { content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId } = node.render;
92
+ return createPortal(
93
+ <MediaSurface
94
+ content={content}
95
+ contentFit={contentFit}
96
+ renderVideo={renderVideo}
97
+ nativeControls={nativeControls}
98
+ accessibilityLabel={accessibilityLabel}
99
+ flightId={flightId}
100
+ style={StyleSheet.absoluteFill}
101
+ />,
102
+ node.wrapper,
103
+ );
104
+ }
105
+
106
+ /**
107
+ * One flying surface. See `MediaFlightLayer.tsx` for why the size is animated
108
+ * rather than scaled.
109
+ */
110
+ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: boolean }) {
111
+ const { id, from, to, progress, content, cornerRadius, contentFit, surfaceType, unbinding } = flight;
112
+
113
+ useEffect(() => {
114
+ notifySurfaceMounted(id);
115
+ }, [id]);
116
+
117
+ const originX = from.x - to.x;
118
+ const originY = from.y - to.y;
119
+
120
+ // CRITICAL — every shared value a mapper READS must be listed in its deps.
121
+ // On web WITHOUT the react-native-worklets babel plugin, reanimated drives a
122
+ // mapper off its deps array rather than off the reads it detects: with none,
123
+ // it runs ONCE and freezes at the opening frame. See `MediaFlightLayer.tsx`.
124
+ const boxStyle = useAnimatedStyle(
125
+ () => ({
126
+ transform: [
127
+ { translateX: interpolate(progress.value, [0, 1], [originX, 0]) },
128
+ { translateY: interpolate(progress.value, [0, 1], [originY, 0]) },
129
+ ],
130
+ width: interpolate(progress.value, [0, 1], [from.width, to.width]),
131
+ height: interpolate(progress.value, [0, 1], [from.height, to.height]),
132
+ }),
133
+ [progress, originX, originY, from.width, from.height, to.width, to.height],
134
+ );
135
+
136
+ const box = [styles.box, { left: to.x, top: to.y, borderRadius: cornerRadius }, boxStyle];
137
+
138
+ // The shared node is already painting this media. The flight is then a BOX
139
+ // that borrows it for the length of the leg, never a second copy.
140
+ if (shared) return <FlyingSharedNode id={id} style={box} />;
141
+
142
+ return (
143
+ <MediaSurface
144
+ content={content}
145
+ contentFit={contentFit}
146
+ surfaceType={surfaceType}
147
+ // One commit with no player before this node leaves the DOM — see
148
+ // `releaseFlight`. Only the no-shared-node path can need it: a shared
149
+ // node is never removed, which is the point of the whole mechanism.
150
+ detached={unbinding}
151
+ // A flying surface is a picture, never a control.
152
+ pointerEvents="none"
153
+ style={box}
154
+ />
155
+ );
156
+ }
157
+
158
+ /**
159
+ * The flight's own claim on a shared node.
160
+ *
161
+ * `pointerEvents` stays `none` on the BOX: a flying surface must not take the
162
+ * press travelling to whatever is underneath it. The wrapper inside sets
163
+ * `pointer-events: auto` for the media's sake, which is what a host needs and
164
+ * is inert here — a descendant asking for `auto` does not override a `none`
165
+ * ancestor.
166
+ */
167
+ function FlyingSharedNode({ id, style }: { id: string; style: MediaSurfaceStyle }) {
168
+ const [node, setNode] = useState<View | null>(null);
169
+ const setBox = useCallback((next: View | null) => setNode(next), []);
170
+
171
+ // A POSITION-ONLY claim: it says where the node lives for the length of the
172
+ // leg and nothing about what is painted in it. A flight that published a
173
+ // render of its own would replace the hosts' — and swap a consumer's own
174
+ // element out and back, which is two remounts in the middle of the one
175
+ // operation whose whole point is that the element is never rebuilt.
176
+ useLayoutEffect(() => {
177
+ const el = node as unknown as HTMLElement | null;
178
+ if (el === null) return undefined;
179
+ claimMediaNode(id, el, FLIGHT_RANK);
180
+ return () => releaseMediaNode(id, el, FLIGHT_RANK);
181
+ }, [id, node]);
182
+
183
+ return <Animated.View ref={setBox} style={style} pointerEvents="none" />;
184
+ }
185
+
186
+ const styles = StyleSheet.create({
187
+ box: {
188
+ position: 'absolute',
189
+ overflow: 'hidden',
190
+ },
191
+ });
@@ -26,7 +26,7 @@ import { Image } from 'expo-image';
26
26
  import Animated from 'react-native-reanimated';
27
27
 
28
28
  import { handOffFlight } from './store';
29
- import type { MediaSurfaceContent } from './types';
29
+ import type { MediaSurfaceContent, MediaVideoSlot } from './types';
30
30
  import {
31
31
  loadExpoVideo,
32
32
  warnExpoVideoUnavailable,
@@ -70,6 +70,14 @@ export interface MediaSurfaceProps {
70
70
  * is every property a flying box relies on.
71
71
  */
72
72
  surfaceType?: VideoSurfaceType;
73
+ /**
74
+ * Paint the video yourself, keeping your own `ref` and expo-video props.
75
+ *
76
+ * When this is given, Bloom builds no `VideoView` at all and the optional
77
+ * peer is never loaded on this path — the consumer already imported
78
+ * expo-video to write the slot. See {@link MediaVideoSlot}.
79
+ */
80
+ renderVideo?: MediaVideoSlot;
73
81
  /** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
74
82
  nativeControls?: boolean;
75
83
  accessibilityLabel?: string;
@@ -98,12 +106,16 @@ export interface MediaSurfaceProps {
98
106
  * the element is still in the DOM.
99
107
  *
100
108
  * 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.
109
+ * a `<video>` removed from the DOM is auto-paused by the browser, so a dying
110
+ * element pauses the one the viewer is watching.
111
+ *
112
+ * Setting this for one commit runs expo-video's own `[props.player]` effect
113
+ * early and the half that matters is `removeAttribute('src'); load()`
114
+ * (`VideoView.web.js:206`), not the unbind beside it. The media element load
115
+ * algorithm pauses WITHOUT firing `pause` (measured in real Chrome, with
116
+ * controls), so the element goes quiet and its later removal fires nothing.
117
+ * Unbinding alone does not help: `unmountVideoView` never clears the handler
118
+ * it installed. See `releaseFlight`.
107
119
  */
108
120
  detached?: boolean;
109
121
  }
@@ -121,6 +133,7 @@ export const MediaSurface = memo(function MediaSurface({
121
133
  style,
122
134
  contentFit = 'contain',
123
135
  surfaceType = 'textureView',
136
+ renderVideo,
124
137
  nativeControls = false,
125
138
  accessibilityLabel,
126
139
  pointerEvents,
@@ -141,10 +154,12 @@ export const MediaSurface = memo(function MediaSurface({
141
154
  }, [flightId]);
142
155
 
143
156
  const still = content.kind === 'video' ? content.poster : content.uri;
144
- // Loaded for the video arm only, and only when there is one an image
145
- // surface must never make an app resolve an optional native peer.
146
- const expoVideo = content.kind === 'video' ? loadExpoVideo() : null;
147
- if (content.kind === 'video' && expoVideo === null) {
157
+ // Loaded for the video arm only, and only when Bloom is the one building the
158
+ // element — an image surface must never make an app resolve an optional
159
+ // native peer, and neither must a consumer that brought its own view.
160
+ const expoVideo =
161
+ content.kind === 'video' && renderVideo === undefined ? loadExpoVideo() : null;
162
+ if (content.kind === 'video' && renderVideo === undefined && expoVideo === null) {
148
163
  // Degrade to the poster rather than to nothing: a black hole where a video
149
164
  // should be reads as a broken app, a still frame reads as a video that has
150
165
  // not started. The warning is what makes the difference visible to the
@@ -174,7 +189,16 @@ export const MediaSurface = memo(function MediaSurface({
174
189
  {...webDraggableProps}
175
190
  />
176
191
  )}
177
- {content.kind === 'video' && expoVideo !== null ? (
192
+ {content.kind === 'video' && renderVideo !== undefined
193
+ ? renderVideo({
194
+ player: detached ? null : content.player,
195
+ // The same style Bloom's own view gets, for the same reason: a
196
+ // replaced element that is not told its size paints at 300x150.
197
+ style: [StyleSheet.absoluteFill, styles.fillReplaced],
198
+ contentFit,
199
+ })
200
+ : null}
201
+ {content.kind === 'video' && renderVideo === undefined && expoVideo !== null ? (
178
202
  <expoVideo.VideoView
179
203
  player={detached ? null : content.player}
180
204
  contentFit={contentFit}
@@ -1,4 +1,5 @@
1
1
  export { MediaFlightLayer } from './MediaFlightLayer';
2
+ export { MediaFlightHost } from './MediaFlightHost';
2
3
  export { MediaSurface, MediaPoster, EmptyMediaSurface } from './MediaSurface';
3
4
  export { useMediaFlight } from './use-media-flight';
4
5
  export {
@@ -25,6 +26,7 @@ export type {
25
26
  MediaFlight,
26
27
  MediaFlightAnchorNode,
27
28
  MediaFlightController,
29
+ MediaFlightHostProps,
28
30
  MediaFlightOptions,
29
31
  MediaSurfaceContent,
30
32
  MediaSurfaceImage,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The web barrel, which exists to NAME the `.web` siblings.
3
+ *
4
+ * Export conditions do not apply to relative specifiers, so a bundler that is
5
+ * not Metro reaches `MediaFlightLayer.web.tsx` and `MediaFlightHost.web.tsx`
6
+ * only because this file spells them out. Those two are the whole web fork of
7
+ * this family: the layer that paints each shared media node once, and the host
8
+ * that claims it. Everything else below is the same module on both platforms.
9
+ */
10
+ export { MediaFlightLayer } from './MediaFlightLayer.web';
11
+ export { MediaFlightHost } from './MediaFlightHost.web';
12
+ export { MediaSurface, MediaPoster, EmptyMediaSurface } from './MediaSurface';
13
+ export { useMediaFlight } from './use-media-flight';
14
+ export {
15
+ DEFAULT_FLIGHT_CORNER_RADIUS,
16
+ flightProgress,
17
+ handOffFlight,
18
+ hasFlight,
19
+ releaseFlight,
20
+ resetMediaFlight,
21
+ } from './store';
22
+ export { SURFACE_MOUNT_TIMEOUT_MS } from './constants';
23
+ export {
24
+ loadExpoVideo,
25
+ warnExpoVideoUnavailable,
26
+ provideExpoVideo,
27
+ resetExpoVideoModule,
28
+ type ExpoVideoLike,
29
+ type VideoPlayerLike,
30
+ type VideoSurfaceContentFit,
31
+ type VideoSurfaceType,
32
+ type VideoViewLikeProps,
33
+ } from './expo-video-module';
34
+ export type {
35
+ MediaFlight,
36
+ MediaFlightAnchorNode,
37
+ MediaFlightController,
38
+ MediaFlightHostProps,
39
+ MediaFlightOptions,
40
+ MediaSurfaceContent,
41
+ MediaSurfaceImage,
42
+ MediaSurfaceVideo,
43
+ MeasuredRect,
44
+ } from './types';
45
+ export type { MediaSurfaceProps } from './MediaSurface';