added account support, Initial Commit

This commit is contained in:
tapframe 2025-08-09 00:07:10 +05:30
parent b1afaa3d53
commit 86f0fde656
18 changed files with 2100 additions and 98 deletions

16
.cursor/mcp.json Normal file
View file

@ -0,0 +1,16 @@
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--project-ref=utypxyhwcekefvhyguxp"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "sbp_a6d99ace66d78d514d6435c4eb58ea9ff6f9d6c7"
}
}
}
}

122
package-lock.json generated
View file

@ -20,6 +20,7 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "^6.15.1",
"@shopify/flash-list": "^2.0.2",
"@supabase/supabase-js": "^2.54.0",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.11.0",
@ -46,6 +47,7 @@
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "^1.11.0",
"react-native-image-colors": "^2.5.0",
"react-native-immersive-mode": "^2.0.2",
"react-native-paper": "^5.13.1",
@ -53,6 +55,7 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
"react-native-vlc-media-player": "^1.0.87",
"react-native-wheel-color-picker": "^1.3.1"
@ -4432,6 +4435,80 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.71.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.19.4",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.0.tgz",
"integrity": "sha512-SEIWApsxyoAe68WU2/5PCCuBwa11LL4Bb8K3r2FHCt3ROpaTthmDiWEhnLMGayP05N4QeYrMk0kyTZOwid/Hjw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.10.4.tgz",
"integrity": "sha512-cvL02GarJVFcNoWe36VBybQqTVRq6wQSOCvTS64C+eyuxOruFIm1utZAY0xi2qKtHJO3EjKaj8iWJKySusDmAQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.54.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.54.0.tgz",
"integrity": "sha512-DLw83YwBfAaFiL3oWV26+sHRdeCGtxmIKccjh/Pndze3BWM4fZghzYKhk3ElOQU8Bluq4AkkCJ5bM5Szl/sfRg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.71.1",
"@supabase/functions-js": "2.4.5",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.19.4",
"@supabase/realtime-js": "2.15.0",
"@supabase/storage-js": "^2.10.4"
}
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@ -4998,6 +5075,12 @@
"@types/node": "*"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@ -5055,6 +5138,15 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@ -8043,6 +8135,12 @@
"license": "MIT",
"optional": true
},
"node_modules/fast-base64-decode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
"integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -12346,6 +12444,18 @@
"react-native": "*"
}
},
"node_modules/react-native-get-random-values": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz",
"integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==",
"license": "MIT",
"dependencies": {
"fast-base64-decode": "^1.0.0"
},
"peerDependencies": {
"react-native": ">=0.56"
}
},
"node_modules/react-native-image-colors": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/react-native-image-colors/-/react-native-image-colors-2.5.0.tgz",
@ -12520,6 +12630,18 @@
"react-native-svg": ">=12.0.0"
}
},
"node_modules/react-native-url-polyfill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
"integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==",
"license": "MIT",
"dependencies": {
"whatwg-url-without-unicode": "8.0.0-3"
},
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/react-native-vector-icons": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz",

View file

@ -21,6 +21,7 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "^6.15.1",
"@shopify/flash-list": "^2.0.2",
"@supabase/supabase-js": "^2.54.0",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.11.0",
@ -47,6 +48,7 @@
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "^1.11.0",
"react-native-image-colors": "^2.5.0",
"react-native-immersive-mode": "^2.0.2",
"react-native-paper": "^5.13.1",
@ -54,6 +56,7 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
"react-native-vlc-media-player": "^1.0.87",
"react-native-wheel-color-picker": "^1.3.1"

View file

@ -66,11 +66,9 @@ const { width } = Dimensions.get('window');
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
// Function to validate IMDB ID format
const isValidImdbId = (id: string): boolean => {
// IMDB IDs should start with 'tt' followed by 7-10 digits
const imdbPattern = /^tt\d{7,10}$/;
return imdbPattern.test(id);
// Allow any known id formats (imdb 'tt...', kitsu 'kitsu:...', tmdb 'tmdb:...', or others)
const isSupportedId = (id: string): boolean => {
return typeof id === 'string' && id.length > 0;
};
// Function to check if an episode has been released
@ -181,10 +179,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Second pass: process each content group with batched API calls
const contentPromises = Object.values(contentGroups).map(async (group) => {
try {
// Validate IMDB ID format before attempting to fetch
if (!isValidImdbId(group.id)) {
return;
}
// Allow any ID; meta resolution will try Cinemeta first, then other addons
if (!isSupportedId(group.id)) return;
// Get metadata once per content
const cachedData = await getCachedMetadata(group.type, group.id);

View file

@ -0,0 +1,89 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import accountService, { AuthUser } from '../services/AccountService';
import supabase from '../services/supabaseClient';
import syncService from '../services/SyncService';
type AccountContextValue = {
user: AuthUser | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<string | null>;
signUp: (email: string, password: string) => Promise<string | null>;
signOut: () => Promise<void>;
};
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Initial session
(async () => {
const u = await accountService.getCurrentUser();
setUser(u);
setLoading(false);
syncService.init();
if (u) {
await syncService.migrateLocalScopeToUser();
await syncService.subscribeRealtime();
await Promise.all([
syncService.fullPull(),
syncService.fullPush(),
]);
}
})();
// Auth state listener
const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => {
const u = session?.user ? { id: session.user.id, email: session.user.email ?? undefined } : null;
setUser(u);
if (u) {
await syncService.migrateLocalScopeToUser();
await syncService.subscribeRealtime();
await Promise.all([
syncService.fullPull(),
syncService.fullPush(),
]);
} else {
syncService.unsubscribeRealtime();
}
});
return () => {
subscription.subscription.unsubscribe();
};
}, []);
const value = useMemo<AccountContextValue>(() => ({
user,
loading,
signIn: async (email: string, password: string) => {
const { error } = await accountService.signInWithEmail(email, password);
return error || null;
},
signUp: async (email: string, password: string) => {
const { error } = await accountService.signUpWithEmail(email, password);
return error || null;
},
signOut: async () => {
await accountService.signOut();
setUser(null);
}
}), [user, loading]);
return (
<AccountContext.Provider value={value}>
{children}
</AccountContext.Provider>
);
};
export const useAccount = (): AccountContextValue => {
const ctx = useContext(AccountContext);
if (!ctx) throw new Error('useAccount must be used within AccountProvider');
return ctx;
};
export default AccountContext;

View file

@ -1,5 +1,6 @@
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { settingsEmitter } from '../hooks/useSettings';
import { colors as defaultColors } from '../styles/colors';
// Define the Theme interface
@ -154,7 +155,7 @@ interface ThemeContextProps {
// Create the context
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);
// Storage keys
// Storage keys (kept for backward compatibility). Primary source of truth is app_settings
const CURRENT_THEME_KEY = 'current_theme';
const CUSTOM_THEMES_KEY = 'custom_themes';
@ -163,34 +164,29 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [currentTheme, setCurrentThemeState] = useState<Theme>(DEFAULT_THEMES[0]);
const [availableThemes, setAvailableThemes] = useState<Theme[]>(DEFAULT_THEMES);
// Load themes from AsyncStorage on mount
// Load themes from app_settings (scoped), with legacy fallbacks
useEffect(() => {
const loadThemes = async () => {
try {
// Load current theme ID
const savedThemeId = await AsyncStorage.getItem(CURRENT_THEME_KEY);
// Load custom themes
const customThemesJson = await AsyncStorage.getItem(CUSTOM_THEMES_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const appSettingsJson = await AsyncStorage.getItem(`@user:${scope}:app_settings`);
const appSettings = appSettingsJson ? JSON.parse(appSettingsJson) : {};
const savedThemeId = appSettings.themeId || (await AsyncStorage.getItem(CURRENT_THEME_KEY));
const customThemesJson = appSettings.customThemes ? JSON.stringify(appSettings.customThemes) : await AsyncStorage.getItem(CUSTOM_THEMES_KEY);
const customThemes = customThemesJson ? JSON.parse(customThemesJson) : [];
// Combine default and custom themes
const allThemes = [...DEFAULT_THEMES, ...customThemes];
setAvailableThemes(allThemes);
// Set current theme
if (savedThemeId) {
const theme = allThemes.find(t => t.id === savedThemeId);
if (theme) {
setCurrentThemeState(theme);
}
if (theme) setCurrentThemeState(theme);
}
} catch (error) {
console.error('Failed to load themes:', error);
}
};
loadThemes();
// Stop live refresh from remote; only refresh on app restart or local changes
return () => {};
}, []);
// Set current theme
@ -198,7 +194,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const theme = availableThemes.find(t => t.id === themeId);
if (theme) {
setCurrentThemeState(theme);
// Persist into scoped app_settings and legacy key for backward compat
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const key = `@user:${scope}:app_settings`;
let settings = {} as any;
try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {}
settings.themeId = themeId;
await AsyncStorage.setItem(key, JSON.stringify(settings));
await AsyncStorage.setItem(CURRENT_THEME_KEY, themeId);
// Do not emit global settings sync for themes (sync on app restart only)
}
};
@ -220,7 +224,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const updatedCustomThemes = [...customThemes, newTheme];
const updatedAllThemes = [...DEFAULT_THEMES, ...updatedCustomThemes];
// Save to storage
// Save to storage (scoped app_settings + legacy key)
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const key = `@user:${scope}:app_settings`;
let settings = {} as any;
try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {}
settings.customThemes = updatedCustomThemes;
await AsyncStorage.setItem(key, JSON.stringify(settings));
await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updatedCustomThemes));
// Update state
@ -229,6 +239,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Set as current theme
setCurrentThemeState(newTheme);
await AsyncStorage.setItem(CURRENT_THEME_KEY, id);
// Do not emit global settings sync for themes
} catch (error) {
console.error('Failed to add custom theme:', error);
}
@ -250,7 +261,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Update available themes
const updatedAllThemes = [...DEFAULT_THEMES, ...updatedCustomThemes];
// Save to storage
// Save to storage (scoped app_settings + legacy key)
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const key = `@user:${scope}:app_settings`;
let settings = {} as any;
try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {}
settings.customThemes = updatedCustomThemes;
await AsyncStorage.setItem(key, JSON.stringify(settings));
await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updatedCustomThemes));
// Update state
@ -260,6 +277,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
if (currentTheme.id === updatedTheme.id) {
setCurrentThemeState(updatedTheme);
}
// Do not emit global settings sync for themes
} catch (error) {
console.error('Failed to update custom theme:', error);
}
@ -279,7 +297,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const customThemes = availableThemes.filter(t => t.isEditable && t.id !== themeId);
const updatedAllThemes = [...DEFAULT_THEMES, ...customThemes];
// Save to storage
// Save to storage (scoped app_settings + legacy key)
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const key = `@user:${scope}:app_settings`;
let settings = {} as any;
try { settings = JSON.parse((await AsyncStorage.getItem(key)) || '{}'); } catch {}
settings.customThemes = customThemes;
await AsyncStorage.setItem(key, JSON.stringify(settings));
await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(customThemes));
// Update state
@ -290,6 +314,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setCurrentThemeState(DEFAULT_THEMES[0]);
await AsyncStorage.setItem(CURRENT_THEME_KEY, DEFAULT_THEMES[0].id);
}
// Do not emit global settings sync for themes
} catch (error) {
console.error('Failed to delete custom theme:', error);
}

