@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
@@ -12,7 +12,6 @@ import {
12
12
  NativeScrollEvent,
13
13
  type ViewStyle,
14
14
  } from 'react-native';
15
- import { Image } from 'expo-image';
16
15
  import Animated, {
17
16
  useSharedValue,
18
17
  useAnimatedStyle,
@@ -36,6 +35,7 @@ import {
36
35
  ArrowOutOfBox_Stroke2_Corner0_Rounded,
37
36
  } from '../icons';
38
37
  import { WEB_POSITION_FIXED, type WebCssStyle } from '../styles/web-view-style';
38
+ import { MediaPoster, MediaSurface } from '../media-flight/MediaSurface';
39
39
  import {
40
40
  getAspectRatio,
41
41
  getIntrinsicSize,
@@ -65,13 +65,44 @@ import {
65
65
  THUMBNAIL_STRIP_TILE_SIZE,
66
66
  } from './constants';
67
67
  import type {
68
- GalleryImage,
68
+ GalleryMedia,
69
69
  MeasuredRect,
70
- ZoomableImageGalleryHandle,
71
- ZoomableImageGalleryProps,
70
+ ZoomableMediaGalleryHandle,
71
+ ZoomableMediaGalleryProps,
72
72
  } from './types';
73
73
 
74
- const AnimatedImage = Animated.createAnimatedComponent(Image);
74
+ /**
75
+ * The still-frame URI of an item: the image itself, or a video's poster.
76
+ * `undefined` for a video with no poster — there is nothing to probe, size
77
+ * against or show in the strip, and every caller below handles that.
78
+ */
79
+ function posterUri(item: GalleryMedia): string | undefined {
80
+ return item.kind === 'video' ? item.poster : item.uri;
81
+ }
82
+
83
+ /** The aspect ratio already known for an item, from the shared probe cache. */
84
+ function cachedRatio(item: GalleryMedia): number | undefined {
85
+ const uri = posterUri(item);
86
+ return uri === undefined ? undefined : getAspectRatio(uri);
87
+ }
88
+
89
+ /**
90
+ * A stable React key. An image is identified by its URI; a video is not (its
91
+ * player is an object with no URL), which is why `GalleryVideo` carries an
92
+ * explicit `id`. The index is folded in so a post repeating one image still
93
+ * pages correctly.
94
+ */
95
+ function mediaKey(item: GalleryMedia, index: number): string {
96
+ return item.kind === 'video' ? `video-${item.id}-${index}` : `image-${item.uri}-${index}`;
97
+ }
98
+
99
+ /**
100
+ * What the share sheet is handed. A poster is not what "share this video"
101
+ * means, so a video shares its own `shareUrl` or nothing at all.
102
+ */
103
+ function shareUri(item: GalleryMedia): string | undefined {
104
+ return item.kind === 'video' ? item.shareUrl : item.uri;
105
+ }
75
106
 
76
107
  // Web-only interaction hints (no-ops on native): the zoom surfaces are
77
108
  // tap-to-dismiss (`cursor: pointer`) and must not select text or drag the
@@ -108,7 +139,7 @@ function NavArrow({
108
139
  <Pressable
109
140
  onPress={onPress}
110
141
  accessibilityRole="button"
111
- accessibilityLabel={isLeft ? 'Previous image' : 'Next image'}
142
+ accessibilityLabel={isLeft ? 'Previous item' : 'Next item'}
112
143
  hitSlop={8}
113
144
  onHoverIn={onHoverIn}
114
145
  onHoverOut={onHoverOut}
@@ -134,7 +165,7 @@ interface FittedSize {
134
165
  }
135
166
 
136
167
  /**
137
- * Resolve `cornerRadius` against the size an image is actually rendered at, so
168
+ * Resolve `cornerRadius` against the size an item is actually rendered at, so
138
169
  * `'circle'` stays a circle at every fitted size (and through the open/close
139
170
  * animation, which scales this same box).
140
171
  */
@@ -143,23 +174,38 @@ function resolveCornerRadius(cornerRadius: number | 'circle', fit: FittedSize):
143
174
  }
144
175
 
145
176
  /**
146
- * Fullscreen, swipeable image viewer that replicates the profile avatar's
147
- * measured-origin zoom transition (`ZoomableAvatar`) for rectangular post media:
177
+ * Fullscreen, swipeable MEDIA viewer images and consumer-owned videos in one
178
+ * pager — replicating the profile avatar's measured-origin zoom transition
179
+ * (`ZoomableAvatar`) for rectangular post media:
148
180
  *
149
181
  * - Open/close feel is identical to the avatar (same spring configs, web
150
182
  * timing/easing, blur backdrop, and the measure-origin technique). The viewer
151
183
  * renders through the Bloom `Portal` on BOTH platforms (RN's `Modal` is not
152
184
  * used on native — on the New Architecture / Fabric Android its host views
153
185
  * mount full-screen but never composite, leaving the viewer invisible).
154
- * - The OPENING (tapped) image animates from its measured rect to a centered,
186
+ * - The OPENING (tapped) item animates from its measured rect to a centered,
155
187
  * aspect-ratio-preserving fit within {@link FIT_FRACTION} of the screen. Once
156
188
  * the open animation settles, a horizontal paging `ScrollView` mounts seeded at
157
- * the tapped index so the user can swipe between every image in the post.
189
+ * the tapped index so the user can swipe between every item in the post.
158
190
  * - Gesture disambiguation: the pager owns horizontal swipes; a vertical-only
159
191
  * `Gesture.Pan` (`activeOffsetY` + `failOffsetX`) owns drag-to-dismiss, so the
160
192
  * two never fight.
193
+ *
194
+ * ## Video, and why it does not restart
195
+ *
196
+ * A video page is fed by a `VideoPlayer` the CONSUMER created and owns. Bloom
197
+ * never creates or destroys one: expo-video keeps the player OBJECT separate
198
+ * from the `VideoView` that shows it, and one player may feed several views, so
199
+ * handing the same player to this gallery moves a playing video into fullscreen
200
+ * without re-opening the stream. Exactly ONE view is mounted per opening — the
201
+ * active page's — while every other page and every strip tile renders the still
202
+ * (`MediaPoster`). The single unavoidable swap is the pre-pager open surface
203
+ * handing over to the pager's active page once the open animation settles.
204
+ *
205
+ * `expo-video` is an OPTIONAL peer loaded through `media-flight/expo-video-module`.
206
+ * Without it a video page degrades to its poster, once, with a dev warning.
161
207
  */
162
- const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, ZoomableImageGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS, indicatorVariant = 'dots' }, ref) => {
208
+ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, ZoomableMediaGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS, indicatorVariant = 'dots', videoControls = false }, ref) => {
163
209
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
164
210
 
165
211
  const radiusFor = useCallback(
@@ -168,17 +214,17 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
168
214
  );
169
215
 
170
216
  const [isOpen, setIsOpen] = useState(false);
171
- // Once true, the swipeable pager is mounted and the single open-image hidden.
217
+ // Once true, the swipeable pager is mounted and the single open-surface hidden.
172
218
  const [pagerReady, setPagerReady] = useState(false);
173
- const [images, setImages] = useState<GalleryImage[]>([]);
219
+ const [items, setItems] = useState<GalleryMedia[]>([]);
174
220
  const [activeIndex, setActiveIndex] = useState(0);
175
- // Whether the active image is currently pinched/double-tapped past its base
221
+ // Whether the active media is currently pinched/double-tapped past its base
176
222
  // size. Drives the pan-while-zoomed gesture, disables the pager's horizontal
177
- // paging + the dismiss drag so a pan moves the zoomed image instead.
223
+ // paging + the dismiss drag so a pan moves the zoomed media instead.
178
224
  const [isZoomed, setIsZoomed] = useState(false);
179
- // Aspect ratio of the OPENING image (drives the open-animation fit box).
225
+ // Aspect ratio of the OPENING item (drives the open-animation fit box).
180
226
  const [openRatio, setOpenRatio] = useState<number>(DEFAULT_ASPECT_RATIO);
181
- // Per-image aspect ratios for the pager pages (index-aligned with `images`).
227
+ // Per-item aspect ratios for the pager pages (index-aligned with `items`).
182
228
  const [pageRatios, setPageRatios] = useState<Record<number, number>>({});
183
229
 
184
230
  // Native `Share` is always available; web exposes a share button only when the
@@ -193,7 +239,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
193
239
  const translateY = useSharedValue(0);
194
240
  const opacity = useSharedValue(0);
195
241
 
196
- // Zoom transform of the active image, kept SEPARATE from the open/close +
242
+ // Zoom transform of the active media, kept SEPARATE from the open/close +
197
243
  // dismiss-drag transform above (reusing those would collide two meanings).
198
244
  // `zoom*` are the live/persisted values; `baseZoomScale` is captured at pinch
199
245
  // start and `savedZoomTranslate*` at pan start for incremental bookkeeping.
@@ -204,7 +250,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
204
250
  const savedZoomTranslateX = useSharedValue(0);
205
251
  const savedZoomTranslateY = useSharedValue(0);
206
252
 
207
- // Origin (offset from screen center) of the tapped image.
253
+ // Origin (offset from screen center) of the tapped item.
208
254
  const originX = useSharedValue(0);
209
255
  const originY = useSharedValue(0);
210
256
 
@@ -212,7 +258,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
212
258
  const startY = useSharedValue(0);
213
259
  // Per-drag baseline captured at pan start (transient).
214
260
  const startScale = useSharedValue(1);
215
- // Persistent scale of the open-image at its thumbnail origin (thumbWidth /
261
+ // Persistent scale of the open-surface at its thumbnail origin (thumbWidth /
216
262
  // fittedWidth). The open animation grows from this to 1; the close animation
217
263
  // shrinks back to it. Never overwritten by the drag gesture.
218
264
  const originScale = useSharedValue(1);
@@ -271,9 +317,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
271
317
  setActiveIndex((prev) => (prev === next ? prev : next));
272
318
  }, []);
