diff --git a/.gitignore b/.gitignore
index 45a0ee8..51246a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@
.env
!.env.example.txt
ServerFiles/data/
+/package-lock.json
diff --git a/assets/icons/camera.svg b/assets/icons/camera.svg
new file mode 100644
index 0000000..1705dee
--- /dev/null
+++ b/assets/icons/camera.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/check.svg b/assets/icons/check.svg
new file mode 100644
index 0000000..b802483
--- /dev/null
+++ b/assets/icons/check.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/device.svg b/assets/icons/device.svg
new file mode 100644
index 0000000..6ce9407
--- /dev/null
+++ b/assets/icons/device.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/duplicates.svg b/assets/icons/duplicates.svg
new file mode 100644
index 0000000..28f4c99
--- /dev/null
+++ b/assets/icons/duplicates.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/empty-mail.svg b/assets/icons/empty-mail.svg
new file mode 100644
index 0000000..449a9b6
--- /dev/null
+++ b/assets/icons/empty-mail.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/empty-photo.svg b/assets/icons/empty-photo.svg
new file mode 100644
index 0000000..40e6efa
--- /dev/null
+++ b/assets/icons/empty-photo.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/folder-open.svg b/assets/icons/folder-open.svg
new file mode 100644
index 0000000..9e31873
--- /dev/null
+++ b/assets/icons/folder-open.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg
new file mode 100644
index 0000000..d43c343
--- /dev/null
+++ b/assets/icons/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/google-photos.svg b/assets/icons/google-photos.svg
new file mode 100644
index 0000000..fad25b0
--- /dev/null
+++ b/assets/icons/google-photos.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/hidden.svg b/assets/icons/hidden.svg
new file mode 100644
index 0000000..5b5d094
--- /dev/null
+++ b/assets/icons/hidden.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/icloud.svg b/assets/icons/icloud.svg
new file mode 100644
index 0000000..404aa47
--- /dev/null
+++ b/assets/icons/icloud.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/library-grid.svg b/assets/icons/library-grid.svg
new file mode 100644
index 0000000..0d05d8f
--- /dev/null
+++ b/assets/icons/library-grid.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/icons/nas.svg b/assets/icons/nas.svg
new file mode 100644
index 0000000..f5ea9c9
--- /dev/null
+++ b/assets/icons/nas.svg
@@ -0,0 +1,8 @@
+
diff --git a/assets/icons/search.svg b/assets/icons/search.svg
new file mode 100644
index 0000000..e87f1f5
--- /dev/null
+++ b/assets/icons/search.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg
new file mode 100644
index 0000000..1c09e47
--- /dev/null
+++ b/assets/icons/trash.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/pfp.png b/assets/pfp.png
new file mode 100644
index 0000000..1a56727
Binary files /dev/null and b/assets/pfp.png differ
diff --git a/metro.config.js b/metro.config.js
new file mode 100644
index 0000000..acfa030
--- /dev/null
+++ b/metro.config.js
@@ -0,0 +1,12 @@
+const { getDefaultConfig } = require('expo/metro-config');
+
+const config = getDefaultConfig(__dirname);
+
+// Ensure SVGs are transformed into React components (Expo-compatible transformer).
+config.transformer.babelTransformerPath = require.resolve('react-native-svg-transformer/expo');
+config.resolver.assetExts = config.resolver.assetExts.filter((ext) => ext !== 'svg');
+if (!config.resolver.sourceExts.includes('svg')) {
+ config.resolver.sourceExts.push('svg');
+}
+
+module.exports = config;
diff --git a/package.json b/package.json
index c19cfc3..72eba8e 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"expo-status-bar": "~55.0.5",
"expo-system-ui": "^55.0.15",
"expo-web-browser": "~55.0.14",
+ "fuse.js": "^7.1.0",
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-gesture-handler": "~2.30.0",
diff --git a/src/components/AlbumPickerModal.js b/src/components/AlbumPickerModal.js
new file mode 100644
index 0000000..dcfe0f3
--- /dev/null
+++ b/src/components/AlbumPickerModal.js
@@ -0,0 +1,120 @@
+import React, { useState, useEffect } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ FlatList,
+ StyleSheet,
+} from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+import ModalShell from './ModalShell';
+
+const AlbumPickerModal = ({
+ visible,
+ onClose,
+ albums,
+ onPickAlbum,
+ onCreateAlbum,
+}) => {
+ const { theme } = useTheme();
+ const [name, setName] = useState('');
+
+ useEffect(() => {
+ if (!visible) setName('');
+ }, [visible]);
+
+ const create = () => {
+ const n = name.trim();
+ if (!n) return;
+ onCreateAlbum?.(n);
+ setName('');
+ };
+
+ return (
+
+ Add to album
+
+ Choose an album or create a new one
+
+
+ String(a.id)}
+ style={styles.list}
+ renderItem={({ item }) => (
+ onPickAlbum?.(item.id)}
+ >
+ {item.name}
+
+ )}
+ ListEmptyComponent={
+ No albums yet — create one below.
+ }
+ />
+
+ New album name
+
+
+
+ Close
+
+
+ Create & add
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
+ hint: { fontSize: 13, marginBottom: 12 },
+ list: { maxHeight: 200, marginBottom: 12 },
+ albumRow: {
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ borderWidth: 1,
+ marginBottom: 8,
+ },
+ albumName: { fontSize: 15, fontWeight: '600' },
+ empty: { fontSize: 13, paddingVertical: 8 },
+ label: { fontSize: 12, marginBottom: 6 },
+ input: {
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 15,
+ marginBottom: 14,
+ },
+ row: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10 },
+ secondary: {
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 10,
+ borderWidth: 1,
+ },
+ primary: {
+ paddingVertical: 10,
+ paddingHorizontal: 18,
+ borderRadius: 10,
+ },
+ primaryText: { color: '#fff', fontWeight: '600', fontSize: 14 },
+});
+
+export default AlbumPickerModal;
diff --git a/src/components/CenteredState.js b/src/components/CenteredState.js
new file mode 100644
index 0000000..7962a41
--- /dev/null
+++ b/src/components/CenteredState.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+const CenteredState = ({
+ loading = false,
+ icon = null,
+ title,
+ description,
+ actionLabel,
+ onAction,
+ containerStyle,
+ iconStyle,
+ titleStyle,
+ descriptionStyle,
+ actionStyle,
+ actionTextStyle,
+}) => {
+ const { theme } = useTheme();
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ icon ? {icon} : null
+ )}
+ {title ? {title} : null}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {actionLabel && onAction ? (
+
+ {actionLabel}
+
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ iconWrap: { marginBottom: 16 },
+ title: { fontSize: 18, fontWeight: '600', marginBottom: 6, textAlign: 'center' },
+ description: { fontSize: 13, textAlign: 'center', lineHeight: 19 },
+ action: {
+ marginTop: 20,
+ paddingHorizontal: 24,
+ paddingVertical: 10,
+ borderRadius: 20,
+ },
+ actionText: { color: '#fff', fontSize: 14, fontWeight: '600' },
+});
+
+export default CenteredState;
+
diff --git a/src/components/MediaDetailDrawer.js b/src/components/MediaDetailDrawer.js
new file mode 100644
index 0000000..3b31052
--- /dev/null
+++ b/src/components/MediaDetailDrawer.js
@@ -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 (
+
+
+
+
+ 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;
diff --git a/src/components/MediaSortControls.js b/src/components/MediaSortControls.js
new file mode 100644
index 0000000..dddf00a
--- /dev/null
+++ b/src/components/MediaSortControls.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+const OPTIONS = [
+ { key: 'date_taken', label: 'Date taken' },
+ { key: 'date_added', label: 'Date added' },
+ { key: 'name', label: 'Name' },
+];
+
+const MediaSortControls = ({ value, onChange }) => {
+ const { theme } = useTheme();
+ return (
+
+ Sort
+
+ {OPTIONS.map((opt) => (
+ onChange(opt.key)}
+ >
+
+ {opt.label}
+
+
+ ))}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ wrap: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingBottom: 4,
+ gap: 8,
+ },
+ prefix: { fontSize: 12, fontWeight: '600' },
+ row: { gap: 6, alignItems: 'center', paddingRight: 8 },
+ chip: {
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 14,
+ height: 28,
+ justifyContent: 'center',
+ },
+ chipText: { fontSize: 12, fontWeight: '500' },
+});
+
+export default MediaSortControls;
diff --git a/src/components/ModalShell.js b/src/components/ModalShell.js
new file mode 100644
index 0000000..fba8828
--- /dev/null
+++ b/src/components/ModalShell.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import {
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+const ModalShell = ({ visible, onClose, children, cardStyle, overlayStyle }) => (
+
+
+
+ {children}
+
+
+);
+
+const styles = StyleSheet.create({
+ overlay: { flex: 1, justifyContent: 'center', paddingHorizontal: 20 },
+ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.45)' },
+ card: {
+ borderRadius: 14,
+ padding: 16,
+ },
+});
+
+export default ModalShell;
+
diff --git a/src/components/RenameMediaModal.js b/src/components/RenameMediaModal.js
new file mode 100644
index 0000000..40a5c2c
--- /dev/null
+++ b/src/components/RenameMediaModal.js
@@ -0,0 +1,90 @@
+import React, { useEffect, useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ StyleSheet,
+} from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+import ModalShell from './ModalShell';
+
+const RenameMediaModal = ({ visible, initialName, onClose, onSave, saving }) => {
+ const { theme } = useTheme();
+ const [name, setName] = useState(initialName || '');
+
+ useEffect(() => {
+ if (visible) setName(initialName || '');
+ }, [visible, initialName]);
+
+ const submit = () => {
+ const trimmed = (name || '').trim();
+ if (!trimmed || saving) return;
+ onSave?.(trimmed);
+ };
+
+ return (
+
+ Rename media
+
+ This updates the name shown in your library and keeps it after refresh.
+
+
+
+
+ Cancel
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
+ hint: { fontSize: 13, marginBottom: 12 },
+ input: {
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 15,
+ marginBottom: 14,
+ },
+ row: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
+ secondary: {
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ },
+ primary: {
+ borderRadius: 10,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ primaryText: { color: '#fff', fontWeight: '700' },
+});
+
+export default RenameMediaModal;
diff --git a/src/components/SelectionToolbar.js b/src/components/SelectionToolbar.js
new file mode 100644
index 0000000..564a264
--- /dev/null
+++ b/src/components/SelectionToolbar.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
+import { useTheme } from '../services/ThemeContext';
+
+const SelectionToolbar = ({
+ visible,
+ selectedCount,
+ onAddToAlbum,
+ onClear,
+}) => {
+ const { theme } = useTheme();
+ if (!visible || selectedCount < 1) return null;
+
+ return (
+
+
+ {selectedCount} selected
+
+
+
+ Add to album
+
+
+ Cancel
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ bar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ zIndex: 50,
+ elevation: 6,
+ },
+ count: { fontSize: 14, fontWeight: '600' },
+ actions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
+ btn: {
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ },
+ btnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
+ clearBtn: { paddingVertical: 6, paddingHorizontal: 4 },
+ clearText: { fontSize: 14, fontWeight: '500' },
+});
+
+export default SelectionToolbar;
diff --git a/src/components/ServiceGlyph.js b/src/components/ServiceGlyph.js
new file mode 100644
index 0000000..ef9b3f4
--- /dev/null
+++ b/src/components/ServiceGlyph.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import DeviceIcon from '../../assets/icons/device.svg';
+import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
+import ICloudIcon from '../../assets/icons/icloud.svg';
+import NasIcon from '../../assets/icons/nas.svg';
+
+const SERVICE_ICONS = {
+ GOOGLE_PHOTOS: GooglePhotosIcon,
+ ICLOUD_PHOTOS: ICloudIcon,
+ NAS: NasIcon,
+ DEVICE: DeviceIcon,
+};
+
+const ServiceGlyph = React.memo(({ serviceType, size = 20, color }) => {
+ const Icon = SERVICE_ICONS[serviceType] || DeviceIcon;
+ return ;
+});
+
+export default ServiceGlyph;
+
diff --git a/src/services/AuthSession.js b/src/services/AuthSession.js
new file mode 100644
index 0000000..8d6fb24
--- /dev/null
+++ b/src/services/AuthSession.js
@@ -0,0 +1,32 @@
+import * as SecureStore from 'expo-secure-store';
+
+const USER_ID_KEY = 'auth_user_id';
+
+export async function getStoredUserId() {
+ try {
+ const raw = await SecureStore.getItemAsync(USER_ID_KEY);
+ if (!raw) return null;
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? n : null;
+ } catch {
+ return null;
+ }
+}
+
+export async function setStoredUserId(userId) {
+ try {
+ if (userId == null) return;
+ await SecureStore.setItemAsync(USER_ID_KEY, String(userId));
+ } catch {
+ /* ignore */
+ }
+}
+
+export async function clearStoredUserId() {
+ try {
+ await SecureStore.deleteItemAsync(USER_ID_KEY);
+ } catch {
+ /* ignore */
+ }
+}
+
diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js
index 6e4a01a..4c1dd6b 100644
--- a/src/services/DatabaseService.js
+++ b/src/services/DatabaseService.js
@@ -1,4 +1,5 @@
import * as SQLite from 'expo-sqlite';
+import Fuse from 'fuse.js';
import CredentialEncryption from './CredentialEncryption';
import MediaMetadataService from './MediaMetadataService';
import { logError } from './ErrorHandler';
@@ -9,6 +10,17 @@ let writeQueue = Promise.resolve();
const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
+const MEDIA_FUZZY_OPTIONS = {
+ includeScore: true,
+ shouldSort: true,
+ ignoreLocation: true,
+ threshold: 0.34,
+ minMatchCharLength: 2,
+ keys: [
+ { name: 'title', weight: 0.45 },
+ { name: '__fuzzyText', weight: 0.55 },
+ ],
+};
const hasValue = (value) => value || value === 0;
const withDecryptedAccount = (row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) });
@@ -32,6 +44,42 @@ const parseJsonSafe = (value, fallback = {}) => {
return { ...fallback };
}
};
+const createdDateMs = (row) => {
+ const t = Date.parse(row?.created_date || '');
+ return Number.isNaN(t) ? 0 : t;
+};
+const enrichRowForFuzzySearch = (row) => ({
+ ...row,
+ __fuzzyText: [
+ row.title,
+ row.description,
+ row.search_text,
+ row.metadata_json,
+ row.source_service,
+ row.source_id,
+ row.remote_path,
+ row.local_path,
+ ]
+ .filter((part) => part != null && String(part).length > 0)
+ .join(' ')
+ .toLowerCase(),
+});
+const runFuzzyMediaSearch = (rows, query) => {
+ if (!Array.isArray(rows) || rows.length === 0) return [];
+ const searchableRows = rows.map(enrichRowForFuzzySearch);
+ const fuse = new Fuse(searchableRows, MEDIA_FUZZY_OPTIONS);
+ return fuse
+ .search(query)
+ .sort((a, b) => {
+ const scoreDelta = (a.score ?? 1) - (b.score ?? 1);
+ if (Math.abs(scoreDelta) > 0.0001) return scoreDelta;
+ return createdDateMs(b.item) - createdDateMs(a.item);
+ })
+ .map(({ item }) => {
+ const { __fuzzyText, ...clean } = item;
+ return clean;
+ });
+};
const enqueueDbWrite = async (operation) => {
const run = async () => operation();
const next = writeQueue.then(run, run);
@@ -537,17 +585,8 @@ const DatabaseService = {
if (!(await DatabaseService.ensureDb())) return [];
const q = (query || '').trim();
if (!q) return [];
- const searchTerm = `%${q}%`;
- const likeRows = await db.getAllAsync(
- `SELECT * FROM media WHERE user_id = ? AND (
- title LIKE ? OR description LIKE ? OR search_text LIKE ? OR metadata_json LIKE ?
- )`,
- [userId, searchTerm, searchTerm, searchTerm, searchTerm]
- );
- const ranked = MediaMetadataService.rankMediaBySearchQuery(likeRows, q);
- if (ranked.length > 0) return ranked;
const allRows = await db.getAllAsync('SELECT * FROM media WHERE user_id = ?', [userId]);
- return MediaMetadataService.rankMediaBySearchQuery(allRows, q);
+ return runFuzzyMediaSearch(allRows, q);
} catch (error) {
logError('Search Media', error);
return [];
diff --git a/src/services/MediaMetadataService.js b/src/services/MediaMetadataService.js
new file mode 100644
index 0000000..034f1a4
--- /dev/null
+++ b/src/services/MediaMetadataService.js
@@ -0,0 +1,415 @@
+import * as MediaLibrary from 'expo-media-library';
+
+export const mediaFingerprint = (item) => {
+ const svc = item.source_service || item.sourceService || 'UNKNOWN';
+ const sid = item.source_id || item.sourceId;
+ if (sid) return `${svc}:${sid}`;
+ const lp = item.local_path || item.localPath;
+ if (lp) return `${svc}:${lp}`;
+ const id = item.id != null ? String(item.id) : '';
+ const title = item.title || '';
+ return `${svc}:${id || title || 'item'}`;
+};
+
+const numDim = (v) => {
+ if (v == null || v === '') return undefined;
+ const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, ''));
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
+};
+
+/**
+ * Parses EXIF-style "YYYY:MM:DD HH:mm:ss", ISO strings, epoch seconds/ms, and Date.parse fallbacks.
+ */
+export const parseFlexibleDateToMs = (value) => {
+ if (value == null || value === '') return null;
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ const ms = value > 1e12 ? value : value > 1e10 ? value : value * 1000;
+ const d = new Date(ms);
+ return Number.isNaN(d.getTime()) ? null : ms;
+ }
+ const s = String(value).trim();
+ if (!s) return null;
+ if (/^\d+$/.test(s)) {
+ const n = Number(s);
+ if (!Number.isFinite(n)) return null;
+ const ms = n > 1e12 ? n : n > 1e10 ? n : n * 1000;
+ const d = new Date(ms);
+ return Number.isNaN(d.getTime()) ? null : ms;
+ }
+ const exif = s.match(/^(\d{4}):(\d{2}):(\d{2})[\sT](\d{2}):(\d{2}):(\d{2})/);
+ if (exif) {
+ const iso = `${exif[1]}-${exif[2]}-${exif[3]}T${exif[4]}:${exif[5]}:${exif[6]}`;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+ }
+ const t = Date.parse(s);
+ return Number.isNaN(t) ? null : t;
+};
+
+export const dateTakenMsToIso = (ms) => {
+ if (ms == null || !Number.isFinite(ms) || ms <= 0) return null;
+ return new Date(ms).toISOString();
+};
+
+/** Single ISO string for DB `created_date` (capture time), never import clock unless no signal. */
+export const resolveCreatedDateIsoForStorage = (item) => {
+ const meta = parseMetadata(item);
+ const chain = [
+ meta.dateTaken,
+ meta.DateTimeOriginal,
+ meta.dateTimeDigitized,
+ meta.CreateDate,
+ meta.ModifyDate,
+ item.creationTime,
+ item.modifyTime,
+ item.created_date,
+ ];
+ for (const v of chain) {
+ const ms = parseFlexibleDateToMs(v);
+ if (ms != null && ms > 0) return new Date(ms).toISOString();
+ }
+ return null;
+};
+
+export const parseMetadata = (itemOrJson) => {
+ if (!itemOrJson) return {};
+ if (typeof itemOrJson === 'object') {
+ let m = {};
+ if (itemOrJson.metadata_json && typeof itemOrJson.metadata_json === 'string') {
+ try {
+ m = JSON.parse(itemOrJson.metadata_json);
+ } catch {
+ m = {};
+ }
+ } else if (itemOrJson.metadata && typeof itemOrJson.metadata === 'object') {
+ m = { ...itemOrJson.metadata };
+ }
+ const w = numDim(m.width) ?? numDim(itemOrJson.width);
+ const h = numDim(m.height) ?? numDim(itemOrJson.height);
+ if (w) m.width = w;
+ if (h) m.height = h;
+ return m;
+ }
+ const raw = typeof itemOrJson === 'string' ? itemOrJson : null;
+ if (!raw || typeof raw !== 'string') return {};
+ try {
+ const m = JSON.parse(raw);
+ const w = numDim(m.width);
+ const h = numDim(m.height);
+ if (w) m.width = w;
+ if (h) m.height = h;
+ return m;
+ } catch {
+ return {};
+ }
+};
+
+export const inferCaptureKind = (meta = {}, filename = '') => {
+ const name = (filename || meta.filename || meta.title || '').toLowerCase();
+ if (/screenshot|screen[_-]?shot|screencap|screen ?recording/i.test(name)) return 'screenshot';
+ if (meta.isScreenshot) return 'screenshot';
+ const make = `${meta.cameraMake || meta.make || ''}`.toLowerCase();
+ const model = `${meta.cameraModel || meta.model || ''}`.toLowerCase();
+ if (make || model) {
+ if (/iphone|ipad|pixel|galaxy|nikon|canon|sony|olympus|fujifilm|dsc-|dsc/i.test(`${make} ${model}`)) {
+ return 'camera';
+ }
+ return 'camera';
+ }
+ if (/^img_|^mvimg_|^dsc|^photo_\d|^p\d{6,}/i.test(name)) return 'camera';
+ return 'unknown';
+};
+
+export const buildSearchText = (item, metadataJsonString) => {
+ let meta = {};
+ try {
+ meta = metadataJsonString ? JSON.parse(metadataJsonString) : {};
+ } catch {
+ meta = {};
+ }
+ const parts = [
+ item.title,
+ item.description,
+ item.source_service,
+ meta.cameraMake,
+ meta.cameraModel,
+ meta.make,
+ meta.model,
+ meta.captureKind,
+ meta.mimeType,
+ meta.dateTaken,
+ item.remote_path,
+ item.local_path,
+ ];
+ return parts.filter((p) => p != null && String(p).length > 0).join(' ').toLowerCase();
+};
+
+export const mergeMetadataJson = (existingJson, incomingJson) => {
+ let a = {};
+ let b = {};
+ try {
+ if (existingJson) a = JSON.parse(existingJson);
+ } catch {
+ a = {};
+ }
+ try {
+ if (incomingJson) b = typeof incomingJson === 'string' ? JSON.parse(incomingJson) : incomingJson;
+ } catch {
+ b = {};
+ }
+ return JSON.stringify({ ...a, ...b });
+};
+
+
+export const getDateTakenMs = (item) => {
+ if (!item) return 0;
+ const meta = parseMetadata(item);
+ const chain = [
+ meta.dateTaken,
+ meta.DateTimeOriginal,
+ meta.CreateDate,
+ meta.ModifyDate,
+ meta.dateTimeDigitized,
+ item.creationTime,
+ item.modifyTime,
+ item.created_date,
+ ];
+ for (const v of chain) {
+ const ms = parseFlexibleDateToMs(v);
+ if (ms != null && ms > 0) return ms;
+ }
+ return 0;
+};
+
+export const getDateAddedMs = (item) => {
+ const raw = item.created_at || item.indexed_at;
+ const ms = parseFlexibleDateToMs(raw);
+ if (ms != null && ms > 0) return ms;
+ return getDateTakenMs(item);
+};
+
+export const sortMediaItems = (items, mode) => {
+ const copy = [...items];
+ if (mode === 'name') {
+ copy.sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' }));
+ return copy;
+ }
+ if (mode === 'date_added') {
+ copy.sort((a, b) => getDateAddedMs(b) - getDateAddedMs(a));
+ return copy;
+ }
+ copy.sort((a, b) => getDateTakenMs(b) - getDateTakenMs(a));
+ return copy;
+};
+
+const mapBasicAsset = (asset) => ({
+ source_service: 'DEVICE',
+ source_id: asset.id,
+ title: asset.filename || 'Untitled',
+ description: '',
+ local_path: asset.uri,
+ remote_path: '',
+ media_type: asset.mediaType === 'video' ? 'video' : 'photo',
+ created_date: (() => {
+ const ms = parseFlexibleDateToMs(asset.creationTime);
+ return ms != null && ms > 0 ? new Date(ms).toISOString() : null;
+ })(),
+ width: asset.width,
+ height: asset.height,
+ duration: asset.duration || 0,
+});
+
+export const deviceAssetToMediaItem = async (asset) => {
+ const base = mapBasicAsset(asset);
+ let exif = {};
+ try {
+ const info = await MediaLibrary.getAssetInfoAsync(asset);
+ if (info?.exif && typeof info.exif === 'object') exif = info.exif;
+ if (info?.width) base.width = info.width;
+ if (info?.height) base.height = info.height;
+ } catch {
+ /* optional enrichment */
+ }
+
+ const meta = {
+ width: numDim(exif.ImageWidth) || numDim(exif.PixelXDimension) || numDim(base.width),
+ height: numDim(exif.ImageHeight) || numDim(exif.PixelYDimension) || numDim(base.height),
+ cameraMake: exif.Make || exif.LensMake,
+ cameraModel: exif.Model || exif.LensModel,
+ orientation: exif.Orientation,
+ mimeType: exif.MIMEType || (base.media_type === 'video' ? 'video/mp4' : 'image/jpeg'),
+ };
+
+ const takenMs = parseFlexibleDateToMs(asset.creationTime);
+ if (takenMs != null && takenMs > 0) {
+ meta.dateTaken = new Date(takenMs).toISOString();
+ }
+
+ meta.captureKind = inferCaptureKind({ ...meta, filename: asset.filename, title: base.title });
+ const metadata_json = JSON.stringify(meta);
+ return {
+ ...base,
+ creationTime: asset.creationTime,
+ created_date: meta.dateTaken || base.created_date,
+ metadata_json,
+ search_text: buildSearchText({ ...base, metadata_json }, metadata_json),
+ };
+};
+
+export const enrichGooglePickerItem = (item) => {
+ const meta = parseMetadata(item.metadata_json || '{}');
+ const width = numDim(item.width) ?? numDim(meta.width);
+ const height = numDim(item.height) ?? numDim(meta.height);
+ const takenMs = parseFlexibleDateToMs(item.created_date) ?? parseFlexibleDateToMs(meta.dateTaken);
+ const dateTakenIso = takenMs != null && takenMs > 0 ? new Date(takenMs).toISOString() : meta.dateTaken;
+ const merged = {
+ ...meta,
+ width,
+ height,
+ mimeType: item.mime_type || meta.mimeType,
+ dateTaken: dateTakenIso,
+ captureKind: inferCaptureKind({ ...meta, filename: item.title, title: item.title }),
+ };
+ const metadata_json = JSON.stringify(merged);
+ const createdIso = dateTakenIso || item.created_date;
+ return {
+ ...item,
+ created_date: createdIso,
+ metadata_json,
+ search_text: buildSearchText({ ...item, metadata_json }, metadata_json),
+ };
+};
+
+export const enrichNasItem = (item, serverMeta = {}) => {
+ const fromServerMs = parseFlexibleDateToMs(serverMeta.DateTimeOriginal)
+ ?? parseFlexibleDateToMs(serverMeta.dateTaken)
+ ?? parseFlexibleDateToMs(serverMeta.CreateDate);
+ const dateTakenIso = fromServerMs != null && fromServerMs > 0
+ ? new Date(fromServerMs).toISOString()
+ : (parseMetadata(item.metadata_json).dateTaken || item.created_date);
+ const meta = {
+ ...parseMetadata(item.metadata_json),
+ width: numDim(serverMeta.width) ?? numDim(serverMeta.ExifImageWidth) ?? numDim(item.width),
+ height: numDim(serverMeta.height) ?? numDim(serverMeta.ExifImageHeight) ?? numDim(item.height),
+ cameraMake: serverMeta.Make || serverMeta.make,
+ cameraModel: serverMeta.Model || serverMeta.model,
+ dateTaken: dateTakenIso,
+ fileSize: serverMeta.fileSize ?? item.size,
+ mimeType: serverMeta.MIMEType || serverMeta.mimeType,
+ };
+ if (!meta.captureKind) {
+ meta.captureKind = inferCaptureKind({ ...meta, filename: item.title });
+ }
+ const metadata_json = JSON.stringify(meta);
+ const createdIso = parseFlexibleDateToMs(dateTakenIso) ? dateTakenIso : item.created_date;
+ return {
+ ...item,
+ created_date: createdIso,
+ metadata_json,
+ search_text: buildSearchText({ ...item, metadata_json }, metadata_json),
+ };
+};
+
+export const chunkIntoRows = (items, columns = 3) => {
+ const rows = [];
+ for (let i = 0; i < items.length; i += columns) {
+ rows.push(items.slice(i, i + columns));
+ }
+ return rows;
+};
+
+const localDateKeyFromMs = (ms) => {
+ const d = new Date(ms);
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+};
+
+const humanDaySectionTitle = (dateKey) => {
+ const d = new Date(`${dateKey}T12:00:00`);
+ const now = new Date();
+ const todayKey = localDateKeyFromMs(now.getTime());
+ const y = new Date(now);
+ y.setDate(y.getDate() - 1);
+ const yestKey = localDateKeyFromMs(y.getTime());
+ const longFmt = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
+ if (dateKey === todayKey) return `Today · ${longFmt}`;
+ if (dateKey === yestKey) return `Yesterday · ${longFmt}`;
+ return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
+};
+
+const UNKNOWN_DAY = '_unknown';
+
+/** Google Photos–style sections: newest calendar day first; each section’s rows are `columns`-wide grid strips. */
+export const groupMediaByDayForTimeline = (items, columns = 3) => {
+ const sorted = sortMediaItems([...items], 'date_taken');
+ const byDay = new Map();
+ for (const item of sorted) {
+ const ms = getDateTakenMs(item);
+ const key = ms > 0 ? localDateKeyFromMs(ms) : UNKNOWN_DAY;
+ if (!byDay.has(key)) byDay.set(key, []);
+ byDay.get(key).push(item);
+ }
+ const datedKeys = [...byDay.keys()].filter((k) => k !== UNKNOWN_DAY).sort((a, b) => b.localeCompare(a));
+ const keys = byDay.has(UNKNOWN_DAY) ? [...datedKeys, UNKNOWN_DAY] : datedKeys;
+ return keys.map((dateKey) => {
+ const rows = chunkIntoRows(byDay.get(dateKey), columns);
+ const data = rows.map((row, ri) => ({
+ row,
+ rowKey: `${dateKey}-${ri}-${row.map((c) => (c ? mediaFingerprint(c) : '·')).join('|')}`,
+ }));
+ return {
+ dateKey,
+ title: dateKey === UNKNOWN_DAY ? 'No capture date' : humanDaySectionTitle(dateKey),
+ data,
+ };
+ });
+};
+
+/** For UI labels (drawer, etc.) */
+export const formatDateTakenForDisplay = (item) => {
+ const ms = getDateTakenMs(item);
+ if (!ms) return '—';
+ try {
+ return new Date(ms).toLocaleString();
+ } catch {
+ return '—';
+ }
+};
+
+export const resolveAlbumEntriesToMediaItems = (entries, dbMedia) => {
+ const byFp = new Map((dbMedia || []).map((m) => [mediaFingerprint(m), m]));
+ return (entries || []).map((e) => {
+ const hit = byFp.get(e.media_fingerprint);
+ if (hit) return { ...hit };
+ return {
+ source_service: e.source_service || 'DEVICE',
+ title: e.title_snapshot || 'Photo',
+ local_path: e.local_path,
+ remote_path: e.remote_path,
+ metadata_json: e.metadata_json,
+ };
+ });
+};
+
+export default {
+ mediaFingerprint,
+ parseMetadata,
+ inferCaptureKind,
+ buildSearchText,
+ mergeMetadataJson,
+ parseFlexibleDateToMs,
+ dateTakenMsToIso,
+ resolveCreatedDateIsoForStorage,
+ getDateTakenMs,
+ getDateAddedMs,
+ sortMediaItems,
+ deviceAssetToMediaItem,
+ enrichGooglePickerItem,
+ enrichNasItem,
+ resolveAlbumEntriesToMediaItems,
+ chunkIntoRows,
+ groupMediaByDayForTimeline,
+ formatDateTakenForDisplay,
+};