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

279 lines
9.0 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, 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;