diff --git a/App.tsx b/App.tsx index ff4b26f..c737fa3 100644 --- a/App.tsx +++ b/App.tsx @@ -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); diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 29a53c4..d801a95 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -1187,6 +1187,7 @@ const AppleTVHero: React.FC = ({ }); } }} + focusable={Platform.isTV} > = ({ ]} onLayout={(event) => { const { height } = event.nativeEvent.layout; - setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); }} > = ({ }); } }} + focusable={Platform.isTV} > @@ -1256,6 +1257,8 @@ const AppleTVHero: React.FC = ({ style={[styles.playButton]} onPress={handlePlayAction} activeOpacity={0.85} + hasTVPreferredFocus={Platform.isTV} + focusable={Platform.isTV} > = ({ style={styles.saveButton} onPress={handleSaveAction} activeOpacity={0.85} + focusable={Platform.isTV} > @@ -536,7 +537,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin - + {/* Bottom fade to blend with background */} @@ -663,7 +665,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin - + {/* Bottom fade to blend with background */} = ({ items, loading = false }) = {(loopingEnabled ? loopData : data).map((item, index) => ( /* TEST 5: ORIGINAL CARD WITHOUT LINEAR GRADIENT */ = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => { +const CarouselCard: React.FC = 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 = memo(({ item, colors, logoFail - + ) : ( <> {/* FRONT FACE */} - + {!bannerLoaded && ( diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx index 3f24812..58aab0b 100644 --- a/src/components/metadata/CastSection.tsx +++ b/src/components/metadata/CastSection.tsx @@ -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 = ({ // 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 = ({ 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 = ({ return 16; // phone } }, [deviceType]); - + // Enhanced cast card sizing const castCardWidth = useMemo(() => { switch (deviceType) { @@ -82,7 +83,7 @@ export const CastSection: React.FC = ({ return 90; // phone } }, [deviceType]); - + const castImageSize = useMemo(() => { switch (deviceType) { case 'tv': @@ -95,7 +96,7 @@ export const CastSection: React.FC = ({ return 80; // phone } }, [deviceType]); - + const castCardSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -122,7 +123,7 @@ export const CastSection: React.FC = ({ } return ( - @@ -131,8 +132,8 @@ export const CastSection: React.FC = ({ { paddingHorizontal: horizontalPadding } ]}> = ({ ]} keyExtractor={(item) => item.id.toString()} renderItem={({ item, index }) => ( - - = ({ /> ) : ( = ({ )} = ({ ]} numberOfLines={1}>{item.name} {isTmdbEnrichmentEnabled && item.character && ( = ({ - collectionName, - collectionMovies, - loadingCollection +export const CollectionSection: React.FC = ({ + collectionName, + collectionMovies, + loadingCollection }) => { const { currentTheme } = useTheme(); const navigation = useNavigation>(); @@ -82,7 +83,7 @@ export const CollectionSection: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ // 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 = ({ }, [collectionMovies]); const renderItem = ({ item }: { item: StreamingContent }) => ( - handleItemPress(item)} > - {item.name} {item.year && ( - {item.year} @@ -177,11 +178,11 @@ export const CollectionSection: React.FC = ({ } return ( - - + {collectionName} @@ -191,9 +192,9 @@ export const CollectionSection: React.FC = ({ keyExtractor={(item) => item.id} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={[styles.listContentContainer, { - paddingHorizontal: horizontalPadding, - paddingRight: horizontalPadding + itemSpacing + contentContainerStyle={[styles.listContentContainer, { + paddingHorizontal: horizontalPadding, + paddingRight: horizontalPadding + itemSpacing }]} /> { const { currentTheme } = useTheme(); const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast(); - + // Performance optimization: Cache theme colors const themeColors = useMemo(() => ({ white: currentTheme.colors.white, black: '#000', primary: currentTheme.colors.primary }), [currentTheme.colors.white, currentTheme.colors.primary]); - + // Optimized navigation handler with useCallback const handleRatingsPress = useCallback(async () => { // Early return if no ID if (!id) return; - + let finalTmdbId: number | null = null; - + if (id.startsWith('tmdb:')) { const numericPart = id.split(':')[1]; const parsedId = parseInt(numericPart, 10); @@ -187,7 +187,7 @@ const ActionButtons = memo(({ finalTmdbId = parsedId; } } - + if (finalTmdbId !== null) { // Use requestAnimationFrame for smoother navigation requestAnimationFrame(() => { @@ -199,15 +199,15 @@ const ActionButtons = memo(({ // Enhanced save handler that combines local library + Trakt watchlist const handleSaveAction = useCallback(async () => { const wasInLibrary = inLibrary; - + // Always toggle local library first toggleLibrary(); - + // If authenticated, also toggle Trakt watchlist if (isAuthenticated && onToggleWatchlist) { await onToggleWatchlist(); } - + // Show appropriate toast if (isAuthenticated) { if (wasInLibrary) { @@ -227,12 +227,12 @@ const ActionButtons = memo(({ // Enhanced collection handler with toast notifications const handleCollectionAction = useCallback(async () => { const wasInCollection = isInCollection; - + // Toggle collection if (onToggleCollection) { await onToggleCollection(); } - + // Show appropriate toast if (wasInCollection) { showInfo('Removed from Collection', 'Removed from your Trakt collection'); @@ -295,10 +295,10 @@ const ActionButtons = memo(({ // For watched episodes, check if next episode exists const nextEpisode = episodeNum + 1; const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; - const nextEpisodeExists = currentSeasonEpisodes.some(ep => + const nextEpisodeExists = currentSeasonEpisodes.some(ep => ep.episode_number === nextEpisode ); - + if (nextEpisodeExists) { // Show the NEXT episode number only if it exists const seasonStr = seasonNum.toString().padStart(2, '0'); @@ -311,10 +311,10 @@ const ActionButtons = memo(({ } else { // For non-watched episodes, check if current episode exists const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; - const currentEpisodeExists = currentSeasonEpisodes.some(ep => + const currentEpisodeExists = currentSeasonEpisodes.some(ep => ep.episode_number === episodeNum ); - + if (currentEpisodeExists) { // Current episode exists, use original button text return playButtonText; @@ -336,141 +336,146 @@ const ActionButtons = memo(({ // Count additional buttons (excluding Play and Save) - AI Chat no longer counted const hasTraktCollection = isAuthenticated; const hasRatings = type === 'series'; - + // Count additional buttons (AI Chat removed - now in top right corner) const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0); - + return ( {/* Single Row Layout - Play, Save, and optionally Collection/Ratings */} - - { - if (isWatched) { - return type === 'movie' ? 'replay' : 'play-arrow'; - } - return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; - })()} - size={isTablet ? 28 : 24} - color={isWatched && type === 'movie' ? "#fff" : "#000"} - /> - {finalPlayButtonText} - + + { + if (isWatched) { + return type === 'movie' ? 'replay' : 'play-arrow'; + } + return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; + })()} + size={isTablet ? 28 : 24} + color={isWatched && type === 'movie' ? "#fff" : "#000"} + /> + {finalPlayButtonText} + + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) + ) : ( + + )} + + + {inLibrary ? 'Saved' : 'Save'} + + + + {/* Trakt Collection Button */} + {hasTraktCollection && ( {Platform.OS === 'ios' ? ( GlassViewComp && liquidGlassAvailable ? ( ) : ( - + ) ) : ( - + )} - - {inLibrary ? 'Saved' : 'Save'} - + )} - {/* Trakt Collection Button */} - {hasTraktCollection && ( - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) + {/* Ratings Button (for series) */} + {hasRatings && ( + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + ) : ( - - )} - - - )} - - {/* Ratings Button (for series) */} - {hasRatings && ( - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) - ) : ( - - )} - - - )} + + ) + ) : ( + + )} + + + )} ); }); // Enhanced WatchProgress Component with Trakt integration and watched status -const WatchProgressDisplay = memo(({ - watchProgress, - type, - getEpisodeDetails, +const WatchProgressDisplay = memo(({ + watchProgress, + type, + getEpisodeDetails, animatedStyle, isWatched, isTrailerPlaying, trailerMuted, trailerReady }: { - watchProgress: { - currentTime: number; - duration: number; - lastUpdated: number; + watchProgress: { + currentTime: number; + duration: number; + lastUpdated: number; episodeId?: string; traktSynced?: boolean; traktProgress?: number; @@ -485,11 +490,11 @@ const WatchProgressDisplay = memo(({ }) => { const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext(); - + // State to trigger refresh after manual sync const [refreshTrigger, setRefreshTrigger] = useState(0); const [isSyncing, setIsSyncing] = useState(false); - + // Animated values for enhanced effects const completionGlow = useSharedValue(0); const celebrationScale = useSharedValue(1); @@ -498,7 +503,7 @@ const WatchProgressDisplay = memo(({ const progressBoxScale = useSharedValue(0.8); const progressBoxTranslateY = useSharedValue(20); const syncRotation = useSharedValue(0); - + // Animate the sync icon when syncing useEffect(() => { if (isSyncing) { @@ -511,7 +516,7 @@ const WatchProgressDisplay = memo(({ syncRotation.value = 0; } }, [isSyncing, syncRotation]); - + // Handle manual Trakt sync const handleTraktSync = useMemo(() => async () => { if (isTraktAuthenticated && forceSyncTraktProgress) { @@ -520,7 +525,7 @@ const WatchProgressDisplay = memo(({ try { const success = await forceSyncTraktProgress(); logger.log(`[HeroSection] Manual Trakt sync ${success ? 'successful' : 'failed'}`); - + // Force component to re-render after a short delay to update sync status if (success) { setTimeout(() => { @@ -541,7 +546,7 @@ const WatchProgressDisplay = memo(({ const syncIconStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${syncRotation.value}deg` }], })); - + // Memoized progress calculation with Trakt integration const progressData = useMemo(() => { // If content is fully watched, show watched status instead of progress @@ -553,16 +558,16 @@ const WatchProgressDisplay = memo(({ episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; } } - - const watchedDate = watchProgress?.lastUpdated + + const watchedDate = watchProgress?.lastUpdated ? new Date(watchProgress.lastUpdated).toLocaleDateString('en-US') : new Date().toLocaleDateString('en-US'); - + // Determine if watched via Trakt or local - const watchedViaTrakt = isTraktAuthenticated && - watchProgress?.traktProgress !== undefined && + const watchedViaTrakt = isTraktAuthenticated && + watchProgress?.traktProgress !== undefined && watchProgress.traktProgress >= 95; - + return { progressPercent: 100, formattedTime: watchedDate, @@ -579,7 +584,7 @@ const WatchProgressDisplay = memo(({ // Determine which progress to show - prioritize Trakt if available and authenticated let progressPercent; let isUsingTraktProgress = false; - + if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) { progressPercent = watchProgress.traktProgress; isUsingTraktProgress = true; @@ -599,7 +604,7 @@ const WatchProgressDisplay = memo(({ // Enhanced display text with Trakt integration let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`; let syncStatus = ''; - + // Show Trakt sync status if user is authenticated if (isTraktAuthenticated) { if (isUsingTraktProgress) { @@ -610,8 +615,8 @@ const WatchProgressDisplay = memo(({ } else if (watchProgress.traktSynced) { syncStatus = ' • Synced with Trakt'; // If we have specific Trakt progress that differs from local, mention it - if (watchProgress.traktProgress !== undefined && - Math.abs(progressPercent - watchProgress.traktProgress) > 5) { + if (watchProgress.traktProgress !== undefined && + Math.abs(progressPercent - watchProgress.traktProgress) > 5) { displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`; } } else { @@ -638,7 +643,7 @@ const WatchProgressDisplay = memo(({ progressBoxOpacity.value = withTiming(1, { duration: 400 }); progressBoxScale.value = withTiming(1, { duration: 400 }); progressBoxTranslateY.value = withTiming(0, { duration: 400 }); - + if (progressData.isWatched || (progressData.progressPercent && progressData.progressPercent >= 85)) { // Celebration animation sequence celebrationScale.value = withRepeat( @@ -646,7 +651,7 @@ const WatchProgressDisplay = memo(({ 2, true ); - + // Glow effect completionGlow.value = withRepeat( withTiming(1, { duration: 1500 }), @@ -712,34 +717,34 @@ const WatchProgressDisplay = memo(({ ) : ( )} - + {/* Enhanced progress bar with glow effects */} - + {/* Background glow for completed content */} {isCompleted && ( )} - - - + } + ]} + /> + {/* Shimmer effect for active progress */} {!isCompleted && progressData.progressPercent > 0 && ( @@ -768,46 +773,46 @@ const WatchProgressDisplay = memo(({ {progressData.episodeInfo} )} - + {/* Trakt sync status with enhanced styling */} {progressData.syncStatus && ( - - {progressData.syncStatus} - - + {progressData.syncStatus} + + {/* Enhanced manual Trakt sync button - moved inline */} - {isTraktAuthenticated && forceSyncTraktProgress && ( - - - - + > + + + - + )} - )} - + )} + ); @@ -896,12 +901,12 @@ const HeroSection: React.FC = memo(({ // Guards to avoid repeated auto-starts const startedOnFocusRef = useRef(false); const startedOnReadyRef = useRef(false); - + // Animation values for trailer unmute effects const actionButtonsOpacity = useSharedValue(1); const titleCardTranslateY = useSharedValue(0); const genreOpacity = useSharedValue(1); - + // Ultra-optimized theme colors with stable references const themeColors = useMemo(() => ({ black: currentTheme.colors.black, @@ -932,7 +937,7 @@ const HeroSection: React.FC = memo(({ setTrailerPreloaded(true); } setTrailerReady(true); - + // Smooth transition: fade out thumbnail, fade in trailer thumbnailOpacity.value = withTiming(0, { duration: 500 }); trailerOpacity.value = withTiming(1, { duration: 500 }); @@ -948,7 +953,7 @@ const HeroSection: React.FC = memo(({ try { const y = (scrollY as any).value || 0; const pauseThreshold = heroHeight.value * 0.7; - + if (y < pauseThreshold) { startedOnReadyRef.current = true; logger.info('HeroSection', 'Trailer ready - auto-starting playback'); @@ -987,7 +992,7 @@ const HeroSection: React.FC = memo(({ setTrailerError(true); setTrailerReady(false); setTrailerPlaying(false); - + // Fade back to thumbnail trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); @@ -997,11 +1002,11 @@ const HeroSection: React.FC = memo(({ const handleTrailerEnd = useCallback(async () => { logger.info('HeroSection', 'Trailer ended - transitioning back to thumbnail'); setTrailerPlaying(false); - + // Reset trailer state to prevent auto-restart setTrailerReady(false); setTrailerPreloaded(false); - + // If trailer is in fullscreen, dismiss it first try { if (trailerVideoRef.current) { @@ -1011,22 +1016,22 @@ const HeroSection: React.FC = memo(({ } catch (error) { logger.warn('HeroSection', 'Error dismissing fullscreen player:', error); } - + // Smooth fade transition: trailer out, thumbnail in trailerOpacity.value = withTiming(0, { duration: 500 }); thumbnailOpacity.value = withTiming(1, { duration: 500 }); - + // Show UI elements again actionButtonsOpacity.value = withTiming(1, { duration: 500 }); genreOpacity.value = withTiming(1, { duration: 500 }); titleCardTranslateY.value = withTiming(0, { duration: 500 }); watchProgressOpacity.value = withTiming(1, { duration: 500 }); }, [trailerOpacity, thumbnailOpacity, actionButtonsOpacity, genreOpacity, titleCardTranslateY, watchProgressOpacity, setTrailerPlaying]); - + // Memoized image source - const imageSource = useMemo(() => + const imageSource = useMemo(() => bannerImage || metadata.banner || metadata.poster - , [bannerImage, metadata.banner, metadata.poster]); + , [bannerImage, metadata.banner, metadata.poster]); // Use the logo provided by metadata (already enriched by useMetadataAssets based on settings) const logoUri = useMemo(() => { @@ -1048,13 +1053,13 @@ const HeroSection: React.FC = memo(({ useEffect(() => { // Check if metadata logo has actually changed from what we last processed const currentMetadataLogo = metadata?.logo; - + if (currentMetadataLogo !== lastSyncedLogoRef.current) { lastSyncedLogoRef.current = currentMetadataLogo; // Reset text fallback and timers on logo updates if (logoWaitTimerRef.current) { - try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} + try { clearTimeout(logoWaitTimerRef.current); } catch (_e) { } logoWaitTimerRef.current = null; } @@ -1076,10 +1081,10 @@ const HeroSection: React.FC = memo(({ }, 600); } } - + return () => { if (logoWaitTimerRef.current) { - try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} + try { clearTimeout(logoWaitTimerRef.current); } catch (_e) { } logoWaitTimerRef.current = null; } }; @@ -1109,7 +1114,7 @@ const HeroSection: React.FC = memo(({ } // If logo loaded successfully before, keep showing it even if it fails later }, [logoHasLoadedSuccessfully, stableLogoUri, metadata, logoLoadOpacity]); - + // Performance optimization: Lazy loading setup useEffect(() => { const timer = InteractionManager.runAfterInteractions(() => { @@ -1118,7 +1123,7 @@ const HeroSection: React.FC = memo(({ setShouldLoadSecondaryData(true); } }); - + return () => timer.cancel(); }, []); @@ -1128,25 +1133,25 @@ const HeroSection: React.FC = memo(({ let timerId: any = null; const fetchTrailer = async () => { if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return; - + // If we expect TMDB ID but don't have it yet, wait a bit more if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) { logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`); return; } - + setTrailerLoading(true); setTrailerError(false); setTrailerReady(false); setTrailerPreloaded(false); - + try { // Use requestIdleCallback or setTimeout to prevent blocking main thread const fetchWithDelay = () => { // Extract TMDB ID if available const tmdbIdString = tmdbId ? String(tmdbId) : undefined; const contentType = type === 'series' ? 'tv' : 'movie'; - + // Debug logging to see what we have logger.info('HeroSection', `Trailer request for ${metadata.name}:`, { hasTmdbId: !!tmdbId, @@ -1155,7 +1160,7 @@ const HeroSection: React.FC = memo(({ metadataKeys: Object.keys(metadata || {}), metadataId: metadata?.id }); - + TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType) .then(url => { if (url) { @@ -1190,12 +1195,12 @@ const HeroSection: React.FC = memo(({ fetchTrailer(); return () => { alive = false; - try { if (timerId) clearTimeout(timerId); } catch (_e) {} + try { if (timerId) clearTimeout(timerId); } catch (_e) { } }; }, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]); // Shimmer animation removed - + // Optimized loading state reset when image source changes useEffect(() => { if (imageSource) { @@ -1203,19 +1208,19 @@ const HeroSection: React.FC = memo(({ imageLoadOpacity.value = 0; } }, [imageSource]); - + // Optimized image handlers with useCallback const handleImageError = useCallback(() => { if (!shouldLoadSecondaryData) return; - + runOnUI(() => { imageOpacity.value = withTiming(0.6, { duration: 150 }); imageLoadOpacity.value = withTiming(0, { duration: 150 }); })(); - + setImageError(true); setImageLoaded(false); - + // Three-level fallback: TMDB → addon banner → poster if (bannerImage !== metadata.banner && metadata.banner) { // Try addon banner if not already on it and it exists @@ -1231,7 +1236,7 @@ const HeroSection: React.FC = memo(({ imageOpacity.value = withTiming(1, { duration: 150 }); imageLoadOpacity.value = withTiming(1, { duration: 400 }); })(); - + setImageError(false); setImageLoaded(true); }, []); @@ -1245,10 +1250,10 @@ const HeroSection: React.FC = memo(({ const logoAnimatedStyle = useAnimatedStyle(() => { // Determine if progress bar should be shown const hasProgress = watchProgress && watchProgress.duration > 0; - + // Scale down logo when progress bar is present const logoScale = hasProgress ? 0.85 : 1; - + return { opacity: logoOpacity.value, transform: [ @@ -1271,22 +1276,22 @@ const HeroSection: React.FC = memo(({ const backdropImageStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; - + // Pre-calculated constants for better performance const DEFAULT_ZOOM = 1.1; const SCROLL_UP_MULTIPLIER = 0.002; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.4; const PARALLAX_FACTOR = 0.3; - + // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); - + // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; - + return { opacity: imageOpacity.value * imageLoadOpacity.value, transform: [ @@ -1299,7 +1304,7 @@ const HeroSection: React.FC = memo(({ // Simplified buttons animation const buttonsAnimatedStyle = useAnimatedStyle(() => ({ opacity: buttonsOpacity.value * actionButtonsOpacity.value, - transform: [{ + transform: [{ translateY: interpolate( buttonsTranslateY.value, [0, 20], @@ -1323,22 +1328,22 @@ const HeroSection: React.FC = memo(({ const trailerParallaxStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; - + // Pre-calculated constants for better performance const DEFAULT_ZOOM = 1.0; const SCROLL_UP_MULTIPLIER = 0.0015; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.25; const PARALLAX_FACTOR = 0.2; - + // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); - + // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; - + return { transform: [ { scale }, @@ -1394,14 +1399,14 @@ const HeroSection: React.FC = memo(({ // Calculate if content is watched (>=85% progress) - check both local and Trakt progress const isWatched = useMemo(() => { if (!watchProgress) return false; - + // Check Trakt progress first if available and user is authenticated if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) { const traktWatched = watchProgress.traktProgress >= 95; // Removed excessive logging for Trakt progress return traktWatched; } - + // Fall back to local progress if (watchProgress.duration === 0) return false; const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; @@ -1457,7 +1462,7 @@ const HeroSection: React.FC = memo(({ } }, 50); } - + return () => { // Stop trailer when leaving this screen to prevent background playback/heat logger.info('HeroSection', 'Screen unfocused - stopping trailer playback'); @@ -1490,7 +1495,7 @@ const HeroSection: React.FC = memo(({ setTrailerUrl(null); trailerOpacity.value = 0; thumbnailOpacity.value = 1; - } catch (_e) {} + } catch (_e) { } } }, [isFocused, setTrailerPlaying]); @@ -1499,7 +1504,7 @@ const HeroSection: React.FC = memo(({ 'worklet'; try { if (!scrollGuardEnabledSV.value || isFocusedSV.value === 0) return; - + // Pre-calculate thresholds for better performance const pauseThreshold = heroHeight.value * 0.7; const resumeThreshold = heroHeight.value * 0.4; @@ -1528,7 +1533,7 @@ const HeroSection: React.FC = memo(({ // Don't stop trailer playback when component unmounts // Let the new hero section (if any) take control of trailer state // This prevents the trailer from stopping when navigating between screens - + // Reset animation values on unmount to prevent memory leaks try { imageOpacity.value = 1; @@ -1548,7 +1553,7 @@ const HeroSection: React.FC = memo(({ } catch (error) { logger.error('HeroSection', 'Error cleaning up animation values:', error); } - + interactionComplete.current = false; }; }, [imageOpacity, imageLoadOpacity, trailerOpacity, thumbnailOpacity, actionButtonsOpacity, titleCardTranslateY, genreOpacity, watchProgressOpacity, buttonsOpacity, buttonsTranslateY, logoOpacity, heroOpacity, heroHeight]); @@ -1574,46 +1579,46 @@ const HeroSection: React.FC = memo(({ {/* Optimized Background */} - - {/* Shimmer loading effect removed */} - - {/* Background thumbnail image - always rendered when available with parallax */} - {shouldLoadSecondaryData && imageSource && !loadingBanner && ( - - - - )} - - {/* Hidden preload trailer player - loads in background */} - {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( - - - - )} - - {/* Visible trailer player - rendered on top with fade transition and parallax */} - {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && ( - - + + + )} + + {/* Hidden preload trailer player - loads in background */} + {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( + + + + )} + + {/* Visible trailer player - rendered on top with fade transition and parallax */} + {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && ( + + = memo(({ } }} /> - - )} + + )} - {/* Trailer control buttons (unmute and fullscreen) */} - {settings?.showTrailers && trailerReady && trailerUrl && ( - = 768 ? 32 : 16, - zIndex: 1000, - opacity: trailerOpacity, - flexDirection: 'row', - gap: 8, - }}> - {/* Fullscreen button */} - e.stopPropagation()} - onPressOut={(e) => e.stopPropagation()} - style={{ - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 20, - }} - > - - + {/* Trailer control buttons (unmute and fullscreen) */} + {settings?.showTrailers && trailerReady && trailerUrl && ( + = 768 ? 32 : 16, + zIndex: 1000, + opacity: trailerOpacity, + flexDirection: 'row', + gap: 8, + }}> + {/* Fullscreen button */} + e.stopPropagation()} + onPressOut={(e) => e.stopPropagation()} + style={{ + padding: 8, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + }} + > + + - {/* Unmute button */} - { - logger.info('HeroSection', 'Mute toggle button pressed, current muted state:', trailerMuted); - updateSetting('trailerMuted', !trailerMuted); - if (trailerMuted) { - // When unmuting, hide action buttons, genre, title card, and watch progress - actionButtonsOpacity.value = withTiming(0, { duration: 300 }); - genreOpacity.value = withTiming(0, { duration: 300 }); - titleCardTranslateY.value = withTiming(100, { duration: 300 }); // Increased from 60 to 120 for further down movement - watchProgressOpacity.value = withTiming(0, { duration: 300 }); - } else { - // When muting, show action buttons, genre, title card, and watch progress - actionButtonsOpacity.value = withTiming(1, { duration: 300 }); - genreOpacity.value = withTiming(1, { duration: 300 }); - titleCardTranslateY.value = withTiming(0, { duration: 300 }); - watchProgressOpacity.value = withTiming(1, { duration: 300 }); - } - }} - activeOpacity={0.7} - onPressIn={(e) => e.stopPropagation()} - onPressOut={(e) => e.stopPropagation()} - style={{ - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 20, - }} - > - - + {/* Unmute button */} + { + logger.info('HeroSection', 'Mute toggle button pressed, current muted state:', trailerMuted); + updateSetting('trailerMuted', !trailerMuted); + if (trailerMuted) { + // When unmuting, hide action buttons, genre, title card, and watch progress + actionButtonsOpacity.value = withTiming(0, { duration: 300 }); + genreOpacity.value = withTiming(0, { duration: 300 }); + titleCardTranslateY.value = withTiming(100, { duration: 300 }); // Increased from 60 to 120 for further down movement + watchProgressOpacity.value = withTiming(0, { duration: 300 }); + } else { + // When muting, show action buttons, genre, title card, and watch progress + actionButtonsOpacity.value = withTiming(1, { duration: 300 }); + genreOpacity.value = withTiming(1, { duration: 300 }); + titleCardTranslateY.value = withTiming(0, { duration: 300 }); + watchProgressOpacity.value = withTiming(1, { duration: 300 }); + } + }} + activeOpacity={0.7} + onPressIn={(e) => e.stopPropagation()} + onPressOut={(e) => e.stopPropagation()} + style={{ + padding: 8, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + }} + > + + - {/* AI Chat button */} - {settings?.aiChatEnabled && ( + {/* AI Chat button */} + {settings?.aiChatEnabled && ( + { + // Extract episode info if it's a series + let episodeData = null; + if (type === 'series' && watchProgress && watchProgress.episodeId) { + const parts = watchProgress.episodeId.split(':'); + if (parts.length >= 3) { + episodeData = { + seasonNumber: parseInt(parts[1], 10), + episodeNumber: parseInt(parts[2], 10) + }; + } + } + + navigation.navigate('AIChat', { + contentId: id, + contentType: type, + episodeId: episodeData && watchProgress ? watchProgress.episodeId : undefined, + seasonNumber: episodeData?.seasonNumber, + episodeNumber: episodeData?.episodeNumber, + title: metadata?.name || metadata?.title || 'Unknown' + }); + }} + activeOpacity={0.7} + onPressIn={(e) => e.stopPropagation()} + onPressOut={(e) => e.stopPropagation()} + style={{ + padding: 8, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 20, + }} + > + + + )} + + )} + + {/* AI Chat button (when trailers are disabled) */} + {settings?.aiChatEnabled && !(settings?.showTrailers && trailerReady && trailerUrl) && ( + = 768 ? 32 : 16, + zIndex: 1000, + }}> { // Extract episode info if it's a series @@ -1726,8 +1781,7 @@ const HeroSection: React.FC = memo(({ }); }} activeOpacity={0.7} - onPressIn={(e) => e.stopPropagation()} - onPressOut={(e) => e.stopPropagation()} + focusable={Platform.isTV} style={{ padding: 8, backgroundColor: 'rgba(0, 0, 0, 0.5)', @@ -1740,164 +1794,116 @@ const HeroSection: React.FC = memo(({ color="white" /> - )} - - )} + + )} - {/* AI Chat button (when trailers are disabled) */} - {settings?.aiChatEnabled && !(settings?.showTrailers && trailerReady && trailerUrl) && ( - = 768 ? 32 : 16, - zIndex: 1000, - }}> - { - // Extract episode info if it's a series - let episodeData = null; - if (type === 'series' && watchProgress && watchProgress.episodeId) { - const parts = watchProgress.episodeId.split(':'); - if (parts.length >= 3) { - episodeData = { - seasonNumber: parseInt(parts[1], 10), - episodeNumber: parseInt(parts[2], 10) - }; - } - } - - navigation.navigate('AIChat', { - contentId: id, - contentType: type, - episodeId: episodeData && watchProgress ? watchProgress.episodeId : undefined, - seasonNumber: episodeData?.seasonNumber, - episodeNumber: episodeData?.episodeNumber, - title: metadata?.name || metadata?.title || 'Unknown' - }); - }} - activeOpacity={0.7} - style={{ - padding: 8, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - borderRadius: 20, - }} - > + + - )} - - - - - - - {/* Ultra-light Gradient with subtle dynamic background blend */} - - {/* Enhanced bottom fade with stronger gradient */} + {/* Ultra-light Gradient with subtle dynamic background blend */} - - {/* Optimized Title/Logo - Show logo immediately when available */} - - - {metadata?.logo ? ( - - ) : shouldShowTextFallback ? ( - - {metadata.name} - - ) : ( - // Reserve space to prevent layout jump while waiting briefly for logo - - )} - - - - {/* Enhanced Watch Progress with Trakt integration */} - + {/* Enhanced bottom fade with stronger gradient */} + - - {/* Optimized genre display with lazy loading; no fixed blank space */} - {shouldLoadSecondaryData && genreElements && ( - - {genreElements} + + {/* Optimized Title/Logo - Show logo immediately when available */} + + + {metadata?.logo ? ( + + ) : shouldShowTextFallback ? ( + + {metadata.name} + + ) : ( + // Reserve space to prevent layout jump while waiting briefly for logo + + )} + - )} + + {/* Enhanced Watch Progress with Trakt integration */} + + + {/* Optimized genre display with lazy loading; no fixed blank space */} + {shouldLoadSecondaryData && genreElements && ( + + {genreElements} + + )} - {/* Optimized Action Buttons */} - - - + {/* Optimized Action Buttons */} + + + ); @@ -2429,7 +2435,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - + // Tablet-specific styles tabletActionButtons: { flexDirection: 'column', @@ -2531,27 +2537,27 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, tabletProgressGlassBackground: { - width: width * 0.7, - maxWidth: 700, - backgroundColor: 'rgba(255,255,255,0.08)', - borderRadius: 16, - padding: 12, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', - overflow: 'hidden', - alignSelf: 'center', - }, - tabletWatchProgressMainText: { - fontSize: 14, - fontWeight: '600', - textAlign: 'center', - }, - tabletWatchProgressSubText: { - fontSize: 12, - textAlign: 'center', - opacity: 0.8, - marginBottom: 1, - }, + width: width * 0.7, + maxWidth: 700, + backgroundColor: 'rgba(255,255,255,0.08)', + borderRadius: 16, + padding: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + overflow: 'hidden', + alignSelf: 'center', + }, + tabletWatchProgressMainText: { + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + }, + tabletWatchProgressSubText: { + fontSize: 12, + textAlign: 'center', + opacity: 0.8, + marginBottom: 1, + }, }); export default HeroSection; diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx index b09044d..3fca29b 100644 --- a/src/components/metadata/MoreLikeThisSection.tsx +++ b/src/components/metadata/MoreLikeThisSection.tsx @@ -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 = ({ - recommendations, - loadingRecommendations +export const MoreLikeThisSection: React.FC = ({ + recommendations, + loadingRecommendations }) => { const { currentTheme } = useTheme(); const navigation = useNavigation>(); @@ -91,16 +92,16 @@ export const MoreLikeThisSection: React.FC = ({ 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 = ({ 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 }) => ( - handleItemPress(item)} > @@ -144,7 +145,7 @@ export const MoreLikeThisSection: React.FC = ({ } return ( - + More Like This = ({ 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} > = ({ selectedSeason === season && styles.selectedSeasonTextButton ]} onPress={() => onSeasonChange(season)} + focusable={Platform.isTV} > = ({ selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }] ]} onPress={() => onSeasonChange(season)} + focusable={Platform.isTV} > = ({ }} keyExtractor={season => season.toString()} /> - + ); }; @@ -1039,6 +1037,7 @@ const SeriesContentComponent: React.FC = ({ onLongPress={() => handleEpisodeLongPress(episode)} delayLongPress={400} activeOpacity={0.7} + focusable={Platform.isTV} > = ({ onLongPress={() => handleEpisodeLongPress(episode)} delayLongPress={400} activeOpacity={0.85} + focusable={Platform.isTV} > {/* Solid outline replaces gradient border */} diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index c1f261d..536e3c9 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -530,6 +530,7 @@ const TrailersSection: React.FC = memo(({ ]} onPress={toggleDropdown} activeOpacity={0.8} + focusable={Platform.isTV} > = memo(({ ]} onPress={() => handleCategorySelect(category)} activeOpacity={0.7} + focusable={Platform.isTV} > = memo(({ ]} onPress={() => handleTrailerPress(trailer)} activeOpacity={0.9} + focusable={Platform.isTV} > {/* Thumbnail with Gradient Overlay */} diff --git a/src/screens/BackupScreen.tsx b/src/screens/BackupScreen.tsx index 97cd785..f6a3ff3 100644 --- a/src/screens/BackupScreen.tsx +++ b/src/screens/BackupScreen.tsx @@ -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 */} - navigation.goBack()} > Settings - + {/* Empty for now, but keeping structure consistent */} - + Backup & Restore {/* Content */} - @@ -321,7 +343,7 @@ const BackupScreen: React.FC = () => { Choose what to include in your backups - + {/* Core Data Group */} { theme={currentTheme} /> - + {/* Addons & Integrations Group */} { theme={currentTheme} /> - + {/* Settings & Preferences Group */} { Try Again @@ -875,6 +876,7 @@ const MetadataScreen: React.FC = () => { Go Back @@ -1245,6 +1247,7 @@ const MetadataScreen: React.FC = () => { type: 'movie', title: metadata.name || 'Gallery' })} + focusable={Platform.isTV} > Backdrop Gallery @@ -1385,6 +1388,7 @@ const MetadataScreen: React.FC = () => { type: 'tv', title: metadata.name || 'Gallery' })} + focusable={Platform.isTV} > Backdrop Gallery