focus enabled for hoemscreena and metascreen

This commit is contained in:
tapframe 2025-12-26 15:29:30 +05:30
parent 703c3e3cfb
commit 18c18257c9
12 changed files with 693 additions and 640 deletions

11
App.tsx
View file

@ -120,7 +120,14 @@ const ThemedApp = () => {
try {
// Check onboarding status
const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding');
setHasCompletedOnboarding(onboardingCompleted === 'true');
// On TV, auto-complete onboarding to skip the tutorial screens
if (Platform.isTV && onboardingCompleted !== 'true') {
await mmkvStorage.setItem('hasCompletedOnboarding', 'true');
setHasCompletedOnboarding(true);
} else {
setHasCompletedOnboarding(onboardingCompleted === 'true');
}
// Initialize update service
await UpdateService.initialize();
@ -135,7 +142,7 @@ const ThemedApp = () => {
// Check if announcement should be shown (version 1.0.0)
const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
if (!announcementShown && onboardingCompleted === 'true') {
if (!announcementShown && (onboardingCompleted === 'true' || Platform.isTV) && !Platform.isTV) {
// Show announcement only after app is ready
setTimeout(() => {
setShowAnnouncement(true);

View file

@ -1187,6 +1187,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
});
}
}}
focusable={Platform.isTV}
>
<View
style={[
@ -1197,7 +1198,6 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
]}
onLayout={(event) => {
const { height } = event.nativeEvent.layout;
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
}}
>
<Image
@ -1223,6 +1223,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
});
}
}}
focusable={Platform.isTV}
>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
@ -1256,6 +1257,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
style={[styles.playButton]}
onPress={handlePlayAction}
activeOpacity={0.85}
hasTVPreferredFocus={Platform.isTV}
focusable={Platform.isTV}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
@ -1270,6 +1273,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
style={styles.saveButton}
onPress={handleSaveAction}
activeOpacity={0.85}
focusable={Platform.isTV}
>
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}

View file

