removed reanimated

This commit is contained in:
tapframe 2025-08-02 14:26:47 +05:30
parent 604b38ba20
commit 332cf99f67
14 changed files with 1100 additions and 2262 deletions

View file

@ -19,7 +19,7 @@ import AppNavigator, {
CustomNavigationDarkTheme, CustomNavigationDarkTheme,
CustomDarkTheme CustomDarkTheme
} from './src/navigation/AppNavigator'; } from './src/navigation/AppNavigator';
import 'react-native-reanimated'; // Removed react-native-reanimated import
import { CatalogProvider } from './src/contexts/CatalogContext'; import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext'; import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext'; import { TraktProvider } from './src/contexts/TraktContext';

View file

@ -49,7 +49,7 @@
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.20.2",
"react-native-immersive-mode": "^2.0.2", "react-native-immersive-mode": "^2.0.2",
"react-native-paper": "^5.13.1", "react-native-paper": "^5.13.1",
"react-native-reanimated": "~3.6.0", "react-native-reanimated": "@latest",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2", "react-native-svg": "^15.11.2",

View file

@ -11,7 +11,7 @@ import {
Alert, Alert,
ActivityIndicator ActivityIndicator
} from 'react-native'; } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; // Removed react-native-reanimated import
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
@ -572,7 +572,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
return ( return (
<Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}> <View style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text> <Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text>
@ -609,13 +609,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{/* Delete Indicator Overlay */} {/* Delete Indicator Overlay */}
{deletingItemId === item.id && ( {deletingItemId === item.id && (
<Animated.View <View style={styles.deletingOverlay}>
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={styles.deletingOverlay}
>
<ActivityIndicator size="large" color="#FFFFFF" /> <ActivityIndicator size="large" color="#FFFFFF" />
</Animated.View> </View>
)} )}
</View> </View>
@ -700,7 +696,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
snapToAlignment="start" snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />} ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
/> />
</Animated.View> </View>
); );
}); });

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React from 'react';
import { import {
View, View,
Text, Text,
@ -13,19 +13,6 @@ import {
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { colors } from '../../styles/colors'; import { colors } from '../../styles/colors';
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
interpolate,
Extrapolate,
runOnJS,
} from 'react-native-reanimated';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import { StreamingContent } from '../../services/catalogService'; import { StreamingContent } from '../../services/catalogService';
interface DropUpMenuProps { interface DropUpMenuProps {
@ -36,144 +23,92 @@ interface DropUpMenuProps {
} }
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { export const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300);
const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
const SNAP_THRESHOLD = 100;
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
translateY.value = withTiming(0, { duration: 300 });
} else {
opacity.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(300, { duration: 300 });
}
}, [visible]);
const gesture = Gesture.Pan()
.onStart(() => {
// Store initial position if needed
})
.onUpdate((event) => {
if (event.translationY > 0) { // Only allow dragging downwards
translateY.value = event.translationY;
opacity.value = interpolate(
event.translationY,
[0, 300],
[1, 0],
Extrapolate.CLAMP
);
}
})
.onEnd((event) => {
if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) {
translateY.value = withTiming(300, { duration: 300 });
opacity.value = withTiming(0, { duration: 200 });
runOnJS(onClose)();
} else {
translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 });
}
});
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const menuStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
}));
const menuOptions = [ const menuOptions = [
{ { id: 'play', label: 'Play', icon: 'play-arrow' },
icon: item.inLibrary ? 'bookmark' : 'bookmark-border', { id: 'info', label: 'More Info', icon: 'info-outline' },
label: item.inLibrary ? 'Remove from Library' : 'Add to Library', { id: 'save', label: 'Add to My List', icon: 'bookmark-border' },
action: 'library' { id: 'share', label: 'Share', icon: 'share' },
},
{
icon: 'check-circle',
label: 'Mark as Watched',
action: 'watched'
},
{
icon: 'playlist-add',
label: 'Add to Playlist',
action: 'playlist'
},
{
icon: 'share',
label: 'Share',
action: 'share'
}
]; ];
const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF'; const handleOptionPress = (optionId: string) => {
onOptionSelect(optionId);
onClose();
};
return ( return (
<Modal <Modal
visible={visible} visible={visible}
transparent transparent
animationType="none" animationType="slide"
onRequestClose={onClose} onRequestClose={onClose}
> >
<GestureHandlerRootView style={{ flex: 1 }}> <View style={styles.modalOverlay}>
<Animated.View style={[styles.modalOverlay, overlayStyle]}> <Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<GestureDetector gesture={gesture}> <View style={[
<Animated.View style={[styles.menuContainer, menuStyle, { backgroundColor }]}> styles.menuContainer,
<View style={styles.dragHandle} /> { backgroundColor: isDarkMode ? colors.darkBackground : colors.lightBackground }
<View style={styles.menuHeader}> ]}>
<ExpoImage {/* Drag Handle */}
source={{ uri: item.poster }} <View style={styles.dragHandle} />
style={styles.menuPoster}
contentFit="cover" {/* Header with item info */}
<View style={styles.menuHeader}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.menuPoster}
contentFit="cover"
cachePolicy="memory"
/>
<View style={styles.menuTitleContainer}>
<Text style={[
styles.menuTitle,
{ color: isDarkMode ? colors.white : colors.black }
]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={[
styles.menuYear,
{ color: isDarkMode ? colors.textMuted : colors.textMutedDark }
]}>
{item.year}
</Text>
)}
</View>
</View>
{/* Menu Options */}
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.id}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? colors.border : colors.border },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => handleOptionPress(option.id)}
activeOpacity={0.7}
>
<MaterialIcons
name={option.icon as any}
size={24}
color={isDarkMode ? colors.white : colors.black}
/> />
<View style={styles.menuTitleContainer}> <Text style={[
<Text style={[styles.menuTitle, { color: isDarkMode ? '#FFFFFF' : '#000000' }]}> styles.menuOptionText,
{item.name} { color: isDarkMode ? colors.white : colors.black }
</Text> ]}>
{item.year && ( {option.label}
<Text style={[styles.menuYear, { color: isDarkMode ? '#999999' : '#666666' }]}> </Text>
{item.year} </TouchableOpacity>
</Text> ))}
)} </View>
</View> </View>
</View> </View>
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.action}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => {
onOptionSelect(option.action);
onClose();
}}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24}
color={colors.primary}
/>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? '#FFFFFF' : '#000000' }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureHandlerRootView>
</Modal> </Modal>
); );
}; };

View file

