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 ? (
<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" 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,
} }
]}> ]}>
{content.mediaType === 'video' && content.videoUrl ? (
<Video
source={{ uri: content.videoUrl }}
style={styles.image}
resizeMode="cover"
repeat={true}
muted={true}
paused={false}
playInBackground={false}
playWhenInactive={false}
/>
) : content.imageUrl ? (
<Image <Image
source={{ uri: content.imageUrl }} source={{ uri: content.imageUrl }}
style={styles.image} style={styles.image}
resizeMode="cover" resizeMode="cover"
/> />
) : null}
</View> </View>
)} )}

View file

@ -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(

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;
@ -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;