feat: add SVG icons and implement AlbumPickerModal and CenteredState components

This commit is contained in:
bobbert committed 2026-04-16 16:36:35 +01:00
1 parent 126f545c44
commit f855dab467
30 files changed
+1302 -10

No files matched your search

+278
View File
@@ -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;