@ -17,14 +17,6 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
withDelay
} from 'react-native-reanimated';
import { StreamingContent } from '../../services/catalogService'; import { StreamingContent } from '../../services/catalogService';
import { SkeletonFeatured } from './SkeletonLoaders'; import { SkeletonFeatured } from './SkeletonLoaders';
import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from '../../utils/logoUtils'; import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from '../../utils/logoUtils';
@ -49,70 +41,18 @@ const NoFeaturedContent = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const styles = StyleSheet.create({
noContentContainer: {
height: height * 0.55,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
backgroundColor: currentTheme.colors.elevation1,
borderRadius: 12,
marginBottom: 12,
},
noContentTitle: {
fontSize: 22,
fontWeight: 'bold',
color: currentTheme.colors.highEmphasis,
marginTop: 16,
marginBottom: 8,
textAlign: 'center',
},
noContentText: {
fontSize: 16,
color: currentTheme.colors.mediumEmphasis,
textAlign: 'center',
marginBottom: 24,
},
noContentButtons: {
flexDirection: 'row',
justifyContent: 'center',
gap: 16,
width: '100%',
},
noContentButton: {
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 30,
backgroundColor: currentTheme.colors.elevation3,
alignItems: 'center',
justifyContent: 'center'
},
noContentButtonText: {
color: currentTheme.colors.highEmphasis,
fontWeight: '600',
fontSize: 14,
}
});
return ( return (
<View style={styles.noContentContainer}> <View style={[styles.featuredContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} /> <View style={styles.backgroundFallback}>
<Text style={styles.noContentTitle}>No Featured Content</Text> <MaterialIcons name="movie" size={64} color={currentTheme.colors.mediumEmphasis} />
<Text style={styles.noContentText}> <Text style={[styles.noContentText, { color: currentTheme.colors.mediumEmphasis }]}>
Install addons with catalogs or change the content source in your settings. No featured content available
</Text> </Text>
<View style={styles.noContentButtons}>
<TouchableOpacity <TouchableOpacity
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Addons')} onPress={() => navigation.navigate('Search')}
> >
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text> <Text style={styles.exploreButtonText}>Explore Content</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.noContentButton}
onPress={() => navigation.navigate('HomeScreenSettings')}
>
<Text style={styles.noContentButtonText}>Settings</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -122,456 +62,193 @@ const NoFeaturedContent = () => {
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoLoaded, setLogoLoaded] = useState(false);
const [bannerLoaded, setBannerLoaded] = useState(false);
const [showSkeleton, setShowSkeleton] = useState(true);
const [logoError, setLogoError] = useState(false);
const [bannerError, setBannerError] = useState(false);
const { settings } = useSettings(); const { settings } = useSettings();
const logoOpacity = useSharedValue(0); const [logoUrl, setLogoUrl] = useState<string | null>(null);
const bannerOpacity = useSharedValue(0); const [isLogoLoading, setIsLogoLoading] = useState(false);
const posterOpacity = useSharedValue(0); const [imageLoaded, setImageLoaded] = useState(false);
const prevContentIdRef = useRef<string | null>(null); const [imageError, setImageError] = useState(false);
// Add state for tracking logo load errors // Removed TMDB service integration
const [logoLoadError, setLogoLoadError] = useState(false);
// Add a ref to track logo fetch in progress
const logoFetchInProgress = useRef<boolean>(false);
// Enhanced poster transition animations // Preload image when component mounts
const posterScale = useSharedValue(1); useEffect(() => {
const posterTranslateY = useSharedValue(0); if (featuredContent?.poster && !imageCache[featuredContent.poster]) {
const overlayOpacity = useSharedValue(0.15); const preloadImage = async () => {
try {
// Animation values await imageCacheService.getCachedImageUrl(featuredContent.poster!);
const posterAnimatedStyle = useAnimatedStyle(() => ({ imageCache[featuredContent.poster!] = true;
opacity: posterOpacity.value, } catch (error) {
transform: [ logger.error('Failed to preload featured image:', error);
{ scale: posterScale.value }, }
{ translateY: posterTranslateY.value } };
], preloadImage();
}));
const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value,
}));
const contentOpacity = useSharedValue(1); // Start visible
const buttonsOpacity = useSharedValue(1);
const contentAnimatedStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
}));
const buttonsAnimatedStyle = useAnimatedStyle(() => ({
opacity: buttonsOpacity.value,
}));
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
// Preload the image
const preloadImage = async (url: string): Promise<boolean> => {
// Skip if already cached to prevent redundant prefetch
if (imageCache[url]) return true;
try {
// Simplified validation to reduce CPU overhead
if (!url || typeof url !== 'string') return false;
// Use our optimized cache service instead of direct prefetch
await imageCacheService.getCachedImageUrl(url);
imageCache[url] = true;
return true;
} catch (error) {
// Clear any partial cache entry on error
delete imageCache[url];
return false;
} }
}; }, [featuredContent?.poster]);
// Reset logo error state when content changes // TMDB data fetching removed due to API limitations
// Fetch logo when featured content changes
useEffect(() => { useEffect(() => {
setLogoLoadError(false);
}, [featuredContent?.id]);
// Fetch logo based on preference
useEffect(() => {
if (!featuredContent || logoFetchInProgress.current) return;
const fetchLogo = async () => { const fetchLogo = async () => {
logoFetchInProgress.current = true; if (!featuredContent || isLogoLoading) return;
setIsLogoLoading(true);
setLogoUrl(null);
try { try {
const contentId = featuredContent.id; // Use existing logo logic
const contentData = featuredContent; // Use a clearer variable name if (featuredContent.logo) {
const currentLogo = contentData.logo; setLogoUrl(featuredContent.logo);
// Get preferences
const logoPreference = settings.logoSourcePreference || 'metahub';
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
// Reset state for new fetch
setLogoUrl(null);
setLogoLoadError(false);
// Extract IDs
let imdbId: string | null = null;
if (contentData.id.startsWith('tt')) {
imdbId = contentData.id;
} else if ((contentData as any).imdbId) {
imdbId = (contentData as any).imdbId;
} else if ((contentData as any).externalIds?.imdb_id) {
imdbId = (contentData as any).externalIds.imdb_id;
} }
let tmdbId: string | null = null;
if (contentData.id.startsWith('tmdb:')) {
tmdbId = contentData.id.split(':')[1];
} else if ((contentData as any).tmdb_id) {
tmdbId = String((contentData as any).tmdb_id);
}
// If we only have IMDB ID, try to find TMDB ID proactively
if (imdbId && !tmdbId) {
try {
const tmdbService = TMDBService.getInstance();
const foundData = await tmdbService.findTMDBIdByIMDB(imdbId);
if (foundData) {
tmdbId = String(foundData);
}
} catch (findError) {
// logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
}
}
const tmdbType = contentData.type === 'series' ? 'tv' : 'movie';
let finalLogoUrl: string | null = null;
let primaryAttempted = false;
let fallbackAttempted = false;
// --- Logo Fetching Logic ---
if (logoPreference === 'metahub') {
// Primary: Metahub (needs imdbId)
if (imdbId) {
primaryAttempted = true;
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
try {
const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) {
finalLogoUrl = metahubUrl;
}
} catch (error) { /* Log if needed */ }
}
// Fallback: TMDB (needs tmdbId)
if (!finalLogoUrl && tmdbId) {
fallbackAttempted = true;
try {
const tmdbService = TMDBService.getInstance();
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
if (logoUrl) {
finalLogoUrl = logoUrl;
}
} catch (error) { /* Log if needed */ }
}
} else { // logoPreference === 'tmdb'
// Primary: TMDB (needs tmdbId)
if (tmdbId) {
primaryAttempted = true;
try {
const tmdbService = TMDBService.getInstance();
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
if (logoUrl) {
finalLogoUrl = logoUrl;
}
} catch (error) { /* Log if needed */ }
}
// Fallback: Metahub (needs imdbId)
if (!finalLogoUrl && imdbId) {
fallbackAttempted = true;
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
try {
const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) {
finalLogoUrl = metahubUrl;
}
} catch (error) { /* Log if needed */ }
}
}
// --- Set Final Logo ---
if (finalLogoUrl) {
setLogoUrl(finalLogoUrl);
} else if (currentLogo) {
// Use existing logo only if primary and fallback failed or weren't applicable
setLogoUrl(currentLogo);
} else {
// No logo found from any source
setLogoLoadError(true);
// logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`);
}
} catch (error) { } catch (error) {
// logger.error('[FeaturedContent] Error in fetchLogo:', error); logger.error('Error fetching logo:', error);
setLogoLoadError(true);
} finally { } finally {
logoFetchInProgress.current = false; setIsLogoLoading(false);
} }
}; };
// Trigger fetch when content changes
fetchLogo(); fetchLogo();
}, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]); }, [featuredContent]);
// Load poster and logo const handlePlayPress = () => {
useEffect(() => { if (featuredContent) {
if (!featuredContent) return; navigation.navigate('Metadata', {
id: featuredContent.id,
const posterUrl = featuredContent.banner || featuredContent.poster; type: featuredContent.type
const contentId = featuredContent.id; });
const isContentChange = contentId !== prevContentIdRef.current;
// Enhanced content change detection and animations
if (isContentChange) {
// Animate out current content
if (prevContentIdRef.current) {
posterOpacity.value = withTiming(0, {
duration: 300,
easing: Easing.out(Easing.cubic)
});
posterScale.value = withTiming(0.95, {
duration: 300,
easing: Easing.out(Easing.cubic)
});
overlayOpacity.value = withTiming(0.6, {
duration: 300,
easing: Easing.out(Easing.cubic)
});
contentOpacity.value = withTiming(0.3, {
duration: 200,
easing: Easing.out(Easing.cubic)
});
buttonsOpacity.value = withTiming(0.3, {
duration: 200,
easing: Easing.out(Easing.cubic)
});
} else {
// Initial load - start from 0
posterOpacity.value = 0;
posterScale.value = 1.1;
overlayOpacity.value = 0;
contentOpacity.value = 0;
buttonsOpacity.value = 0;
}
logoOpacity.value = 0;
} }
prevContentIdRef.current = contentId;
// Set poster URL for immediate display
if (posterUrl) setBannerUrl(posterUrl);
// Load images with enhanced animations
const loadImages = async () => {
// Small delay to allow fade out animation to complete
await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0));
// Load poster with enhanced transition
if (posterUrl) {
const posterSuccess = await preloadImage(posterUrl);
if (posterSuccess) {
// Animate in new poster with scale and fade
posterScale.value = withTiming(1, {
duration: 800,
easing: Easing.out(Easing.cubic)
});
posterOpacity.value = withTiming(1, {
duration: 700,
easing: Easing.out(Easing.cubic)
});
overlayOpacity.value = withTiming(0.15, {
duration: 600,
easing: Easing.out(Easing.cubic)
});
// Animate content back in with delay
contentOpacity.value = withDelay(200, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
buttonsOpacity.value = withDelay(400, withTiming(1, {
duration: 500,
easing: Easing.out(Easing.cubic)
}));
}
}
// Load logo if available with enhanced timing
if (logoUrl) {
const logoSuccess = await preloadImage(logoUrl);
if (logoSuccess) {
logoOpacity.value = withDelay(500, withTiming(1, {
duration: 600,
easing: Easing.out(Easing.cubic)
}));
} else {
setLogoLoadError(true);
}
}
};
loadImages();
}, [featuredContent?.id, logoUrl]);
const onLogoLoadError = () => {
setLogoLoaded(true); // Treat error as "loaded" to stop spinner
setLogoError(true);
}; };
const handleInfoPress = () => { const handleInfoPress = () => {
if (featuredContent) { if (featuredContent) {
navigation.navigate('Metadata', { navigation.navigate('Metadata', {
id: featuredContent.id, id: featuredContent.id,
type: featuredContent.type type: featuredContent.type
}); });
} }
}; };
const formatGenres = (genres: string[] | undefined) => {
if (!genres || genres.length === 0) return '';
return genres.slice(0, 3).join(' • ');
};
if (!featuredContent) { if (!featuredContent) {
return <NoFeaturedContent />; return <NoFeaturedContent />;
} }
const posterUrl = featuredContent.poster;
const formattedGenres = formatGenres(featuredContent.genres);
return ( return (
<Animated.View <View style={styles.featuredContainer}>
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))} {/* Background Image */}
> <View style={styles.imageContainer}>
<TouchableOpacity {posterUrl && !imageError ? (
activeOpacity={0.95} <ExpoImage
onPress={() => { source={{ uri: posterUrl }}
navigation.navigate('Metadata', { style={styles.featuredImage}
id: featuredContent.id, contentFit="cover"
type: featuredContent.type cachePolicy="memory-disk"
}); transition={300}
}} onLoad={() => setImageLoaded(true)}
style={styles.featuredContainer as ViewStyle} onError={() => setImageError(true)}
placeholder={{ uri: 'https://via.placeholder.com/400x600' }}
placeholderContentFit="cover"
/>
) : (
<View style={[styles.backgroundFallback, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie" size={64} color={currentTheme.colors.mediumEmphasis} />
</View>
)}
</View>
{/* Content Overlay */}
<View style={styles.contentOverlay} />
{/* Gradient Overlay */}
<LinearGradient
colors={[
'transparent',
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.7)',
'rgba(0,0,0,0.9)'
]}
locations={[0, 0.4, 0.7, 1]}
style={styles.featuredGradient}
> >
<Animated.View style={[styles.imageContainer, posterAnimatedStyle]}> <View style={styles.featuredContentContainer}>
<ImageBackground {/* Logo or Title */}
source={{ uri: bannerUrl || featuredContent.poster }} {logoUrl && !isLogoLoading ? (
style={styles.featuredImage as ViewStyle} <ExpoImage
resizeMode="cover" source={{ uri: logoUrl }}
> style={styles.featuredLogo}
{/* Subtle content overlay for better readability */} contentFit="contain"
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} /> cachePolicy="memory-disk"
transition={200}
/>
) : (
<Text style={[styles.featuredTitleText, { color: '#FFFFFF' }]} numberOfLines={2}>
{featuredContent.name}
</Text>
)}
<LinearGradient {/* Genres */}
colors={[ {formattedGenres && (
'rgba(0,0,0,0.1)', <View style={styles.genreContainer}>
'rgba(0,0,0,0.2)', <Text style={[styles.genreText, { color: '#FFFFFF' }]}>
'rgba(0,0,0,0.4)', {formattedGenres}
'rgba(0,0,0,0.8)', </Text>
currentTheme.colors.darkBackground, </View>
]} )}
locations={[0, 0.2, 0.5, 0.8, 1]}
style={styles.featuredGradient as ViewStyle} {/* Action Buttons */}
<View style={styles.featuredButtons}>
{/* Play Button */}
<TouchableOpacity
style={[styles.playButton, { backgroundColor: '#FFFFFF' }]}
onPress={handlePlayPress}
activeOpacity={0.8}
> >
<Animated.View <MaterialIcons name="play-arrow" size={20} color="#000000" />
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]} <Text style={[styles.playButtonText, { color: '#000000' }]}>Play</Text>
> </TouchableOpacity>
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
cachePolicy="memory"
transition={300}
recyclingKey={`logo-${featuredContent.id}`}
onError={onLogoLoadError}
/>
</Animated.View>
) : (
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}>
{featuredContent.name}
</Text>
)}
<View style={styles.genreContainer as ViewStyle}>
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
{index < array.length - 1 && (
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}></Text>
)}
</React.Fragment>
))}
</View>
</Animated.View>
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}> {/* My List Button */}
<TouchableOpacity <TouchableOpacity
style={styles.myListButton as ViewStyle} style={styles.myListButton}
onPress={handleSaveToLibrary} onPress={handleSaveToLibrary}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialIcons <MaterialIcons
name={isSaved ? "bookmark" : "bookmark-border"} name={isSaved ? "check" : "add"}
size={24} size={20}
color={currentTheme.colors.white} color="#FFFFFF"
/> />
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}> <Text style={[styles.myListButtonText, { color: '#FFFFFF' }]}>
{isSaved ? "Saved" : "Save"} {isSaved ? 'Saved' : 'My List'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity {/* Info Button */}
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]} <TouchableOpacity
onPress={() => { style={styles.infoButton}
if (featuredContent) { onPress={handleInfoPress}
navigation.navigate('Streams', { activeOpacity={0.7}
id: featuredContent.id, >
type: featuredContent.type <MaterialIcons name="info-outline" size={20} color="#FFFFFF" />
}); <Text style={[styles.infoButtonText, { color: '#FFFFFF' }]}>Info</Text>
} </TouchableOpacity>
}} </View>
activeOpacity={0.8} </View>
> </LinearGradient>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> </View>
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton as ViewStyle}
onPress={handleInfoPress}
activeOpacity={0.7}
>
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
Info
</Text>
</TouchableOpacity>
</Animated.View>
</LinearGradient>
</ImageBackground>
</Animated.View>
</TouchableOpacity>
</Animated.View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
featuredContainer: { featuredContainer: {
width: '100%', width: '100%',
height: height * 0.55, // Slightly taller for better proportions height: height * 0.55,
marginTop: 0, marginTop: 0,
marginBottom: 12, marginBottom: 12,
position: 'relative', position: 'relative',
@ -596,7 +273,7 @@ const styles = StyleSheet.create({
featuredImage: { featuredImage: {
width: '100%', width: '100%',
height: '100%', height: '100%',
transform: [{ scale: 1.05 }], // Subtle zoom for depth transform: [{ scale: 1.05 }],
}, },
backgroundFallback: { backgroundFallback: {
position: 'absolute', position: 'absolute',
@ -724,6 +401,23 @@ const styles = StyleSheet.create({
zIndex: 1, zIndex: 1,
pointerEvents: 'none', pointerEvents: 'none',
}, },
noContentText: {
fontSize: 16,
fontWeight: '500',
marginTop: 16,
marginBottom: 20,
textAlign: 'center',
},
exploreButton: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
exploreButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
}); });
export default React.memo(FeaturedContent); export default React.memo(FeaturedContent);

View file

@ -20,7 +20,7 @@ import { tmdbService } from '../../services/tmdbService';
import { useLibrary } from '../../hooks/useLibrary'; import { useLibrary } from '../../hooks/useLibrary';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns'; import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
import Animated, { FadeIn, FadeInRight } from 'react-native-reanimated'; // Removed react-native-reanimated import
import { useCalendarData } from '../../hooks/useCalendarData'; import { useCalendarData } from '../../hooks/useCalendarData';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -109,10 +109,7 @@ export const ThisWeekSection = React.memo(() => {
item.poster); item.poster);
return ( return (
<Animated.View <View style={styles.episodeItemContainer}>
entering={FadeInRight.delay(index * 50).duration(300)}
style={styles.episodeItemContainer}
>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.episodeItem, styles.episodeItem,
@ -177,12 +174,12 @@ export const ThisWeekSection = React.memo(() => {
</LinearGradient> </LinearGradient>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </View>
); );
}; };
return ( return (
<Animated.View entering={FadeIn.duration(300)} style={styles.container}> <View style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text> <Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
@ -206,7 +203,7 @@ export const ThisWeekSection = React.memo(() => {
snapToAlignment="start" snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />} ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
/> />
</Animated.View> </View>
); );
}); });

