@oxyhq/bloom 1.3.0 → 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 (50) hide show
  1. package/docs/media-flight.mdx +46 -0
  2. package/lib/commonjs/media-flight/MediaFlightHost.js +2 -0
  3. package/lib/commonjs/media-flight/MediaFlightHost.js.map +1 -1
  4. package/lib/commonjs/media-flight/MediaFlightHost.web.js +3 -1
  5. package/lib/commonjs/media-flight/MediaFlightHost.web.js.map +1 -1
  6. package/lib/commonjs/media-flight/MediaFlightLayer.web.js +10 -24
  7. package/lib/commonjs/media-flight/MediaFlightLayer.web.js.map +1 -1
  8. package/lib/commonjs/media-flight/MediaSurface.js +13 -5
  9. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
  10. package/lib/commonjs/media-flight/media-node.web.js +29 -6
  11. package/lib/commonjs/media-flight/media-node.web.js.map +1 -1
  12. package/lib/module/media-flight/MediaFlightHost.js +2 -0
  13. package/lib/module/media-flight/MediaFlightHost.js.map +1 -1
  14. package/lib/module/media-flight/MediaFlightHost.web.js +3 -1
  15. package/lib/module/media-flight/MediaFlightHost.web.js.map +1 -1
  16. package/lib/module/media-flight/MediaFlightLayer.web.js +11 -25
  17. package/lib/module/media-flight/MediaFlightLayer.web.js.map +1 -1
  18. package/lib/module/media-flight/MediaSurface.js +13 -5
  19. package/lib/module/media-flight/MediaSurface.js.map +1 -1
  20. package/lib/module/media-flight/media-node.web.js +29 -6
  21. package/lib/module/media-flight/media-node.web.js.map +1 -1
  22. package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts +1 -1
  23. package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts +1 -1
  25. package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +9 -1
  28. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/media-flight/media-node.web.d.ts +17 -3
  30. package/lib/typescript/commonjs/media-flight/media-node.web.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/media-flight/types.d.ts +49 -0
  32. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
  33. package/lib/typescript/module/media-flight/MediaFlightHost.d.ts +1 -1
  34. package/lib/typescript/module/media-flight/MediaFlightHost.d.ts.map +1 -1
  35. package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts +1 -1
  36. package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts.map +1 -1
  37. package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts.map +1 -1
  38. package/lib/typescript/module/media-flight/MediaSurface.d.ts +9 -1
  39. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
  40. package/lib/typescript/module/media-flight/media-node.web.d.ts +17 -3
  41. package/lib/typescript/module/media-flight/media-node.web.d.ts.map +1 -1
  42. package/lib/typescript/module/media-flight/types.d.ts +49 -0
  43. package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/media-flight/MediaFlightHost.tsx +2 -0
  46. package/src/media-flight/MediaFlightHost.web.tsx +3 -2
  47. package/src/media-flight/MediaFlightLayer.web.tsx +10 -38
  48. package/src/media-flight/MediaSurface.tsx +26 -6
  49. package/src/media-flight/media-node.web.ts +48 -10
  50. package/src/media-flight/types.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "1.3.0",
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",
@@ -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';
@@ -48,7 +46,6 @@ import {
48
46
  getMediaNodes,
49
47
  releaseMediaNode,
50
48
  subscribeToMediaNodes,
51
- type MediaNodeRender,
52
49
  type MediaNodeView,
53
50
  } from './media-node.web';
54
51
  import { getFlights, notifySurfaceMounted, subscribeToFlights } from './store';
@@ -91,11 +88,12 @@ MediaFlightLayer.displayName = 'MediaFlightLayer';
91
88
 
92
89
  /** One media element, painted into the wrapper its id owns, and never again. */
93
90
  function SharedMediaNode({ node }: { node: MediaNodeView }) {
94
- const { content, contentFit, nativeControls, accessibilityLabel, flightId } = node.render;
91
+ const { content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId } = node.render;
95
92
  return createPortal(
96
93
  <MediaSurface
97
94
  content={content}
98
95
  contentFit={contentFit}
96
+ renderVideo={renderVideo}
99
97
  nativeControls={nativeControls}
100
98
  accessibilityLabel={accessibilityLabel}
101
99
  flightId={flightId}
@@ -137,21 +135,9 @@ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: b
137
135
 
138
136
  const box = [styles.box, { left: to.x, top: to.y, borderRadius: cornerRadius }, boxStyle];
139
137
 
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
138
  // The shared node is already painting this media. The flight is then a BOX
153
139
  // that borrows it for the length of the leg, never a second copy.
154
- if (shared) return <FlyingSharedNode id={id} style={box} render={render} />;
140
+ if (shared) return <FlyingSharedNode id={id} style={box} />;
155
141
 
156
142
  return (
157
143
  <MediaSurface
@@ -178,33 +164,19 @@ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: b
178
164
  * is inert here — a descendant asking for `auto` does not override a `none`
179
165
  * ancestor.
180
166
  */
181
- function FlyingSharedNode({
182
- id,
183
- style,
184
- render,
185
- }: {
186
- id: string;
187
- style: MediaSurfaceStyle;
188
- render: MediaNodeRender;
189
- }) {
167
+ function FlyingSharedNode({ id, style }: { id: string; style: MediaSurfaceStyle }) {
190
168
  const [node, setNode] = useState<View | null>(null);
191
169
  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
170
 
203
- // Owns the claim's LIFETIME, and only that.
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.
204
176
  useLayoutEffect(() => {
205
177
  const el = node as unknown as HTMLElement | null;
206
178
  if (el === null) return undefined;
207
- claimMediaNode(id, el, FLIGHT_RANK, renderRef.current);
179
+ claimMediaNode(id, el, FLIGHT_RANK);
208
180
  return () => releaseMediaNode(id, el, FLIGHT_RANK);
209
181
  }, [id, node]);
210
182
 
@@ -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;
@@ -125,6 +133,7 @@ export const MediaSurface = memo(function MediaSurface({
125
133
  style,
126
134
  contentFit = 'contain',
127
135
  surfaceType = 'textureView',
136
+ renderVideo,
128
137
  nativeControls = false,
129
138
  accessibilityLabel,
130
139
  pointerEvents,
@@ -145,10 +154,12 @@ export const MediaSurface = memo(function MediaSurface({
145
154
  }, [flightId]);
146
155
 
147
156
  const still = content.kind === 'video' ? content.poster : content.uri;
148
- // Loaded for the video arm only, and only when there is one an image
149
- // surface must never make an app resolve an optional native peer.
150
- const expoVideo = content.kind === 'video' ? loadExpoVideo() : null;
151
- 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) {
152
163
  // Degrade to the poster rather than to nothing: a black hole where a video
153
164
  // should be reads as a broken app, a still frame reads as a video that has
154
165
  // not started. The warning is what makes the difference visible to the
@@ -178,7 +189,16 @@ export const MediaSurface = memo(function MediaSurface({
178
189
  {...webDraggableProps}
179
190
  />
180
191
  )}
181
- {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 ? (
182
202
  <expoVideo.VideoView
183
203
  player={detached ? null : content.player}
184
204
  contentFit={contentFit}
@@ -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
- render: MediaNodeRender;
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
- record = { wrapper: makeWrapper(), claims: [], render };
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 record.claims) {
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 = topClaim(record)?.render ?? 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: MediaNodeRender,
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
- if (sameRender(existing.render, render) && record.wrapper.parentElement === topClaim(record)?.el) {
266
- return;
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;
@@ -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;