acc sync init

This commit is contained in:
tapframe 2026-02-16 21:39:41 +05:30
parent 8a4aa64074
commit e27b6de202
12 changed files with 3217 additions and 77 deletions

10
App.tsx
View file

@ -48,6 +48,7 @@ import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage'; import { mmkvStorage } from './src/services/mmkvStorage';
import { CampaignManager } from './src/components/promotions/CampaignManager'; import { CampaignManager } from './src/components/promotions/CampaignManager';
import { isErrorReportingEnabledSync } from './src/services/telemetryService'; import { isErrorReportingEnabledSync } from './src/services/telemetryService';
import { supabaseSyncService } from './src/services/supabaseSyncService';
// Initialize Sentry with privacy-first defaults // Initialize Sentry with privacy-first defaults
// Settings are loaded from telemetryService and can be controlled by user // Settings are loaded from telemetryService and can be controlled by user
@ -180,6 +181,15 @@ const ThemedApp = () => {
const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding'); const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding');
setHasCompletedOnboarding(onboardingCompleted === 'true'); setHasCompletedOnboarding(onboardingCompleted === 'true');
// Initialize Supabase auth/session and start background sync.
// This is intentionally non-blocking for app startup UX.
supabaseSyncService
.initialize()
.then(() => supabaseSyncService.startupSync())
.catch((error) => {
console.warn('[App] Supabase sync bootstrap failed:', error);
});
// Initialize update service // Initialize update service
await UpdateService.initialize(); await UpdateService.initialize();

1254
docs/SUPABASE_SYNC.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -38,11 +38,17 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
user, user,
loading, loading,
signIn: async (email: string, password: string) => { signIn: async (email: string, password: string) => {
const { error } = await accountService.signInWithEmail(email, password); const { user: signedInUser, error } = await accountService.signInWithEmail(email, password);
if (!error && signedInUser) {
setUser(signedInUser);
}
return error || null; return error || null;
}, },
signUp: async (email: string, password: string) => { signUp: async (email: string, password: string) => {
const { error } = await accountService.signUpWithEmail(email, password); const { user: signedUpUser, error } = await accountService.signUpWithEmail(email, password);
if (!error && signedUpUser) {
setUser(signedUpUser);
}
return error || null; return error || null;
}, },
signOut: async () => { signOut: async () => {
@ -107,4 +113,3 @@ export const useAccount = (): AccountContextValue => {
}; };
export default AccountContext; export default AccountContext;

View file

@ -39,6 +39,7 @@ if (Platform.OS === 'ios') {
import HomeScreen from '../screens/HomeScreen'; import HomeScreen from '../screens/HomeScreen';
import LibraryScreen from '../screens/LibraryScreen'; import LibraryScreen from '../screens/LibraryScreen';
import SettingsScreen from '../screens/SettingsScreen'; import SettingsScreen from '../screens/SettingsScreen';
import SyncSettingsScreen from '../screens/SyncSettingsScreen';
import DownloadsScreen from '../screens/DownloadsScreen'; import DownloadsScreen from '../screens/DownloadsScreen';
import MetadataScreen from '../screens/MetadataScreen'; import MetadataScreen from '../screens/MetadataScreen';
import KSPlayerCore from '../components/player/KSPlayerCore'; import KSPlayerCore from '../components/player/KSPlayerCore';
@ -105,6 +106,7 @@ export type RootStackParamList = {
Home: undefined; Home: undefined;
Library: undefined; Library: undefined;
Settings: undefined; Settings: undefined;
SyncSettings: undefined;
Update: undefined; Update: undefined;
Search: undefined; Search: undefined;
Calendar: undefined; Calendar: undefined;
@ -1854,7 +1856,12 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
}, },
}} }}
/> />
</Stack.Navigator> <Stack.Screen
name="SyncSettings"
component={SyncSettingsScreen}
options={{ headerShown: false }}
/>
</Stack.Navigator>
</View> </View>
</PaperProvider> </PaperProvider>
</> </>
@ -1924,7 +1931,6 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C" apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
options={{ options={{
host: "https://us.i.posthog.com", host: "https://us.i.posthog.com",
autocapture: analyticsEnabled,
// Start opted out if analytics is disabled // Start opted out if analytics is disabled
defaultOptIn: analyticsEnabled, defaultOptIn: analyticsEnabled,
}} }}