View file

@ -7,19 +7,11 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
Platform, Platform,
Modal,
} from 'react-native'; } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { Cast } from '../../types/cast'; import { Cast } from '../../types/cast';
@ -54,419 +46,360 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null); const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasFetched, setHasFetched] = useState(false); const [error, setError] = useState<string | null>(null);
const modalOpacity = useSharedValue(0);
const modalScale = useSharedValue(0.9);
useEffect(() => { useEffect(() => {
if (visible && castMember) { if (visible && castMember?.id) {
modalOpacity.value = withTiming(1, { duration: 250 }); fetchPersonDetails();
modalScale.value = withSpring(1, { damping: 20, stiffness: 200 });
if (!hasFetched || personDetails?.id !== castMember.id) {
fetchPersonDetails();
}
} else {
modalOpacity.value = withTiming(0, { duration: 200 });
modalScale.value = withTiming(0.9, { duration: 200 });
if (!visible) {
setHasFetched(false);
setPersonDetails(null);
}
} }
}, [visible, castMember]); }, [visible, castMember?.id]);
const fetchPersonDetails = async () => { const fetchPersonDetails = async () => {
if (!castMember || loading) return; if (!castMember?.id) return;
setLoading(true); setLoading(true);
setError(null);
try { try {
const details = await tmdbService.getPersonDetails(castMember.id); const details = await tmdbService.getPersonDetails(castMember.id);
setPersonDetails(details); setPersonDetails(details);
setHasFetched(true); } catch (err) {
} catch (error) { console.error('Error fetching person details:', err);
console.error('Error fetching person details:', error); setError('Failed to load cast member details');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const modalStyle = useAnimatedStyle(() => ({
opacity: modalOpacity.value,
transform: [{ scale: modalScale.value }],
}));
const handleClose = () => {
modalOpacity.value = withTiming(0, { duration: 200 });
modalScale.value = withTiming(0.9, { duration: 200 }, () => {
runOnJS(onClose)();
});
};
const formatDate = (dateString: string | null) => { const formatDate = (dateString: string | null) => {
if (!dateString) return null; if (!dateString) return null;
const date = new Date(dateString); try {
return date.toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'long',
day: 'numeric', day: 'numeric'
}); });
} catch {
return dateString;
}
}; };
const calculateAge = (birthday: string | null) => { const calculateAge = (birthday: string | null) => {
if (!birthday) return null; if (!birthday) return null;
const today = new Date(); try {
const birthDate = new Date(birthday); const birthDate = new Date(birthday);
let age = today.getFullYear() - birthDate.getFullYear(); const today = new Date();
const monthDiff = today.getMonth() - birthDate.getMonth(); let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--; age--;
}
return age;
} catch {
return null;
} }
return age;
}; };
if (!visible || !castMember) return null; if (!visible || !castMember) return null;
return ( return (
<Animated.View <Modal
entering={FadeIn.duration(250)} visible={visible}
exiting={FadeOut.duration(200)} transparent
style={{ animationType="fade"
position: 'absolute', onRequestClose={onClose}
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.85)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
padding: 20,
}}
> >
<TouchableOpacity <View style={styles.overlay}>
style={{ <TouchableOpacity
position: 'absolute', style={styles.backdrop}
top: 0, activeOpacity={1}
left: 0, onPress={onClose}
right: 0, />
bottom: 0,
}}
onPress={handleClose}
activeOpacity={1}
/>
<Animated.View <View style={[styles.modalContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
style={[ {Platform.OS === 'ios' ? (
{ <BlurView intensity={80} style={styles.blurBackground} tint="dark" />
width: MODAL_WIDTH, ) : (
height: MODAL_HEIGHT, <View style={[styles.androidBackground, { backgroundColor: currentTheme.colors.darkBackground }]} />
overflow: 'hidden', )}
borderRadius: 24,
backgroundColor: Platform.OS === 'android'
? 'rgba(20, 20, 20, 0.95)'
: 'transparent',
},
modalStyle,
]}
>
{Platform.OS === 'ios' ? (
<BlurView
intensity={100}
tint="dark"
style={{
width: '100%',
height: '100%',
backgroundColor: 'rgba(20, 20, 20, 0.8)',
}}
>
{renderContent()}
</BlurView>
) : (
renderContent()
)}
</Animated.View>
</Animated.View>
);
function renderContent() { {/* Header */}
return ( <View style={styles.header}>
<> <Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
{/* Header */} Cast Details
<LinearGradient </Text>
colors={[ <TouchableOpacity onPress={onClose} style={styles.closeButton}>
currentTheme.colors.primary + 'DD', <MaterialIcons name="close" size={24} color={currentTheme.colors.highEmphasis} />
currentTheme.colors.primaryVariant + 'CC',
]}
style={{
padding: 20,
paddingTop: 24,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{
width: 60,
height: 60,
borderRadius: 30,
overflow: 'hidden',
marginRight: 16,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}>
{castMember.profile_path ? (
<Image
source={{
uri: `https://image.tmdb.org/t/p/w185${castMember.profile_path}`,
}}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
/>
) : (
<View style={{
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '700',
}}>
{castMember.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
</Text>
</View>
)}
</View>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '800',
marginBottom: 4,
}} numberOfLines={2}>
{castMember.name}
</Text>
{castMember.character && (
<Text style={{
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 14,
fontWeight: '500',
}} numberOfLines={2}>
as {castMember.character}
</Text>
)}
</View>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</LinearGradient>
{/* Content */} {/* Content */}
<ScrollView <ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
style={{ flex: 1 }} {loading ? (
contentContainerStyle={{ padding: 20 }} <View style={styles.loadingContainer}>
showsVerticalScrollIndicator={false} <ActivityIndicator size="large" color={currentTheme.colors.primary} />
> <Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
{loading ? ( Loading details...
<View style={{ </Text>
alignItems: 'center', </View>
justifyContent: 'center', ) : error ? (
paddingVertical: 40, <View style={styles.errorContainer}>
}}> <MaterialIcons name="error-outline" size={48} color={currentTheme.colors.error} />
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
<Text style={{ {error}
color: 'rgba(255, 255, 255, 0.7)', </Text>
fontSize: 14, <TouchableOpacity onPress={fetchPersonDetails} style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}>
marginTop: 12, <Text style={styles.retryButtonText}>Retry</Text>
}}> </TouchableOpacity>
Loading details... </View>
</Text> ) : personDetails ? (
</View> <View style={styles.detailsContainer}>
) : ( {/* Profile Image and Basic Info */}
<View> <View style={styles.profileSection}>
{/* Quick Info */} <View style={styles.imageContainer}>
{(personDetails?.known_for_department || personDetails?.birthday || personDetails?.place_of_birth) && ( {personDetails.profile_path ? (
<View style={{ <Image
backgroundColor: 'rgba(255, 255, 255, 0.05)', source={{ uri: `https://image.tmdb.org/t/p/w500${personDetails.profile_path}` }}
borderRadius: 16, style={styles.profileImage}
padding: 16, contentFit="cover"
marginBottom: 20, />
}}> ) : (
{personDetails?.known_for_department && ( <View style={[styles.placeholderImage, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={{ <MaterialIcons name="person" size={60} color={currentTheme.colors.mediumEmphasis} />
flexDirection: 'row', </View>
alignItems: 'center', )}
marginBottom: personDetails?.birthday || personDetails?.place_of_birth ? 12 : 0 </View>
}}>
<MaterialIcons name="work" size={16} color={currentTheme.colors.primary} />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginLeft: 8,
marginRight: 12,
}}>
Department
</Text>
<Text style={{
color: '#fff',
fontSize: 14,
fontWeight: '600',
}}>
{personDetails.known_for_department}
</Text>
</View>
)}
{personDetails?.birthday && ( <View style={styles.basicInfo}>
<View style={{ <Text style={[styles.name, { color: currentTheme.colors.highEmphasis }]}>
flexDirection: 'row', {personDetails.name}
alignItems: 'center', </Text>
marginBottom: personDetails?.place_of_birth ? 12 : 0
}}>
<MaterialIcons name="cake" size={16} color="#22C55E" />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginLeft: 8,
marginRight: 12,
}}>
Age
</Text>
<Text style={{
color: '#fff',
fontSize: 14,
fontWeight: '600',
}}>
{calculateAge(personDetails.birthday)} years old
</Text>
</View>
)}
{personDetails?.place_of_birth && ( <Text style={[styles.department, { color: currentTheme.colors.primary }]}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> {personDetails.known_for_department}
<MaterialIcons name="place" size={16} color="#F59E0B" /> </Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginLeft: 8,
marginRight: 12,
}}>
Born in
</Text>
<Text style={{
color: '#fff',
fontSize: 14,
fontWeight: '600',
flex: 1,
}}>
{personDetails.place_of_birth}
</Text>
</View>
)}
{personDetails?.birthday && ( {personDetails.birthday && (
<View style={{ <View style={styles.infoRow}>
marginTop: 12, <MaterialIcons name="cake" size={16} color={currentTheme.colors.mediumEmphasis} />
paddingTop: 12, <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
borderTopWidth: 1, {formatDate(personDetails.birthday)}
borderTopColor: 'rgba(255, 255, 255, 0.1)', {calculateAge(personDetails.birthday) && ` (${calculateAge(personDetails.birthday)} years old)`}
}}> </Text>
<Text style={{ </View>
color: 'rgba(255, 255, 255, 0.7)', )}
fontSize: 12,
marginBottom: 4, {personDetails.place_of_birth && (
}}> <View style={styles.infoRow}>
Born on {formatDate(personDetails.birthday)} <MaterialIcons name="place" size={16} color={currentTheme.colors.mediumEmphasis} />
</Text> <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
</View> {personDetails.place_of_birth}
)} </Text>
</View>
)}
</View>
</View> </View>
)}
{/* Biography */} {/* Biography */}
{personDetails?.biography && ( {personDetails.biography && (
<View style={{ marginBottom: 20 }}> <View style={styles.section}>
<Text style={{ <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
color: '#fff', Biography
fontSize: 16, </Text>
fontWeight: '700', <Text style={[styles.biography, { color: currentTheme.colors.mediumEmphasis }]}>
marginBottom: 12, {personDetails.biography}
}}> </Text>
Biography </View>
</Text> )}
<Text style={{
color: 'rgba(255, 255, 255, 0.9)',
fontSize: 14,
lineHeight: 20,
fontWeight: '400',
}}>
{personDetails.biography}
</Text>
</View>
)}
{/* Also Known As - Compact */} {/* Also Known As */}
{personDetails?.also_known_as && personDetails.also_known_as.length > 0 && ( {personDetails.also_known_as && personDetails.also_known_as.length > 0 && (
<View> <View style={styles.section}>
<Text style={{ <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
color: '#fff', Also Known As
fontSize: 16, </Text>
fontWeight: '700', <View style={styles.aliasContainer}>
marginBottom: 12, {personDetails.also_known_as.slice(0, 5).map((alias, index) => (
}}> <View key={index} style={[styles.aliasChip, { backgroundColor: currentTheme.colors.elevation1 }]}>
Also Known As <Text style={[styles.aliasText, { color: currentTheme.colors.mediumEmphasis }]}>
</Text> {alias}
<Text style={{ </Text>
color: 'rgba(255, 255, 255, 0.8)', </View>
fontSize: 14, ))}
lineHeight: 20, </View>
}}> </View>
{personDetails.also_known_as.slice(0, 4).join(' • ')} )}
</Text> </View>
</View> ) : null}
)} </ScrollView>
</View>
</View>
</Modal>
);
};
{/* No details available */} const styles = {
{!loading && !personDetails?.biography && !personDetails?.birthday && !personDetails?.place_of_birth && ( overlay: {
<View style={{ flex: 1,
alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center', justifyContent: 'center' as const,
paddingVertical: 40, alignItems: 'center' as const,
}}> },
<MaterialIcons name="info" size={32} color="rgba(255, 255, 255, 0.3)" /> backdrop: {
<Text style={{ position: 'absolute' as const,
color: 'rgba(255, 255, 255, 0.7)', top: 0,
fontSize: 14, left: 0,
marginTop: 12, right: 0,
textAlign: 'center', bottom: 0,
}}> },
No additional details available modalContainer: {
</Text> width: MODAL_WIDTH,
</View> height: MODAL_HEIGHT,
)} borderRadius: 16,
</View> overflow: 'hidden' as const,
)} elevation: 8,
</ScrollView> shadowColor: '#000',
</> shadowOffset: { width: 0, height: 4 },
); shadowOpacity: 0.3,
} shadowRadius: 8,
},
blurBackground: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
},
androidBackground: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.95,
},
header: {
flexDirection: 'row' as const,
justifyContent: 'space-between' as const,
alignItems: 'center' as const,
padding: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
headerTitle: {
fontSize: 20,
fontWeight: '700' as const,
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center' as const,
alignItems: 'center' as const,
paddingVertical: 40,
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center' as const,
alignItems: 'center' as const,
paddingVertical: 40,
},
errorText: {
marginTop: 16,
fontSize: 16,
textAlign: 'center' as const,
marginBottom: 20,
},
retryButton: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: '#fff',
fontWeight: '600' as const,
},
detailsContainer: {
flex: 1,
},
profileSection: {
flexDirection: 'row' as const,
marginBottom: 24,
},
imageContainer: {
marginRight: 16,
},
profileImage: {
width: 100,
height: 150,
borderRadius: 8,
},
placeholderImage: {
width: 100,
height: 150,
borderRadius: 8,
justifyContent: 'center' as const,
alignItems: 'center' as const,
},
basicInfo: {
flex: 1,
justifyContent: 'flex-start' as const,
},
name: {
fontSize: 24,
fontWeight: '700' as const,
marginBottom: 4,
},
department: {
fontSize: 16,
fontWeight: '600' as const,
marginBottom: 12,
},
infoRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
marginBottom: 8,
},
infoText: {
fontSize: 14,
marginLeft: 8,
flex: 1,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700' as const,
marginBottom: 12,
},
biography: {
fontSize: 14,
lineHeight: 20,
},
aliasContainer: {
flexDirection: 'row' as const,
flexWrap: 'wrap' as const,
gap: 8,
},
aliasChip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
aliasText: {
fontSize: 12,
fontWeight: '500' as const,
},
}; };
export default CastDetailsModal; export default CastDetailsModal;

