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