added video support to sdui server

This commit is contained in:
tapframe 2025-12-30 02:15:43 +05:30
parent fd6e29a8ec
commit 44abb9f635
6 changed files with 275 additions and 1598 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, useWindowDimensions } from 'react-native'; import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, useWindowDimensions } from 'react-native';
import Video from 'react-native-video';
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated'; import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -148,18 +149,34 @@ const BottomSheetCampaign: React.FC<BottomSheetProps> = ({ campaign, onDismiss,
<Ionicons name="close" size={isTablet ? 26 : 22} color={content.closeButtonColor || '#fff'} /> <Ionicons name="close" size={isTablet ? 26 : 22} color={content.closeButtonColor || '#fff'} />
</TouchableOpacity> </TouchableOpacity>
{content.imageUrl && ( {/* Media - Image or Video */}
<Image {(content.imageUrl || content.videoUrl) && (
source={{ uri: content.imageUrl }} <View style={[
style={[ styles.bottomSheetImage,
styles.bottomSheetImage, {
{ aspectRatio: content.aspectRatio || 1.5,
aspectRatio: content.aspectRatio || 1.5, maxHeight: imageMaxHeight,
maxHeight: imageMaxHeight, }
} ]}>
]} {content.mediaType === 'video' && content.videoUrl ? (
resizeMode="cover" <Video
/> source={{ uri: content.videoUrl }}
style={StyleSheet.absoluteFill}
resizeMode="cover"
repeat={true}
muted={true}
paused={false}
playInBackground={false}
playWhenInactive={false}
/>
) : content.imageUrl ? (
<Image
source={{ uri: content.imageUrl }}
style={StyleSheet.absoluteFill}
resizeMode="cover"
/>
) : null}
</View>
)} )}
<View style={styles.bottomSheetContent}> <View style={styles.bottomSheetContent}>

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useRef } from 'react';
import { import {
View, View,
Text, Text,
@ -13,6 +13,7 @@ import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Campaign } from '../../services/campaignService'; import { Campaign } from '../../services/campaignService';
import Video from 'react-native-video';
interface PosterModalProps { interface PosterModalProps {
campaign: Campaign; campaign: Campaign;
@ -94,7 +95,8 @@ export const PosterModal: React.FC<PosterModalProps> = ({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{content.imageUrl && ( {/* Media Container - Image or Video */}
{(content.imageUrl || content.videoUrl) && (
<View style={[ <View style={[
styles.imageContainer, styles.imageContainer,
{ {
@ -102,11 +104,24 @@ export const PosterModal: React.FC<PosterModalProps> = ({
maxHeight: maxImageHeight, maxHeight: maxImageHeight,
} }
]}> ]}>
<Image {content.mediaType === 'video' && content.videoUrl ? (
source={{ uri: content.imageUrl }} <Video
style={styles.image} source={{ uri: content.videoUrl }}
resizeMode="cover" style={styles.image}
/> resizeMode="cover"
repeat={true}
muted={true}
paused={false}
playInBackground={false}
playWhenInactive={false}
/>
) : content.imageUrl ? (
<Image
source={{ uri: content.imageUrl }}
style={styles.image}
resizeMode="cover"
/>
) : null}
</View> </View>
)} )}

View file

@ -29,19 +29,19 @@ const BackupScreen: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const navigation = useNavigation(); const navigation = useNavigation();
const { preferences, updatePreference, getBackupOptions } = useBackupOptions(); const { preferences, updatePreference, getBackupOptions } = useBackupOptions();
// Collapsible sections state // Collapsible sections state
const [expandedSections, setExpandedSections] = useState({ const [expandedSections, setExpandedSections] = useState({
coreData: false, coreData: false,
addonsIntegrations: false, addonsIntegrations: false,
settingsPreferences: false, settingsPreferences: false,
}); });
// Animated values for each section // Animated values for each section
const coreDataAnim = useRef(new Animated.Value(0)).current; const coreDataAnim = useRef(new Animated.Value(0)).current;
const addonsAnim = useRef(new Animated.Value(0)).current; const addonsAnim = useRef(new Animated.Value(0)).current;
const settingsAnim = useRef(new Animated.Value(0)).current; const settingsAnim = useRef(new Animated.Value(0)).current;
// Chevron rotation animated values // Chevron rotation animated values
const coreDataChevron = useRef(new Animated.Value(0)).current; const coreDataChevron = useRef(new Animated.Value(0)).current;
const addonsChevron = useRef(new Animated.Value(0)).current; const addonsChevron = useRef(new Animated.Value(0)).current;
@ -60,7 +60,7 @@ const BackupScreen: React.FC = () => {
) => { ) => {
setAlertTitle(title); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
}; };
@ -73,7 +73,7 @@ const BackupScreen: React.FC = () => {
openAlert( openAlert(
'Restart Failed', 'Restart Failed',
'Failed to restart the app. Please manually close and reopen the app to see your restored data.', 'Failed to restart the app. Please manually close and reopen the app to see your restored data.',
[{ label: 'OK', onPress: () => {} }] [{ label: 'OK', onPress: () => { } }]
); );
} }
}; };
@ -81,10 +81,10 @@ const BackupScreen: React.FC = () => {
// Toggle section collapse/expand // Toggle section collapse/expand
const toggleSection = useCallback((section: 'coreData' | 'addonsIntegrations' | 'settingsPreferences') => { const toggleSection = useCallback((section: 'coreData' | 'addonsIntegrations' | 'settingsPreferences') => {
const isExpanded = expandedSections[section]; const isExpanded = expandedSections[section];
let heightAnim: Animated.Value; let heightAnim: Animated.Value;
let chevronAnim: Animated.Value; let chevronAnim: Animated.Value;
if (section === 'coreData') { if (section === 'coreData') {
heightAnim = coreDataAnim; heightAnim = coreDataAnim;
chevronAnim = coreDataChevron; chevronAnim = coreDataChevron;
@ -95,7 +95,7 @@ const BackupScreen: React.FC = () => {
heightAnim = settingsAnim; heightAnim = settingsAnim;
chevronAnim = settingsChevron; chevronAnim = settingsChevron;
} }
// Animate height and chevron rotation // Animate height and chevron rotation
Animated.parallel([ Animated.parallel([
Animated.timing(heightAnim, { Animated.timing(heightAnim, {
@ -111,8 +111,8 @@ const BackupScreen: React.FC = () => {
easing: Easing.inOut(Easing.ease), easing: Easing.inOut(Easing.ease),
}), }),
]).start(); ]).start();
setExpandedSections(prev => ({...prev, [section]: !isExpanded})); setExpandedSections(prev => ({ ...prev, [section]: !isExpanded }));
}, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]); }, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]);
// Create backup // Create backup
@ -135,6 +135,9 @@ const BackupScreen: React.FC = () => {
if (preferences.includeWatchProgress) { if (preferences.includeWatchProgress) {
items.push(`Watch Progress: ${preview.watchProgress} entries`); items.push(`Watch Progress: ${preview.watchProgress} entries`);
total += preview.watchProgress; total += preview.watchProgress;
// Include watched status with watch progress
items.push(`Watched Status: ${preview.watchedStatus} items`);
total += preview.watchedStatus;
} }
if (preferences.includeAddons) { if (preferences.includeAddons) {
@ -149,7 +152,7 @@ const BackupScreen: React.FC = () => {
// Check if no items are selected // Check if no items are selected
const message = items.length > 0 const message = items.length > 0
? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, and integration data.` ? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, watched markers, and integration data.`
: `No content selected for backup.\n\nPlease enable at least one option in the Backup Options section above.`; : `No content selected for backup.\n\nPlease enable at least one option in the Backup Options section above.`;
openAlert( openAlert(
@ -157,51 +160,51 @@ const BackupScreen: React.FC = () => {
message, message,
items.length > 0 items.length > 0
? [ ? [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Create Backup', label: 'Create Backup',
onPress: async () => { onPress: async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const backupOptions = getBackupOptions(); const backupOptions = getBackupOptions();
const fileUri = await backupService.createBackup(backupOptions); const fileUri = await backupService.createBackup(backupOptions);
// Share the backup file // Share the backup file
if (await Sharing.isAvailableAsync()) { if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, { await Sharing.shareAsync(fileUri, {
mimeType: 'application/json', mimeType: 'application/json',
dialogTitle: 'Share Nuvio Backup', dialogTitle: 'Share Nuvio Backup',
}); });
}
openAlert(
'Backup Created',
'Your backup has been created and is ready to share.',
[{ label: 'OK', onPress: () => {} }]
);
} catch (error) {
logger.error('[BackupScreen] Failed to create backup:', error);
openAlert(
'Backup Failed',
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => {} }]
);
} finally {
setIsLoading(false);
} }
openAlert(
'Backup Created',
'Your backup has been created and is ready to share.',
[{ label: 'OK', onPress: () => { } }]
);
} catch (error) {
logger.error('[BackupScreen] Failed to create backup:', error);
openAlert(
'Backup Failed',
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => { } }]
);
} finally {
setIsLoading(false);
} }
} }
] }
: [{ label: 'OK', onPress: () => {} }] ]
: [{ label: 'OK', onPress: () => { } }]
); );
} catch (error) { } catch (error) {
logger.error('[BackupScreen] Failed to get backup preview:', error); logger.error('[BackupScreen] Failed to get backup preview:', error);
openAlert( openAlert(
'Error', 'Error',
'Failed to prepare backup information. Please try again.', 'Failed to prepare backup information. Please try again.',
[{ label: 'OK', onPress: () => {} }] [{ label: 'OK', onPress: () => { } }]
); );
setIsLoading(false); setIsLoading(false);
} }
@ -228,7 +231,7 @@ const BackupScreen: React.FC = () => {
'Confirm Restore', 'Confirm Restore',
`This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`, `This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`,
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Restore', label: 'Restore',
onPress: async () => { onPress: async () => {
@ -243,9 +246,9 @@ const BackupScreen: React.FC = () => {
'Restore Complete', 'Restore Complete',
'Your data has been successfully restored. Please restart the app to see all changes.', 'Your data has been successfully restored. Please restart the app to see all changes.',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Restart App', label: 'Restart App',
onPress: restartApp, onPress: restartApp,
style: { fontWeight: 'bold' } style: { fontWeight: 'bold' }
} }
@ -256,7 +259,7 @@ const BackupScreen: React.FC = () => {
openAlert( openAlert(
'Restore Failed', 'Restore Failed',
`Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`, `Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => {} }] [{ label: 'OK', onPress: () => { } }]
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -270,7 +273,7 @@ const BackupScreen: React.FC = () => {
openAlert( openAlert(
'File Selection Failed', 'File Selection Failed',
`Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`, `Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => {} }] [{ label: 'OK', onPress: () => { } }]
); );
} }
}, [openAlert]); }, [openAlert]);
@ -281,26 +284,26 @@ const BackupScreen: React.FC = () => {
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} /> <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
{/* Empty for now, but keeping structure consistent */} {/* Empty for now, but keeping structure consistent */}
</View> </View>
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
Backup & Restore Backup & Restore
</Text> </Text>
{/* Content */} {/* Content */}
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
> >
@ -321,7 +324,7 @@ const BackupScreen: React.FC = () => {
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Choose what to include in your backups Choose what to include in your backups
</Text> </Text>
{/* Core Data Group */} {/* Core Data Group */}
<TouchableOpacity <TouchableOpacity
style={styles.sectionHeader} style={styles.sectionHeader}
@ -369,7 +372,7 @@ const BackupScreen: React.FC = () => {
theme={currentTheme} theme={currentTheme}
/> />
</Animated.View> </Animated.View>
{/* Addons & Integrations Group */} {/* Addons & Integrations Group */}
<TouchableOpacity <TouchableOpacity
style={styles.sectionHeader} style={styles.sectionHeader}
@ -424,7 +427,7 @@ const BackupScreen: React.FC = () => {
theme={currentTheme} theme={currentTheme}
/> />
</Animated.View> </Animated.View>
{/* Settings & Preferences Group */} {/* Settings & Preferences Group */}
<TouchableOpacity <TouchableOpacity
style={styles.sectionHeader} style={styles.sectionHeader}

View file

@ -60,6 +60,13 @@ export interface BackupData {
// Onboarding/flags // Onboarding/flags
hasCompletedOnboarding?: boolean; hasCompletedOnboarding?: boolean;
showLoginHintToastOnce?: boolean; showLoginHintToastOnce?: boolean;
// Watched status markers
watchedStatus?: Record<string, boolean>;
// Catalog UI preferences
catalogUiPreferences?: {
mobileColumns?: string;
showTitles?: string;
};
}; };
metadata: { metadata: {
totalItems: number; totalItems: number;
@ -89,7 +96,7 @@ export class BackupService {
private readonly BACKUP_VERSION = '1.0.0'; private readonly BACKUP_VERSION = '1.0.0';
private readonly BACKUP_FILENAME_PREFIX = 'nuvio_backup_'; private readonly BACKUP_FILENAME_PREFIX = 'nuvio_backup_';
private constructor() {} private constructor() { }
public static getInstance(): BackupService { public static getInstance(): BackupService {
if (!BackupService.instance) { if (!BackupService.instance) {
@ -104,11 +111,11 @@ export class BackupService {
public async createBackup(options: BackupOptions = {}): Promise<string> { public async createBackup(options: BackupOptions = {}): Promise<string> {
try { try {
logger.info('[BackupService] Starting backup creation...'); logger.info('[BackupService] Starting backup creation...');
const userScope = await this.getUserScope(); const userScope = await this.getUserScope();
const timestamp = Date.now(); const timestamp = Date.now();
const filename = `${this.BACKUP_FILENAME_PREFIX}${timestamp}.json`; const filename = `${this.BACKUP_FILENAME_PREFIX}${timestamp}.json`;
// Collect all data // Collect all data
const backupData: BackupData = { const backupData: BackupData = {
version: this.BACKUP_VERSION, version: this.BACKUP_VERSION,
@ -136,6 +143,8 @@ export class BackupService {
globalSeasonViewMode: options.includeUserPreferences !== false ? await this.getGlobalSeasonViewMode() : undefined, globalSeasonViewMode: options.includeUserPreferences !== false ? await this.getGlobalSeasonViewMode() : undefined,
hasCompletedOnboarding: options.includeUserPreferences !== false ? await this.getHasCompletedOnboarding() : undefined, hasCompletedOnboarding: options.includeUserPreferences !== false ? await this.getHasCompletedOnboarding() : undefined,
showLoginHintToastOnce: options.includeUserPreferences !== false ? await this.getShowLoginHintToastOnce() : undefined, showLoginHintToastOnce: options.includeUserPreferences !== false ? await this.getShowLoginHintToastOnce() : undefined,
watchedStatus: options.includeWatchProgress !== false ? await this.getWatchedStatus() : undefined,
catalogUiPreferences: options.includeSettings !== false ? await this.getCatalogUiPreferences() : undefined,
}, },
metadata: { metadata: {
totalItems: 0, totalItems: 0,
@ -170,7 +179,7 @@ export class BackupService {
logger.info(`[BackupService] Backup created successfully: ${filename}`); logger.info(`[BackupService] Backup created successfully: ${filename}`);
logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`); logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`);
return fileUri; return fileUri;
} catch (error) { } catch (error) {
logger.error('[BackupService] Failed to create backup:', error); logger.error('[BackupService] Failed to create backup:', error);
@ -187,6 +196,7 @@ export class BackupService {
addons: number; addons: number;
downloads: number; downloads: number;
scrapers: number; scrapers: number;
watchedStatus: number;
total: number; total: number;
}> { }> {
try { try {
@ -195,13 +205,15 @@ export class BackupService {
watchProgressData, watchProgressData,
addonsData, addonsData,
downloadsData, downloadsData,
scrapersData scrapersData,
watchedStatusData
] = await Promise.all([ ] = await Promise.all([
this.getLibrary(), this.getLibrary(),
this.getWatchProgress(), this.getWatchProgress(),
this.getAddons(), this.getAddons(),
this.getDownloads(), this.getDownloads(),
this.getLocalScrapers() this.getLocalScrapers(),
this.getWatchedStatus()
]); ]);
const libraryCount = Array.isArray(libraryData) ? libraryData.length : 0; const libraryCount = Array.isArray(libraryData) ? libraryData.length : 0;
@ -209,6 +221,7 @@ export class BackupService {
const addonsCount = Array.isArray(addonsData) ? addonsData.length : 0; const addonsCount = Array.isArray(addonsData) ? addonsData.length : 0;
const downloadsCount = Array.isArray(downloadsData) ? downloadsData.length : 0; const downloadsCount = Array.isArray(downloadsData) ? downloadsData.length : 0;
const scrapersCount = scrapersData.scrapers ? Object.keys(scrapersData.scrapers).length : 0; const scrapersCount = scrapersData.scrapers ? Object.keys(scrapersData.scrapers).length : 0;
const watchedStatusCount = Object.keys(watchedStatusData).length;
return { return {
library: libraryCount, library: libraryCount,
@ -216,11 +229,12 @@ export class BackupService {
addons: addonsCount, addons: addonsCount,
downloads: downloadsCount, downloads: downloadsCount,
scrapers: scrapersCount, scrapers: scrapersCount,
total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount watchedStatus: watchedStatusCount,
total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount + watchedStatusCount
}; };
} catch (error) { } catch (error) {
logger.error('[BackupService] Failed to get backup preview:', error); logger.error('[BackupService] Failed to get backup preview:', error);
return { library: 0, watchProgress: 0, addons: 0, downloads: 0, scrapers: 0, total: 0 }; return { library: 0, watchProgress: 0, addons: 0, downloads: 0, scrapers: 0, watchedStatus: 0, total: 0 };
} }
} }
@ -230,14 +244,14 @@ export class BackupService {
public async restoreBackup(fileUri: string, options: BackupOptions = {}): Promise<void> { public async restoreBackup(fileUri: string, options: BackupOptions = {}): Promise<void> {
try { try {
logger.info('[BackupService] Starting backup restore...'); logger.info('[BackupService] Starting backup restore...');
// Read and validate backup file // Read and validate backup file
const backupContent = await FileSystem.readAsStringAsync(fileUri); const backupContent = await FileSystem.readAsStringAsync(fileUri);
const backupData: BackupData = JSON.parse(backupContent); const backupData: BackupData = JSON.parse(backupContent);
// Validate backup format // Validate backup format
this.validateBackupData(backupData); this.validateBackupData(backupData);
logger.info(`[BackupService] Restoring backup from ${backupData.timestamp}`); logger.info(`[BackupService] Restoring backup from ${backupData.timestamp}`);
logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`); logger.info(`[BackupService] Backup contains: ${backupData.metadata.totalItems} items`);
@ -245,27 +259,27 @@ export class BackupService {
if (options.includeSettings !== false && backupData.data.settings) { if (options.includeSettings !== false && backupData.data.settings) {
await this.restoreSettings(backupData.data.settings); await this.restoreSettings(backupData.data.settings);
} }
if (options.includeLibrary !== false && backupData.data.library) { if (options.includeLibrary !== false && backupData.data.library) {
await this.restoreLibrary(backupData.data.library); await this.restoreLibrary(backupData.data.library);
} }
if (options.includeWatchProgress !== false && backupData.data.watchProgress) { if (options.includeWatchProgress !== false && backupData.data.watchProgress) {
await this.restoreWatchProgress(backupData.data.watchProgress); await this.restoreWatchProgress(backupData.data.watchProgress);
} }
if (options.includeAddons !== false && backupData.data.addons) { if (options.includeAddons !== false && backupData.data.addons) {
await this.restoreAddons(backupData.data.addons); await this.restoreAddons(backupData.data.addons);
} }
if (options.includeDownloads !== false && backupData.data.downloads) { if (options.includeDownloads !== false && backupData.data.downloads) {
await this.restoreDownloads(backupData.data.downloads); await this.restoreDownloads(backupData.data.downloads);
} }
if (options.includeTraktData !== false && backupData.data.traktSettings) { if (options.includeTraktData !== false && backupData.data.traktSettings) {
await this.restoreTraktSettings(backupData.data.traktSettings); await this.restoreTraktSettings(backupData.data.traktSettings);
} }
if (options.includeLocalScrapers !== false && backupData.data.localScrapers) { if (options.includeLocalScrapers !== false && backupData.data.localScrapers) {
await this.restoreLocalScrapers(backupData.data.localScrapers); await this.restoreLocalScrapers(backupData.data.localScrapers);
} }
@ -312,6 +326,12 @@ export class BackupService {
if (backupData.data.syncQueue) { if (backupData.data.syncQueue) {
await this.restoreSyncQueue(backupData.data.syncQueue); await this.restoreSyncQueue(backupData.data.syncQueue);
} }
if (backupData.data.watchedStatus) {
await this.restoreWatchedStatus(backupData.data.watchedStatus);
}
if (backupData.data.catalogUiPreferences) {
await this.restoreCatalogUiPreferences(backupData.data.catalogUiPreferences);
}
logger.info('[BackupService] Backup restore completed successfully'); logger.info('[BackupService] Backup restore completed successfully');
} catch (error) { } catch (error) {
@ -327,7 +347,7 @@ export class BackupService {
try { try {
const backupContent = await FileSystem.readAsStringAsync(fileUri); const backupContent = await FileSystem.readAsStringAsync(fileUri);
const backupData: BackupData = JSON.parse(backupContent); const backupData: BackupData = JSON.parse(backupContent);
return { return {
version: backupData.version, version: backupData.version,
timestamp: backupData.timestamp, timestamp: backupData.timestamp,
@ -412,10 +432,10 @@ export class BackupService {
try { try {
const scope = await this.getUserScope(); const scope = await this.getUserScope();
const allKeys = await mmkvStorage.getAllKeys(); const allKeys = await mmkvStorage.getAllKeys();
const watchProgressKeys = allKeys.filter(key => const watchProgressKeys = allKeys.filter(key =>
key.startsWith(`@user:${scope}:@watch_progress:`) key.startsWith(`@user:${scope}:@watch_progress:`)
); );
const watchProgress: Record<string, any> = {}; const watchProgress: Record<string, any> = {};
if (watchProgressKeys.length > 0) { if (watchProgressKeys.length > 0) {
const pairs = await mmkvStorage.multiGet(watchProgressKeys); const pairs = await mmkvStorage.multiGet(watchProgressKeys);
@ -460,7 +480,7 @@ export class BackupService {
const scopedKey = `@user:${scope}:@subtitle_settings`; const scopedKey = `@user:${scope}:@subtitle_settings`;
const subtitlesJson = await mmkvStorage.getItem(scopedKey); const subtitlesJson = await mmkvStorage.getItem(scopedKey);
let subtitleSettings = subtitlesJson ? JSON.parse(subtitlesJson) : {}; let subtitleSettings = subtitlesJson ? JSON.parse(subtitlesJson) : {};
// Also check for legacy subtitle size preference // Also check for legacy subtitle size preference
const legacySubtitleSize = await mmkvStorage.getItem('@subtitle_size_preference'); const legacySubtitleSize = await mmkvStorage.getItem('@subtitle_size_preference');
if (legacySubtitleSize && !subtitleSettings.subtitleSize) { if (legacySubtitleSize && !subtitleSettings.subtitleSize) {
@ -469,7 +489,7 @@ export class BackupService {
subtitleSettings.subtitleSize = legacySize; subtitleSettings.subtitleSize = legacySize;
} }
} }
return subtitleSettings; return subtitleSettings;
} catch (error) { } catch (error) {
logger.error('[BackupService] Failed to get subtitle settings:', error); logger.error('[BackupService] Failed to get subtitle settings:', error);
@ -505,10 +525,10 @@ export class BackupService {
try { try {
const scope = await this.getUserScope(); const scope = await this.getUserScope();
const allKeys = await mmkvStorage.getAllKeys(); const allKeys = await mmkvStorage.getAllKeys();
const durationKeys = allKeys.filter(key => const durationKeys = allKeys.filter(key =>
key.startsWith(`@user:${scope}:@content_duration:`) key.startsWith(`@user:${scope}:@content_duration:`)
); );
const contentDuration: Record<string, number> = {}; const contentDuration: Record<string, number> = {};
if (durationKeys.length > 0) { if (durationKeys.length > 0) {
const pairs = await mmkvStorage.multiGet(durationKeys); const pairs = await mmkvStorage.multiGet(durationKeys);
@ -540,7 +560,7 @@ export class BackupService {
// Get general Trakt settings // Get general Trakt settings
const traktSettingsJson = await mmkvStorage.getItem('trakt_settings'); const traktSettingsJson = await mmkvStorage.getItem('trakt_settings');
const traktSettings = traktSettingsJson ? JSON.parse(traktSettingsJson) : {}; const traktSettings = traktSettingsJson ? JSON.parse(traktSettingsJson) : {};
// Get authentication tokens // Get authentication tokens
const [ const [
accessToken, accessToken,
@ -557,7 +577,7 @@ export class BackupService {
mmkvStorage.getItem('trakt_sync_frequency'), mmkvStorage.getItem('trakt_sync_frequency'),
mmkvStorage.getItem('trakt_completion_threshold') mmkvStorage.getItem('trakt_completion_threshold')
]); ]);
return { return {
...traktSettings, ...traktSettings,
authentication: { authentication: {
@ -567,7 +587,7 @@ export class BackupService {
}, },
autosync: { autosync: {
enabled: autosyncEnabled ? (() => { enabled: autosyncEnabled ? (() => {
try { return JSON.parse(autosyncEnabled); } try { return JSON.parse(autosyncEnabled); }
catch { return true; } catch { return true; }
})() : true, })() : true,
frequency: syncFrequency ? parseInt(syncFrequency, 10) : 60000, frequency: syncFrequency ? parseInt(syncFrequency, 10) : 60000,
@ -625,7 +645,7 @@ export class BackupService {
mmkvStorage.getItem('mdblist_api_key'), mmkvStorage.getItem('mdblist_api_key'),
mmkvStorage.getItem('openrouter_api_key') mmkvStorage.getItem('openrouter_api_key')
]); ]);
return { return {
mdblistApiKey: mdblistKey || undefined, mdblistApiKey: mdblistKey || undefined,
openRouterApiKey: openRouterKey || undefined openRouterApiKey: openRouterKey || undefined
@ -650,14 +670,14 @@ export class BackupService {
try { try {
const scope = await this.getUserScope(); const scope = await this.getUserScope();
const scopedKey = `@user:${scope}:stremio-addon-order`; const scopedKey = `@user:${scope}:stremio-addon-order`;
// Try scoped key first, then legacy keys // Try scoped key first, then legacy keys
const [scopedOrder, legacyOrder, localOrder] = await Promise.all([ const [scopedOrder, legacyOrder, localOrder] = await Promise.all([
mmkvStorage.getItem(scopedKey), mmkvStorage.getItem(scopedKey),
mmkvStorage.getItem('stremio-addon-order'), mmkvStorage.getItem('stremio-addon-order'),
mmkvStorage.getItem('@user:local:stremio-addon-order') mmkvStorage.getItem('@user:local:stremio-addon-order')
]); ]);
const orderJson = scopedOrder || legacyOrder || localOrder; const orderJson = scopedOrder || legacyOrder || localOrder;
return orderJson ? JSON.parse(orderJson) : []; return orderJson ? JSON.parse(orderJson) : [];
} catch (error) { } catch (error) {
@ -764,12 +784,12 @@ export class BackupService {
const scope = await this.getUserScope(); const scope = await this.getUserScope();
const scopedKey = `@user:${scope}:@subtitle_settings`; const scopedKey = `@user:${scope}:@subtitle_settings`;
await mmkvStorage.setItem(scopedKey, JSON.stringify(subtitles)); await mmkvStorage.setItem(scopedKey, JSON.stringify(subtitles));
// Also restore legacy subtitle size preference for backward compatibility // Also restore legacy subtitle size preference for backward compatibility
if (subtitles && typeof subtitles.subtitleSize === 'number') { if (subtitles && typeof subtitles.subtitleSize === 'number') {
await mmkvStorage.setItem('@subtitle_size_preference', subtitles.subtitleSize.toString()); await mmkvStorage.setItem('@subtitle_size_preference', subtitles.subtitleSize.toString());
} }
logger.info('[BackupService] Subtitle settings restored'); logger.info('[BackupService] Subtitle settings restored');
} catch (error) { } catch (error) {
logger.error('[BackupService] Failed to restore subtitle settings:', error); logger.error('[BackupService] Failed to restore subtitle settings:', error);
@ -822,48 +842,48 @@ export class BackupService {
// Restore general Trakt settings // Restore general Trakt settings
if (traktSettings && typeof traktSettings === 'object') { if (traktSettings && typeof traktSettings === 'object') {
const { authentication, autosync, ...generalSettings } = traktSettings; const { authentication, autosync, ...generalSettings } = traktSettings;
// Restore general settings // Restore general settings
await mmkvStorage.setItem('trakt_settings', JSON.stringify(generalSettings)); await mmkvStorage.setItem('trakt_settings', JSON.stringify(generalSettings));
// Restore authentication tokens if available // Restore authentication tokens if available
if (authentication) { if (authentication) {
const tokenPromises = []; const tokenPromises = [];
if (authentication.accessToken) { if (authentication.accessToken) {
tokenPromises.push(mmkvStorage.setItem('trakt_access_token', authentication.accessToken)); tokenPromises.push(mmkvStorage.setItem('trakt_access_token', authentication.accessToken));
} }
if (authentication.refreshToken) { if (authentication.refreshToken) {
tokenPromises.push(mmkvStorage.setItem('trakt_refresh_token', authentication.refreshToken)); tokenPromises.push(mmkvStorage.setItem('trakt_refresh_token', authentication.refreshToken));
} }
if (authentication.tokenExpiry) { if (authentication.tokenExpiry) {
tokenPromises.push(mmkvStorage.setItem('trakt_token_expiry', authentication.tokenExpiry.toString())); tokenPromises.push(mmkvStorage.setItem('trakt_token_expiry', authentication.tokenExpiry.toString()));
} }
await Promise.all(tokenPromises); await Promise.all(tokenPromises);
} }
// Restore autosync settings if available // Restore autosync settings if available
if (autosync) { if (autosync) {
const autosyncPromises = []; const autosyncPromises = [];
if (autosync.enabled !== undefined) { if (autosync.enabled !== undefined) {
autosyncPromises.push(mmkvStorage.setItem('trakt_autosync_enabled', JSON.stringify(autosync.enabled))); autosyncPromises.push(mmkvStorage.setItem('trakt_autosync_enabled', JSON.stringify(autosync.enabled)));
} }
if (autosync.frequency !== undefined) { if (autosync.frequency !== undefined) {
autosyncPromises.push(mmkvStorage.setItem('trakt_sync_frequency', autosync.frequency.toString())); autosyncPromises.push(mmkvStorage.setItem('trakt_sync_frequency', autosync.frequency.toString()));
} }
if (autosync.completionThreshold !== undefined) { if (autosync.completionThreshold !== undefined) {
autosyncPromises.push(mmkvStorage.setItem('trakt_completion_threshold', autosync.completionThreshold.toString())); autosyncPromises.push(mmkvStorage.setItem('trakt_completion_threshold', autosync.completionThreshold.toString()));
} }
await Promise.all(autosyncPromises); await Promise.all(autosyncPromises);
} }
logger.info('[BackupService] Trakt settings and authentication restored'); logger.info('[BackupService] Trakt settings and authentication restored');
} }
} catch (error) { } catch (error) {
@ -912,15 +932,15 @@ export class BackupService {
private async restoreApiKeys(apiKeys: { mdblistApiKey?: string; openRouterApiKey?: string }): Promise<void> { private async restoreApiKeys(apiKeys: { mdblistApiKey?: string; openRouterApiKey?: string }): Promise<void> {
try { try {
const setPromises: Promise<void>[] = []; const setPromises: Promise<void>[] = [];
if (apiKeys.mdblistApiKey) { if (apiKeys.mdblistApiKey) {
setPromises.push(mmkvStorage.setItem('mdblist_api_key', apiKeys.mdblistApiKey)); setPromises.push(mmkvStorage.setItem('mdblist_api_key', apiKeys.mdblistApiKey));
} }
if (apiKeys.openRouterApiKey) { if (apiKeys.openRouterApiKey) {
setPromises.push(mmkvStorage.setItem('openrouter_api_key', apiKeys.openRouterApiKey)); setPromises.push(mmkvStorage.setItem('openrouter_api_key', apiKeys.openRouterApiKey));
} }
await Promise.all(setPromises); await Promise.all(setPromises);
logger.info('[BackupService] API keys restored'); logger.info('[BackupService] API keys restored');
} catch (error) { } catch (error) {
@ -941,13 +961,13 @@ export class BackupService {
try { try {
const scope = await this.getUserScope(); const scope = await this.getUserScope();
const scopedKey = `@user:${scope}:stremio-addon-order`; const scopedKey = `@user:${scope}:stremio-addon-order`;
// Restore to both scoped and legacy keys for compatibility // Restore to both scoped and legacy keys for compatibility
await Promise.all([ await Promise.all([
mmkvStorage.setItem(scopedKey, JSON.stringify(addonOrder)), mmkvStorage.setItem(scopedKey, JSON.stringify(addonOrder)),
mmkvStorage.setItem('stremio-addon-order', JSON.stringify(addonOrder)) mmkvStorage.setItem('stremio-addon-order', JSON.stringify(addonOrder))
]); ]);
logger.info('[BackupService] Addon order restored'); logger.info('[BackupService] Addon order restored');
} catch (error) { } catch (error) {
logger.error('[BackupService] Failed to restore addon order:', error); logger.error('[BackupService] Failed to restore addon order:', error);
@ -990,11 +1010,87 @@ export class BackupService {
} }
} }
// Get all watched status markers (watched:movie:* and watched:series:*)
private async getWatchedStatus(): Promise<Record<string, boolean>> {
try {
const allKeys = await mmkvStorage.getAllKeys();
const watchedKeys = allKeys.filter(key =>
key.startsWith('watched:movie:') || key.startsWith('watched:series:') || key.startsWith('watched:')
);
const watchedStatus: Record<string, boolean> = {};
if (watchedKeys.length > 0) {
const pairs = await mmkvStorage.multiGet(watchedKeys);
for (const [key, value] of pairs) {
if (value) {
watchedStatus[key] = value === 'true';
}
}
}
logger.info(`[BackupService] Found ${Object.keys(watchedStatus).length} watched status markers`);
return watchedStatus;
} catch (error) {
logger.error('[BackupService] Failed to get watched status:', error);
return {};
}
}
// Get catalog UI preferences (column count, show titles)
private async getCatalogUiPreferences(): Promise<{ mobileColumns?: string; showTitles?: string }> {
try {
const [mobileColumns, showTitles] = await Promise.all([
mmkvStorage.getItem('catalog_mobile_columns'),
mmkvStorage.getItem('catalog_show_titles')
]);
return {
mobileColumns: mobileColumns || undefined,
showTitles: showTitles || undefined
};
} catch (error) {
logger.error('[BackupService] Failed to get catalog UI preferences:', error);
return {};
}
}
// Restore watched status markers
private async restoreWatchedStatus(watchedStatus: Record<string, boolean>): Promise<void> {
try {
const pairs: [string, string][] = Object.entries(watchedStatus).map(([key, value]) => [key, value ? 'true' : 'false']);
if (pairs.length > 0) {
await mmkvStorage.multiSet(pairs);
}
logger.info(`[BackupService] Restored ${pairs.length} watched status markers`);
} catch (error) {
logger.error('[BackupService] Failed to restore watched status:', error);
}
}
// Restore catalog UI preferences
private async restoreCatalogUiPreferences(prefs: { mobileColumns?: string; showTitles?: string }): Promise<void> {
try {
const setPromises: Promise<void>[] = [];
if (prefs.mobileColumns) {
setPromises.push(mmkvStorage.setItem('catalog_mobile_columns', prefs.mobileColumns));
}
if (prefs.showTitles) {
setPromises.push(mmkvStorage.setItem('catalog_show_titles', prefs.showTitles));
}
await Promise.all(setPromises);
logger.info('[BackupService] Catalog UI preferences restored');
} catch (error) {
logger.error('[BackupService] Failed to restore catalog UI preferences:', error);
}
}
private validateBackupData(backupData: any): void { private validateBackupData(backupData: any): void {
if (!backupData.version || !backupData.timestamp || !backupData.data) { if (!backupData.version || !backupData.timestamp || !backupData.data) {
throw new Error('Invalid backup file format'); throw new Error('Invalid backup file format');
} }
if (backupData.version !== this.BACKUP_VERSION) { if (backupData.version !== this.BACKUP_VERSION) {
throw new Error(`Unsupported backup version: ${backupData.version}`); throw new Error(`Unsupported backup version: ${backupData.version}`);
} }

View file

@ -1,9 +1,8 @@
import { mmkvStorage } from './mmkvStorage'; import { mmkvStorage } from './mmkvStorage';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
const DEV_URL = ''; // Campaign API URL - use env variable, fallback to local dev server
const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || ''; const CAMPAIGN_API_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || 'http://localhost:3000';
const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL;
export type CampaignAction = { export type CampaignAction = {
type: 'link' | 'navigate' | 'dismiss'; type: 'link' | 'navigate' | 'dismiss';
@ -15,7 +14,9 @@ export type CampaignAction = {
export type CampaignContent = { export type CampaignContent = {
title?: string; title?: string;
message?: string; message?: string;
mediaType?: 'image' | 'video';
imageUrl?: string; imageUrl?: string;
videoUrl?: string;
backgroundColor?: string; backgroundColor?: string;
textColor?: string; textColor?: string;
closeButtonColor?: string; closeButtonColor?: string;
@ -58,11 +59,16 @@ class CampaignService {
try { try {
const now = Date.now(); const now = Date.now();
console.log('[CampaignService] getActiveCampaign called, API URL:', CAMPAIGN_API_URL);
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) { if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
console.log('[CampaignService] Using cached campaigns');
return this.getNextValidCampaign(); return this.getNextValidCampaign();
} }
const platform = Platform.OS; const platform = Platform.OS;
const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`;
console.log('[CampaignService] Fetching from:', url);
const response = await fetch( const response = await fetch(
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`, `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
{ {
@ -89,13 +95,20 @@ class CampaignService {
if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) { if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) {
campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`; campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`;
} }
if (campaign.content?.videoUrl && campaign.content.videoUrl.startsWith('/')) {
campaign.content.videoUrl = `${CAMPAIGN_API_URL}${campaign.content.videoUrl}`;
}
}); });
console.log('[CampaignService] Fetched campaigns:', campaigns.length, 'CAMPAIGN_API_URL:', CAMPAIGN_API_URL);
this.campaignQueue = campaigns; this.campaignQueue = campaigns;
this.currentIndex = 0; this.currentIndex = 0;
this.lastFetch = now; this.lastFetch = now;
return this.getNextValidCampaign(); const result = this.getNextValidCampaign();
console.log('[CampaignService] Next valid campaign:', result?.id, result?.type);
return result;
} catch (error) { } catch (error) {
console.warn('[CampaignService] Error fetching campaigns:', error); console.warn('[CampaignService] Error fetching campaigns:', error);
return null; return null;