@ -443,7 +443,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
<LinearGradient
colors={[
'transparent',
'transparent',
'transparent',
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.7)',
'rgba(0,0,0,0.95)'
@ -459,7 +459,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<FastImage
source={{
source={{
uri: logoUrl,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
@ -506,6 +506,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
}
}}
activeOpacity={0.8}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="play-arrow" size={28} color={currentTheme.colors.black} />
<Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}>
@ -536,7 +537,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
</TouchableOpacity>
</Animated.View>
</Animated.View>
{/* Bottom fade to blend with background */}
<LinearGradient
colors={[
@ -589,7 +590,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<FastImage
source={{
source={{
uri: logoUrl,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
@ -641,6 +642,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
}
}}
activeOpacity={0.8}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
@ -663,7 +665,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
</ImageBackground>
</Animated.View>
</TouchableOpacity>
{/* Bottom fade to blend with background */}
<LinearGradient
colors={[

View file

@ -394,6 +394,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
{(loopingEnabled ? loopData : data).map((item, index) => (
/* TEST 5: ORIGINAL CARD WITHOUT LINEAR GRADIENT */
<CarouselCard
hasTVPreferredFocus={Platform.isTV && (loopingEnabled ? index === 1 : index === 0)}
key={`${item.id}-${index}-${loopingEnabled ? 'loop' : 'base'}`}
item={item}
colors={currentTheme.colors}
@ -607,9 +608,10 @@ interface CarouselCardProps {
cardWidth: number;
cardHeight: number;
isTablet: boolean;
hasTVPreferredFocus?: boolean;
}
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => {
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet, hasTVPreferredFocus }) => {
const [bannerLoaded, setBannerLoaded] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(false);
@ -851,13 +853,13 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
</Text>
</ScrollView>
</View>
<TouchableOpacity activeOpacity={0.9} onPress={onPressInfo} style={StyleSheet.absoluteFillObject as any} />
<TouchableOpacity activeOpacity={0.9} onPress={onPressInfo} style={StyleSheet.absoluteFillObject as any} hasTVPreferredFocus={hasTVPreferredFocus} />
</>
) : (
<>
{/* FRONT FACE */}
<Animated.View style={[styles.flipFace as any, styles.frontFace as any, frontFlipStyle]} pointerEvents={flipped ? 'none' : 'auto'}>
<TouchableOpacity activeOpacity={0.9} onPress={onPressInfo} style={StyleSheet.absoluteFillObject as any}>
<TouchableOpacity activeOpacity={0.9} onPress={onPressInfo} style={StyleSheet.absoluteFillObject as any} hasTVPreferredFocus={hasTVPreferredFocus && !isTablet}>
<View style={styles.bannerContainer as ViewStyle}>
{!bannerLoaded && (
<View style={styles.skeletonBannerFull as ViewStyle} />

View file

@ -7,6 +7,7 @@ import {
TouchableOpacity,
ActivityIndicator,
Dimensions,
Platform,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import Animated, {
@ -40,7 +41,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
@ -48,13 +49,13 @@ export const CastSection: React.FC<CastSectionProps> = ({
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
@ -68,7 +69,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
return 16; // phone
}
}, [deviceType]);
// Enhanced cast card sizing
const castCardWidth = useMemo(() => {
switch (deviceType) {
@ -82,7 +83,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
return 90; // phone
}
}, [deviceType]);
const castImageSize = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -95,7 +96,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
return 80; // phone
}
}, [deviceType]);
const castCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -122,7 +123,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
}
return (
<Animated.View
<Animated.View
style={styles.castSection}
entering={FadeIn.duration(300).delay(150)}
>
@ -131,8 +132,8 @@ export const CastSection: React.FC<CastSectionProps> = ({
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.sectionTitle,
{
styles.sectionTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
@ -149,10 +150,10 @@ export const CastSection: React.FC<CastSectionProps> = ({
]}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<Animated.View
entering={FadeIn.duration(300).delay(50 + index * 30)}
<Animated.View
entering={FadeIn.duration(300).delay(50 + index * 30)}
>
<TouchableOpacity
<TouchableOpacity
style={[
styles.castCard,
{
@ -182,15 +183,15 @@ export const CastSection: React.FC<CastSectionProps> = ({
/>
) : (
<View style={[
styles.castImagePlaceholder,
{
styles.castImagePlaceholder,
{
backgroundColor: currentTheme.colors.darkBackground,
borderRadius: castImageSize / 2
}
]}>
<Text style={[
styles.placeholderText,
{
styles.placeholderText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
@ -201,8 +202,8 @@ export const CastSection: React.FC<CastSectionProps> = ({
)}
</View>
<Text style={[
styles.castName,
{
styles.castName,
{
color: currentTheme.colors.text,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
width: castCardWidth
@ -210,8 +211,8 @@ export const CastSection: React.FC<CastSectionProps> = ({
]} numberOfLines={1}>{item.name}</Text>
{isTmdbEnrichmentEnabled && item.character && (
<Text style={[
styles.characterName,
{
styles.characterName,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
width: castCardWidth,

View file

@ -7,6 +7,7 @@ import {
TouchableOpacity,
ActivityIndicator,
Dimensions,
Platform,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native';
@ -34,10 +35,10 @@ interface CollectionSectionProps {
loadingCollection: boolean;
}
export const CollectionSection: React.FC<CollectionSectionProps> = ({
collectionName,
collectionMovies,
loadingCollection
export const CollectionSection: React.FC<CollectionSectionProps> = ({
collectionName,
collectionMovies,
loadingCollection
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -82,7 +83,7 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
default: return 180;
}
}, [deviceType]);
const backdropHeight = React.useMemo(() => backdropWidth * (9/16), [backdropWidth]); // 16:9 aspect ratio
const backdropHeight = React.useMemo(() => backdropWidth * (9 / 16), [backdropWidth]); // 16:9 aspect ratio
const [alertVisible, setAlertVisible] = React.useState(false);
const [alertTitle, setAlertTitle] = React.useState('');
@ -93,15 +94,15 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
try {
// Extract TMDB ID from the tmdb:123456 format
const tmdbId = item.id.replace('tmdb:', '');
// Get Stremio ID directly using catalogService
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
if (stremioId) {
navigation.dispatch(
StackActions.push('Metadata', {
id: stremioId,
type: item.type
StackActions.push('Metadata', {
id: stremioId,
type: item.type
})
);
} else {
@ -111,7 +112,7 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
if (__DEV__) console.error('Error navigating to collection item:', error);
setAlertTitle('Error');
setAlertMessage('Unable to load this content. Please try again later.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
}
};
@ -120,9 +121,9 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
// Upcoming/unreleased movies without a year will be sorted last
const sortedCollectionMovies = React.useMemo(() => {
if (!collectionMovies) return [];
const FUTURE_YEAR_PLACEHOLDER = 9999; // Very large number to sort unreleased movies last
return [...collectionMovies].sort((a, b) => {
// Treat missing years as future year placeholder (sorts last)
const yearA = a.year ? parseInt(a.year.toString()) : FUTURE_YEAR_PLACEHOLDER;
@ -132,31 +133,31 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
}, [collectionMovies]);
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
<TouchableOpacity
style={[styles.itemContainer, { width: backdropWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.banner || item.poster }}
style={[styles.backdrop, {
backgroundColor: currentTheme.colors.elevation1,
width: backdropWidth,
height: backdropHeight,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
style={[styles.backdrop, {
backgroundColor: currentTheme.colors.elevation1,
width: backdropWidth,
height: backdropHeight,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
}]}
resizeMode={FastImage.resizeMode.cover}
/>
<Text style={[styles.title, {
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
lineHeight: isTV ? 20 : 18
<Text style={[styles.title, {
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
lineHeight: isTV ? 20 : 18
}]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.year, {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
<Text style={[styles.year, {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
}]}>
{item.year}
</Text>
@ -177,11 +178,11 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<Text style={[styles.sectionTitle, {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
paddingHorizontal: horizontalPadding
<View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
paddingHorizontal: horizontalPadding
}]}>
{collectionName}
</Text>
@ -191,9 +192,9 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.listContentContainer, {
paddingHorizontal: horizontalPadding,
paddingRight: horizontalPadding + itemSpacing
contentContainerStyle={[styles.listContentContainer, {
paddingHorizontal: horizontalPadding,
paddingRight: horizontalPadding + itemSpacing
}]}
/>
<CustomAlert

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import {
TouchableOpacity,
ActivityIndicator,
Dimensions,
Platform,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native';
@ -33,9 +34,9 @@ interface MoreLikeThisSectionProps {
loadingRecommendations: boolean;
}
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -91,16 +92,16 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
try {
// Extract TMDB ID from the tmdb:123456 format
const tmdbId = item.id.replace('tmdb:', '');
// Get Stremio ID directly using catalogService
// The catalogService.getStremioId method already handles the conversion internally
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
if (stremioId) {
navigation.dispatch(
StackActions.push('Metadata', {
id: stremioId,
type: item.type
StackActions.push('Metadata', {
id: stremioId,
type: item.type
})
);
} else {
@ -110,13 +111,13 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
if (__DEV__) console.error('Error navigating to recommendation:', error);
setAlertTitle('Error');
setAlertMessage('Unable to load this content. Please try again later.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
}
};
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
<TouchableOpacity
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
@ -144,7 +145,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
<FlatList
data={recommendations}

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable, Platform } from 'react-native';
import * as Haptics from 'expo-haptics';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
@ -794,12 +794,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
borderRadius: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
}
]}
onPress={() => {
const newMode = seasonViewMode === 'posters' ? 'text' : 'posters';
updateViewMode(newMode);
if (__DEV__) console.log('[SeriesContent] View mode changed to:', newMode, 'Current ref value:', seasonViewMode);
}}
activeOpacity={0.7}
focusable={Platform.isTV}
>
<Text style={[
styles.seasonViewToggleText,
@ -863,6 +859,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
selectedSeason === season && styles.selectedSeasonTextButton
]}
onPress={() => onSeasonChange(season)}
focusable={Platform.isTV}
>
<Text style={[
styles.seasonTextButtonText,
@ -898,6 +895,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
]}
onPress={() => onSeasonChange(season)}
focusable={Platform.isTV}
>
<View style={[
styles.seasonPosterContainer,
@ -945,7 +943,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
}}
keyExtractor={season => season.toString()}
/>
</View>
</View >
);
};
@ -1039,6 +1037,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
onLongPress={() => handleEpisodeLongPress(episode)}
delayLongPress={400}
activeOpacity={0.7}
focusable={Platform.isTV}
>
<View style={[
styles.episodeImageContainer,
@ -1322,6 +1321,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
onLongPress={() => handleEpisodeLongPress(episode)}
delayLongPress={400}
activeOpacity={0.85}
focusable={Platform.isTV}
>
{/* Solid outline replaces gradient border */}

View file

@ -530,6 +530,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
]}
onPress={toggleDropdown}
activeOpacity={0.8}
focusable={Platform.isTV}
>
<Text
style={[
@ -585,6 +586,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
]}
onPress={() => handleCategorySelect(category)}
activeOpacity={0.7}
focusable={Platform.isTV}
>
<View style={styles.dropdownItemContent}>
<View style={[
@ -664,6 +666,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
]}
onPress={() => handleTrailerPress(trailer)}
activeOpacity={0.9}
focusable={Platform.isTV}
>
{/* Thumbnail with Gradient Overlay */}
<View style={styles.thumbnailWrapper}>

View file

@ -14,8 +14,6 @@ import {
Easing,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as Sharing from 'expo-sharing';
import * as Updates from 'expo-updates';
import { useNavigation } from '@react-navigation/native';
import { backupService } from '../services/backupService';
@ -24,24 +22,40 @@ import { logger } from '../utils/logger';
import CustomAlert from '../components/CustomAlert';
import { useBackupOptions } from '../hooks/useBackupOptions';
// Check if running on TV platform
const isTV = Platform.isTV;
// Conditionally import expo-document-picker and expo-sharing (not available on TV)
let DocumentPicker: typeof import('expo-document-picker') | null = null;
let Sharing: typeof import('expo-sharing') | null = null;
if (!isTV) {
try {
DocumentPicker = require('expo-document-picker');
Sharing = require('expo-sharing');
} catch (e) {
logger.warn('[BackupScreen] Document picker/sharing not available');
}
}
const BackupScreen: React.FC = () => {
const { currentTheme } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const navigation = useNavigation();
const { preferences, updatePreference, getBackupOptions } = useBackupOptions();
// Collapsible sections state
const [expandedSections, setExpandedSections] = useState({
coreData: false,
addonsIntegrations: false,
settingsPreferences: false,
});
// Animated values for each section
const coreDataAnim = useRef(new Animated.Value(0)).current;
const addonsAnim = useRef(new Animated.Value(0)).current;
const settingsAnim = useRef(new Animated.Value(0)).current;
// Chevron rotation animated values
const coreDataChevron = useRef(new Animated.Value(0)).current;
const addonsChevron = useRef(new Animated.Value(0)).current;
@ -60,7 +74,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 +87,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: () => { } }]
);
}
};
@ -81,10 +95,10 @@ const BackupScreen: React.FC = () => {
// Toggle section collapse/expand
const toggleSection = useCallback((section: 'coreData' | 'addonsIntegrations' | 'settingsPreferences') => {
const isExpanded = expandedSections[section];
let heightAnim: Animated.Value;
let chevronAnim: Animated.Value;
if (section === 'coreData') {
heightAnim = coreDataAnim;
chevronAnim = coreDataChevron;
@ -95,7 +109,7 @@ const BackupScreen: React.FC = () => {
heightAnim = settingsAnim;
chevronAnim = settingsChevron;
}
// Animate height and chevron rotation
Animated.parallel([
Animated.timing(heightAnim, {
@ -111,8 +125,8 @@ const BackupScreen: React.FC = () => {
easing: Easing.inOut(Easing.ease),
}),
]).start();
setExpandedSections(prev => ({...prev, [section]: !isExpanded}));
setExpandedSections(prev => ({ ...prev, [section]: !isExpanded }));
}, [expandedSections, coreDataAnim, addonsAnim, settingsAnim, coreDataChevron, addonsChevron, settingsChevron]);
// Create backup
@ -157,51 +171,54 @@ 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 (Sharing && await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, {
mimeType: 'application/json',
dialogTitle: 'Share Nuvio Backup',
});
} else {
openAlert('Info', 'Backup created successfully at ' + fileUri);
return;
}
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);
}
@ -210,6 +227,11 @@ const BackupScreen: React.FC = () => {
// Restore backup
const handleRestoreBackup = useCallback(async () => {
try {
if (!DocumentPicker) {
openAlert('Not Supported', 'Backup restore is not supported on this device/platform.');
return;
}
const result = await DocumentPicker.getDocumentAsync({
type: 'application/json',
copyToCacheDirectory: true,
@ -228,7 +250,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,9 +265,9 @@ const BackupScreen: React.FC = () => {
'Restore Complete',
'Your data has been successfully restored. Please restart the app to see all changes.',
[
{ label: 'Cancel', onPress: () => {} },
{
label: 'Restart App',
{ label: 'Cancel', onPress: () => { } },
{
label: 'Restart App',
onPress: restartApp,
style: { fontWeight: 'bold' }
}
@ -256,7 +278,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 +292,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]);
@ -281,26 +303,26 @@ const BackupScreen: React.FC = () => {
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but keeping structure consistent */}
</View>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
Backup & Restore
</Text>
{/* Content */}
<ScrollView
style={styles.scrollView}
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
@ -321,7 +343,7 @@ const BackupScreen: React.FC = () => {
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Choose what to include in your backups
</Text>
{/* Core Data Group */}
<TouchableOpacity
style={styles.sectionHeader}
@ -369,7 +391,7 @@ const BackupScreen: React.FC = () => {
theme={currentTheme}
/>
</Animated.View>
{/* Addons & Integrations Group */}
<TouchableOpacity
style={styles.sectionHeader}
@ -424,7 +446,7 @@ const BackupScreen: React.FC = () => {
theme={currentTheme}
/>
</Animated.View>
{/* Settings & Preferences Group */}
<TouchableOpacity
style={styles.sectionHeader}

View file

@ -868,6 +868,7 @@ const MetadataScreen: React.FC = () => {
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadMetadata}
focusable={Platform.isTV}
>
<MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>Try Again</Text>
@ -875,6 +876,7 @@ const MetadataScreen: React.FC = () => {
<TouchableOpacity
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
onPress={handleBack}
focusable={Platform.isTV}
>
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text>
</TouchableOpacity>
@ -1245,6 +1247,7 @@ const MetadataScreen: React.FC = () => {
type: 'movie',
title: metadata.name || 'Gallery'
})}
focusable={Platform.isTV}
>
<Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>Backdrop Gallery</Text>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} />
@ -1385,6 +1388,7 @@ const MetadataScreen: React.FC = () => {
type: 'tv',
title: metadata.name || 'Gallery'
})}
focusable={Platform.isTV}
>
<Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>Backdrop Gallery</Text>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} />