View file

@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { StreamingContent } from '../types/metadata';
import { catalogService } from '../services/catalogService';
const LIBRARY_STORAGE_KEY = 'stremio-library';
const LEGACY_LIBRARY_STORAGE_KEY = 'stremio-library';
export const useLibrary = () => {
const [libraryItems, setLibraryItems] = useState<StreamingContent[]>([]);
@ -13,7 +13,17 @@ export const useLibrary = () => {
const loadLibraryItems = useCallback(async () => {
try {
setLoading(true);
const storedItems = await AsyncStorage.getItem(LIBRARY_STORAGE_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:stremio-library`;
let storedItems = await AsyncStorage.getItem(scopedKey);
if (!storedItems) {
// migrate legacy into scoped
const legacy = await AsyncStorage.getItem(LEGACY_LIBRARY_STORAGE_KEY);
if (legacy) {
await AsyncStorage.setItem(scopedKey, legacy);
storedItems = legacy;
}
}
if (storedItems) {
const parsedItems = JSON.parse(storedItems);
@ -40,8 +50,11 @@ export const useLibrary = () => {
acc[`${item.type}:${item.id}`] = item;
return acc;
}, {} as Record<string, StreamingContent>);
await AsyncStorage.setItem(LIBRARY_STORAGE_KEY, JSON.stringify(itemsObject));
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:stremio-library`;
await AsyncStorage.setItem(scopedKey, JSON.stringify(itemsObject));
// keep legacy for backward-compat
await AsyncStorage.setItem(LEGACY_LIBRARY_STORAGE_KEY, JSON.stringify(itemsObject));
} catch (error) {
console.error('Error saving library items:', error);
}
@ -49,18 +62,33 @@ export const useLibrary = () => {
// Add item to library
const addToLibrary = useCallback(async (item: StreamingContent) => {
const updatedItems = [...libraryItems, { ...item, inLibrary: true }];
setLibraryItems(updatedItems);
await saveLibraryItems(updatedItems);
return true;
try {
await catalogService.addToLibrary({ ...item, inLibrary: true });
return true;
} catch (e) {
console.error('Error adding to library via catalogService:', e);
// Fallback local write
const updatedItems = [...libraryItems, { ...item, inLibrary: true }];
setLibraryItems(updatedItems);
await saveLibraryItems(updatedItems);
return true;
}
}, [libraryItems, saveLibraryItems]);
// Remove item from library
const removeFromLibrary = useCallback(async (id: string) => {
const updatedItems = libraryItems.filter(item => item.id !== id);
setLibraryItems(updatedItems);
await saveLibraryItems(updatedItems);
return true;
try {
const type = libraryItems.find(i => i.id === id)?.type || 'movie';
await catalogService.removeFromLibrary(type, id);
return true;
} catch (e) {
console.error('Error removing from library via catalogService:', e);
// Fallback local write
const updatedItems = libraryItems.filter(item => item.id !== id);
setLibraryItems(updatedItems);
await saveLibraryItems(updatedItems);
return true;
}
}, [libraryItems, saveLibraryItems]);
// Toggle item in library

View file

@ -20,6 +20,13 @@ class SettingsEventEmitter {
// Singleton instance for app-wide access
export const settingsEmitter = new SettingsEventEmitter();
export interface CustomThemeDef {
id: string;
name: string;
colors: { primary: string; secondary: string; darkBackground: string };
isEditable: boolean;
}
export interface AppSettings {
enableDarkMode: boolean;
enableNotifications: boolean;
@ -48,6 +55,9 @@ export interface AppSettings {
excludedQualities: string[]; // Array of quality strings to exclude (e.g., ['2160p', '4K', '1080p', '720p'])
// Playback behavior
alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85%
// Theme settings
themeId: string;
customThemes: CustomThemeDef[];
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -78,6 +88,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
excludedQualities: [], // No qualities excluded by default
// Playback behavior defaults
alwaysResume: false,
// Theme defaults
themeId: 'default',
customThemes: [],
};
const SETTINGS_STORAGE_KEY = 'app_settings';
@ -98,7 +111,8 @@ export const useSettings = () => {
const loadSettings = async () => {
try {
const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const storedSettings = await AsyncStorage.getItem(`@user:${scope}:${SETTINGS_STORAGE_KEY}`);
if (storedSettings) {
const parsedSettings = JSON.parse(storedSettings);
// Merge with defaults to ensure all properties exist
@ -118,7 +132,8 @@ export const useSettings = () => {
) => {
const newSettings = { ...settings, [key]: value };
try {
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
await AsyncStorage.setItem(`@user:${scope}:${SETTINGS_STORAGE_KEY}`, JSON.stringify(newSettings));
setSettings(newSettings);
console.log(`Setting updated: ${key}`, value);

View file

@ -39,6 +39,8 @@ import LogoSourceSettings from '../screens/LogoSourceSettings';
import ThemeScreen from '../screens/ThemeScreen';
import ProfilesScreen from '../screens/ProfilesScreen';
import OnboardingScreen from '../screens/OnboardingScreen';
import AuthScreen from '../screens/AuthScreen';
import { AccountProvider, useAccount } from '../contexts/AccountContext';
import PluginsScreen from '../screens/PluginsScreen';
// Stack navigator types
@ -654,8 +656,9 @@ const customFadeInterpolator = ({ current, layouts }: any) => {
};
// Stack Navigator
const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => {
const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => {
const { currentTheme } = useTheme();
const { user, loading } = useAccount();
// Handle Android-specific optimizations
useEffect(() => {
@ -713,6 +716,17 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
}),
}}
>
{!loading && !user && (
<Stack.Screen
name="Account"
component={AuthScreen as any}
options={{
headerShown: false,
animation: 'fade',
contentStyle: { backgroundColor: currentTheme.colors.darkBackground },
}}
/>
)}
<Stack.Screen
name="Onboarding"
component={OnboardingScreen}
@ -771,7 +785,7 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
name="Player"
component={VideoPlayer as any}
options={{
animation: Platform.OS === 'android' ? 'none' : 'fade',
animation: Platform.OS === 'android' ? 'none' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 0 : 300,
// Force fullscreen presentation on iPad
presentation: Platform.OS === 'ios' ? 'fullScreenModal' : 'card',
@ -1049,4 +1063,10 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
);
};
const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => (
<AccountProvider>
<InnerNavigator initialRouteName={initialRouteName} />
</AccountProvider>
);
export default AppNavigator;

647
src/screens/AuthScreen.tsx Normal file
View file

@ -0,0 +1,647 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useAccount } from '../contexts/AccountContext';
import { useNavigation } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width, height } = Dimensions.get('window');
const AuthScreen: React.FC = () => {
const { currentTheme } = useTheme();
const { signIn, signUp } = useAccount();
const navigation = useNavigation<any>();
const insets = useSafeAreaInsets();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Subtle, performant animations
const introOpacity = useRef(new Animated.Value(0)).current;
const introTranslateY = useRef(new Animated.Value(10)).current;
const cardOpacity = useRef(new Animated.Value(0)).current;
const cardTranslateY = useRef(new Animated.Value(12)).current;
const ctaScale = useRef(new Animated.Value(1)).current;
const titleOpacity = useRef(new Animated.Value(1)).current;
const titleTranslateY = useRef(new Animated.Value(0)).current;
const ctaTextOpacity = useRef(new Animated.Value(1)).current;
const ctaTextTranslateY = useRef(new Animated.Value(0)).current;
const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup
const [switchWidth, setSwitchWidth] = useState(0);
const toastOpacity = useRef(new Animated.Value(0)).current;
const toastTranslateY = useRef(new Animated.Value(16)).current;
const [toast, setToast] = useState<{ visible: boolean; message: string; type: 'success' | 'error' | 'info' }>({ visible: false, message: '', type: 'info' });
const [headerHeight, setHeaderHeight] = useState(0);
const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden
useEffect(() => {
Animated.parallel([
Animated.timing(introOpacity, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(introTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(cardOpacity, {
toValue: 1,
duration: 360,
delay: 90,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(cardTranslateY, {
toValue: 0,
duration: 360,
delay: 90,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}, [cardOpacity, cardTranslateY, introOpacity, introTranslateY]);
// Animate on mode change
useEffect(() => {
Animated.timing(modeAnim, {
toValue: mode === 'signin' ? 0 : 1,
duration: 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
Animated.sequence([
Animated.parallel([
Animated.timing(titleOpacity, { toValue: 0, duration: 120, useNativeDriver: true }),
Animated.timing(titleTranslateY, { toValue: -6, duration: 120, useNativeDriver: true }),
Animated.timing(ctaTextOpacity, { toValue: 0, duration: 120, useNativeDriver: true }),
Animated.timing(ctaTextTranslateY, { toValue: -4, duration: 120, useNativeDriver: true }),
]),
Animated.parallel([
Animated.timing(titleOpacity, { toValue: 1, duration: 180, useNativeDriver: true }),
Animated.timing(titleTranslateY, { toValue: 0, duration: 180, useNativeDriver: true }),
Animated.timing(ctaTextOpacity, { toValue: 1, duration: 180, useNativeDriver: true }),
Animated.timing(ctaTextTranslateY, { toValue: 0, duration: 180, useNativeDriver: true }),
]),
]).start();
}, [mode, ctaTextOpacity, ctaTextTranslateY, modeAnim, titleOpacity, titleTranslateY]);
// Hide/show header when keyboard toggles
useEffect(() => {
const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const onShow = () => {
Animated.timing(headerHideAnim, {
toValue: 1,
duration: 180,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
};
const onHide = () => {
Animated.timing(headerHideAnim, {
toValue: 0,
duration: 180,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
};
const subShow = Keyboard.addListener(showEvt, onShow);
const subHide = Keyboard.addListener(hideEvt, onHide);
return () => {
subShow.remove();
subHide.remove();
};
}, [headerHideAnim]);
const isEmailValid = useMemo(() => /\S+@\S+\.\S+/.test(email.trim()), [email]);
const isPasswordValid = useMemo(() => password.length >= 6, [password]);
const canSubmit = isEmailValid && isPasswordValid;
const handleSubmit = async () => {
if (loading) return;
if (!isEmailValid) {
const msg = 'Enter a valid email address';
setError(msg);
showToast(msg, 'error');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
if (!isPasswordValid) {
const msg = 'Password must be at least 6 characters';
setError(msg);
showToast(msg, 'error');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
setLoading(true);
setError(null);
const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password);
if (err) {
setError(err);
showToast(err, 'error');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
} else {
const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful';
showToast(msg, 'success');
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
}
setLoading(false);
};
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
setToast({ visible: true, message, type });
toastOpacity.setValue(0);
toastTranslateY.setValue(16);
Animated.parallel([
Animated.timing(toastOpacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(toastTranslateY, { toValue: 0, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
]).start(() => {
setTimeout(() => {
Animated.parallel([
Animated.timing(toastOpacity, { toValue: 0, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(toastTranslateY, { toValue: 16, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
]).start(() => setToast(prev => ({ ...prev, visible: false })));
}, 2200);
});
};
return (
<View style={{ flex: 1 }}>
{Platform.OS !== 'android' ? (
<LinearGradient
colors={['#0D1117', '#161B22', '#21262D']}
style={StyleSheet.absoluteFill}
/>
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#0D1117' }]} />
)}
{/* Background Pattern (iOS only) */}
{Platform.OS !== 'android' && (
<View style={styles.backgroundPattern}>
{Array.from({ length: 20 }).map((_, i) => (
<View
key={i}
style={[
styles.patternDot,
{
left: (i % 5) * (width / 4),
top: Math.floor(i / 5) * (height / 4),
opacity: 0.03 + (i % 3) * 0.02,
}
]}
/>
))}
</View>
)}
<SafeAreaView style={{ flex: 1 }}>
{/* Header outside KeyboardAvoidingView to avoid being overlapped */}
<Animated.View
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
style={[
styles.header,
{
opacity: Animated.multiply(
introOpacity,
headerHideAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] })
),
transform: [
{
translateY: Animated.add(
introTranslateY,
headerHideAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -12] })
),
},
],
},
]}
>
{navigation.canGoBack() && (
<TouchableOpacity onPress={() => navigation.goBack()} style={[styles.backButton, Platform.OS === 'android' ? { top: Math.max(insets.top + 6, 18) } : null]} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<MaterialIcons name="arrow-back" size={22} color={currentTheme.colors.white} />
</TouchableOpacity>
)}
<Animated.Text style={[styles.heading, { color: currentTheme.colors.white, opacity: titleOpacity, transform: [{ translateY: titleTranslateY }] }]}>
{mode === 'signin' ? 'Welcome back' : 'Create your account'}
</Animated.Text>
<Text style={[styles.subheading, { color: currentTheme.colors.textMuted }] }>
Sync your addons, progress and settings across devices
</Text>
</Animated.View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0}
>
{/* Main Card */}
<View style={styles.centerContainer}>
<Animated.View style={[styles.card, {
backgroundColor: Platform.OS === 'android' ? '#121212' : 'rgba(255,255,255,0.02)',
borderColor: Platform.OS === 'android' ? '#1f1f1f' : 'rgba(255,255,255,0.06)',
...(Platform.OS !== 'android' ? {
shadowColor: currentTheme.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 20,
} : {}),
opacity: cardOpacity,
transform: [{ translateY: cardTranslateY }],
}]}>
{/* Mode Toggle */}
<View
onLayout={(e) => setSwitchWidth(e.nativeEvent.layout.width)}
style={[styles.switchRow, { backgroundColor: Platform.OS === 'android' ? '#1a1a1a' : 'rgba(255,255,255,0.04)' }]}
>
{/* Animated indicator */}
<Animated.View
pointerEvents="none"
style={[
styles.switchIndicator,
{
width: Math.max(0, (switchWidth - 6) / 2),
backgroundColor: Platform.OS === 'android' ? '#2a2a2a' : currentTheme.colors.primary,
transform: [
{
translateX: modeAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, Math.max(0, (switchWidth - 6) / 2)],
}),
},
],
},
]}
/>
<TouchableOpacity
style={[
styles.switchButton,
]}
onPress={() => setMode('signin')}
activeOpacity={0.8}
>
<Text style={[styles.switchText, { color: mode === 'signin' ? '#fff' : currentTheme.colors.textMuted }]}>
Sign In
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.switchButton,
]}
onPress={() => setMode('signup')}
activeOpacity={0.8}
>
<Text style={[styles.switchText, { color: mode === 'signup' ? '#fff' : currentTheme.colors.textMuted }]}>
Sign Up
</Text>
</TouchableOpacity>
</View>
{/* Email Input */}
<View style={[styles.inputContainer]}>
<View style={[styles.inputRow, {
backgroundColor: Platform.OS === 'android' ? '#1a1a1a' : 'rgba(255,255,255,0.03)',
borderColor: Platform.OS === 'android' ? '#2a2a2a' : (isEmailValid || !email ? 'rgba(255,255,255,0.08)' : 'rgba(255,107,107,0.4)'),
borderWidth: 1,
}]}>
<View style={[styles.iconContainer, { backgroundColor: Platform.OS === 'android' ? '#222' : (isEmailValid ? 'rgba(46,160,67,0.15)' : 'rgba(255,255,255,0.05)') }]}>
<MaterialIcons
name="mail-outline"
size={18}
color={Platform.OS === 'android' ? currentTheme.colors.textMuted : (isEmailValid ? '#2EA043' : currentTheme.colors.textMuted)}
/>
</View>
<TextInput
placeholder="Email address"
placeholderTextColor="rgba(255,255,255,0.4)"
style={[styles.input, { color: currentTheme.colors.white }]}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
returnKeyType="next"
/>
{Platform.OS !== 'android' && isEmailValid && (
<MaterialIcons name="check-circle" size={16} color="#2EA043" style={{ marginRight: 12 }} />
)}
</View>
</View>
{/* Password Input */}
<View style={styles.inputContainer}>
<View style={[styles.inputRow, {
backgroundColor: Platform.OS === 'android' ? '#1a1a1a' : 'rgba(255,255,255,0.03)',
borderColor: Platform.OS === 'android' ? '#2a2a2a' : (isPasswordValid || !password ? 'rgba(255,255,255,0.08)' : 'rgba(255,107,107,0.4)'),
borderWidth: 1,
}]}>
<View style={[styles.iconContainer, { backgroundColor: Platform.OS === 'android' ? '#222' : (isPasswordValid ? 'rgba(46,160,67,0.15)' : 'rgba(255,255,255,0.05)') }]}>
<MaterialIcons
name="lock-outline"
size={18}
color={Platform.OS === 'android' ? currentTheme.colors.textMuted : (isPasswordValid ? '#2EA043' : currentTheme.colors.textMuted)}
/>
</View>
<TextInput
placeholder="Password (min 6 characters)"
placeholderTextColor="rgba(255,255,255,0.4)"
style={[styles.input, { color: currentTheme.colors.white }]}
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
<TouchableOpacity onPress={() => setShowPassword(p => !p)} style={styles.eyeButton}>
<MaterialIcons
name={showPassword ? 'visibility-off' : 'visibility'}
size={16}
color={currentTheme.colors.textMuted}
/>
</TouchableOpacity>
{Platform.OS !== 'android' && isPasswordValid && (
<MaterialIcons name="check-circle" size={16} color="#2EA043" style={{ marginRight: 12 }} />
)}
</View>
</View>
{/* Error */}
{!!error && (
<View style={styles.errorRow}>
<MaterialIcons name="error-outline" size={16} color="#ff6b6b" />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{/* Submit Button */}
<Animated.View style={{ transform: [{ scale: ctaScale }] }}>
<TouchableOpacity
style={[
styles.ctaButton,
{
backgroundColor: canSubmit ? currentTheme.colors.primary : 'rgba(255,255,255,0.08)',
...(Platform.OS !== 'android' ? {
shadowColor: canSubmit ? currentTheme.colors.primary : 'transparent',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: canSubmit ? 0.3 : 0,
shadowRadius: 12,
} : {}),
}
]}
onPress={handleSubmit}
onPressIn={() => {
Animated.spring(ctaScale, {
toValue: 0.98,
useNativeDriver: true,
speed: 20,
bounciness: 0,
}).start();
}}
onPressOut={() => {
Animated.spring(ctaScale, {
toValue: 1,
useNativeDriver: true,
speed: 20,
bounciness: 6,
}).start();
}}
activeOpacity={0.85}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Animated.Text style={[styles.ctaText, { opacity: ctaTextOpacity, transform: [{ translateY: ctaTextTranslateY }] }]}>
{mode === 'signin' ? 'Sign In' : 'Create Account'}
</Animated.Text>
)}
</TouchableOpacity>
</Animated.View>
{/* Switch Mode */}
<TouchableOpacity
onPress={() => setMode(mode === 'signin' ? 'signup' : 'signin')}
activeOpacity={0.7}
style={{ marginTop: 16 }}
>
<Text style={[styles.switchModeText, { color: currentTheme.colors.textMuted }]}>
{mode === 'signin' ? "Don't have an account? " : 'Already have an account? '}
<Text style={{ color: currentTheme.colors.primary, fontWeight: '600' }}>
{mode === 'signin' ? 'Sign up' : 'Sign in'}
</Text>
</Text>
</TouchableOpacity>
</Animated.View>
{/* Toast */}
{toast.visible && (
<Animated.View
pointerEvents="none"
style={[styles.toast, {
opacity: toastOpacity,
transform: [{ translateY: toastTranslateY }],
backgroundColor: toast.type === 'success' ? 'rgba(46,160,67,0.95)' : toast.type === 'error' ? 'rgba(229, 62, 62, 0.95)' : 'rgba(99, 102, 241, 0.95)'
}]}
>
<MaterialIcons name={toast.type === 'success' ? 'check-circle' : toast.type === 'error' ? 'error-outline' : 'info-outline'} size={16} color="#fff" />
<Text style={styles.toastText}>{toast.message}</Text>
</Animated.View>
)}
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</View>
);
};
const styles = StyleSheet.create({
backgroundPattern: {
...StyleSheet.absoluteFillObject,
zIndex: 0,
},
patternDot: {
position: 'absolute',
width: 2,
height: 2,
backgroundColor: '#fff',
borderRadius: 1,
},
header: {
alignItems: 'center',
paddingTop: 64,
paddingBottom: 8,
},
logoContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
logo: {
width: 180,
height: 54,
zIndex: 2,
},
logoGlow: {
position: 'absolute',
width: 200,
height: 70,
backgroundColor: 'rgba(229, 9, 20, 0.1)',
borderRadius: 35,
zIndex: 1,
},
heading: {
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
marginBottom: 4,
},
subheading: {
fontSize: 13,
lineHeight: 18,
textAlign: 'center',
paddingHorizontal: 20,
marginTop: 1,
},
centerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
paddingBottom: 28,
},
card: {
width: '100%',
maxWidth: 400,
padding: 24,
borderRadius: 20,
borderWidth: 1,
elevation: 20,
},
switchRow: {
flexDirection: 'row',
borderRadius: 14,
padding: 3,
marginBottom: 24,
position: 'relative',
overflow: 'hidden',
},
switchIndicator: {
position: 'absolute',
top: 3,
bottom: 3,
left: 3,
borderRadius: 12,
},
switchButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
elevation: 0,
},
switchText: {
fontWeight: '700',
fontSize: 15,
letterSpacing: 0.2,
},
inputContainer: {
marginBottom: 16,
},
inputRow: {
height: 56,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 14,
paddingHorizontal: 4,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
input: {
flex: 1,
fontSize: 16,
paddingVertical: 0,
fontWeight: '500',
},
eyeButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
},
errorRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
paddingHorizontal: 4,
},
errorText: {
color: '#ff6b6b',
marginLeft: 8,
fontSize: 13,
fontWeight: '500',
},
ctaButton: {
height: 56,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
elevation: 0,
},
ctaText: {
color: '#fff',
fontWeight: '700',
fontSize: 16,
letterSpacing: 0.3,
},
backButton: {
position: 'absolute',
left: 16,
top: 8,
},
toast: {
position: 'absolute',
bottom: 24,
left: 20,
right: 20,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
toastText: {
color: '#fff',
fontWeight: '600',
flex: 1,
},
switchModeText: {
textAlign: 'center',
fontSize: 14,
fontWeight: '500',
},
});
export default AuthScreen;

View file

@ -26,6 +26,7 @@ import { stremioService } from '../services/stremioService';
import { useCatalogContext } from '../contexts/CatalogContext';
import { useTraktContext } from '../contexts/TraktContext';
import { useTheme } from '../contexts/ThemeContext';
import { useAccount } from '../contexts/AccountContext';
import { catalogService } from '../services/catalogService';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Sentry from '@sentry/react-native';
@ -135,6 +136,7 @@ const SettingsScreen: React.FC = () => {
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const { user, signOut } = useAccount();
// Add a useEffect to check authentication status on focus
useEffect(() => {
@ -295,6 +297,27 @@ const SettingsScreen: React.FC = () => {
>
{/* Account Section */}
<SettingsCard title="ACCOUNT">
{user ? (
<>
<SettingItem
title={user.email || user.id}
description="Signed in"
icon="account-circle"
/>
<SettingItem
title="Sign out"
icon="logout"
onPress={signOut}
/>
</>
) : (
<SettingItem
title="Sign in / Create account"
description="Sync across devices"
icon="login"
onPress={() => navigation.navigate('Account')}
/>
)}
<SettingItem
title="Trakt"
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"}

View file

@ -0,0 +1,60 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import supabase from './supabaseClient';
export type AuthUser = {
id: string;
email?: string;
};
const USER_SCOPE_KEY = '@user:current';
class AccountService {
private static instance: AccountService;
private constructor() {}
static getInstance(): AccountService {
if (!AccountService.instance) AccountService.instance = new AccountService();
return AccountService.instance;
}
async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> {
const { data, error } = await supabase.auth.signUp({ email, password });
if (error) return { error: error.message };
const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id);
return { user };
}
async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id);
return { user };
}
async signOut(): Promise<void> {
await supabase.auth.signOut();
await AsyncStorage.setItem(USER_SCOPE_KEY, 'local');
}
async getCurrentUser(): Promise<AuthUser | null> {
const { data } = await supabase.auth.getUser();
const u = data.user;
if (!u) return null;
return { id: u.id, email: u.email ?? undefined };
}
async getCurrentUserIdScoped(): Promise<string> {
const user = await this.getCurrentUser();
if (user?.id) return user.id;
// Guest scope
const scope = (await AsyncStorage.getItem(USER_SCOPE_KEY)) || 'local';
if (!scope) await AsyncStorage.setItem(USER_SCOPE_KEY, 'local');
return scope || 'local';
}
}
export const accountService = AccountService.getInstance();
export default accountService;

782
src/services/SyncService.ts Normal file
View file

@ -0,0 +1,782 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import supabase from './supabaseClient';
import accountService from './AccountService';
import { storageService } from './storageService';
import { addonEmitter, ADDON_EVENTS, stremioService } from './stremioService';
import { catalogService, StreamingContent } from './catalogService';
// import localScraperService from './localScraperService';
import { settingsEmitter } from '../hooks/useSettings';
import { logger } from '../utils/logger';
type WatchProgressRow = {
user_id: string;
media_type: string;
media_id: string;
episode_id: string;
current_time_seconds: number;
duration_seconds: number;
last_updated_ms: number;
trakt_synced?: boolean;
trakt_last_synced_ms?: number | null;
trakt_progress_percent?: number | null;
};
const SYNC_QUEUE_KEY = '@sync_queue';
class SyncService {
private static instance: SyncService;
private syncing = false;
private suppressPush = false;
private realtimeChannels: any[] = [];
private pullDebounceTimer: NodeJS.Timeout | null = null;
private addonsPollInterval: NodeJS.Timeout | null = null;
private suppressLibraryPush: boolean = false;
private libraryUnsubscribe: (() => void) | null = null;
static getInstance(): SyncService {
if (!SyncService.instance) SyncService.instance = new SyncService();
return SyncService.instance;
}
init(): void {
// Watch progress updates
storageService.subscribeToWatchProgressUpdates(() => {
if (this.suppressPush) return;
logger.log('[Sync] watch_progress local change → push');
this.pushWatchProgress().catch(() => undefined);
});
storageService.onWatchProgressRemoved((id, type, episodeId) => {
if (this.suppressPush) return;
logger.log(`[Sync] watch_progress removed → soft delete ${type}:${id}:${episodeId || ''}`);
this.softDeleteWatchProgress(type, id, episodeId).catch(() => undefined);
});
// Addon order and changes
addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, () => { logger.log('[Sync] addon order changed → push'); this.pushAddons(); });
addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, () => { logger.log('[Sync] addon added → push'); this.pushAddons(); });
addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, () => { logger.log('[Sync] addon removed → push'); this.pushAddons(); });
// Settings updates: no realtime push; sync only on app restart
logger.log('[Sync] init completed (listeners wired; settings push disabled)');
// Library local change → push
if (this.libraryUnsubscribe) {
try { this.libraryUnsubscribe(); } catch {}
this.libraryUnsubscribe = null;
}
const unsubAdd = catalogService.onLibraryAdd((item) => {
if (this.suppressLibraryPush) return;
logger.log(`[Sync] library add → push ${item.type}:${item.id}`);
this.pushLibraryAdd(item).catch(() => undefined);
});
const unsubRem = catalogService.onLibraryRemove((type, id) => {
if (this.suppressLibraryPush) return;
logger.log(`[Sync] library remove → push ${type}:${id}`);
this.pushLibraryRemove(type, id).catch(() => undefined);
});
this.libraryUnsubscribe = () => { try { unsubAdd(); unsubRem(); } catch {} };
}
subscribeRealtime = async (): Promise<void> => {
const user = await accountService.getCurrentUser();
if (!user) return;
const userId = user.id;
const addChannel = (table: string, handler: (payload: any) => void) => {
const channel = supabase
.channel(`rt-${table}`)
.on('postgres_changes', { event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` }, handler)
.subscribe();
this.realtimeChannels.push(channel);
logger.log(`[Sync] Realtime subscribed: ${table}`);
};
// Watch progress: apply granular updates (ignore self-caused pushes via suppressPush)
addChannel('watch_progress', async (payload) => {
try {
const row = (payload.new || payload.old);
if (!row) return;
const type = row.media_type as string;
const id = row.media_id as string;
const episodeId = (payload.eventType === 'DELETE') ? (row.episode_id || '') : (row.episode_id || '');
this.suppressPush = true;
const deletedAt = (row as any).deleted_at;
if (payload.eventType === 'DELETE' || deletedAt) {
await storageService.removeWatchProgress(id, type, episodeId || undefined);
// Record tombstone with remote timestamp if available
try {
const remoteUpdated = (row as any).updated_at ? new Date((row as any).updated_at).getTime() : Date.now();
await storageService.addWatchProgressTombstone(id, type, episodeId || undefined, remoteUpdated);
} catch {}
} else {
await storageService.setWatchProgress(
id,
type,
{
currentTime: row.current_time_seconds || 0,
duration: row.duration_seconds || 0,
lastUpdated: row.last_updated_ms || Date.now(),
traktSynced: row.trakt_synced ?? undefined,
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
traktProgress: row.trakt_progress_percent ?? undefined,
},
episodeId || undefined
);
}
} catch {}
finally {
this.suppressPush = false;
}
});
const debouncedPull = (payload?: any) => {
if (payload?.table) logger.log(`[Sync][rt] change on ${payload.table} → debounced fullPull`);
if (this.pullDebounceTimer) clearTimeout(this.pullDebounceTimer);
this.pullDebounceTimer = setTimeout(() => {
logger.log('[Sync] fullPull (debounced) start');
this.fullPull()
.then(() => logger.log('[Sync] fullPull (debounced) done'))
.catch((e) => console.warn('[Sync] fullPull (debounced) error', e));
}, 300);
};
// Addons: just re-pull snapshot quickly
addChannel('installed_addons', () => debouncedPull({ table: 'installed_addons' }));
// Library realtime: apply row-level changes
addChannel('user_library', async (payload) => {
try {
const row = (payload.new || payload.old);
if (!row) return;
const mediaType = (row.media_type as string) === 'movie' ? 'movie' : 'series';
const mediaId = row.media_id as string;
this.suppressLibraryPush = true;
const deletedAt = (row as any).deleted_at;
if (payload.eventType === 'DELETE' || deletedAt) {
await catalogService.removeFromLibrary(mediaType, mediaId);
logger.log(`[Sync][rt] user_library DELETE ${mediaType}:${mediaId}`);
} else {
const content: StreamingContent = {
id: mediaId,
type: mediaType,
name: (row.title as string) || mediaId,
poster: (row.poster_url as string) || '',
inLibrary: true,
year: row.year ?? undefined,
} as any;
await catalogService.addToLibrary(content);
logger.log(`[Sync][rt] user_library ${payload.eventType} ${mediaType}:${mediaId}`);
}
} catch (e) {
console.warn('[Sync][rt] user_library handler error', e);
} finally {
this.suppressLibraryPush = false;
}
});
// Excluded: local_scrapers, scraper_repository from realtime sync
logger.log('[Sync] Realtime subscriptions active');
// Fallback polling for addons (in case realtime isn't enabled)
if (this.addonsPollInterval) clearInterval(this.addonsPollInterval);
this.addonsPollInterval = setInterval(async () => {
try {
const u = await accountService.getCurrentUser();
if (!u) return;
// Compare excluding preinstalled
const exclude = new Set(['com.linvo.cinemeta', 'org.stremio.opensubtitlesv3']);
const localIds = new Set(
(await stremioService.getInstalledAddonsAsync())
.map((a: any) => a.id)
.filter((id: string) => !exclude.has(id))
);
const { data: remote } = await supabase
.from('installed_addons')
.select('addon_id')
.eq('user_id', u.id);
const remoteIds = new Set(
((remote || []) as any[])
.map(r => r.addon_id as string)
.filter((id: string) => !exclude.has(id))
);
if (localIds.size !== remoteIds.size) {
logger.log('[Sync][poll] addons mismatch by count → pull snapshot');
await this.pullAddonsSnapshot(u.id);
return;
}
for (const id of remoteIds) {
if (!localIds.has(id)) {
logger.log('[Sync][poll] addons mismatch by set → pull snapshot');
await this.pullAddonsSnapshot(u.id);
break;
}
}
} catch (e) {
// silent
}
}, 14400000);
};
unsubscribeRealtime = (): void => {
try {
logger.log(`[Sync] Realtime unsubscribe (${this.realtimeChannels.length})`);
for (const ch of this.realtimeChannels) {
try { ch.unsubscribe?.(); } catch {}
}
} finally {
this.realtimeChannels = [];
if (this.addonsPollInterval) {
clearInterval(this.addonsPollInterval);
this.addonsPollInterval = null;
}
if (this.libraryUnsubscribe) {
try { this.libraryUnsubscribe(); } catch {}
this.libraryUnsubscribe = null;
}
}
};
async migrateLocalScopeToUser(): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
const userId = user.id;
const keys = await AsyncStorage.getAllKeys();
const migrations: Array<Promise<void>> = [];
const moveKey = async (from: string, to: string) => {
const val = await AsyncStorage.getItem(from);
if (val == null) return;
const exists = await AsyncStorage.getItem(to);
if (!exists) {
await AsyncStorage.setItem(to, val);
} else {
// Prefer the one with newer lastUpdated if JSON
try {
const a = JSON.parse(val);
const b = JSON.parse(exists);
const aLU = a?.lastUpdated ?? 0;
const bLU = b?.lastUpdated ?? 0;
if (aLU > bLU) await AsyncStorage.setItem(to, val);
} catch {
// Keep existing if equal
}
}
await AsyncStorage.removeItem(from);
};
// Watch progress/content durations/subtitles/app settings
for (const k of keys) {
if (k.startsWith('@user:local:@watch_progress:')) {
const suffix = k.replace('@user:local:@watch_progress:', '');
migrations.push(moveKey(k, `@user:${userId}:@watch_progress:${suffix}`));
} else if (k.startsWith('@user:local:@content_duration:')) {
const suffix = k.replace('@user:local:@content_duration:', '');
migrations.push(moveKey(k, `@user:${userId}:@content_duration:${suffix}`));
} else if (k === '@user:local:@subtitle_settings') {
migrations.push(moveKey(k, `@user:${userId}:@subtitle_settings`));
} else if (k === 'app_settings') {
migrations.push(moveKey('app_settings', `@user:${userId}:app_settings`));
} else if (k === '@user:local:app_settings') {
migrations.push(moveKey(k, `@user:${userId}:app_settings`));
} else if (k === '@user:local:stremio-addons') {
migrations.push(moveKey(k, `@user:${userId}:stremio-addons`));
} else if (k === '@user:local:stremio-addon-order') {
migrations.push(moveKey(k, `@user:${userId}:stremio-addon-order`));
} else if (k === '@user:local:local-scrapers') {
migrations.push(moveKey(k, `@user:${userId}:local-scrapers`));
} else if (k === '@user:local:scraper-repository-url') {
migrations.push(moveKey(k, `@user:${userId}:scraper-repository-url`));
} else if (k === '@user:local:stremio-library') {
migrations.push((async () => {
const val = (await AsyncStorage.getItem(k)) || '{}';
await moveKey(k, `@user:${userId}:stremio-library`);
try {
const parsed = JSON.parse(val) as Record<string, any>;
const count = Array.isArray(parsed) ? parsed.length : Object.keys(parsed || {}).length;
if (count > 0) await AsyncStorage.setItem(`@user:${userId}:library_initialized`, 'true');
} catch {}
})());
} else if (k === 'stremio-library') {
migrations.push((async () => {
const val = (await AsyncStorage.getItem(k)) || '{}';
await moveKey(k, `@user:${userId}:stremio-library`);
try {
const parsed = JSON.parse(val) as Record<string, any>;
const count = Array.isArray(parsed) ? parsed.length : Object.keys(parsed || {}).length;
if (count > 0) await AsyncStorage.setItem(`@user:${userId}:library_initialized`, 'true');
} catch {}
})());
}
}
// Migrate legacy theme keys into scoped app_settings
try {
const legacyThemeId = await AsyncStorage.getItem('current_theme');
const legacyCustomThemesJson = await AsyncStorage.getItem('custom_themes');
const scopedSettingsKey = `@user:${userId}:app_settings`;
let scopedSettings: any = {};
try { scopedSettings = JSON.parse((await AsyncStorage.getItem(scopedSettingsKey)) || '{}'); } catch {}
let changed = false;
if (legacyThemeId && scopedSettings.themeId !== legacyThemeId) {
scopedSettings.themeId = legacyThemeId;
changed = true;
}
if (legacyCustomThemesJson) {
const legacyCustomThemes = JSON.parse(legacyCustomThemesJson);
if (Array.isArray(legacyCustomThemes)) {
scopedSettings.customThemes = legacyCustomThemes;
changed = true;
}
}
if (changed) {
await AsyncStorage.setItem(scopedSettingsKey, JSON.stringify(scopedSettings));
}
} catch {}
await Promise.all(migrations);
logger.log(`[Sync] migrateLocalScopeToUser done (moved ~${migrations.length} keys)`);
}
async fullPush(): Promise<void> {
logger.log('[Sync] fullPush start');
await Promise.allSettled([
this.pushWatchProgress(),
// Settings push only at app start/sign-in handled by fullPush itself; keep here OK
this.pushSettings(),
this.pushAddons(),
// Excluded: this.pushLocalScrapers(),
this.pushLibrary(),
]);
logger.log('[Sync] fullPush done');
}
async fullPull(): Promise<void> {
logger.log('[Sync] fullPull start');
const user = await accountService.getCurrentUser();
if (!user) return;
const userId = user.id;
await Promise.allSettled([
(async () => {
logger.log('[Sync] pull watch_progress');
const { data: wp } = await supabase
.from('watch_progress')
.select('*')
.eq('user_id', userId)
.is('deleted_at', null);
if (wp && Array.isArray(wp)) {
const remoteActiveKeys = new Set<string>();
for (const row of wp as any[]) {
await storageService.setWatchProgress(
row.media_id,
row.media_type,
{
currentTime: row.current_time_seconds,
duration: row.duration_seconds,
lastUpdated: row.last_updated_ms,
traktSynced: row.trakt_synced ?? undefined,
traktLastSynced: row.trakt_last_synced_ms ?? undefined,
traktProgress: row.trakt_progress_percent ?? undefined,
},
row.episode_id || undefined
);
remoteActiveKeys.add(`${row.media_type}|${row.media_id}|${row.episode_id || ''}`);
}
// Remove any local progress not present on server (server is source of truth)
try {
const allLocal = await storageService.getAllWatchProgress();
for (const [key] of Object.entries(allLocal)) {
const parts = key.split(':');
const type = parts[0];
const id = parts[1];
const ep = parts[2] || '';
const k = `${type}|${id}|${ep}`;
if (!remoteActiveKeys.has(k)) {
this.suppressPush = true;
await storageService.removeWatchProgress(id, type, ep || undefined);
this.suppressPush = false;
}
}
} catch {}
}
})(),
(async () => {
logger.log('[Sync] pull user_settings');
const { data: us } = await supabase
.from('user_settings')
.select('*')
.eq('user_id', userId)
.single();
if (us) {
await AsyncStorage.setItem(`@user:${userId}:app_settings`, JSON.stringify(us.app_settings || {}));
await AsyncStorage.setItem('app_settings', JSON.stringify(us.app_settings || {}));
await storageService.saveSubtitleSettings(us.subtitle_settings || {});
}
})(),
this.pullAddonsSnapshot(userId),
this.pullLibrary(userId),
]);
logger.log('[Sync] fullPull done');
}
private async pullLibrary(userId: string): Promise<void> {
try {
logger.log('[Sync] pull user_library');
const { data, error } = await supabase
.from('user_library')
.select('media_type, media_id, title, poster_url, year, deleted_at, updated_at')
.eq('user_id', userId);
if (error) {
console.warn('[SyncService] pull library error', error);
return;
}
const obj: Record<string, any> = {};
for (const row of (data || []) as any[]) {
if (row.deleted_at) continue;
const key = `${row.media_type}:${row.media_id}`;
obj[key] = {
id: row.media_id,
type: row.media_type,
name: row.title || row.media_id,
poster: row.poster_url || '',
year: row.year || undefined,
inLibrary: true,
};
}
await AsyncStorage.setItem(`@user:${userId}:stremio-library`, JSON.stringify(obj));
await AsyncStorage.setItem('stremio-library', JSON.stringify(obj));
logger.log(`[Sync] pull user_library wrote items=${Object.keys(obj).length}`);
} catch (e) {
console.warn('[SyncService] pullLibrary exception', e);
}
}
private async pushLibrary(): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
try {
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const json =
(await AsyncStorage.getItem(`@user:${scope}:stremio-library`)) ||
(await AsyncStorage.getItem('stremio-library')) || '{}';
const itemsObj = JSON.parse(json) as Record<string, any>;
const entries = Object.values(itemsObj) as any[];
logger.log(`[Sync] push user_library entries=${entries.length}`);
const initialized = (await AsyncStorage.getItem(`@user:${user.id}:library_initialized`)) === 'true';
// If not initialized and local entries are 0, attempt to import from server first
if (!initialized && entries.length === 0) {
logger.log('[Sync] user_library not initialized and local empty → pulling before deletions');
await this.pullLibrary(user.id);
const post = (await AsyncStorage.getItem(`@user:${user.id}:stremio-library`)) || '{}';
const postObj = JSON.parse(post) as Record<string, any>;
const postEntries = Object.values(postObj) as any[];
if (postEntries.length > 0) {
await AsyncStorage.setItem(`@user:${user.id}:library_initialized`, 'true');
}
}
// Upsert rows
if (entries.length > 0) {
const rows = entries.map((it) => ({
user_id: user.id,
media_type: it.type === 'movie' ? 'movie' : 'series',
media_id: it.id,
title: it.name || it.title || it.id,
poster_url: it.poster || it.poster_url || null,
year: normalizeYear(it.year),
updated_at: new Date().toISOString(),
}));
const { error: upErr } = await supabase
.from('user_library')
.upsert(rows, { onConflict: 'user_id,media_type,media_id' });
if (upErr) console.warn('[SyncService] push library upsert error', upErr);
else await AsyncStorage.setItem(`@user:${user.id}:library_initialized`, 'true');
}
// No computed deletions; removals happen only via explicit user action (soft delete)
} catch (e) {
console.warn('[SyncService] pushLibrary exception', e);
}
}
private async pushLibraryAdd(item: StreamingContent): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
try {
const row = {
user_id: user.id,
media_type: item.type === 'movie' ? 'movie' : 'series',
media_id: item.id,
title: (item as any).name || (item as any).title || item.id,
poster_url: (item as any).poster || null,
year: normalizeYear((item as any).year),
deleted_at: null as any,
updated_at: new Date().toISOString(),
};
const { error } = await supabase.from('user_library').upsert(row, { onConflict: 'user_id,media_type,media_id' });
if (error) console.warn('[SyncService] pushLibraryAdd error', error);
} catch (e) {
console.warn('[SyncService] pushLibraryAdd exception', e);
}
}
private async pushLibraryRemove(type: string, id: string): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
try {
const { error } = await supabase
.from('user_library')
.update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq('user_id', user.id)
.eq('media_type', type === 'movie' ? 'movie' : 'series')
.eq('media_id', id);
if (error) console.warn('[SyncService] pushLibraryRemove error', error);
} catch (e) {
console.warn('[SyncService] pushLibraryRemove exception', e);
}
}
private async pullAddonsSnapshot(userId: string): Promise<void> {
logger.log('[Sync] pull installed_addons');
const { data: addons, error: addonsErr } = await supabase
.from('installed_addons')
.select('*')
.eq('user_id', userId)
.order('position', { ascending: true });
if (addonsErr) {
console.warn('[SyncService] pull addons error', addonsErr);
return;
}
if (!(addons && Array.isArray(addons))) return;
// Start from currently installed (to preserve pre-installed like Cinemeta/OpenSubtitles)
const map = new Map<string, any>();
for (const a of addons as any[]) {
try {
let manifest = a.manifest_data;
if (!manifest) {
const urlToUse = a.original_url || a.url;
if (urlToUse) {
manifest = await stremioService.getManifest(urlToUse);
}
}
if (!manifest) {
manifest = {
id: a.addon_id,
name: a.name || a.addon_id,
version: a.version || '1.0.0',
description: a.description || '',
url: a.url || a.original_url || '',
originalUrl: a.original_url || a.url || '',
catalogs: [],
resources: [],
types: [],
};
}
manifest.id = a.addon_id;
map.set(a.addon_id, manifest);
} catch (e) {
console.warn('[SyncService] failed to fetch manifest for', a.addon_id, e);
}
}
// Always include preinstalled regardless of server
try { map.set('com.linvo.cinemeta', await stremioService.getManifest('https://v3-cinemeta.strem.io/manifest.json')); } catch {}
try { map.set('org.stremio.opensubtitlesv3', await stremioService.getManifest('https://opensubtitles-v3.strem.io/manifest.json')); } catch {}
(stremioService as any).installedAddons = map;
const order = (addons as any[]).map(a => a.addon_id);
const ensureFront = (arr: string[], id: string) => {
const idx = arr.indexOf(id);
if (idx === -1) arr.unshift(id);
else if (idx > 0) { arr.splice(idx, 1); arr.unshift(id); }
};
ensureFront(order, 'com.linvo.cinemeta');
ensureFront(order, 'org.stremio.opensubtitlesv3');
// Keep order strictly from server after preinstalled
// Do not append missing local-only ids to avoid resurrecting removed addons
(stremioService as any).addonOrder = order;
await (stremioService as any).saveInstalledAddons();
await (stremioService as any).saveAddonOrder();
}
async pushWatchProgress(): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
const userId = user.id;
const unsynced = await storageService.getUnsyncedProgress();
logger.log(`[Sync] push watch_progress rows=${unsynced.length}`);
const rows: any[] = unsynced.map(({ id, type, episodeId, progress }) => ({
user_id: userId,
media_type: type,
media_id: id,
episode_id: episodeId || '',
current_time_seconds: Math.floor(progress.currentTime || 0),
duration_seconds: Math.floor(progress.duration || 0),
last_updated_ms: progress.lastUpdated || Date.now(),
trakt_synced: progress.traktSynced ?? undefined,
trakt_last_synced_ms: progress.traktLastSynced ?? undefined,
trakt_progress_percent: progress.traktProgress ?? undefined,
deleted_at: null,
updated_at: new Date().toISOString(),
}));
if (rows.length > 0) {
// Prevent resurrecting remotely-deleted rows when server has newer update
try {
const keys = rows.map(r => ({ media_type: r.media_type, media_id: r.media_id, episode_id: r.episode_id }));
const { data: remote } = await supabase
.from('watch_progress')
.select('media_type,media_id,episode_id,deleted_at,updated_at')
.eq('user_id', userId)
.in('media_type', keys.map(k => k.media_type))
.in('media_id', keys.map(k => k.media_id))
.in('episode_id', keys.map(k => k.episode_id));
const shouldSkip = new Set<string>();
if (remote) {
for (const r of remote as any[]) {
const key = `${r.media_type}|${r.media_id}|${r.episode_id || ''}`;
if (r.deleted_at && r.updated_at) {
const remoteUpdatedMs = new Date(r.updated_at as string).getTime();
// Find matching local row
const local = rows.find(x => x.media_type === r.media_type && x.media_id === r.media_id && x.episode_id === (r.episode_id || ''));
const localUpdatedMs = local?.last_updated_ms ?? 0;
if (remoteUpdatedMs >= localUpdatedMs) {
shouldSkip.add(key);
// also write a tombstone locally
try { await storageService.addWatchProgressTombstone(r.media_id, r.media_type, r.episode_id || undefined, remoteUpdatedMs); } catch {}
}
}
}
}
if (shouldSkip.size > 0) {
logger.log(`[Sync] push watch_progress skipping resurrect count=${shouldSkip.size}`);
}
// Filter rows to upsert
const filteredRows = rows.filter(r => !shouldSkip.has(`${r.media_type}|${r.media_id}|${r.episode_id}`));
if (filteredRows.length > 0) {
const { error } = await supabase
.from('watch_progress')
.upsert(filteredRows, { onConflict: 'user_id,media_type,media_id,episode_id' });
if (error) console.warn('[SyncService] push watch_progress error', error);
else logger.log('[Sync] push watch_progress upsert ok');
}
} catch (e) {
// Fallback to normal upsert if pre-check fails
const { error } = await supabase
.from('watch_progress')
.upsert(rows, { onConflict: 'user_id,media_type,media_id,episode_id' });
if (error) console.warn('[SyncService] push watch_progress error', error);
else logger.log('[Sync] push watch_progress upsert ok');
}
}
// Deletions occur only on explicit remove; no bulk deletions here
}
private async softDeleteWatchProgress(type: string, id: string, episodeId?: string): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
try {
const { error } = await supabase
.from('watch_progress')
.update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq('user_id', user.id)
.eq('media_type', type)
.eq('media_id', id)
.eq('episode_id', episodeId || '');
if (error) console.warn('[SyncService] softDeleteWatchProgress error', error);
} catch (e) {
console.warn('[SyncService] softDeleteWatchProgress exception', e);
}
}
async pushSettings(): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
const userId = user.id;
logger.log('[Sync] push user_settings start');
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const appSettingsJson =
(await AsyncStorage.getItem(`@user:${scope}:app_settings`)) ||
(await AsyncStorage.getItem('app_settings')) ||
'{}';
const appSettings = JSON.parse(appSettingsJson);
const subtitleSettings = (await storageService.getSubtitleSettings()) || {};
const { error } = await supabase.from('user_settings').upsert({
user_id: userId,
app_settings: appSettings,
subtitle_settings: subtitleSettings,
});
if (error) console.warn('[SyncService] push settings error', error);
else logger.log('[Sync] push user_settings ok');
}
async pushAddons(): Promise<void> {
const user = await accountService.getCurrentUser();
if (!user) return;
const userId = user.id;
const addons = await stremioService.getInstalledAddonsAsync();
logger.log(`[Sync] push installed_addons count=${addons.length}`);
const order = (stremioService as any).addonOrder as string[];
const rows = addons.map((a: any) => ({
user_id: userId,
addon_id: a.id,
name: a.name,
url: a.url,
original_url: a.originalUrl,
version: a.version,
description: a.description,
position: Math.max(0, order.indexOf(a.id)),
manifest_data: a,
}));
// Delete remote addons that no longer exist locally (excluding pre-installed to be safe)
try {
const { data: remote, error: rErr } = await supabase
.from('installed_addons')
.select('addon_id')
.eq('user_id', userId);
if (!rErr && remote) {
const localIds = new Set(addons.map((a: any) => a.id));
const toDelete = (remote as any[])
.map(r => r.addon_id as string)
.filter(id => !localIds.has(id) && id !== 'com.linvo.cinemeta' && id !== 'org.stremio.opensubtitlesv3');
logger.log(`[Sync] push installed_addons deletions=${toDelete.length}`);
if (toDelete.length > 0) {
const del = await supabase
.from('installed_addons')
.delete()
.eq('user_id', userId)
.in('addon_id', toDelete);
if (del.error) console.warn('[SyncService] delete addons error', del.error);
}
}
} catch (e) {
console.warn('[SyncService] deletion sync for addons failed', e);
}
const { error } = await supabase.from('installed_addons').upsert(rows, { onConflict: 'user_id,addon_id' });
if (error) console.warn('[SyncService] push addons error', error);
}
// Excluded: pushLocalScrapers (local scrapers are device-local only)
}
export const syncService = SyncService.getInstance();
export default syncService;
// Small helper to batch delete operations
function chunkArray<T>(arr: T[], size: number): T[][] {
const res: T[][] = [];
for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size));
return res;
}
// Normalize year values to integer or null
function normalizeYear(value: any): number | null {
if (value == null) return null;
if (typeof value === 'number' && Number.isInteger(value)) return value;
if (typeof value === 'string') {
// Extract first 4 consecutive digits
const m = value.match(/\d{4}/);
if (m) {
const y = parseInt(m[0], 10);
if (y >= 1900 && y <= 2100) return y;
return y;
}
}
return null;
}

View file

@ -86,12 +86,14 @@ const CATALOG_SETTINGS_KEY = 'catalog_settings';
class CatalogService {
private static instance: CatalogService;
private readonly LIBRARY_KEY = 'stremio-library';
private readonly LEGACY_LIBRARY_KEY = 'stremio-library';
private readonly RECENT_CONTENT_KEY = 'stremio-recent-content';
private library: Record<string, StreamingContent> = {};
private recentContent: StreamingContent[] = [];
private readonly MAX_RECENT_ITEMS = 20;
private librarySubscribers: ((items: StreamingContent[]) => void)[] = [];
private libraryAddListeners: ((item: StreamingContent) => void)[] = [];
private libraryRemoveListeners: ((type: string, id: string) => void)[] = [];
private constructor() {
this.loadLibrary();
@ -107,7 +109,16 @@ class CatalogService {
private async loadLibrary(): Promise<void> {
try {
const storedLibrary = await AsyncStorage.getItem(this.LIBRARY_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:stremio-library`;
let storedLibrary = (await AsyncStorage.getItem(scopedKey));
if (!storedLibrary) {
// Fallback: read legacy and migrate into scoped
storedLibrary = await AsyncStorage.getItem(this.LEGACY_LIBRARY_KEY);
if (storedLibrary) {
await AsyncStorage.setItem(scopedKey, storedLibrary);
}
}
if (storedLibrary) {
this.library = JSON.parse(storedLibrary);
}
@ -118,7 +129,10 @@ class CatalogService {
private async saveLibrary(): Promise<void> {
try {
await AsyncStorage.setItem(this.LIBRARY_KEY, JSON.stringify(this.library));
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:stremio-library`;
await AsyncStorage.setItem(scopedKey, JSON.stringify(this.library));
await AsyncStorage.setItem(this.LEGACY_LIBRARY_KEY, JSON.stringify(this.library));
} catch (error) {
logger.error('Failed to save library:', error);
}
@ -623,6 +637,20 @@ class CatalogService {
this.librarySubscribers.forEach(callback => callback(items));
}
public onLibraryAdd(listener: (item: StreamingContent) => void): () => void {
this.libraryAddListeners.push(listener);
return () => {
this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener);
};
}
public onLibraryRemove(listener: (type: string, id: string) => void): () => void {
this.libraryRemoveListeners.push(listener);
return () => {
this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener);
};
}
public getLibraryItems(): StreamingContent[] {
return Object.values(this.library);
}
@ -646,17 +674,18 @@ class CatalogService {
this.library[key] = content;
this.saveLibrary();
this.notifyLibrarySubscribers();
try { this.libraryAddListeners.forEach(l => l(content)); } catch {}
// Auto-setup notifications for series when added to library
if (content.type === 'series') {
try {
const { notificationService } = await import('./notificationService');
await notificationService.updateNotificationsForSeries(content.id);
console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`);
} catch (error) {
console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error);
}
}
// if (content.type === 'series') {
// try {
// const { notificationService } = await import('./notificationService');
// await notificationService.updateNotificationsForSeries(content.id);
// console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`);
// } catch (error) {
// console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error);
// }
// }
}
public async removeFromLibrary(type: string, id: string): Promise<void> {
@ -664,24 +693,25 @@ class CatalogService {
delete this.library[key];
this.saveLibrary();
this.notifyLibrarySubscribers();
try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch {}
// Cancel notifications for series when removed from library
if (type === 'series') {
try {
const { notificationService } = await import('./notificationService');
// Cancel all notifications for this series
const scheduledNotifications = await notificationService.getScheduledNotifications();
const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id);
for (const notification of seriesToCancel) {
await notificationService.cancelNotification(notification.id);
}
console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`);
} catch (error) {
console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
}
}
// if (type === 'series') {
// try {
// const { notificationService } = await import('./notificationService');
// // Cancel all notifications for this series
// const scheduledNotifications = await notificationService.getScheduledNotifications();
// const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id);
//
// for (const notification of seriesToCancel) {
// await notificationService.cancelNotification(notification.id);
// }
//
// console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`);
// } catch (error) {
// console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
// }
// }
}
private addToRecentContent(content: StreamingContent): void {

View file

@ -71,13 +71,14 @@ class LocalScraperService {
try {
// Load repository URL
const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const storedRepoUrl = await AsyncStorage.getItem(`@user:${scope}:${this.REPOSITORY_KEY}`);
if (storedRepoUrl) {
this.repositoryUrl = storedRepoUrl;
}
// Load installed scrapers
const storedScrapers = await AsyncStorage.getItem(this.STORAGE_KEY);
const storedScrapers = await AsyncStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
if (storedScrapers) {
const scrapers: ScraperInfo[] = JSON.parse(storedScrapers);
const validScrapers: ScraperInfo[] = [];
@ -166,7 +167,8 @@ class LocalScraperService {
// Set repository URL
async setRepositoryUrl(url: string): Promise<void> {
this.repositoryUrl = url;
await AsyncStorage.setItem(this.REPOSITORY_KEY, url);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
await AsyncStorage.setItem(`@user:${scope}:${this.REPOSITORY_KEY}`, url);
logger.log('[LocalScraperService] Repository URL set to:', url);
}
@ -326,8 +328,9 @@ class LocalScraperService {
// Save installed scrapers to storage
private async saveInstalledScrapers(): Promise<void> {
try {
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const scrapers = Array.from(this.installedScrapers.values());
await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(scrapers));
await AsyncStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(scrapers));
} catch (error) {
logger.error('[LocalScraperService] Failed to save scrapers:', error);
}
@ -699,7 +702,8 @@ class LocalScraperService {
this.scraperCode.clear();
// Clear from storage
await AsyncStorage.removeItem(this.STORAGE_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
await AsyncStorage.removeItem(`@user:${scope}:${this.STORAGE_KEY}`);
// Clear cached code
const keys = await AsyncStorage.getAllKeys();

View file

@ -15,7 +15,9 @@ class StorageService {
private readonly WATCH_PROGRESS_KEY = '@watch_progress:';
private readonly CONTENT_DURATION_KEY = '@content_duration:';
private readonly SUBTITLE_SETTINGS_KEY = '@subtitle_settings';
private readonly WP_TOMBSTONES_KEY = '@wp_tombstones';
private watchProgressSubscribers: (() => void)[] = [];
private watchProgressRemoveListeners: ((id: string, type: string, episodeId?: string) => void)[] = [];
private notificationDebounceTimer: NodeJS.Timeout | null = null;
private lastNotificationTime: number = 0;
private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce
@ -30,12 +32,79 @@ class StorageService {
return StorageService.instance;
}
private getWatchProgressKey(id: string, type: string, episodeId?: string): string {
return `${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
private async getUserScope(): Promise<string> {
try {
const scope = await AsyncStorage.getItem('@user:current');
return scope || 'local';
} catch {
return 'local';
}
}
private getContentDurationKey(id: string, type: string, episodeId?: string): string {
return `${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
private async getWatchProgressKeyScoped(id: string, type: string, episodeId?: string): Promise<string> {
const scope = await this.getUserScope();
return `@user:${scope}:${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
}
private async getContentDurationKeyScoped(id: string, type: string, episodeId?: string): Promise<string> {
const scope = await this.getUserScope();
return `@user:${scope}:${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
}
private async getSubtitleSettingsKeyScoped(): Promise<string> {
const scope = await this.getUserScope();
return `@user:${scope}:${this.SUBTITLE_SETTINGS_KEY}`;
}
private async getTombstonesKeyScoped(): Promise<string> {
const scope = await this.getUserScope();
return `@user:${scope}:${this.WP_TOMBSTONES_KEY}`;
}
private buildWpKeyString(id: string, type: string, episodeId?: string): string {
return `${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
}
public async addWatchProgressTombstone(
id: string,
type: string,
episodeId?: string,
deletedAtMs?: number
): Promise<void> {
try {
const key = await this.getTombstonesKeyScoped();
const json = (await AsyncStorage.getItem(key)) || '{}';
const map = JSON.parse(json) as Record<string, number>;
map[this.buildWpKeyString(id, type, episodeId)] = deletedAtMs || Date.now();
await AsyncStorage.setItem(key, JSON.stringify(map));
} catch {}
}
public async clearWatchProgressTombstone(
id: string,
type: string,
episodeId?: string
): Promise<void> {
try {
const key = await this.getTombstonesKeyScoped();
const json = (await AsyncStorage.getItem(key)) || '{}';
const map = JSON.parse(json) as Record<string, number>;
const k = this.buildWpKeyString(id, type, episodeId);
if (map[k] != null) {
delete map[k];
await AsyncStorage.setItem(key, JSON.stringify(map));
}
} catch {}
}
public async getWatchProgressTombstones(): Promise<Record<string, number>> {
try {
const key = await this.getTombstonesKeyScoped();
const json = (await AsyncStorage.getItem(key)) || '{}';
return JSON.parse(json) as Record<string, number>;
} catch {
return {};
}
}
public async setContentDuration(
@ -45,7 +114,7 @@ class StorageService {
episodeId?: string
): Promise<void> {
try {
const key = this.getContentDurationKey(id, type, episodeId);
const key = await this.getContentDurationKeyScoped(id, type, episodeId);
await AsyncStorage.setItem(key, duration.toString());
} catch (error) {
logger.error('Error setting content duration:', error);
@ -58,7 +127,7 @@ class StorageService {
episodeId?: string
): Promise<number | null> {
try {
const key = this.getContentDurationKey(id, type, episodeId);
const key = await this.getContentDurationKeyScoped(id, type, episodeId);
const data = await AsyncStorage.getItem(key);
return data ? parseFloat(data) : null;
} catch (error) {
@ -99,7 +168,16 @@ class StorageService {
episodeId?: string
): Promise<void> {
try {
const key = this.getWatchProgressKey(id, type, episodeId);
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
// Do not resurrect if tombstone exists and is newer than this progress
try {
const tombstones = await this.getWatchProgressTombstones();
const tombKey = this.buildWpKeyString(id, type, episodeId);
const tombAt = tombstones[tombKey];
if (tombAt && (progress.lastUpdated == null || progress.lastUpdated <= tombAt)) {
return;
}
} catch {}
// Check if progress has actually changed significantly
const existingProgress = await this.getWatchProgress(id, type, episodeId);
@ -113,7 +191,8 @@ class StorageService {
}
}
await AsyncStorage.setItem(key, JSON.stringify(progress));
const updated = { ...progress, lastUpdated: Date.now() };
await AsyncStorage.setItem(key, JSON.stringify(updated));
// Use debounced notification to reduce spam
this.debouncedNotifySubscribers();
@ -164,13 +243,21 @@ class StorageService {
};
}
public onWatchProgressRemoved(listener: (id: string, type: string, episodeId?: string) => void): () => void {
this.watchProgressRemoveListeners.push(listener);
return () => {
const index = this.watchProgressRemoveListeners.indexOf(listener);
if (index > -1) this.watchProgressRemoveListeners.splice(index, 1);
};
}
public async getWatchProgress(
id: string,
type: string,
episodeId?: string
): Promise<WatchProgress | null> {
try {
const key = this.getWatchProgressKey(id, type, episodeId);
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
const data = await AsyncStorage.getItem(key);
return data ? JSON.parse(data) : null;
} catch (error) {
@ -185,10 +272,13 @@ class StorageService {
episodeId?: string
): Promise<void> {
try {
const key = this.getWatchProgressKey(id, type, episodeId);
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
await AsyncStorage.removeItem(key);
await this.addWatchProgressTombstone(id, type, episodeId);
// Notify subscribers
this.notifyWatchProgressSubscribers();
// Emit explicit remove event for sync layer
try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch {}
} catch (error) {
logger.error('Error removing watch progress:', error);
}
@ -196,12 +286,14 @@ class StorageService {
public async getAllWatchProgress(): Promise<Record<string, WatchProgress>> {
try {
const scope = await this.getUserScope();
const prefix = `@user:${scope}:${this.WATCH_PROGRESS_KEY}`;
const keys = await AsyncStorage.getAllKeys();
const watchProgressKeys = keys.filter(key => key.startsWith(this.WATCH_PROGRESS_KEY));
const watchProgressKeys = keys.filter(key => key.startsWith(prefix));
const pairs = await AsyncStorage.multiGet(watchProgressKeys);
return pairs.reduce((acc, [key, value]) => {
if (value) {
acc[key.replace(this.WATCH_PROGRESS_KEY, '')] = JSON.parse(value);
acc[key.replace(prefix, '')] = JSON.parse(value);
}
return acc;
}, {} as Record<string, WatchProgress>);
@ -223,7 +315,7 @@ class StorageService {
exactTime?: number
): Promise<void> {
try {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress) {
// Preserve the highest Trakt progress and currentTime values to avoid accidental regressions
const highestTraktProgress = (() => {
@ -272,6 +364,12 @@ class StorageService {
}> = [];
for (const [key, progress] of Object.entries(allProgress)) {
// Skip if tombstoned and tombstone is newer
const tombstones = await this.getWatchProgressTombstones();
const tombAt = tombstones[key];
if (tombAt && (progress.lastUpdated == null || progress.lastUpdated <= tombAt)) {
continue;
}
// Check if needs sync (either never synced or local progress is newer)
const needsSync = !progress.traktSynced ||
(progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced);
@ -424,7 +522,8 @@ class StorageService {
public async saveSubtitleSettings(settings: Record<string, any>): Promise<void> {
try {
await AsyncStorage.setItem(this.SUBTITLE_SETTINGS_KEY, JSON.stringify(settings));
const key = await this.getSubtitleSettingsKeyScoped();
await AsyncStorage.setItem(key, JSON.stringify(settings));
} catch (error) {
logger.error('Error saving subtitle settings:', error);
}
@ -432,7 +531,8 @@ class StorageService {
public async getSubtitleSettings(): Promise<Record<string, any> | null> {
try {
const data = await AsyncStorage.getItem(this.SUBTITLE_SETTINGS_KEY);
const key = await this.getSubtitleSettingsKeyScoped();
const data = await AsyncStorage.getItem(key);
return data ? JSON.parse(data) : null;
} catch (error) {
logger.error('Error loading subtitle settings:', error);

View file

@ -199,7 +199,8 @@ class StremioService {
if (this.initialized) return;
try {
const storedAddons = await AsyncStorage.getItem(this.STORAGE_KEY);
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const storedAddons = await AsyncStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
if (storedAddons) {
const parsed = JSON.parse(storedAddons);
@ -301,7 +302,7 @@ class StremioService {
}
// Load addon order if exists
const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY);
const storedOrder = await AsyncStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
if (storedOrder) {
this.addonOrder = JSON.parse(storedOrder);
// Filter out any ids that aren't in installedAddons
@ -388,7 +389,8 @@ class StremioService {
private async saveInstalledAddons(): Promise<void> {
try {
const addonsArray = Array.from(this.installedAddons.values());
await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray));
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
await AsyncStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray));
} catch (error) {
logger.error('Failed to save addons:', error);
}
@ -396,7 +398,8 @@ class StremioService {
private async saveAddonOrder(): Promise<void> {
try {
await AsyncStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder));
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
await AsyncStorage.setItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`, JSON.stringify(this.addonOrder));
} catch (error) {
logger.error('Failed to save addon order:', error);
}
@ -470,9 +473,26 @@ class StremioService {
getInstalledAddons(): Manifest[] {
// Return addons in the specified order
return this.addonOrder
const result = this.addonOrder
.filter(id => this.installedAddons.has(id))
.map(id => this.installedAddons.get(id)!);
// Ensure pre-installed presence
const cinId = 'com.linvo.cinemeta';
const osId = 'org.stremio.opensubtitlesv3';
if (!result.find(a => a.id === cinId) && this.installedAddons.has(cinId)) {
result.unshift(this.installedAddons.get(cinId)!);
}
if (!result.find(a => a.id === osId) && this.installedAddons.has(osId)) {
// Put OpenSubtitles right after Cinemeta if possible, else at start
const cinIdx = result.findIndex(a => a.id === cinId);
const osManifest = this.installedAddons.get(osId)!;
if (cinIdx >= 0) {
result.splice(cinIdx + 1, 0, osManifest);
} else {
result.unshift(osManifest);
}
}
return result;
}
async getInstalledAddonsAsync(): Promise<Manifest[]> {
@ -749,8 +769,10 @@ class StremioService {
// Check if local scrapers are enabled and execute them first
try {
// Load settings from AsyncStorage directly
const settingsJson = await AsyncStorage.getItem('app_settings');
// Load settings from AsyncStorage directly (scoped with fallback)
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
const settingsJson = (await AsyncStorage.getItem(`@user:${scope}:app_settings`))
|| (await AsyncStorage.getItem('app_settings'));
const settings: AppSettings = settingsJson ? JSON.parse(settingsJson) : DEFAULT_SETTINGS;
if (settings.enableLocalScrapers) {

View file

@ -0,0 +1,20 @@
import 'react-native-url-polyfill/auto';
import 'react-native-get-random-values';
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
const SUPABASE_URL = 'https://utypxyhwcekefvhyguxp.supabase.co';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV0eXB4eWh3Y2VrZWZ2aHlndXhwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ2NjE4NTksImV4cCI6MjA3MDIzNzg1OX0.kc76fjHLjq6a5tNLsQh6KxS4uGp0ngl_ipQBte6KZuA';
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
storage: AsyncStorage as unknown as Storage,
autoRefreshToken: true,
detectSessionInUrl: false,
},
});
export default supabase;