@oxyhq/bloom 1.0.6 → 1.2.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 (220) hide show
  1. package/docs/level-picker.mdx +105 -0
  2. package/docs/media-flight.mdx +272 -0
  3. package/docs/migrating-the-media-gallery.mdx +103 -0
  4. package/docs/migrating-to-1.0.mdx +1 -1
  5. package/docs/slider.mdx +3 -1
  6. package/docs/zoomable-media-gallery.mdx +116 -0
  7. package/lib/commonjs/floating/index.js.map +1 -1
  8. package/lib/commonjs/image-aspect-ratio-cache/aspect-ratio-cache.js +1 -1
  9. package/lib/commonjs/index.js +8 -0
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/index.web.js +33 -25
  12. package/lib/commonjs/index.web.js.map +1 -1
  13. package/lib/commonjs/level-picker/LevelPicker.js +349 -0
  14. package/lib/commonjs/level-picker/LevelPicker.js.map +1 -0
  15. package/lib/commonjs/level-picker/LevelPicker.web.js +346 -0
  16. package/lib/commonjs/level-picker/LevelPicker.web.js.map +1 -0
  17. package/lib/commonjs/level-picker/constants.js +137 -0
  18. package/lib/commonjs/level-picker/constants.js.map +1 -0
  19. package/lib/commonjs/level-picker/index.js +13 -0
  20. package/lib/commonjs/level-picker/index.js.map +1 -0
  21. package/lib/commonjs/level-picker/index.web.js +13 -0
  22. package/lib/commonjs/level-picker/index.web.js.map +1 -0
  23. package/lib/commonjs/level-picker/types.js +6 -0
  24. package/lib/commonjs/level-picker/types.js.map +1 -0
  25. package/lib/commonjs/media-flight/MediaFlightLayer.js +151 -0
  26. package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -0
  27. package/lib/commonjs/media-flight/MediaSurface.js +188 -0
  28. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -0
  29. package/lib/commonjs/media-flight/constants.js +39 -0
  30. package/lib/commonjs/media-flight/constants.js.map +1 -0
  31. package/lib/commonjs/media-flight/expo-video-module.js +148 -0
  32. package/lib/commonjs/media-flight/expo-video-module.js.map +1 -0
  33. package/lib/commonjs/media-flight/index.js +102 -0
  34. package/lib/commonjs/media-flight/index.js.map +1 -0
  35. package/lib/commonjs/media-flight/store.js +457 -0
  36. package/lib/commonjs/media-flight/store.js.map +1 -0
  37. package/lib/commonjs/media-flight/types.js +6 -0
  38. package/lib/commonjs/media-flight/types.js.map +1 -0
  39. package/lib/commonjs/media-flight/use-media-flight.js +41 -0
  40. package/lib/commonjs/media-flight/use-media-flight.js.map +1 -0
  41. package/lib/commonjs/tab-bar/TabBarBase.js +1 -1
  42. package/lib/commonjs/{zoomable-image-gallery/ZoomableImageGallery.js → zoomable-media-gallery/ZoomableMediaGallery.js} +185 -131
  43. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -0
  44. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.js.map +1 -1
  45. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.js +5 -5
  46. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.js.map +1 -1
  47. package/lib/commonjs/zoomable-media-gallery/types.js +6 -0
  48. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/types.js.map +1 -1
  49. package/lib/module/floating/index.js +5 -0
  50. package/lib/module/floating/index.js.map +1 -1
  51. package/lib/module/image-aspect-ratio-cache/aspect-ratio-cache.js +1 -1
  52. package/lib/module/index.js +3 -1
  53. package/lib/module/index.js.map +1 -1
  54. package/lib/module/index.web.js +3 -1
  55. package/lib/module/index.web.js.map +1 -1
  56. package/lib/module/level-picker/LevelPicker.js +343 -0
  57. package/lib/module/level-picker/LevelPicker.js.map +1 -0
  58. package/lib/module/level-picker/LevelPicker.web.js +340 -0
  59. package/lib/module/level-picker/LevelPicker.web.js.map +1 -0
  60. package/lib/module/level-picker/constants.js +131 -0
  61. package/lib/module/level-picker/constants.js.map +1 -0
  62. package/lib/module/level-picker/index.js +4 -0
  63. package/lib/module/level-picker/index.js.map +1 -0
  64. package/lib/module/level-picker/index.web.js +4 -0
  65. package/lib/module/level-picker/index.web.js.map +1 -0
  66. package/lib/module/level-picker/types.js +4 -0
  67. package/lib/module/level-picker/types.js.map +1 -0
  68. package/lib/module/media-flight/MediaFlightLayer.js +146 -0
  69. package/lib/module/media-flight/MediaFlightLayer.js.map +1 -0
  70. package/lib/module/media-flight/MediaSurface.js +180 -0
  71. package/lib/module/media-flight/MediaSurface.js.map +1 -0
  72. package/lib/module/media-flight/constants.js +35 -0
  73. package/lib/module/media-flight/constants.js.map +1 -0
  74. package/lib/module/media-flight/expo-video-module.js +142 -0
  75. package/lib/module/media-flight/expo-video-module.js.map +1 -0
  76. package/lib/module/media-flight/index.js +9 -0
  77. package/lib/module/media-flight/index.js.map +1 -0
  78. package/lib/module/media-flight/store.js +439 -0
  79. package/lib/module/media-flight/store.js.map +1 -0
  80. package/lib/module/media-flight/types.js +4 -0
  81. package/lib/module/media-flight/types.js.map +1 -0
  82. package/lib/module/media-flight/use-media-flight.js +36 -0
  83. package/lib/module/media-flight/use-media-flight.js.map +1 -0
  84. package/lib/module/tab-bar/TabBarBase.js +1 -1
  85. package/lib/module/{zoomable-image-gallery/ZoomableImageGallery.js → zoomable-media-gallery/ZoomableMediaGallery.js} +184 -130
  86. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -0
  87. package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.js.map +1 -1
  88. package/lib/module/zoomable-media-gallery/index.js +5 -0
  89. package/lib/module/zoomable-media-gallery/index.js.map +1 -0
  90. package/lib/module/zoomable-media-gallery/types.js +4 -0
  91. package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/types.js.map +1 -1
  92. package/lib/typescript/commonjs/floating/index.d.ts +5 -0
  93. package/lib/typescript/commonjs/floating/index.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/index.d.ts +2 -0
  95. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  97. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts +6 -0
  99. package/lib/typescript/commonjs/level-picker/LevelPicker.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts +6 -0
  101. package/lib/typescript/commonjs/level-picker/LevelPicker.web.d.ts.map +1 -0
  102. package/lib/typescript/commonjs/level-picker/constants.d.ts +93 -0
  103. package/lib/typescript/commonjs/level-picker/constants.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/level-picker/index.d.ts +3 -0
  105. package/lib/typescript/commonjs/level-picker/index.d.ts.map +1 -0
  106. package/lib/typescript/commonjs/level-picker/index.web.d.ts +3 -0
  107. package/lib/typescript/commonjs/level-picker/index.web.d.ts.map +1 -0
  108. package/lib/typescript/commonjs/level-picker/types.d.ts +63 -0
  109. package/lib/typescript/commonjs/level-picker/types.d.ts.map +1 -0
  110. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.d.ts +11 -0
  111. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.d.ts.map +1 -0
  112. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +106 -0
  113. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -0
  114. package/lib/typescript/commonjs/media-flight/constants.d.ts +18 -0
  115. package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -0
  116. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts +103 -0
  117. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/media-flight/index.d.ts +9 -0
  119. package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -0
  120. package/lib/typescript/commonjs/media-flight/store.d.ts +132 -0
  121. package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -0
  122. package/lib/typescript/commonjs/media-flight/types.d.ts +195 -0
  123. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -0
  124. package/lib/typescript/commonjs/media-flight/use-media-flight.d.ts +4 -0
  125. package/lib/typescript/commonjs/media-flight/use-media-flight.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts +5 -0
  127. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/zoomable-media-gallery/index.d.ts +4 -0
  130. package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/zoomable-media-gallery/types.d.ts +90 -0
  132. package/lib/typescript/commonjs/zoomable-media-gallery/types.d.ts.map +1 -0
  133. package/lib/typescript/module/floating/index.d.ts +5 -0
  134. package/lib/typescript/module/floating/index.d.ts.map +1 -1
  135. package/lib/typescript/module/index.d.ts +2 -0
  136. package/lib/typescript/module/index.d.ts.map +1 -1
  137. package/lib/typescript/module/index.web.d.ts +2 -0
  138. package/lib/typescript/module/index.web.d.ts.map +1 -1
  139. package/lib/typescript/module/level-picker/LevelPicker.d.ts +6 -0
  140. package/lib/typescript/module/level-picker/LevelPicker.d.ts.map +1 -0
  141. package/lib/typescript/module/level-picker/LevelPicker.web.d.ts +6 -0
  142. package/lib/typescript/module/level-picker/LevelPicker.web.d.ts.map +1 -0
  143. package/lib/typescript/module/level-picker/constants.d.ts +93 -0
  144. package/lib/typescript/module/level-picker/constants.d.ts.map +1 -0
  145. package/lib/typescript/module/level-picker/index.d.ts +3 -0
  146. package/lib/typescript/module/level-picker/index.d.ts.map +1 -0
  147. package/lib/typescript/module/level-picker/index.web.d.ts +3 -0
  148. package/lib/typescript/module/level-picker/index.web.d.ts.map +1 -0
  149. package/lib/typescript/module/level-picker/types.d.ts +63 -0
  150. package/lib/typescript/module/level-picker/types.d.ts.map +1 -0
  151. package/lib/typescript/module/media-flight/MediaFlightLayer.d.ts +11 -0
  152. package/lib/typescript/module/media-flight/MediaFlightLayer.d.ts.map +1 -0
  153. package/lib/typescript/module/media-flight/MediaSurface.d.ts +106 -0
  154. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -0
  155. package/lib/typescript/module/media-flight/constants.d.ts +18 -0
  156. package/lib/typescript/module/media-flight/constants.d.ts.map +1 -0
  157. package/lib/typescript/module/media-flight/expo-video-module.d.ts +103 -0
  158. package/lib/typescript/module/media-flight/expo-video-module.d.ts.map +1 -0
  159. package/lib/typescript/module/media-flight/index.d.ts +9 -0
  160. package/lib/typescript/module/media-flight/index.d.ts.map +1 -0
  161. package/lib/typescript/module/media-flight/store.d.ts +132 -0
  162. package/lib/typescript/module/media-flight/store.d.ts.map +1 -0
  163. package/lib/typescript/module/media-flight/types.d.ts +195 -0
  164. package/lib/typescript/module/media-flight/types.d.ts.map +1 -0
  165. package/lib/typescript/module/media-flight/use-media-flight.d.ts +4 -0
  166. package/lib/typescript/module/media-flight/use-media-flight.d.ts.map +1 -0
  167. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts +5 -0
  168. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -0
  169. package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts.map +1 -1
  170. package/lib/typescript/module/zoomable-media-gallery/index.d.ts +4 -0
  171. package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/index.d.ts.map +1 -1
  172. package/lib/typescript/module/zoomable-media-gallery/types.d.ts +90 -0
  173. package/lib/typescript/module/zoomable-media-gallery/types.d.ts.map +1 -0
  174. package/package.json +61 -8
  175. package/src/floating/index.ts +5 -0
  176. package/src/image-aspect-ratio-cache/aspect-ratio-cache.ts +1 -1
  177. package/src/index.ts +4 -1
  178. package/src/index.web.ts +4 -1
  179. package/src/level-picker/LevelPicker.tsx +365 -0
  180. package/src/level-picker/LevelPicker.web.tsx +374 -0
  181. package/src/level-picker/constants.ts +131 -0
  182. package/src/level-picker/index.ts +2 -0
  183. package/src/level-picker/index.web.ts +2 -0
  184. package/src/level-picker/types.ts +63 -0
  185. package/src/media-flight/MediaFlightLayer.tsx +144 -0
  186. package/src/media-flight/MediaSurface.tsx +233 -0
  187. package/src/media-flight/constants.ts +32 -0
  188. package/src/media-flight/expo-video-module.ts +181 -0
  189. package/src/media-flight/index.ts +33 -0
  190. package/src/media-flight/store.ts +497 -0
  191. package/src/media-flight/types.ts +210 -0
  192. package/src/media-flight/use-media-flight.ts +42 -0
  193. package/src/tab-bar/TabBarBase.tsx +1 -1
  194. package/src/{zoomable-image-gallery/ZoomableImageGallery.tsx → zoomable-media-gallery/ZoomableMediaGallery.tsx} +192 -117
  195. package/src/zoomable-media-gallery/index.ts +11 -0
  196. package/src/zoomable-media-gallery/types.ts +100 -0
  197. package/docs/zoomable-image-gallery.mdx +0 -66
  198. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +0 -1
  199. package/lib/commonjs/zoomable-image-gallery/types.js +0 -2
  200. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +0 -1
  201. package/lib/module/zoomable-image-gallery/index.js +0 -5
  202. package/lib/module/zoomable-image-gallery/index.js.map +0 -1
  203. package/lib/module/zoomable-image-gallery/types.js +0 -2
  204. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts +0 -5
  205. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +0 -1
  206. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +0 -4
  207. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +0 -56
  208. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +0 -1
  209. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts +0 -5
  210. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +0 -1
  211. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +0 -4
  212. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +0 -56
  213. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +0 -1
  214. package/src/zoomable-image-gallery/index.ts +0 -9
  215. package/src/zoomable-image-gallery/types.ts +0 -59
  216. /package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.js +0 -0
  217. /package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.js +0 -0
  218. /package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts +0 -0
  219. /package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts +0 -0
  220. /package/src/{zoomable-image-gallery → zoomable-media-gallery}/constants.ts +0 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The layer that survives navigation.