273
319
 
274
- // Snap the active image back to its un-zoomed baseline (no animation) and clear
275
- // all zoom bookkeeping. Called on every path that changes the active page so a
276
- // freshly-viewed image always starts un-zoomed.
320
+ // Snap the active media back to its un-zoomed baseline (no animation) and
321
+ // clear all zoom bookkeeping. Called on every path that changes the active
322
+ // page so a freshly-viewed item always starts un-zoomed.
277
323
  const resetZoom = useCallback(() => {
278
324
  zoomScale.value = 1;
279
325
  zoomTranslateX.value = 0;
@@ -291,7 +337,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
291
337
  );
292
338
 
293
339
  // Largest width/height for `ratio` that fits inside `fitBox` without cropping
294
- // (contain), never exceeding the image's own resolution: filling the viewport
340
+ // (contain), never exceeding the media's own resolution: filling the viewport
295
341
  // with a 512px avatar or a small thumbnail just renders it soft. `uri` is what
296
342
  // lets us look the intrinsic size up; without it (not measured yet) the fit
297
343
  // box wins and the clamp applies on the next frame.
@@ -311,22 +357,27 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
311
357
  return { width, height };
312
358
  }, [fitBox]);
313
359
 
314
- // Fitted size of the single open-image for the CURRENT page. On open this is
315
- // the opened image's fit (active index == opened index, ratio == `openRatio`);
316
- // after swiping it tracks the viewed image so the collapse-on-dismiss renders
317
- // and flies back the image actually on screen.
360
+ // Fitted size of the single open-surface for the CURRENT page. On open this is
361
+ // the opened item's fit (active index == opened index, ratio == `openRatio`);
362
+ // after swiping it tracks the viewed item so the collapse-on-dismiss renders
363
+ // and flies back the media actually on screen.
364
+ const activeItem = items[activeIndex];
318
365
  const activeRatio = pageRatios[activeIndex] ?? openRatio;
