@oxyhq/bloom 1.1.0 → 1.2.1

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 (150) hide show
  1. package/docs/media-flight.mdx +282 -0
  2. package/docs/migrating-the-media-gallery.mdx +103 -0
  3. package/docs/migrating-to-1.0.mdx +1 -1
  4. package/docs/zoomable-media-gallery.mdx +116 -0
  5. package/lib/commonjs/image-aspect-ratio-cache/aspect-ratio-cache.js +1 -1
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/index.web.js.map +1 -1
  8. package/lib/commonjs/media-flight/MediaFlightLayer.js +151 -0
  9. package/lib/commonjs/media-flight/MediaFlightLayer.js.map +1 -0
  10. package/lib/commonjs/media-flight/MediaSurface.js +207 -0
  11. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -0
  12. package/lib/commonjs/media-flight/constants.js +39 -0
  13. package/lib/commonjs/media-flight/constants.js.map +1 -0
  14. package/lib/commonjs/media-flight/expo-video-module.js +180 -0
  15. package/lib/commonjs/media-flight/expo-video-module.js.map +1 -0
  16. package/lib/commonjs/media-flight/index.js +108 -0
  17. package/lib/commonjs/media-flight/index.js.map +1 -0
  18. package/lib/commonjs/media-flight/store.js +457 -0
  19. package/lib/commonjs/media-flight/store.js.map +1 -0
  20. package/lib/commonjs/media-flight/types.js +6 -0
  21. package/lib/commonjs/media-flight/types.js.map +1 -0
  22. package/lib/commonjs/media-flight/use-media-flight.js +41 -0
  23. package/lib/commonjs/media-flight/use-media-flight.js.map +1 -0
  24. package/lib/commonjs/tab-bar/TabBarBase.js +1 -1
  25. package/lib/commonjs/{zoomable-image-gallery/ZoomableImageGallery.js → zoomable-media-gallery/ZoomableMediaGallery.js} +185 -131
  26. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -0
  27. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.js.map +1 -1
  28. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.js +5 -5
  29. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.js.map +1 -1
  30. package/lib/commonjs/zoomable-media-gallery/types.js +6 -0
  31. package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/types.js.map +1 -1
  32. package/lib/module/image-aspect-ratio-cache/aspect-ratio-cache.js +1 -1
  33. package/lib/module/index.js +2 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/index.web.js +2 -1
  36. package/lib/module/index.web.js.map +1 -1
  37. package/lib/module/media-flight/MediaFlightLayer.js +146 -0
  38. package/lib/module/media-flight/MediaFlightLayer.js.map +1 -0
  39. package/lib/module/media-flight/MediaSurface.js +199 -0
  40. package/lib/module/media-flight/MediaSurface.js.map +1 -0
  41. package/lib/module/media-flight/constants.js +35 -0
  42. package/lib/module/media-flight/constants.js.map +1 -0
  43. package/lib/module/media-flight/expo-video-module.js +173 -0
  44. package/lib/module/media-flight/expo-video-module.js.map +1 -0
  45. package/lib/module/media-flight/index.js +9 -0
  46. package/lib/module/media-flight/index.js.map +1 -0
  47. package/lib/module/media-flight/store.js +439 -0
  48. package/lib/module/media-flight/store.js.map +1 -0
  49. package/lib/module/media-flight/types.js +4 -0
  50. package/lib/module/media-flight/types.js.map +1 -0
  51. package/lib/module/media-flight/use-media-flight.js +36 -0
  52. package/lib/module/media-flight/use-media-flight.js.map +1 -0
  53. package/lib/module/tab-bar/TabBarBase.js +1 -1
  54. package/lib/module/{zoomable-image-gallery/ZoomableImageGallery.js → zoomable-media-gallery/ZoomableMediaGallery.js} +184 -130
  55. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -0
  56. package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.js.map +1 -1
  57. package/lib/module/zoomable-media-gallery/index.js +5 -0
  58. package/lib/module/zoomable-media-gallery/index.js.map +1 -0
  59. package/lib/module/zoomable-media-gallery/types.js +4 -0
  60. package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/types.js.map +1 -1
  61. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.d.ts +11 -0
  64. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +106 -0
  66. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/media-flight/constants.d.ts +18 -0
  68. package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts +125 -0
  70. package/lib/typescript/commonjs/media-flight/expo-video-module.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/media-flight/index.d.ts +9 -0
  72. package/lib/typescript/commonjs/media-flight/index.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/media-flight/store.d.ts +132 -0
  74. package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/media-flight/types.d.ts +195 -0
  76. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/media-flight/use-media-flight.d.ts +4 -0
  78. package/lib/typescript/commonjs/media-flight/use-media-flight.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts +5 -0
  80. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/zoomable-media-gallery/index.d.ts +4 -0
  83. package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/index.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/zoomable-media-gallery/types.d.ts +90 -0
  85. package/lib/typescript/commonjs/zoomable-media-gallery/types.d.ts.map +1 -0
  86. package/lib/typescript/module/index.d.ts.map +1 -1
  87. package/lib/typescript/module/index.web.d.ts.map +1 -1
  88. package/lib/typescript/module/media-flight/MediaFlightLayer.d.ts +11 -0
  89. package/lib/typescript/module/media-flight/MediaFlightLayer.d.ts.map +1 -0
  90. package/lib/typescript/module/media-flight/MediaSurface.d.ts +106 -0
  91. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -0
  92. package/lib/typescript/module/media-flight/constants.d.ts +18 -0
  93. package/lib/typescript/module/media-flight/constants.d.ts.map +1 -0
  94. package/lib/typescript/module/media-flight/expo-video-module.d.ts +125 -0
  95. package/lib/typescript/module/media-flight/expo-video-module.d.ts.map +1 -0
  96. package/lib/typescript/module/media-flight/index.d.ts +9 -0
  97. package/lib/typescript/module/media-flight/index.d.ts.map +1 -0
  98. package/lib/typescript/module/media-flight/store.d.ts +132 -0
  99. package/lib/typescript/module/media-flight/store.d.ts.map +1 -0
  100. package/lib/typescript/module/media-flight/types.d.ts +195 -0
  101. package/lib/typescript/module/media-flight/types.d.ts.map +1 -0
  102. package/lib/typescript/module/media-flight/use-media-flight.d.ts +4 -0
  103. package/lib/typescript/module/media-flight/use-media-flight.d.ts.map +1 -0
  104. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts +5 -0
  105. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -0
  106. package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts.map +1 -1
  107. package/lib/typescript/module/zoomable-media-gallery/index.d.ts +4 -0
  108. package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/index.d.ts.map +1 -1
  109. package/lib/typescript/module/zoomable-media-gallery/types.d.ts +90 -0
  110. package/lib/typescript/module/zoomable-media-gallery/types.d.ts.map +1 -0
  111. package/package.json +42 -8
  112. package/src/image-aspect-ratio-cache/aspect-ratio-cache.ts +1 -1
  113. package/src/index.ts +2 -1
  114. package/src/index.web.ts +2 -1
  115. package/src/media-flight/MediaFlightLayer.tsx +144 -0
  116. package/src/media-flight/MediaSurface.tsx +251 -0
  117. package/src/media-flight/constants.ts +32 -0
  118. package/src/media-flight/expo-video-module.ts +212 -0
  119. package/src/media-flight/index.ts +34 -0
  120. package/src/media-flight/store.ts +497 -0
  121. package/src/media-flight/types.ts +210 -0
  122. package/src/media-flight/use-media-flight.ts +42 -0
  123. package/src/tab-bar/TabBarBase.tsx +1 -1
  124. package/src/{zoomable-image-gallery/ZoomableImageGallery.tsx → zoomable-media-gallery/ZoomableMediaGallery.tsx} +192 -117
  125. package/src/zoomable-media-gallery/index.ts +11 -0
  126. package/src/zoomable-media-gallery/types.ts +100 -0
  127. package/docs/zoomable-image-gallery.mdx +0 -66
  128. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +0 -1
  129. package/lib/commonjs/zoomable-image-gallery/types.js +0 -2
  130. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +0 -1
  131. package/lib/module/zoomable-image-gallery/index.js +0 -5
  132. package/lib/module/zoomable-image-gallery/index.js.map +0 -1
  133. package/lib/module/zoomable-image-gallery/types.js +0 -2
  134. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts +0 -5
  135. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +0 -1
  136. package/lib/typescript/commonjs/zoomable-image-gallery/index.d.ts +0 -4
  137. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts +0 -56
  138. package/lib/typescript/commonjs/zoomable-image-gallery/types.d.ts.map +0 -1
  139. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts +0 -5
  140. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +0 -1
  141. package/lib/typescript/module/zoomable-image-gallery/index.d.ts +0 -4
  142. package/lib/typescript/module/zoomable-image-gallery/types.d.ts +0 -56
  143. package/lib/typescript/module/zoomable-image-gallery/types.d.ts.map +0 -1
  144. package/src/zoomable-image-gallery/index.ts +0 -9
  145. package/src/zoomable-image-gallery/types.ts +0 -59
  146. /package/lib/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.js +0 -0
  147. /package/lib/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.js +0 -0
  148. /package/lib/typescript/commonjs/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts +0 -0
  149. /package/lib/typescript/module/{zoomable-image-gallery → zoomable-media-gallery}/constants.d.ts +0 -0
  150. /package/src/{zoomable-image-gallery → zoomable-media-gallery}/constants.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -577,18 +577,46 @@