3
+ *
4
+ * ## The problem it exists for
5
+ *
6
+ * A shared-element transition normally assumes the ORIGIN screen is still
7
+ * mounted while the element travels — which is why `react-native-teleport` needs
8
+ * `presentation: 'transparentModal'` and has no web story at all. Under
9
+ * expo-router on web the origin route is UNMOUNTED the moment the URL changes,
10
+ * so anything the origin was rendering (a `VideoView`, a decoded image) is gone
11
+ * before the first frame of the transition.
12
+ *
13
+ * The fix is to move the surface OUT of both screens for the duration of the
14
+ * flight. `<MediaFlightLayer>` is mounted ONCE at the app root and renders
15
+ * through Bloom's `Portal`, whose outlet lives above the router on both
16
+ * platforms — so the surface it paints belongs to neither route and neither
17
+ * route can take it down. Origin and destination only DECLARE where they want
18
+ * it (`registerAnchor` + `flyTo`); the layer is what actually paints it.
19
+ *
20
+ * ## One surface per id
21
+ *
22
+ * That is the contract, and for video it is the whole feature. expo-video keeps
23
+ * the `VideoPlayer` OBJECT separate from the `VideoView` that shows it, and one
24
+ * player may feed several views — so moving a video is a matter of mounting ONE
25
+ * view in the right place, never of creating a second player. A second surface
26
+ * for the same id would mean two views mounted over one another and a visible
27
+ * swap; the store enforces one by RETARGETING an existing flight rather than
28
+ * adding to it.
29
+ *
30
+ * ## It never takes a click
31
+ *
32
+ * Every node here is `pointerEvents="none"` (or `box-none` on the root, via
33
+ * `OverlayRoot`). A flying surface is a picture of something the app is already
34
+ * showing: the controls underneath it stay the ones the user is pressing, and a
35
+ * layer mounted for the whole life of the app that swallowed presses would be
36
+ * indistinguishable from a frozen UI.
37
+ */
38
+ import { useEffect, useSyncExternalStore } from 'react';
39
+ import { StyleSheet } from 'react-native';
40
+ import { interpolate, useAnimatedStyle } from 'react-native-reanimated';
41
+
42
+ import { OverlayRoot } from '../overlay';
43
+ import { Portal } from '../portal';
44
+ import { MediaSurface } from './MediaSurface';
45
+ import { getFlights, notifySurfaceMounted, subscribeToFlights } from './store';
46
+ import type { MediaFlight } from './types';
47
+
48
+ /**
49
+ * Mount ONCE, at the app root, above the router. Renders nothing at all until a
50
+ * flight is live, so it costs a subscription and no view while idle — and takes
51
+ * an overlay rank only while it is actually painting, which is what keeps the
52
+ * overlay stack's counter able to reset (see `overlay/stack.ts`).
53
+ */
54
+ export function MediaFlightLayer() {
55
+ const flights = useSyncExternalStore(subscribeToFlights, getFlights, getFlights);
56
+
57
+ if (flights.length === 0) return null;
58
+
59
+ return (
60
+ <Portal>
61
+ {/* `OverlayRoot` fills the viewport, is `box-none`, and takes this
62
+ surface's place in the open-order overlay stack — so a flight started
63
+ over an open dialog paints above it, and one started under a later
64
+ sheet goes behind. */}
65
+ <OverlayRoot>
66
+ {flights.map((flight) => (
67
+ <MediaFlightSurface key={flight.id} flight={flight} />
68
+ ))}
69
+ </OverlayRoot>
70
+ </Portal>
71
+ );
72
+ }
73
+
74
+ MediaFlightLayer.displayName = 'MediaFlightLayer';
75
+
76
+ /**
77
+ * One flying surface.
78
+ *
79
+ * ## Why the size is animated rather than scaled
80
+ *
81
+ * The media gallery flies its images with a uniform `scale`, which is right
82
+ * there: it opens from a thumbnail into a box of the SAME aspect ratio, so one
83
+ * factor describes the whole move. A flight does not have that luxury — it
84
+ * routinely goes from a cropped square tile to a 16:9 player — and a uniform
85
+ * scale would leave the first frame the wrong height, i.e. visibly not the
86
+ * thumbnail it is supposed to be replacing. Interpolating `width`/`height` is
87
+ * exact at both ends; the position still rides a transform, so the only layout
88
+ * work per frame is one absolutely-positioned node resizing.
89
+ */
90
+ function MediaFlightSurface({ flight }: { flight: MediaFlight }) {
91
+ const { id, from, to, progress, content, cornerRadius, contentFit, surfaceType } = flight;
92
+
93
+ // The COMMIT signal `flyTo`'s promise waits on, and the reason it is an effect
94
+ // rather than anything derived: what the caller needs to know is that React
95
+ // has actually put this surface in the tree, and an effect is the only thing
96
+ // in React that means exactly that. On web it is also the moment the
97
+ // `<video>` element joined expo-video's mounted set — which is what carries
98
+ // `currentTime` across from the origin, and what stops working the instant
99
+ // the caller navigates first.
100
+ useEffect(() => {
101
+ notifySurfaceMounted(id);
102
+ }, [id]);
103
+
104
+ // Deltas rather than absolute rects, so the worklet closes over four numbers.
105
+ const originX = from.x - to.x;
106
+ const originY = from.y - to.y;
107
+
108
+ // CRITICAL — every shared value a mapper READS must be listed in its deps.
109
+ // On web WITHOUT the react-native-worklets babel plugin, reanimated cannot
110
+ // auto-detect the reads and drives the mapper off the deps array instead:
111
+ // with none, it runs ONCE and freezes at the opening frame while `progress`
112
+ // animates underneath it. Same rule as `ZoomableMediaGallery` and
113
+ // `BottomSheetBase`. Do NOT strip these.
114
+ const boxStyle = useAnimatedStyle(
115
+ () => ({
116
+ transform: [
117
+ { translateX: interpolate(progress.value, [0, 1], [originX, 0]) },
118
+ { translateY: interpolate(progress.value, [0, 1], [originY, 0]) },
119
+ ],
120
+ width: interpolate(progress.value, [0, 1], [from.width, to.width]),
121
+ height: interpolate(progress.value, [0, 1], [from.height, to.height]),
122
+ }),
123
+ [progress, originX, originY, from.width, from.height, to.width, to.height],
124
+ );
125
+
126
+ return (
127
+ <MediaSurface
128
+ content={content}
129
+ contentFit={contentFit}
130
+ surfaceType={surfaceType}
131
+ // A flying surface is a picture, never a control: it must not intercept
132
+ // the press that is already travelling to whatever is underneath it.
133
+ pointerEvents="none"
134
+ style={[styles.box, { left: to.x, top: to.y, borderRadius: cornerRadius }, boxStyle]}
135
+ />
136
+ );
137
+ }
138
+
139
+ const styles = StyleSheet.create({
140
+ box: {
141
+ position: 'absolute',
142
+ overflow: 'hidden',
143
+ },
144
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * ONE renderer for "an image or a video, painted into a box".
3
+ *
4
+ * Both the flight layer and the media gallery need exactly this, and they need
5
+ * it to be the same code: a flight that lands inside the gallery hands its
6
+ * surface over frame-for-frame, so two renderers that disagree by a pixel of
7
+ * `contentFit` or a corner radius show the swap.
8
+ *
9
+ * BOTH ARMS RENDER THE SAME BOX — an `Animated.View` with the media filling it —
10
+ * rather than the image arm rendering an animated `Image` directly. That is the
11
+ * property that makes an image and a video interchangeable at a call site: the
12
+ * caller's style means the same thing either way, the corner radius clips a
13
+ * platform video surface the way it rounds an image, and the transform lands on
14
+ * a node reanimated is allowed to drive (expo-video's own host is not one).
15
+ */
16
+ import { memo, useCallback, useState, type ComponentProps } from 'react';
17
+ import {
18
+ Platform,
19
+ StyleSheet,
20
+ View,
21
+ type ImageStyle,
22
+ type StyleProp,
23
+ type ViewStyle,
24
+ } from 'react-native';
25
+ import { Image } from 'expo-image';
26
+ import Animated from 'react-native-reanimated';
27
+
28
+ import { handOffFlight } from './store';
29
+ import type { MediaSurfaceContent } from './types';
30
+ import {
31
+ loadExpoVideo,
32
+ warnExpoVideoUnavailable,
33
+ type VideoSurfaceType,
34
+ } from './expo-video-module';
35
+
36
+ /**
37
+ * Exactly what `Animated.View` accepts, taken from the component rather than
38
+ * restated: a caller hands this component the output of `useAnimatedStyle` and a
39
+ * hand-written `StyleProp<ViewStyle>` would reject it.
40
+ */
41
+ export type MediaSurfaceStyle = ComponentProps<typeof Animated.View>['style'];
42
+
43
+ /**
44
+ * The style a STILL accepts. The intersection is what lets one value reach both
45
+ * an `Image` and a `View`: `ViewStyle` and `ImageStyle` disagree on exactly one
46
+ * key (`overflow`, which `ViewStyle` also allows to be `'scroll'`), and the
47
+ * intersection narrows it to the two values both accept.
48
+ */
49
+ export type MediaPosterStyle = StyleProp<ViewStyle & ImageStyle>;
50
+
51
+ // Web-only: a media surface is never a drag source. Written as an inline
52
+ // conditional SPREAD rather than a `Platform.select` returning `undefined` —
53
+ // spreading a `T | undefined` into JSX crashes tsc 5.9 outright
54
+ // (`getIntersectionTypeFacts: type.types is not iterable`), and the gallery
55
+ // already wrote it this way for the same reason.
56
+ const webDraggableProps = Platform.OS === 'web' ? { draggable: false } : {};
57
+
58
+ export interface MediaSurfaceProps {
59
+ /** What to paint: a still image, or a consumer-owned expo-video player. */
60
+ content: MediaSurfaceContent;
61
+ /** Size, radius and transform of the box. May carry an animated style. */
62
+ style?: MediaSurfaceStyle;
63
+ /** How the media fills the box. Defaults to `'contain'`. */
64
+ contentFit?: 'contain' | 'cover';
65
+ /**
66
+ * Android's rendering surface for the video arm. Defaults to `'textureView'`
67
+ * and is CAPTURED ON FIRST RENDER: expo-video documents that this prop must
68
+ * not change at runtime, and a `surfaceView` is composited outside the app's
69
+ * view hierarchy — it ignores its parent's clip, radius and transform, which
70
+ * is every property a flying box relies on.
71
+ */
72
+ surfaceType?: VideoSurfaceType;
73
+ /** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
74
+ nativeControls?: boolean;
75
+ accessibilityLabel?: string;
76
+ /**
77
+ * Passed through as a PROP, never as a style key: react-native-web resolves
78
+ * the RN-only `box-none`/`box-only` values from the prop path only.
79
+ */
80
+ pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only';
81
+ /**
82
+ * Set this on the DESTINATION surface of a media flight, to the same id the
83
+ * flight was started with. When this surface presents its first frame it tells
84
+ * the layer to let the flying copy go.
85
+ *
86
+ * It exists so the ordering knowledge stays in Bloom. The alternative is a
87
+ * consumer releasing the flight on a timer — and the timer is always wrong,
88
+ * because a fullscreen video route measured ~1 s from tap to first frame
89
+ * against production while a flight animation lasts ~300 ms. Whoever guessed
90
+ * would be choosing between a hole and a surface that overstays.
91
+ *
92
+ * Leave it unset on an origin surface, and on the flight layer's own: a
93
+ * surface that handed off to itself would release on its own first frame.
94
+ */
95
+ flightId?: string;
96
+ }
97
+
98
+ /**
99
+ * A still image, or a video fed by a player the CONSUMER owns.
100
+ *
101
+ * The video arm mounts a `VideoView` around the caller's `player`. Bloom never
102
+ * creates, replaces or releases that player — handing one player to a new view
103
+ * is what moves a playing video between surfaces without restarting it, and it
104
+ * is the reason this component takes a player object rather than a source URL.
105
+ */
106
+ export const MediaSurface = memo(function MediaSurface({
107
+ content,
108
+ style,
109
+ contentFit = 'contain',
110
+ surfaceType = 'textureView',
111
+ nativeControls = false,
112
+ accessibilityLabel,
113
+ pointerEvents,
114
+ flightId,
115
+ }: MediaSurfaceProps) {
116
+ // Captured once — see `surfaceType` above. A consumer changing it later gets
117
+ // the value the view was mounted with, which is the only value expo-video
118
+ // supports.
119
+ const [mountedSurfaceType] = useState(surfaceType);
120
+
121
+ // Both arms report the same fact — "there is a picture here now" — because the
122
+ // destination of a flight can be either, and a caller should not have to know
123
+ // which one it wired. expo-video raises `onFirstFrameRender` from `loadeddata`
124
+ // on web; expo-image raises `onLoad` once the source is decoded and displayed.
125
+ const reportLive = useCallback(() => {
126
+ if (flightId !== undefined) handOffFlight(flightId);
127
+ }, [flightId]);
128
+
129
+ const still = content.kind === 'video' ? content.poster : content.uri;
130
+ // Loaded for the video arm only, and only when there is one — an image
131
+ // surface must never make an app resolve an optional native peer.
132
+ const expoVideo = content.kind === 'video' ? loadExpoVideo() : null;
133
+ if (content.kind === 'video' && expoVideo === null) {
134
+ // Degrade to the poster rather than to nothing: a black hole where a video
135
+ // should be reads as a broken app, a still frame reads as a video that has
136
+ // not started. The warning is what makes the difference visible to the
137
+ // developer, once, in dev.
138
+ warnExpoVideoUnavailable();
139
+ }
140
+
141
+ return (
142
+ <Animated.View style={[styles.box, style]} pointerEvents={pointerEvents}>
143
+ {still === undefined ? null : (
144
+ // Behind the video (and the whole picture on the image arm), so the box
145
+ // is never empty while a first frame decodes and a flight that starts
146
+ // before playback still carries a picture. This is also why a flight
147
+ // never has to wait for a decode: an unpainted `<video>` sets no
148
+ // `poster` attribute and an Android TextureView runs with the ExoPlayer
149
+ // shutter off, so neither draws anything opaque over this.
150
+ <Image
151
+ source={{ uri: still }}
152
+ contentFit={contentFit}
153
+ style={StyleSheet.absoluteFill}
154
+ transition={0}
155
+ accessibilityLabel={accessibilityLabel}
156
+ // Only the arm that IS the picture reports being live. On the video
157
+ // arm the poster is scenery: handing off on it would release the
158
+ // flying surface while the destination still had no video.
159
+ onLoad={content.kind === 'video' ? undefined : reportLive}
160
+ {...webDraggableProps}
161
+ />
162
+ )}
163
+ {content.kind === 'video' && expoVideo !== null ? (
164
+ <expoVideo.VideoView
165
+ player={content.player}
166
+ contentFit={contentFit}
167
+ surfaceType={mountedSurfaceType}
168
+ nativeControls={nativeControls}
169
+ style={StyleSheet.absoluteFill}
170
+ accessibilityLabel={accessibilityLabel}
171
+ onFirstFrameRender={reportLive}
172
+ />
173
+ ) : null}
174
+ </Animated.View>
175
+ );
176
+ });
177
+
178
+ MediaSurface.displayName = 'MediaSurface';
179
+
180
+ /**
181
+ * The STILL of a media item — a video's poster, or the image itself — and never
182
+ * a video view.
183
+ *
184
+ * This is what every surface that is not the live one renders: an off-screen
185
+ * gallery page, a thumbnail-strip tile, an origin placeholder while its media is
186
+ * in flight. Mounting a `MediaSurface` there instead would put a second
187
+ * `VideoView` on the same player, which is precisely what the "one live surface
188
+ * per id" contract forbids — and on a thumbnail strip it would mean one decoder
189
+ * per tile.
190
+ */
191
+ export function MediaPoster({
192
+ content,
193
+ style,
194
+ contentFit = 'cover',
195
+ accessibilityLabel,
196
+ }: {
197
+ content: MediaSurfaceContent;
198
+ style?: MediaPosterStyle;
199
+ contentFit?: 'contain' | 'cover';
200
+ accessibilityLabel?: string;
201
+ }) {
202
+ const uri = content.kind === 'video' ? content.poster : content.uri;
203
+ if (uri === undefined) return <EmptyMediaSurface style={style} />;
204
+ return (
205
+ <Image
206
+ source={{ uri }}
207
+ contentFit={contentFit}
208
+ style={style}
209
+ transition={0}
210
+ accessibilityLabel={accessibilityLabel}
211
+ {...webDraggableProps}
212
+ />
213
+ );
214
+ }
215
+
216
+ /**
217
+ * A box with no media at all — what a gallery page renders for a video item
218
+ * whose poster is unknown, so the layout never collapses.
219
+ */
220
+ export function EmptyMediaSurface({ style }: { style?: MediaPosterStyle }) {
221
+ return <View style={[styles.empty, style]} pointerEvents="none" />;
222
+ }
223
+
224
+ const styles = StyleSheet.create({
225
+ box: {
226
+ // What makes the box's corner radius actually clip a video, and the other
227
+ // half of why the Android surface has to be a `textureView`.
228
+ overflow: 'hidden',
229
+ },
230
+ empty: {
231
+ backgroundColor: 'rgba(255,255,255,0.08)',
232
+ },
233
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * How long `flyTo` will wait for `<MediaFlightLayer>` to commit the surface
3
+ * before letting the caller carry on regardless (ms).
4
+ *
5
+ * The wait is a COMMIT, not a network round-trip: the surface only has to be
6
+ * mounted, because that is what puts it in expo-video's `_mountedVideos` set
7
+ * while the origin is still there — which is the whole mechanism that carries
8
+ * `currentTime` across. One React commit is a frame or two. Painting is a
9
+ * separate, slower thing that the poster covers (see `MediaSurface`), so it is
10
+ * deliberately NOT waited on here: blocking a tap on a decode would trade a
11
+ * problem nobody can see for one everybody can feel.
12
+ *
13
+ * The timeout exists for the case where no layer is mounted at all. Without it
14
+ * the promise never settles and the consumer never navigates — an app that
15
+ * forgot one line at its root would appear to have a dead feed.
16
+ */
17
+ export const SURFACE_MOUNT_TIMEOUT_MS = 250;
18
+
19
+ /*
20
+ * There is deliberately NO hand-off timeout.
21
+ *
22
+ * The obvious one — release the flying surface after N seconds if no destination
23
+ * ever claims it — would have to be wrong in one direction or the other, because
24
+ * the layer cannot tell "the destination failed" from "the caller meant the
25
+ * layer to keep painting this". And the failure it would guard against is not
26
+ * actually bad: a destination that never loads leaves the flying surface
27
+ * showing the video, playing, at exactly the rect the destination wanted, over a
28
+ * destination showing its poster. Going back calls `flyBack`, which releases it.
29
+ *
30
+ * `flyBack(id)` and `releaseFlight(id)` are the escape hatches, and they are
31
+ * the caller's to use.
32
+ */
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The optional-peer boundary for `expo-video`.
3
+ *
4
+ * Bloom ships no native code and never will, so `expo-video` cannot be a hard
5
+ * dependency: an app that renders no video must not be made to install a native
6
+ * module. It is therefore an OPTIONAL peer, and a static `import` contradicts
7
+ * that — Metro resolves every static import in the eager graph, so an omitted
8
+ * "optional" peer does not degrade, it aborts the whole bundle with `Unable to
9
+ * resolve module expo-video`, naming a package the app never mentions.
10
+ *
11
+ * The load has to be dynamic in the ONE shape Metro understands: a `require()`
12
+ * of a STRING LITERAL as a DIRECT STATEMENT of a `try` block, which Metro
13
+ * collects as an optional dependency (resolving the real module when installed,
14
+ * writing `null` into the dependency map when it is not, so the failure lands in
15
+ * the `catch` below at evaluation time instead of at build time). One `if` of
16
+ * nesting loses that — `isOptionalDependency` returns at the FIRST BlockStatement
17
+ * it meets — hence the `typeof require` guard sits OUTSIDE the try.
18
+ *
19
+ * `require` may also not exist at all (this file backs the `import` condition
20
+ * too, i.e. an ESM build under Node), hence that guard.
21
+ *
22
+ * A missing video module is invisible — a poster where a video should be looks
23
+ * exactly like a video that has not started — so {@link warnExpoVideoUnavailable}
24
+ * names the package once, in dev.
25
+ *
26
+ * @see connection-status/netinfo.ts — the same boundary for netinfo.
27
+ * @see hooks/haptics-module.ts — the same boundary for expo-haptics.
28
+ */
29
+ import type { ComponentType } from 'react';
30
+ import type { StyleProp, ViewStyle } from 'react-native';
31
+
32
+ /**
33
+ * Declared locally rather than taken from the ambient `NodeRequire`, which
34
+ * returns `any`: `unknown` forces the module handle to be narrowed below instead
35
+ * of leaking an untyped value into the caller.
36
+ */
37
+ declare const require: (moduleName: string) => unknown;
38
+
39
+ /**
40
+ * The slice of `expo-video`'s `VideoPlayer` Bloom depends on.
41
+ *
42
+ * Hand-written rather than `typeof import('expo-video')` so no expo-video type
43
+ * reaches Bloom's emitted declarations — a consumer that skips the optional peer
44
+ * must not inherit a TS7016 from Bloom's own `.d.ts`. The two are kept in step by
45
+ * the assignability check in `__tests__/MediaFlight.test.tsx`, which is excluded
46
+ * from the build and so may name the real package.
47
+ *
48
+ * Bloom never CALLS any of this. **The consumer creates the player, owns it and
49
+ * destroys it**; Bloom only hands the object back to a `VideoView`, which is the
50
+ * whole mechanism that lets a video move between surfaces without restarting
51
+ * (expo-video's own docs: "when the same player is playing in multiple video
52
+ * views"). The three members below exist so the type is not `object` — a
53
+ * structural minimum the real `VideoPlayer` satisfies, and that an arbitrary
54
+ * object does not.
55
+ *
56
+ * `play`/`pause` are declared in METHOD syntax deliberately: `strictFunctionTypes`
57
+ * exempts method parameters from contravariance, which keeps a real
58
+ * `VideoPlayer` assignable here across `@types/react` copies.
59
+ */
60
+ export interface VideoPlayerLike {
61
+ readonly playing: boolean;
62
+ play(): void;
63
+ pause(): void;
64
+ }
65
+
66
+ /** How a video is scaled inside its box. Mirrors expo-video's `VideoContentFit`. */
67
+ export type VideoSurfaceContentFit = 'contain' | 'cover' | 'fill';
68
+
69
+ /**
70
+ * Android's rendering surface for a video view.
71
+ *
72
+ * `textureView` is what Bloom asks for everywhere, because a `SurfaceView` is
73
+ * composited by the system OUTSIDE the app's view hierarchy: it ignores the
74
+ * parent's clip, corner radius and transform, so a video flying between two
75
+ * rects would paint as an un-rounded rectangle at the wrong place. It also
76
+ * cannot be changed at runtime (expo-video: "This prop should not be changed at
77
+ * runtime"), which is why {@link MediaSurfaceProps.surfaceType} is captured on
78
+ * first render.
79
+ */
80
+ export type VideoSurfaceType = 'textureView' | 'surfaceView';
81
+
82
+ /** The slice of `VideoViewProps` Bloom passes. */
83
+ export interface VideoViewLikeProps {
84
+ player?: VideoPlayerLike | null;
85
+ contentFit?: VideoSurfaceContentFit;
86
+ surfaceType?: VideoSurfaceType;
87
+ nativeControls?: boolean;
88
+ style?: StyleProp<ViewStyle>;
89
+ pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only';
90
+ accessibilityLabel?: string;
91
+ /**
92
+ * Raised once the mounted player has rendered its first frame into this view.
93
+ * On web expo-video raises it from the element's `loadeddata` event; it is the
94
+ * only OBSERVABLE "this surface is live now" the package offers, and the media
95
+ * flight layer's hand-off depends on it rather than on a timer.
96
+ */
97
+ onFirstFrameRender?: () => void;
98
+ }
99
+
100
+ /** The slice of expo-video's module surface Bloom renders. */
101
+ export interface ExpoVideoLike {
102
+ VideoView: ComponentType<VideoViewLikeProps>;
103
+ }
104
+
105
+ /** `undefined` until the first load attempt, then the module or `null`. */
106
+ let videoModule: ExpoVideoLike | null | undefined;
107
+ /** Why the load failed, quoted verbatim in the dev warning. */
108
+ let unavailableReason = '';
109
+ let hasWarned = false;
110
+
111
+ /** The expo-video module, or `null` when the optional peer is not installed. */
112
+ export function loadExpoVideo(): ExpoVideoLike | null {
113
+ if (videoModule !== undefined) return videoModule;
114
+ videoModule = null;
115
+
116
+ // The `typeof require` guard sits OUTSIDE the try on purpose — see the header.
117
+ // Nested one `if`/`else` deeper, the require below is no longer a direct
118
+ // statement of the try block, Metro stops marking it optional, and an app
119
+ // without expo-video fails to BUILD instead of losing the video surface.
120
+ if (typeof require === 'undefined') {
121
+ unavailableReason = 'this bundle has no CommonJS `require`';
122
+ return videoModule;
123
+ }
124
+
125
+ try {
126
+ const loaded = require('expo-video') as
127
+ | (Partial<ExpoVideoLike> & { default?: Partial<ExpoVideoLike> })
128
+ | null
129
+ | undefined;
130
+ // `VideoView` is a class component upstream, so `typeof` is `'function'`
131
+ // either way; a memo/forwardRef build would be an object carrying
132
+ // `$$typeof`. Both are accepted, nothing else is.
133
+ const resolved = isComponentBearing(loaded) ? loaded : loaded?.default;
134
+
135
+ if (isComponentBearing(resolved)) {
136
+ videoModule = { VideoView: resolved.VideoView as ComponentType<VideoViewLikeProps> };
137
+ } else {
138
+ unavailableReason = 'the module resolved without a `VideoView` export';
139
+ }
140
+ } catch (error) {
141
+ unavailableReason = error instanceof Error ? error.message : String(error);
142
+ }
143
+
144
+ return videoModule;
145
+ }
146
+
147
+ function isComponentBearing(
148
+ candidate: (Partial<ExpoVideoLike> & { default?: unknown }) | null | undefined,
149
+ ): candidate is Partial<ExpoVideoLike> & { VideoView: ComponentType<VideoViewLikeProps> } {
150
+ const view = candidate?.VideoView;
151
+ if (typeof view === 'function') return true;
152
+ return typeof view === 'object' && view !== null && '$$typeof' in view;
153
+ }
154
+
155
+ /**
156
+ * One warning per module lifetime, and none in production — the same mechanism
157
+ * as `connection-status/netinfo.ts` and `hooks/haptics-module.ts`. Metro and
158
+ * Vite/Rolldown both fold the `NODE_ENV` check statically, so a production
159
+ * bundle keeps neither the branch nor the message.
160
+ */
161
+ export function warnExpoVideoUnavailable(): void {
162
+ if (process.env.NODE_ENV === 'production' || hasWarned) return;
163
+ hasWarned = true;
164
+ // Internal Bloom diagnostic: only the consumer's package.json can fix this,
165
+ // so it names the package, the install command and what is lost.
166
+ // eslint-disable-next-line no-console
167
+ console.warn(
168
+ '[Bloom] A video media surface fell back to its poster: the optional peer ' +
169
+ '`expo-video` could not be loaded, so no video is rendered and the shared ' +
170
+ 'media transition carries a still frame instead. Install it ' +
171
+ '(`npx expo install expo-video`) or pass image content only. ' +
172
+ `Reason: ${unavailableReason}`,
173
+ );
174
+ }
175
+
176
+ /** Test seam — drops the cached module handle so a suite can load it again. */
177
+ export function resetExpoVideoModule(): void {
178
+ videoModule = undefined;
179
+ unavailableReason = '';
180
+ hasWarned = false;
181
+ }
@@ -0,0 +1,33 @@
1
+ export { MediaFlightLayer } from './MediaFlightLayer';
2
+ export { MediaSurface, MediaPoster, EmptyMediaSurface } from './MediaSurface';
3
+ export { useMediaFlight } from './use-media-flight';
4
+ export {
5
+ DEFAULT_FLIGHT_CORNER_RADIUS,
6
+ flightProgress,
7
+ handOffFlight,
8
+ hasFlight,
9
+ releaseFlight,
10
+ resetMediaFlight,
11
+ } from './store';
12
+ export { SURFACE_MOUNT_TIMEOUT_MS } from './constants';
13
+ export {
14
+ loadExpoVideo,
15
+ warnExpoVideoUnavailable,
16
+ resetExpoVideoModule,
17
+ type ExpoVideoLike,
18
+ type VideoPlayerLike,
19
+ type VideoSurfaceContentFit,
20
+ type VideoSurfaceType,
21
+ type VideoViewLikeProps,
22
+ } from './expo-video-module';
23
+ export type {
24
+ MediaFlight,
25
+ MediaFlightAnchorNode,
26
+ MediaFlightController,
27
+ MediaFlightOptions,
28
+ MediaSurfaceContent,
29
+ MediaSurfaceImage,
30
+ MediaSurfaceVideo,
31
+ MeasuredRect,
32
+ } from './types';
33
+ export type { MediaSurfaceProps } from './MediaSurface';