@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
@@ -0,0 +1,373 @@
1
+ /**
2
+ * ONE DOM node per media id, for its whole life.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * expo-video's web player keeps a SET of mounted `<video>` elements and keeps
7
+ * them in step: `mountVideoView` adds an element and then copies play state and
8
+ * `currentTime` onto it from `[...set][0]`, and `_addListeners` gives every
9
+ * element an `onpause` that pauses all the OTHERS. `unmountVideoView` deletes
10
+ * the element from the set and nothing more — the handlers it installed are
11
+ * never removed by anything in the package.
12
+ *
13
+ * So an element on its way out still governs the player. The browser pauses a
14
+ * `<video>` the moment it leaves the document, that pause runs the dead
15
+ * element's handler, and the handler stops the element the viewer is watching.
16
+ *
17
+ * Measured against the real `VideoPlayerWeb` (expo-video 57.0.2), across every
18
+ * topology a consumer can produce on one shared player:
19
+ *
20
+ * ```
21
+ * origin dies BEFORE the destination mounts ct=0 playing (restarts)
22
+ * origin dies AFTER the destination mounts ct=4.28 paused (freezes)
23
+ * one element, re-parented ct=4.28 playing
24
+ * ```
25
+ *
26
+ * TWO ELEMENTS GIVE THE POSITION OR THE PLAYBACK, NEVER BOTH. That is not a
27
+ * property of the flight layer — the first two rows have no Bloom surface in
28
+ * them at all — it is a property of two elements sharing one player. The only
29
+ * topology that gives both is one element that is never unmounted and never
30
+ * removed from the document.
31
+ *
32
+ * ## How a node survives its host
33
+ *
34
+ * A `<video>` React renders belongs to React: when the host unmounts, React
35
+ * removes it, and if anything has moved it in the meantime React's
36
+ * `parent.removeChild(node)` throws. So the node cannot be owned by either end.
37
+ *
38
+ * Instead Bloom creates a WRAPPER outside React (`document.createElement`) and
39
+ * the layer renders the media into it with `createPortal`. A portal's container
40
+ * is not part of React's tree — React reconciles the media against the wrapper
41
+ * and never against the document — so moving the wrapper between hosts with
42
+ * `appendChild` is invisible to React. The container's IDENTITY never changes,
43
+ * which is the whole trick: `createPortal` to a container that CHANGES unmounts
44
+ * and remounts, which is exactly what we are avoiding.
45
+ *
46
+ * And a synchronous move does not pause the video. HTML's removal steps for a
47
+ * media element await a stable state and then abort if the element is back in a
48
+ * document — so `appendChild` from one parent to another is not a removal as
49
+ * far as playback is concerned. (This is why re-parenting a `<video>` works and
50
+ * re-parenting an `<iframe>` does not: an iframe reloads.)
51
+ *
52
+ * ## Claims
53
+ *
54
+ * Hosts do not move the node themselves; they CLAIM the id and the registry
55
+ * decides. A flight outranks a host, so the layer keeps the node for the whole
56
+ * leg even though the destination has already mounted and claimed it — and the
57
+ * frame the flight lets go is the frame the destination takes it, with no gap
58
+ * and no second element. With no claim at all the node parks in a holder that
59
+ * is attached to the document but invisible, because a parked node that was
60
+ * DETACHED would pause.
61
+ */
62
+ import { hasFlight } from './store';
63
+ import type { MediaSurfaceContent, MediaVideoSlot } from './types';
64
+
65
+ /** What the layer paints into a shared node, as its current holder wants it. */
66
+ export interface MediaNodeRender {
67
+ content: MediaSurfaceContent;
68
+ contentFit: 'contain' | 'cover';
69
+ /** The consumer's own video element, if it brought one. Compared by identity. */
70
+ renderVideo: MediaVideoSlot | undefined;
71
+ nativeControls: boolean;
72
+ accessibilityLabel: string | undefined;
73
+ /** Forwarded to the media, so a consumer keeps its own first-frame signal. */
74
+ flightId: string | undefined;
75
+ }
76
+
77
+ /** A flight outranks a host: the layer holds the node until the leg ends. */
78
+ export const HOST_RANK = 0;
79
+ export const FLIGHT_RANK = 1;
80
+
81
+ interface MediaNodeClaim {
82
+ el: HTMLElement;
83
+ rank: number;
84
+ /** Registration order, so the most recent host wins among equals. */
85
+ seq: number;
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;
99
+ }
100
+
101
+ interface MediaNodeRecord {
102
+ wrapper: HTMLDivElement;
103
+ claims: MediaNodeClaim[];
104
+ /**
105
+ * How the media is painted right now: the top claim's, or — while the node is
106
+ * PARKED between hosts — the last claim's.
107
+ *
108
+ * Parking has to keep painting. A node the layer stopped rendering would lose
109
+ * its element to React, which is the removal this whole module exists to
110
+ * avoid, and it would lose it exactly in the gap between the origin
111
+ * unmounting and the destination mounting: the moment of the flight.
112
+ */
113
+ render: MediaNodeRender;
114
+ }
115
+
116
+ /** What the layer needs in order to paint one shared node. */
117
+ export interface MediaNodeView {
118
+ id: string;
119
+ wrapper: HTMLDivElement;
120
+ render: MediaNodeRender;
121
+ }
122
+
123
+ interface NodeRegistry {
124
+ nodes: Map<string, MediaNodeRecord>;
125
+ listeners: Set<() => void>;
126
+ /** Cached for `useSyncExternalStore`, which compares snapshots by identity. */
127
+ snapshot: readonly MediaNodeView[];
128
+ holder: HTMLElement | null;
129
+ seq: number;
130
+ }
131
+
132
+ declare global {
133
+ // eslint-disable-next-line no-var
134
+ var __oxyhq_bloom_media_nodes__: NodeRegistry | undefined;
135
+ }
136
+
137
+ function registry(): NodeRegistry {
138
+ globalThis.__oxyhq_bloom_media_nodes__ ??= {
139
+ nodes: new Map(),
140
+ listeners: new Set(),
141
+ snapshot: [],
142
+ holder: null,
143
+ seq: 0,
144
+ };
145
+ return globalThis.__oxyhq_bloom_media_nodes__;
146
+ }
147
+
148
+ /** Where a node with no holder waits: in the document, and not on screen. */
149
+ const HOLDER_ID = 'bloom-media-holder';
150
+
151
+ function holder(reg: NodeRegistry): HTMLElement | null {
152
+ if (typeof document === 'undefined') return null;
153
+ if (reg.holder !== null && reg.holder.isConnected) return reg.holder;
154
+ const existing = document.getElementById(HOLDER_ID);
155
+ if (existing !== null) {
156
+ reg.holder = existing;
157
+ return existing;
158
+ }
159
+ const created = document.createElement('div');
160
+ created.id = HOLDER_ID;
161
+ // Attached, so nothing pauses; 1x1 and transparent, so nothing shows. NOT
162
+ // `display: none` — a video in a `display: none` subtree keeps playing in
163
+ // every browser that matters, but it also stops presenting frames, and the
164
+ // point of parking is that the media survives untouched.
165
+ created.style.cssText =
166
+ 'position:fixed;top:0;left:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;';
167
+ created.setAttribute('aria-hidden', 'true');
168
+ document.body.appendChild(created);
169
+ reg.holder = created;
170
+ return created;
171
+ }
172
+
173
+ function makeWrapper(): HTMLDivElement {
174
+ const wrapper = document.createElement('div');
175
+ // Fills whichever box currently hosts it. `pointer-events: auto` is explicit
176
+ // and load-bearing: a host may sit inside a `pointer-events: none` layer (a
177
+ // poster overlay, a flying surface), and the media inside this wrapper is
178
+ // the one thing in there that a viewer may need to press.
179
+ wrapper.style.cssText =
180
+ 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:auto;';
181
+ wrapper.setAttribute('data-bloom-media-node', '');
182
+ return wrapper;
183
+ }
184
+
185
+ function recordFor(id: string, render: MediaNodeRender | undefined): MediaNodeRecord {
186
+ const reg = registry();
187
+ let record = reg.nodes.get(id);
188
+ if (!record) {
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 };
193
+ reg.nodes.set(id, record);
194
+ }
195
+ return record;
196
+ }
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
+
208
+ /** The claim that currently holds the node: highest rank, then most recent. */
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 {
219
+ let best: MediaNodeClaim | null = null;
220
+ for (const claim of claims) {
221
+ if (best === null || claim.rank > best.rank || (claim.rank === best.rank && claim.seq > best.seq)) {
222
+ best = claim;
223
+ }
224
+ }
225
+ return best;
226
+ }
227
+
228
+ /**
229
+ * Put the wrapper where its top claim says, in ONE synchronous move.
230
+ *
231
+ * The `parentElement` check is not an optimisation: re-appending to the same
232
+ * parent is a remove-then-insert, and doing that per render would put the media
233
+ * through the removal path it exists to avoid.
234
+ */
235
+ function place(record: MediaNodeRecord): void {
236
+ const reg = registry();
237
+ const target = topClaim(record)?.el ?? holder(reg);
238
+ if (target === null) return;
239
+ if (record.wrapper.parentElement === target) return;
240
+ target.appendChild(record.wrapper);
241
+ }
242
+
243
+ function sameRender(a: MediaNodeRender, b: MediaNodeRender): boolean {
244
+ if (a.contentFit !== b.contentFit) return false;
245
+ if (a.renderVideo !== b.renderVideo) return false;
246
+ if (a.nativeControls !== b.nativeControls) return false;
247
+ if (a.accessibilityLabel !== b.accessibilityLabel) return false;
248
+ if (a.flightId !== b.flightId) return false;
249
+ const x = a.content;
250
+ const y = b.content;
251
+ if (x.kind !== y.kind) return false;
252
+ if (x.kind === 'video' && y.kind === 'video') {
253
+ return x.player === y.player && x.poster === y.poster;
254
+ }
255
+ return x.kind !== 'video' && y.kind !== 'video' && x.uri === y.uri;
256
+ }
257
+
258
+ function publish(reg: NodeRegistry): void {
259
+ const next: MediaNodeView[] = [];
260
+ for (const [id, record] of reg.nodes) {
261
+ record.render = topRender(record)?.render ?? record.render;
262
+ next.push({ id, wrapper: record.wrapper, render: record.render });
263
+ }
264
+ const previous = reg.snapshot;
265
+ const unchanged =
266
+ previous.length === next.length &&
267
+ previous.every((view, i) => {
268
+ const candidate = next[i];
269
+ return (
270
+ candidate !== undefined &&
271
+ candidate.id === view.id &&
272
+ candidate.wrapper === view.wrapper &&
273
+ sameRender(candidate.render, view.render)
274
+ );
275
+ });
276
+ // Identity is the signal `useSyncExternalStore` reads. Publishing a fresh
277
+ // array for an unchanged set renders the layer forever.
278
+ if (unchanged) {
279
+ for (const listener of reg.listeners) listener();
280
+ return;
281
+ }
282
+ reg.snapshot = next;
283
+ for (const listener of reg.listeners) listener();
284
+ }
285
+
286
+ /**
287
+ * Claim the shared node for `id` into `el`.
288
+ *
289
+ * Idempotent for an unchanged claim: called from a layout effect on every
290
+ * render, it moves the node once and publishes nothing afterwards.
291
+ */
292
+ export function claimMediaNode(
293
+ id: string,
294
+ el: HTMLElement,
295
+ rank: number,
296
+ render?: MediaNodeRender,
297
+ ): void {
298
+ const reg = registry();
299
+ const record = recordFor(id, render);
300
+ const existing = record.claims.find((claim) => claim.el === el && claim.rank === rank);
301
+ if (existing !== undefined) {
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;
306
+ existing.render = render;
307
+ } else {
308
+ reg.seq += 1;
309
+ record.claims.push({ el, rank, seq: reg.seq, render });
310
+ }
311
+ place(record);
312
+ publish(reg);
313
+ }
314
+
315
+ /**
316
+ * Drop a claim. The node goes to the next claim, or parks — or is DISPOSED, if
317
+ * nothing is going to want it back.
318
+ *
319
+ * "Nothing is going to want it back" is a fact the registry can read rather
320
+ * than a timeout it has to guess: no claim left AND no flight live for this id.
321
+ * During a navigation there IS a flight live — that is what a flight IS — so
322
+ * the node survives the gap between the origin unmounting and the destination
323
+ * mounting. Without a flight, an unmounted host is just a host that went away.
324
+ */
325
+ export function releaseMediaNode(id: string, el: HTMLElement, rank: number): void {
326
+ const reg = registry();
327
+ const record = reg.nodes.get(id);
328
+ if (record === undefined) return;
329
+ const before = record.claims.length;
330
+ record.claims = record.claims.filter((claim) => !(claim.el === el && claim.rank === rank));
331
+ if (record.claims.length === before) return;
332
+ if (record.claims.length === 0 && !hasFlight(id)) {
333
+ reg.nodes.delete(id);
334
+ record.wrapper.remove();
335
+ publish(reg);
336
+ return;
337
+ }
338
+ place(record);
339
+ publish(reg);
340
+ }
341
+
342
+ /** Whether anything holds a shared node for this id. */
343
+ export function hasMediaNode(id: string): boolean {
344
+ const record = registry().nodes.get(id);
345
+ return record !== undefined && record.claims.length > 0;
346
+ }
347
+
348
+ /** Subscribe to the shared-node set. The layer's `useSyncExternalStore` half. */
349
+ export function subscribeToMediaNodes(listener: () => void): () => void {
350
+ const reg = registry();
351
+ reg.listeners.add(listener);
352
+ return () => {
353
+ reg.listeners.delete(listener);
354
+ };
355
+ }
356
+
357
+ /** Every shared node that something is currently holding. Stable identity. */
358
+ export function getMediaNodes(): readonly MediaNodeView[] {
359
+ return registry().snapshot;
360
+ }
361
+
362
+ /** Test seam: drop every node and take its wrapper out of the document. */
363
+ export function resetMediaNodes(): void {
364
+ const reg = registry();
365
+ for (const record of reg.nodes.values()) record.wrapper.remove();
366
+ reg.nodes.clear();
367
+ reg.snapshot = [];
368
+ if (reg.holder !== null) {
369
+ reg.holder.remove();
370
+ reg.holder = null;
371
+ }
372
+ for (const listener of reg.listeners) listener();
373
+ }
@@ -414,10 +414,25 @@ export function flyBack(id: string): void {
414
414
  * nobody having called `pause()`.
415
415
  *
416
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.
417
+ * effect while the node is STILL in the DOM, and the operative part of that is
418
+ * NOT the unbind. `unmountVideoView` takes the element out of `_mountedVideos`
419
+ * and leaves the `onpause` handler `_addListeners` installed, so unbinding
420
+ * alone would still mirror. What saves it is the other half of the same
421
+ * branch: `removeAttribute('src'); load()` (`VideoView.web.js:206`). The media
422
+ * element load algorithm sets `paused` to true WITHOUT firing `pause`, so the
423
+ * element goes quiet, and its later removal fires nothing either because it is
424
+ * already paused.
425
+ *
426
+ * Measured in real Chrome, with controls: a playing element put through
427
+ * `removeAttribute('src') + load()` reports events `["play"]` and nothing more,
428
+ * while the same element paused by hand, or removed from the DOM while
429
+ * playing, reports `["play","pause"]`. Against real builds: without this commit
430
+ * the destination ends STOPPED (5/5), with it PLAYING (6/6) and the position
431
+ * carried.
432
+ *
433
+ * DO NOT replace this with a direct `unmountVideoView` call. It looks
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.
421
436
  *
422
437
  * The cost is one frame in which the outgoing surface shows its poster instead
423
438
  * of video. It is already on its way out and the destination is live by then —
@@ -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
 
3
5
  import type { VideoPlayerLike, VideoSurfaceType } from './expo-video-module';
@@ -221,3 +223,97 @@ export interface MediaFlightController {
221
223
  */
222
224
  progress: SharedValue<number>;
223
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
+
272
+ /**
273
+ * A place a media surface may live: the feed row it starts in, the fullscreen
274
+ * player it lands in, and every other end of a flight.
275
+ *
276
+ * A host DECLARES a box; it does not own the media. On web that distinction is
277
+ * the whole feature — the media is ONE DOM node the layer paints once and moves
278
+ * between hosts, so it survives the origin route unmounting mid-flight without
279
+ * ever leaving the document. On native a host renders its media inline, which
280
+ * is what it has always done and what the platform makes correct there.
281
+ */
282
+ export interface MediaFlightHostProps {
283
+ /** The id this host shares with the flight and with the other end. */
284
+ id: string;
285
+ /** What to paint: a still image, or a consumer-owned expo-video player. */
286
+ content: MediaSurfaceContent;
287
+ /** Style of the host BOX. Give it the radius and `overflow: 'hidden'` you want. */
288
+ style?: StyleProp<ViewStyle>;
289
+ /** How the media fills the box. Defaults to `'cover'`. */
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;
296
+ /** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
297
+ nativeControls?: boolean;
298
+ accessibilityLabel?: string;
299
+ /**
300
+ * Android's rendering surface for the video arm. See `MediaSurfaceProps`.
301
+ * Ignored on web, where there is no such choice.
302
+ */
303
+ surfaceType?: VideoSurfaceType;
304
+ /**
305
+ * Passed through as a PROP, never as a style key — react-native-web resolves
306
+ * `box-none`/`box-only` from the prop path only.
307
+ */
308
+ pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only';
309
+ /**
310
+ * Set on a DESTINATION host, to the id its flight was started with, when the
311
+ * destination paints its media ITSELF rather than through the shared node —
312
+ * which on native is always. The web host does not need it: a flight there
313
+ * ends when the destination host claims the node, because by then the node it
314
+ * is claiming has been showing the media all along.
315
+ *
316
+ * Leave it unset on an origin host, which would otherwise hand off to itself.
317
+ */
318
+ flightId?: string;
319
+ }