577
577
  "default": "./lib/commonjs/media-inset-border/index.js"
578
578
  }
579
579
  },
580
+ "./zoomable-media-gallery": {
581
+ "react-native": {
582
+ "types": "./lib/typescript/module/zoomable-media-gallery/index.d.ts",
583
+ "default": "./src/zoomable-media-gallery/index.ts"
584
+ },
585
+ "import": {
586
+ "types": "./lib/typescript/module/zoomable-media-gallery/index.d.ts",
587
+ "default": "./lib/module/zoomable-media-gallery/index.js"
588
+ },
589
+ "require": {
590
+ "types": "./lib/typescript/commonjs/zoomable-media-gallery/index.d.ts",
591
+ "default": "./lib/commonjs/zoomable-media-gallery/index.js"
592
+ }
593
+ },
580
594
  "./zoomable-image-gallery": {
581
595
  "react-native": {
582
- "types": "./lib/typescript/module/zoomable-image-gallery/index.d.ts",
583
- "default": "./src/zoomable-image-gallery/index.ts"
596
+ "types": "./lib/typescript/module/zoomable-media-gallery/index.d.ts",
597
+ "default": "./src/zoomable-media-gallery/index.ts"
598
+ },
599
+ "import": {
600
+ "types": "./lib/typescript/module/zoomable-media-gallery/index.d.ts",
601
+ "default": "./lib/module/zoomable-media-gallery/index.js"
602
+ },
603
+ "require": {
604
+ "types": "./lib/typescript/commonjs/zoomable-media-gallery/index.d.ts",
605
+ "default": "./lib/commonjs/zoomable-media-gallery/index.js"
606
+ }
607
+ },
608
+ "./media-flight": {
609
+ "react-native": {
610
+ "types": "./lib/typescript/module/media-flight/index.d.ts",
611
+ "default": "./src/media-flight/index.ts"
584
612
  },
585
613
  "import": {
586
- "types": "./lib/typescript/module/zoomable-image-gallery/index.d.ts",
587
- "default": "./lib/module/zoomable-image-gallery/index.js"
614
+ "types": "./lib/typescript/module/media-flight/index.d.ts",
615
+ "default": "./lib/module/media-flight/index.js"
588
616
  },
589
617
  "require": {
590
- "types": "./lib/typescript/commonjs/zoomable-image-gallery/index.d.ts",
591
- "default": "./lib/commonjs/zoomable-image-gallery/index.js"
618
+ "types": "./lib/typescript/commonjs/media-flight/index.d.ts",
619
+ "default": "./lib/commonjs/media-flight/index.js"
592
620
  }
593
621
  },