View file

@ -11,11 +11,6 @@ import { BlurView as ExpoBlurView } from 'expo-blur';
import { BlurView as CommunityBlurView } from '@react-native-community/blur'; import { BlurView as CommunityBlurView } from '@react-native-community/blur';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
@ -27,9 +22,6 @@ interface FloatingHeaderProps {
handleBack: () => void; handleBack: () => void;
handleToggleLibrary: () => void; handleToggleLibrary: () => void;
inLibrary: boolean; inLibrary: boolean;
headerOpacity: Animated.SharedValue<number>;
headerElementsY: Animated.SharedValue<number>;
headerElementsOpacity: Animated.SharedValue<number>;
safeAreaTop: number; safeAreaTop: number;
setLogoLoadError: (error: boolean) => void; setLogoLoadError: (error: boolean) => void;
} }
@ -40,37 +32,20 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
handleBack, handleBack,
handleToggleLibrary, handleToggleLibrary,
inLibrary, inLibrary,
headerOpacity,
headerElementsY,
headerElementsOpacity,
safeAreaTop, safeAreaTop,
setLogoLoadError, setLogoLoadError,
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Animated styles for the header
const headerAnimatedStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
transform: [
{ translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) }
]
}));
// Animated style for header elements
const headerElementsStyle = useAnimatedStyle(() => ({
opacity: headerElementsOpacity.value,
transform: [{ translateY: headerElementsY.value }]
}));
return ( return (
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}> <View style={styles.floatingHeader}>
{Platform.OS === 'ios' ? ( {Platform.OS === 'ios' ? (
<ExpoBlurView <ExpoBlurView
intensity={50} intensity={50}
tint="dark" tint="dark"
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]} style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
> >
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}> <View style={styles.floatingHeaderContent}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={handleBack} onPress={handleBack}
@ -111,7 +86,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
color={currentTheme.colors.highEmphasis} color={currentTheme.colors.highEmphasis}
/> />
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </View>
</ExpoBlurView> </ExpoBlurView>
) : ( ) : (
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}> <View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
@ -121,7 +96,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
blurAmount={15} blurAmount={15}
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)" reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
/> />
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}> <View style={styles.floatingHeaderContent}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={handleBack} onPress={handleBack}
@ -162,11 +137,11 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
color={currentTheme.colors.highEmphasis} color={currentTheme.colors.highEmphasis}
/> />
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </View>
</View> </View>
)} )}
{Platform.OS === 'ios' && <View style={[styles.headerBottomBorder, { backgroundColor: 'rgba(255,255,255,0.15)' }]} />} {Platform.OS === 'ios' && <View style={[styles.headerBottomBorder, { backgroundColor: 'rgba(255,255,255,0.15)' }]} />}
</Animated.View> </View>
); );
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native'; import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import Animated, { // Removed react-native-reanimated imports
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { styles } from '../utils/playerStyles'; import { styles } from '../utils/playerStyles';
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
@ -89,9 +84,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
return ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
<Animated.View <View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -107,12 +100,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
onPress={handleClose} onPress={handleClose}
activeOpacity={1} activeOpacity={1}
/> />
</Animated.View> </View>
{/* Side Menu */} {/* Side Menu */}
<Animated.View <View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -527,7 +518,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
</Animated.View> </View>
</> </>
); );
}; };

