import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { View, Text, StyleSheet, Animated, PanResponder, Dimensions, ActivityIndicator, ScrollView, Image, } from 'react-native'; import MediaMetadataService from '../services/MediaMetadataService'; import ServiceGlyph from './ServiceGlyph'; const { height: SCREEN_H } = Dimensions.get('window'); const COLLAPSED_H = 120; const EXPANDED_H = Math.min(SCREEN_H * 0.58, 460); const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }; const labelCapture = (kind) => { if (kind === 'screenshot') return 'Screenshot'; if (kind === 'camera') return 'Camera photo'; return 'Unknown / other'; }; const MediaDetailDrawer = ({ theme, displayMedia, resolveNasMetadata, }) => { const heightAnim = useRef(new Animated.Value(COLLAPSED_H)).current; const dragStartHeight = useRef(COLLAPSED_H); const [nasLoading, setNasLoading] = useState(false); const [nasExtra, setNasExtra] = useState(null); const [measured, setMeasured] = useState({ width: undefined, height: undefined }); const fetchedFor = useRef(null); const meta = { ...MediaMetadataService.parseMetadata(displayMedia), ...nasExtra }; useEffect(() => { heightAnim.setValue(COLLAPSED_H); setNasExtra(null); setMeasured({ width: undefined, height: undefined }); fetchedFor.current = null; }, [displayMedia?.source_id, displayMedia?.local_path, heightAnim]); useEffect(() => { const uri = displayMedia?.local_path; if (!uri || displayMedia?.media_type === 'video') return; let cancelled = false; Image.getSize( uri, (w, h) => { if (!cancelled && w > 0 && h > 0) setMeasured({ width: w, height: h }); }, () => {} ); return () => { cancelled = true; }; }, [displayMedia?.local_path, displayMedia?.media_type]); const maybeFetchNas = useCallback(async () => { if (!resolveNasMetadata || displayMedia?.source_service !== 'NAS') return; const key = displayMedia.remote_path || displayMedia.title; if (!key || fetchedFor.current === key) return; fetchedFor.current = key; setNasLoading(true); try { const remote = await resolveNasMetadata(displayMedia); if (remote) setNasExtra(remote); } finally { setNasLoading(false); } }, [displayMedia, resolveNasMetadata]); const fetchNasRef = useRef(maybeFetchNas); fetchNasRef.current = maybeFetchNas; const snapTo = useCallback((target) => { Animated.spring(heightAnim, { toValue: target, useNativeDriver: false, friction: 9, tension: 65, }).start(({ finished }) => { if (finished && target >= EXPANDED_H - 8) fetchNasRef.current?.(); }); }, [heightAnim]); const toggleExpanded = useCallback(() => { heightAnim.stopAnimation((v) => { const open = v < (COLLAPSED_H + EXPANDED_H) / 2; snapTo(open ? EXPANDED_H : COLLAPSED_H); }); }, [heightAnim, snapTo]); const pan = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onPanResponderGrant: () => { heightAnim.stopAnimation((v) => { dragStartHeight.current = v; }); }, onPanResponderMove: (_, g) => { const next = Math.min( EXPANDED_H, Math.max(COLLAPSED_H, dragStartHeight.current - g.dy) ); heightAnim.setValue(next); }, onPanResponderRelease: (_, g) => { if (Math.abs(g.dy) < 10 && Math.abs(g.dx) < 10) { toggleExpanded(); return; } heightAnim.stopAnimation((v) => { const mid = (COLLAPSED_H + EXPANDED_H) / 2; let target = v >= mid ? EXPANDED_H : COLLAPSED_H; if (g.vy < -0.85) target = EXPANDED_H; if (g.vy > 0.85) target = COLLAPSED_H; snapTo(target); }); }, }), [heightAnim, snapTo, toggleExpanded] ); const formattedDate = MediaMetadataService.formatDateTakenForDisplay(displayMedia); const w = meta.width || measured.width || displayMedia?.width; const h = meta.height || measured.height || displayMedia?.height; const resolution = w && h ? `${w} × ${h}` : '—'; const captureKind = meta.captureKind || MediaMetadataService.inferCaptureKind(meta, displayMedia?.title); return ( Drag up/down · tap here to toggle {displayMedia?.title || 'Photo'} Source {displayMedia?.sources && displayMedia.sources.length > 1 ? ( <> {(displayMedia.sources || []).slice(0, 3).map((s) => ( ))} {(displayMedia.sources || []).map((s) => SERVICE_NAMES[s] || s).join(' · ')} ) : ( <> {SERVICE_NAMES[displayMedia?.source_service] || displayMedia?.source_service} )} {displayMedia?.sources && displayMedia.sources.length > 1 && ( Available on {displayMedia.sources.length} services )} Resolution {resolution} Date taken {formattedDate} Capture type {labelCapture(captureKind)} {(meta.cameraMake || meta.cameraModel) && ( Camera {[meta.cameraMake, meta.cameraModel].filter(Boolean).join(' ')} )} {(meta.fileSize != null || displayMedia?.size != null) && ( Size {formatBytes(meta.fileSize ?? displayMedia?.size)} )} {nasLoading && ( Loading file details… )} ); }; function formatBytes(n) { if (n == null || Number.isNaN(n)) return '—'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } const styles = StyleSheet.create({ sheet: { position: 'absolute', left: 0, right: 0, bottom: 0, borderTopLeftRadius: 16, borderTopRightRadius: 16, overflow: 'hidden', /* Above thumbnail carousel (MediaViewer thumb strip uses lower z-index). */ zIndex: 50, elevation: 50, }, dragStrip: { paddingTop: 8, paddingHorizontal: 16, paddingBottom: 6, minHeight: 56, }, pressStrip: { alignItems: 'center', paddingBottom: 6 }, handle: { width: 40, height: 5, borderRadius: 3, marginBottom: 4, }, hint: { color: '#9aa0a6', fontSize: 10, marginBottom: 2 }, title: { fontSize: 15, fontWeight: '600', alignSelf: 'stretch' }, scroll: { flexGrow: 1 }, scrollContent: { paddingHorizontal: 16, paddingBottom: 28 }, row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8, gap: 12 }, label: { fontSize: 12, color: '#9aa0a6' }, value: { fontSize: 12, color: '#e8eaed', fontWeight: '500', flex: 1, textAlign: 'right' }, sourceLine: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }, loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 8, }, loadingText: { color: '#9aa0a6', fontSize: 12 }, }); export default MediaDetailDrawer;