react-native-ensys-camera-x 1.0.7

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 (506) hide show
  1. package/CustomCamera.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +833 -0
  4. package/android/build.gradle +135 -0
  5. package/android/consumer-rules.pro +47 -0
  6. package/android/src/main/AndroidManifest.xml +72 -0
  7. package/android/src/main/java/android/print/PdfPrint.java +51 -0
  8. package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
  9. package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
  10. package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
  11. package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
  12. package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
  13. package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
  14. package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
  15. package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
  16. package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
  17. package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
  18. package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
  19. package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
  20. package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
  21. package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
  22. package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
  23. package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
  24. package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
  25. package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
  26. package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
  27. package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
  28. package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
  29. package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
  30. package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
  31. package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
  32. package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
  33. package/android/src/main/java/com/customcamera/Localization.kt +206 -0
  34. package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
  35. package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
  36. package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
  37. package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
  38. package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
  39. package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
  40. package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
  41. package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
  42. package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
  43. package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
  44. package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
  45. package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
  46. package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
  47. package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
  48. package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
  49. package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
  50. package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
  51. package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
  52. package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
  53. package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
  54. package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
  55. package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
  56. package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
  57. package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
  58. package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
  59. package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
  60. package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
  61. package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
  62. package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
  63. package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
  64. package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
  65. package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
  66. package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
  67. package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
  68. package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
  69. package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
  70. package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
  71. package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
  72. package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
  73. package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
  74. package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
  75. package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
  76. package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
  77. package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
  78. package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
  79. package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
  80. package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
  81. package/android/src/main/res/drawable/bg_pill.xml +6 -0
  82. package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
  83. package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
  84. package/android/src/main/res/drawable/bg_send_button.xml +5 -0
  85. package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
  86. package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
  87. package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
  88. package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
  89. package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
  90. package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
  91. package/android/src/main/res/drawable/ic_audio.xml +10 -0
  92. package/android/src/main/res/drawable/ic_back.xml +10 -0
  93. package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
  94. package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
  95. package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
  96. package/android/src/main/res/drawable/ic_check.xml +17 -0
  97. package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
  98. package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
  99. package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
  100. package/android/src/main/res/drawable/ic_close.xml +10 -0
  101. package/android/src/main/res/drawable/ic_cover.xml +9 -0
  102. package/android/src/main/res/drawable/ic_crop.xml +10 -0
  103. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  104. package/android/src/main/res/drawable/ic_document.xml +10 -0
  105. package/android/src/main/res/drawable/ic_done.xml +10 -0
  106. package/android/src/main/res/drawable/ic_download.xml +10 -0
  107. package/android/src/main/res/drawable/ic_emoji.xml +10 -0
  108. package/android/src/main/res/drawable/ic_filter.xml +10 -0
  109. package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
  110. package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
  111. package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
  112. package/android/src/main/res/drawable/ic_flip.xml +15 -0
  113. package/android/src/main/res/drawable/ic_gallery.xml +10 -0
  114. package/android/src/main/res/drawable/ic_gif.xml +24 -0
  115. package/android/src/main/res/drawable/ic_lock.xml +19 -0
  116. package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
  117. package/android/src/main/res/drawable/ic_marker.xml +10 -0
  118. package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
  119. package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
  120. package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
  121. package/android/src/main/res/drawable/ic_pause.xml +10 -0
  122. package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
  123. package/android/src/main/res/drawable/ic_play.xml +10 -0
  124. package/android/src/main/res/drawable/ic_redo.xml +10 -0
  125. package/android/src/main/res/drawable/ic_rotate.xml +9 -0
  126. package/android/src/main/res/drawable/ic_send.xml +10 -0
  127. package/android/src/main/res/drawable/ic_share.xml +10 -0
  128. package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
  129. package/android/src/main/res/drawable/ic_sticker.xml +24 -0
  130. package/android/src/main/res/drawable/ic_text.xml +10 -0
  131. package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
  132. package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
  133. package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
  134. package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
  135. package/android/src/main/res/drawable/ic_trim.xml +23 -0
  136. package/android/src/main/res/drawable/ic_undo.xml +10 -0
  137. package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
  138. package/android/src/main/res/drawable/ic_video_note.xml +13 -0
  139. package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
  140. package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
  141. package/android/src/main/res/drawable/sticker_heart.xml +9 -0
  142. package/android/src/main/res/drawable/sticker_smile.xml +9 -0
  143. package/android/src/main/res/drawable/sticker_star.xml +9 -0
  144. package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
  145. package/android/src/main/res/values/colors.xml +34 -0
  146. package/android/src/main/res/values/ids.xml +15 -0
  147. package/android/src/main/res/values/strings.xml +15 -0
  148. package/android/src/main/res/values/styles.xml +20 -0
  149. package/android/src/main/res/xml/file_paths.xml +34 -0
  150. package/ios/CustomCamera.h +5 -0
  151. package/ios/CustomCamera.mm +214 -0
  152. package/lib/module/AudioPlayerContext.js +233 -0
  153. package/lib/module/AudioPlayerContext.js.map +1 -0
  154. package/lib/module/CameraPreview.js +304 -0
  155. package/lib/module/CameraPreview.js.map +1 -0
  156. package/lib/module/NativeAnimatedImageView.js +41 -0
  157. package/lib/module/NativeAnimatedImageView.js.map +1 -0
  158. package/lib/module/NativeAudioPlayerView.js +33 -0
  159. package/lib/module/NativeAudioPlayerView.js.map +1 -0
  160. package/lib/module/NativeBlurView.js +35 -0
  161. package/lib/module/NativeBlurView.js.map +1 -0
  162. package/lib/module/NativeCustomCamera.js +128 -0
  163. package/lib/module/NativeCustomCamera.js.map +1 -0
  164. package/lib/module/NativeCustomCamera.web.js +143 -0
  165. package/lib/module/NativeCustomCamera.web.js.map +1 -0
  166. package/lib/module/NativeCustomVideoView.js +5 -0
  167. package/lib/module/NativeCustomVideoView.js.map +1 -0
  168. package/lib/module/NativeDocumentPreviewView.js +156 -0
  169. package/lib/module/NativeDocumentPreviewView.js.map +1 -0
  170. package/lib/module/NativeMediaThumbnailView.js +110 -0
  171. package/lib/module/NativeMediaThumbnailView.js.map +1 -0
  172. package/lib/module/NativeSvgIcon.js +32 -0
  173. package/lib/module/NativeSvgIcon.js.map +1 -0
  174. package/lib/module/ResponsivePreviewContainer.js +171 -0
  175. package/lib/module/ResponsivePreviewContainer.js.map +1 -0
  176. package/lib/module/accessibility.js +61 -0
  177. package/lib/module/accessibility.js.map +1 -0
  178. package/lib/module/config.js +352 -0
  179. package/lib/module/config.js.map +1 -0
  180. package/lib/module/cpuOptimization.js +296 -0
  181. package/lib/module/cpuOptimization.js.map +1 -0
  182. package/lib/module/db/DatabaseManager.js +191 -0
  183. package/lib/module/db/DatabaseManager.js.map +1 -0
  184. package/lib/module/db/MediaMapper.js +284 -0
  185. package/lib/module/db/MediaMapper.js.map +1 -0
  186. package/lib/module/db/MediaRepository.js +242 -0
  187. package/lib/module/db/MediaRepository.js.map +1 -0
  188. package/lib/module/db/index.js +41 -0
  189. package/lib/module/db/index.js.map +1 -0
  190. package/lib/module/db/migrations.js +180 -0
  191. package/lib/module/db/migrations.js.map +1 -0
  192. package/lib/module/db/react-native-sqlcipher.d.js +2 -0
  193. package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
  194. package/lib/module/db/secureKey.js +87 -0
  195. package/lib/module/db/secureKey.js.map +1 -0
  196. package/lib/module/db/types.js +57 -0
  197. package/lib/module/db/types.js.map +1 -0
  198. package/lib/module/db/useMediaDatabase.js +123 -0
  199. package/lib/module/db/useMediaDatabase.js.map +1 -0
  200. package/lib/module/defaultIcons.js +2259 -0
  201. package/lib/module/defaultIcons.js.map +1 -0
  202. package/lib/module/deviceCapabilities.js +408 -0
  203. package/lib/module/deviceCapabilities.js.map +1 -0
  204. package/lib/module/errors.js +267 -0
  205. package/lib/module/errors.js.map +1 -0
  206. package/lib/module/iconConfig.js +796 -0
  207. package/lib/module/iconConfig.js.map +1 -0
  208. package/lib/module/icons.js +514 -0
  209. package/lib/module/icons.js.map +1 -0
  210. package/lib/module/index.js +251 -0
  211. package/lib/module/index.js.map +1 -0
  212. package/lib/module/jest-globals.d.js +2 -0
  213. package/lib/module/jest-globals.d.js.map +1 -0
  214. package/lib/module/library/MediaLibrary.js +107 -0
  215. package/lib/module/library/MediaLibrary.js.map +1 -0
  216. package/lib/module/library/MediaLibraryScreen.js +2597 -0
  217. package/lib/module/library/MediaLibraryScreen.js.map +1 -0
  218. package/lib/module/library/components/AudioPreview.js +74 -0
  219. package/lib/module/library/components/AudioPreview.js.map +1 -0
  220. package/lib/module/library/components/DocumentPreview.js +331 -0
  221. package/lib/module/library/components/DocumentPreview.js.map +1 -0
  222. package/lib/module/library/components/ExpandableFab.js +343 -0
  223. package/lib/module/library/components/ExpandableFab.js.map +1 -0
  224. package/lib/module/library/components/FileTiles.js +251 -0
  225. package/lib/module/library/components/FileTiles.js.map +1 -0
  226. package/lib/module/library/components/ImagePreview.js +70 -0
  227. package/lib/module/library/components/ImagePreview.js.map +1 -0
  228. package/lib/module/library/components/MediaMetadataHeader.js +175 -0
  229. package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
  230. package/lib/module/library/components/RecordAudioScreen.js +816 -0
  231. package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
  232. package/lib/module/library/components/VideoPreview.js +453 -0
  233. package/lib/module/library/components/VideoPreview.js.map +1 -0
  234. package/lib/module/library/components/ViewerCounter.js +55 -0
  235. package/lib/module/library/components/ViewerCounter.js.map +1 -0
  236. package/lib/module/library/compressionEstimate.js +507 -0
  237. package/lib/module/library/compressionEstimate.js.map +1 -0
  238. package/lib/module/library/fileMetadata.js +196 -0
  239. package/lib/module/library/fileMetadata.js.map +1 -0
  240. package/lib/module/library/index.js +42 -0
  241. package/lib/module/library/index.js.map +1 -0
  242. package/lib/module/library/libraryIconRegistry.js +69 -0
  243. package/lib/module/library/libraryIconRegistry.js.map +1 -0
  244. package/lib/module/library/libraryIcons.js +858 -0
  245. package/lib/module/library/libraryIcons.js.map +1 -0
  246. package/lib/module/library/localizedFormat.js +181 -0
  247. package/lib/module/library/localizedFormat.js.map +1 -0
  248. package/lib/module/library/palette.js +225 -0
  249. package/lib/module/library/palette.js.map +1 -0
  250. package/lib/module/library/responsive.js +53 -0
  251. package/lib/module/library/responsive.js.map +1 -0
  252. package/lib/module/locales.js +416 -0
  253. package/lib/module/locales.js.map +1 -0
  254. package/lib/module/localization.js +1064 -0
  255. package/lib/module/localization.js.map +1 -0
  256. package/lib/module/logger.js +20 -0
  257. package/lib/module/logger.js.map +1 -0
  258. package/lib/module/lowMemoryCache.js +206 -0
  259. package/lib/module/lowMemoryCache.js.map +1 -0
  260. package/lib/module/mediaHistory.js +33 -0
  261. package/lib/module/mediaHistory.js.map +1 -0
  262. package/lib/module/mediaStorageService.js +402 -0
  263. package/lib/module/mediaStorageService.js.map +1 -0
  264. package/lib/module/memory.js +79 -0
  265. package/lib/module/memory.js.map +1 -0
  266. package/lib/module/nativeActions.js +307 -0
  267. package/lib/module/nativeActions.js.map +1 -0
  268. package/lib/module/nativeComponentSupport.js +61 -0
  269. package/lib/module/nativeComponentSupport.js.map +1 -0
  270. package/lib/module/networkPerformance.js +24 -0
  271. package/lib/module/networkPerformance.js.map +1 -0
  272. package/lib/module/package.json +1 -0
  273. package/lib/module/performance.js +104 -0
  274. package/lib/module/performance.js.map +1 -0
  275. package/lib/module/performanceMonitor.js +155 -0
  276. package/lib/module/performanceMonitor.js.map +1 -0
  277. package/lib/module/permissions.js +370 -0
  278. package/lib/module/permissions.js.map +1 -0
  279. package/lib/module/rendering.js +40 -0
  280. package/lib/module/rendering.js.map +1 -0
  281. package/lib/module/security.js +18 -0
  282. package/lib/module/security.js.map +1 -0
  283. package/lib/module/state.js +266 -0
  284. package/lib/module/state.js.map +1 -0
  285. package/lib/module/storage.js +400 -0
  286. package/lib/module/storage.js.map +1 -0
  287. package/lib/module/theme.js +390 -0
  288. package/lib/module/theme.js.map +1 -0
  289. package/lib/module/types.js +4 -0
  290. package/lib/module/types.js.map +1 -0
  291. package/lib/module/utils.js +115 -0
  292. package/lib/module/utils.js.map +1 -0
  293. package/lib/module/validation.js +288 -0
  294. package/lib/module/validation.js.map +1 -0
  295. package/lib/typescript/package.json +1 -0
  296. package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
  297. package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
  298. package/lib/typescript/src/CameraPreview.d.ts +13 -0
  299. package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
  300. package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
  301. package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
  302. package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
  303. package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
  304. package/lib/typescript/src/NativeBlurView.d.ts +12 -0
  305. package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
  306. package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
  307. package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
  308. package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
  309. package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
  310. package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
  311. package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
  312. package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
  313. package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
  314. package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
  315. package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
  316. package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
  317. package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
  318. package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
  319. package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
  320. package/lib/typescript/src/accessibility.d.ts +20 -0
  321. package/lib/typescript/src/accessibility.d.ts.map +1 -0
  322. package/lib/typescript/src/config.d.ts +145 -0
  323. package/lib/typescript/src/config.d.ts.map +1 -0
  324. package/lib/typescript/src/cpuOptimization.d.ts +63 -0
  325. package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
  326. package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
  327. package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
  328. package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
  329. package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
  330. package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
  331. package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
  332. package/lib/typescript/src/db/index.d.ts +23 -0
  333. package/lib/typescript/src/db/index.d.ts.map +1 -0
  334. package/lib/typescript/src/db/migrations.d.ts +31 -0
  335. package/lib/typescript/src/db/migrations.d.ts.map +1 -0
  336. package/lib/typescript/src/db/secureKey.d.ts +10 -0
  337. package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
  338. package/lib/typescript/src/db/types.d.ts +115 -0
  339. package/lib/typescript/src/db/types.d.ts.map +1 -0
  340. package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
  341. package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
  342. package/lib/typescript/src/defaultIcons.d.ts +139 -0
  343. package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
  344. package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
  345. package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
  346. package/lib/typescript/src/errors.d.ts +95 -0
  347. package/lib/typescript/src/errors.d.ts.map +1 -0
  348. package/lib/typescript/src/iconConfig.d.ts +176 -0
  349. package/lib/typescript/src/iconConfig.d.ts.map +1 -0
  350. package/lib/typescript/src/icons.d.ts +217 -0
  351. package/lib/typescript/src/icons.d.ts.map +1 -0
  352. package/lib/typescript/src/index.d.ts +104 -0
  353. package/lib/typescript/src/index.d.ts.map +1 -0
  354. package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
  355. package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
  356. package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
  357. package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
  358. package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
  359. package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
  360. package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
  361. package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
  362. package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
  363. package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
  364. package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
  365. package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
  366. package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
  367. package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
  368. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
  369. package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
  370. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
  371. package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
  372. package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
  373. package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
  374. package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
  375. package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
  376. package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
  377. package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
  378. package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
  379. package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
  380. package/lib/typescript/src/library/index.d.ts +40 -0
  381. package/lib/typescript/src/library/index.d.ts.map +1 -0
  382. package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
  383. package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
  384. package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
  385. package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
  386. package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
  387. package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
  388. package/lib/typescript/src/library/palette.d.ts +104 -0
  389. package/lib/typescript/src/library/palette.d.ts.map +1 -0
  390. package/lib/typescript/src/library/responsive.d.ts +15 -0
  391. package/lib/typescript/src/library/responsive.d.ts.map +1 -0
  392. package/lib/typescript/src/locales.d.ts +103 -0
  393. package/lib/typescript/src/locales.d.ts.map +1 -0
  394. package/lib/typescript/src/localization.d.ts +1051 -0
  395. package/lib/typescript/src/localization.d.ts.map +1 -0
  396. package/lib/typescript/src/logger.d.ts +4 -0
  397. package/lib/typescript/src/logger.d.ts.map +1 -0
  398. package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
  399. package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
  400. package/lib/typescript/src/mediaHistory.d.ts +3 -0
  401. package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
  402. package/lib/typescript/src/mediaStorageService.d.ts +155 -0
  403. package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
  404. package/lib/typescript/src/memory.d.ts +21 -0
  405. package/lib/typescript/src/memory.d.ts.map +1 -0
  406. package/lib/typescript/src/nativeActions.d.ts +174 -0
  407. package/lib/typescript/src/nativeActions.d.ts.map +1 -0
  408. package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
  409. package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
  410. package/lib/typescript/src/networkPerformance.d.ts +12 -0
  411. package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
  412. package/lib/typescript/src/performance.d.ts +32 -0
  413. package/lib/typescript/src/performance.d.ts.map +1 -0
  414. package/lib/typescript/src/performanceMonitor.d.ts +45 -0
  415. package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
  416. package/lib/typescript/src/permissions.d.ts +132 -0
  417. package/lib/typescript/src/permissions.d.ts.map +1 -0
  418. package/lib/typescript/src/rendering.d.ts +9 -0
  419. package/lib/typescript/src/rendering.d.ts.map +1 -0
  420. package/lib/typescript/src/security.d.ts +6 -0
  421. package/lib/typescript/src/security.d.ts.map +1 -0
  422. package/lib/typescript/src/state.d.ts +102 -0
  423. package/lib/typescript/src/state.d.ts.map +1 -0
  424. package/lib/typescript/src/storage.d.ts +117 -0
  425. package/lib/typescript/src/storage.d.ts.map +1 -0
  426. package/lib/typescript/src/theme.d.ts +15 -0
  427. package/lib/typescript/src/theme.d.ts.map +1 -0
  428. package/lib/typescript/src/types.d.ts +802 -0
  429. package/lib/typescript/src/types.d.ts.map +1 -0
  430. package/lib/typescript/src/utils.d.ts +50 -0
  431. package/lib/typescript/src/utils.d.ts.map +1 -0
  432. package/lib/typescript/src/validation.d.ts +52 -0
  433. package/lib/typescript/src/validation.d.ts.map +1 -0
  434. package/package.json +204 -0
  435. package/react-native.config.js +17 -0
  436. package/src/AudioPlayerContext.tsx +220 -0
  437. package/src/CameraPreview.tsx +352 -0
  438. package/src/NativeAnimatedImageView.tsx +71 -0
  439. package/src/NativeAudioPlayerView.tsx +39 -0
  440. package/src/NativeBlurView.tsx +40 -0
  441. package/src/NativeCustomCamera.ts +305 -0
  442. package/src/NativeCustomCamera.web.ts +192 -0
  443. package/src/NativeCustomVideoView.ts +30 -0
  444. package/src/NativeDocumentPreviewView.tsx +196 -0
  445. package/src/NativeMediaThumbnailView.tsx +167 -0
  446. package/src/NativeSvgIcon.tsx +46 -0
  447. package/src/ResponsivePreviewContainer.tsx +227 -0
  448. package/src/accessibility.ts +80 -0
  449. package/src/config.ts +406 -0
  450. package/src/cpuOptimization.ts +365 -0
  451. package/src/db/DatabaseManager.ts +212 -0
  452. package/src/db/MediaMapper.ts +377 -0
  453. package/src/db/MediaRepository.ts +313 -0
  454. package/src/db/index.ts +73 -0
  455. package/src/db/migrations.ts +222 -0
  456. package/src/db/react-native-sqlcipher.d.ts +76 -0
  457. package/src/db/secureKey.ts +100 -0
  458. package/src/db/types.ts +123 -0
  459. package/src/db/useMediaDatabase.ts +191 -0
  460. package/src/defaultIcons.tsx +2390 -0
  461. package/src/deviceCapabilities.ts +438 -0
  462. package/src/errors.ts +350 -0
  463. package/src/iconConfig.tsx +962 -0
  464. package/src/icons.tsx +788 -0
  465. package/src/index.tsx +763 -0
  466. package/src/jest-globals.d.ts +9 -0
  467. package/src/library/MediaLibrary.tsx +141 -0
  468. package/src/library/MediaLibraryScreen.tsx +3003 -0
  469. package/src/library/components/AudioPreview.tsx +73 -0
  470. package/src/library/components/DocumentPreview.tsx +361 -0
  471. package/src/library/components/ExpandableFab.tsx +342 -0
  472. package/src/library/components/FileTiles.tsx +252 -0
  473. package/src/library/components/ImagePreview.tsx +72 -0
  474. package/src/library/components/MediaMetadataHeader.tsx +208 -0
  475. package/src/library/components/RecordAudioScreen.tsx +845 -0
  476. package/src/library/components/VideoPreview.tsx +478 -0
  477. package/src/library/components/ViewerCounter.tsx +71 -0
  478. package/src/library/compressionEstimate.ts +662 -0
  479. package/src/library/fileMetadata.ts +212 -0
  480. package/src/library/index.ts +66 -0
  481. package/src/library/libraryIconRegistry.tsx +86 -0
  482. package/src/library/libraryIcons.tsx +928 -0
  483. package/src/library/localizedFormat.ts +207 -0
  484. package/src/library/palette.ts +281 -0
  485. package/src/library/responsive.ts +49 -0
  486. package/src/locales.ts +381 -0
  487. package/src/localization.ts +1947 -0
  488. package/src/logger.ts +20 -0
  489. package/src/lowMemoryCache.ts +218 -0
  490. package/src/mediaHistory.ts +42 -0
  491. package/src/mediaStorageService.ts +546 -0
  492. package/src/memory.ts +102 -0
  493. package/src/nativeActions.ts +422 -0
  494. package/src/nativeComponentSupport.ts +60 -0
  495. package/src/networkPerformance.ts +30 -0
  496. package/src/performance.ts +137 -0
  497. package/src/performanceMonitor.ts +219 -0
  498. package/src/permissions.ts +403 -0
  499. package/src/rendering.ts +47 -0
  500. package/src/security.ts +19 -0
  501. package/src/state.ts +333 -0
  502. package/src/storage.ts +453 -0
  503. package/src/theme.tsx +467 -0
  504. package/src/types.ts +946 -0
  505. package/src/utils.ts +177 -0
  506. package/src/validation.ts +372 -0