View file

@ -1,202 +0,0 @@
import { useEffect } from 'react';
import { Dimensions } from 'react-native';
import {
useSharedValue,
withTiming,
withSpring,
Easing,
useAnimatedScrollHandler,
runOnUI,
cancelAnimation,
} from 'react-native-reanimated';
const { width, height } = Dimensions.get('window');
// Highly optimized animation configurations
const fastSpring = {
damping: 15,
mass: 0.8,
stiffness: 150,
};
const ultraFastSpring = {
damping: 12,
mass: 0.6,
stiffness: 200,
};
// Ultra-optimized easing functions
const easings = {
fast: Easing.out(Easing.quad),
ultraFast: Easing.out(Easing.linear),
natural: Easing.bezier(0.2, 0, 0.2, 1),
};
export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => {
// Consolidated entrance animations - start with visible values for Android compatibility
const screenOpacity = useSharedValue(1);
const contentOpacity = useSharedValue(1);
// Combined hero animations
const heroOpacity = useSharedValue(1);
const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
const heroHeightValue = useSharedValue(height * 0.5);
// Combined UI element animations
const uiElementsOpacity = useSharedValue(1);
const uiElementsTranslateY = useSharedValue(0);
// Progress animation - simplified to single value
const progressOpacity = useSharedValue(0);
// Scroll values - minimal
const scrollY = useSharedValue(0);
const headerProgress = useSharedValue(0); // Single value for all header animations
// Static header elements Y for performance
const staticHeaderElementsY = useSharedValue(0);
// Ultra-fast entrance sequence - batch animations for better performance
useEffect(() => {
// Batch all entrance animations to run simultaneously with safety
const enterAnimations = () => {
'worklet';
try {
// Start with slightly reduced values and animate to full visibility
screenOpacity.value = withTiming(1, {
duration: 250,
easing: easings.fast
});
heroOpacity.value = withTiming(1, {
duration: 300,
easing: easings.fast
});
heroScale.value = withSpring(1, ultraFastSpring);
uiElementsOpacity.value = withTiming(1, {
duration: 400,
easing: easings.natural
});
uiElementsTranslateY.value = withSpring(0, fastSpring);
contentOpacity.value = withTiming(1, {
duration: 350,
easing: easings.fast
});
} catch (error) {
// Silently handle any animation errors
console.warn('Animation error in enterAnimations:', error);
}
};
// Use runOnUI for better performance with error handling
try {
runOnUI(enterAnimations)();
} catch (error) {
console.warn('Failed to run enter animations:', error);
}
}, []);
// Optimized watch progress animation with safety
useEffect(() => {
const hasProgress = watchProgress && watchProgress.duration > 0;
const updateProgress = () => {
'worklet';
try {
progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
duration: hasProgress ? 200 : 150,
easing: easings.fast
});
} catch (error) {
console.warn('Animation error in updateProgress:', error);
}
};
try {
runOnUI(updateProgress)();
} catch (error) {
console.warn('Failed to run progress animation:', error);
}
}, [watchProgress]);
// Cleanup function to cancel animations
useEffect(() => {
return () => {
try {
cancelAnimation(screenOpacity);
cancelAnimation(contentOpacity);
cancelAnimation(heroOpacity);
cancelAnimation(heroScale);
cancelAnimation(uiElementsOpacity);
cancelAnimation(uiElementsTranslateY);
cancelAnimation(progressOpacity);
cancelAnimation(scrollY);
cancelAnimation(headerProgress);
cancelAnimation(staticHeaderElementsY);
} catch (error) {
console.warn('Error canceling animations:', error);
}
};
}, []);
// Ultra-optimized scroll handler with minimal calculations and safety
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
'worklet';
try {
const rawScrollY = event.contentOffset.y;
scrollY.value = rawScrollY;
// Single calculation for header threshold
const threshold = height * 0.4 - safeAreaTop;
const progress = rawScrollY > threshold ? 1 : 0;
// Use single progress value for all header animations
if (headerProgress.value !== progress) {
headerProgress.value = withTiming(progress, {
duration: progress ? 200 : 150,
easing: easings.ultraFast
});
}
} catch (error) {
console.warn('Animation error in scroll handler:', error);
}
},
});
return {
// Optimized shared values - reduced count
screenOpacity,
contentOpacity,
heroOpacity,
heroScale,
uiElementsOpacity,
uiElementsTranslateY,
progressOpacity,
scrollY,
headerProgress,
// Direct shared value references for compatibility
heroHeight: heroHeightValue,
logoOpacity: uiElementsOpacity,
buttonsOpacity: uiElementsOpacity,
buttonsTranslateY: uiElementsTranslateY,
contentTranslateY: uiElementsTranslateY,
watchProgressOpacity: progressOpacity,
watchProgressWidth: progressOpacity, // Reuse for width animation
headerOpacity: headerProgress,
headerElementsY: staticHeaderElementsY,
headerElementsOpacity: headerProgress,
// Functions
scrollHandler,
animateLogo: () => {}, // Simplified - no separate logo animation
};
};