594
622
  "./pressable-scale": {
@@ -1498,8 +1526,9 @@
1498
1526
  "generate:tokens-json": "bun scripts/generate-design-tokens-json.ts",
1499
1527
  "prebuild": "node scripts/generate-platform-exports.mjs && bun scripts/generate-theme-css.ts && bun scripts/generate-design-tokens-json.ts",
1500
1528
  "build": "bob build",
1501
- "postbuild": "node scripts/verify-package.mjs",
1529
+ "postbuild": "node scripts/verify-package.mjs && node scripts/verify-build-freshness.mjs --worktree",
1502
1530
  "verify:package": "node scripts/verify-package.mjs",
1531
+ "verify:build-freshness": "node scripts/verify-build-freshness.mjs --worktree",
1503
1532
  "verify:consumer-types": "node scripts/verify-consumer-typecheck.mjs",
1504
1533
  "test": "jest",
1505
1534
  "typescript": "tsc --noEmit && node scripts/verify-consumer-typecheck.mjs",
@@ -1544,6 +1573,7 @@
1544
1573
  "expo-image": "~56.0.11",
1545
1574
  "expo-router": "^56.0.0",
1546
1575
  "expo-symbols": "~56.0.6",
1576
+ "expo-video": "~57.0.2",
1547
1577
  "jest": "^30.3.0",
1548
1578
  "jest-environment-jsdom": "^30.4.1",
1549
1579
  "react": "19.2.0",
@@ -1576,6 +1606,7 @@
1576
1606
  "expo-image": "*",
1577
1607
  "expo-router": ">=3.0.0",
1578
1608
  "expo-symbols": ">=0.4.5",
1609
+ "expo-video": "*",
1579
1610
  "nativewind": ">=5.0.0",
1580
1611
  "react": ">=18.0.0",
1581
1612
  "react-dom": ">=18.0.0",
@@ -1603,6 +1634,9 @@
1603
1634
  "expo-router": {
1604
1635
  "optional": true
1605
1636
  },
1637
+ "expo-video": {
1638
+ "optional": true
1639
+ },
1606
1640
  "nativewind": {
1607
1641
  "optional": true
1608
1642
  },
@@ -3,7 +3,7 @@
3
3
  * the image source URI.
4
4
  *
5
5
  * Intended for a thumbnail renderer and a fullscreen viewer (e.g.
6
- * `@oxyhq/bloom/zoomable-image-gallery`) sharing the SAME image URI to read
6
+ * `@oxyhq/bloom/zoomable-media-gallery`) sharing the SAME image URI to read
7
7
  * from and write to this one map, so an image already resolved as a thumbnail