@@ -0,0 +1,2597 @@
1
+ "use strict";
2
+
3
+ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
4
+ import { StyleSheet, Text, View, TouchableOpacity, FlatList, StatusBar, Platform, Modal, useWindowDimensions, Animated, Pressable, PanResponder, ScrollView, Alert } from 'react-native';
5
+ import { openCameraPicker, openDocumentPicker, openAudioPicker, openGalleryEditor, startUpload, secureDeleteMedia, shareMedia, compressVideo, compressImage, addCompressionProgressListener, getMediaMetadata } from "../nativeActions.js";
6
+ // Media-list rows show the file's own frame — an extracted video frame or the decoded
7
+ // image — through the library's native thumbnail decoder.
8
+ import { MediaThumbnail } from "../NativeMediaThumbnailView.js";
9
+ import { extensionForMimeType, useMediaDatabase } from "../db/index.js";
10
+ import { getDocumentPreviewKind } from "../NativeDocumentPreviewView.js";
11
+ import { usePortraitPreviewDimensions } from "../ResponsivePreviewContainer.js";
12
+ import { ExpandableFab } from "./components/ExpandableFab.js";
13
+ import { DocumentPreview } from "./components/DocumentPreview.js";
14
+ import { AudioPreview } from "./components/AudioPreview.js";
15
+ import { ImagePreview } from "./components/ImagePreview.js";
16
+ import { VideoPreview } from "./components/VideoPreview.js";
17
+ import { RecordAudioScreen } from "./components/RecordAudioScreen.js";
18
+ import { ViewerCounter } from "./components/ViewerCounter.js";
19
+ import { GlobalAudioPlayerProvider, GlobalMiniAudioPlayer } from "../AudioPlayerContext.js";
20
+ import { useCameraLocalization } from "../localization.js";
21
+ import { RenderIcon } from "../icons.js";
22
+ import { DocumentTile, AudioTile } from "./components/FileTiles.js";
23
+ // Shared responsive scaling (portrait-normalized + clamped): one source of
24
+ // truth for chrome sizing across the app and all components. Layout that must
25
+ // track live rotation (pager/viewer page widths) uses useWindowDimensions.
26
+ import { wp, hp, ms, fs } from "./responsive.js";
27
+ import { useLibraryPalette } from "./palette.js";
28
+ import { refreshAssetMetadata, refreshAssetMetadataBatch } from "./fileMetadata.js";
29
+ import { formatFileSize, formatFileSizeRange, formatMediaDate, formatMediaTime, formatMediaDuration, formatDimensions, localizeNumber } from "./localizedFormat.js";
30
+ // Compressing the displayed image/video: the level ladder, the size band each level promises, and
31
+ // the measured countdown the overlay reads. Shared arithmetic with the native editor's own menu.
32
+ import { COMPRESSION_QUALITY_LEVELS, compressedCeilingBytes, compressedSizeBand, compressionCountdownLabel, compressionCountdownTickMs, compressionOptionForQuality, compressionSavingPercent, createCompressionProgressTracker, estimateImageCompressionSeconds, estimateVideoCompressionSeconds, foldCompressionProgress, isOriginalCompressionOption, originalCompressionOption, parseCompressionPercent } from "./compressionEstimate.js";
33
+
34
+ // Height of the system status bar. Used to keep the screen's own chrome and the
35
+ // full-screen viewer's overlay controls (close button / counter) clear of the
36
+ // status bar while media renders edge-to-edge behind the system bars.
37
+ //
38
+ // This is why the shell needs no `react-native-safe-area-context`: the only
39
+ // inset that matters on Android is the status bar, and `StatusBar.currentHeight`
40
+ // reports it directly.
41
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
42
+ const STATUS_BAR_INSET = Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 44;
43
+
44
+ // ── Media categories ─────────────────────────────────────────────
45
+ // Single ordered source of truth shared by the filter tabs and the horizontal
46
+ // category pager, so tab index ↔ page index mapping can never drift.
47
+ const FILTER_KEYS = ['all', 'photos', 'videos', 'documents', 'audio'];
48
+ /**
49
+ * One of the five library categories, in tab order. Exported from the package
50
+ * as `MediaLibraryFilterKey` — the type of the `initialFilter` prop.
51
+ */
52
+
53
+ // Mutable copy for FlatList's `data` prop (typed against mutable arrays).
54
+ const FILTER_PAGES = [...FILTER_KEYS];
55
+
56
+ // Map an asset's media type to the category tab that displays it.
57
+ const FILTER_KEY_FOR_TYPE = {
58
+ photo: 'photos',
59
+ video: 'videos',
60
+ document: 'documents',
61
+ audio: 'audio'
62
+ };
63
+
64
+ /**
65
+ * The renderer family named by a file NAME alone, ignoring any declared MIME type, and only when
66
+ * that name denotes a container with no pixel frame of its own (pdf/docx/xlsx/…). Returns null for
67
+ * images and for names that carry no recognised extension.
68
+ */
69
+ const documentKindFromName = name => {
70
+ if (!name) return null;
71
+ const kind = getDocumentPreviewKind(name, undefined);
72
+ return kind !== null && kind !== 'image' ? kind : null;
73
+ };
74
+ const getAssetPreviewMode = asset => {
75
+ if (asset.type === 'video') return 'video';
76
+ if (asset.type === 'audio') return 'audio';
77
+ const previewUri = asset.documentUri || asset.uri;
78
+ const previewName = previewUri ? previewUri.split(/[?#]/)[0]?.split(/[\\/]/).pop() : undefined;
79
+ const kind = getDocumentPreviewKind(previewName ?? asset.fileName, asset.mimeType);
80
+
81
+ // The FILE the viewer is about to open wins over the MIME type the asset carries, whenever the
82
+ // two disagree AND the file names a document container. "Export By → PDF" turns a photo into a
83
+ // PDF, and an asset that predates the export re-stating its own metadata still describes itself
84
+ // as image/jpeg while `documentUri` points at a .pdf. The image branch below trusted that stale
85
+ // type, sent the file to ImagePreview, and the decoder — which has no idea what a PDF is —
86
+ // produced nothing, so the slide came up blank. Checked against the resolved URI first and the
87
+ // asset's own name second, so a content:// URI with no extension still falls back correctly.
88
+ if (documentKindFromName(previewName) ?? documentKindFromName(asset.fileName)) {
89
+ return 'document';
90
+ }
91
+
92
+ // A still is an image no matter which picker it arrived through. The document module maps
93
+ // jpg/png/gif/webp/heic/avif — and every `image/*` MIME — to its own `image` kind, so EVERY
94
+ // image (captures included) used to land here as a "document" and render inside
95
+ // DocumentPreview's bordered, rounded surface: a card that letterboxed the frame and left
96
+ // empty background strips above and below it. Images take the same edge-to-edge path videos
97
+ // take; only formats with no pixel frame of their own (pdf/docx/xlsx/…) keep that surface.
98
+ if (kind === 'image' || asset.mimeType?.startsWith('image/')) {
99
+ return 'image';
100
+ }
101
+ if (asset.type === 'document' || kind !== null) {
102
+ return 'document';
103
+ }
104
+ return 'image';
105
+ };
106
+
107
+ /**
108
+ * Which compressor applies to this asset, or `null` when none does.
109
+ *
110
+ * Compression is a re-ENCODE, so it only means anything for media that has pixels of its own: a
111
+ * still or a clip. A document has no encoder that could shrink it without destroying it, and audio
112
+ * has no image/video encoder at all — both return null, which is the single predicate that hides
113
+ * the viewer's compress button AND refuses its handler, so the two can never disagree.
114
+ *
115
+ * Resolved through getAssetPreviewMode for the same reason `assetSupportsMetadata` is: a still is
116
+ * detected as 'image' whether it carries type 'photo', type 'image' or just an `image/*` MIME, and
117
+ * a photo that was exported to PDF resolves as the document it now is rather than the image it
118
+ * claims to be.
119
+ */
120
+ const compressibleKind = asset => {
121
+ if (!asset) return null;
122
+ const mode = getAssetPreviewMode(asset);
123
+ if (mode === 'video') return 'video';
124
+ if (mode === 'image') return 'image';
125
+ return null;
126
+ };
127
+
128
+ // Whether the viewer's "ⓘ" details affordance applies to this asset. Routed through
129
+ // getAssetPreviewMode so it is immune to the 'photo' vs 'image' type-naming split — a still is
130
+ // detected as 'image' whether the asset carries type 'photo', type 'image', or just an image/*
131
+ // MIME. This is the single predicate that gates BOTH the icon's visibility and its tap handler,
132
+ // so they can never disagree (the old split showed the icon for videos only, or showed it for a
133
+ // still whose tap then silently no-op'd).
134
+ const assetSupportsMetadata = asset => {
135
+ if (!asset) return false;
136
+ const mode = getAssetPreviewMode(asset);
137
+ return mode === 'video' || mode === 'image' || mode === 'document';
138
+ };
139
+
140
+ // The category tab a batch of just-sent assets should reveal. A single-type
141
+ // batch lands on that type's section (audio → 'audio'); a mixed batch has no
142
+ // single home, so it falls back to 'all'. Empty/unknown → null (no redirect).
143
+ const filterKeyForSentAssets = sent => {
144
+ if (sent.length === 0) return null;
145
+ const first = FILTER_KEY_FOR_TYPE[sent[0].type];
146
+ if (!first) return null;
147
+ return sent.every(a => FILTER_KEY_FOR_TYPE[a.type] === first) ? first : 'all';
148
+ };
149
+ const metadataFileName = asset => {
150
+ const value = asset.fileName || asset.documentUri || asset.uri;
151
+ const withoutQuery = value?.split(/[?#]/)[0] || '';
152
+ return withoutQuery.split(/[\\/]/).pop() || null;
153
+ };
154
+ const MetadataSheet = ({
155
+ asset,
156
+ visible,
157
+ onClose
158
+ }) => {
159
+ const {
160
+ t,
161
+ locale
162
+ } = useCameraLocalization();
163
+ const palette = useLibraryPalette();
164
+ const metadataStyles = useMemo(() => makeMetadataStyles(palette), [palette]);
165
+ const [resolvedAsset, setResolvedAsset] = useState(asset);
166
+ const sheetHeightRef = useRef(420);
167
+ const translateY = useRef(new Animated.Value(420)).current;
168
+ const backdropOpacity = useRef(new Animated.Value(0)).current;
169
+ const closingRef = useRef(false);
170
+ useEffect(() => {
171
+ if (!visible || !asset) return;
172
+ let cancelled = false;
173
+ setResolvedAsset(asset);
174
+ closingRef.current = false;
175
+ translateY.setValue(sheetHeightRef.current);
176
+ backdropOpacity.setValue(0);
177
+ Animated.parallel([Animated.spring(translateY, {
178
+ toValue: 0,
179
+ useNativeDriver: true,
180
+ tension: 72,
181
+ friction: 12
182
+ }), Animated.timing(backdropOpacity, {
183
+ toValue: 1,
184
+ duration: 220,
185
+ useNativeDriver: true
186
+ })]).start();
187
+ refreshAssetMetadata(asset).then(refreshed => {
188
+ if (!cancelled) setResolvedAsset(refreshed);
189
+ }).catch(() => {});
190
+ return () => {
191
+ cancelled = true;
192
+ };
193
+ }, [asset, backdropOpacity, translateY, visible]);
194
+ const closeAnimated = useCallback(() => {
195
+ if (closingRef.current) return;
196
+ closingRef.current = true;
197
+ Animated.parallel([Animated.timing(translateY, {
198
+ toValue: sheetHeightRef.current,
199
+ duration: 190,
200
+ useNativeDriver: true
201
+ }), Animated.timing(backdropOpacity, {
202
+ toValue: 0,
203
+ duration: 160,
204
+ useNativeDriver: true
205
+ })]).start(({
206
+ finished
207
+ }) => {
208
+ if (finished) onClose();
209
+ });
210
+ }, [backdropOpacity, onClose, translateY]);
211
+ const panResponder = useMemo(() => PanResponder.create({
212
+ onStartShouldSetPanResponder: () => false,
213
+ onMoveShouldSetPanResponder: (_event, gesture) => gesture.dy > 8,
214
+ onPanResponderMove: (_event, gesture) => {
215
+ translateY.setValue(Math.max(0, gesture.dy));
216
+ },
217
+ onPanResponderRelease: (_event, gesture) => {
218
+ if (gesture.dy > 100 || gesture.vy > 1.2) {
219
+ closeAnimated();
220
+ return;
221
+ }
222
+ Animated.spring(translateY, {
223
+ toValue: 0,
224
+ useNativeDriver: true,
225
+ tension: 80,
226
+ friction: 11
227
+ }).start();
228
+ },
229
+ onPanResponderTerminate: () => {
230
+ Animated.spring(translateY, {
231
+ toValue: 0,
232
+ useNativeDriver: true,
233
+ tension: 80,
234
+ friction: 11
235
+ }).start();
236
+ }
237
+ }), [closeAnimated, translateY]);
238
+ if (!resolvedAsset) return null;
239
+ const previewMode = getAssetPreviewMode(resolvedAsset);
240
+ const isVideo = previewMode === 'video';
241
+ const unknown = t('unknown');
242
+ // Title mirrors the asset kind: photos get "Photo details", videos "Video details". Resolved
243
+ // through getAssetPreviewMode so a still titles correctly regardless of its 'photo'/'image' type.
244
+ const detailsTitle = isVideo ? t('videoDetails') : previewMode === 'image' ? t('photoDetails') : t('mediaDetails');
245
+ const resolution = formatDimensions(resolvedAsset.width, resolvedAsset.height, locale);
246
+ const dateStr = formatMediaDate(resolvedAsset.dateTaken, locale);
247
+ const timeStr = formatMediaTime(resolvedAsset.dateTaken, locale);
248
+ const dateValue = dateStr && timeStr ? `${dateStr} · ${timeStr}` : dateStr ?? unknown;
249
+ const rows = [[t('metaFileName'), metadataFileName(resolvedAsset) ?? unknown], [t('metaFileType'), resolvedAsset.mimeType || resolvedAsset.type || unknown], [t('metaFileSize'), formatFileSize(resolvedAsset.size, locale, t) ?? unknown],
250
+ // Duration only applies to time-based media (video); images have none.
251
+ ...(isVideo ? [[t('metaDuration'), formatMediaDuration(resolvedAsset.duration, locale) ?? unknown]] : []), [t('metaResolution'), resolution ? `${resolution} ${t('pixelsUnit')}` : unknown], [t('metaDate'), dateValue], [t('metaPath'), resolvedAsset.documentUri || resolvedAsset.uri || unknown]];
252
+ return /*#__PURE__*/_jsx(Modal, {
253
+ transparent: true,
254
+ visible: visible,
255
+ animationType: "none",
256
+ onRequestClose: closeAnimated,
257
+ children: /*#__PURE__*/_jsxs(View, {
258
+ style: metadataStyles.modalRoot,
259
+ children: [/*#__PURE__*/_jsx(Animated.View, {
260
+ style: [metadataStyles.backdrop, {
261
+ opacity: backdropOpacity
262
+ }],
263
+ children: /*#__PURE__*/_jsx(Pressable, {
264
+ style: StyleSheet.absoluteFill,
265
+ onPress: closeAnimated
266
+ })
267
+ }), /*#__PURE__*/_jsxs(Animated.View, {
268
+ style: [metadataStyles.sheet, {
269
+ transform: [{
270
+ translateY
271
+ }]
272
+ }],
273
+ onLayout: event => {
274
+ sheetHeightRef.current = event.nativeEvent.layout.height;
275
+ },
276
+ ...panResponder.panHandlers,
277
+ children: [/*#__PURE__*/_jsx(View, {
278
+ style: metadataStyles.handle
279
+ }), /*#__PURE__*/_jsxs(View, {
280
+ style: metadataStyles.sheetHeader,
281
+ children: [/*#__PURE__*/_jsx(Text, {
282
+ style: metadataStyles.sheetTitle,
283
+ children: detailsTitle
284
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
285
+ onPress: closeAnimated,
286
+ style: metadataStyles.sheetClose,
287
+ accessibilityRole: "button",
288
+ accessibilityLabel: t('close'),
289
+ children: /*#__PURE__*/_jsx(RenderIcon, {
290
+ name: "close",
291
+ size: 20,
292
+ color: palette.white
293
+ })
294
+ })]
295
+ }), /*#__PURE__*/_jsx(ScrollView, {
296
+ showsVerticalScrollIndicator: false,
297
+ contentContainerStyle: metadataStyles.rows,
298
+ children: rows.map(([label, value]) => /*#__PURE__*/_jsxs(View, {
299
+ style: metadataStyles.row,
300
+ children: [/*#__PURE__*/_jsx(Text, {
301
+ style: metadataStyles.rowLabel,
302
+ children: label
303
+ }), /*#__PURE__*/_jsx(Text, {
304
+ style: metadataStyles.rowValue,
305
+ selectable: true,
306
+ children: value
307
+ })]
308
+ }, label))
309
+ })]
310
+ })]
311
+ })
312
+ });
313
+ };
314
+
315
+ /**
316
+ * The compression sheet: the ladder of quality levels for the image or video the viewer is showing,
317
+ * each row quoting the size it is expected to produce.
318
+ *
319
+ * Rows are QUALITY levels — the percentage of the original quality to keep — so the number on the
320
+ * row IS the value handed to the native compressor, and a lower row means a stronger compression.
321
+ * Every size is quoted against the ORIGINAL file (`asset` is always the stored asset, never a copy
322
+ * already made from it), so picking a second level after a first predicts against the untouched
323
+ * source exactly as the compressor itself will.
324
+ *
325
+ * Mirrors {@link MetadataSheet}'s presentation — same handle, header, spring-in/timing-out and
326
+ * drag-to-dismiss — so the viewer has one sheet idiom rather than two.
327
+ */
328
+ const CompressionSheet = ({
329
+ asset,
330
+ kind,
331
+ activeOption,
332
+ visible,
333
+ onClose,
334
+ onSelect
335
+ }) => {
336
+ const {
337
+ t,
338
+ locale
339
+ } = useCameraLocalization();
340
+ const palette = useLibraryPalette();
341
+ const sheetStyles = useMemo(() => makeCompressionStyles(palette), [palette]);
342
+ const sheetHeightRef = useRef(480);
343
+ const translateY = useRef(new Animated.Value(480)).current;
344
+ const backdropOpacity = useRef(new Animated.Value(0)).current;
345
+ const closingRef = useRef(false);
346
+ useEffect(() => {
347
+ if (!visible || !asset) return;
348
+ closingRef.current = false;
349
+ translateY.setValue(sheetHeightRef.current);
350
+ backdropOpacity.setValue(0);
351
+ Animated.parallel([Animated.spring(translateY, {
352
+ toValue: 0,
353
+ useNativeDriver: true,
354
+ tension: 72,
355
+ friction: 12
356
+ }), Animated.timing(backdropOpacity, {
357
+ toValue: 1,
358
+ duration: 220,
359
+ useNativeDriver: true
360
+ })]).start();
361
+ }, [asset, backdropOpacity, translateY, visible]);
362
+
363
+ /**
364
+ * Slide the sheet away, and — when the dismissal came from a row — apply that row once it is gone.
365
+ * Selecting first would start an encode underneath a sheet still animating out.
366
+ */
367
+ const closeAnimated = useCallback(selected => {
368
+ if (closingRef.current) return;
369
+ closingRef.current = true;
370
+ Animated.parallel([Animated.timing(translateY, {
371
+ toValue: sheetHeightRef.current,
372
+ duration: 190,
373
+ useNativeDriver: true
374
+ }), Animated.timing(backdropOpacity, {
375
+ toValue: 0,
376
+ duration: 160,
377
+ useNativeDriver: true
378
+ })]).start(({
379
+ finished
380
+ }) => {
381
+ if (!finished) return;
382
+ onClose();
383
+ if (selected) onSelect(selected);
384
+ });
385
+ }, [backdropOpacity, onClose, onSelect, translateY]);
386
+
387
+ // Bare dismissal, for the handlers that are handed an event (backdrop press, hardware back) —
388
+ // passing that event straight through would be read as a selected option.
389
+ const dismiss = useCallback(() => closeAnimated(), [closeAnimated]);
390
+ const panResponder = useMemo(() => PanResponder.create({
391
+ onStartShouldSetPanResponder: () => false,
392
+ onMoveShouldSetPanResponder: (_event, gesture) => gesture.dy > 8,
393
+ onPanResponderMove: (_event, gesture) => {
394
+ translateY.setValue(Math.max(0, gesture.dy));
395
+ },
396
+ onPanResponderRelease: (_event, gesture) => {
397
+ if (gesture.dy > 100 || gesture.vy > 1.2) {
398
+ dismiss();
399
+ return;
400
+ }
401
+ Animated.spring(translateY, {
402
+ toValue: 0,
403
+ useNativeDriver: true,
404
+ tension: 80,
405
+ friction: 11
406
+ }).start();
407
+ },
408
+ onPanResponderTerminate: () => {
409
+ Animated.spring(translateY, {
410
+ toValue: 0,
411
+ useNativeDriver: true,
412
+ tension: 80,
413
+ friction: 11
414
+ }).start();
415
+ }
416
+ }), [dismiss, translateY]);
417
+ if (!asset) return null;
418
+ const isVideo = kind === 'video';
419
+ // The original is the file on disk, so its row states a real measured size, with no tilde.
420
+ const originalBytes = asset.size ?? 0;
421
+ const originalLabel = t(isVideo ? 'originalVideo' : 'originalImage');
422
+ const originalSize = formatFileSize(originalBytes, locale, t);
423
+ const rows = [{
424
+ option: originalCompressionOption(kind),
425
+ label: originalSize ? t('compressOriginalSize', originalLabel, originalSize) : originalLabel,
426
+ active: isOriginalCompressionOption(activeOption)
427
+ }];
428
+ for (const quality of COMPRESSION_QUALITY_LEVELS) {
429
+ const option = compressionOptionForQuality(quality);
430
+ // "{0}% quality": the level reads as what it KEEPS, which is also what the bridge is given.
431
+ const levelLabel = t('compressActiveLabel', localizeNumber(quality, locale));
432
+ // A re-encode is aimed at a target the encoder spends around rather than hits, so the row
433
+ // promises the band it will land in — as an estimate ("~"), unlike the original's real size.
434
+ // The asset rides along as the clip's geometry/duration: that is what lets a video band respect
435
+ // the encoder's quality floor, so a heavy row never promises a reduction the pipeline refuses to
436
+ // make in order to keep the picture watchable.
437
+ const band = compressedSizeBand(originalBytes, quality, kind, asset);
438
+ const bandText = band ? formatFileSizeRange(band.low, band.high, locale, t) : null;
439
+ rows.push({
440
+ option,
441
+ label: bandText ? t('compressEstimatedSize', levelLabel, bandText) : levelLabel,
442
+ active: !isOriginalCompressionOption(activeOption) && activeOption === option
443
+ });
444
+ }
445
+ return /*#__PURE__*/_jsx(Modal, {
446
+ transparent: true,
447
+ visible: visible,
448
+ animationType: "none",
449
+ onRequestClose: dismiss,
450
+ children: /*#__PURE__*/_jsxs(View, {
451
+ style: sheetStyles.modalRoot,
452
+ children: [/*#__PURE__*/_jsx(Animated.View, {
453
+ style: [sheetStyles.backdrop, {
454
+ opacity: backdropOpacity
455
+ }],
456
+ children: /*#__PURE__*/_jsx(Pressable, {
457
+ style: StyleSheet.absoluteFill,
458
+ onPress: dismiss
459
+ })
460
+ }), /*#__PURE__*/_jsxs(Animated.View, {
461
+ style: [sheetStyles.sheet, {
462
+ transform: [{
463
+ translateY
464
+ }]
465
+ }],
466
+ onLayout: event => {
467
+ sheetHeightRef.current = event.nativeEvent.layout.height;
468
+ },
469
+ ...panResponder.panHandlers,
470
+ children: [/*#__PURE__*/_jsx(View, {
471
+ style: sheetStyles.handle
472
+ }), /*#__PURE__*/_jsxs(View, {
473
+ style: sheetStyles.sheetHeader,
474
+ children: [/*#__PURE__*/_jsx(Text, {
475
+ style: sheetStyles.sheetTitle,
476
+ children: t(isVideo ? 'compressVideo' : 'compressImage')
477
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
478
+ onPress: dismiss,
479
+ style: sheetStyles.sheetClose,
480
+ accessibilityRole: "button",
481
+ accessibilityLabel: t('close'),
482
+ children: /*#__PURE__*/_jsx(RenderIcon, {
483
+ name: "close",
484
+ size: 20,
485
+ color: palette.white
486
+ })
487
+ })]
488
+ }), /*#__PURE__*/_jsx(ScrollView, {
489
+ showsVerticalScrollIndicator: false,
490
+ contentContainerStyle: sheetStyles.rows,
491
+ children: rows.map(row => /*#__PURE__*/_jsxs(TouchableOpacity, {
492
+ style: [sheetStyles.row, row.active && sheetStyles.rowActive],
493
+ activeOpacity: 0.75,
494
+ onPress: () => closeAnimated(row.option),
495
+ accessibilityRole: "button",
496
+ accessibilityState: {
497
+ selected: row.active
498
+ },
499
+ accessibilityLabel: row.active ? t('selectedOption', row.label) : row.label,
500
+ children: [/*#__PURE__*/_jsx(Text, {
501
+ style: [sheetStyles.rowText, row.active && sheetStyles.rowTextActive],
502
+ numberOfLines: 1,
503
+ children: row.label
504
+ }), row.active ? /*#__PURE__*/_jsx(RenderIcon, {
505
+ name: "check",
506
+ size: 18,
507
+ color: palette.accentGreen
508
+ }) : null]
509
+ }, row.option))
510
+ })]
511
+ })]
512
+ })
513
+ });
514
+ };
515
+
516
+ /** Props for {@link MediaLibraryScreen}. */
517
+
518
+ /**
519
+ * The media library screen: the category tabs + swipeable category pager, the
520
+ * Plus/Cross FAB that opens the camera / gallery editor / document + audio
521
+ * pickers / audio recorder, the full-screen swipeable viewer (image, video with
522
+ * scrubber, document and audio preview cards) with its position counter and
523
+ * details sheet, and the mini audio player.
524
+ *
525
+ * It reads its entire dataset from the encrypted SQLCipher store, so it needs
526
+ * the `react-native-sqlcipher` peer dependency installed.
527
+ *
528
+ * Providers above it are optional: the theme, icon and localization hooks all
529
+ * fall back to the process-wide configuration (`configureCameraPlugin`,
530
+ * `setCameraIcons`, `setCameraLocalization`), so this renders correctly on its
531
+ * own. What it does NOT do by itself is install the shell's hand-authored
532
+ * outline icon set — {@link CameraMediaLibrary} adds that (plus the three
533
+ * providers) on top, which is why the drop-in is the recommended entry point.
534
+ */
535
+ export function MediaLibraryScreen({
536
+ uploadUrl,
537
+ initialFilter = 'all',
538
+ onAssetsChange,
539
+ style
540
+ } = {}) {
541
+ // ── Encrypted SQLCipher store is the single source of truth ──────
542
+ // The Media Library reads its entire dataset (and the filter-card counters)
543
+ // from the encrypted database. Captures and gallery selections are written to
544
+ // SQLCipher and immediately reflected here without an app restart.
545
+ const {
546
+ assets,
547
+ counts,
548
+ loading,
549
+ addAssets,
550
+ removeById
551
+ // removeMany is available for batch delete affordances and keeps SQLCipher
552
+ // in sync with the list.
553
+ } = useMediaDatabase();
554
+ const {
555
+ t,
556
+ locale
557
+ } = useCameraLocalization();
558
+ const palette = useLibraryPalette();
559
+ const s = useMemo(() => makeS(palette), [palette]);
560
+ const [isCapturing, setIsCapturing] = useState(false);
561
+
562
+ // Mirror the store outward for hosts that keep their own badge/count. Held in
563
+ // a ref so a caller passing an inline arrow can't re-fire the effect.
564
+ const onAssetsChangeRef = useRef(onAssetsChange);
565
+ onAssetsChangeRef.current = onAssetsChange;
566
+ useEffect(() => {
567
+ onAssetsChangeRef.current?.(assets);
568
+ }, [assets]);
569
+ const [activeFilter, setActiveFilter] = useState(initialFilter);
570
+ // Pager that gives the tabs swipe navigation. Tap and swipe stay in sync by
571
+ // funnelling BOTH through selectFilter/onCategoryScrollEnd → setActiveFilter.
572
+ const categoryPagerRef = useRef(null);
573
+ // Stable handle to selectFilter so the send handlers — declared ABOVE
574
+ // selectFilter — can drive the tab+pager redirect without a TDZ reference.
575
+ // Assigned in an effect once selectFilter exists (see below).
576
+ const selectFilterRef = useRef(null);
577
+
578
+ // Counters are computed dynamically from SQLCipher (COUNT(*) per media type).
579
+ const photoCount = counts.image;
580
+ const videoCount = counts.video;
581
+ const documentsCount = counts.document;
582
+ const audioCount = counts.audio;
583
+
584
+ // ── Compressed stand-ins for the stored assets ───────────────────
585
+ // A compression writes a NEW file into the app cache and never touches the original, so the
586
+ // result is held HERE — id → the copy that now stands in for that row — rather than written back
587
+ // over the SQLCipher row. Two reasons: the store's rows are published Documents files by
588
+ // contract, and the cache is evictable, so persisting a pointer into it could outlive the file it
589
+ // names. The override therefore lasts for the session, and the original is what survives a
590
+ // restart. Every surface (rows, viewer slides, counter, share, the details sheet) reads the
591
+ // override through `effectiveAssets` below, so there is ONE place the substitution happens.
592
+ const [compressedOverrides, setCompressedOverrides] = useState({});
593
+
594
+ // The stored assets by id — how a handler reaches the ORIGINAL even while an override is being
595
+ // displayed in its place. Every size the compression sheet quotes, and every source URI handed to
596
+ // the compressor, comes from here, so picking a second level compresses the untouched file again
597
+ // instead of compressing a compression.
598
+ const assetsById = useMemo(() => {
599
+ const byId = new Map();
600
+ for (const asset of assets) byId.set(asset.id, asset);
601
+ return byId;
602
+ }, [assets]);
603
+ const effectiveAssets = useMemo(() => {
604
+ if (Object.keys(compressedOverrides).length === 0) return assets;
605
+ return assets.map(asset => compressedOverrides[asset.id] ?? asset);
606
+ }, [assets, compressedOverrides]);
607
+ const assetsForFilter = useCallback(filter => {
608
+ switch (filter) {
609
+ case 'photos':
610
+ return effectiveAssets.filter(asset => asset.type === 'photo');
611
+ case 'videos':
612
+ return effectiveAssets.filter(asset => asset.type === 'video');
613
+ case 'documents':
614
+ return effectiveAssets.filter(asset => asset.type === 'document');
615
+ case 'audio':
616
+ return effectiveAssets.filter(asset => asset.type === 'audio');
617
+ case 'all':
618
+ default:
619
+ return effectiveAssets;
620
+ }
621
+ }, [effectiveAssets]);
622
+
623
+ // The ACTIVE category's rows — also the dataset behind the full-screen viewer,
624
+ // so viewer paging/counter keep operating on exactly what the user is browsing.
625
+ const filteredAssets = useMemo(() => assetsForFilter(activeFilter), [assetsForFilter, activeFilter]);
626
+ const formatDate = timestamp => {
627
+ const dateStr = formatMediaDate(timestamp, locale, 'long');
628
+ if (!dateStr) return t('unknown');
629
+ const timeStr = formatMediaTime(timestamp, locale);
630
+ return timeStr ? `${dateStr} - ${timeStr}` : dateStr;
631
+ };
632
+ const formatDuration = secs => formatMediaDuration(secs, locale) ?? t('unknown');
633
+ const [viewerVisible, setViewerVisible] = useState(false);
634
+ const [viewerIndex, setViewerIndex] = useState(0);
635
+ const [viewerPaused, setViewerPaused] = useState(true);
636
+ const [viewerControlsVisible, setViewerControlsVisible] = useState(true);
637
+ // Live mirror of viewerControlsVisible. toggleViewerControls reads this instead of the state so
638
+ // the toggle handler keeps a stable identity across visibility flips (a fresh closure over the
639
+ // state value would re-memoize renderViewerItem and could hand VideoPreview a stale toggle).
640
+ const viewerControlsVisibleRef = useRef(true);
641
+ const [metadataVisible, setMetadataVisible] = useState(false);
642
+ const viewerListRef = useRef(null);
643
+ // The counter tracks the finger through this ref instead of through `viewerIndex`, so a swipe
644
+ // repaints two lines of text rather than re-rendering this whole screen. See ViewerCounter.
645
+ const viewerCounterRef = useRef(null);
646
+
647
+ // Live window width for the full-screen viewer. useWindowDimensions re-renders
648
+ // on every runtime size change (rotation, split-screen, foldable fold/unfold,
649
+ // desktop window resize), so each paging slide, the snap interval, and the
650
+ // scroll math always match the CURRENT window instead of a value cached at
651
+ // module load. Using the stale module-level SCREEN_W here would mis-size the
652
+ // slides and break paging after any such change.
653
+ const {
654
+ width: viewerWidth
655
+ } = useWindowDimensions();
656
+
657
+ // Preview, editing, export and sending all share ONE fixed portrait 9:16
658
+ // canvas. The hook returns fresh 9:16 dimensions on every render and on
659
+ // window changes (orientation, resize). Media is always fitted (contain)
660
+ // inside this canvas — scaled + centered with its aspect ratio preserved —
661
+ // so a landscape capture is never rotated, stretched, distorted, or cropped.
662
+ const previewDims = usePortraitPreviewDimensions();
663
+ const previewW = previewDims.width;
664
+ const previewH = previewDims.height;
665
+ const previewAspectRatio = previewDims.aspectRatio;
666
+
667
+ // Hands confirmed assets to the native foreground UploadService, which posts the
668
+ // "Uploading media" progress notification and the "Upload completed/failed" alert
669
+ // in the system notification bar. Fire-and-forget: the service owns the lifecycle
670
+ // (and survives the app being backgrounded), so an upload error must never block
671
+ // or crash the capture/pick UX — it surfaces as a failure notification instead.
672
+ // With no `uploadUrl` prop there is nowhere to post to, so this is a no-op and
673
+ // the rest of the confirm pipeline is unchanged.
674
+ const uploadAssets = useCallback(assets => {
675
+ if (!uploadUrl) return;
676
+ const uris = assets.map(asset => asset.documentUri || asset.uri).filter(uri => !!uri);
677
+ if (uris.length === 0) return;
678
+ startUpload(uris, uploadUrl).catch(uploadError => {
679
+ console.warn('Failed to start upload service:', uploadError);
680
+ });
681
+ }, [uploadUrl]);
682
+
683
+ // Everything that happens once media has been CONFIRMED on the native send screen —
684
+ // normalize the assets, upload, persist to SQLCipher, reveal the section they landed in.
685
+ // The camera capture flow and the gallery → editor flow both end here, which is what makes
686
+ // gallery-picked media behave exactly like a capture from this point on. A cancelled result
687
+ // (back / X on either screen) is a no-op.
688
+ const processConfirmedAssets = useCallback(async pickerResult => {
689
+ if (pickerResult && !pickerResult.cancelled && Array.isArray(pickerResult.assets) && pickerResult.assets.length > 0) {
690
+ const capturedAssets = [];
691
+ pickerResult.assets.forEach((asset, index) => {
692
+ // Skip a null/garbage element instead of dereferencing it (the native contract
693
+ // should never send one, but a bad element must not abort the whole capture).
694
+ if (!asset) return;
695
+ const a = asset;
696
+ const processedAsset = {
697
+ // Multi-select assets can arrive without ids. Date.now() alone would hand every
698
+ // asset in this same synchronous loop the SAME id → duplicate FlatList keys and
699
+ // silent dedup/data-loss. Add the index (and a random suffix) so ids are unique.
700
+ id: a.id || `${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`,
701
+ uri: a.uri || '',
702
+ type: a.type || 'photo',
703
+ width: previewW,
704
+ height: previewH,
705
+ fileName: a.fileName || (a.type === 'video' ? `video_${Date.now()}.mp4` : `photo_${Date.now()}.${extensionForMimeType(a.mimeType, 'jpg')}`),
706
+ dateTaken: a.dateTaken || Date.now(),
707
+ previewAspectRatio: previewAspectRatio,
708
+ needsThumbnail: a.needsThumbnail || false,
709
+ mimeType: a.mimeType,
710
+ caption: a.caption,
711
+ duration: a.duration,
712
+ size: a.size,
713
+ location: a.location,
714
+ // Preserve the storage-lifecycle fields the native Send workflow sets.
715
+ // These tell the DB layer that the file is now in the Documents
716
+ // directory (documentUri set, cachePath cleared) and where it came
717
+ // from (originalUri present ⇒ gallery). Stripping them would hide the
718
+ // final persistent path and force a fallback — so keep them intact.
719
+ documentUri: a.documentUri,
720
+ cachePath: a.cachePath,
721
+ isCached: a.isCached,
722
+ originalUri: a.originalUri,
723
+ // A compressed video's `size` is the proportional display figure the
724
+ // dropdown/toast quoted, not the bytes on disk. Carry the flag so the
725
+ // DB persists it and refreshAssetMetadata keeps `size` instead of
726
+ // re-statting the file (which would show the larger real size).
727
+ sizeIsDisplayEstimate: a.sizeIsDisplayEstimate
728
+ };
729
+ capturedAssets.push(processedAsset);
730
+ });
731
+ // Refresh metadata from actual files (especially important after compression/export)
732
+ // so preview and storage always show the correct file size, dimensions, etc.
733
+ let assetsWithFreshMetadata = capturedAssets;
734
+ try {
735
+ assetsWithFreshMetadata = await refreshAssetMetadataBatch(capturedAssets);
736
+ } catch (metadataError) {
737
+ console.warn('Failed to refresh asset metadata:', metadataError);
738
+ // If refresh fails, continue with original metadata
739
+ }
740
+ // Send confirmed → hand the files to the native UploadService FIRST.
741
+ // The upload only needs the documentUri/uri values already on the assets
742
+ // (published natively before the picker resolved), so it has zero
743
+ // dependency on the database write below. It previously ran AFTER the
744
+ // awaited SQLCipher insert + full-library refresh, which serialized the
745
+ // upload — and its foreground progress notification — behind seconds of
746
+ // DB work; that was the "progress bar appears 5-6s after Send" delay.
747
+ uploadAssets(assetsWithFreshMetadata);
748
+ // The picker's promise only resolves AFTER the user pressed Send and the
749
+ // native workflow moved each confirmed file into the Documents directory
750
+ // (a cancel/back resolves with `cancelled: true` and no assets, handled
751
+ // above). Persisting here therefore happens only on explicit Send.
752
+ //
753
+ // The repository stores ONLY assets confirmed in Documents (final path),
754
+ // skips any cache-only/publish-failed stragglers, and auto-detects each
755
+ // item's origin (camera vs gallery). The hook then refreshes the list +
756
+ // counters from SQLCipher so new media appears immediately. A DB failure
757
+ // is contained here and never blocks the capture UX.
758
+ try {
759
+ await addAssets(assetsWithFreshMetadata);
760
+ } catch (dbError) {
761
+ console.warn('Failed to persist media to SQLCipher:', dbError);
762
+ }
763
+ // Reveal the section the just-sent media lives in (audio → 'audio',
764
+ // mixed batch → 'all'), so the user lands on their new item.
765
+ const target = filterKeyForSentAssets(assetsWithFreshMetadata);
766
+ if (target) selectFilterRef.current?.(target);
767
+ }
768
+ }, [addAssets, uploadAssets, previewW, previewH, previewAspectRatio]);
769
+
770
+ // One capture flow for the FAB's Camera / Photo / Video actions. `mediaTypes` scopes the
771
+ // native camera: 'photo' hides video recording entirely, 'video' opens straight into
772
+ // video mode with photo capture off, 'all' keeps the full capture UI. Everything after
773
+ // the picker resolves (upload, SQLCipher persist, filter reveal) is identical.
774
+ const openCapture = async mediaTypes => {
775
+ try {
776
+ setIsCapturing(true);
777
+ const pickerResult = await openCameraPicker({
778
+ mediaTypes,
779
+ selectionLimit: 0,
780
+ // 0 = unlimited selection
781
+ enableVideo: mediaTypes !== 'photo'
782
+ });
783
+ await processConfirmedAssets(pickerResult);
784
+ } catch (error) {
785
+ console.error('Error capturing from camera:', error);
786
+ } finally {
787
+ setIsCapturing(false);
788
+ }
789
+ };
790
+
791
+ // The FAB's Camera action. `openCapture` still takes a mode because the native
792
+ // camera supports photo-only / video-only entry — the shell's menu just offers
793
+ // the full capture UI, and a host wiring its own row can call it directly.
794
+ const handleOpenCamera = () => openCapture('all');
795
+
796
+ // The FAB's Document and Audio actions, backed by the native Storage Access Framework pickers
797
+ // (openDocumentPicker / openAudioPicker). Both resolve with the same `{ cancelled, assets }`
798
+ // shape as the camera picker; the selected files are persisted to the encrypted store just like
799
+ // captures, so they appear immediately in the library.
800
+ const handlePickFiles = useCallback(async pick => {
801
+ try {
802
+ setIsCapturing(true);
803
+ const result = await pick({
804
+ selectionLimit: 0
805
+ });
806
+ if (result && !result.cancelled && Array.isArray(result.assets) && result.assets.length > 0) {
807
+ const picked = result.assets.filter(Boolean).map((asset, index) => ({
808
+ ...asset,
809
+ id: asset.id || `${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`,
810
+ dateTaken: asset.dateTaken || Date.now()
811
+ }));
812
+ // Refresh metadata from actual files to ensure correct file sizes and properties
813
+ let pickedWithFreshMetadata = picked;
814
+ try {
815
+ pickedWithFreshMetadata = await refreshAssetMetadataBatch(picked);
816
+ } catch (metadataError) {
817
+ console.warn('Failed to refresh picked file metadata:', metadataError);
818
+ // If refresh fails, continue with original metadata
819
+ }
820
+ // Picked documents/audio go through the same notified upload flow.
821
+ // Upload first for the same reason as the camera path: the progress
822
+ // notification must not wait behind the awaited SQLCipher persist.
823
+ uploadAssets(pickedWithFreshMetadata);
824
+ try {
825
+ await addAssets(pickedWithFreshMetadata);
826
+ } catch (dbError) {
827
+ console.warn('Failed to persist picked files to SQLCipher:', dbError);
828
+ }
829
+ // Reveal the section the picked files live in (e.g. audio → 'audio',
830
+ // documents → 'documents'), so the user lands on their new item.
831
+ const target = filterKeyForSentAssets(pickedWithFreshMetadata);
832
+ if (target) selectFilterRef.current?.(target);
833
+ }
834
+ } catch (error) {
835
+ console.error('Error picking files:', error);
836
+ } finally {
837
+ setIsCapturing(false);
838
+ }
839
+ }, [addAssets, uploadAssets]);
840
+ const handleOpenDocument = useCallback(() => handlePickFiles(openDocumentPicker), [handlePickFiles]);
841
+ const handleOpenAudio = useCallback(() => handlePickFiles(openAudioPicker), [handlePickFiles]);
842
+ const [recordAudioVisible, setRecordAudioVisible] = useState(false);
843
+ const handleOpenRecordAudio = useCallback(() => setRecordAudioVisible(true), []);
844
+ const handleRecordedAudio = useCallback(asset => {
845
+ // Refresh metadata from the actual recorded file
846
+ refreshAssetMetadataBatch([asset]).then(refreshed => {
847
+ const assetToStore = refreshed[0] || asset;
848
+ // The native side already moved the file to Documents and returned
849
+ // a fully-formed asset with documentUri. Persist it to SQLCipher
850
+ // so it appears in the library immediately.
851
+ addAssets([assetToStore]).catch(dbError => {
852
+ console.warn('Failed to persist recorded audio to SQLCipher:', dbError);
853
+ });
854
+ // Reveal the Audio section.
855
+ selectFilterRef.current?.('audio');
856
+ }).catch(metadataError => {
857
+ console.warn('Failed to refresh recorded audio metadata:', metadataError);
858
+ // If refresh fails, persist with original metadata
859
+ addAssets([asset]).catch(dbError => {
860
+ console.warn('Failed to persist recorded audio to SQLCipher:', dbError);
861
+ });
862
+ selectFilterRef.current?.('audio');
863
+ });
864
+ }, [addAssets]);
865
+
866
+ // The FAB's Gallery action. Opens the device gallery, detects whether the picked item is an
867
+ // image or a video, and hands it to the SAME native preview/editor screen a capture opens on —
868
+ // the image editor with its full tool set for a photo, the video editor for a clip. Sending from
869
+ // there returns through the identical result pipeline, so it flows into processConfirmedAssets
870
+ // exactly like a capture. Backing out of the picker or the editor is a silent no-op, and an
871
+ // unreadable/unsupported selection comes back cancelled (the editor reports it natively).
872
+ const handleOpenImageVideo = useCallback(async () => {
873
+ try {
874
+ setIsCapturing(true);
875
+ const result = await openGalleryEditor();
876
+ await processConfirmedAssets(result);
877
+ } catch (error) {
878
+ console.error('Error editing media from gallery:', error);
879
+ } finally {
880
+ setIsCapturing(false);
881
+ }
882
+ }, [processConfirmedAssets]);
883
+
884
+ // ── Category swipe pager ⇄ tabs synchronization ─────────────────
885
+ // The pager lives inside s.screen (paddingHorizontal: wp(16)), so its usable
886
+ // width — and therefore the page width every paging computation must use —
887
+ // is the live window width minus that padding. One shared constant keeps
888
+ // page size, snap interval, offset math and scroll-end math identical.
889
+ const categoryPageWidth = viewerWidth - wp(32);
890
+ const categoryIndex = FILTER_KEYS.indexOf(activeFilter);
891
+
892
+ // True while a tab-tap glide (selectFilter's animated scrollToOffset) is in
893
+ // flight. Live swipe→tab tracking must pause for its duration: the tapped tab
894
+ // is already highlighted, and tracking the glide would flash every pill
895
+ // between the origin and the target as the pager sweeps past them.
896
+ const isTabTapScrollRef = useRef(false);
897
+
898
+ // Tab tap: update state AND glide the pager to the matching page.
899
+ const selectFilter = useCallback(key => {
900
+ isTabTapScrollRef.current = true;
901
+ setActiveFilter(key);
902
+ categoryPagerRef.current?.scrollToOffset({
903
+ offset: categoryPageWidth * FILTER_KEYS.indexOf(key),
904
+ animated: true
905
+ });
906
+ }, [categoryPageWidth]);
907
+
908
+ // Keep the ref pointed at the latest selectFilter so the send handlers above
909
+ // can trigger the post-send redirect through the same tab+pager path.
910
+ useEffect(() => {
911
+ selectFilterRef.current = selectFilter;
912
+ }, [selectFilter]);
913
+
914
+ // Live swipe → tab sync: flip the active pill the moment the scroll crosses
915
+ // the halfway point to a neighbouring page, instead of waiting for the
916
+ // momentum animation to settle (onMomentumScrollEnd) — that wait is what made
917
+ // the highlight visibly lag the swipe. The gesture itself stays fully native:
918
+ // this handler only touches React state, and the functional setState bails
919
+ // out render-free on every frame where the derived page hasn't changed, so at
920
+ // most one re-render happens per page crossing and the swipe smoothness is
921
+ // untouched.
922
+ const handleCategoryScroll = useCallback(event => {
923
+ if (isTabTapScrollRef.current) return;
924
+ const rawIndex = Math.round(event.nativeEvent.contentOffset.x / categoryPageWidth);
925
+ const index = Math.max(0, Math.min(rawIndex, FILTER_KEYS.length - 1));
926
+ const key = FILTER_KEYS[index];
927
+ if (key) setActiveFilter(prev => prev === key ? prev : key);
928
+ }, [categoryPageWidth]);
929
+
930
+ // A user drag always takes live tracking back over — including grabbing the
931
+ // pager mid tap-glide (also covers Android, where programmatic animated
932
+ // scrolls don't reliably emit onMomentumScrollEnd to clear the flag).
933
+ const handleCategoryDragBegin = useCallback(() => {
934
+ isTabTapScrollRef.current = false;
935
+ }, []);
936
+
937
+ // Swipe settle: derive the landed page and mirror it into the active tab.
938
+ // With live tracking above this is normally a no-op safety net; it also ends
939
+ // the tap-glide window so live tracking resumes for the next gesture.
940
+ const handleCategoryScrollEnd = useCallback(event => {
941
+ isTabTapScrollRef.current = false;
942
+ const rawIndex = Math.round(event.nativeEvent.contentOffset.x / categoryPageWidth);
943
+ const index = Math.max(0, Math.min(rawIndex, FILTER_KEYS.length - 1));
944
+ const key = FILTER_KEYS[index];
945
+ if (key) setActiveFilter(key);
946
+ }, [categoryPageWidth]);
947
+ const getCategoryPageLayout = useCallback((_, index) => ({
948
+ length: categoryPageWidth,
949
+ offset: categoryPageWidth * index,
950
+ index
951
+ }), [categoryPageWidth]);
952
+
953
+ // Re-snap the pager to the active page when the window width changes
954
+ // (rotation, split-screen, fold) so it never settles between two categories.
955
+ // Guarded by a width ref: firing on categoryIndex changes too would stomp the
956
+ // tab-tap glide (selectFilter's animated scroll) with an instant jump.
957
+ const lastPagerWidthRef = useRef(categoryPageWidth);
958
+ useEffect(() => {
959
+ if (lastPagerWidthRef.current === categoryPageWidth) return;
960
+ lastPagerWidthRef.current = categoryPageWidth;
961
+ categoryPagerRef.current?.scrollToOffset({
962
+ offset: categoryPageWidth * categoryIndex,
963
+ animated: false
964
+ });
965
+ }, [categoryPageWidth, categoryIndex]);
966
+ const openViewer = useCallback((index, sourceFilter) => {
967
+ // A row tapped on a pager page that hasn't finished settling as the active
968
+ // filter yet must open the viewer against ITS page's dataset, not a stale
969
+ // one — align the filter first (same render batch, so filteredAssets is
970
+ // correct by the time the Modal mounts).
971
+ if (sourceFilter) {
972
+ setActiveFilter(prev => prev === sourceFilter ? prev : sourceFilter);
973
+ }
974
+ setViewerIndex(index);
975
+ setViewerControlsVisible(true);
976
+ // Start the video immediately when the viewer opens so the playhead and scrub
977
+ // fill advance together with the native video playback.
978
+ setViewerPaused(false);
979
+ setViewerVisible(true);
980
+ }, []);
981
+
982
+ // Reveal the transport controls. There is no auto-hide timer: once shown, the controls stay
983
+ // until an explicit tap on the video area hides them (toggleViewerControls). Removing the old
984
+ // 5s auto-hide fixed the controls (including the scrubber) vanishing mid-drag when a scrub
985
+ // lasted longer than the timer — the finger was still down but the whole controls block, and
986
+ // with it the PanResponder, unmounted. Kept as a named callback because VideoPreview calls it
987
+ // as onControlInteraction and the open/index effect below reuses it.
988
+ const showViewerControls = useCallback(() => {
989
+ setViewerControlsVisible(true);
990
+ }, []);
991
+
992
+ // Hide the transport controls at once.
993
+ const hideViewerControls = useCallback(() => {
994
+ setViewerControlsVisible(false);
995
+ }, []);
996
+
997
+ // A tap on the video surface (VideoPreview.onVideoAreaPress) toggles the transport controls:
998
+ // reveal them when hidden, hide them when already shown. The direction is decided from the live
999
+ // ref so a second tap reliably hides regardless of what the memoized renderViewerItem closure
1000
+ // captured. Only the video area calls this — chrome (X, counter, Share/Delete/Info) is outside
1001
+ // it and never toggles control visibility.
1002
+ const toggleViewerControls = useCallback(() => {
1003
+ if (viewerControlsVisibleRef.current) {
1004
+ hideViewerControls();
1005
+ } else {
1006
+ showViewerControls();
1007
+ }
1008
+ }, [hideViewerControls, showViewerControls]);
1009
+
1010
+ // Keep the ref in step with the state (open/index changes and the toggle itself all flow
1011
+ // through here) so toggleViewerControls always reads the current visibility.
1012
+ useEffect(() => {
1013
+ viewerControlsVisibleRef.current = viewerControlsVisible;
1014
+ }, [viewerControlsVisible]);
1015
+ useEffect(() => {
1016
+ if (!viewerVisible) return;
1017
+ // Ensure controls are visible when the viewer opens or the page changes.
1018
+ showViewerControls();
1019
+ }, [showViewerControls, viewerIndex, viewerVisible]);
1020
+ const handleExpandAudioPlayer = useCallback(uri => {
1021
+ // Find the audio asset in the current list
1022
+ const index = filteredAssets.findIndex(a => a.uri === uri || a.documentUri === uri);
1023
+ if (index >= 0) {
1024
+ openViewer(index);
1025
+ } else {
1026
+ // If it's not in the current filter, switch to 'all' and find it
1027
+ setActiveFilter('all');
1028
+ // The same array the 'all' page renders (`assetsForFilter('all')`), so the index resolved
1029
+ // here addresses the same row the viewer will land on.
1030
+ const allAssets = effectiveAssets;
1031
+ const allIndex = allAssets.findIndex(a => a.uri === uri || a.documentUri === uri);
1032
+ if (allIndex >= 0) {
1033
+ // Schedule the viewer to open after state update
1034
+ setTimeout(() => openViewer(allIndex, 'all'), 50);
1035
+ }
1036
+ }
1037
+ }, [filteredAssets, effectiveAssets, openViewer]);
1038
+ const closeViewer = useCallback(() => {
1039
+ setMetadataVisible(false);
1040
+ setCompressionSheetVisible(false);
1041
+ setViewerVisible(false);
1042
+ setViewerPaused(true);
1043
+ }, []);
1044
+ const openMetadata = useCallback(() => {
1045
+ if (assetSupportsMetadata(filteredAssets[viewerIndex])) {
1046
+ setMetadataVisible(true);
1047
+ }
1048
+ }, [filteredAssets, viewerIndex]);
1049
+
1050
+ // Share the currently displayed media through the native Android share sheet
1051
+ // (ACTION_SEND with the correct MIME type for the asset's kind). No third-party
1052
+ // dependency — the native bridge launches Intent.createChooser directly.
1053
+ const handleShareMedia = useCallback(async () => {
1054
+ const item = filteredAssets[viewerIndex];
1055
+ if (!item) return;
1056
+ const uri = item.uri || item.documentUri || '';
1057
+ if (!uri) return;
1058
+ try {
1059
+ await shareMedia([uri], item.mimeType || '');
1060
+ } catch (err) {
1061
+ console.warn('[viewer] share failed', err);
1062
+ }
1063
+ }, [filteredAssets, viewerIndex]);
1064
+
1065
+ // ── Compressing the displayed image or video ─────────────────────
1066
+ const [compressionSheetVisible, setCompressionSheetVisible] = useState(false);
1067
+ // id → the option applied to it. Absent means the original, which is also what the sheet's first
1068
+ // row restores; the option string is the exact argument the native compressor was given.
1069
+ const [compressionLevels, setCompressionLevels] = useState({});
1070
+ // The countdown's current line. Non-null IS "a compression is running" — it drives the overlay,
1071
+ // so the text and the blocking layer can never disagree about whether work is in flight.
1072
+ const [compressionLabel, setCompressionLabel] = useState(null);
1073
+ // `${id}|${option}` → the variant that combination already produced, so re-picking a level
1074
+ // already made is instant and re-encodes nothing.
1075
+ const compressionResultsRef = useRef(new Map());
1076
+ // Monotonic token: only the newest request may apply its result or clear the overlay, so a level
1077
+ // picked while an earlier one is still finishing cannot be overwritten by it.
1078
+ const compressionRequestRef = useRef(0);
1079
+ // The URI the in-flight compression was started with — how a live progress report is told from a
1080
+ // stale one left over from a previous job.
1081
+ const compressionSourceRef = useRef(null);
1082
+ const compressionTrackerRef = useRef(null);
1083
+ const compressionTimerRef = useRef(null);
1084
+
1085
+ /**
1086
+ * The asset the compress button acts on: the ORIGINAL stored row behind the displayed slide, plus
1087
+ * the compressor its kind selects. Null for a document, for audio, and for an empty viewer — the
1088
+ * one value that both hides the button and refuses the handler.
1089
+ */
1090
+ const compressionTarget = useMemo(() => {
1091
+ const displayed = filteredAssets[viewerIndex];
1092
+ const kind = compressibleKind(displayed);
1093
+ if (!displayed || !kind) return null;
1094
+ return {
1095
+ asset: assetsById.get(displayed.id) ?? displayed,
1096
+ kind
1097
+ };
1098
+ }, [assetsById, filteredAssets, viewerIndex]);
1099
+
1100
+ /**
1101
+ * The countdown's own clock. It re-schedules itself through a ref rather than a self-referencing
1102
+ * callback, and re-reads `t` on every tick, so a host language change mid-compression is picked up
1103
+ * by the very next line rather than at the end.
1104
+ */
1105
+ const compressionTickRef = useRef(() => {});
1106
+ compressionTickRef.current = () => {
1107
+ const tracker = compressionTrackerRef.current;
1108
+ if (!tracker) return;
1109
+ const now = Date.now();
1110
+ setCompressionLabel(compressionCountdownLabel(tracker, now, t));
1111
+ compressionTimerRef.current = setTimeout(() => compressionTickRef.current(), compressionCountdownTickMs(tracker, now));
1112
+ };
1113
+ const stopCompressionCountdown = useCallback(() => {
1114
+ if (compressionTimerRef.current) {
1115
+ clearTimeout(compressionTimerRef.current);
1116
+ compressionTimerRef.current = null;
1117
+ }
1118
+ compressionTrackerRef.current = null;
1119
+ compressionSourceRef.current = null;
1120
+ setCompressionLabel(null);
1121
+ }, []);
1122
+
1123
+ // ONE subscription for the screen's lifetime. Reports are folded into the tracker (which
1124
+ // re-derives the total from `elapsed / progress`) rather than rendered directly — the line itself
1125
+ // is repainted by the tick above, so a burst of native reports costs no extra renders.
1126
+ useEffect(() => {
1127
+ const subscription = addCompressionProgressListener(event => {
1128
+ const tracker = compressionTrackerRef.current;
1129
+ if (!tracker) return;
1130
+ if (compressionSourceRef.current && event.uri !== compressionSourceRef.current) {
1131
+ return;
1132
+ }
1133
+ compressionTrackerRef.current = foldCompressionProgress(tracker, event.progress, Date.now());
1134
+ });
1135
+ return () => subscription.remove();
1136
+ }, []);
1137
+
1138
+ // A screen torn down mid-compression must not leave its timer firing into an unmounted tree.
1139
+ useEffect(() => () => {
1140
+ if (compressionTimerRef.current) clearTimeout(compressionTimerRef.current);
1141
+ }, []);
1142
+
1143
+ /**
1144
+ * The asset that stands in for `original` now that `outUri` has been written — the compressed copy
1145
+ * described as a full asset, so every surface can render it without knowing a compression happened.
1146
+ */
1147
+ const compressedVariant = useCallback(async (original, outUri, kind, quality) => {
1148
+ let meta = null;
1149
+ try {
1150
+ meta = await getMediaMetadata(outUri);
1151
+ } catch {
1152
+ // A copy whose metadata cannot be read still displays — it just keeps the original's figures.
1153
+ meta = null;
1154
+ }
1155
+ // A clip is encoded at a TARGET bit rate the encoder spends around rather than hits, so the
1156
+ // honest figure to QUOTE for it is the proportional one the native editor and its toast quote,
1157
+ // not a file stat. `sizeIsDisplayEstimate` is what stops refreshAssetMetadata replacing it.
1158
+ // Computed from the ORIGINAL's geometry and duration, so it is raised to the same quality floor
1159
+ // the sheet's row was — otherwise the viewer would go on displaying the row's old promise for a
1160
+ // file the encoder deliberately kept larger.
1161
+ const displayBytes = kind === 'video' ? compressedCeilingBytes(original.size ?? 0, quality, 'video', original) : 0;
1162
+ const measured = meta?.size && meta.size > 0 ? meta.size : undefined;
1163
+ return {
1164
+ ...original,
1165
+ // The copy lives in the app cache, never in Documents — clearing documentUri/cachePath is
1166
+ // what points every preview at it, since they all resolve `documentUri || uri`.
1167
+ uri: outUri,
1168
+ documentUri: undefined,
1169
+ cachePath: undefined,
1170
+ // Where it came from, so a delete can still find and erase the original alongside it.
1171
+ originalUri: original.documentUri || original.uri,
1172
+ width: meta?.width ?? original.width,
1173
+ height: meta?.height ?? original.height,
1174
+ duration: meta?.duration ?? original.duration,
1175
+ mimeType: meta?.mimeType || original.mimeType,
1176
+ fileName: meta?.fileName || original.fileName,
1177
+ size: displayBytes > 0 ? displayBytes : measured ?? original.size,
1178
+ sizeIsDisplayEstimate: displayBytes > 0 ? true : undefined
1179
+ };
1180
+ }, []);
1181
+
1182
+ /**
1183
+ * Apply one row of the compression sheet to the displayed media.
1184
+ *
1185
+ * The original file is only ever READ: the compressor writes a new file into the app cache and the
1186
+ * result is held as a session override, so "preserve the original, preview and send the copy" is
1187
+ * true by construction — there is no path here that overwrites, moves or deletes the source.
1188
+ */
1189
+ const applyCompressionOption = useCallback(async option => {
1190
+ const target = compressionTarget;
1191
+ if (!target) return;
1192
+ const {
1193
+ asset: original,
1194
+ kind
1195
+ } = target;
1196
+ const id = original.id;
1197
+
1198
+ // The first row: back to the stored file. Nothing is encoded and nothing is thrown away — the
1199
+ // copies already made stay cached, so moving between levels stays instant.
1200
+ if (isOriginalCompressionOption(option)) {
1201
+ setCompressionLevels(prev => {
1202
+ if (!prev[id]) return prev;
1203
+ const next = {
1204
+ ...prev
1205
+ };
1206
+ delete next[id];
1207
+ return next;
1208
+ });
1209
+ setCompressedOverrides(prev => {
1210
+ if (!prev[id]) return prev;
1211
+ const next = {
1212
+ ...prev
1213
+ };
1214
+ delete next[id];
1215
+ return next;
1216
+ });
1217
+ return;
1218
+ }
1219
+ const cacheKey = `${id}|${option}`;
1220
+ const alreadyMade = compressionResultsRef.current.get(cacheKey);
1221
+ if (alreadyMade) {
1222
+ setCompressionLevels(prev => ({
1223
+ ...prev,
1224
+ [id]: option
1225
+ }));
1226
+ setCompressedOverrides(prev => ({
1227
+ ...prev,
1228
+ [id]: alreadyMade
1229
+ }));
1230
+ return;
1231
+ }
1232
+
1233
+ // ALWAYS the original file, even while a compressed copy is the one on screen: re-encoding a
1234
+ // re-encode would stack generation loss and quote sizes against the wrong baseline.
1235
+ const sourceUri = original.documentUri || original.uri;
1236
+ if (!sourceUri) return;
1237
+ const quality = parseCompressionPercent(option);
1238
+ const token = compressionRequestRef.current + 1;
1239
+ compressionRequestRef.current = token;
1240
+ compressionSourceRef.current = sourceUri;
1241
+ compressionTrackerRef.current = createCompressionProgressTracker(kind === 'video' ? estimateVideoCompressionSeconds({
1242
+ sizeBytes: original.size,
1243
+ duration: original.duration,
1244
+ width: original.width,
1245
+ height: original.height,
1246
+ percent: quality
1247
+ }) : estimateImageCompressionSeconds({
1248
+ width: original.width,
1249
+ height: original.height
1250
+ }), Date.now());
1251
+ // Paints the first line and starts the clock — which is also what raises the overlay.
1252
+ compressionTickRef.current();
1253
+ const title = t(kind === 'video' ? 'compressVideo' : 'compressImage');
1254
+ try {
1255
+ // The PROMISE is the completion signal, never the progress stream: a compression that gives
1256
+ // up part way through settles without a final report on every path.
1257
+ const outUri = kind === 'video' ? await compressVideo(sourceUri, option) : await compressImage(sourceUri, option);
1258
+ // A newer level was picked while this one was running — it owns the overlay and the result.
1259
+ if (compressionRequestRef.current !== token) return;
1260
+ stopCompressionCountdown();
1261
+ if (!outUri || outUri === sourceUri) {
1262
+ // Native resolves the INPUT back when no re-encode came out smaller. Nothing failed —
1263
+ // there is simply nothing left to shave, so the original stays applied.
1264
+ Alert.alert(title, t('compressionAlreadyMinimal'));
1265
+ return;
1266
+ }
1267
+ const variant = await compressedVariant(original, outUri, kind, quality);
1268
+ if (compressionRequestRef.current !== token) return;
1269
+ compressionResultsRef.current.set(cacheKey, variant);
1270
+ setCompressionLevels(prev => ({
1271
+ ...prev,
1272
+ [id]: option
1273
+ }));
1274
+ setCompressedOverrides(prev => ({
1275
+ ...prev,
1276
+ [id]: variant
1277
+ }));
1278
+ const producedSize = formatFileSize(variant.size, locale, t);
1279
+ const saving = compressionSavingPercent(original.size ?? 0, variant.size ?? 0);
1280
+ if (producedSize) {
1281
+ Alert.alert(title, saving !== null ? t('compressedToSize', producedSize, localizeNumber(saving, locale)) : t('compressedToSizeOnly', producedSize));
1282
+ }
1283
+ } catch (err) {
1284
+ console.warn('[viewer] compression failed', err);
1285
+ if (compressionRequestRef.current !== token) return;
1286
+ stopCompressionCountdown();
1287
+ Alert.alert(title, t('compressionFailed'));
1288
+ }
1289
+ }, [compressedVariant, compressionTarget, locale, stopCompressionCountdown, t]);
1290
+
1291
+ // Gated on the same value that hides the button, so a document or an audio file can never open
1292
+ // the sheet even if the icon were somehow reachable.
1293
+ const openCompressionSheet = useCallback(() => {
1294
+ if (!compressionTarget) return;
1295
+ setCompressionSheetVisible(true);
1296
+ }, [compressionTarget]);
1297
+
1298
+ // Confirm deletion with a NATIVE dialog, then on confirm the file is SECURELY
1299
+ // erased via native Android APIs, the DB row is deleted (which rebuilds the
1300
+ // preview list + counter), and the pager moves to the next available item. When
1301
+ // the last item is removed the viewer exits gracefully back to the previous screen.
1302
+ const handleDeleteMedia = useCallback(() => {
1303
+ const item = filteredAssets[viewerIndex];
1304
+ if (!item) return;
1305
+ Alert.alert(t('deleteConfirmTitle'), t('deleteConfirmMessage'), [{
1306
+ text: t('cancel'),
1307
+ style: 'cancel'
1308
+ }, {
1309
+ text: t('delete'),
1310
+ style: 'destructive',
1311
+ onPress: () => performDelete()
1312
+ }]);
1313
+ }, [filteredAssets, viewerIndex]);
1314
+ const performDelete = useCallback(async () => {
1315
+ const list = filteredAssets;
1316
+ const idx = viewerIndex;
1317
+ const item = list[idx];
1318
+ if (!item) return;
1319
+ const uri = item.uri || item.documentUri || '';
1320
+ // `item` may be a compressed stand-in, whose `uri` points at the cached copy. The ORIGINAL is
1321
+ // what the store still holds, and each is a COMPLETE file — erasing only one would leave the
1322
+ // other recoverable, which defeats the whole point of a secure delete.
1323
+ const original = assetsById.get(item.id);
1324
+ const companionUris = Array.from(new Set([item.cachePath, item.documentUri, item.uri, item.originalUri, original?.cachePath, original?.documentUri, original?.uri].filter(u => !!u && u !== uri)));
1325
+
1326
+ // 1. SECURELY destroy the physical file: native measures its exact size,
1327
+ // overwrites it with encrypted cryptographically secure random data 2 MB
1328
+ // larger than that size (chunked + flushed, so a big video never lands in
1329
+ // RAM), unlinks it, and sweeps thumbnails, cached copies and derived
1330
+ // variants. `assetId` matters — compressed/exported/edited copies and video
1331
+ // cover frames are named after it, so omitting it would leave them behind.
1332
+ let secureErase = false;
1333
+ try {
1334
+ if (uri) {
1335
+ const report = await secureDeleteMedia(uri, {
1336
+ assetId: item.id,
1337
+ fileName: item.fileName,
1338
+ coverUri: item.coverUri,
1339
+ // A published item usually still has its cache file, and a compressed copy is a
1340
+ // second complete file again — each is erasable on its own, so all of them go.
1341
+ extraUris: companionUris
1342
+ });
1343
+ secureErase = report.fullySecure;
1344
+ if (!secureErase) {
1345
+ // A deleted-but-not-overwritten file must never be reported as a secure
1346
+ // deletion. The row is still removed below (the user asked for the item
1347
+ // to go), but the shortfall is surfaced rather than swallowed.
1348
+ console.warn('[viewer] secure erase incomplete', JSON.stringify(report));
1349
+ }
1350
+ }
1351
+ } catch (err) {
1352
+ console.warn('[viewer] native secure delete failed', err);
1353
+ }
1354
+
1355
+ // 2. Remove the persisted SQLCipher row — AFTER the file, so a failed erase can
1356
+ // never leave a row-less file stranded on disk with nothing pointing at it.
1357
+ // useMediaDatabase.refresh() rebuilds `assets` and the filter counts from
1358
+ // SQLCipher (so filteredAssets updates too).
1359
+ try {
1360
+ await removeById(item.id);
1361
+ } catch (err) {
1362
+ console.warn('[viewer] database delete failed', err);
1363
+ }
1364
+
1365
+ // Every compressed copy of this asset has just been erased along with it, so nothing may keep
1366
+ // pointing at one. `effectiveAssets` maps over the store, which no longer holds the row, but the
1367
+ // cached variants would otherwise be re-applied to a future asset that reused the id.
1368
+ setCompressedOverrides(prev => {
1369
+ if (!prev[item.id]) return prev;
1370
+ const next = {
1371
+ ...prev
1372
+ };
1373
+ delete next[item.id];
1374
+ return next;
1375
+ });
1376
+ setCompressionLevels(prev => {
1377
+ if (!prev[item.id]) return prev;
1378
+ const next = {
1379
+ ...prev
1380
+ };
1381
+ delete next[item.id];
1382
+ return next;
1383
+ });
1384
+ for (const key of Array.from(compressionResultsRef.current.keys())) {
1385
+ if (key.startsWith(`${item.id}|`)) compressionResultsRef.current.delete(key);
1386
+ }
1387
+ if (!secureErase) {
1388
+ Alert.alert(t('notSecurelyErasedTitle'), t('notSecurelyErasedMessage'));
1389
+ }
1390
+
1391
+ // 3. Move to the next available item — or exit when the last one was removed.
1392
+ const remaining = list.length - 1;
1393
+ if (remaining <= 0) {
1394
+ closeViewer();
1395
+ return;
1396
+ }
1397
+ // Deleting the current slide shifts everything after it left by one; clamping
1398
+ // means a deleted last item lands on the previous one. The existing
1399
+ // viewerIndex effect re-snaps the pager to the new offset.
1400
+ const nextIndex = Math.min(idx, remaining - 1);
1401
+ setViewerIndex(nextIndex);
1402
+ viewerCounterRef.current?.setIndex(nextIndex);
1403
+ }, [filteredAssets, viewerIndex, removeById, closeViewer, assetsById]);
1404
+
1405
+ // Live position while the finger (or the fling) is moving. This ONLY moves the counter —
1406
+ // `viewerIndex` still settles in onMomentumScrollEnd below, because it also gates which slide
1407
+ // is `active`, and flipping that mid-drag would stop and release the outgoing video while it
1408
+ // is still half on screen.
1409
+ const handleViewerScroll = useCallback(event => {
1410
+ if (viewerWidth <= 0) return;
1411
+ const offsetX = event.nativeEvent.contentOffset.x;
1412
+ // Clamp: iOS rubber-banding reports offsets past both ends of the list.
1413
+ const index = Math.max(0, Math.min(Math.round(offsetX / viewerWidth), Math.max(0, filteredAssets.length - 1)));
1414
+ viewerCounterRef.current?.setIndex(index);
1415
+ }, [viewerWidth, filteredAssets.length]);
1416
+ const handleViewerScrollEnd = useCallback(event => {
1417
+ const offsetX = event.nativeEvent.contentOffset.x;
1418
+ const index = Math.round(offsetX / viewerWidth);
1419
+ setViewerIndex(index);
1420
+ // Keep the newly visible video playing so its native progress and the scrub
1421
+ // bar advance together when the user swipes to a new slide.
1422
+ setViewerPaused(false);
1423
+ }, [viewerWidth]);
1424
+ const getViewerItemLayout = useCallback((_, index) => ({
1425
+ length: viewerWidth,
1426
+ offset: viewerWidth * index,
1427
+ index
1428
+ }), [viewerWidth]);
1429
+
1430
+ // When the window width changes while the viewer is open (rotation, split-
1431
+ // screen, fold/unfold, resize) the paged content offset becomes stale and the
1432
+ // list can settle between two slides. Re-snap to the current item's new offset
1433
+ // so the active media stays perfectly aligned to the resized viewport.
1434
+ useEffect(() => {
1435
+ if (!viewerVisible) return;
1436
+ viewerListRef.current?.scrollToOffset({
1437
+ offset: viewerWidth * viewerIndex,
1438
+ animated: false
1439
+ });
1440
+ }, [viewerWidth, viewerVisible, viewerIndex]);
1441
+ const renderViewerItem = useCallback(({
1442
+ item,
1443
+ index
1444
+ }) => {
1445
+ // Only the clip on the currently-visible page is allowed to play. An off-screen or
1446
+ // recycled cell is always paused so it can never play (or momentarily show) the wrong
1447
+ // clip while the user is on another page.
1448
+ const isActivePage = index === viewerIndex;
1449
+ // Every item — captured OR gallery-selected — fills the full-screen viewer
1450
+ // slide (viewerWidth wide, full height). The slide width tracks the live
1451
+ // window width, so every item is identical to the current viewport and the
1452
+ // container reflows correctly on rotation / resize without changing shape
1453
+ // between items.
1454
+ //
1455
+ // Route each media kind to its dedicated preview. Every kind carries the same metadata
1456
+ // header at the top, but only the frameless kinds — audio and documents — are drawn as a
1457
+ // card: images and videos are pixel content and fill the slide edge to edge, contain-fitted,
1458
+ // with no surface, border or rounded frame of their own behind them.
1459
+ let mediaContent;
1460
+ let shouldCenterContent = false;
1461
+ const previewMode = getAssetPreviewMode(item);
1462
+ // Keyed on the FILE, not just the asset id, so swapping in a compressed copy of the same
1463
+ // asset remounts the preview instead of handing a mounted one a new `asset` prop it may
1464
+ // already have snapshotted. Unchanged for every asset that has no compressed copy.
1465
+ const previewKey = `${item.id}|${item.documentUri || item.uri}`;
1466
+ if (previewMode === 'video') {
1467
+ mediaContent = /*#__PURE__*/_jsx(VideoPreview, {
1468
+ asset: item,
1469
+ paused: viewerPaused,
1470
+ onTogglePause: () => setViewerPaused(prev => !prev),
1471
+ onPlaybackEnded: () => setViewerPaused(true),
1472
+ active: isActivePage,
1473
+ controlsVisible: viewerControlsVisible,
1474
+ onVideoAreaPress: toggleViewerControls,
1475
+ onControlInteraction: showViewerControls
1476
+ }, previewKey);
1477
+ } else if (previewMode === 'document') {
1478
+ mediaContent = /*#__PURE__*/_jsx(DocumentPreview, {
1479
+ asset: item
1480
+ // Only the visible page runs expensive native preview work (PDF page
1481
+ // rasterization); mounted-but-off-screen neighbours stay cheap.
1482
+ ,
1483
+ active: isActivePage
1484
+ }, previewKey);
1485
+ shouldCenterContent = true;
1486
+ } else if (previewMode === 'audio') {
1487
+ mediaContent = /*#__PURE__*/_jsx(AudioPreview, {
1488
+ asset: item
1489
+ // Only the visible page may play; swiping away force-pauses it. The card
1490
+ // has its own play/pause + scrubber, so it never auto-plays on its own.
1491
+ ,
1492
+ active: isActivePage
1493
+ }, previewKey);
1494
+ shouldCenterContent = true;
1495
+ } else {
1496
+ // All image types (regular images, GIFs, HEIF/AVIF) use the unified ImagePreview
1497
+ mediaContent = /*#__PURE__*/_jsx(ImagePreview, {
1498
+ asset: item
1499
+ }, previewKey);
1500
+ }
1501
+
1502
+ // Images/videos fill the slide space; audio/documents are centered
1503
+ return /*#__PURE__*/_jsx(View, {
1504
+ style: [s.viewerSlide, {
1505
+ width: viewerWidth
1506
+ }],
1507
+ children: shouldCenterContent ? /*#__PURE__*/_jsx(View, {
1508
+ style: s.viewerContentCenter,
1509
+ children: mediaContent
1510
+ }) : mediaContent
1511
+ }, item.id);
1512
+ },
1513
+ // viewerControlsVisible is in deps so this memoized closure captures the CURRENT value.
1514
+ // Without it the closure keeps a stale `controlsVisible`, so a tap show/hide never reaches
1515
+ // the mounted VideoPreview cell. Pairs with the FlatList's
1516
+ // extraData={viewerControlsVisible}, which busts the cell memoization so the cell actually
1517
+ // re-renders when visibility flips.
1518
+ [viewerPaused, viewerIndex, viewerWidth, viewerControlsVisible, s]);
1519
+
1520
+ // ── Reusable Sub-Components ──────────────────────────────────────
1521
+
1522
+ const ChevronRight = useCallback(() => /*#__PURE__*/_jsx(View, {
1523
+ style: s.chevronContainer,
1524
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1525
+ name: "chevron_right",
1526
+ size: ms(20),
1527
+ color: palette.textFaded
1528
+ })
1529
+ }), [s, palette]);
1530
+
1531
+ // ── Empty State ──────────────────────────────────────────────────
1532
+ const EmptyState = useCallback(() => /*#__PURE__*/_jsxs(View, {
1533
+ style: s.emptyStateContainer,
1534
+ children: [/*#__PURE__*/_jsx(View, {
1535
+ style: s.emptyIconCircle,
1536
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1537
+ name: "document",
1538
+ size: ms(34),
1539
+ color: palette.textSoft
1540
+ })
1541
+ }), /*#__PURE__*/_jsx(Text, {
1542
+ style: s.emptyTitle,
1543
+ children: t('libraryEmptyTitle')
1544
+ }), /*#__PURE__*/_jsx(Text, {
1545
+ style: s.emptySubtitle,
1546
+ children: t('libraryEmptyMessage')
1547
+ })]
1548
+ }),
1549
+ // `t` is stable until the host changes locale/bundles, so this re-derives
1550
+ // exactly when the empty-state copy must change language.
1551
+ [t, s, palette]);
1552
+
1553
+ // ── Asset Row Renderer ──────────────────────────────────────────
1554
+ // Curried per pager page: rows must open the viewer against their OWN page's
1555
+ // dataset (sourceFilter), and the row index is the index within that page.
1556
+ const renderAssetItemForFilter = useCallback(sourceFilter => ({
1557
+ item,
1558
+ index
1559
+ }) => {
1560
+ const title = item.fileName || t('untitled');
1561
+ const dateTimeStr = formatDate(item.dateTaken);
1562
+ const durationStr = item.type === 'video' ? formatDuration(item.duration) : null;
1563
+ const subtitle = durationStr ? `${dateTimeStr} · ${durationStr}` : dateTimeStr;
1564
+ return /*#__PURE__*/_jsxs(TouchableOpacity, {
1565
+ style: s.assetRow,
1566
+ onPress: () => openViewer(index, sourceFilter),
1567
+ activeOpacity: 0.8,
1568
+ children: [/*#__PURE__*/_jsx(View, {
1569
+ style: s.thumbnailWrapper,
1570
+ children: getAssetPreviewMode(item) === 'document' ?
1571
+ /*#__PURE__*/
1572
+ // Colour-coded mini page + solid file-type badge (PDF red, Word
1573
+ // blue, sheet green, …) — identifiable at a glance, with a
1574
+ // neutral FILE fallback when the extension is unknown.
1575
+ _jsx(DocumentTile, {
1576
+ asset: item
1577
+ }) : item.type === 'audio' ?
1578
+ /*#__PURE__*/
1579
+ // Deterministic per-file waveform + format badge; falls back to
1580
+ // a generic AUD badge when no extension/MIME is available.
1581
+ _jsx(AudioTile, {
1582
+ asset: item
1583
+ }) : item.uri || item.documentUri ?
1584
+ /*#__PURE__*/
1585
+ // The media's OWN frame — never a stand-in glyph. Images decode from the
1586
+ // image file; videos show their user-picked cover, or a frame extracted from
1587
+ // the clip. The decode runs off the main thread at the tile's measured size —
1588
+ // a 12MP still decoded whole is ~48MB of bitmap for a ~56px row — and is served
1589
+ // from a shared LRU cache on the way back up the list. Where the native decoder
1590
+ // is not in the build, MediaThumbnail falls back to decoding the still in JS,
1591
+ // so an image row still shows its own picture.
1592
+ //
1593
+ // Guarded on the same pair MediaThumbnail resolves its source from: a published
1594
+ // asset carries the path on documentUri, and testing uri alone dropped those
1595
+ // rows onto the placeholder while a perfectly readable file sat behind them.
1596
+ _jsx(MediaThumbnail, {
1597
+ asset: item,
1598
+ style: s.thumbnail,
1599
+ resizeMode: "cover"
1600
+ }) : /*#__PURE__*/_jsx(View, {
1601
+ style: s.thumbnailPlaceholder,
1602
+ children: /*#__PURE__*/_jsx(Text, {
1603
+ style: s.thumbnailPlaceholderText,
1604
+ children: t('preview')
1605
+ })
1606
+ })
1607
+ }), /*#__PURE__*/_jsxs(View, {
1608
+ style: s.assetTextGroup,
1609
+ children: [/*#__PURE__*/_jsx(Text, {
1610
+ style: s.assetTitle,
1611
+ numberOfLines: 1,
1612
+ ellipsizeMode: "tail",
1613
+ children: title
1614
+ }), /*#__PURE__*/_jsx(Text, {
1615
+ style: s.assetSubtitle,
1616
+ numberOfLines: 1,
1617
+ children: subtitle
1618
+ })]
1619
+ }), /*#__PURE__*/_jsx(View, {
1620
+ style: s.assetActions,
1621
+ children: /*#__PURE__*/_jsx(ChevronRight, {})
1622
+ })]
1623
+ });
1624
+ }, [openViewer, t, locale, s, ChevronRight]);
1625
+
1626
+ // ── Separator ───────────────────────────────────────────────────
1627
+ const ItemSeparator = useCallback(() => /*#__PURE__*/_jsx(View, {
1628
+ style: s.separator
1629
+ }), [s]);
1630
+
1631
+ // ── Category Page Renderer ──────────────────────────────────────
1632
+ // One page per category, each with its own vertical list (so every category
1633
+ // keeps its own scroll position while swiping between them). Pages are
1634
+ // exactly categoryPageWidth wide — the pager's visible width — so paging
1635
+ // snaps whole pages and never shows two categories at once.
1636
+ const renderCategoryPage = useCallback(({
1637
+ item: filterKey
1638
+ }) => {
1639
+ const pageAssets = assetsForFilter(filterKey);
1640
+ return /*#__PURE__*/_jsx(View, {
1641
+ style: {
1642
+ width: categoryPageWidth
1643
+ },
1644
+ children: /*#__PURE__*/_jsx(FlatList, {
1645
+ data: pageAssets,
1646
+ keyExtractor: item => item.id,
1647
+ renderItem: renderAssetItemForFilter(filterKey),
1648
+ ItemSeparatorComponent: ItemSeparator
1649
+ // Fill the page (the pager stretches pages to its height) so the
1650
+ // vertical list scrolls inside the page instead of overflowing it.
1651
+ ,
1652
+ style: s.categoryPageList,
1653
+ contentContainerStyle: [s.assetList, pageAssets.length === 0 && s.assetListEmpty],
1654
+ showsVerticalScrollIndicator: false
1655
+ // An ELEMENT, not a factory: `ComponentType | null` is not a valid
1656
+ // ListEmptyComponent, and leaving it undefined while the first DB
1657
+ // read is pending keeps the "no media yet" card out of the tree.
1658
+ ,
1659
+ ListEmptyComponent: loading ? undefined : /*#__PURE__*/_jsx(EmptyState, {}),
1660
+ nestedScrollEnabled: true
1661
+ // ── Low-RAM virtualization ──
1662
+ // RN's defaults keep ~21 viewports of rows mounted (windowSize 21)
1663
+ // and mount 10 rows per batch — and this list exists ×5 (one per
1664
+ // category page, all kept alive for scroll-position retention).
1665
+ // With image thumbnails per row that's hundreds of live bitmap
1666
+ // views on a big library. 5 viewports is invisible while flinging
1667
+ // yet caps memory at ~a quarter of the default.
1668
+ ,
1669
+ windowSize: 5,
1670
+ initialNumToRender: 8,
1671
+ maxToRenderPerBatch: 6,
1672
+ updateCellsBatchingPeriod: 50
1673
+ // Android detaches off-window row views (bitmaps become
1674
+ // collectable). Left OFF on iOS where it has known blank-cell
1675
+ // bugs and much less benefit.
1676
+ ,
1677
+ removeClippedSubviews: Platform.OS === 'android'
1678
+ })
1679
+ });
1680
+ }, [assetsForFilter, categoryPageWidth, renderAssetItemForFilter, ItemSeparator, EmptyState, loading, s]);
1681
+ return (
1682
+ /*#__PURE__*/
1683
+ // A plain View, not a safe-area view: `edges={[]}` already disabled inset
1684
+ // handling on Android (this plugin's only platform), so the safe-area
1685
+ // dependency bought nothing. iOS gets the same top inset via
1686
+ // STATUS_BAR_INSET, keeping the shell dependency-free.
1687
+ _jsx(View, {
1688
+ style: [s.container, style],
1689
+ children: /*#__PURE__*/_jsxs(GlobalAudioPlayerProvider, {
1690
+ children: [/*#__PURE__*/_jsx(StatusBar, {
1691
+ barStyle: palette.isDark ? 'light-content' : 'dark-content',
1692
+ backgroundColor: palette.appBackground,
1693
+ translucent: false
1694
+ }), /*#__PURE__*/_jsxs(View, {
1695
+ style: s.screen,
1696
+ children: [/*#__PURE__*/_jsx(GlobalMiniAudioPlayer, {
1697
+ onExpandPlayer: handleExpandAudioPlayer
1698
+ }), /*#__PURE__*/_jsx(View, {
1699
+ style: s.filtersRow,
1700
+ children: [{
1701
+ key: 'all',
1702
+ label: t('all'),
1703
+ count: counts.all
1704
+ }, {
1705
+ key: 'photos',
1706
+ label: t('photos'),
1707
+ count: photoCount
1708
+ }, {
1709
+ key: 'videos',
1710
+ label: t('videos'),
1711
+ count: videoCount
1712
+ }, {
1713
+ key: 'documents',
1714
+ label: t('documents'),
1715
+ count: documentsCount
1716
+ }, {
1717
+ key: 'audio',
1718
+ label: t('audio'),
1719
+ count: audioCount
1720
+ }].map(tab => {
1721
+ const isActive = activeFilter === tab.key;
1722
+ return /*#__PURE__*/_jsxs(TouchableOpacity, {
1723
+ style: [s.filterPill, isActive && s.filterPillActive],
1724
+ onPress: () => selectFilter(tab.key),
1725
+ activeOpacity: 0.8,
1726
+ children: [/*#__PURE__*/_jsx(Text, {
1727
+ style: [s.filterCount, isActive && s.filterCountActive],
1728
+ children: tab.count
1729
+ }), /*#__PURE__*/_jsx(Text, {
1730
+ style: [s.filterLabel, isActive && s.filterLabelActive],
1731
+ numberOfLines: 1,
1732
+ adjustsFontSizeToFit: true,
1733
+ minimumFontScale: 0.75,
1734
+ children: tab.label
1735
+ })]
1736
+ }, tab.key);
1737
+ })
1738
+ }), /*#__PURE__*/_jsx(View, {
1739
+ style: s.listSection,
1740
+ children: /*#__PURE__*/_jsx(FlatList, {
1741
+ ref: categoryPagerRef,
1742
+ data: FILTER_PAGES,
1743
+ keyExtractor: key => key,
1744
+ horizontal: true,
1745
+ pagingEnabled: true,
1746
+ showsHorizontalScrollIndicator: false,
1747
+ initialScrollIndex: categoryIndex,
1748
+ getItemLayout: getCategoryPageLayout
1749
+ // Live highlight while the finger moves (see handleCategoryScroll);
1750
+ // 16ms throttle ≈ one event per frame on iOS (Android already emits
1751
+ // every frame). Scroll-end stays as the settle/no-op safety net.
1752
+ ,
1753
+ onScroll: handleCategoryScroll,
1754
+ scrollEventThrottle: 16,
1755
+ onScrollBeginDrag: handleCategoryDragBegin,
1756
+ onMomentumScrollEnd: handleCategoryScrollEnd
1757
+ // All five pages stay mounted: category datasets are light (rows,
1758
+ // not decoded media), and this keeps each page's scroll position
1759
+ // alive while swiping back and forth.
1760
+ ,
1761
+ windowSize: FILTER_PAGES.length,
1762
+ initialNumToRender: FILTER_PAGES.length,
1763
+ removeClippedSubviews: false,
1764
+ decelerationRate: "fast",
1765
+ snapToInterval: categoryPageWidth,
1766
+ snapToAlignment: "start",
1767
+ disableIntervalMomentum: true,
1768
+ nestedScrollEnabled: true,
1769
+ renderItem: renderCategoryPage
1770
+ })
1771
+ })]
1772
+ }), /*#__PURE__*/_jsx(Modal, {
1773
+ visible: viewerVisible,
1774
+ animationType: "slide",
1775
+ onRequestClose: closeViewer,
1776
+ transparent: false,
1777
+ statusBarTranslucent: true,
1778
+ navigationBarTranslucent: true,
1779
+ children: /*#__PURE__*/_jsxs(View, {
1780
+ style: s.viewerContainer,
1781
+ children: [/*#__PURE__*/_jsx(StatusBar, {
1782
+ barStyle: "light-content",
1783
+ translucent: true,
1784
+ backgroundColor: "transparent"
1785
+ }), /*#__PURE__*/_jsx(View, {
1786
+ style: s.viewerContent,
1787
+ children: /*#__PURE__*/_jsx(FlatList, {
1788
+ ref: viewerListRef,
1789
+ data: filteredAssets
1790
+ // Control visibility lives outside `data`, so this PureComponent list needs
1791
+ // extraData to know a visibility flip must re-render the mounted cells — without
1792
+ // it a tap show/hide can't reach VideoPreview. Works with renderViewerItem's deps.
1793
+ ,
1794
+ extraData: viewerControlsVisible,
1795
+ horizontal: true,
1796
+ pagingEnabled: true,
1797
+ showsHorizontalScrollIndicator: false,
1798
+ initialScrollIndex: viewerIndex,
1799
+ getItemLayout: getViewerItemLayout,
1800
+ keyExtractor: item => item.id,
1801
+ renderItem: renderViewerItem,
1802
+ onScroll: handleViewerScroll
1803
+ // 16ms ≈ one counter update per frame at 60Hz. The handler itself only touches a
1804
+ // ref, and ViewerCounter drops same-value updates, so this costs a setState per
1805
+ // slide crossed, not per frame.
1806
+ ,
1807
+ scrollEventThrottle: 16,
1808
+ onMomentumScrollEnd: handleViewerScrollEnd,
1809
+ removeClippedSubviews: false
1810
+ // Slides are full-screen media — the most expensive views in the
1811
+ // app. windowSize 3 = current + one neighbour each side (enough
1812
+ // for gap-free paging); batches of 2 keep a fast fling from
1813
+ // stacking up decodes of 8 screen-sized bitmaps at once.
1814
+ ,
1815
+ maxToRenderPerBatch: 2,
1816
+ windowSize: 3,
1817
+ initialNumToRender: 1,
1818
+ onScrollToIndexFailed: info => {
1819
+ // scrollToIndex throws synchronously if the target is out of range (e.g.
1820
+ // the list shrank, or the cell is still unmeasured). Running it inside a
1821
+ // bare .then() with no .catch turned that into an unhandled promise
1822
+ // rejection that can crash a release build. Clamp the index and guard the
1823
+ // retry so a failed re-scroll is a no-op instead.
1824
+ const wait = new Promise(resolve => setTimeout(resolve, 500));
1825
+ wait.then(() => {
1826
+ const count = filteredAssets.length;
1827
+ if (count === 0) return;
1828
+ const target = Math.max(0, Math.min(info.index, count - 1));
1829
+ try {
1830
+ viewerListRef.current?.scrollToIndex({
1831
+ index: target,
1832
+ animated: true
1833
+ });
1834
+ } catch {
1835
+ // Cell still not measurable — leave the list where it is.
1836
+ }
1837
+ }).catch(() => {});
1838
+ },
1839
+ decelerationRate: "fast",
1840
+ snapToInterval: viewerWidth,
1841
+ snapToAlignment: "center",
1842
+ disableIntervalMomentum: true
1843
+ })
1844
+ }), /*#__PURE__*/_jsxs(View, {
1845
+ style: s.viewerHeader,
1846
+ pointerEvents: "box-none",
1847
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
1848
+ onPress: closeViewer,
1849
+ style: s.viewerCloseButton,
1850
+ activeOpacity: 0.8,
1851
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1852
+ name: "close",
1853
+ size: 24,
1854
+ color: palette.white
1855
+ })
1856
+ }), /*#__PURE__*/_jsx(View, {
1857
+ style: s.viewerRightControls,
1858
+ onStartShouldSetResponder: () => true,
1859
+ children: /*#__PURE__*/_jsx(ViewerCounter, {
1860
+ ref: viewerCounterRef,
1861
+ index: viewerIndex,
1862
+ total: filteredAssets.length,
1863
+ style: s.viewerCounter
1864
+ })
1865
+ })]
1866
+ }), /*#__PURE__*/_jsxs(View, {
1867
+ style: s.viewerActions,
1868
+ pointerEvents: "box-none",
1869
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
1870
+ onPress: handleDeleteMedia,
1871
+ style: s.viewerActionButton,
1872
+ activeOpacity: 0.7,
1873
+ accessibilityRole: "button",
1874
+ accessibilityLabel: t('deleteMedia'),
1875
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1876
+ name: "delete",
1877
+ size: 24,
1878
+ color: palette.white
1879
+ })
1880
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
1881
+ onPress: handleShareMedia,
1882
+ style: s.viewerActionButton,
1883
+ activeOpacity: 0.7,
1884
+ accessibilityRole: "button",
1885
+ accessibilityLabel: t('shareMedia'),
1886
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1887
+ name: "share",
1888
+ size: 24,
1889
+ color: palette.white
1890
+ })
1891
+ }), compressionTarget ? /*#__PURE__*/_jsx(TouchableOpacity, {
1892
+ onPress: openCompressionSheet,
1893
+ style: s.viewerActionButton,
1894
+ activeOpacity: 0.7,
1895
+ accessibilityRole: "button",
1896
+ accessibilityLabel: t(compressionTarget.kind === 'video' ? 'compressVideo' : 'compressImage'),
1897
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1898
+ name: "compress",
1899
+ size: 24,
1900
+ color: palette.white
1901
+ })
1902
+ }) : null, assetSupportsMetadata(filteredAssets[viewerIndex]) ? /*#__PURE__*/_jsx(TouchableOpacity, {
1903
+ onPress: openMetadata,
1904
+ style: s.viewerActionButton,
1905
+ activeOpacity: 0.7,
1906
+ accessibilityRole: "button",
1907
+ accessibilityLabel: t('viewDetails'),
1908
+ children: /*#__PURE__*/_jsx(RenderIcon, {
1909
+ name: "view_details",
1910
+ size: 24,
1911
+ color: palette.white
1912
+ })
1913
+ }) : null]
1914
+ }), compressionLabel !== null ? /*#__PURE__*/_jsx(View, {
1915
+ style: s.compressionOverlay,
1916
+ accessibilityRole: "progressbar",
1917
+ accessibilityLabel: compressionLabel,
1918
+ children: /*#__PURE__*/_jsx(View, {
1919
+ style: s.compressionCountdownPill,
1920
+ children: /*#__PURE__*/_jsx(Text, {
1921
+ style: s.compressionCountdownText,
1922
+ children: compressionLabel
1923
+ })
1924
+ })
1925
+ }) : null]
1926
+ })
1927
+ }), compressionTarget ? /*#__PURE__*/_jsx(CompressionSheet, {
1928
+ asset: compressionTarget.asset,
1929
+ kind: compressionTarget.kind,
1930
+ activeOption: compressionLevels[compressionTarget.asset.id] ?? originalCompressionOption(compressionTarget.kind),
1931
+ visible: compressionSheetVisible,
1932
+ onClose: () => setCompressionSheetVisible(false),
1933
+ onSelect: applyCompressionOption
1934
+ }) : null, /*#__PURE__*/_jsx(MetadataSheet, {
1935
+ asset: filteredAssets[viewerIndex],
1936
+ visible: metadataVisible,
1937
+ onClose: () => setMetadataVisible(false)
1938
+ }), !viewerVisible && /*#__PURE__*/_jsx(ExpandableFab, {
1939
+ busy: isCapturing,
1940
+ disabled: isCapturing,
1941
+ onCamera: handleOpenCamera,
1942
+ onImageVideo: handleOpenImageVideo,
1943
+ onDocument: handleOpenDocument,
1944
+ onAudio: handleOpenAudio,
1945
+ onRecordAudio: handleOpenRecordAudio
1946
+ }), /*#__PURE__*/_jsx(RecordAudioScreen, {
1947
+ visible: recordAudioVisible,
1948
+ onClose: () => setRecordAudioVisible(false),
1949
+ onRecorded: handleRecordedAudio
1950
+ })]
1951
+ })
1952
+ })
1953
+ );
1954
+ }
1955
+
1956
+ // ── Derived responsive values ────────────────────────────────────
1957
+ const THUMB_SIZE = ms(56);
1958
+ const THUMB_RADIUS = ms(16);
1959
+ const CARD_PAD_H = wp(14);
1960
+ // CARD_RADIUS and CARD_PAD_V kept for future card-styled asset rows
1961
+ const PILL_RADIUS = ms(14);
1962
+ const BOTTOM_BAR_H = hp(85);
1963
+ const NAV_ICON_SIZE = ms(28);
1964
+
1965
+ // Use imported constants for consistent WhatsApp-like preview dimensions
1966
+ // PREVIEW_WIDTH, PREVIEW_HEIGHT, and WHATSAPP_ASPECT_RATIO are imported from ResponsivePreviewContainer
1967
+
1968
+ const makeS = palette => StyleSheet.create({
1969
+ // ── Root ────────────────────────────────────────────────────────
1970
+ container: {
1971
+ flex: 1,
1972
+ backgroundColor: palette.appBackground,
1973
+ // Android needs nothing here: `translucent={false}` already starts content
1974
+ // below the status bar, and `screen` adds StatusBar.currentHeight on top of
1975
+ // that. The constant only does work on iOS, which this plugin doesn't
1976
+ // target — it stands in for the old safe-area top edge.
1977
+ paddingTop: Platform.OS === 'android' ? 0 : STATUS_BAR_INSET
1978
+ },
1979
+ // ── Screen ─────────────────────────────────────────────────────
1980
+ screen: {
1981
+ flex: 1,
1982
+ paddingTop: Platform.OS === 'android' ? hp(12) + (StatusBar.currentHeight ?? 0) : hp(12),
1983
+ paddingHorizontal: wp(16)
1984
+ },
1985
+ // ── Filter Pills ──────────────────────────────────────────────
1986
+ filtersRow: {
1987
+ flexDirection: 'row',
1988
+ // Even gaps between five equal-width pills; each pill is flex:1 so the row
1989
+ // stays a single line and scales with the screen width / density.
1990
+ gap: wp(6),
1991
+ marginBottom: hp(16)
1992
+ },
1993
+ filterPill: {
1994
+ flex: 1,
1995
+ paddingVertical: hp(10),
1996
+ paddingHorizontal: wp(4),
1997
+ borderRadius: PILL_RADIUS,
1998
+ backgroundColor: palette.surface,
1999
+ borderWidth: 1,
2000
+ borderColor: palette.hairlineFaint,
2001
+ alignItems: 'center',
2002
+ justifyContent: 'center'
2003
+ },
2004
+ filterPillActive: {
2005
+ backgroundColor: palette.primary,
2006
+ borderColor: palette.accentIndigo,
2007
+ shadowColor: palette.primary,
2008
+ shadowOffset: {
2009
+ width: 0,
2010
+ height: hp(6)
2011
+ },
2012
+ shadowOpacity: 0.4,
2013
+ shadowRadius: ms(14),
2014
+ elevation: 8
2015
+ },
2016
+ filterCount: {
2017
+ color: palette.textDim,
2018
+ fontSize: fs(13),
2019
+ fontWeight: '700'
2020
+ },
2021
+ filterCountActive: {
2022
+ color: palette.white
2023
+ },
2024
+ filterLabel: {
2025
+ color: palette.textDim,
2026
+ fontSize: fs(10),
2027
+ fontWeight: '500',
2028
+ marginTop: hp(2),
2029
+ letterSpacing: 0.2
2030
+ },
2031
+ filterLabelActive: {
2032
+ color: palette.textSoft
2033
+ },
2034
+ // ── List ───────────────────────────────────────────────────────
2035
+ listSection: {
2036
+ flex: 1
2037
+ },
2038
+ categoryPageList: {
2039
+ flex: 1
2040
+ },
2041
+ assetList: {
2042
+ paddingBottom: hp(190)
2043
+ },
2044
+ assetListEmpty: {
2045
+ flexGrow: 1,
2046
+ justifyContent: 'center'
2047
+ },
2048
+ separator: {
2049
+ height: 1,
2050
+ backgroundColor: palette.surfaceIndigo,
2051
+ marginHorizontal: CARD_PAD_H
2052
+ },
2053
+ // ── Asset Row Card ─────────────────────────────────────────────
2054
+ assetRow: {
2055
+ flexDirection: 'row',
2056
+ alignItems: 'center',
2057
+ paddingVertical: hp(14),
2058
+ paddingHorizontal: CARD_PAD_H
2059
+ },
2060
+ thumbnailWrapper: {
2061
+ width: THUMB_SIZE,
2062
+ height: THUMB_SIZE,
2063
+ borderRadius: THUMB_RADIUS,
2064
+ overflow: 'hidden',
2065
+ backgroundColor: 'transparent',
2066
+ justifyContent: 'center',
2067
+ alignItems: 'center',
2068
+ marginRight: wp(12)
2069
+ },
2070
+ thumbnail: {
2071
+ width: '100%',
2072
+ height: '100%'
2073
+ },
2074
+ thumbnailPlaceholder: {
2075
+ flex: 1,
2076
+ justifyContent: 'center',
2077
+ alignItems: 'center'
2078
+ },
2079
+ thumbnailPlaceholderText: {
2080
+ color: palette.textCharcoal,
2081
+ fontSize: fs(9),
2082
+ textAlign: 'center',
2083
+ fontWeight: '500'
2084
+ },
2085
+ assetTextGroup: {
2086
+ flex: 1,
2087
+ marginRight: wp(8)
2088
+ },
2089
+ assetTitle: {
2090
+ color: palette.textBright,
2091
+ fontSize: fs(14),
2092
+ fontWeight: '700',
2093
+ marginBottom: hp(4)
2094
+ },
2095
+ subtitleRow: {
2096
+ flexDirection: 'row',
2097
+ alignItems: 'center'
2098
+ },
2099
+ dateIconText: {
2100
+ fontSize: fs(10)
2101
+ },
2102
+ assetSubtitle: {
2103
+ color: palette.textGray,
2104
+ fontSize: fs(12),
2105
+ fontWeight: '400',
2106
+ flexShrink: 1
2107
+ },
2108
+ assetActions: {
2109
+ flexDirection: 'row',
2110
+ alignItems: 'center',
2111
+ gap: 8
2112
+ },
2113
+ editButton: {
2114
+ width: 32,
2115
+ height: 32,
2116
+ borderRadius: 16,
2117
+ backgroundColor: palette.greenTintA10,
2118
+ justifyContent: 'center',
2119
+ alignItems: 'center'
2120
+ },
2121
+ viewerContainer: {
2122
+ flex: 1,
2123
+ backgroundColor: palette.viewerBackdrop
2124
+ },
2125
+ viewerHeader: {
2126
+ position: 'absolute',
2127
+ top: 0,
2128
+ left: 0,
2129
+ right: 0,
2130
+ zIndex: 10,
2131
+ flexDirection: 'row',
2132
+ alignItems: 'center',
2133
+ justifyContent: 'space-between',
2134
+ paddingHorizontal: ms(16),
2135
+ paddingTop: STATUS_BAR_INSET + ms(12),
2136
+ paddingBottom: ms(12)
2137
+ },
2138
+ viewerCloseButton: {
2139
+ padding: ms(8)
2140
+ },
2141
+ viewerEditButton: {
2142
+ padding: ms(8)
2143
+ },
2144
+ viewerRightControls: {
2145
+ flexDirection: 'row',
2146
+ alignItems: 'center',
2147
+ gap: ms(12)
2148
+ },
2149
+ compressButtonContainer: {
2150
+ position: 'relative'
2151
+ },
2152
+ compressButton: {
2153
+ flexDirection: 'row',
2154
+ alignItems: 'center',
2155
+ gap: ms(4),
2156
+ paddingVertical: ms(6),
2157
+ paddingHorizontal: ms(10),
2158
+ borderRadius: ms(16),
2159
+ backgroundColor: palette.whiteA12
2160
+ },
2161
+ compressionMenu: {
2162
+ position: 'absolute',
2163
+ top: ms(44),
2164
+ right: ms(0),
2165
+ width: ms(160),
2166
+ maxHeight: ms(280),
2167
+ backgroundColor: palette.chromeDark,
2168
+ borderRadius: ms(12),
2169
+ borderWidth: 1,
2170
+ borderColor: palette.whiteA15,
2171
+ overflow: 'hidden',
2172
+ zIndex: 1000,
2173
+ shadowColor: palette.black,
2174
+ shadowOffset: {
2175
+ width: 0,
2176
+ height: ms(4)
2177
+ },
2178
+ shadowOpacity: 0.4,
2179
+ shadowRadius: ms(12),
2180
+ elevation: 12
2181
+ },
2182
+ compressionMenuScroll: {
2183
+ maxHeight: ms(280),
2184
+ flexGrow: 0
2185
+ },
2186
+ compressionMenuBackdrop: {
2187
+ position: 'absolute',
2188
+ top: 0,
2189
+ left: 0,
2190
+ right: 0,
2191
+ bottom: 0,
2192
+ zIndex: 999
2193
+ },
2194
+ compressionMenuItem: {
2195
+ flexDirection: 'row',
2196
+ alignItems: 'center',
2197
+ justifyContent: 'space-between',
2198
+ paddingVertical: ms(10),
2199
+ paddingHorizontal: ms(12),
2200
+ borderBottomWidth: StyleSheet.hairlineWidth,
2201
+ borderBottomColor: palette.whiteA12
2202
+ },
2203
+ compressionMenuItemActive: {
2204
+ backgroundColor: palette.greenTintA10
2205
+ },
2206
+ compressionMenuText: {
2207
+ color: palette.chromeText,
2208
+ fontSize: fs(13),
2209
+ fontWeight: '400'
2210
+ },
2211
+ compressionMenuTextActive: {
2212
+ color: palette.accentGreen,
2213
+ fontWeight: '600'
2214
+ },
2215
+ viewerCounter: {
2216
+ color: palette.chromeText,
2217
+ fontSize: fs(14),
2218
+ fontWeight: '500'
2219
+ },
2220
+ viewerActions: {
2221
+ // A FIXED, reserved-height bar in normal flow (not an overlay). It sits AFTER
2222
+ // the flex-1 viewerContent, so the preview area only ever spans the space above
2223
+ // these buttons — no image, video, document, or audio surface can extend
2224
+ // underneath Delete/Share, for every media source and screen size/orientation.
2225
+ zIndex: 10,
2226
+ flexDirection: 'row',
2227
+ alignItems: 'center',
2228
+ justifyContent: 'center',
2229
+ // Centered, evenly spaced row that never touches the media (the media lives in
2230
+ // viewerContent above) or the top controls. The gap keeps the actions visually
2231
+ // balanced and thumb-reachable, and is sized so the widest row — Delete, Share,
2232
+ // Compress and Info together — still fits a narrow screen without wrapping.
2233
+ gap: ms(28),
2234
+ paddingTop: ms(10),
2235
+ // Keeps the buttons clear of the gesture/home navigation bar while reserving
2236
+ // fixed vertical space the media area shrinks to accommodate.
2237
+ paddingBottom: STATUS_BAR_INSET + ms(24),
2238
+ backgroundColor: palette.viewerBackdrop
2239
+ },
2240
+ viewerActionButton: {
2241
+ width: ms(48),
2242
+ height: ms(48),
2243
+ borderRadius: ms(24),
2244
+ backgroundColor: palette.scrimA55Tight,
2245
+ borderWidth: 1,
2246
+ borderColor: palette.whiteA15,
2247
+ justifyContent: 'center',
2248
+ alignItems: 'center',
2249
+ // Soft lift so the controls read as a distinct, tappable row over the media.
2250
+ shadowColor: palette.black,
2251
+ shadowOffset: {
2252
+ width: 0,
2253
+ height: ms(3)
2254
+ },
2255
+ shadowOpacity: 0.35,
2256
+ shadowRadius: ms(8),
2257
+ elevation: 6
2258
+ },
2259
+ // ── Compression progress ───────────────────────────────────────
2260
+ // A full-bleed layer over the whole viewer while a compression runs: it dims the media and,
2261
+ // because it is NOT box-none, swallows every touch — so the pager cannot be swiped to another
2262
+ // slide, and Delete/Share/Compress cannot be tapped, while an encode is in flight.
2263
+ compressionOverlay: {
2264
+ ...StyleSheet.absoluteFill,
2265
+ zIndex: 30,
2266
+ alignItems: 'center',
2267
+ justifyContent: 'center',
2268
+ backgroundColor: palette.scrimA70
2269
+ },
2270
+ // One centred line of text, never a spinner: the line itself is the progress report, counting
2271
+ // MEASURED remaining seconds down and handing over to "Finalizing..." at the tail.
2272
+ compressionCountdownPill: {
2273
+ maxWidth: '82%',
2274
+ paddingVertical: ms(14),
2275
+ paddingHorizontal: ms(22),
2276
+ borderRadius: ms(14),
2277
+ borderWidth: 1,
2278
+ borderColor: palette.whiteA15,
2279
+ backgroundColor: palette.sheet95
2280
+ },
2281
+ compressionCountdownText: {
2282
+ color: palette.chromeText,
2283
+ fontSize: fs(15),
2284
+ fontWeight: '600',
2285
+ textAlign: 'center'
2286
+ },
2287
+ viewerSlide: {
2288
+ // Width is supplied inline from the live window width (useWindowDimensions)
2289
+ // so each slide tracks runtime size changes; see renderViewerItem.
2290
+ // Height is bounded by viewerContent, which now spans only the space above the
2291
+ // reserved bottom action bar — so media is contain-fitted into the available
2292
+ // preview area and can never reach underneath the Delete/Share buttons.
2293
+ flex: 1,
2294
+ alignItems: 'center',
2295
+ justifyContent: 'center',
2296
+ overflow: 'hidden',
2297
+ paddingHorizontal: wp(16),
2298
+ // Top padding keeps media clear of the overlaid top header (× + counter); only a
2299
+ // small bottom margin is needed because the action bar reserves its own space.
2300
+ paddingTop: STATUS_BAR_INSET + hp(52),
2301
+ paddingBottom: hp(24)
2302
+ },
2303
+ viewerContentCenter: {
2304
+ flex: 1,
2305
+ justifyContent: 'center',
2306
+ alignItems: 'center',
2307
+ width: '100%'
2308
+ },
2309
+ viewerContent: {
2310
+ flex: 1,
2311
+ overflow: 'hidden'
2312
+ },
2313
+ // ── Video Fallback ─────────────────────────────────────────────
2314
+ videoFallbackOverlay: {
2315
+ position: 'absolute',
2316
+ top: 0,
2317
+ left: 0,
2318
+ right: 0,
2319
+ bottom: 0,
2320
+ justifyContent: 'center',
2321
+ alignItems: 'center',
2322
+ backgroundColor: palette.scrimA55Tight
2323
+ },
2324
+ videoFallbackText: {
2325
+ color: palette.white,
2326
+ fontSize: fs(15),
2327
+ fontWeight: '600',
2328
+ marginTop: hp(10)
2329
+ },
2330
+ videoFallbackHint: {
2331
+ color: palette.chromeTextMuted,
2332
+ fontSize: fs(12),
2333
+ fontWeight: '400',
2334
+ marginTop: hp(4)
2335
+ },
2336
+ // ── Chevron ────────────────────────────────────────────────────
2337
+ chevronContainer: {
2338
+ width: ms(32),
2339
+ height: ms(32),
2340
+ justifyContent: 'center',
2341
+ alignItems: 'center'
2342
+ },
2343
+ chevronArrow: {
2344
+ fontSize: fs(32),
2345
+ color: palette.textFaded,
2346
+ fontWeight: '300',
2347
+ includeFontPadding: false
2348
+ },
2349
+ // ── Empty State ────────────────────────────────────────────────
2350
+ emptyStateContainer: {
2351
+ alignItems: 'center',
2352
+ justifyContent: 'center',
2353
+ paddingHorizontal: wp(40),
2354
+ paddingBottom: hp(60)
2355
+ },
2356
+ emptyIconCircle: {
2357
+ width: ms(80),
2358
+ height: ms(80),
2359
+ borderRadius: ms(40),
2360
+ backgroundColor: palette.surface,
2361
+ borderWidth: 1,
2362
+ borderColor: palette.hairlineFainter,
2363
+ justifyContent: 'center',
2364
+ alignItems: 'center',
2365
+ marginBottom: hp(20)
2366
+ },
2367
+ emptyIconText: {
2368
+ fontSize: fs(32)
2369
+ },
2370
+ emptyTitle: {
2371
+ color: palette.textSoft,
2372
+ fontSize: fs(17),
2373
+ fontWeight: '700',
2374
+ marginBottom: hp(8),
2375
+ textAlign: 'center'
2376
+ },
2377
+ emptySubtitle: {
2378
+ color: palette.textSlate,
2379
+ fontSize: fs(13),
2380
+ fontWeight: '400',
2381
+ textAlign: 'center',
2382
+ lineHeight: fs(19)
2383
+ },
2384
+ // ── Bottom Navigation Bar ──────────────────────────────────────
2385
+ bottomBar: {
2386
+ position: 'absolute',
2387
+ left: 0,
2388
+ right: 0,
2389
+ bottom: 0,
2390
+ height: BOTTOM_BAR_H + (Platform.OS === 'ios' ? hp(20) : hp(36)),
2391
+ flexDirection: 'row',
2392
+ alignItems: 'center',
2393
+ justifyContent: 'space-evenly',
2394
+ paddingBottom: Platform.OS === 'ios' ? hp(20) : hp(36),
2395
+ backgroundColor: palette.surfaceCard,
2396
+ borderTopWidth: 0
2397
+ },
2398
+ navItem: {
2399
+ alignItems: 'center',
2400
+ justifyContent: 'center',
2401
+ flex: 1
2402
+ },
2403
+ navIconWrap: {
2404
+ width: NAV_ICON_SIZE,
2405
+ height: NAV_ICON_SIZE,
2406
+ justifyContent: 'center',
2407
+ alignItems: 'center',
2408
+ marginBottom: hp(4)
2409
+ },
2410
+ navIcon: {
2411
+ fontSize: fs(18),
2412
+ color: palette.navInactive
2413
+ },
2414
+ navIconActive: {
2415
+ color: palette.textPale
2416
+ },
2417
+ navLabel: {
2418
+ color: palette.navInactive,
2419
+ fontSize: fs(12),
2420
+ fontWeight: '500',
2421
+ letterSpacing: 0.2
2422
+ },
2423
+ navLabelActive: {
2424
+ color: palette.textPale
2425
+ }
2426
+ });
2427
+ const makeMetadataStyles = palette => StyleSheet.create({
2428
+ modalRoot: {
2429
+ flex: 1,
2430
+ justifyContent: 'flex-end'
2431
+ },
2432
+ backdrop: {
2433
+ ...StyleSheet.absoluteFill,
2434
+ backgroundColor: 'rgba(0,0,0,0.62)'
2435
+ },
2436
+ sheet: {
2437
+ maxHeight: '78%',
2438
+ minHeight: ms(300),
2439
+ backgroundColor: palette.chromeDark,
2440
+ borderTopLeftRadius: ms(22),
2441
+ borderTopRightRadius: ms(22),
2442
+ paddingTop: ms(10),
2443
+ paddingHorizontal: ms(20),
2444
+ paddingBottom: STATUS_BAR_INSET + ms(14),
2445
+ shadowColor: palette.black,
2446
+ shadowOffset: {
2447
+ width: 0,
2448
+ height: -ms(4)
2449
+ },
2450
+ shadowOpacity: 0.35,
2451
+ shadowRadius: ms(14),
2452
+ elevation: 14
2453
+ },
2454
+ handle: {
2455
+ alignSelf: 'center',
2456
+ width: ms(42),
2457
+ height: ms(4),
2458
+ borderRadius: ms(2),
2459
+ backgroundColor: palette.whiteA25,
2460
+ marginBottom: ms(12)
2461
+ },
2462
+ sheetHeader: {
2463
+ flexDirection: 'row',
2464
+ alignItems: 'center',
2465
+ justifyContent: 'space-between',
2466
+ paddingBottom: ms(10),
2467
+ borderBottomWidth: StyleSheet.hairlineWidth,
2468
+ borderBottomColor: palette.whiteA15
2469
+ },
2470
+ sheetTitle: {
2471
+ color: palette.white,
2472
+ fontSize: fs(18),
2473
+ fontWeight: '700'
2474
+ },
2475
+ sheetClose: {
2476
+ width: ms(34),
2477
+ height: ms(34),
2478
+ borderRadius: ms(17),
2479
+ alignItems: 'center',
2480
+ justifyContent: 'center',
2481
+ backgroundColor: palette.whiteA12
2482
+ },
2483
+ rows: {
2484
+ paddingVertical: ms(4)
2485
+ },
2486
+ row: {
2487
+ minHeight: ms(48),
2488
+ paddingVertical: ms(9),
2489
+ borderBottomWidth: StyleSheet.hairlineWidth,
2490
+ borderBottomColor: palette.whiteA12
2491
+ },
2492
+ rowLabel: {
2493
+ color: palette.chromeTextMuted,
2494
+ fontSize: fs(11),
2495
+ fontWeight: '600',
2496
+ marginBottom: ms(3)
2497
+ },
2498
+ rowValue: {
2499
+ color: palette.chromeText,
2500
+ fontSize: fs(14),
2501
+ lineHeight: fs(19)
2502
+ }
2503
+ });
2504
+
2505
+ /**
2506
+ * Styles for {@link CompressionSheet}. Deliberately the metadata sheet's own geometry — same
2507
+ * radius, handle, header rule and inset — so the viewer reads as having ONE sheet, and every colour
2508
+ * comes from the palette rather than a literal, so the whole surface follows the host's theme.
2509
+ */
2510
+ const makeCompressionStyles = palette => StyleSheet.create({
2511
+ modalRoot: {
2512
+ flex: 1,
2513
+ justifyContent: 'flex-end'
2514
+ },
2515
+ backdrop: {
2516
+ ...StyleSheet.absoluteFill,
2517
+ backgroundColor: palette.scrimA60
2518
+ },
2519
+ sheet: {
2520
+ maxHeight: '78%',
2521
+ backgroundColor: palette.chromeDark,
2522
+ borderTopLeftRadius: ms(22),
2523
+ borderTopRightRadius: ms(22),
2524
+ paddingTop: ms(10),
2525
+ paddingHorizontal: ms(20),
2526
+ paddingBottom: STATUS_BAR_INSET + ms(14),
2527
+ shadowColor: palette.black,
2528
+ shadowOffset: {
2529
+ width: 0,
2530
+ height: -ms(4)
2531
+ },
2532
+ shadowOpacity: 0.35,
2533
+ shadowRadius: ms(14),
2534
+ elevation: 14
2535
+ },
2536
+ handle: {
2537
+ alignSelf: 'center',
2538
+ width: ms(42),
2539
+ height: ms(4),
2540
+ borderRadius: ms(2),
2541
+ backgroundColor: palette.whiteA25,
2542
+ marginBottom: ms(12)
2543
+ },
2544
+ sheetHeader: {
2545
+ flexDirection: 'row',
2546
+ alignItems: 'center',
2547
+ justifyContent: 'space-between',
2548
+ paddingBottom: ms(10),
2549
+ borderBottomWidth: StyleSheet.hairlineWidth,
2550
+ borderBottomColor: palette.whiteA15
2551
+ },
2552
+ sheetTitle: {
2553
+ color: palette.white,
2554
+ fontSize: fs(18),
2555
+ fontWeight: '700'
2556
+ },
2557
+ sheetClose: {
2558
+ width: ms(34),
2559
+ height: ms(34),
2560
+ borderRadius: ms(17),
2561
+ alignItems: 'center',
2562
+ justifyContent: 'center',
2563
+ backgroundColor: palette.whiteA12
2564
+ },
2565
+ rows: {
2566
+ paddingVertical: ms(4)
2567
+ },
2568
+ row: {
2569
+ minHeight: ms(48),
2570
+ flexDirection: 'row',
2571
+ alignItems: 'center',
2572
+ justifyContent: 'space-between',
2573
+ gap: ms(10),
2574
+ paddingVertical: ms(9),
2575
+ paddingHorizontal: ms(10),
2576
+ marginVertical: ms(2),
2577
+ borderRadius: ms(10),
2578
+ borderBottomWidth: StyleSheet.hairlineWidth,
2579
+ borderBottomColor: palette.whiteA12
2580
+ },
2581
+ // The applied row is tinted as well as check-marked, so the current level is findable at a
2582
+ // glance in a ladder twenty rows long.
2583
+ rowActive: {
2584
+ backgroundColor: palette.greenTintA10
2585
+ },
2586
+ rowText: {
2587
+ flexShrink: 1,
2588
+ color: palette.chromeText,
2589
+ fontSize: fs(14),
2590
+ lineHeight: fs(19)
2591
+ },
2592
+ rowTextActive: {
2593
+ color: palette.accentGreen,
2594
+ fontWeight: '600'
2595
+ }
2596
+ });
2597
+ //# sourceMappingURL=MediaLibraryScreen.js.map