mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 16:31:44 +00:00
added video support to sdui server
This commit is contained in:
parent
fd6e29a8ec
commit
44abb9f635
6 changed files with 275 additions and 1598 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
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 { BlurView } from 'expo-blur';
|
||||
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'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{content.imageUrl && (
|
||||
<Image
|
||||
source={{ uri: content.imageUrl }}
|
||||
style={[
|
||||
styles.bottomSheetImage,
|
||||
{
|
||||
aspectRatio: content.aspectRatio || 1.5,
|
||||
maxHeight: imageMaxHeight,
|
||||
}
|
||||
]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{/* Media - Image or Video */}
|
||||
{(content.imageUrl || content.videoUrl) && (
|
||||
<View style={[
|
||||
styles.bottomSheetImage,
|
||||
{
|
||||
aspectRatio: content.aspectRatio || 1.5,
|
||||
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"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.bottomSheetContent}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -13,6 +13,7 @@ import { BlurView } from 'expo-blur';
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Campaign } from '../../services/campaignService';
|
||||
import Video from 'react-native-video';
|
||||
|
||||
interface PosterModalProps {
|
||||
campaign: Campaign;
|
||||
|
|
@ -94,7 +95,8 @@ export const PosterModal: React.FC<PosterModalProps> = ({
|
|||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{content.imageUrl && (
|
||||
{/* Media Container - Image or Video */}
|
||||
{(content.imageUrl || content.videoUrl) && (
|
||||
<View style={[
|
||||
styles.imageContainer,
|
||||
{
|
||||
|
|
@ -102,11 +104,24 @@ export const PosterModal: React.FC<PosterModalProps> = ({
|
|||
maxHeight: maxImageHeight,
|
||||
}
|
||||
]}>
|
||||
<Image
|
||||
source={{ uri: content.imageUrl }}
|
||||
style={styles.image}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{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
|
||||
source={{ uri: content.imageUrl }}
|
||||
style={styles.image}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const BackupScreen: React.FC = () => {
|
|||
) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ const BackupScreen: React.FC = () => {
|
|||
openAlert(
|
||||
'Restart Failed',
|
||||
'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();
|
||||
|
||||
setExpandedSections(prev => ({...prev, [section]: !isExpanded}));
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !isExpanded }));
|
||||
}, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]);
|
||||
|
||||
// Create backup
|
||||
|
|
@ -135,6 +135,9 @@ const BackupScreen: React.FC = () => {
|
|||
if (preferences.includeWatchProgress) {
|
||||
items.push(`Watch Progress: ${preview.watchProgress} entries`);
|
||||
total += preview.watchProgress;
|
||||
// Include watched status with watch progress
|
||||
items.push(`Watched Status: ${preview.watchedStatus} items`);
|
||||
total += preview.watchedStatus;
|
||||
}
|
||||
|
||||
if (preferences.includeAddons) {
|
||||
|
|
@ -149,7 +152,7 @@ const BackupScreen: React.FC = () => {
|
|||
|
||||
// Check if no items are selected
|
||||
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.`;
|
||||
|
||||
openAlert(
|
||||
|
|
@ -157,51 +160,51 @@ const BackupScreen: React.FC = () => {
|
|||
message,
|
||||
items.length > 0
|
||||
? [
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{
|
||||
label: 'Create Backup',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Create Backup',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const backupOptions = getBackupOptions();
|
||||
const backupOptions = getBackupOptions();
|
||||
|
||||
const fileUri = await backupService.createBackup(backupOptions);
|
||||
const fileUri = await backupService.createBackup(backupOptions);
|
||||
|
||||
// Share the backup file
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(fileUri, {
|
||||
mimeType: 'application/json',
|
||||
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);
|
||||
// Share the backup file
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(fileUri, {
|
||||
mimeType: 'application/json',
|
||||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
: [{ label: 'OK', onPress: () => {} }]
|
||||
}
|
||||
]
|
||||
: [{ label: 'OK', onPress: () => { } }]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[BackupScreen] Failed to get backup preview:', error);
|
||||
openAlert(
|
||||
'Error',
|
||||
'Failed to prepare backup information. Please try again.',
|
||||
[{ label: 'OK', onPress: () => {} }]
|
||||
[{ label: 'OK', onPress: () => { } }]
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -228,7 +231,7 @@ const BackupScreen: React.FC = () => {
|
|||
'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?`,
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Restore',
|
||||
onPress: async () => {
|
||||
|
|
@ -243,7 +246,7 @@ const BackupScreen: React.FC = () => {
|
|||
'Restore Complete',
|
||||
'Your data has been successfully restored. Please restart the app to see all changes.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Restart App',
|
||||
onPress: restartApp,
|
||||
|
|
@ -256,7 +259,7 @@ const BackupScreen: React.FC = () => {
|
|||
openAlert(
|
||||
'Restore Failed',
|
||||
`Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
[{ label: 'OK', onPress: () => {} }]
|
||||
[{ label: 'OK', onPress: () => { } }]
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
@ -270,7 +273,7 @@ const BackupScreen: React.FC = () => {
|
|||
openAlert(
|
||||
'File Selection Failed',
|
||||
`Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
[{ label: 'OK', onPress: () => {} }]
|
||||
[{ label: 'OK', onPress: () => { } }]
|
||||
);
|
||||
}
|
||||
}, [openAlert]);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ export interface BackupData {
|
|||
// Onboarding/flags
|
||||
hasCompletedOnboarding?: boolean;
|
||||
showLoginHintToastOnce?: boolean;
|
||||
// Watched status markers
|
||||
watchedStatus?: Record<string, boolean>;
|
||||
// Catalog UI preferences
|
||||
catalogUiPreferences?: {
|
||||
mobileColumns?: string;
|
||||
showTitles?: string;
|
||||
};
|
||||
};
|
||||
metadata: {
|
||||
totalItems: number;
|
||||
|
|
@ -89,7 +96,7 @@ export class BackupService {
|
|||
private readonly BACKUP_VERSION = '1.0.0';
|
||||
private readonly BACKUP_FILENAME_PREFIX = 'nuvio_backup_';
|
||||
|
||||
private constructor() {}
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): BackupService {
|
||||
if (!BackupService.instance) {
|
||||
|
|
@ -136,6 +143,8 @@ export class BackupService {
|
|||
globalSeasonViewMode: options.includeUserPreferences !== false ? await this.getGlobalSeasonViewMode() : undefined,
|
||||
hasCompletedOnboarding: options.includeUserPreferences !== false ? await this.getHasCompletedOnboarding() : 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: {
|
||||
totalItems: 0,
|
||||
|
|
@ -187,6 +196,7 @@ export class BackupService {
|
|||
addons: number;
|
||||
downloads: number;
|
||||
scrapers: number;
|
||||
watchedStatus: number;
|
||||
total: number;
|
||||
}> {
|
||||
try {
|
||||
|
|
@ -195,13 +205,15 @@ export class BackupService {
|
|||
watchProgressData,
|
||||
addonsData,
|
||||
downloadsData,
|
||||
scrapersData
|
||||
scrapersData,
|
||||
watchedStatusData
|
||||
] = await Promise.all([
|
||||
this.getLibrary(),
|
||||
this.getWatchProgress(),
|
||||
this.getAddons(),
|
||||
this.getDownloads(),
|
||||
this.getLocalScrapers()
|
||||
this.getLocalScrapers(),
|
||||
this.getWatchedStatus()
|
||||
]);
|
||||
|
||||
const libraryCount = Array.isArray(libraryData) ? libraryData.length : 0;
|
||||
|
|
@ -209,6 +221,7 @@ export class BackupService {
|
|||
const addonsCount = Array.isArray(addonsData) ? addonsData.length : 0;
|
||||
const downloadsCount = Array.isArray(downloadsData) ? downloadsData.length : 0;
|
||||
const scrapersCount = scrapersData.scrapers ? Object.keys(scrapersData.scrapers).length : 0;
|
||||
const watchedStatusCount = Object.keys(watchedStatusData).length;
|
||||
|
||||
return {
|
||||
library: libraryCount,
|
||||
|
|
@ -216,11 +229,12 @@ export class BackupService {
|
|||
addons: addonsCount,
|
||||
downloads: downloadsCount,
|
||||
scrapers: scrapersCount,
|
||||
total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount
|
||||
watchedStatus: watchedStatusCount,
|
||||
total: libraryCount + watchProgressCount + addonsCount + downloadsCount + scrapersCount + watchedStatusCount
|
||||
};
|
||||
} catch (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) {
|
||||
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');
|
||||
} 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 {
|
||||
if (!backupData.version || !backupData.timestamp || !backupData.data) {
|
||||
throw new Error('Invalid backup file format');
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { mmkvStorage } from './mmkvStorage';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const DEV_URL = '';
|
||||
const PROD_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || '';
|
||||
const CAMPAIGN_API_URL = __DEV__ ? DEV_URL : PROD_URL;
|
||||
// Campaign API URL - use env variable, fallback to local dev server
|
||||
const CAMPAIGN_API_URL = process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || 'http://localhost:3000';
|
||||
|
||||
export type CampaignAction = {
|
||||
type: 'link' | 'navigate' | 'dismiss';
|
||||
|
|
@ -15,7 +14,9 @@ export type CampaignAction = {
|
|||
export type CampaignContent = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
mediaType?: 'image' | 'video';
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
closeButtonColor?: string;
|
||||
|
|
@ -58,11 +59,16 @@ class CampaignService {
|
|||
try {
|
||||
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) {
|
||||
console.log('[CampaignService] Using cached campaigns');
|
||||
return this.getNextValidCampaign();
|
||||
}
|
||||
|
||||
const platform = Platform.OS;
|
||||
const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`;
|
||||
console.log('[CampaignService] Fetching from:', url);
|
||||
const response = await fetch(
|
||||
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
|
||||
{
|
||||
|
|
@ -89,13 +95,20 @@ class CampaignService {
|
|||
if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) {
|
||||
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.currentIndex = 0;
|
||||
this.lastFetch = now;
|
||||
|
||||
return this.getNextValidCampaign();
|
||||
const result = this.getNextValidCampaign();
|
||||
console.log('[CampaignService] Next valid campaign:', result?.id, result?.type);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('[CampaignService] Error fetching campaigns:', error);
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue