import { Button, ButtonSize, ButtonVariant } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { CORNERS, FONT_SIZE } from '@/theme/globals'; import { Image as ExpoImage } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; import { LucideProps, Video, X } from 'lucide-react-native'; import React, { forwardRef, useEffect, useRef, useState } from 'react'; import { Dimensions, FlatList, Modal, Pressable, View as RNView, StyleSheet, TouchableOpacity, ViewStyle, } from 'react-native'; export type MediaType = 'image' | 'video' | 'all'; export type MediaQuality = 'low' | 'medium' | 'high'; export interface MediaAsset { id: string; uri: string; type: 'image' | 'video'; width?: number; height?: number; duration?: number; filename?: string; fileSize?: number; } export interface MediaPickerProps { children?: React.ReactNode; style?: ViewStyle; size?: ButtonSize; variant?: ButtonVariant; icon?: React.ComponentType; disabled?: boolean; mediaType?: MediaType; multiple?: boolean; maxSelection?: number; quality?: MediaQuality; buttonText?: string; placeholder?: string; gallery?: boolean; showPreview?: boolean; previewSize?: number; selectedAssets?: MediaAsset[]; onSelectionChange?: (assets: MediaAsset[]) => void; onError?: (error: string) => void; } const { width: screenWidth } = Dimensions.get('window'); // Helper function to compare arrays of MediaAssets const arraysEqual = (a: MediaAsset[], b: MediaAsset[]): boolean => { if (a.length !== b.length) return false; return a.every((item, index) => { const bItem = b[index]; return ( item.id === bItem.id && item.uri === bItem.uri && item.type === bItem.type ); }); }; export const MediaPicker = forwardRef( ( { children, mediaType = 'all', multiple = false, gallery = false, maxSelection = 10, quality = 'high', onSelectionChange, onError, buttonText, showPreview = true, previewSize = 80, style, variant, size, icon, disabled = false, selectedAssets = [], }, ref ) => { const [assets, setAssets] = useState(selectedAssets); const [isGalleryVisible, setIsGalleryVisible] = useState(false); const [galleryAssets, setGalleryAssets] = useState( [] ); const [hasPermission, setHasPermission] = useState(null); // Use ref to track previous selectedAssets to avoid unnecessary updates const prevSelectedAssetsRef = useRef(selectedAssets); // Theme colors const cardColor = useColor('card'); const borderColor = useColor('border'); const textColor = useColor('text'); const mutedColor = useColor('mutedForeground'); const primaryColor = useColor('primary'); const secondary = useColor('secondary'); // Request permissions on mount useEffect(() => { requestPermissions(); }, []); // Update internal state when selectedAssets prop changes (with proper comparison) useEffect(() => { // Only update if the arrays are actually different if (!arraysEqual(prevSelectedAssetsRef.current, selectedAssets)) { setAssets(selectedAssets); prevSelectedAssetsRef.current = selectedAssets; } }, [selectedAssets]); const requestPermissions = async () => { try { const { status } = await MediaLibrary.requestPermissionsAsync(); setHasPermission(status === 'granted'); if (status !== 'granted') { onError?.( 'Media library permission is required to access photos and videos' ); } } catch (error) { onError?.('Failed to request permissions'); setHasPermission(false); } }; const loadGalleryAssets = async () => { if (!hasPermission) return; try { const mediaTypeFilter = mediaType === 'image' ? [MediaLibrary.MediaType.photo] : mediaType === 'video' ? [MediaLibrary.MediaType.video] : [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; const { assets: galleryAssets } = await MediaLibrary.getAssetsAsync({ first: 100, mediaType: mediaTypeFilter, sortBy: MediaLibrary.SortBy.creationTime, }); setGalleryAssets(galleryAssets); } catch (error) { onError?.('Failed to load gallery assets'); } }; const pickFromGallery = async () => { if (!hasPermission) { await requestPermissions(); return; } if (gallery) { await loadGalleryAssets(); setIsGalleryVisible(true); return; } try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: mediaType === 'image' ? ImagePicker.MediaTypeOptions.Images : mediaType === 'video' ? ImagePicker.MediaTypeOptions.Videos : ImagePicker.MediaTypeOptions.All, allowsMultipleSelection: multiple, quality: quality === 'high' ? 1 : quality === 'medium' ? 0.7 : 0.3, selectionLimit: multiple ? maxSelection : 1, }); if (!result.canceled && result.assets) { const newAssets = result.assets.map((asset, index) => ({ id: `gallery_${Date.now()}_${index}`, uri: asset.uri, type: asset.type === 'video' ? ('video' as const) : ('image' as const), width: asset.width, height: asset.height, duration: asset.duration || undefined, filename: asset.fileName || undefined, fileSize: asset.fileSize, })); handleAssetSelection(newAssets); } } catch (error) { onError?.('Failed to pick media from gallery'); } }; const handleAssetSelection = (newAssets: MediaAsset[]) => { let updatedAssets: MediaAsset[]; if (multiple) { updatedAssets = [...assets, ...newAssets].slice(0, maxSelection); } else { updatedAssets = newAssets; } setAssets(updatedAssets); prevSelectedAssetsRef.current = updatedAssets; // Update ref to prevent loop onSelectionChange?.(updatedAssets); }; const handleGalleryAssetSelect = async ( galleryAsset: MediaLibrary.Asset ) => { try { const assetInfo = await MediaLibrary.getAssetInfoAsync(galleryAsset); const newAsset: MediaAsset = { id: galleryAsset.id, uri: assetInfo.localUri || galleryAsset.uri, type: galleryAsset.mediaType === MediaLibrary.MediaType.video ? 'video' : 'image', width: galleryAsset.width, height: galleryAsset.height, duration: galleryAsset.duration || undefined, filename: galleryAsset.filename, }; if (multiple) { const isAlreadySelected = assets.some( (asset) => asset.id === newAsset.id ); if (isAlreadySelected) { const filteredAssets = assets.filter( (asset) => asset.id !== newAsset.id ); setAssets(filteredAssets); prevSelectedAssetsRef.current = filteredAssets; // Update ref onSelectionChange?.(filteredAssets); } else if (assets.length < maxSelection) { const updatedAssets = [...assets, newAsset]; setAssets(updatedAssets); prevSelectedAssetsRef.current = updatedAssets; // Update ref onSelectionChange?.(updatedAssets); } } else { const newAssets = [newAsset]; setAssets(newAssets); prevSelectedAssetsRef.current = newAssets; // Update ref onSelectionChange?.(newAssets); setIsGalleryVisible(false); } } catch (error) { onError?.('Failed to select asset'); } }; const removeAsset = (assetId: string) => { const filteredAssets = assets.filter((asset) => asset.id !== assetId); setAssets(filteredAssets); prevSelectedAssetsRef.current = filteredAssets; // Update ref onSelectionChange?.(filteredAssets); }; const renderPreviewItem = ({ item }: { item: MediaAsset }) => ( {item.type === 'video' && ( )} removeAsset(item.id)} > ); const renderGalleryItem = ({ item }: { item: MediaLibrary.Asset }) => { const isSelected = assets.some((asset) => asset.id === item.id); const itemWidth = screenWidth / 3 - 4; return ( handleGalleryAssetSelect(item)} > {item.mediaType === MediaLibrary.MediaType.video && ( )} {multiple && isSelected && ( {assets.findIndex((asset) => asset.id === item.id) + 1} )} ); }; return ( {children ? ( children ) : ( )} {showPreview && assets.length > 0 && ( item.id} horizontal showsHorizontalScrollIndicator={false} style={styles.previewContainer} contentContainerStyle={styles.previewContent} /> )} {gallery && ( {buttonText || `Select ${ mediaType === 'all' ? 'Media' : mediaType === 'image' ? 'Images' : 'Videos' }`} {multiple && ( {assets.length}/{maxSelection} )} item.id} numColumns={3} contentContainerStyle={styles.galleryContent} /> )} ); } ); const styles = StyleSheet.create({ compactButton: { width: 60, height: 60, borderRadius: CORNERS, borderWidth: 1, borderStyle: 'dashed', alignItems: 'center', justifyContent: 'center', }, disabled: { opacity: 0.5, }, previewContainer: { marginTop: 12, }, previewContent: { paddingHorizontal: 4, }, previewItem: { marginHorizontal: 4, borderRadius: 8, borderWidth: 1, overflow: 'hidden', position: 'relative', }, previewImage: { borderRadius: 8, }, videoIndicator: { position: 'absolute', top: 8, left: 8, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: 12, padding: 4, }, removeButton: { position: 'absolute', top: 6, right: 6, width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, modalContainer: { flex: 1, }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, }, modalActions: { flexDirection: 'row', alignItems: 'center', gap: 16, }, selectionCount: { fontSize: FONT_SIZE, fontWeight: '500', }, closeButton: { padding: 4, }, galleryContent: { padding: 2, }, galleryItem: { margin: 1, borderRadius: 4, overflow: 'hidden', position: 'relative', }, galleryImage: { width: '100%', height: '100%', }, selectedIndicator: { position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center', }, }); MediaPicker.displayName = 'MediaPicker';