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

@ -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: () => { } }]
); );
} }
}; };
@ -112,7 +112,7 @@ const BackupScreen: React.FC = () => {
}), }),
]).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,7 +246,7 @@ 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,
@ -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]);

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) {
@ -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,
@ -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 };
} }
} }
@ -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) {
@ -990,6 +1010,82 @@ 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');

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;