mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
acc sync init
This commit is contained in:
parent
8a4aa64074
commit
e27b6de202
12 changed files with 3217 additions and 77 deletions
12
App.tsx
12
App.tsx
|
|
@ -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();
|
||||||
|
|
||||||
|
|
@ -314,4 +324,4 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Sentry.wrap(App);
|
export default Sentry.wrap(App);
|
||||||
|
|
|
||||||
1254
docs/SUPABASE_SYNC.md
Normal file
1254
docs/SUPABASE_SYNC.md
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -145,16 +144,7 @@ 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1211,4 +1235,4 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SettingsScreen;
|
export default SettingsScreen;
|
||||||
|
|
|
||||||
467
src/screens/SyncSettingsScreen.tsx
Normal file
467
src/screens/SyncSettingsScreen.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1777,4 +1793,4 @@ class LocalScraperService {
|
||||||
|
|
||||||
export const localScraperService = LocalScraperService.getInstance();
|
export const localScraperService = LocalScraperService.getInstance();
|
||||||
export const pluginService = localScraperService; // Alias for UI consistency
|
export const pluginService = localScraperService; // Alias for UI consistency
|
||||||
export default localScraperService;
|
export default localScraperService;
|
||||||
|
|
|
||||||
1145
src/services/supabaseSyncService.ts
Normal file
1145
src/services/supabaseSyncService.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,10 +4,19 @@ 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.
|
||||||
*
|
*
|
||||||
* When Trakt is authenticated, it syncs to Trakt.
|
* When Trakt is authenticated, it syncs to Trakt.
|
||||||
* When not authenticated, it stores locally.
|
* When not authenticated, it stores locally.
|
||||||
*/
|
*/
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue