mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
466 lines
17 KiB
JavaScript
466 lines
17 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl,
|
|
} from 'react-native';
|
|
import { useFocusEffect } from '@react-navigation/native';
|
|
import { useTheme } from '../services/ThemeContext';
|
|
import DatabaseService from '../services/DatabaseService';
|
|
import DeviceMediaService from '../services/DeviceMediaService';
|
|
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
|
import NASAUTH from '../AUTH/NASAUTH';
|
|
import { File } from 'expo-file-system';
|
|
import TopBar from '../components/TopBar';
|
|
import PhotoGrid from '../components/PhotoGrid';
|
|
import MediaViewer from '../components/MediaViewer';
|
|
import { logError } from '../services/ErrorHandler';
|
|
import { TouchableOpacity } from 'react-native';
|
|
|
|
/**
|
|
* Merge media items from different services.
|
|
* Items with the same filename (title) are merged into a single entry
|
|
* with a `sources` array listing every service it appears in.
|
|
* The best local_path is kept (prefer file:// from DEVICE over a cached
|
|
* Picker download, since device URIs are always fresh).
|
|
*/
|
|
const mergeMediaAcrossServices = (items) => {
|
|
// First pass: group by normalised filename
|
|
const byFilename = new Map();
|
|
const noFilename = [];
|
|
|
|
for (const item of items) {
|
|
const key = item.title?.toLowerCase().trim();
|
|
if (!key || key === 'untitled') {
|
|
noFilename.push({ ...item, sources: [item.source_service] });
|
|
continue;
|
|
}
|
|
|
|
if (byFilename.has(key)) {
|
|
const existing = byFilename.get(key);
|
|
// Add this service to the sources list
|
|
if (!existing.sources.includes(item.source_service)) {
|
|
existing.sources.push(item.source_service);
|
|
}
|
|
// Prefer a local device path over a cached download
|
|
if (item.source_service === 'DEVICE' && item.local_path) {
|
|
existing.local_path = item.local_path;
|
|
}
|
|
// Keep the best local_path if the existing one is missing
|
|
if (!existing.local_path && item.local_path) {
|
|
existing.local_path = item.local_path;
|
|
}
|
|
} else {
|
|
byFilename.set(key, { ...item, sources: [item.source_service] });
|
|
}
|
|
}
|
|
|
|
// Second pass: also deduplicate by source_id for items with different titles
|
|
const bySourceId = new Map();
|
|
for (const item of [...byFilename.values(), ...noFilename]) {
|
|
const key = item.source_id || item.id || item.local_path || Math.random().toString();
|
|
if (bySourceId.has(key)) {
|
|
const existing = bySourceId.get(key);
|
|
for (const src of (item.sources || [])) {
|
|
if (!existing.sources.includes(src)) existing.sources.push(src);
|
|
}
|
|
if (!existing.local_path && item.local_path) existing.local_path = item.local_path;
|
|
} else {
|
|
bySourceId.set(key, item);
|
|
}
|
|
}
|
|
|
|
return Array.from(bySourceId.values());
|
|
};
|
|
|
|
const HomeScreen = ({ navigation, route }) => {
|
|
const userId = route?.params?.userId;
|
|
const { theme } = useTheme();
|
|
const [currentUser, setCurrentUser] = useState(null);
|
|
const [linkedAccounts, setLinkedAccounts] = useState([]);
|
|
const [allMedia, setAllMedia] = useState([]);
|
|
const [filteredMedia, setFilteredMedia] = useState([]);
|
|
const [selectedFilter, setSelectedFilter] = useState('all');
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [selectedMedia, setSelectedMedia] = useState(null);
|
|
const [viewerVisible, setViewerVisible] = useState(false);
|
|
const [deviceEndCursor, setDeviceEndCursor] = useState(null);
|
|
const [deviceHasMore, setDeviceHasMore] = useState(false);
|
|
const [googleNextPage, setGoogleNextPage] = useState(null);
|
|
const [googleSessionId, setGoogleSessionId] = useState(null);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (userId) loadUserData();
|
|
}, [userId])
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (currentUser) loadAllMedia();
|
|
}, [currentUser]);
|
|
|
|
useEffect(() => { filterMedia(); }, [selectedFilter, allMedia]);
|
|
|
|
const loadUserData = async () => {
|
|
try {
|
|
const user = await DatabaseService.getUserById(userId);
|
|
setCurrentUser(user);
|
|
const accounts = await DatabaseService.getLinkedAccounts(userId);
|
|
setLinkedAccounts(accounts);
|
|
} catch (err) {
|
|
console.error('Error loading user data:', err);
|
|
}
|
|
};
|
|
|
|
const loadAllMedia = async () => {
|
|
if (!loading) setLoading(true);
|
|
try {
|
|
const newMedia = [];
|
|
|
|
// ─── Device photos (local camera roll, screenshots, etc.) ───
|
|
try {
|
|
const deviceResult = await DeviceMediaService.getPhotos(100);
|
|
if (deviceResult.mediaItems.length) {
|
|
newMedia.push(...deviceResult.mediaItems);
|
|
}
|
|
setDeviceEndCursor(deviceResult.endCursor);
|
|
setDeviceHasMore(deviceResult.hasNextPage);
|
|
} catch (err) {
|
|
console.error('Device media fetch error:', err);
|
|
}
|
|
|
|
// ─── Previously imported Google Photos (stored in DB) ───
|
|
try {
|
|
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
|
if (googleMedia?.length) {
|
|
// Validate cached files still exist — clear stale paths
|
|
const validated = googleMedia.map((item) => {
|
|
if (item.local_path) {
|
|
try {
|
|
const f = new File(item.local_path);
|
|
if (!f.exists || f.size === 0) {
|
|
return { ...item, local_path: null };
|
|
}
|
|
} catch (_) {
|
|
return { ...item, local_path: null };
|
|
}
|
|
}
|
|
return item;
|
|
});
|
|
newMedia.push(...validated);
|
|
}
|
|
} catch (err) {
|
|
console.error('DB Google media fetch error:', err);
|
|
}
|
|
|
|
// ─── NAS fetch ───
|
|
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
|
if (nasAccount) {
|
|
try {
|
|
const nasAuth = new NASAUTH();
|
|
const nasData = nasAccount.account_data;
|
|
await nasAuth.authenticate(nasData.host, nasData.port, nasData.username, nasData.password, nasData.photoFolder);
|
|
const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder);
|
|
if (nasPhotos?.mediaItems) newMedia.push(...nasPhotos.mediaItems);
|
|
await nasAuth.revokeAccess();
|
|
} catch (err) {
|
|
console.error('NAS fetch error:', err);
|
|
}
|
|
}
|
|
|
|
// ─── iCloud (placeholder) ───
|
|
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
|
|
if (iosAccount) {
|
|
console.log('iCloud: placeholder — would fetch photos here');
|
|
}
|
|
|
|
// ─── Merge & cross-reference across services ───
|
|
// Match items across sources by filename so we can show "this photo
|
|
// exists on Device AND Google Photos" etc.
|
|
const merged = mergeMediaAcrossServices(newMedia);
|
|
setAllMedia(merged);
|
|
} catch (err) {
|
|
console.error('Error loading media:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
// ─── Import from Google Photos (opens picker in browser) ───
|
|
const importGooglePhotos = async () => {
|
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
|
if (!googleAccount) {
|
|
console.warn('No Google account linked');
|
|
return;
|
|
}
|
|
|
|
setImporting(true);
|
|
try {
|
|
const data = googleAccount.account_data || {};
|
|
const googleAuth = new GoogleAUTH();
|
|
googleAuth.accessToken = data.accessToken || null;
|
|
googleAuth.refreshToken = data.refreshToken || null;
|
|
googleAuth.expiresAt = data.expiresAt || 0;
|
|
|
|
let result = await googleAuth.pickPhotos();
|
|
|
|
// If 403 (stale scopes), auto re-auth and retry
|
|
if (result?.error?.code === 403) {
|
|
console.log('Stale token — re-authenticating…');
|
|
await googleAuth.revokeAccess();
|
|
const fresh = await googleAuth.authenticate();
|
|
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
|
accessToken: fresh.accessToken,
|
|
refreshToken: fresh.refreshToken || null,
|
|
expiresAt: googleAuth.expiresAt,
|
|
email: fresh.email || data.email || 'google-user',
|
|
});
|
|
result = await googleAuth.pickPhotos();
|
|
}
|
|
|
|
if (result?.error) {
|
|
logError('Import Google Photos', result.error.message);
|
|
} else if (result?.mediaItems?.length) {
|
|
// Save imported items to DB so they persist across app restarts
|
|
for (const item of result.mediaItems) {
|
|
await DatabaseService.saveMediaItem(userId, item);
|
|
}
|
|
|
|
setGoogleNextPage(result.nextPageToken || null);
|
|
setGoogleSessionId(result.sessionId || null);
|
|
|
|
// Merge into current view with cross-service matching
|
|
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
|
|
|
|
console.log(`Imported ${result.mediaItems.length} photos from Google`);
|
|
}
|
|
|
|
// Persist refreshed tokens
|
|
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
|
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
|
accessToken: googleAuth.accessToken,
|
|
refreshToken: googleAuth.refreshToken || data.refreshToken,
|
|
expiresAt: googleAuth.expiresAt,
|
|
email: data.email || 'google-user',
|
|
});
|
|
}
|
|
} catch (err) {
|
|
logError('Import Google Photos', err);
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const filterMedia = () => {
|
|
let filtered = allMedia;
|
|
if (selectedFilter !== 'all') {
|
|
// Check both source_service and the sources array for cross-service items
|
|
filtered = allMedia.filter((m) =>
|
|
m.source_service === selectedFilter ||
|
|
(m.sources && m.sources.includes(selectedFilter))
|
|
);
|
|
}
|
|
filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0));
|
|
setFilteredMedia(filtered);
|
|
};
|
|
|
|
const loadMorePhotos = async () => {
|
|
if (loadingMore) return;
|
|
const canLoadDevice = deviceHasMore;
|
|
const canLoadGoogle = !!googleNextPage && !!googleSessionId;
|
|
if (!canLoadDevice && !canLoadGoogle) return;
|
|
|
|
setLoadingMore(true);
|
|
try {
|
|
const moreMedia = [];
|
|
|
|
if (canLoadDevice) {
|
|
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
|
|
if (result.mediaItems.length) moreMedia.push(...result.mediaItems);
|
|
setDeviceEndCursor(result.endCursor);
|
|
setDeviceHasMore(result.hasNextPage);
|
|
}
|
|
|
|
if (canLoadGoogle) {
|
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
|
if (googleAccount) {
|
|
const data = googleAccount.account_data || {};
|
|
const googleAuth = new GoogleAUTH();
|
|
googleAuth.accessToken = data.accessToken || null;
|
|
googleAuth.refreshToken = data.refreshToken || null;
|
|
googleAuth.expiresAt = data.expiresAt || 0;
|
|
|
|
const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId);
|
|
if (photos?.mediaItems?.length) {
|
|
moreMedia.push(...photos.mediaItems);
|
|
for (const item of photos.mediaItems) {
|
|
await DatabaseService.saveMediaItem(userId, item);
|
|
}
|
|
}
|
|
setGoogleNextPage(photos?.nextPageToken || null);
|
|
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
|
|
}
|
|
}
|
|
|
|
if (moreMedia.length) {
|
|
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia]));
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading more photos:', err);
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
};
|
|
|
|
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
|
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
|
|
|
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
|
|
|
|
if (loading && allMedia.length === 0) {
|
|
return (
|
|
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
|
<ActivityIndicator size="large" color={theme.primary} />
|
|
<Text style={[styles.loadingText, { color: theme.textSecondary }]}>Loading photos...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Build the list of available source filters
|
|
const sourceFilters = [
|
|
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
|
|
...linkedAccounts.map((acc) => ({
|
|
key: acc.service_type,
|
|
icon: getServiceIcon(acc.service_type),
|
|
name: getServiceName(acc.service_type),
|
|
})),
|
|
];
|
|
|
|
const FilterChips = () => (
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow} style={styles.filterContainer}>
|
|
<TouchableOpacity
|
|
style={[styles.chip, { backgroundColor: selectedFilter === 'all' ? theme.chipActiveBg : theme.chipBg }]}
|
|
onPress={() => setSelectedFilter('all')}
|
|
>
|
|
<Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
|
|
</TouchableOpacity>
|
|
{sourceFilters.map((f) => (
|
|
<TouchableOpacity
|
|
key={f.key}
|
|
style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]}
|
|
onPress={() => setSelectedFilter(f.key)}
|
|
>
|
|
<Text style={styles.chipIcon}>{f.icon}</Text>
|
|
<Text style={[styles.chipText, selectedFilter === f.key && { color: theme.primary, fontWeight: '600' }]}>
|
|
{f.name}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
);
|
|
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
|
<TopBar
|
|
currentUser={currentUser}
|
|
onSearchPress={() => navigation.navigate('Search')}
|
|
onProfilePress={() => navigation.navigate('Settings', { userId })}
|
|
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
|
/>
|
|
|
|
<FilterChips />
|
|
|
|
{/* Import from Google Photos button */}
|
|
{hasGoogleAccount && (
|
|
<TouchableOpacity
|
|
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
|
onPress={importGooglePhotos}
|
|
disabled={importing}
|
|
activeOpacity={0.7}
|
|
>
|
|
{importing ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<>
|
|
<Text style={styles.importIcon}>📷</Text>
|
|
<Text style={styles.importText}>Import from Google Photos</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{filteredMedia.length === 0 ? (
|
|
<View style={styles.centerContainer}>
|
|
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
|
|
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
|
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
|
{hasGoogleAccount
|
|
? 'Tap "Import from Google Photos" above to select cloud photos,\nor grant media library permission to see on-device photos.'
|
|
: 'Grant media library permission or take some photos to get started'}
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
|
onPress={() => { setRefreshing(true); loadAllMedia(); }}
|
|
>
|
|
<Text style={styles.retryButtonText}>Refresh</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<PhotoGrid
|
|
data={filteredMedia}
|
|
onItemPress={(item) => { setSelectedMedia(item); setViewerVisible(true); }}
|
|
onEndReached={loadMorePhotos}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadAllMedia(); }} />
|
|
}
|
|
ListFooterComponent={loadingMore ? (
|
|
<View style={{ padding: 16, alignItems: 'center' }}>
|
|
<ActivityIndicator size="small" color={theme.primary} />
|
|
</View>
|
|
) : null}
|
|
/>
|
|
)}
|
|
|
|
<MediaViewer
|
|
visible={viewerVisible}
|
|
media={selectedMedia}
|
|
allMedia={filteredMedia}
|
|
onClose={() => setViewerVisible(false)}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1 },
|
|
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
|
|
loadingText: { marginTop: 12, fontSize: 14 },
|
|
// Filter chips
|
|
filterContainer: { maxHeight: 40 },
|
|
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
|
|
chip: {
|
|
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4,
|
|
borderRadius: 16, gap: 4, height: 28,
|
|
},
|
|
chipIcon: { fontSize: 11 },
|
|
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
|
// Import button
|
|
importButton: {
|
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
|
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
|
|
borderRadius: 12, gap: 8, minHeight: 42,
|
|
},
|
|
importIcon: { fontSize: 16 },
|
|
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
|
// Empty
|
|
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
|
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
|
|
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
|
retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
|
});
|
|
|
|
export default HomeScreen;
|
|
|