Refactor AuthScreen and SyncSettingsScreen; remove warning details and sync code generation

- Removed warning details and associated animations from AuthScreen.
- Simplified AuthScreen layout by eliminating unused state and styles.
- Updated SyncSettingsScreen to fetch remote sync stats and display them.
- Introduced new actions for pulling from cloud and uploading local data.
- Removed legacy code related to sync code generation and claiming.
- Enhanced UI with new section headers and improved layout for better user experience.
- Added reconciliation logic for remote watched items in watchedService.
- Implemented addon order reconciliation based on remote manifest URLs in stremioService.
This commit is contained in:
tapframe 2026-02-17 01:33:36 +05:30
parent e27b6de202
commit b5ae55da9e
7 changed files with 447 additions and 413 deletions

View file

@ -119,6 +119,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState);
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingRefreshRef = useRef(false);
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -326,6 +327,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Modified loadContinueWatching to render incrementally
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
if (isRefreshingRef.current) {
pendingRefreshRef.current = true;
return;
}
@ -368,6 +370,20 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return candidateProgress > existingProgress;
};
const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => {
const aProgress = a.progress ?? 0;
const bProgress = b.progress ?? 0;
const aIsUpNext = a.type === 'series' && aProgress <= 0;
const bIsUpNext = b.type === 'series' && bProgress <= 0;
// Keep active in-progress items ahead of "Up Next" placeholders.
if (aIsUpNext !== bIsUpNext) {
return aIsUpNext ? 1 : -1;
}
return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
};
type LocalProgressEntry = {
episodeId?: string;
season?: number;
@ -466,7 +482,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
const merged = Array.from(map.values());
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
merged.sort(compareCwItems);
return merged;
});
@ -1272,7 +1288,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
});
// Sort by lastUpdated descending and set directly
adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
adjustedItems.sort(compareCwItems);
// Debug final order (only if changed)
try {
@ -1515,7 +1531,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return it;
});
adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
adjustedItems.sort(compareCwItems);
setContinueWatchingItems(adjustedItems);
} catch (err) {
logger.error('[SimklSync] Error in Simkl merge:', err);
@ -1529,6 +1545,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} finally {
setLoading(false);
isRefreshingRef.current = false;
if (pendingRefreshRef.current) {
pendingRefreshRef.current = false;
setTimeout(() => {
loadContinueWatching(true);
}, 0);
}
}
}, [getCachedMetadata]);
@ -1602,6 +1624,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Initial load
useEffect(() => {
loadContinueWatching();
const trailingRefreshId = setTimeout(() => {
loadContinueWatching(true);
}, 4000);
return () => {
clearTimeout(trailingRefreshId);
};
}, [loadContinueWatching]);
// Refresh on screen focus (lightweight, no polling)
@ -1879,7 +1908,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}, [computedPosterWidth]);
// Memoized render function for poster-style continue watching items
const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
return (
<TouchableOpacity
style={[
styles.posterContentItem,
@ -1978,10 +2008,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
)}
</View>
</TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]);
);
}, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]);
// Memoized render function for wide-style continue watching items
const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
return (
<TouchableOpacity
style={[
styles.wideContentItem,
@ -2143,7 +2175,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
)}
</View>
</TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius]);
);
}, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius, t]);
// Choose the appropriate render function based on settings
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
@ -2190,7 +2223,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
</View>
<FlatList
data={[...continueWatchingItems].sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0))}
data={[...continueWatchingItems].sort((a, b) => {
const aProgress = a.progress ?? 0;
const bProgress = b.progress ?? 0;
const aIsUpNext = a.type === 'series' && aProgress <= 0;
const bIsUpNext = b.type === 'series' && bProgress <= 0;
if (aIsUpNext !== bIsUpNext) return aIsUpNext ? 1 : -1;
return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
})}
renderItem={renderContinueWatchingItem}
keyExtractor={keyExtractor}
horizontal

View file

