Files
Media-Aggregation-and-Sorti…/src/components/MediaViewer.js
T

266 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }) => (
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)}
</View>
);
const renderThumb = ({ item, index }) => (
<TouchableOpacity
style={[
styles.thumbItem,
index === activeIndex && { borderColor: '#fff', borderWidth: 2 },
]}
onPress={() => onThumbPress(index)}
activeOpacity={0.8}
>
{item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
) : (
<View style={styles.thumbPlaceholder}>
<Text style={{ fontSize: 20 }}>📷</Text>
</View>
)}
</TouchableOpacity>
);
const displayMedia = currentMedia || media;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
{/* Close button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{/* Counter */}
{hasCarousel && (
<View style={styles.counter}>
<Text style={styles.counterText}>
{activeIndex + 1} / {mediaList.length}
</Text>
</View>
)}
{/* Main image area – swipeable */}
{hasCarousel ? (
<FlatList
ref={mainListRef}
data={mediaList}
renderItem={renderMainItem}
keyExtractor={(item, i) => 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 ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
<View style={styles.fullPlaceholder}>
<Text style={{ fontSize: 64 }}>📷</Text>
</View>
)
)}
{/* Thumbnail carousel */}
{hasCarousel && (
<View style={styles.thumbCarousel}>
<FlatList
ref={thumbListRef}
data={mediaList}
renderItem={renderThumb}
keyExtractor={(item, i) => '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,
})}
/>
</View>
)}
{/* Info panel */}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
<View style={styles.row}>
<Text style={styles.label}>Source</Text>
<Text style={styles.value}>
{displayMedia.sources && displayMedia.sources.length > 1
? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ')
: `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}`
}
</Text>
</View>
{displayMedia.sources && displayMedia.sources.length > 1 && (
<View style={styles.row}>
<Text style={styles.label}>Available on</Text>
<Text style={[styles.value, { color: '#8ab4f8' }]}>
{displayMedia.sources.length} services
</Text>
</View>
)}
{displayMedia.created_date && (
<View style={styles.row}>
<Text style={styles.label}>Date</Text>
<Text style={styles.value}>
{new Date(displayMedia.created_date).toLocaleDateString()}
</Text>
</View>
)}
</View>
</View>
</Modal>
);
};
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;