View file

@ -13,12 +13,13 @@ import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
ScrollView, ScrollView,
TVEventHandler,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; // Removed react-native-reanimated import
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { catalogService } from '../services/catalogService'; import { catalogService } from '../services/catalogService';
import type { StreamingContent } from '../services/catalogService'; import type { StreamingContent } from '../services/catalogService';
@ -203,7 +204,7 @@ const SkeletonLoader = () => {
const LibraryScreen = () => { const LibraryScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
const { width } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]); const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
@ -212,6 +213,22 @@ const LibraryScreen = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// TV-optimized grid calculations
const isTV = Platform.isTV || width > 1200;
const getNumColumns = () => {
if (isTV) {
if (width >= 1920) return 6; // 4K TVs
if (width >= 1280) return 5; // HD TVs
return 4; // Smaller TVs
}
return 2; // Mobile/tablet
};
const numColumns = getNumColumns();
const itemSpacing = isTV ? 24 : 16;
const containerPadding = isTV ? 48 : 12;
const itemWidth = (width - (containerPadding * 2) - (itemSpacing * (numColumns - 1))) / numColumns;
// Trakt integration // Trakt integration
const { const {
isAuthenticated: traktAuthenticated, isAuthenticated: traktAuthenticated,
@ -270,6 +287,51 @@ const LibraryScreen = () => {
}; };
}, []); }, []);
// TV Event Handler for remote control navigation
useEffect(() => {
if (!isTV) return;
const handleTVEvent = (evt: any) => {
if (evt && evt.eventType === 'focus') {
// Handle focus events for TV navigation
console.log('TV Focus Event:', evt);
} else if (evt && evt.eventType === 'blur') {
// Handle blur events
console.log('TV Blur Event:', evt);
} else if (evt && evt.eventType === 'select') {
// Handle select/enter button press
console.log('TV Select Event:', evt);
} else if (evt && evt.eventType === 'longSelect') {
// Handle long press on select button
console.log('TV Long Select Event:', evt);
} else if (evt && evt.eventType === 'left') {
// Handle left arrow navigation
console.log('TV Left Event:', evt);
} else if (evt && evt.eventType === 'right') {
// Handle right arrow navigation
console.log('TV Right Event:', evt);
} else if (evt && evt.eventType === 'up') {
// Handle up arrow navigation
console.log('TV Up Event:', evt);
} else if (evt && evt.eventType === 'down') {
// Handle down arrow navigation
console.log('TV Down Event:', evt);
} else if (evt && evt.eventType === 'playPause') {
// Handle play/pause button
console.log('TV Play/Pause Event:', evt);
} else if (evt && evt.eventType === 'menu') {
// Handle menu button - could show filters or options
console.log('TV Menu Event:', evt);
}
};
const subscription = TVEventHandler.addListener(handleTVEvent);
return () => {
subscription?.remove();
};
}, [isTV]);
const filteredItems = libraryItems.filter(item => { const filteredItems = libraryItems.filter(item => {
if (filter === 'all') return true; if (filter === 'all') return true;
if (filter === 'movies') return item.type === 'movie'; if (filter === 'movies') return item.type === 'movie';
@ -328,15 +390,37 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0); return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const itemWidth = (width - 48) / 2; // 2 items per row with padding // Use the TV-optimized itemWidth from above instead of hardcoded calculation
const renderItem = ({ item }: { item: LibraryItem }) => ( const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity <TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]} style={[
styles.itemContainer,
{
width: itemWidth,
marginHorizontal: itemSpacing / 2,
}
]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7} activeOpacity={0.7}
// TV optimizations
hasTVPreferredFocus={false}
tvParallaxProperties={{
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.1,
}}
> >
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> <View style={[
styles.posterContainer,
{
shadowColor: currentTheme.colors.black,
// TV-optimized dimensions
height: itemWidth * 1.5, // 2:3 aspect ratio
}
]}>
<Image <Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster} style={styles.poster}
@ -348,13 +432,24 @@ const LibraryScreen = () => {
style={styles.posterGradient} style={styles.posterGradient}
> >
<Text <Text
style={[styles.itemTitle, { color: currentTheme.colors.white }]} style={[
styles.itemTitle,
{
color: currentTheme.colors.white,
fontSize: width > 1920 ? 18 : width > 1280 ? 16 : 15, // Responsive font size
}
]}
numberOfLines={2} numberOfLines={2}
> >
{item.name} {item.name}
</Text> </Text>
{item.lastWatched && ( {item.lastWatched && (
<Text style={styles.lastWatched}> <Text style={[
styles.lastWatched,
{
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
}
]}>
{item.lastWatched} {item.lastWatched}
</Text> </Text>
)} )}
@ -365,7 +460,11 @@ const LibraryScreen = () => {
<View <View
style={[ style={[
styles.progressBar, styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary } {
width: `${item.progress * 100}%`,
backgroundColor: currentTheme.colors.primary,
height: width > 1920 ? 6 : 4, // Larger progress bar for TV
}
]} ]}
/> />
</View> </View>
@ -374,11 +473,17 @@ const LibraryScreen = () => {
<View style={styles.badgeContainer}> <View style={styles.badgeContainer}>
<MaterialIcons <MaterialIcons
name="live-tv" name="live-tv"
size={14} size={width > 1920 ? 18 : width > 1280 ? 16 : 14} // Responsive icon size
color={currentTheme.colors.white} color={currentTheme.colors.white}
style={{ marginRight: 4 }} style={{ marginRight: 4 }}
/> />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text> <Text style={[
styles.badgeText,
{
color: currentTheme.colors.white,
fontSize: width > 1920 ? 12 : width > 1280 ? 11 : 10, // Responsive font size
}
]}>Series</Text>
</View> </View>
)} )}
</View> </View>
@ -388,31 +493,69 @@ const LibraryScreen = () => {
// Render individual Trakt collection folder // Render individual Trakt collection folder
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity <TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]} style={[
styles.itemContainer,
{
width: itemWidth,
marginHorizontal: itemSpacing / 2,
}
]}
onPress={() => { onPress={() => {
setSelectedTraktFolder(folder.id); setSelectedTraktFolder(folder.id);
loadAllCollections(); // Load all collections when entering a specific folder loadAllCollections(); // Load all collections when entering a specific folder
}} }}
activeOpacity={0.7} activeOpacity={0.7}
// TV optimizations
hasTVPreferredFocus={false}
tvParallaxProperties={{
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.1,
}}
> >
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}> <View style={[
styles.posterContainer,
styles.folderContainer,
{
shadowColor: currentTheme.colors.black,
height: itemWidth * 1.5, // 2:3 aspect ratio
}
]}>
<LinearGradient <LinearGradient
colors={folder.gradient} colors={folder.gradient}
style={styles.folderGradient} style={styles.folderGradient}
> >
<MaterialIcons <MaterialIcons
name={folder.icon} name={folder.icon}
size={60} size={width > 1920 ? 80 : width > 1280 ? 70 : 60} // Responsive icon size
color={currentTheme.colors.white} color={currentTheme.colors.white}
style={{ marginBottom: 12 }} style={{ marginBottom: width > 1920 ? 16 : 12 }}
/> />
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}> <Text style={[
styles.folderTitle,
{
color: currentTheme.colors.white,
fontSize: width > 1920 ? 22 : width > 1280 ? 20 : 18, // Responsive font size
}
]}>
{folder.name} {folder.name}
</Text> </Text>
<Text style={styles.folderCount}> <Text style={[
styles.folderCount,
{
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
}
]}>
{folder.itemCount} items {folder.itemCount} items
</Text> </Text>
<Text style={styles.folderSubtitle}> <Text style={[
styles.folderSubtitle,
{
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
}
]}>
{folder.description} {folder.description}
</Text> </Text>
</LinearGradient> </LinearGradient>
@ -859,14 +1002,28 @@ const LibraryScreen = () => {
return renderItem({ item: item as LibraryItem }); return renderItem({ item: item as LibraryItem });
}} }}
keyExtractor={item => item.id} keyExtractor={item => item.id}
numColumns={2} numColumns={numColumns}
contentContainerStyle={styles.listContainer} contentContainerStyle={[
styles.listContainer,
{
paddingHorizontal: containerPadding,
paddingVertical: width > 1920 ? 24 : width > 1280 ? 20 : 16,
paddingBottom: width > 1920 ? 120 : 90,
}
]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper} columnWrapperStyle={numColumns > 1 ? [
initialNumToRender={6} styles.columnWrapper,
maxToRenderPerBatch={6} {
marginBottom: width > 1920 ? 24 : width > 1280 ? 20 : 16,
}
] : undefined}
initialNumToRender={numColumns * 3}
maxToRenderPerBatch={numColumns * 2}
windowSize={5} windowSize={5}
removeClippedSubviews={Platform.OS === 'android'} removeClippedSubviews={Platform.OS === 'android'}
// TV optimizations
getItemLayout={undefined} // Let FlatList calculate for TV focus
/> />
); );
}; };
@ -1017,7 +1174,7 @@ const styles = StyleSheet.create({
paddingBottom: 90, paddingBottom: 90,
}, },
columnWrapper: { columnWrapper: {
justifyContent: 'space-between', justifyContent: 'flex-start',
marginBottom: 16, marginBottom: 16,
}, },
skeletonContainer: { skeletonContainer: {

View file

@ -7,6 +7,7 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
TouchableOpacity, TouchableOpacity,
ScrollView,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRoute, useNavigation } from '@react-navigation/native'; import { useRoute, useNavigation } from '@react-navigation/native';
@ -21,13 +22,6 @@ import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection'; import { RatingsSection } from '../components/metadata/RatingsSection';
import { RouteParams, Episode } from '../types/metadata'; import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { RouteProp } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
@ -38,7 +32,6 @@ import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScre
import HeroSection from '../components/metadata/HeroSection'; import HeroSection from '../components/metadata/HeroSection';
import FloatingHeader from '../components/metadata/FloatingHeader'; import FloatingHeader from '../components/metadata/FloatingHeader';
import MetadataDetails from '../components/metadata/MetadataDetails'; import MetadataDetails from '../components/metadata/MetadataDetails';
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
import { useMetadataAssets } from '../hooks/useMetadataAssets'; import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useWatchProgress } from '../hooks/useWatchProgress'; import { useWatchProgress } from '../hooks/useWatchProgress';
import { TraktService, TraktPlaybackItem } from '../services/traktService'; import { TraktService, TraktPlaybackItem } from '../services/traktService';
@ -59,7 +52,7 @@ const MetadataScreen: React.FC = () => {
const [isContentReady, setIsContentReady] = useState(false); const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false); const [showCastModal, setShowCastModal] = useState(false);
const [selectedCastMember, setSelectedCastMember] = useState<any>(null); const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const transitionOpacity = useSharedValue(1); // Removed animation state
const { const {
metadata, metadata,
@ -84,7 +77,6 @@ const MetadataScreen: React.FC = () => {
// Optimized hooks with memoization // Optimized hooks with memoization
const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
// Fetch and log Trakt progress data when entering the screen // Fetch and log Trakt progress data when entering the screen
useEffect(() => { useEffect(() => {
@ -192,11 +184,10 @@ const MetadataScreen: React.FC = () => {
useEffect(() => { useEffect(() => {
if (isReady) { if (isReady) {
setIsContentReady(true); setIsContentReady(true);
transitionOpacity.value = withTiming(1, { duration: 50 }); // Removed animation logic
} else if (!isReady && isContentReady) { } else if (!isReady && isContentReady) {
setIsContentReady(false); setIsContentReady(false);
transitionOpacity.value = 0; }
}
}, [isReady, isContentReady]); }, [isReady, isContentReady]);
// Optimized callback functions with reduced dependencies // Optimized callback functions with reduced dependencies
@ -318,19 +309,7 @@ const MetadataScreen: React.FC = () => {
setShowCastModal(true); setShowCastModal(true);
}, []); }, []);
// Ultra-optimized animated styles - minimal calculations // Removed animated styles
const containerStyle = useAnimatedStyle(() => ({
opacity: animations.screenOpacity.value,
}), []);
const contentStyle = useAnimatedStyle(() => ({
opacity: animations.contentOpacity.value,
transform: [{ translateY: animations.uiElementsTranslateY.value }]
}), []);
const transitionStyle = useAnimatedStyle(() => ({
opacity: transitionOpacity.value,
}), []);
// Memoized error component for performance // Memoized error component for performance
const ErrorComponent = useMemo(() => { const ErrorComponent = useMemo(() => {
@ -377,7 +356,7 @@ const MetadataScreen: React.FC = () => {
return ( return (
<SafeAreaView <SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']} edges={['bottom']}
> >
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated /> <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
@ -390,19 +369,14 @@ const MetadataScreen: React.FC = () => {
logoLoadError={assetData.logoLoadError} logoLoadError={assetData.logoLoadError}
handleBack={handleBack} handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary} handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary} inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop} safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError} setLogoLoadError={assetData.setLogoLoadError}
/> />
<Animated.ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false} bounces={false}
overScrollMode="never" overScrollMode="never"
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@ -413,14 +387,6 @@ const MetadataScreen: React.FC = () => {
bannerImage={assetData.bannerImage} bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner} loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError} logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress} watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'} type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails} getEpisodeDetails={watchProgressData.getEpisodeDetails}
@ -436,7 +402,7 @@ const MetadataScreen: React.FC = () => {
/> />
{/* Main Content - Optimized */} {/* Main Content - Optimized */}
<Animated.View style={contentStyle}> <View>
<MetadataDetails <MetadataDetails
metadata={metadata} metadata={metadata}
imdbId={imdbId} imdbId={imdbId}
@ -475,8 +441,8 @@ const MetadataScreen: React.FC = () => {
) : ( ) : (
metadata && <MovieContent metadata={metadata} /> metadata && <MovieContent metadata={metadata} />
)} )}
</Animated.View> </View>
</Animated.ScrollView> </ScrollView>
</> </>
)} )}

