mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
279 lines
9.0 KiB
JavaScript
279 lines
9.0 KiB
JavaScript
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 (
|
||
<Animated.View style={[styles.sheet, { height: heightAnim, backgroundColor: theme.modalInfoBg }]}>
|
||
<View {...pan.panHandlers} style={styles.dragStrip}>
|
||
<View style={styles.pressStrip}>
|
||
<View style={[styles.handle, { backgroundColor: theme.textTertiary || '#666' }]} />
|
||
<Text style={styles.hint}>Drag up/down · tap here to toggle</Text>
|
||
</View>
|
||
<Text style={[styles.title, { color: '#fff' }]} numberOfLines={1}>
|
||
{displayMedia?.title || 'Photo'}
|
||
</Text>
|
||
</View>
|
||
|
||
<ScrollView
|
||
style={styles.scroll}
|
||
contentContainerStyle={styles.scrollContent}
|
||
showsVerticalScrollIndicator={false}
|
||
keyboardShouldPersistTaps="handled"
|
||
nestedScrollEnabled
|
||
>
|
||
<View style={styles.row}>
|
||
<Text style={styles.label}>Source</Text>
|
||
<View style={styles.sourceLine}>
|
||
{displayMedia?.sources && displayMedia.sources.length > 1 ? (
|
||
<>
|
||
{(displayMedia.sources || []).slice(0, 3).map((s) => (
|
||
<ServiceGlyph key={s} serviceType={s} size={14} />
|
||
))}
|
||
<Text style={styles.value}>{(displayMedia.sources || []).map((s) => SERVICE_NAMES[s] || s).join(' · ')}</Text>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ServiceGlyph serviceType={displayMedia?.source_service} size={14} />
|
||
<Text style={styles.value}>{SERVICE_NAMES[displayMedia?.source_service] || displayMedia?.source_service}</Text>
|
||
</>
|
||
)}
|
||
</View>
|
||
</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>
|
||
)}
|
||
|
||
<View style={styles.row}>
|
||
<Text style={styles.label}>Resolution</Text>
|
||
<Text style={styles.value}>{resolution}</Text>
|
||
</View>
|
||
|
||
<View style={styles.row}>
|
||
<Text style={styles.label}>Date taken</Text>
|
||
<Text style={styles.value}>{formattedDate}</Text>
|
||
</View>
|
||
|
||
<View style={styles.row}>
|
||
<Text style={styles.label}>Capture type</Text>
|
||
<Text style={styles.value}>{labelCapture(captureKind)}</Text>
|
||
</View>
|
||
|
||
{(meta.cameraMake || meta.cameraModel) && (
|
||
<View style={styles.row}>
|
||
<Text style={styles.label}>Camera</Text>
|
||
<Text style={styles.value}>
|
||
{[meta.cameraMake, meta.cameraModel].filter(Boolean).join(' ')}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{(meta.fileSize != null || displayMedia?.size != null) && (
|
||
<View style={styles.row}>
|
||
<Text style={styles.label}>Size</Text>
|
||
<Text style={styles.value}>
|
||
{formatBytes(meta.fileSize ?? displayMedia?.size)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{nasLoading && (
|
||
<View style={styles.loadingRow}>
|
||
<ActivityIndicator size="small" color="#fff" />
|
||
<Text style={styles.loadingText}>Loading file details…</Text>
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
</Animated.View>
|
||
);
|
||
};
|
||
|
||
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;
|