@ -29,8 +29,6 @@ const AuthScreen: React.FC = () => {
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showWarningDetails, setShowWarningDetails] = useState(false);
const authCardOpacity = useRef(new Animated.Value(1)).current;
// Subtle, performant animations
const introOpacity = useRef(new Animated.Value(0)).current;
@ -191,27 +189,6 @@ const AuthScreen: React.FC = () => {
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' as never }] } as any);
};
const toggleWarningDetails = () => {
if (showWarningDetails) {
// Fade in auth card
Animated.timing(authCardOpacity, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
} else {
// Fade out auth card
Animated.timing(authCardOpacity, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}
setShowWarningDetails(!showWarningDetails);
};
// showToast helper replaced with direct calls to toast.* API
return (
@ -279,72 +256,12 @@ const AuthScreen: React.FC = () => {
</Text>
</Animated.View>
{/* Important Warning Message */}
<Animated.View
style={[
styles.warningContainer,
{
opacity: introOpacity,
transform: [{ translateY: introTranslateY }],
},
]}
>
<TouchableOpacity
style={[styles.warningCard, { backgroundColor: 'rgba(255, 193, 7, 0.1)', borderColor: 'rgba(255, 193, 7, 0.3)' }]}
onPress={toggleWarningDetails}
activeOpacity={0.8}
>
<MaterialIcons name="warning" size={20} color="#FFC107" style={styles.warningIcon} />
<View style={styles.warningContent}>
<Text style={[styles.warningTitle, { color: '#FFC107' }]}>
Important Notice
</Text>
<Text style={[styles.warningText, { color: currentTheme.colors.white }]}>
This authentication system will be completely replaced by local backup/restore functionality by October 8th. Please create backup files as your cloud data will be permanently destroyed.
</Text>
<Text style={[styles.readMoreText, { color: '#FFC107' }]}>
Read more {showWarningDetails ? '▼' : '▶'}
</Text>
</View>
</TouchableOpacity>
{/* Expanded Details */}
{showWarningDetails && (
<Animated.View style={[styles.warningDetails, { backgroundColor: 'rgba(255, 193, 7, 0.05)', borderColor: 'rgba(255, 193, 7, 0.2)' }]}>
<View style={styles.detailsContent}>
<Text style={[styles.detailsTitle, { color: '#FFC107' }]}>
Why is this system being discontinued?
</Text>
<Text style={[styles.detailsText, { color: currentTheme.colors.white }]}>
Lack of real-time support for addon synchronization{'\n'}
Database synchronization issues with addons and settings{'\n'}
Unreliable cloud data management{'\n'}
Performance problems with remote data access
</Text>
<Text style={[styles.detailsTitle, { color: '#FFC107', marginTop: 16 }]}>
Benefits of Local Backup System:
</Text>
<Text style={[styles.detailsText, { color: currentTheme.colors.white }]}>
Instant addon synchronization across devices{'\n'}
Reliable offline access to all your data{'\n'}
Complete control over your backup files{'\n'}
Faster performance with local data storage{'\n'}
No dependency on external servers{'\n'}
Easy migration between devices
</Text>
</View>
</Animated.View>
)}
</Animated.View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0}
>
{/* Main Card - Hide when warning details are expanded */}
<Animated.View style={[styles.centerContainer, { opacity: authCardOpacity }]}>
<Animated.View style={styles.centerContainer}>
<Animated.View style={[styles.card, {
backgroundColor: Platform.OS === 'android' ? '#121212' : 'rgba(255,255,255,0.02)',
borderColor: Platform.OS === 'android' ? '#1f1f1f' : 'rgba(255,255,255,0.06)',
@ -774,63 +691,6 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '500',
},
warningContainer: {
paddingHorizontal: 20,
marginTop: 24,
marginBottom: 8,
},
warningCard: {
flexDirection: 'row',
padding: 16,
borderRadius: 12,
borderWidth: 1,
alignItems: 'flex-start',
},
warningIcon: {
marginRight: 12,
marginTop: 2,
},
warningContent: {
flex: 1,
},
warningTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 6,
},
warningText: {
fontSize: 14,
lineHeight: 20,
fontWeight: '500',
},
disabledButton: {
opacity: 0.5,
},
readMoreText: {
fontSize: 14,
fontWeight: '600',
marginTop: 8,
alignSelf: 'flex-start',
},
warningDetails: {
marginTop: 8,
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
},
detailsContent: {
padding: 16,
},
detailsTitle: {
fontSize: 15,
fontWeight: '700',
marginBottom: 8,
},
detailsText: {
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
},
});
export default AuthScreen;

View file

@ -5,17 +5,17 @@ import {
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { NavigationProp, useFocusEffect, useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { RootStackParamList } from '../navigation/AppNavigator';
import ScreenHeader from '../components/common/ScreenHeader';
import { useTheme } from '../contexts/ThemeContext';
import CustomAlert from '../components/CustomAlert';
import { supabaseSyncService, SupabaseUser, LinkedDevice } from '../services/supabaseSyncService';
import { supabaseSyncService, SupabaseUser, RemoteSyncStats } from '../services/supabaseSyncService';
import { useAccount } from '../contexts/AccountContext';
const SyncSettingsScreen: React.FC = () => {
@ -28,12 +28,7 @@ const SyncSettingsScreen: React.FC = () => {
const [syncCodeLoading, setSyncCodeLoading] = useState(false);
const [sessionUser, setSessionUser] = useState<SupabaseUser | null>(null);
const [ownerId, setOwnerId] = useState<string | null>(null);
const [linkedDevices, setLinkedDevices] = useState<LinkedDevice[]>([]);
const [lastCode, setLastCode] = useState<string>('');
const [pin, setPin] = useState('');
const [claimCode, setClaimCode] = useState('');
const [claimPin, setClaimPin] = useState('');
const [deviceName, setDeviceName] = useState('');
const [remoteStats, setRemoteStats] = useState<RemoteSyncStats | null>(null);
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@ -57,8 +52,8 @@ const SyncSettingsScreen: React.FC = () => {
setSessionUser(supabaseSyncService.getCurrentSessionUser());
const owner = await supabaseSyncService.getEffectiveOwnerId();
setOwnerId(owner);
const devices = await supabaseSyncService.getLinkedDevices();
setLinkedDevices(devices);
const stats = await supabaseSyncService.getRemoteStats();
setRemoteStats(stats);
} catch (error: any) {
openAlert('Sync Error', error?.message || 'Failed to load sync state');
} finally {
@ -78,103 +73,42 @@ const SyncSettingsScreen: React.FC = () => {
return `Email session${sessionUser.email ? ` (${sessionUser.email})` : ''}`;
}, [sessionUser]);
const handleGenerateCode = async () => {
if (!pin.trim()) {
openAlert('PIN Required', 'Enter a PIN before generating a sync code.');
return;
}
setSyncCodeLoading(true);
try {
const result = await supabaseSyncService.generateSyncCode(pin.trim());
if (result.error || !result.code) {
openAlert('Generate Failed', result.error || 'Unable to generate sync code');
} else {
setLastCode(result.code);
openAlert('Sync Code Ready', `Code: ${result.code}`);
await loadSyncState();
}
} finally {
setSyncCodeLoading(false);
}
};
const handleGetCode = async () => {
if (!pin.trim()) {
openAlert('PIN Required', 'Enter your PIN to retrieve the current sync code.');
return;
}
setSyncCodeLoading(true);
try {
const result = await supabaseSyncService.getSyncCode(pin.trim());
if (result.error || !result.code) {
openAlert('Fetch Failed', result.error || 'Unable to fetch sync code');
} else {
setLastCode(result.code);
openAlert('Current Sync Code', `Code: ${result.code}`);
}
} finally {
setSyncCodeLoading(false);
}
};
const handleClaimCode = async () => {
if (!claimCode.trim() || !claimPin.trim()) {
openAlert('Missing Details', 'Enter both sync code and PIN to claim.');
return;
}
setSyncCodeLoading(true);
try {
const result = await supabaseSyncService.claimSyncCode(
claimCode.trim().toUpperCase(),
claimPin.trim(),
deviceName.trim() || undefined
);
if (!result.success) {
openAlert('Claim Failed', result.message);
} else {
openAlert('Device Linked', result.message);
setClaimCode('');
setClaimPin('');
await loadSyncState();
}
} finally {
setSyncCodeLoading(false);
}
};
const statItems = useMemo(() => {
if (!remoteStats) return [];
return [
{ label: 'Plugins', value: remoteStats.plugins },
{ label: 'Addons', value: remoteStats.addons },
{ label: 'Watch Progress', value: remoteStats.watchProgress },
{ label: 'Library Items', value: remoteStats.libraryItems },
{ label: 'Watched Items', value: remoteStats.watchedItems },
{ label: 'Linked Devices', value: remoteStats.linkedDevices },
];
}, [remoteStats]);
const handleManualSync = async () => {
setSyncCodeLoading(true);
try {
await supabaseSyncService.syncNow();
openAlert('Sync Complete', 'Manual sync completed successfully.');
await supabaseSyncService.pullAllToLocal();
openAlert('Cloud Data Pulled', 'Latest cloud data was pulled to this device.');
await loadSyncState();
} catch (error: any) {
openAlert('Sync Failed', error?.message || 'Manual sync failed');
openAlert('Pull Failed', error?.message || 'Failed to pull cloud data');
} finally {
setSyncCodeLoading(false);
}
};
const handleUnlinkDevice = (deviceUserId: string) => {
openAlert('Unlink Device', 'Are you sure you want to unlink this device?', [
{ label: 'Cancel', onPress: () => {} },
{
label: 'Unlink',
onPress: async () => {
setSyncCodeLoading(true);
try {
const result = await supabaseSyncService.unlinkDevice(deviceUserId);
if (!result.success) {
openAlert('Unlink Failed', result.error || 'Unable to unlink device');
} else {
await loadSyncState();
}
} finally {
setSyncCodeLoading(false);
}
},
},
]);
const handleUploadLocalData = async () => {
setSyncCodeLoading(true);
try {
await supabaseSyncService.pushAllLocalData();
openAlert('Upload Complete', 'This device data has been uploaded to cloud.');
await loadSyncState();
} catch (error: any) {
openAlert('Upload Failed', error?.message || 'Failed to upload local data');
} finally {
setSyncCodeLoading(false);
}
};
const handleSignOut = async () => {
@ -207,8 +141,22 @@ const SyncSettingsScreen: React.FC = () => {
<ScreenHeader title="Nuvio Sync" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 24 }]}>
<View style={[styles.heroCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<View style={styles.heroTopRow}>
<View style={styles.heroTitleWrap}>
<Text style={[styles.heroTitle, { color: currentTheme.colors.highEmphasis }]}>Cloud Sync</Text>
<Text style={[styles.heroSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
Keep your addons, progress and library aligned across devices.
</Text>
</View>
</View>
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Account</Text>
<View style={styles.sectionHeader}>
<MaterialIcons name="person-outline" size={18} color={currentTheme.colors.highEmphasis} />
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Account</Text>
</View>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
{user?.email ? `Signed in as ${user.email}` : 'Not signed in'}
</Text>
@ -241,7 +189,10 @@ const SyncSettingsScreen: React.FC = () => {
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Connection Status</Text>
<View style={styles.sectionHeader}>
<MaterialIcons name="link" size={18} color={currentTheme.colors.highEmphasis} />
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Connection</Text>
</View>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>{authLabel}</Text>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
Effective owner: {ownerId || 'Unavailable'}
@ -254,108 +205,65 @@ const SyncSettingsScreen: React.FC = () => {
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Sync Code</Text>
<TextInput
value={pin}
onChangeText={setPin}
placeholder="PIN"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
secureTextEntry
/>
{!!lastCode && (
<Text style={[styles.codeText, { color: currentTheme.colors.primary }]}>
Latest code: {lastCode}
<View style={styles.sectionHeader}>
<MaterialIcons name="storage" size={18} color={currentTheme.colors.highEmphasis} />
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Database Stats</Text>
</View>
{!remoteStats ? (
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
Sign in to load remote data counts.
</Text>
) : (
<View style={styles.statsGrid}>
{statItems.map((item) => (
<View key={item.label} style={[styles.statTile, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.statValue, { color: currentTheme.colors.highEmphasis }]}>{item.value}</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>{item.label}</Text>
</View>
))}
</View>
)}
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}>
<MaterialIcons name="sync" size={18} color={currentTheme.colors.highEmphasis} />
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Actions</Text>
</View>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
Pull to refresh this device from cloud, or upload this device as the latest source.
</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleGenerateCode}
style={[
styles.button,
styles.primaryButton,
{ backgroundColor: currentTheme.colors.primary },
(syncCodeLoading || !supabaseSyncService.isConfigured()) && styles.buttonDisabled,
]}
onPress={handleManualSync}
>
<Text style={styles.buttonText}>Generate Code</Text>
{syncCodeLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Pull From Cloud</Text>
)}
</TouchableOpacity>
<TouchableOpacity
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
style={[styles.button, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleGetCode}
style={[
styles.button,
styles.secondaryButton,
{ backgroundColor: currentTheme.colors.elevation2, borderColor: currentTheme.colors.elevation2 },
(syncCodeLoading || !supabaseSyncService.isConfigured()) && styles.buttonDisabled,
]}
onPress={handleUploadLocalData}
>
<Text style={styles.buttonText}>Get Existing Code</Text>
<Text style={styles.buttonText}>Upload This Device</Text>
</TouchableOpacity>
</View>
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Claim Sync Code</Text>
<TextInput
value={claimCode}
onChangeText={setClaimCode}
placeholder="SYNC-CODE"
autoCapitalize="characters"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
/>
<TextInput
value={claimPin}
onChangeText={setClaimPin}
placeholder="PIN"
secureTextEntry
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
/>
<TextInput
value={deviceName}
onChangeText={setDeviceName}
placeholder="Device name (optional)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
/>
<TouchableOpacity
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleClaimCode}
>
<Text style={styles.buttonText}>Claim Code</Text>
</TouchableOpacity>
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Linked Devices</Text>
{linkedDevices.length === 0 && (
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>No linked devices.</Text>
)}
{linkedDevices.map((device) => (
<View key={`${device.owner_id}:${device.device_user_id}`} style={styles.deviceRow}>
<View style={styles.deviceInfo}>
<Text style={[styles.deviceName, { color: currentTheme.colors.highEmphasis }]}>
{device.device_name || 'Unnamed device'}
</Text>
<Text style={[styles.deviceMeta, { color: currentTheme.colors.mediumEmphasis }]}>
{device.device_user_id}
</Text>
</View>
<TouchableOpacity
style={[styles.unlinkButton, { borderColor: currentTheme.colors.elevation2 }]}
onPress={() => handleUnlinkDevice(device.device_user_id)}
>
<Text style={[styles.unlinkText, { color: currentTheme.colors.white }]}>Unlink</Text>
</TouchableOpacity>
</View>
))}
</View>
<TouchableOpacity
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
style={[styles.syncNowButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleManualSync}
>
{syncCodeLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sync Now</Text>
)}
</TouchableOpacity>
</ScrollView>
<CustomAlert
@ -380,7 +288,29 @@ const styles = StyleSheet.create({
},
content: {
padding: 16,
gap: 16,
gap: 14,
},
heroCard: {
borderWidth: 1,
borderRadius: 16,
padding: 16,
},
heroTopRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroTitleWrap: {
flex: 1,
},
heroTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 4,
},
heroSubtitle: {
fontSize: 13,
lineHeight: 18,
},
card: {
borderWidth: 1,
@ -388,8 +318,13 @@ const styles = StyleSheet.create({
padding: 14,
gap: 10,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
cardTitle: {
fontSize: 16,
fontSize: 15,
fontWeight: '700',
},
cardText: {
@ -400,12 +335,27 @@ const styles = StyleSheet.create({
fontSize: 12,
marginTop: 4,
},
input: {
statsGrid: {
marginTop: 2,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
statTile: {
width: '48%',
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
paddingHorizontal: 10,
paddingVertical: 8,
},
statValue: {
fontSize: 18,
fontWeight: '800',
marginBottom: 2,
},
statLabel: {
fontSize: 11,
fontWeight: '600',
},
buttonRow: {
flexDirection: 'row',
@ -419,49 +369,20 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: 12,
},
primaryButton: {
borderWidth: 0,
},
secondaryButton: {
borderWidth: 1,
},
buttonDisabled: {
opacity: 0.55,
},
buttonText: {
color: '#fff',
fontWeight: '700',
fontSize: 13,
},
codeText: {
fontSize: 13,
fontWeight: '600',
},
deviceRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 6,
},
deviceInfo: {
flex: 1,
},
deviceName: {
fontSize: 14,
fontWeight: '600',
},
deviceMeta: {
fontSize: 12,
marginTop: 2,
},
unlinkButton: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
},
unlinkText: {
fontSize: 12,
fontWeight: '700',
},
syncNowButton: {
minHeight: 48,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});
export default SyncSettingsScreen;

View file

@ -1987,6 +1987,65 @@ class StremioService {
return false;
}
// Reconcile local addon order to match a remote ordered list of addon manifest URLs.
// Any local addons not present in the remote list are appended in their current order.
async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise<boolean> {
await this.ensureInitialized();
if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) return false;
const normalizeManifestUrl = (raw: string): string => {
const value = (raw || '').trim();
if (!value) return '';
const withManifest = value.includes('manifest.json')
? value
: `${value.replace(/\/$/, '')}/manifest.json`;
return withManifest.toLowerCase();
};
const localByNormalizedUrl = new Map<string, string[]>();
for (const installationId of this.addonOrder) {
const addon = this.installedAddons.get(installationId);
if (!addon) continue;
const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || '');
if (!normalized) continue;
const list = localByNormalizedUrl.get(normalized) || [];
list.push(installationId);
localByNormalizedUrl.set(normalized, list);
}
const nextOrder: string[] = [];
const seenInstallations = new Set<string>();
for (const remoteUrl of manifestUrls) {
const normalizedRemote = normalizeManifestUrl(remoteUrl);
if (!normalizedRemote) continue;
const candidates = localByNormalizedUrl.get(normalizedRemote);
if (!candidates || candidates.length === 0) continue;
const installationId = candidates.shift();
if (!installationId || seenInstallations.has(installationId)) continue;
nextOrder.push(installationId);
seenInstallations.add(installationId);
}
for (const installationId of this.addonOrder) {
if (!this.installedAddons.has(installationId)) continue;
if (seenInstallations.has(installationId)) continue;
nextOrder.push(installationId);
seenInstallations.add(installationId);
}
const changed =
nextOrder.length !== this.addonOrder.length ||
nextOrder.some((id, index) => id !== this.addonOrder[index]);
if (!changed) return false;
this.addonOrder = nextOrder;
await this.saveAddonOrder();
addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
return true;
}
// Check if any installed addons can provide streams (including embedded streams in metadata)
async hasStreamProviders(type?: string): Promise<boolean> {
await this.ensureInitialized();
@ -2089,4 +2148,4 @@ export interface AddonCatalogItem {
}
export const stremioService = StremioService.getInstance();
export default stremioService;
export default stremioService;

View file

@ -97,6 +97,15 @@ export type LinkedDevice = {
linked_at: string;
};
export type RemoteSyncStats = {
plugins: number;
addons: number;
watchProgress: number;
libraryItems: number;
watchedItems: number;
linkedDevices: number;
};
type PushTarget = 'plugins' | 'addons' | 'watch_progress' | 'library' | 'watched_items';
class SupabaseSyncService {
@ -372,6 +381,63 @@ class SupabaseSyncService {
}
}
public async getRemoteStats(): Promise<RemoteSyncStats | null> {
try {
const token = await this.getValidAccessToken();
if (!token) return null;
const ownerId = await this.getEffectiveOwnerId();
if (!ownerId) return null;
const ownerFilter = encodeURIComponent(ownerId);
const [
pluginRows,
addonRows,
watchRows,
libraryRows,
watchedRows,
deviceRows,
] = await Promise.all([
this.request<Array<{ id: string }>>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ device_user_id: string }>>(`/rest/v1/linked_devices?select=device_user_id&owner_id=eq.${ownerFilter}`, {
method: 'GET',
authToken: token,
}),
]);
return {
plugins: pluginRows?.length || 0,
addons: addonRows?.length || 0,
watchProgress: watchRows?.length || 0,
libraryItems: libraryRows?.length || 0,
watchedItems: watchedRows?.length || 0,
linkedDevices: deviceRows?.length || 0,
};
} catch (error) {
logger.warn('[SupabaseSyncService] Failed to fetch remote stats:', error);
return null;
}
}
public async unlinkDevice(deviceUserId: string): Promise<{ success: boolean; error?: string }> {
try {
await this.callRpc<void>('unlink_device', { p_device_user_id: deviceUserId });
@ -423,21 +489,8 @@ class SupabaseSyncService {
});
});
logger.log(`[SupabaseSyncService] runStartupSync: step=pull_addons:done ok=${addonPullOk}`);
if (pluginPullOk) {
logger.log('[SupabaseSyncService] runStartupSync: step=push_plugins:start');
await this.safeRun('push_plugins', async () => {
await this.pushPluginsFromLocal();
});
logger.log('[SupabaseSyncService] runStartupSync: step=push_plugins:done');
}
if (addonPullOk) {
logger.log('[SupabaseSyncService] runStartupSync: step=push_addons:start');
await this.safeRun('push_addons', async () => {
await this.pushAddonsFromLocal();
});
logger.log('[SupabaseSyncService] runStartupSync: step=push_addons:done');
if (!pluginPullOk || !addonPullOk) {
logger.warn('[SupabaseSyncService] runStartupSync: one or more pull steps failed; skipped startup push-by-design');
}
const traktConnected = await this.isTraktConnected();
@ -464,22 +517,8 @@ class SupabaseSyncService {
});
});
if (watchPullOk) {
await this.safeRun('push_watch_progress', async () => {
await this.pushWatchProgressFromLocal();
});
}
if (libraryPullOk) {
await this.safeRun('push_library', async () => {
await this.pushLibraryFromLocal();
});
}
if (watchedPullOk) {
await this.safeRun('push_watched_items', async () => {
await this.pushWatchedItemsFromLocal();
});
if (!watchPullOk || !libraryPullOk || !watchedPullOk) {
logger.warn('[SupabaseSyncService] runStartupSync: one or more content pulls failed; skipped startup push-by-design');
}
}
@ -896,6 +935,11 @@ class SupabaseSyncService {
const localRepos = await localScraperService.getRepositories();
const byUrl = new Map(localRepos.map((repo) => [this.normalizeUrl(repo.url), repo]));
const remoteSet = new Set(
(rows || [])
.map((row) => (row?.url ? this.normalizeUrl(row.url) : null))
.filter((url): url is string => Boolean(url))
);
for (const row of rows || []) {
if (!row.url) continue;
@ -923,6 +967,25 @@ class SupabaseSyncService {
});
}
}
// Reconcile removals only when remote has at least one entry to avoid wiping local
// data if backend temporarily returns an empty set.
if (remoteSet.size > 0) {
let removedCount = 0;
for (const repo of localRepos) {
const normalized = this.normalizeUrl(repo.url);
if (remoteSet.has(normalized)) continue;
try {
await localScraperService.removeRepository(repo.id);
removedCount += 1;
} catch (error) {
logger.warn('[SupabaseSyncService] Failed to remove local plugin repository missing in remote set:', repo.name, error);
}
}
logger.log(`[SupabaseSyncService] pullPluginsToLocal: removedLocalExtras=${removedCount}`);
} else {
logger.log('[SupabaseSyncService] pullPluginsToLocal: remote set empty, skipped local prune');
}
}
private async pushPluginsFromLocal(): Promise<void> {
@ -951,6 +1014,15 @@ class SupabaseSyncService {
}
);
logger.log(`[SupabaseSyncService] pullAddonsToLocal: remoteCount=${rows?.length || 0}`);
const orderedRemoteUrls: string[] = [];
const seenRemoteUrls = new Set<string>();
for (const row of rows || []) {
if (!row?.url) continue;
const normalized = this.normalizeUrl(row.url);
if (seenRemoteUrls.has(normalized)) continue;
seenRemoteUrls.add(normalized);
orderedRemoteUrls.push(row.url);
}
const installed = await stremioService.getInstalledAddonsAsync();
logger.log(`[SupabaseSyncService] pullAddonsToLocal: localInstalledBefore=${installed.length}`);
@ -999,6 +1071,15 @@ class SupabaseSyncService {
} else {
logger.log('[SupabaseSyncService] pullAddonsToLocal: remote set empty, skipped local prune');
}
if (orderedRemoteUrls.length > 0) {
try {
const changed = await stremioService.applyAddonOrderFromManifestUrls(orderedRemoteUrls);
logger.log(`[SupabaseSyncService] pullAddonsToLocal: orderReconciled changed=${changed}`);
} catch (error) {
logger.warn('[SupabaseSyncService] pullAddonsToLocal: failed to reconcile addon order:', error);
}
}
}
private async pushAddonsFromLocal(): Promise<void> {
@ -1024,12 +1105,14 @@ class SupabaseSyncService {
private async pullWatchProgressToLocal(): Promise<void> {
const rows = await this.callRpc<WatchProgressRow[]>('sync_pull_watch_progress', {});
const remoteSet = new Set<string>();
for (const row of rows || []) {
if (!row.content_id) continue;
const type = row.content_type === 'movie' ? 'movie' : 'series';
const season = row.season == null ? null : Number(row.season);
const episode = row.episode == null ? null : Number(row.episode);
remoteSet.add(`${type}:${row.content_id}:${season ?? ''}:${episode ?? ''}`);
const episodeId = type === 'series' && season != null && episode != null
? `${row.content_id}:${season}:${episode}`
@ -1054,9 +1137,34 @@ class SupabaseSyncService {
{
preserveTimestamp: true,
forceWrite: true,
forceNotify: true,
}
);
}
// Reconcile removals only when remote has at least one entry to avoid wiping local
// data if backend temporarily returns an empty set.
if (remoteSet.size > 0) {
const allLocal = await storageService.getAllWatchProgress();
let removedCount = 0;
for (const [key] of Object.entries(allLocal)) {
const parsed = this.parseWatchProgressKey(key);
if (!parsed) continue;
const localSig = `${parsed.contentType}:${parsed.contentId}:${parsed.season ?? ''}:${parsed.episode ?? ''}`;
if (remoteSet.has(localSig)) continue;
const episodeId = parsed.contentType === 'series' && parsed.season != null && parsed.episode != null
? `${parsed.contentId}:${parsed.season}:${parsed.episode}`
: undefined;
await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId);
removedCount += 1;
}
logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: removedLocalExtras=${removedCount}`);
} else {
logger.log('[SupabaseSyncService] pullWatchProgressToLocal: remote set empty, skipped local prune');
}
}
private async pushWatchProgressFromLocal(): Promise<void> {
@ -1085,11 +1193,13 @@ class SupabaseSyncService {
const rows = await this.callRpc<LibraryRow[]>('sync_pull_library', {});
const localItems = await catalogService.getLibraryItems();
const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
const remoteSet = new Set<string>();
for (const row of rows || []) {
if (!row.content_id || !row.content_type) continue;
const type = row.content_type === 'movie' ? 'movie' : 'series';
const key = `${type}:${row.content_id}`;
remoteSet.add(key);
if (existing.has(key)) continue;
try {
@ -1099,6 +1209,25 @@ class SupabaseSyncService {
logger.warn('[SupabaseSyncService] Failed to merge library item from sync:', key, error);
}
}
// Reconcile removals only when remote has at least one entry to avoid wiping local
// data if backend temporarily returns an empty set.
if (remoteSet.size > 0) {
let removedCount = 0;
for (const item of localItems) {
const key = `${item.type}:${item.id}`;
if (remoteSet.has(key)) continue;
try {
await catalogService.removeFromLibrary(item.type, item.id);
removedCount += 1;
} catch (error) {
logger.warn('[SupabaseSyncService] Failed to remove local library item missing in remote set:', key, error);
}
}
logger.log(`[SupabaseSyncService] pullLibraryToLocal: removedLocalExtras=${removedCount}`);
} else {
logger.log('[SupabaseSyncService] pullLibraryToLocal: remote set empty, skipped local prune');
}
}
private async pushLibraryFromLocal(): Promise<void> {
@ -1124,7 +1253,7 @@ class SupabaseSyncService {
private async pullWatchedItemsToLocal(): Promise<void> {
const rows = await this.callRpc<WatchedRow[]>('sync_pull_watched_items', {});
const mapped = (rows || []).map((row) => this.toWatchedItem(row));
await watchedService.mergeRemoteWatchedItems(mapped);
await watchedService.reconcileRemoteWatchedItems(mapped);
}
private async pushWatchedItemsFromLocal(): Promise<void> {

View file

@ -166,6 +166,31 @@ class WatchedService {
}
}
public async reconcileRemoteWatchedItems(items: LocalWatchedItem[]): Promise<void> {
const normalizedRemote = items
.map((item) => this.normalizeWatchedItem(item))
.filter((item) => Boolean(item.content_id));
// Guard: do not wipe local watched data if backend temporarily returns empty.
if (normalizedRemote.length === 0) {
return;
}
await this.saveWatchedItems(normalizedRemote);
this.notifyWatchedSubscribers();
for (const item of normalizedRemote) {
if (item.content_type === 'movie') {
await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at));
continue;
}
if (item.season == null || item.episode == null) continue;
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at));
}
}
/**
* Mark a movie as watched
* @param imdbId - The IMDb ID of the movie