8
8
  * has its ratio on hand when it opens "big" — no second cache, no duplicate
9
9
  * `Image.getSize` round-trip on the hot path. If the two surfaces render
package/src/index.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  // `ScrollRestorationProvider` itself. `theme/adaptive-colors.ts` also
18
18
  // names expo-router, but through the optional-`require` boundary, which
19
19
  // links nothing.
20
- // · `./zoomable-image-gallery` — statically imports `expo-image`.
20
+ // · `./zoomable-media-gallery` and `./media-flight` both reach
21
+ // `expo-image` through the shared `media-flight/MediaSurface`.
21
22
  // Gate: `src/__tests__/root-barrel-graph.test.ts`.
22
23
  //
23
24
  // 2. Its exports are generic, collision-prone names. Those come in as a
package/src/index.web.ts CHANGED
@@ -22,7 +22,8 @@
22
22
  // `ScrollRestorationProvider` itself. `theme/adaptive-colors.ts` also
23
23
  // names expo-router, but through the optional-`require` boundary, which
24
24
  // links nothing.
25
- // · `./zoomable-image-gallery` — statically imports `expo-image`.
25
+ // · `./zoomable-media-gallery` and `./media-flight` both reach
26
+ // `expo-image` through the shared `media-flight/MediaSurface`.
26
27
  // Gate: `src/__tests__/root-barrel-graph.test.ts`.