View file

@ -13,14 +13,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Animated, { // Removed react-native-reanimated imports
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
FadeInDown,
FadeInUp,
} from 'react-native-reanimated';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { NavigationProp, useNavigation } from '@react-navigation/native'; import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
@ -77,18 +70,14 @@ const OnboardingScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const progressValue = useSharedValue(0); // Removed animated progress values
const animatedProgressStyle = useAnimatedStyle(() => ({
width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`),
}));
const handleNext = () => { const handleNext = () => {
if (currentIndex < onboardingData.length - 1) { if (currentIndex < onboardingData.length - 1) {
const nextIndex = currentIndex + 1; const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true }); flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true });
progressValue.value = (nextIndex + 1) / onboardingData.length; // Removed progress animation
} else { } else {
handleGetStarted(); handleGetStarted();
} }
@ -125,22 +114,16 @@ const OnboardingScreen = () => {
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> >
<Animated.View <View style={styles.iconWrapper}>
entering={FadeInDown.delay(300).duration(800)}
style={styles.iconWrapper}
>
<MaterialIcons <MaterialIcons
name={item.icon} name={item.icon}
size={80} size={80}
color="white" color="white"
/> />
</Animated.View> </View>
</LinearGradient> </LinearGradient>
<Animated.View <View style={styles.textContainer}>
entering={FadeInUp.delay(500).duration(800)}
style={styles.textContainer}
>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
{item.title} {item.title}
</Text> </Text>
@ -150,7 +133,7 @@ const OnboardingScreen = () => {
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
{item.description} {item.description}
</Text> </Text>
</Animated.View> </View>
</View> </View>
); );
}; };
@ -188,11 +171,10 @@ const OnboardingScreen = () => {
{/* Progress Bar */} {/* Progress Bar */}
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Animated.View <View
style={[ style={[
styles.progressBar, styles.progressBar,
{ backgroundColor: currentTheme.colors.primary }, { backgroundColor: currentTheme.colors.primary, width: `${((currentIndex + 1) / onboardingData.length) * 100}%` }
animatedProgressStyle
]} ]}
/> />
</View> </View>