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.
- package/CustomCamera.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +833 -0
- package/android/build.gradle +135 -0
- package/android/consumer-rules.pro +47 -0
- package/android/src/main/AndroidManifest.xml +72 -0
- package/android/src/main/java/android/print/PdfPrint.java +51 -0
- package/android/src/main/java/com/customcamera/AnimatedImageViewManager.kt +199 -0
- package/android/src/main/java/com/customcamera/AudioPlayerView.kt +322 -0
- package/android/src/main/java/com/customcamera/AudioRecorder.kt +506 -0
- package/android/src/main/java/com/customcamera/BarcodeScannerActivity.kt +361 -0
- package/android/src/main/java/com/customcamera/BlurBackgroundView.kt +199 -0
- package/android/src/main/java/com/customcamera/CacheCleanupManager.kt +386 -0
- package/android/src/main/java/com/customcamera/CameraAspectRatioMode.kt +77 -0
- package/android/src/main/java/com/customcamera/CameraAsset.kt +211 -0
- package/android/src/main/java/com/customcamera/CameraTheme.kt +479 -0
- package/android/src/main/java/com/customcamera/CompressionCacheStore.kt +504 -0
- package/android/src/main/java/com/customcamera/CompressionFileNameUtils.kt +7 -0
- package/android/src/main/java/com/customcamera/CompressionProfile.kt +195 -0
- package/android/src/main/java/com/customcamera/CustomCameraActivity.kt +7291 -0
- package/android/src/main/java/com/customcamera/CustomCameraFileProvider.kt +9 -0
- package/android/src/main/java/com/customcamera/CustomCameraModule.kt +2479 -0
- package/android/src/main/java/com/customcamera/CustomCameraPackage.kt +53 -0
- package/android/src/main/java/com/customcamera/DisplayUtils.kt +9 -0
- package/android/src/main/java/com/customcamera/DocumentPreviewView.kt +846 -0
- package/android/src/main/java/com/customcamera/DrawingView.kt +214 -0
- package/android/src/main/java/com/customcamera/EmojiPickerData.kt +228 -0
- package/android/src/main/java/com/customcamera/FilterStripAdapter.kt +158 -0
- package/android/src/main/java/com/customcamera/GifSupport.kt +215 -0
- package/android/src/main/java/com/customcamera/GlobalAudioPlayer.kt +320 -0
- package/android/src/main/java/com/customcamera/ImageExporter.kt +1394 -0
- package/android/src/main/java/com/customcamera/ImageFormatSupport.kt +163 -0
- package/android/src/main/java/com/customcamera/Localization.kt +206 -0
- package/android/src/main/java/com/customcamera/MediaAdapters.kt +758 -0
- package/android/src/main/java/com/customcamera/MediaCacheManager.kt +782 -0
- package/android/src/main/java/com/customcamera/MediaExportFormats.kt +201 -0
- package/android/src/main/java/com/customcamera/MediaFileIo.kt +310 -0
- package/android/src/main/java/com/customcamera/MediaMetadataStore.kt +494 -0
- package/android/src/main/java/com/customcamera/MediaPreviewEditor.kt +14865 -0
- package/android/src/main/java/com/customcamera/MediaPublisher.kt +423 -0
- package/android/src/main/java/com/customcamera/MediaStorageWorkflow.kt +722 -0
- package/android/src/main/java/com/customcamera/MediaStoreRepository.kt +378 -0
- package/android/src/main/java/com/customcamera/MediaThumbnailView.kt +286 -0
- package/android/src/main/java/com/customcamera/MediaUtils.kt +37 -0
- package/android/src/main/java/com/customcamera/NativeVideoViewManager.kt +845 -0
- package/android/src/main/java/com/customcamera/OfficeDocumentConverter.kt +2791 -0
- package/android/src/main/java/com/customcamera/PendingCaptureCrops.kt +88 -0
- package/android/src/main/java/com/customcamera/PowerPointPreview.kt +4077 -0
- package/android/src/main/java/com/customcamera/RecentPickerStore.kt +74 -0
- package/android/src/main/java/com/customcamera/SecureFileEraser.kt +411 -0
- package/android/src/main/java/com/customcamera/SecureKeyModule.kt +88 -0
- package/android/src/main/java/com/customcamera/SecureMediaDeleter.kt +493 -0
- package/android/src/main/java/com/customcamera/StickerPickerData.kt +107 -0
- package/android/src/main/java/com/customcamera/SvgDrawable.kt +175 -0
- package/android/src/main/java/com/customcamera/SvgIconViewManager.kt +77 -0
- package/android/src/main/java/com/customcamera/TextEntryController.kt +553 -0
- package/android/src/main/java/com/customcamera/ThumbnailLoader.kt +455 -0
- package/android/src/main/java/com/customcamera/UploadService.kt +909 -0
- package/android/src/main/java/com/customcamera/VideoEditUtils.kt +210 -0
- package/android/src/main/java/com/customcamera/VideoRotationTranscoder.kt +1992 -0
- package/android/src/main/java/com/customcamera/gif/AnimatedGifEncoder.kt +473 -0
- package/android/src/main/java/com/customcamera/gif/GifDecoder.kt +690 -0
- package/android/src/main/java/com/customcamera/gif/GifFramesDrawable.kt +160 -0
- package/android/src/main/java/com/customcamera/gif/GifHttp.kt +112 -0
- package/android/src/main/java/com/customcamera/gif/GifImageLoader.kt +186 -0
- package/android/src/main/java/com/customcamera/gif/GifModels.kt +74 -0
- package/android/src/main/java/com/customcamera/gif/GifProvider.kt +49 -0
- package/android/src/main/java/com/customcamera/gif/GifProviders.kt +27 -0
- package/android/src/main/java/com/customcamera/gif/GifRepository.kt +124 -0
- package/android/src/main/java/com/customcamera/gif/GifSafety.kt +33 -0
- package/android/src/main/java/com/customcamera/gif/GiphyGifProvider.kt +146 -0
- package/android/src/main/java/com/customcamera/gif/LZWEncoder.kt +258 -0
- package/android/src/main/java/com/customcamera/gif/NeuQuant.kt +435 -0
- package/android/src/main/java/com/customcamera/gif/TenorGifProvider.kt +147 -0
- package/android/src/main/res/drawable/bg_brush_selected.xml +5 -0
- package/android/src/main/res/drawable/bg_color_swatch.xml +5 -0
- package/android/src/main/res/drawable/bg_editor_toolbar.xml +6 -0
- package/android/src/main/res/drawable/bg_icon_circle.xml +5 -0
- package/android/src/main/res/drawable/bg_icon_circle_light.xml +5 -0
- package/android/src/main/res/drawable/bg_mode_tab_active.xml +6 -0
- package/android/src/main/res/drawable/bg_pill.xml +6 -0
- package/android/src/main/res/drawable/bg_recording_dot.xml +6 -0
- package/android/src/main/res/drawable/bg_selection_badge.xml +8 -0
- package/android/src/main/res/drawable/bg_send_button.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_inner.xml +5 -0
- package/android/src/main/res/drawable/bg_shutter_recording.xml +6 -0
- package/android/src/main/res/drawable/bg_shutter_ring.xml +8 -0
- package/android/src/main/res/drawable/bg_shutter_video_idle.xml +5 -0
- package/android/src/main/res/drawable/bg_thumbnail.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +17 -0
- package/android/src/main/res/drawable/ic_audio.xml +10 -0
- package/android/src/main/res/drawable/ic_back.xml +10 -0
- package/android/src/main/res/drawable/ic_brush_medium.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thick.xml +12 -0
- package/android/src/main/res/drawable/ic_brush_thin.xml +12 -0
- package/android/src/main/res/drawable/ic_check.xml +17 -0
- package/android/src/main/res/drawable/ic_chevron_down.xml +10 -0
- package/android/src/main/res/drawable/ic_chevron_up.xml +13 -0
- package/android/src/main/res/drawable/ic_clear_drawing.xml +10 -0
- package/android/src/main/res/drawable/ic_close.xml +10 -0
- package/android/src/main/res/drawable/ic_cover.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +10 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_document.xml +10 -0
- package/android/src/main/res/drawable/ic_done.xml +10 -0
- package/android/src/main/res/drawable/ic_download.xml +10 -0
- package/android/src/main/res/drawable/ic_emoji.xml +10 -0
- package/android/src/main/res/drawable/ic_filter.xml +10 -0
- package/android/src/main/res/drawable/ic_flash_auto.xml +7 -0
- package/android/src/main/res/drawable/ic_flash_off.xml +23 -0
- package/android/src/main/res/drawable/ic_flash_on.xml +16 -0
- package/android/src/main/res/drawable/ic_flip.xml +15 -0
- package/android/src/main/res/drawable/ic_gallery.xml +10 -0
- package/android/src/main/res/drawable/ic_gif.xml +24 -0
- package/android/src/main/res/drawable/ic_lock.xml +19 -0
- package/android/src/main/res/drawable/ic_lock_open.xml +22 -0
- package/android/src/main/res/drawable/ic_marker.xml +10 -0
- package/android/src/main/res/drawable/ic_more_vert.xml +18 -0
- package/android/src/main/res/drawable/ic_overlay_close.xml +19 -0
- package/android/src/main/res/drawable/ic_overlay_resize.xml +18 -0
- package/android/src/main/res/drawable/ic_pause.xml +10 -0
- package/android/src/main/res/drawable/ic_photo_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_play.xml +10 -0
- package/android/src/main/res/drawable/ic_redo.xml +10 -0
- package/android/src/main/res/drawable/ic_rotate.xml +9 -0
- package/android/src/main/res/drawable/ic_send.xml +10 -0
- package/android/src/main/res/drawable/ic_share.xml +10 -0
- package/android/src/main/res/drawable/ic_stat_upload.xml +10 -0
- package/android/src/main/res/drawable/ic_sticker.xml +24 -0
- package/android/src/main/res/drawable/ic_text.xml +10 -0
- package/android/src/main/res/drawable/ic_text_align_center.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_left.xml +11 -0
- package/android/src/main/res/drawable/ic_text_align_right.xml +11 -0
- package/android/src/main/res/drawable/ic_text_bg.xml +11 -0
- package/android/src/main/res/drawable/ic_trim.xml +23 -0
- package/android/src/main/res/drawable/ic_undo.xml +10 -0
- package/android/src/main/res/drawable/ic_video_mode.xml +10 -0
- package/android/src/main/res/drawable/ic_video_note.xml +13 -0
- package/android/src/main/res/drawable/ic_volume_off.xml +9 -0
- package/android/src/main/res/drawable/ic_volume_on.xml +9 -0
- package/android/src/main/res/drawable/sticker_heart.xml +9 -0
- package/android/src/main/res/drawable/sticker_smile.xml +9 -0
- package/android/src/main/res/drawable/sticker_star.xml +9 -0
- package/android/src/main/res/drawable/sticker_thumbsup.xml +9 -0
- package/android/src/main/res/values/colors.xml +34 -0
- package/android/src/main/res/values/ids.xml +15 -0
- package/android/src/main/res/values/strings.xml +15 -0
- package/android/src/main/res/values/styles.xml +20 -0
- package/android/src/main/res/xml/file_paths.xml +34 -0
- package/ios/CustomCamera.h +5 -0
- package/ios/CustomCamera.mm +214 -0
- package/lib/module/AudioPlayerContext.js +233 -0
- package/lib/module/AudioPlayerContext.js.map +1 -0
- package/lib/module/CameraPreview.js +304 -0
- package/lib/module/CameraPreview.js.map +1 -0
- package/lib/module/NativeAnimatedImageView.js +41 -0
- package/lib/module/NativeAnimatedImageView.js.map +1 -0
- package/lib/module/NativeAudioPlayerView.js +33 -0
- package/lib/module/NativeAudioPlayerView.js.map +1 -0
- package/lib/module/NativeBlurView.js +35 -0
- package/lib/module/NativeBlurView.js.map +1 -0
- package/lib/module/NativeCustomCamera.js +128 -0
- package/lib/module/NativeCustomCamera.js.map +1 -0
- package/lib/module/NativeCustomCamera.web.js +143 -0
- package/lib/module/NativeCustomCamera.web.js.map +1 -0
- package/lib/module/NativeCustomVideoView.js +5 -0
- package/lib/module/NativeCustomVideoView.js.map +1 -0
- package/lib/module/NativeDocumentPreviewView.js +156 -0
- package/lib/module/NativeDocumentPreviewView.js.map +1 -0
- package/lib/module/NativeMediaThumbnailView.js +110 -0
- package/lib/module/NativeMediaThumbnailView.js.map +1 -0
- package/lib/module/NativeSvgIcon.js +32 -0
- package/lib/module/NativeSvgIcon.js.map +1 -0
- package/lib/module/ResponsivePreviewContainer.js +171 -0
- package/lib/module/ResponsivePreviewContainer.js.map +1 -0
- package/lib/module/accessibility.js +61 -0
- package/lib/module/accessibility.js.map +1 -0
- package/lib/module/config.js +352 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/cpuOptimization.js +296 -0
- package/lib/module/cpuOptimization.js.map +1 -0
- package/lib/module/db/DatabaseManager.js +191 -0
- package/lib/module/db/DatabaseManager.js.map +1 -0
- package/lib/module/db/MediaMapper.js +284 -0
- package/lib/module/db/MediaMapper.js.map +1 -0
- package/lib/module/db/MediaRepository.js +242 -0
- package/lib/module/db/MediaRepository.js.map +1 -0
- package/lib/module/db/index.js +41 -0
- package/lib/module/db/index.js.map +1 -0
- package/lib/module/db/migrations.js +180 -0
- package/lib/module/db/migrations.js.map +1 -0
- package/lib/module/db/react-native-sqlcipher.d.js +2 -0
- package/lib/module/db/react-native-sqlcipher.d.js.map +1 -0
- package/lib/module/db/secureKey.js +87 -0
- package/lib/module/db/secureKey.js.map +1 -0
- package/lib/module/db/types.js +57 -0
- package/lib/module/db/types.js.map +1 -0
- package/lib/module/db/useMediaDatabase.js +123 -0
- package/lib/module/db/useMediaDatabase.js.map +1 -0
- package/lib/module/defaultIcons.js +2259 -0
- package/lib/module/defaultIcons.js.map +1 -0
- package/lib/module/deviceCapabilities.js +408 -0
- package/lib/module/deviceCapabilities.js.map +1 -0
- package/lib/module/errors.js +267 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/iconConfig.js +796 -0
- package/lib/module/iconConfig.js.map +1 -0
- package/lib/module/icons.js +514 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +251 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/jest-globals.d.js +2 -0
- package/lib/module/jest-globals.d.js.map +1 -0
- package/lib/module/library/MediaLibrary.js +107 -0
- package/lib/module/library/MediaLibrary.js.map +1 -0
- package/lib/module/library/MediaLibraryScreen.js +2597 -0
- package/lib/module/library/MediaLibraryScreen.js.map +1 -0
- package/lib/module/library/components/AudioPreview.js +74 -0
- package/lib/module/library/components/AudioPreview.js.map +1 -0
- package/lib/module/library/components/DocumentPreview.js +331 -0
- package/lib/module/library/components/DocumentPreview.js.map +1 -0
- package/lib/module/library/components/ExpandableFab.js +343 -0
- package/lib/module/library/components/ExpandableFab.js.map +1 -0
- package/lib/module/library/components/FileTiles.js +251 -0
- package/lib/module/library/components/FileTiles.js.map +1 -0
- package/lib/module/library/components/ImagePreview.js +70 -0
- package/lib/module/library/components/ImagePreview.js.map +1 -0
- package/lib/module/library/components/MediaMetadataHeader.js +175 -0
- package/lib/module/library/components/MediaMetadataHeader.js.map +1 -0
- package/lib/module/library/components/RecordAudioScreen.js +816 -0
- package/lib/module/library/components/RecordAudioScreen.js.map +1 -0
- package/lib/module/library/components/VideoPreview.js +453 -0
- package/lib/module/library/components/VideoPreview.js.map +1 -0
- package/lib/module/library/components/ViewerCounter.js +55 -0
- package/lib/module/library/components/ViewerCounter.js.map +1 -0
- package/lib/module/library/compressionEstimate.js +507 -0
- package/lib/module/library/compressionEstimate.js.map +1 -0
- package/lib/module/library/fileMetadata.js +196 -0
- package/lib/module/library/fileMetadata.js.map +1 -0
- package/lib/module/library/index.js +42 -0
- package/lib/module/library/index.js.map +1 -0
- package/lib/module/library/libraryIconRegistry.js +69 -0
- package/lib/module/library/libraryIconRegistry.js.map +1 -0
- package/lib/module/library/libraryIcons.js +858 -0
- package/lib/module/library/libraryIcons.js.map +1 -0
- package/lib/module/library/localizedFormat.js +181 -0
- package/lib/module/library/localizedFormat.js.map +1 -0
- package/lib/module/library/palette.js +225 -0
- package/lib/module/library/palette.js.map +1 -0
- package/lib/module/library/responsive.js +53 -0
- package/lib/module/library/responsive.js.map +1 -0
- package/lib/module/locales.js +416 -0
- package/lib/module/locales.js.map +1 -0
- package/lib/module/localization.js +1064 -0
- package/lib/module/localization.js.map +1 -0
- package/lib/module/logger.js +20 -0
- package/lib/module/logger.js.map +1 -0
- package/lib/module/lowMemoryCache.js +206 -0
- package/lib/module/lowMemoryCache.js.map +1 -0
- package/lib/module/mediaHistory.js +33 -0
- package/lib/module/mediaHistory.js.map +1 -0
- package/lib/module/mediaStorageService.js +402 -0
- package/lib/module/mediaStorageService.js.map +1 -0
- package/lib/module/memory.js +79 -0
- package/lib/module/memory.js.map +1 -0
- package/lib/module/nativeActions.js +307 -0
- package/lib/module/nativeActions.js.map +1 -0
- package/lib/module/nativeComponentSupport.js +61 -0
- package/lib/module/nativeComponentSupport.js.map +1 -0
- package/lib/module/networkPerformance.js +24 -0
- package/lib/module/networkPerformance.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/performance.js +104 -0
- package/lib/module/performance.js.map +1 -0
- package/lib/module/performanceMonitor.js +155 -0
- package/lib/module/performanceMonitor.js.map +1 -0
- package/lib/module/permissions.js +370 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/rendering.js +40 -0
- package/lib/module/rendering.js.map +1 -0
- package/lib/module/security.js +18 -0
- package/lib/module/security.js.map +1 -0
- package/lib/module/state.js +266 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/storage.js +400 -0
- package/lib/module/storage.js.map +1 -0
- package/lib/module/theme.js +390 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils.js +115 -0
- package/lib/module/utils.js.map +1 -0
- package/lib/module/validation.js +288 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts +27 -0
- package/lib/typescript/src/AudioPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/CameraPreview.d.ts +13 -0
- package/lib/typescript/src/CameraPreview.d.ts.map +1 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts +33 -0
- package/lib/typescript/src/NativeAnimatedImageView.d.ts.map +1 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts +12 -0
- package/lib/typescript/src/NativeAudioPlayerView.d.ts.map +1 -0
- package/lib/typescript/src/NativeBlurView.d.ts +12 -0
- package/lib/typescript/src/NativeBlurView.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts +167 -0
- package/lib/typescript/src/NativeCustomCamera.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts +47 -0
- package/lib/typescript/src/NativeCustomCamera.web.d.ts.map +1 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts +30 -0
- package/lib/typescript/src/NativeCustomVideoView.d.ts.map +1 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts +48 -0
- package/lib/typescript/src/NativeDocumentPreviewView.d.ts.map +1 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts +54 -0
- package/lib/typescript/src/NativeMediaThumbnailView.d.ts.map +1 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts +25 -0
- package/lib/typescript/src/NativeSvgIcon.d.ts.map +1 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts +92 -0
- package/lib/typescript/src/ResponsivePreviewContainer.d.ts.map +1 -0
- package/lib/typescript/src/accessibility.d.ts +20 -0
- package/lib/typescript/src/accessibility.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +145 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/cpuOptimization.d.ts +63 -0
- package/lib/typescript/src/cpuOptimization.d.ts.map +1 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts +48 -0
- package/lib/typescript/src/db/DatabaseManager.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaMapper.d.ts +77 -0
- package/lib/typescript/src/db/MediaMapper.d.ts.map +1 -0
- package/lib/typescript/src/db/MediaRepository.d.ts +66 -0
- package/lib/typescript/src/db/MediaRepository.d.ts.map +1 -0
- package/lib/typescript/src/db/index.d.ts +23 -0
- package/lib/typescript/src/db/index.d.ts.map +1 -0
- package/lib/typescript/src/db/migrations.d.ts +31 -0
- package/lib/typescript/src/db/migrations.d.ts.map +1 -0
- package/lib/typescript/src/db/secureKey.d.ts +10 -0
- package/lib/typescript/src/db/secureKey.d.ts.map +1 -0
- package/lib/typescript/src/db/types.d.ts +115 -0
- package/lib/typescript/src/db/types.d.ts.map +1 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts +41 -0
- package/lib/typescript/src/db/useMediaDatabase.d.ts.map +1 -0
- package/lib/typescript/src/defaultIcons.d.ts +139 -0
- package/lib/typescript/src/defaultIcons.d.ts.map +1 -0
- package/lib/typescript/src/deviceCapabilities.d.ts +92 -0
- package/lib/typescript/src/deviceCapabilities.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +95 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/iconConfig.d.ts +176 -0
- package/lib/typescript/src/iconConfig.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +217 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +104 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts +57 -0
- package/lib/typescript/src/library/MediaLibrary.d.ts.map +1 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts +51 -0
- package/lib/typescript/src/library/MediaLibraryScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts +6 -0
- package/lib/typescript/src/library/components/AudioPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts +10 -0
- package/lib/typescript/src/library/components/DocumentPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts +39 -0
- package/lib/typescript/src/library/components/ExpandableFab.d.ts.map +1 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts +8 -0
- package/lib/typescript/src/library/components/FileTiles.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts +5 -0
- package/lib/typescript/src/library/components/ImagePreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts +16 -0
- package/lib/typescript/src/library/components/MediaMetadataHeader.d.ts.map +1 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts +8 -0
- package/lib/typescript/src/library/components/RecordAudioScreen.d.ts.map +1 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts +20 -0
- package/lib/typescript/src/library/components/VideoPreview.d.ts.map +1 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts +13 -0
- package/lib/typescript/src/library/components/ViewerCounter.d.ts.map +1 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts +313 -0
- package/lib/typescript/src/library/compressionEstimate.d.ts.map +1 -0
- package/lib/typescript/src/library/fileMetadata.d.ts +33 -0
- package/lib/typescript/src/library/fileMetadata.d.ts.map +1 -0
- package/lib/typescript/src/library/index.d.ts +40 -0
- package/lib/typescript/src/library/index.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts +35 -0
- package/lib/typescript/src/library/libraryIconRegistry.d.ts.map +1 -0
- package/lib/typescript/src/library/libraryIcons.d.ts +85 -0
- package/lib/typescript/src/library/libraryIcons.d.ts.map +1 -0
- package/lib/typescript/src/library/localizedFormat.d.ts +49 -0
- package/lib/typescript/src/library/localizedFormat.d.ts.map +1 -0
- package/lib/typescript/src/library/palette.d.ts +104 -0
- package/lib/typescript/src/library/palette.d.ts.map +1 -0
- package/lib/typescript/src/library/responsive.d.ts +15 -0
- package/lib/typescript/src/library/responsive.d.ts.map +1 -0
- package/lib/typescript/src/locales.d.ts +103 -0
- package/lib/typescript/src/locales.d.ts.map +1 -0
- package/lib/typescript/src/localization.d.ts +1051 -0
- package/lib/typescript/src/localization.d.ts.map +1 -0
- package/lib/typescript/src/logger.d.ts +4 -0
- package/lib/typescript/src/logger.d.ts.map +1 -0
- package/lib/typescript/src/lowMemoryCache.d.ts +64 -0
- package/lib/typescript/src/lowMemoryCache.d.ts.map +1 -0
- package/lib/typescript/src/mediaHistory.d.ts +3 -0
- package/lib/typescript/src/mediaHistory.d.ts.map +1 -0
- package/lib/typescript/src/mediaStorageService.d.ts +155 -0
- package/lib/typescript/src/mediaStorageService.d.ts.map +1 -0
- package/lib/typescript/src/memory.d.ts +21 -0
- package/lib/typescript/src/memory.d.ts.map +1 -0
- package/lib/typescript/src/nativeActions.d.ts +174 -0
- package/lib/typescript/src/nativeActions.d.ts.map +1 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts +30 -0
- package/lib/typescript/src/nativeComponentSupport.d.ts.map +1 -0
- package/lib/typescript/src/networkPerformance.d.ts +12 -0
- package/lib/typescript/src/networkPerformance.d.ts.map +1 -0
- package/lib/typescript/src/performance.d.ts +32 -0
- package/lib/typescript/src/performance.d.ts.map +1 -0
- package/lib/typescript/src/performanceMonitor.d.ts +45 -0
- package/lib/typescript/src/performanceMonitor.d.ts.map +1 -0
- package/lib/typescript/src/permissions.d.ts +132 -0
- package/lib/typescript/src/permissions.d.ts.map +1 -0
- package/lib/typescript/src/rendering.d.ts +9 -0
- package/lib/typescript/src/rendering.d.ts.map +1 -0
- package/lib/typescript/src/security.d.ts +6 -0
- package/lib/typescript/src/security.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +102 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/storage.d.ts +117 -0
- package/lib/typescript/src/storage.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +15 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +802 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils.d.ts +50 -0
- package/lib/typescript/src/utils.d.ts.map +1 -0
- package/lib/typescript/src/validation.d.ts +52 -0
- package/lib/typescript/src/validation.d.ts.map +1 -0
- package/package.json +204 -0
- package/react-native.config.js +17 -0
- package/src/AudioPlayerContext.tsx +220 -0
- package/src/CameraPreview.tsx +352 -0
- package/src/NativeAnimatedImageView.tsx +71 -0
- package/src/NativeAudioPlayerView.tsx +39 -0
- package/src/NativeBlurView.tsx +40 -0
- package/src/NativeCustomCamera.ts +305 -0
- package/src/NativeCustomCamera.web.ts +192 -0
- package/src/NativeCustomVideoView.ts +30 -0
- package/src/NativeDocumentPreviewView.tsx +196 -0
- package/src/NativeMediaThumbnailView.tsx +167 -0
- package/src/NativeSvgIcon.tsx +46 -0
- package/src/ResponsivePreviewContainer.tsx +227 -0
- package/src/accessibility.ts +80 -0
- package/src/config.ts +406 -0
- package/src/cpuOptimization.ts +365 -0
- package/src/db/DatabaseManager.ts +212 -0
- package/src/db/MediaMapper.ts +377 -0
- package/src/db/MediaRepository.ts +313 -0
- package/src/db/index.ts +73 -0
- package/src/db/migrations.ts +222 -0
- package/src/db/react-native-sqlcipher.d.ts +76 -0
- package/src/db/secureKey.ts +100 -0
- package/src/db/types.ts +123 -0
- package/src/db/useMediaDatabase.ts +191 -0
- package/src/defaultIcons.tsx +2390 -0
- package/src/deviceCapabilities.ts +438 -0
- package/src/errors.ts +350 -0
- package/src/iconConfig.tsx +962 -0
- package/src/icons.tsx +788 -0
- package/src/index.tsx +763 -0
- package/src/jest-globals.d.ts +9 -0
- package/src/library/MediaLibrary.tsx +141 -0
- package/src/library/MediaLibraryScreen.tsx +3003 -0
- package/src/library/components/AudioPreview.tsx +73 -0
- package/src/library/components/DocumentPreview.tsx +361 -0
- package/src/library/components/ExpandableFab.tsx +342 -0
- package/src/library/components/FileTiles.tsx +252 -0
- package/src/library/components/ImagePreview.tsx +72 -0
- package/src/library/components/MediaMetadataHeader.tsx +208 -0
- package/src/library/components/RecordAudioScreen.tsx +845 -0
- package/src/library/components/VideoPreview.tsx +478 -0
- package/src/library/components/ViewerCounter.tsx +71 -0
- package/src/library/compressionEstimate.ts +662 -0
- package/src/library/fileMetadata.ts +212 -0
- package/src/library/index.ts +66 -0
- package/src/library/libraryIconRegistry.tsx +86 -0
- package/src/library/libraryIcons.tsx +928 -0
- package/src/library/localizedFormat.ts +207 -0
- package/src/library/palette.ts +281 -0
- package/src/library/responsive.ts +49 -0
- package/src/locales.ts +381 -0
- package/src/localization.ts +1947 -0
- package/src/logger.ts +20 -0
- package/src/lowMemoryCache.ts +218 -0
- package/src/mediaHistory.ts +42 -0
- package/src/mediaStorageService.ts +546 -0
- package/src/memory.ts +102 -0
- package/src/nativeActions.ts +422 -0
- package/src/nativeComponentSupport.ts +60 -0
- package/src/networkPerformance.ts +30 -0
- package/src/performance.ts +137 -0
- package/src/performanceMonitor.ts +219 -0
- package/src/permissions.ts +403 -0
- package/src/rendering.ts +47 -0
- package/src/security.ts +19 -0
- package/src/state.ts +333 -0
- package/src/storage.ts +453 -0
- package/src/theme.tsx +467 -0
- package/src/types.ts +946 -0
- package/src/utils.ts +177 -0
- 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
|
+
});
|