27
28
  //
28
29
  // 2. Its exports are generic, collision-prone names. Those come in as a
@@ -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,251 @@
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
+ // `absoluteFill` ALONE does not size this, and the reason is a CSS
170
+ // rule rather than a bug: expo-video renders a DOM `<video>`, which is
171
+ // a REPLACED element, and `position:absolute; inset:0` with
172
+ // `width/height:auto` resolves a replaced element to its INTRINSIC
173
+ // size — 300x150, the `<video>` default — instead of stretching it.
174
+ // Measured: inside a 320x200 box the element computed
175
+ // `left/right/top/bottom: 0px` and `width: 300px; height: 150px`, so
176
+ // it sat at the default while the box animated around it and then
177
+ // jumped when metadata arrived. The poster escapes this only because
178
+ // expo-image sizes its own element.
179
+ style={[StyleSheet.absoluteFill, styles.fillReplaced]}
180
+ accessibilityLabel={accessibilityLabel}
181
+ onFirstFrameRender={reportLive}
182
+ />
183
+ ) : null}
184
+ </Animated.View>
185
+ );
186
+ });
187
+
188
+ MediaSurface.displayName = 'MediaSurface';
189
+
190
+ /**
191
+ * The STILL of a media item — a video's poster, or the image itself — and never
192
+ * a video view.
193
+ *
194
+ * This is what every surface that is not the live one renders: an off-screen
195
+ * gallery page, a thumbnail-strip tile, an origin placeholder while its media is
196
+ * in flight. Mounting a `MediaSurface` there instead would put a second
197
+ * `VideoView` on the same player, which is precisely what the "one live surface
198
+ * per id" contract forbids — and on a thumbnail strip it would mean one decoder
199
+ * per tile.
200
+ */
201
+ export function MediaPoster({
202
+ content,
203
+ style,
204
+ contentFit = 'cover',
205
+ accessibilityLabel,
206
+ }: {
207
+ content: MediaSurfaceContent;
208
+ style?: MediaPosterStyle;
209
+ contentFit?: 'contain' | 'cover';
210
+ accessibilityLabel?: string;
211
+ }) {
212
+ const uri = content.kind === 'video' ? content.poster : content.uri;
213
+ if (uri === undefined) return <EmptyMediaSurface style={style} />;
214
+ return (
215
+ <Image
216
+ source={{ uri }}
217
+ contentFit={contentFit}
218
+ style={style}
219
+ transition={0}
220
+ accessibilityLabel={accessibilityLabel}
221
+ {...webDraggableProps}
222
+ />
223
+ );
224
+ }
225
+
226
+ /**
227
+ * A box with no media at all — what a gallery page renders for a video item
228
+ * whose poster is unknown, so the layout never collapses.
229
+ */
230
+ export function EmptyMediaSurface({ style }: { style?: MediaPosterStyle }) {
231
+ return <View style={[styles.empty, style]} pointerEvents="none" />;
232
+ }
233
+
234
+ const styles = StyleSheet.create({
235
+ /**
236
+ * Explicit size for a REPLACED element. Redundant for a `<div>` and
237
+ * load-bearing for a `<video>` — see the note at the call site.
238
+ */
239
+ fillReplaced: {
240
+ width: '100%',
241
+ height: '100%',
242
+ },
243
+ box: {
244
+ // What makes the box's corner radius actually clip a video, and the other
245
+ // half of why the Android surface has to be a `textureView`.
246
+ overflow: 'hidden',
247
+ },
248
+ empty: {
249
+ backgroundColor: 'rgba(255,255,255,0.08)',
250
+ },
251
+ });
@@ -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
+ */