View file

@ -27,7 +27,6 @@ const AuthScreen: React.FC = () => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [mode, setMode] = useState<'signin' | 'signup'>('signin'); const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const signupDisabled = true; // Signup disabled due to upcoming system replacement
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showWarningDetails, setShowWarningDetails] = useState(false); const [showWarningDetails, setShowWarningDetails] = useState(false);
@ -146,15 +145,6 @@ const AuthScreen: React.FC = () => {
const handleSubmit = async () => { const handleSubmit = async () => {
if (loading) return; if (loading) return;
// Prevent signup if disabled
if (mode === 'signup' && signupDisabled) {
const msg = 'Sign up is currently disabled due to upcoming system changes';
setError(msg);
showError('Sign Up Disabled', 'Sign up is currently disabled due to upcoming system changes');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
if (!isEmailValid) { if (!isEmailValid) {
const msg = 'Enter a valid email address'; const msg = 'Enter a valid email address';
setError(msg); setError(msg);
@ -404,21 +394,17 @@ const AuthScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={styles.switchButton}
styles.switchButton, onPress={() => setMode('signup')}
signupDisabled && styles.disabledButton, activeOpacity={0.8}
]}
onPress={() => !signupDisabled && setMode('signup')}
activeOpacity={signupDisabled ? 1 : 0.8}
disabled={signupDisabled}
> >
<Text style={[ <Text style={[
styles.switchText, styles.switchText,
{ {
color: mode === 'signup' ? '#fff' : (signupDisabled ? 'rgba(255,255,255,0.3)' : currentTheme.colors.textMuted) color: mode === 'signup' ? '#fff' : currentTheme.colors.textMuted
} }
]}> ]}>
Sign Up {signupDisabled && '(Disabled)'} Sign Up
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -583,29 +569,18 @@ const AuthScreen: React.FC = () => {
</Animated.View> </Animated.View>
{/* Switch Mode */} {/* Switch Mode */}
{!signupDisabled && ( <TouchableOpacity
<TouchableOpacity onPress={() => setMode(mode === 'signin' ? 'signup' : 'signin')}
onPress={() => setMode(mode === 'signin' ? 'signup' : 'signin')} activeOpacity={0.7}
activeOpacity={0.7} style={{ marginTop: 16 }}
style={{ marginTop: 16 }} >
> <Text style={[styles.switchModeText, { color: currentTheme.colors.textMuted }]}>
<Text style={[styles.switchModeText, { color: currentTheme.colors.textMuted }]}> {mode === 'signin' ? "Don't have an account? " : 'Already have an account? '}
{mode === 'signin' ? "Don't have an account? " : 'Already have an account? '} <Text style={{ color: currentTheme.colors.primary, fontWeight: '600' }}>
<Text style={{ color: currentTheme.colors.primary, fontWeight: '600' }}> {mode === 'signin' ? 'Sign up' : 'Sign in'}
{mode === 'signin' ? 'Sign up' : 'Sign in'}
</Text>
</Text> </Text>
</TouchableOpacity> </Text>
)} </TouchableOpacity>
{/* Signup disabled message */}
{signupDisabled && mode === 'signin' && (
<View style={{ marginTop: 16, alignItems: 'center' }}>
<Text style={[styles.switchModeText, { color: 'rgba(255,255,255,0.5)', fontSize: 13 }]}>
New account creation is temporarily disabled
</Text>
</View>
)}
{/* Skip sign in - more prominent when coming from onboarding */} {/* Skip sign in - more prominent when coming from onboarding */}
<TouchableOpacity <TouchableOpacity
@ -859,4 +834,3 @@ const styles = StyleSheet.create({
}); });
export default AuthScreen; export default AuthScreen;

View file

@ -361,6 +361,9 @@ const SettingsScreen: React.FC = () => {
if (item && item.visible === false) return false; if (item && item.visible === false) return false;
return true; return true;
}; };
const showTraktItem = isItemVisible('trakt');
const showSimklItem = isItemVisible('simkl');
const showCloudSyncItem = isItemVisible('cloud_sync');
// Filter categories based on conditions // Filter categories based on conditions
const visibleCategories = SETTINGS_CATEGORIES.filter(category => { const visibleCategories = SETTINGS_CATEGORIES.filter(category => {
@ -376,24 +379,35 @@ const SettingsScreen: React.FC = () => {
case 'account': case 'account':
return ( return (
<SettingsCard title={t('settings.sections.account')} isTablet={isTablet}> <SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
{isItemVisible('trakt') && ( {showTraktItem && (
<SettingItem <SettingItem
title={t('trakt.title')} title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')} description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} />} customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')} onPress={() => navigation.navigate('TraktSettings')}
isLast={!isItemVisible('simkl')} isLast={!showSimklItem && !showCloudSyncItem}
isTablet={isTablet} isTablet={isTablet}
/> />
)} )}
{isItemVisible('simkl') && ( {showSimklItem && (
<SettingItem <SettingItem
title={t('settings.items.simkl')} title={t('settings.items.simkl')}
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')} description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
customIcon={<SimklIcon size={isTablet ? 24 : 20} />} customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('SimklSettings')} onPress={() => navigation.navigate('SimklSettings')}
isLast={!showCloudSyncItem}
isTablet={isTablet}
/>
)}
{showCloudSyncItem && (
<SettingItem
title="Nuvio Sync"
description="Sync data across your Nuvio devices"
icon="refresh-cw"
renderControl={() => <ChevronRight />}
onPress={() => (navigation as any).navigate('SyncSettings')}
isLast={true} isLast={true}
isTablet={isTablet} isTablet={isTablet}
/> />
@ -682,25 +696,35 @@ const SettingsScreen: React.FC = () => {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
{/* Account */} {/* Account */}
{(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && ( {(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem) && (
<SettingsCard title={t('settings.account').toUpperCase()}> <SettingsCard title={t('settings.account').toUpperCase()}>
{isItemVisible('trakt') && ( {showTraktItem && (
<SettingItem <SettingItem
title={t('trakt.title')} title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')} description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={20} />} customIcon={<TraktIcon size={20} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')} onPress={() => navigation.navigate('TraktSettings')}
isLast={!isItemVisible('simkl')} isLast={!showSimklItem && !showCloudSyncItem}
/> />
)} )}
{isItemVisible('simkl') && ( {showSimklItem && (
<SettingItem <SettingItem
title={t('settings.items.simkl')} title={t('settings.items.simkl')}
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')} description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
customIcon={<SimklIcon size={20} />} customIcon={<SimklIcon size={20} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('SimklSettings')} onPress={() => navigation.navigate('SimklSettings')}
isLast={!showCloudSyncItem}
/>
)}
{showCloudSyncItem && (
<SettingItem
title="Nuvio Sync"
description="Sync data across your Nuvio devices"
icon="refresh-cw"
renderControl={() => <ChevronRight />}
onPress={() => (navigation as any).navigate('SyncSettings')}
isLast={true} isLast={true}
/> />
)} )}

View file

@ -0,0 +1,467 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
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 { 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 { useAccount } from '../contexts/AccountContext';
const SyncSettingsScreen: React.FC = () => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const insets = useSafeAreaInsets();
const { user, signOut } = useAccount();
const [loading, setLoading] = useState(false);
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 [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([]);
const openAlert = useCallback(
(title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }>) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
setAlertVisible(true);
},
[]
);
const loadSyncState = useCallback(async () => {
setLoading(true);
try {
await supabaseSyncService.initialize();
setSessionUser(supabaseSyncService.getCurrentSessionUser());
const owner = await supabaseSyncService.getEffectiveOwnerId();
setOwnerId(owner);
const devices = await supabaseSyncService.getLinkedDevices();
setLinkedDevices(devices);
} catch (error: any) {
openAlert('Sync Error', error?.message || 'Failed to load sync state');
} finally {
setLoading(false);
}
}, [openAlert]);
useFocusEffect(
useCallback(() => {
loadSyncState();
}, [loadSyncState])
);
const authLabel = useMemo(() => {
if (!supabaseSyncService.isConfigured()) return 'Supabase not configured';
if (!sessionUser) return 'Not authenticated';
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 handleManualSync = async () => {
setSyncCodeLoading(true);
try {
await supabaseSyncService.syncNow();
openAlert('Sync Complete', 'Manual sync completed successfully.');
await loadSyncState();
} catch (error: any) {
openAlert('Sync Failed', error?.message || 'Manual sync failed');
} 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 handleSignOut = async () => {
setSyncCodeLoading(true);
try {
await signOut();
await loadSyncState();
} catch (error: any) {
openAlert('Sign Out Failed', error?.message || 'Failed to sign out');
} finally {
setSyncCodeLoading(false);
}
};
if (loading) {
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Nuvio Sync" showBackButton onBackPress={() => navigation.goBack()} />
<View style={styles.loadingContainer}>
<ActivityIndicator color={currentTheme.colors.primary} size="large" />
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Nuvio Sync" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 24 }]}>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Account</Text>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
{user?.email ? `Signed in as ${user.email}` : 'Not signed in'}
</Text>
<View style={styles.buttonRow}>
{!user ? (
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Account')}
>
<Text style={styles.buttonText}>Sign In / Sign Up</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('AccountManage')}
>
<Text style={styles.buttonText}>Manage Account</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={syncCodeLoading}
style={[styles.button, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleSignOut}
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Connection Status</Text>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>{authLabel}</Text>
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
Effective owner: {ownerId || 'Unavailable'}
</Text>
{!supabaseSyncService.isConfigured() && (
<Text style={[styles.warning, { color: '#ffb454' }]}>
Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to enable sync.
</Text>
)}
</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}
</Text>
)}
<View style={styles.buttonRow}>
<TouchableOpacity
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleGenerateCode}
>
<Text style={styles.buttonText}>Generate Code</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
style={[styles.button, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleGetCode}
>
<Text style={styles.buttonText}>Get Existing Code</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
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
content: {
padding: 16,
gap: 16,
},
card: {
borderWidth: 1,
borderRadius: 14,
padding: 14,
gap: 10,
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
},
cardText: {
fontSize: 13,
lineHeight: 18,
},
warning: {
fontSize: 12,
marginTop: 4,
},
input: {
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
},
buttonRow: {
flexDirection: 'row',
gap: 10,
},
button: {
flex: 1,
borderRadius: 10,
minHeight: 42,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 12,
},
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

@ -1,4 +1,6 @@
import { mmkvStorage } from './mmkvStorage'; import { mmkvStorage } from './mmkvStorage';
import { supabaseSyncService, SupabaseUser } from './supabaseSyncService';
import { logger } from '../utils/logger';
export type AuthUser = { export type AuthUser = {
id: string; id: string;
@ -19,23 +21,72 @@ class AccountService {
return AccountService.instance; return AccountService.instance;
} }
private mapSupabaseUser(user: SupabaseUser): AuthUser {
return {
id: user.id,
email: user.email,
displayName: user.user_metadata?.display_name as string | undefined,
avatarUrl: user.user_metadata?.avatar_url as string | undefined,
};
}
private async persistUser(user: AuthUser): Promise<void> {
await mmkvStorage.setItem(USER_DATA_KEY, JSON.stringify(user));
await mmkvStorage.setItem(USER_SCOPE_KEY, 'local');
}
async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> {
// Since signup is disabled, always return error const result = await supabaseSyncService.signUpWithEmail(email, password);
return { error: 'Sign up is currently disabled due to upcoming system changes' }; if (result.error || !result.user) {
return { error: result.error || 'Sign up failed' };
}
const mapped = this.mapSupabaseUser(result.user);
await this.persistUser(mapped);
try {
await supabaseSyncService.onSignUpPushAll();
} catch (error) {
logger.error('[AccountService] Sign-up push-all failed:', error);
}
return { user: mapped };
} }
async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> {
// Since signin is disabled, always return error const result = await supabaseSyncService.signInWithEmail(email, password);
return { error: 'Authentication is currently disabled' }; if (result.error || !result.user) {
return { error: result.error || 'Sign in failed' };
}
const mapped = this.mapSupabaseUser(result.user);
await this.persistUser(mapped);
try {
await supabaseSyncService.onSignInPullAll();
} catch (error) {
logger.error('[AccountService] Sign-in pull-all failed:', error);
}
return { user: mapped };
} }
async signOut(): Promise<void> { async signOut(): Promise<void> {
await supabaseSyncService.signOut();
await mmkvStorage.removeItem(USER_DATA_KEY); await mmkvStorage.removeItem(USER_DATA_KEY);
await mmkvStorage.setItem(USER_SCOPE_KEY, 'local'); await mmkvStorage.setItem(USER_SCOPE_KEY, 'local');
} }
async getCurrentUser(): Promise<AuthUser | null> { async getCurrentUser(): Promise<AuthUser | null> {
try { try {
await supabaseSyncService.initialize();
const sessionUser = supabaseSyncService.getCurrentSessionUser();
if (sessionUser) {
const mapped = this.mapSupabaseUser(sessionUser);
await this.persistUser(mapped);
return mapped;
}
const userData = await mmkvStorage.getItem(USER_DATA_KEY); const userData = await mmkvStorage.getItem(USER_DATA_KEY);
if (!userData) return null; if (!userData) return null;
return JSON.parse(userData); return JSON.parse(userData);
@ -69,4 +120,3 @@ class AccountService {
export const accountService = AccountService.getInstance(); export const accountService = AccountService.getInstance();
export default accountService; export default accountService;

View file

@ -6,6 +6,7 @@ import { Stream } from '../types/streams';
import { cacheService } from './cacheService'; import { cacheService } from './cacheService';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig'; import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig';
import EventEmitter from 'eventemitter3';
const MAX_CONCURRENT_SCRAPERS = 5; const MAX_CONCURRENT_SCRAPERS = 5;
const MAX_INFLIGHT_KEYS = 30; const MAX_INFLIGHT_KEYS = 30;
@ -24,6 +25,12 @@ const VIDEO_CONTENT_TYPES = [
const MAX_PREFLIGHT_SIZE = 50 * 1024 * 1024; const MAX_PREFLIGHT_SIZE = 50 * 1024 * 1024;
export const PLUGIN_SYNC_EVENTS = {
CHANGED: 'changed',
} as const;
const pluginSyncEmitter = new EventEmitter();
// Types for local scrapers // Types for local scrapers
export interface ScraperManifest { export interface ScraperManifest {
name: string; name: string;
@ -176,6 +183,10 @@ class LocalScraperService {
return LocalScraperService.instance; return LocalScraperService.instance;
} }
public getPluginSyncEventEmitter(): EventEmitter {
return pluginSyncEmitter;
}
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
if (this.initialized) return; if (this.initialized) return;
@ -367,6 +378,7 @@ class LocalScraperService {
}; };
this.repositories.set(id, newRepo); this.repositories.set(id, newRepo);
await this.saveRepositories(); await this.saveRepositories();
pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'add_repository', id: newRepo.id });
logger.log('[LocalScraperService] Added repository:', newRepo.name); logger.log('[LocalScraperService] Added repository:', newRepo.name);
return id; return id;
} }
@ -386,6 +398,7 @@ class LocalScraperService {
this.repositoryUrl = updatedRepo.url; this.repositoryUrl = updatedRepo.url;
this.repositoryName = updatedRepo.name; this.repositoryName = updatedRepo.name;
} }
pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'update_repository', id });
logger.log('[LocalScraperService] Updated repository:', updatedRepo.name); logger.log('[LocalScraperService] Updated repository:', updatedRepo.name);
} }
@ -424,6 +437,7 @@ class LocalScraperService {
this.repositories.delete(id); this.repositories.delete(id);
await this.saveRepositories(); await this.saveRepositories();
await this.saveInstalledScrapers(); await this.saveInstalledScrapers();
pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'remove_repository', id });
logger.log('[LocalScraperService] Removed repository:', id); logger.log('[LocalScraperService] Removed repository:', id);
} }
@ -450,6 +464,7 @@ class LocalScraperService {
} }
logger.log('[LocalScraperService] Switched to repository:', repo.name); logger.log('[LocalScraperService] Switched to repository:', repo.name);
pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'set_current_repository', id });
} }
getCurrentRepositoryId(): string { getCurrentRepositoryId(): string {
@ -553,6 +568,7 @@ class LocalScraperService {
this.repositories.set(id, repo); this.repositories.set(id, repo);
await this.saveRepositories(); await this.saveRepositories();
pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'toggle_repository_enabled', id, enabled });
logger.log('[LocalScraperService] Toggled repository', repo.name, 'to', enabled ? 'enabled' : 'disabled'); logger.log('[LocalScraperService] Toggled repository', repo.name, 'to', enabled ? 'enabled' : 'disabled');
} }

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,15 @@ import { storageService } from './storageService';
import { mmkvStorage } from './mmkvStorage'; import { mmkvStorage } from './mmkvStorage';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
export interface LocalWatchedItem {
content_id: string;
content_type: 'movie' | 'series';
title: string;
season: number | null;
episode: number | null;
watched_at: number;
}
/** /**
* WatchedService - Manages "watched" status for movies, episodes, and seasons. * WatchedService - Manages "watched" status for movies, episodes, and seasons.
* Handles both local storage and Trakt sync transparently. * Handles both local storage and Trakt sync transparently.
@ -15,6 +24,8 @@ class WatchedService {
private static instance: WatchedService; private static instance: WatchedService;
private traktService: TraktService; private traktService: TraktService;
private simklService: SimklService; private simklService: SimklService;
private readonly WATCHED_ITEMS_KEY = '@user:local:watched_items';
private watchedSubscribers: Array<() => void> = [];
private constructor() { private constructor() {
this.traktService = TraktService.getInstance(); this.traktService = TraktService.getInstance();
@ -28,6 +39,133 @@ class WatchedService {
return WatchedService.instance; return WatchedService.instance;
} }
private watchedKey(item: Pick<LocalWatchedItem, 'content_id' | 'season' | 'episode'>): string {
return `${item.content_id}::${item.season ?? -1}::${item.episode ?? -1}`;
}
private normalizeWatchedItem(item: LocalWatchedItem): LocalWatchedItem {
return {
content_id: String(item.content_id || ''),
content_type: item.content_type === 'movie' ? 'movie' : 'series',
title: item.title || '',
season: item.season == null ? null : Number(item.season),
episode: item.episode == null ? null : Number(item.episode),
watched_at: Number(item.watched_at || Date.now()),
};
}
private notifyWatchedSubscribers(): void {
if (this.watchedSubscribers.length === 0) return;
this.watchedSubscribers.forEach((cb) => cb());
}
public subscribeToWatchedUpdates(callback: () => void): () => void {
this.watchedSubscribers.push(callback);
return () => {
const index = this.watchedSubscribers.indexOf(callback);
if (index > -1) {
this.watchedSubscribers.splice(index, 1);
}
};
}
private async loadWatchedItems(): Promise<LocalWatchedItem[]> {
try {
const json = await mmkvStorage.getItem(this.WATCHED_ITEMS_KEY);
if (!json) return [];
const parsed = JSON.parse(json);
if (!Array.isArray(parsed)) return [];
const deduped = new Map<string, LocalWatchedItem>();
parsed.forEach((raw) => {
if (!raw || typeof raw !== 'object') return;
const normalized = this.normalizeWatchedItem(raw as LocalWatchedItem);
if (!normalized.content_id) return;
const key = this.watchedKey(normalized);
const existing = deduped.get(key);
if (!existing || normalized.watched_at > existing.watched_at) {
deduped.set(key, normalized);
}
});
return Array.from(deduped.values());
} catch (error) {
logger.error('[WatchedService] Failed to load local watched items:', error);
return [];
}
}
private async saveWatchedItems(items: LocalWatchedItem[]): Promise<void> {
try {
await mmkvStorage.setItem(this.WATCHED_ITEMS_KEY, JSON.stringify(items));
} catch (error) {
logger.error('[WatchedService] Failed to save local watched items:', error);
}
}
public async getAllWatchedItems(): Promise<LocalWatchedItem[]> {
return await this.loadWatchedItems();
}
private async upsertLocalWatchedItems(items: LocalWatchedItem[]): Promise<void> {
if (items.length === 0) return;
const current = await this.loadWatchedItems();
const byKey = new Map<string, LocalWatchedItem>(
current.map((item) => [this.watchedKey(item), item])
);
let changed = false;
for (const raw of items) {
const normalized = this.normalizeWatchedItem(raw);
if (!normalized.content_id) continue;
const key = this.watchedKey(normalized);
const existing = byKey.get(key);
if (!existing || normalized.watched_at > existing.watched_at || (normalized.title && normalized.title !== existing.title)) {
byKey.set(key, normalized);
changed = true;
}
}
if (changed) {
await this.saveWatchedItems(Array.from(byKey.values()));
this.notifyWatchedSubscribers();
}
}
private async removeLocalWatchedItems(items: Array<Pick<LocalWatchedItem, 'content_id' | 'season' | 'episode'>>): Promise<void> {
if (items.length === 0) return;
const current = await this.loadWatchedItems();
const toRemove = new Set(items.map((item) => this.watchedKey({ content_id: item.content_id, season: item.season ?? null, episode: item.episode ?? null })));
const filtered = current.filter((item) => !toRemove.has(this.watchedKey(item)));
if (filtered.length !== current.length) {
await this.saveWatchedItems(filtered);
this.notifyWatchedSubscribers();
}
}
public async mergeRemoteWatchedItems(items: LocalWatchedItem[]): Promise<void> {
const normalized = items
.map((item) => this.normalizeWatchedItem(item))
.filter((item) => Boolean(item.content_id));
await this.upsertLocalWatchedItems(normalized);
for (const item of normalized) {
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 * Mark a movie as watched
* @param imdbId - The IMDb ID of the movie * @param imdbId - The IMDb ID of the movie
@ -59,6 +197,16 @@ class WatchedService {
// Also store locally as "completed" (100% progress) // Also store locally as "completed" (100% progress)
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt); await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
await this.upsertLocalWatchedItems([
{
content_id: imdbId,
content_type: 'movie',
title: imdbId,
season: null,
episode: null,
watched_at: watchedAt.getTime(),
},
]);
return { success: true, syncedToTrakt }; return { success: true, syncedToTrakt };
} catch (error) { } catch (error) {
@ -119,6 +267,16 @@ class WatchedService {
// Store locally as "completed" // Store locally as "completed"
const episodeId = `${showId}:${season}:${episode}`; const episodeId = `${showId}:${season}:${episode}`;
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
await this.upsertLocalWatchedItems([
{
content_id: showImdbId,
content_type: 'series',
title: showImdbId,
season,
episode,
watched_at: watchedAt.getTime(),
},
]);
return { success: true, syncedToTrakt }; return { success: true, syncedToTrakt };
} catch (error) { } catch (error) {
@ -188,6 +346,17 @@ class WatchedService {
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
} }
await this.upsertLocalWatchedItems(
episodes.map((ep) => ({
content_id: showImdbId,
content_type: 'series' as const,
title: showImdbId,
season: ep.season,
episode: ep.episode,
watched_at: watchedAt.getTime(),
}))
);
return { success: true, syncedToTrakt, count: episodes.length }; return { success: true, syncedToTrakt, count: episodes.length };
} catch (error) { } catch (error) {
logger.error('[WatchedService] Failed to mark episodes as watched:', error); logger.error('[WatchedService] Failed to mark episodes as watched:', error);
@ -231,7 +400,6 @@ class WatchedService {
const isSimklAuth = await this.simklService.isAuthenticated(); const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) { if (isSimklAuth) {
// Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it // Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it
// We know the episodeNumbers from the arguments!
const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() })); const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() }));
await this.simklService.addToHistory({ await this.simklService.addToHistory({
shows: [{ shows: [{
@ -251,6 +419,17 @@ class WatchedService {
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
} }
await this.upsertLocalWatchedItems(
episodeNumbers.map((episode) => ({
content_id: showImdbId,
content_type: 'series' as const,
title: showImdbId,
season,
episode,
watched_at: watchedAt.getTime(),
}))
);
return { success: true, syncedToTrakt, count: episodeNumbers.length }; return { success: true, syncedToTrakt, count: episodeNumbers.length };
} catch (error) { } catch (error) {
logger.error('[WatchedService] Failed to mark season as watched:', error); logger.error('[WatchedService] Failed to mark season as watched:', error);
@ -285,6 +464,9 @@ class WatchedService {
// Remove local progress // Remove local progress
await storageService.removeWatchProgress(imdbId, 'movie'); await storageService.removeWatchProgress(imdbId, 'movie');
await mmkvStorage.removeItem(`watched:movie:${imdbId}`); await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
await this.removeLocalWatchedItems([
{ content_id: imdbId, season: null, episode: null },
]);
return { success: true, syncedToTrakt }; return { success: true, syncedToTrakt };
} catch (error) { } catch (error) {
@ -335,6 +517,9 @@ class WatchedService {
// Remove local progress // Remove local progress
const episodeId = `${showId}:${season}:${episode}`; const episodeId = `${showId}:${season}:${episode}`;
await storageService.removeWatchProgress(showId, 'series', episodeId); await storageService.removeWatchProgress(showId, 'series', episodeId);
await this.removeLocalWatchedItems([
{ content_id: showImdbId, season, episode },
]);
return { success: true, syncedToTrakt }; return { success: true, syncedToTrakt };
} catch (error) { } catch (error) {
@ -368,10 +553,6 @@ class WatchedService {
showImdbId, showImdbId,
season season
); );
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
showImdbId,
season
);
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
} }
@ -397,6 +578,14 @@ class WatchedService {
await storageService.removeWatchProgress(showId, 'series', episodeId); await storageService.removeWatchProgress(showId, 'series', episodeId);
} }
await this.removeLocalWatchedItems(
episodeNumbers.map((episode) => ({
content_id: showImdbId,
season,
episode,
}))
);
return { success: true, syncedToTrakt, count: episodeNumbers.length }; return { success: true, syncedToTrakt, count: episodeNumbers.length };
} catch (error) { } catch (error) {
logger.error('[WatchedService] Failed to unmark season as watched:', error); logger.error('[WatchedService] Failed to unmark season as watched:', error);