319
- const activeUri = images[activeIndex]?.uri;
366
+ const activeUri = activeItem === undefined ? undefined : posterUri(activeItem);
320
367
  const activeFit = useMemo(
321
368
  () => fitForRatio(activeRatio, activeUri),
322
369
  [fitForRatio, activeRatio, activeUri],
323
370
  );
324
371
 
325
- // Alt text (accessibility description) of the image currently on screen, shown
372
+ // Alt text (accessibility description) of the item currently on screen, shown
326
373
  // as a caption at the bottom of the viewer (Bluesky-style lightbox footer).
327
- const activeAlt = images[activeIndex]?.alt?.trim() || undefined;
374
+ const activeAlt = activeItem?.alt?.trim() || undefined;
328
375
 
329
- const ensureRatio = useCallback((index: number, uri: string) => {
376
+ // Probe and cache the ratio of the page at `index`. A video with no poster has
377
+ // nothing to probe — it keeps whatever ratio the consumer declared, or the
378
+ // gallery default — so this is a no-op rather than a guess.
379
+ const ensureRatio = useCallback((index: number, uri: string | undefined) => {
380
+ if (uri === undefined) return;
330
381
  const cached = getAspectRatio(uri);
331
382
  if (cached !== undefined) {
332
383
  setPageRatios((prev) => (prev[index] === cached ? prev : { ...prev, [index]: cached }));
@@ -348,7 +399,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
348
399
  opacity.value = 0;
349
400
  }, [opacity, scale, translateX, translateY]);
350
401
 
351
- // Fly the (possibly dragged) image back toward `target` — the rect of the
402
+ // Fly the (possibly dragged) media back toward `target` — the rect of the
352
403
  // thumbnail currently being viewed — shrinking to its footprint. Uses the
353
404
  // EXACT same close spring (native) / timing (web) as the avatar transition.
354
405
  const flyBackTo = useCallback(
@@ -396,14 +447,14 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
396
447
  // fly-back from an already-moving image.
397
448
  if (dismissingRef.current) return;
398
449
  dismissingRef.current = true;
399
- // Collapse the pager back to the single open-image so the fly-back animates
400
- // one image (the current one) rather than the whole scrolled strip.
450
+ // Collapse the pager back to the single open-surface so the fly-back
451
+ // animates one item (the current one) rather than the whole scrolled strip.
401
452
  setPagerReady(false);
402
453
 
403
454
  const index = activeIndexRef.current;
404
- const current = images[index];
405
- // Recompute the fly-back target from the CURRENT image: the live rect of its
406
- // thumbnail + the same fitted box (`activeFit`) the open-image is rendered
455
+ const current = items[index];
456
+ // Recompute the fly-back target from the CURRENT item: the live rect of its
457
+ // thumbnail + the same fitted box (`activeFit`) the open-surface is rendered
407
458
  // at, using the same screen-center math as `open`. Falls back to a center
408
459
  // fade-out when the thumbnail can't be measured.
409
460
  if (measureThumb && current) {
@@ -428,7 +479,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
428
479
  activeFit,
429
480
  fadeOutCenter,
430
481
  flyBackTo,
431
- images,
482
+ items,
432
483
  measureThumb,
433
484
  SCREEN_HEIGHT,
434
485
  SCREEN_WIDTH,
@@ -442,41 +493,43 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
442
493
  }, []);
443
494
 
444
495
  const open = useCallback(
445
- (nextImages: GalleryImage[], index: number, rect?: MeasuredRect) => {
446
- if (isOpen || nextImages.length === 0) return;
496
+ (nextItems: GalleryMedia[], index: number, rect?: MeasuredRect) => {
497
+ if (isOpen || nextItems.length === 0) return;
447
498
  dismissingRef.current = false;
448
- const safeIndex = Math.min(Math.max(index, 0), nextImages.length - 1);
449
- const target = nextImages[safeIndex];
499
+ const safeIndex = Math.min(Math.max(index, 0), nextItems.length - 1);
500
+ const target = nextItems[safeIndex];
450
501
  if (!target) return;
451
- const knownRatio = target.aspectRatio ?? getAspectRatio(target.uri);
502
+ const targetUri = posterUri(target);
503
+ const knownRatio = target.aspectRatio ?? cachedRatio(target);
452
504
  const ratio = knownRatio ?? DEFAULT_ASPECT_RATIO;
453
505
 
454
- setImages(nextImages);
506
+ setItems(nextItems);
455
507
  setActiveIndexBoth(safeIndex);
456
508
  setOpenRatio(ratio);
457
509
  // Seed every page whose ratio is already known (consumer-provided metadata
458
510
  // or a previously-cached probe) so swiping never hits the same snap — only
459
- // a genuinely-unknown image falls through to `ensureRatio`'s async probe.
511
+ // a genuinely-unknown item falls through to `ensureRatio`'s async probe.
460
512
  const initialRatios: Record<number, number> = {};
461
- nextImages.forEach((img, i) => {
462
- const r = img.aspectRatio ?? getAspectRatio(img.uri);
513
+ nextItems.forEach((item, i) => {
514
+ const r = item.aspectRatio ?? cachedRatio(item);
463
515
  if (r !== undefined) initialRatios[i] = r;
464
516
  });
465
517
  setPageRatios(initialRatios);
466
518
 
467
- // Resolve the opening ratio if it was not yet known, then re-fit.
468
- if (knownRatio === undefined) {
469
- void fetchAspectRatio(target.uri).then((resolved) => {
519
+ // Resolve the opening ratio if it was not yet known, then re-fit. A video
520
+ // with no poster has nothing to probe and keeps the default.
521
+ if (knownRatio === undefined && targetUri !== undefined) {
522
+ void fetchAspectRatio(targetUri).then((resolved) => {
470
523
  setOpenRatio(resolved);
471
524
  setPageRatios((prev) => ({ ...prev, [safeIndex]: resolved }));
472
525
  });
473
526
  }
474
527
 
475
- // The open-image box is rendered at its FINAL fitted size; the open
528
+ // The open-surface box is rendered at its FINAL fitted size; the open
476
529
  // animation grows it from the thumbnail's footprint (scale < 1) up to 1,
477
530
  // mirroring the avatar's small→big scale. The initial scale is the ratio
478
531
  // of the thumbnail width to the fitted width.
479
- const fitted = fitForRatio(ratio, target.uri);
532
+ const fitted = fitForRatio(ratio, targetUri);
480
533
  const centerX = SCREEN_WIDTH / 2;
481
534
  const centerY = SCREEN_HEIGHT / 2;
482
535
  if (rect && rect.width > 0) {
@@ -591,7 +644,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
591
644
  [opacity],
592
645
  );
593
646
 
594
- // The single open-image animates from origin → fitted center.
647
+ // The single open-surface animates from origin → fitted center.
595
648
  const openImageStyle = useAnimatedStyle(
596
649
  () => ({
597
650
  transform: [
@@ -635,7 +688,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
635
688
  // (web, where paging may not fire a reliable momentum-end).
636
689
  const updateIndexFromOffset = useCallback(
637
690
  (offsetX: number) => {
638
- const lastIndex = images.length - 1;
691
+ const lastIndex = items.length - 1;
639
692
  if (lastIndex < 0) return;
640
693
  const next = Math.min(Math.max(Math.round(offsetX / SCREEN_WIDTH), 0), lastIndex);
641
694
  if (next === activeIndexRef.current) return;
@@ -643,10 +696,10 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
643
696
  // the page scrolling away).
644
697
  resetZoom();
645
698
  setActiveIndexBoth(next);
646
- const img = images[next];
647
- if (img) ensureRatio(next, img.uri);
699
+ const item = items[next];
700
+ if (item) ensureRatio(next, posterUri(item));
648
701
  },
649
- [ensureRatio, images, resetZoom, setActiveIndexBoth, SCREEN_WIDTH]
702
+ [ensureRatio, items, resetZoom, setActiveIndexBoth, SCREEN_WIDTH]
650
703
  );
651
704
 
652
705
  // Programmatically page to `index` (arrow buttons, keyboard, thumbnail taps).
@@ -655,13 +708,13 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
655
708
  const pageTo = useCallback(
656
709
  (index: number) => {
657
710
  if (!pagerReady) return;
658
- const lastIndex = images.length - 1;
711
+ const lastIndex = items.length - 1;
659
712
  if (lastIndex < 0) return;
660
713
  const clamped = Math.min(Math.max(index, 0), lastIndex);
661
714
  resetZoom();
662
715
  pagerRef.current?.scrollTo({ x: clamped * SCREEN_WIDTH, y: 0, animated: true });
663
716
  },
664
- [images.length, pagerReady, resetZoom, SCREEN_WIDTH]
717
+ [items.length, pagerReady, resetZoom, SCREEN_WIDTH]
665
718
  );
666
719
 
667
720
  const onPagerScroll = useCallback(
@@ -830,10 +883,12 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
830
883
  [tapGesture, pinchGesture, panWhileZoomedGesture]
831
884
  );
832
885
 
833
- // Share the active image's URI: the OS share sheet on native; the Web Share API
834
- // on supporting browsers (the button is only rendered where it exists).
886
+ // Share the active item's URI: the OS share sheet on native; the Web Share API
887
+ // on supporting browsers (the button is only rendered where it exists). A
888
+ // video shares its own `shareUrl`; its poster is not what "share this" means.
835
889
  const handleShare = useCallback(async () => {
836
- const uri = images[activeIndexRef.current]?.uri;
890
+ const item = items[activeIndexRef.current];
891
+ const uri = item === undefined ? undefined : shareUri(item);
837
892
  if (!uri) return;
838
893
  try {
839
894
  if (Platform.OS === 'web') {
@@ -847,9 +902,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
847
902
  // A user-cancelled share sheet rejects (AbortError on web) — that's
848
903
  // expected, not a failure. Anything else is a real error worth surfacing.
849
904
  if (err instanceof Error && err.name === 'AbortError') return;
850
- if (typeof console !== 'undefined' && console.error) console.error('Image share failed:', err);
905
+ if (typeof console !== 'undefined' && console.error) console.error('Media share failed:', err);
851
906
  }
852
- }, [images]);
907
+ }, [items]);
853
908
 
854
909
  // Keyboard navigation (web only), scoped to the open lifetime like Dialog.web's
855
910
  // Escape handler. Reads the live index from the ref so it need not re-subscribe
@@ -888,7 +943,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
888
943
  in isolation. */}
889
944
  <Backdrop
890
945
  onPress={handleDismiss}
891
- accessibilityLabel="Close image viewer"
946
+ accessibilityLabel="Close media viewer"
892
947
  progress={opacity}
893
948
  />
894
949
 
@@ -902,18 +957,26 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
902
957
  ]}
903
958
  pointerEvents="box-none"
904
959
  >
905
- {!pagerReady && (
960
+ {!pagerReady && activeItem !== undefined && (
906
961
  <Pressable onPress={handleDismiss} style={webPointerStyle}>
907
- <AnimatedImage
908
- source={{ uri: images[activeIndex]?.uri }}
909
- contentFit="contain"
962
+ {/* The animated transform rides the BOX, not the media, so the
963
+ image and video arms move identically — and so the box's
964
+ radius clips a video the same way it rounds an image. */}
965
+ <Animated.View
910
966
  style={[
967
+ styles.mediaBox,
911
968
  { width: activeFit.width, height: activeFit.height, borderRadius: radiusFor(activeFit) },
912
969
  openImageStyle,
913
970
  ]}
914
- transition={0}
915
- {...(Platform.OS === 'web' ? { draggable: false } : {})}
916
- />
971
+ >
972
+ <MediaSurface
973
+ content={activeItem}
974
+ contentFit="contain"
975
+ nativeControls={videoControls}
976
+ accessibilityLabel={activeItem.alt}
977
+ style={StyleSheet.absoluteFill}
978
+ />
979
+ </Animated.View>
917
980
  </Pressable>
918
981
  )}
919
982
 
@@ -933,55 +996,64 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
933
996
  scrollEventThrottle={16}
934
997
  style={StyleSheet.absoluteFill}
935
998
  >
936
- {images.map((img, idx) => {
937
- const ratio = pageRatios[idx] ?? getAspectRatio(img.uri) ?? DEFAULT_ASPECT_RATIO;
938
- const fit = fitForRatio(ratio, img.uri);
999
+ {items.map((item, idx) => {
1000
+ const uri = posterUri(item);
1001
+ const ratio =
1002
+ pageRatios[idx] ?? cachedRatio(item) ?? item.aspectRatio ?? DEFAULT_ASPECT_RATIO;
1003
+ const fit = fitForRatio(ratio, uri);
939
1004
  // Only the active page is zoomable + gesture-wrapped (double-tap
940
- // / single-tap dismiss / pinch / pan). Off-screen pages stay a
941
- // plain image; the active image handles its own tap-to-dismiss
942
- // through the gesture, so it isn't wrapped in a Pressable.
1005
+ // / single-tap dismiss / pinch / pan). It is also the ONLY page
1006
+ // that mounts a real media surface: an off-screen page renders
1007
+ // the still, because a second `VideoView` on the same player is
1008
+ // exactly the duplicate surface this package exists to avoid.
1009
+ // The active media handles its own tap-to-dismiss through the
1010
+ // gesture, so it isn't wrapped in a Pressable.
943
1011
  if (idx === activeIndex) {
944
1012
  return (
945
1013
  <Pressable
946
- key={`${img.uri}-${idx}`}
1014
+ key={mediaKey(item, idx)}
947
1015
  // The page fills the screen ON TOP of the backdrop (the
948
1016
  // pager below it needs pointer events to swipe), so a
949
- // press on the empty area around the image can never
1017
+ // press on the empty area around the media can never
950
1018
  // reach the backdrop — the page dismisses it itself.
951
- // Same outcome as tapping the image, which already
1019
+ // Same outcome as tapping the media, which already
952
1020
  // dismisses through `singleTapDismissGesture`.
953
1021
  onPress={handleDismiss}
954
- accessibilityLabel="Close image viewer"
1022
+ accessibilityLabel="Close media viewer"
955
1023
  style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }, webPointerStyle]}
956
1024
  >
957
1025
  <GestureDetector gesture={activePageGesture}>
958
- <AnimatedImage
959
- source={{ uri: img.uri }}
960
- contentFit="contain"
1026
+ <Animated.View
961
1027
  style={[
1028
+ styles.mediaBox,
962
1029
  { width: fit.width, height: fit.height, borderRadius: radiusFor(fit) },
963
1030
  zoomImageStyle,
964
1031
  ]}
965
- transition={0}
966
- {...(Platform.OS === 'web' ? { draggable: false } : {})}
967
- />
1032
+ >
1033
+ <MediaSurface
1034
+ content={item}
1035
+ contentFit="contain"
1036
+ nativeControls={videoControls}
1037
+ accessibilityLabel={item.alt}
1038
+ style={StyleSheet.absoluteFill}
1039
+ />
1040
+ </Animated.View>
968
1041
  </GestureDetector>
969
1042
  </Pressable>
970
1043
  );
971
1044
  }
972
1045
  return (
973
1046
  <Pressable
974
- key={`${img.uri}-${idx}`}
1047
+ key={mediaKey(item, idx)}
975
1048
  onPress={handleDismiss}
976
- accessibilityLabel="Close image viewer"
1049
+ accessibilityLabel="Close media viewer"
977
1050
  style={[styles.page, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT }]}
978
1051
  >
979
- <Image
980
- source={{ uri: img.uri }}
1052
+ <MediaPoster
1053
+ content={item}
981
1054
  contentFit="contain"
1055
+ accessibilityLabel={item.alt}
982
1056
  style={{ width: fit.width, height: fit.height, borderRadius: radiusFor(fit) }}
983
- transition={0}
984
- {...(Platform.OS === 'web' ? { draggable: false } : {})}
985
1057
  />
986
1058
  </Pressable>
987
1059
  );
@@ -990,10 +1062,10 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
990
1062
  </Animated.View>
991
1063
  )}
992
1064
 
993
- {pagerReady && images.length > 1 && (
1065
+ {pagerReady && items.length > 1 && (
994
1066
  <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="box-none">
995
1067
  <View style={styles.counterPill} pointerEvents="none">
996
- <Text style={styles.counterText}>{`${activeIndex + 1} / ${images.length}`}</Text>
1068
+ <Text style={styles.counterText}>{`${activeIndex + 1} / ${items.length}`}</Text>
997
1069
  </View>
998
1070
  {indicatorVariant === 'thumbnails' ? (
999
1071
  <ScrollView
@@ -1002,33 +1074,30 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
1002
1074
  style={styles.thumbStripScroll}
1003
1075
  contentContainerStyle={styles.thumbStripContent}
1004
1076
  >
1005
- {images.map((img, idx) => (
1077
+ {items.map((item, idx) => (
1006
1078
  <Pressable
1007
- key={`thumb-${img.uri}-${idx}`}
1079
+ key={`thumb-${mediaKey(item, idx)}`}
1008
1080
  onPress={() => pageTo(idx)}
1009
1081
  accessibilityRole="button"
1010
- accessibilityLabel={`Go to image ${idx + 1} of ${images.length}`}
1082
+ accessibilityLabel={`Go to item ${idx + 1} of ${items.length}`}
1011
1083
  style={[
1012
1084
  styles.thumbTile,
1013
1085
  idx === activeIndex ? styles.thumbTileActive : styles.thumbTileInactive,
1014
1086
  webPointerStyle,
1015
1087
  ]}
1016
1088
  >
1017
- <Image
1018
- source={{ uri: img.uri }}
1019
- contentFit="cover"
1020
- style={styles.thumbTileImage}
1021
- transition={0}
1022
- {...(Platform.OS === 'web' ? { draggable: false } : {})}
1023
- />
1089
+ {/* A STILL, never a surface: one decoder per strip tile is
1090
+ what mounting the real media here would cost, and for a
1091
+ video it would be a second view on the live player. */}
1092
+ <MediaPoster content={item} contentFit="cover" style={styles.thumbTileImage} />
1024
1093
  </Pressable>
1025
1094
  ))}
1026
1095
  </ScrollView>
1027
1096
  ) : (
1028
1097
  <View style={styles.dotsRow} pointerEvents="none">
1029
- {images.map((img, idx) => (
1098
+ {items.map((item, idx) => (
1030
1099
  <View
1031
- key={`dot-${img.uri}-${idx}`}
1100
+ key={`dot-${mediaKey(item, idx)}`}
1032
1101
  style={[styles.dot, idx === activeIndex ? styles.dotActive : styles.dotInactive]}
1033
1102
  />
1034
1103
  ))}
@@ -1037,11 +1106,11 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
1037
1106
  </Animated.View>
1038
1107
  )}
1039
1108
 
1040
- {Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex > 0 && (
1109
+ {Platform.OS === 'web' && pagerReady && items.length > 1 && activeIndex > 0 && (
1041
1110
  <NavArrow direction="left" onPress={() => pageTo(activeIndex - 1)} />
1042
1111
  )}
1043
1112
 
1044
- {Platform.OS === 'web' && pagerReady && images.length > 1 && activeIndex < images.length - 1 && (
1113
+ {Platform.OS === 'web' && pagerReady && items.length > 1 && activeIndex < items.length - 1 && (
1045
1114
  <NavArrow direction="right" onPress={() => pageTo(activeIndex + 1)} />
1046
1115
  )}
1047
1116
 
@@ -1049,7 +1118,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
1049
1118
  <Pressable
1050
1119
  onPress={handleShare}
1051
1120
  accessibilityRole="button"
1052
- accessibilityLabel="Share image"
1121
+ accessibilityLabel="Share media"
1053
1122
  hitSlop={8}
1054
1123
  style={[styles.shareButton, webPointerStyle]}
1055
1124
  >
@@ -1061,7 +1130,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
1061
1130
  <Animated.View
1062
1131
  style={[
1063
1132
  styles.altCaptionWrap,
1064
- images.length > 1 && styles.altCaptionWrapWithIndicator,
1133
+ items.length > 1 && styles.altCaptionWrapWithIndicator,
1065
1134
  backdropStyle,
1066
1135
  ]}
1067
1136
  pointerEvents="none"
@@ -1088,9 +1157,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
1088
1157
  return <Portal>{renderContent()}</Portal>;
1089
1158
  });
1090
1159
 
1091
- ZoomableImageGalleryInner.displayName = 'ZoomableImageGallery';
1160
+ ZoomableMediaGalleryInner.displayName = 'ZoomableMediaGallery';
1092
1161
 
1093
- export const ZoomableImageGallery = ZoomableImageGalleryInner;
1162
+ export const ZoomableMediaGallery = ZoomableMediaGalleryInner;
1094
1163
 
1095
1164
  const styles = StyleSheet.create({
1096
1165
  modalContainer: {
@@ -1134,6 +1203,12 @@ const styles = StyleSheet.create({
1134
1203
  justifyContent: 'center',
1135
1204
  alignItems: 'center',
1136
1205
  },
1206
+ // The box the media is painted into. `overflow: hidden` is what makes the
1207
+ // box's corner radius clip a VIDEO — an image would round on its own, a
1208
+ // platform video surface will not.
1209
+ mediaBox: {
1210
+ overflow: 'hidden',
1211
+ },
1137
1212
  indicatorWrap: {
1138
1213
  position: 'absolute',
1139
1214
  bottom: 48,
@@ -1260,4 +1335,4 @@ const styles = StyleSheet.create({
1260
1335
  },
1261
1336
  });
1262
1337
 
1263
- export default ZoomableImageGallery;
1338
+ export default ZoomableMediaGallery;