import React, { useRef, useState, useEffect } from 'react'; import { Modal, View, Text, TouchableOpacity, Image, StyleSheet, Dimensions, FlatList, } from 'react-native'; import { useTheme } from '../services/ThemeContext'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const THUMB_SIZE = 60; const THUMB_GAP = 4; const getServiceIcon = (type) => ({ DEVICE: '๐Ÿ“ฑ', GOOGLE_PHOTOS: '๐Ÿ“ท', ICLOUD_PHOTOS: '๐ŸŽ', NAS: '๐Ÿ’พ' }[type] || '๐Ÿ“ฑ'); const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); /** * Fullscreen media viewer with thumbnail carousel. * * Props: * visible โ€“ boolean to show/hide the modal * media โ€“ currently selected media item * allMedia โ€“ (optional) full array of media items for carousel navigation * onClose โ€“ callback when user dismisses the viewer */ const MediaViewer = ({ visible, media, allMedia, onClose }) => { const { theme } = useTheme(); const mainListRef = useRef(null); const thumbListRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); const [currentMedia, setCurrentMedia] = useState(media); const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []); const hasCarousel = mediaList.length > 1; // When the modal opens or media prop changes, scroll to the correct item useEffect(() => { if (!visible || !media || mediaList.length === 0) return; const idx = mediaList.findIndex( (m) => (m.source_id || m.id) === (media.source_id || media.id) ); const newIndex = idx >= 0 ? idx : 0; setActiveIndex(newIndex); setCurrentMedia(mediaList[newIndex]); // Delay scroll so FlatList has rendered setTimeout(() => { if (mainListRef.current && newIndex >= 0) { mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false }); } if (thumbListRef.current && newIndex >= 0) { try { thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 }); } catch (e) { /* index might be out of range during initial render */ } } }, 100); }, [visible, media]); const activeIndexRef = useRef(0); const onMainScroll = (event) => { const offsetX = event.nativeEvent.contentOffset.x; const idx = Math.round(offsetX / SCREEN_WIDTH); if (idx >= 0 && idx < mediaList.length && idx !== activeIndexRef.current) { activeIndexRef.current = idx; setActiveIndex(idx); setCurrentMedia(mediaList[idx]); if (thumbListRef.current) { try { thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); } catch (_) { /* ignore */ } } } }; const onThumbPress = (index) => { setActiveIndex(index); setCurrentMedia(mediaList[index]); if (mainListRef.current) { mainListRef.current.scrollToOffset({ offset: index * SCREEN_WIDTH, animated: true }); } }; if (!media) return null; const renderMainItem = ({ item }) => ( {item.local_path ? ( ) : ( ๐Ÿ“ท )} ); const renderThumb = ({ item, index }) => ( onThumbPress(index)} activeOpacity={0.8} > {item.local_path ? ( ) : ( ๐Ÿ“ท )} ); const displayMedia = currentMedia || media; return ( {/* Close button */} โœ• {/* Counter */} {hasCarousel && ( {activeIndex + 1} / {mediaList.length} )} {/* Main image area โ€“ swipeable */} {hasCarousel ? ( item.source_id || item.id?.toString() || i.toString()} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onMomentumScrollEnd={onMainScroll} getItemLayout={(_, index) => ({ length: SCREEN_WIDTH, offset: SCREEN_WIDTH * index, index, })} style={styles.mainList} /> ) : ( // Single image mode (backward compat) displayMedia.local_path ? ( ) : ( ๐Ÿ“ท ) )} {/* Thumbnail carousel */} {hasCarousel && ( 'thumb_' + (item.source_id || item.id?.toString() || i)} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.thumbContent} getItemLayout={(_, index) => ({ length: THUMB_SIZE + THUMB_GAP, offset: (THUMB_SIZE + THUMB_GAP) * index, index, })} /> )} {/* Info panel */} {displayMedia.title || 'Photo'} Source {displayMedia.sources && displayMedia.sources.length > 1 ? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' ยท ') : `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}` } {displayMedia.sources && displayMedia.sources.length > 1 && ( Available on {displayMedia.sources.length} services )} {displayMedia.created_date && ( Date {new Date(displayMedia.created_date).toLocaleDateString()} )} ); }; const styles = StyleSheet.create({ container: { flex: 1 }, closeButton: { position: 'absolute', top: 44, right: 16, zIndex: 20, width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.2)', justifyContent: 'center', alignItems: 'center', }, closeText: { color: '#fff', fontSize: 20, fontWeight: '600' }, counter: { position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20, alignItems: 'center', }, counterText: { color: '#fff', fontSize: 14, fontWeight: '600' }, mainList: { flex: 1 }, fullImage: { flex: 1, width: '100%' }, fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' }, // Thumbnail carousel thumbCarousel: { position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15, backgroundColor: 'rgba(0,0,0,0.4)', paddingVertical: 8, }, thumbContent: { paddingHorizontal: 12, gap: THUMB_GAP }, thumbItem: { width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 6, overflow: 'hidden', borderWidth: 2, borderColor: 'transparent', }, thumbImage: { width: '100%', height: '100%' }, thumbPlaceholder: { width: '100%', height: '100%', backgroundColor: '#333', justifyContent: 'center', alignItems: 'center', }, // Info panel infoPanel: { padding: 16, paddingBottom: 32, borderTopLeftRadius: 16, borderTopRightRadius: 16, }, title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 }, row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }, label: { fontSize: 12, color: '#9aa0a6' }, value: { fontSize: 12, color: '#e8eaed', fontWeight: '500' }, }); export default MediaViewer;