mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-08 03:40:20 +00:00
feat: add SVG icons and implement AlbumPickerModal and CenteredState components
This commit is contained in:
1 parent
126f545c44
commit
f855dab467
30 files changed
+1302
-10
No files matched your search
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
Reference in new issue
Block a user