@oxyhq/bloom 1.2.1 → 1.3.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.
- package/docs/getting-started.mdx +29 -0
- package/docs/media-flight.mdx +108 -0
- package/lib/commonjs/media-flight/MediaFlightHost.js +56 -0
- package/lib/commonjs/media-flight/MediaFlightHost.js.map +1 -0
- package/lib/commonjs/media-flight/MediaFlightHost.web.js +85 -0
- package/lib/commonjs/media-flight/MediaFlightHost.web.js.map +1 -0
- package/lib/commonjs/media-flight/MediaFlightLayer.js +9 -1
- package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -1
- package/lib/commonjs/media-flight/MediaFlightLayer.web.js +212 -0
- package/lib/commonjs/media-flight/MediaFlightLayer.web.js.map +1 -0
- package/lib/commonjs/media-flight/MediaSurface.js +3 -2
- package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
- package/lib/commonjs/media-flight/index.js +8 -1
- package/lib/commonjs/media-flight/index.js.map +1 -1
- package/lib/commonjs/media-flight/index.web.js +115 -0
- package/lib/commonjs/media-flight/index.web.js.map +1 -0
- package/lib/commonjs/media-flight/media-node.web.js +291 -0
- package/lib/commonjs/media-flight/media-node.web.js.map +1 -0
- package/lib/commonjs/media-flight/store.js +61 -2
- package/lib/commonjs/media-flight/store.js.map +1 -1
- package/lib/module/media-flight/MediaFlightHost.js +51 -0
- package/lib/module/media-flight/MediaFlightHost.js.map +1 -0
- package/lib/module/media-flight/MediaFlightHost.web.js +80 -0
- package/lib/module/media-flight/MediaFlightHost.web.js.map +1 -0
- package/lib/module/media-flight/MediaFlightLayer.js +9 -1
- package/lib/module/media-flight/MediaFlightLayer.js.map +1 -1
- package/lib/module/media-flight/MediaFlightLayer.web.js +206 -0
- package/lib/module/media-flight/MediaFlightLayer.web.js.map +1 -0
- package/lib/module/media-flight/MediaSurface.js +3 -2
- package/lib/module/media-flight/MediaSurface.js.map +1 -1
- package/lib/module/media-flight/index.js +2 -1
- package/lib/module/media-flight/index.js.map +1 -1
- package/lib/module/media-flight/index.web.js +19 -0
- package/lib/module/media-flight/index.web.js.map +1 -0
- package/lib/module/media-flight/media-node.web.js +281 -0
- package/lib/module/media-flight/media-node.web.js.map +1 -0
- package/lib/module/media-flight/store.js +61 -2
- package/lib/module/media-flight/store.js.map +1 -1
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts +6 -0
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts.map +1 -0
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts +6 -0
- package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts +5 -0
- package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +17 -0
- package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/index.d.ts +2 -1
- package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/index.web.d.ts +19 -0
- package/lib/typescript/commonjs/media-flight/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/media-flight/media-node.web.d.ts +79 -0
- package/lib/typescript/commonjs/media-flight/media-node.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/media-flight/store.d.ts +39 -1
- package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/types.d.ts +57 -0
- package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/MediaFlightHost.d.ts +6 -0
- package/lib/typescript/module/media-flight/MediaFlightHost.d.ts.map +1 -0
- package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts +6 -0
- package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts.map +1 -0
- package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts +5 -0
- package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts.map +1 -0
- package/lib/typescript/module/media-flight/MediaSurface.d.ts +17 -0
- package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/index.d.ts +2 -1
- package/lib/typescript/module/media-flight/index.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/index.web.d.ts +19 -0
- package/lib/typescript/module/media-flight/index.web.d.ts.map +1 -0
- package/lib/typescript/module/media-flight/media-node.web.d.ts +79 -0
- package/lib/typescript/module/media-flight/media-node.web.d.ts.map +1 -0
- package/lib/typescript/module/media-flight/store.d.ts +39 -1
- package/lib/typescript/module/media-flight/store.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/types.d.ts +57 -0
- package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/media-flight/MediaFlightHost.tsx +54 -0
- package/src/media-flight/MediaFlightHost.web.tsx +84 -0
- package/src/media-flight/MediaFlightLayer.tsx +7 -1
- package/src/media-flight/MediaFlightLayer.web.tsx +219 -0
- package/src/media-flight/MediaSurface.tsx +19 -1
- package/src/media-flight/index.ts +2 -0
- package/src/media-flight/index.web.ts +45 -0
- package/src/media-flight/media-node.web.ts +335 -0
- package/src/media-flight/store.ts +61 -1
- package/src/media-flight/types.ts +58 -0
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
nativeControls = false,
|
|
27
|
+
accessibilityLabel,
|
|
28
|
+
surfaceType,
|
|
29
|
+
pointerEvents,
|
|
30
|
+
flightId,
|
|
31
|
+
}: MediaFlightHostProps) {
|
|
32
|
+
// A callback ref rather than an effect: the anchor must be measurable the
|
|
33
|
+
// moment the box is on screen, and a tap can come one frame later.
|
|
34
|
+
const setNode = useCallback(
|
|
35
|
+
(node: View | null) => registerAnchor(id, node),
|
|
36
|
+
[id],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View ref={setNode} style={style} pointerEvents={pointerEvents}>
|
|
41
|
+
<MediaSurface
|
|
42
|
+
content={content}
|
|
43
|
+
contentFit={contentFit}
|
|
44
|
+
nativeControls={nativeControls}
|
|
45
|
+
accessibilityLabel={accessibilityLabel}
|
|
46
|
+
surfaceType={surfaceType}
|
|
47
|
+
flightId={flightId}
|
|
48
|
+
style={StyleSheet.absoluteFill}
|
|
49
|
+
/>
|
|
50
|
+
</View>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
MediaFlightHost.displayName = 'MediaFlightHost';
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
nativeControls = false,
|
|
34
|
+
accessibilityLabel,
|
|
35
|
+
pointerEvents,
|
|
36
|
+
flightId,
|
|
37
|
+
}: MediaFlightHostProps) {
|
|
38
|
+
// State rather than a ref, because the claim runs in a layout effect and an
|
|
39
|
+
// effect cannot depend on a ref's mutation. One extra render at mount.
|
|
40
|
+
const [node, setNode] = useState<View | null>(null);
|
|
41
|
+
|
|
42
|
+
const setHost = useCallback(
|
|
43
|
+
(next: View | null) => {
|
|
44
|
+
registerAnchor(id, next);
|
|
45
|
+
setNode(next);
|
|
46
|
+
},
|
|
47
|
+
[id],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const render = useMemo<MediaNodeRender>(
|
|
51
|
+
() => ({ content, contentFit, nativeControls, accessibilityLabel, flightId }),
|
|
52
|
+
[content, contentFit, nativeControls, accessibilityLabel, flightId],
|
|
53
|
+
);
|
|
54
|
+
const renderRef = useRef(render);
|
|
55
|
+
|
|
56
|
+
// Declared FIRST so it runs first on mount: it seeds the ref the claim below
|
|
57
|
+
// reads, and afterwards it is what pushes prop changes into a live claim —
|
|
58
|
+
// in place, without releasing, because a release would move the node and a
|
|
59
|
+
// move away and back is a removal, which is what pauses a video.
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
renderRef.current = render;
|
|
62
|
+
const el = node as unknown as HTMLElement | null;
|
|
63
|
+
if (el !== null) claimMediaNode(id, el, HOST_RANK, render);
|
|
64
|
+
}, [id, node, render]);
|
|
65
|
+
|
|
66
|
+
// Owns the claim's LIFETIME, and only that: its cleanup must run when the
|
|
67
|
+
// host really goes away, never on a prop change.
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
const el = node as unknown as HTMLElement | null;
|
|
70
|
+
if (el === null) return undefined;
|
|
71
|
+
claimMediaNode(id, el, HOST_RANK, renderRef.current);
|
|
72
|
+
// A new host appearing mid-flight IS the hand-off. On native the
|
|
73
|
+
// destination has to decode a first frame before it can say this; here the
|
|
74
|
+
// node it just claimed is the one that has been playing since the origin,
|
|
75
|
+
// so mounting is the whole signal. An origin host does not reach this with
|
|
76
|
+
// a flight live — it claimed before the flight began.
|
|
77
|
+
if (hasFlight(id)) handOffFlight(id);
|
|
78
|
+
return () => releaseMediaNode(id, el, HOST_RANK);
|
|
79
|
+
}, [id, node]);
|
|
80
|
+
|
|
81
|
+
return <View ref={setHost} style={style} pointerEvents={pointerEvents} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
MediaFlightHost.displayName = 'MediaFlightHost';
|
|
@@ -88,7 +88,7 @@ MediaFlightLayer.displayName = 'MediaFlightLayer';
|
|
|
88
88
|
* work per frame is one absolutely-positioned node resizing.
|
|
89
89
|
*/
|
|
90
90
|
function MediaFlightSurface({ flight }: { flight: MediaFlight }) {
|
|
91
|
-
const { id, from, to, progress, content, cornerRadius, contentFit, surfaceType } = flight;
|
|
91
|
+
const { id, from, to, progress, content, cornerRadius, contentFit, surfaceType, unbinding } = flight;
|
|
92
92
|
|
|
93
93
|
// The COMMIT signal `flyTo`'s promise waits on, and the reason it is an effect
|
|
94
94
|
// rather than anything derived: what the caller needs to know is that React
|
|
@@ -128,6 +128,12 @@ function MediaFlightSurface({ flight }: { flight: MediaFlight }) {
|
|
|
128
128
|
content={content}
|
|
129
129
|
contentFit={contentFit}
|
|
130
130
|
surfaceType={surfaceType}
|
|
131
|
+
// One commit with no player before this node leaves the DOM — see
|
|
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.
|
|
136
|
+
detached={unbinding}
|
|
131
137
|
// A flying surface is a picture, never a control: it must not intercept
|
|
132
138
|
// the press that is already travelling to whatever is underneath it.
|
|
133
139
|
pointerEvents="none"
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
useMemo,
|
|
34
|
+
useRef,
|
|
35
|
+
useState,
|
|
36
|
+
useSyncExternalStore,
|
|
37
|
+
} from 'react';
|
|
38
|
+
import { StyleSheet, type View } from 'react-native';
|
|
39
|
+
import { createPortal } from 'react-dom';
|
|
40
|
+
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
|
|
41
|
+
|
|
42
|
+
import { OverlayRoot } from '../overlay';
|
|
43
|
+
import { Portal } from '../portal/index.web';
|
|
44
|
+
import { MediaSurface, type MediaSurfaceStyle } from './MediaSurface';
|
|
45
|
+
import {
|
|
46
|
+
FLIGHT_RANK,
|
|
47
|
+
claimMediaNode,
|
|
48
|
+
getMediaNodes,
|
|
49
|
+
releaseMediaNode,
|
|
50
|
+
subscribeToMediaNodes,
|
|
51
|
+
type MediaNodeRender,
|
|
52
|
+
type MediaNodeView,
|
|
53
|
+
} from './media-node.web';
|
|
54
|
+
import { getFlights, notifySurfaceMounted, subscribeToFlights } from './store';
|
|
55
|
+
import type { MediaFlight } from './types';
|
|
56
|
+
|
|
57
|
+
export function MediaFlightLayer() {
|
|
58
|
+
const flights = useSyncExternalStore(subscribeToFlights, getFlights, getFlights);
|
|
59
|
+
const nodes = useSyncExternalStore(subscribeToMediaNodes, getMediaNodes, getMediaNodes);
|
|
60
|
+
|
|
61
|
+
if (flights.length === 0 && nodes.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{nodes.map((node) => (
|
|
66
|
+
<SharedMediaNode key={node.id} node={node} />
|
|
67
|
+
))}
|
|
68
|
+
{flights.length === 0 ? null : (
|
|
69
|
+
<Portal>
|
|
70
|
+
{/* `OverlayRoot` fills the viewport, is `box-none`, and takes this
|
|
71
|
+
surface's place in the open-order overlay stack. */}
|
|
72
|
+
<OverlayRoot>
|
|
73
|
+
{flights.map((flight) => (
|
|
74
|
+
<MediaFlightSurface
|
|
75
|
+
key={flight.id}
|
|
76
|
+
flight={flight}
|
|
77
|
+
// Read from the SNAPSHOT, never from the registry: a lookup
|
|
78
|
+
// during render is external mutable state in a memoized
|
|
79
|
+
// position, which the React Compiler is allowed to freeze.
|
|
80
|
+
shared={nodes.some((node) => node.id === flight.id)}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
</OverlayRoot>
|
|
84
|
+
</Portal>
|
|
85
|
+
)}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
MediaFlightLayer.displayName = 'MediaFlightLayer';
|
|
91
|
+
|
|
92
|
+
/** One media element, painted into the wrapper its id owns, and never again. */
|
|
93
|
+
function SharedMediaNode({ node }: { node: MediaNodeView }) {
|
|
94
|
+
const { content, contentFit, nativeControls, accessibilityLabel, flightId } = node.render;
|
|
95
|
+
return createPortal(
|
|
96
|
+
<MediaSurface
|
|
97
|
+
content={content}
|
|
98
|
+
contentFit={contentFit}
|
|
99
|
+
nativeControls={nativeControls}
|
|
100
|
+
accessibilityLabel={accessibilityLabel}
|
|
101
|
+
flightId={flightId}
|
|
102
|
+
style={StyleSheet.absoluteFill}
|
|
103
|
+
/>,
|
|
104
|
+
node.wrapper,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One flying surface. See `MediaFlightLayer.tsx` for why the size is animated
|
|
110
|
+
* rather than scaled.
|
|
111
|
+
*/
|
|
112
|
+
function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: boolean }) {
|
|
113
|
+
const { id, from, to, progress, content, cornerRadius, contentFit, surfaceType, unbinding } = flight;
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
notifySurfaceMounted(id);
|
|
117
|
+
}, [id]);
|
|
118
|
+
|
|
119
|
+
const originX = from.x - to.x;
|
|
120
|
+
const originY = from.y - to.y;
|
|
121
|
+
|
|
122
|
+
// CRITICAL — every shared value a mapper READS must be listed in its deps.
|
|
123
|
+
// On web WITHOUT the react-native-worklets babel plugin, reanimated drives a
|
|
124
|
+
// mapper off its deps array rather than off the reads it detects: with none,
|
|
125
|
+
// it runs ONCE and freezes at the opening frame. See `MediaFlightLayer.tsx`.
|
|
126
|
+
const boxStyle = useAnimatedStyle(
|
|
127
|
+
() => ({
|
|
128
|
+
transform: [
|
|
129
|
+
{ translateX: interpolate(progress.value, [0, 1], [originX, 0]) },
|
|
130
|
+
{ translateY: interpolate(progress.value, [0, 1], [originY, 0]) },
|
|
131
|
+
],
|
|
132
|
+
width: interpolate(progress.value, [0, 1], [from.width, to.width]),
|
|
133
|
+
height: interpolate(progress.value, [0, 1], [from.height, to.height]),
|
|
134
|
+
}),
|
|
135
|
+
[progress, originX, originY, from.width, from.height, to.width, to.height],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const box = [styles.box, { left: to.x, top: to.y, borderRadius: cornerRadius }, boxStyle];
|
|
139
|
+
|
|
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
|
+
// The shared node is already painting this media. The flight is then a BOX
|
|
153
|
+
// that borrows it for the length of the leg, never a second copy.
|
|
154
|
+
if (shared) return <FlyingSharedNode id={id} style={box} render={render} />;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<MediaSurface
|
|
158
|
+
content={content}
|
|
159
|
+
contentFit={contentFit}
|
|
160
|
+
surfaceType={surfaceType}
|
|
161
|
+
// One commit with no player before this node leaves the DOM — see
|
|
162
|
+
// `releaseFlight`. Only the no-shared-node path can need it: a shared
|
|
163
|
+
// node is never removed, which is the point of the whole mechanism.
|
|
164
|
+
detached={unbinding}
|
|
165
|
+
// A flying surface is a picture, never a control.
|
|
166
|
+
pointerEvents="none"
|
|
167
|
+
style={box}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The flight's own claim on a shared node.
|
|
174
|
+
*
|
|
175
|
+
* `pointerEvents` stays `none` on the BOX: a flying surface must not take the
|
|
176
|
+
* press travelling to whatever is underneath it. The wrapper inside sets
|
|
177
|
+
* `pointer-events: auto` for the media's sake, which is what a host needs and
|
|
178
|
+
* is inert here — a descendant asking for `auto` does not override a `none`
|
|
179
|
+
* ancestor.
|
|
180
|
+
*/
|
|
181
|
+
function FlyingSharedNode({
|
|
182
|
+
id,
|
|
183
|
+
style,
|
|
184
|
+
render,
|
|
185
|
+
}: {
|
|
186
|
+
id: string;
|
|
187
|
+
style: MediaSurfaceStyle;
|
|
188
|
+
render: MediaNodeRender;
|
|
189
|
+
}) {
|
|
190
|
+
const [node, setNode] = useState<View | null>(null);
|
|
191
|
+
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
|
+
|
|
203
|
+
// Owns the claim's LIFETIME, and only that.
|
|
204
|
+
useLayoutEffect(() => {
|
|
205
|
+
const el = node as unknown as HTMLElement | null;
|
|
206
|
+
if (el === null) return undefined;
|
|
207
|
+
claimMediaNode(id, el, FLIGHT_RANK, renderRef.current);
|
|
208
|
+
return () => releaseMediaNode(id, el, FLIGHT_RANK);
|
|
209
|
+
}, [id, node]);
|
|
210
|
+
|
|
211
|
+
return <Animated.View ref={setBox} style={style} pointerEvents="none" />;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const styles = StyleSheet.create({
|
|
215
|
+
box: {
|
|
216
|
+
position: 'absolute',
|
|
217
|
+
overflow: 'hidden',
|
|
218
|
+
},
|
|
219
|
+
});
|
|
@@ -93,6 +93,23 @@ 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, so a dying
|
|
102
|
+
* element pauses the one the viewer is watching.
|
|
103
|
+
*
|
|
104
|
+
* Setting this for one commit runs expo-video's own `[props.player]` effect
|
|
105
|
+
* early — and the half that matters is `removeAttribute('src'); load()`
|
|
106
|
+
* (`VideoView.web.js:206`), not the unbind beside it. The media element load
|
|
107
|
+
* algorithm pauses WITHOUT firing `pause` (measured in real Chrome, with
|
|
108
|
+
* controls), so the element goes quiet and its later removal fires nothing.
|
|
109
|
+
* Unbinding alone does not help: `unmountVideoView` never clears the handler
|
|
110
|
+
* it installed. See `releaseFlight`.
|
|
111
|
+
*/
|
|
112
|
+
detached?: boolean;
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
/**
|
|
@@ -112,6 +129,7 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
112
129
|
accessibilityLabel,
|
|
113
130
|
pointerEvents,
|
|
114
131
|
flightId,
|
|
132
|
+
detached = false,
|
|
115
133
|
}: MediaSurfaceProps) {
|
|
116
134
|
// Captured once — see `surfaceType` above. A consumer changing it later gets
|
|
117
135
|
// the value the view was mounted with, which is the only value expo-video
|
|
@@ -162,7 +180,7 @@ export const MediaSurface = memo(function MediaSurface({
|
|
|
162
180
|
)}
|
|
163
181
|
{content.kind === 'video' && expoVideo !== null ? (
|
|
164
182
|
<expoVideo.VideoView
|
|
165
|
-
player={content.player}
|
|
183
|
+
player={detached ? null : content.player}
|
|
166
184
|
contentFit={contentFit}
|
|
167
185
|
surfaceType={mountedSurfaceType}
|
|
168
186
|
nativeControls={nativeControls}
|
|
@@ -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';
|