removed reanimated
This commit is contained in:
parent
604b38ba20
commit
332cf99f67
14 changed files with 1100 additions and 2262 deletions
2
App.tsx
2
App.tsx
|
|
@ -19,7 +19,7 @@ import AppNavigator, {
|
|||
CustomNavigationDarkTheme,
|
||||
CustomDarkTheme
|
||||
} from './src/navigation/AppNavigator';
|
||||
import 'react-native-reanimated';
|
||||
// Removed react-native-reanimated import
|
||||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
import { TraktProvider } from './src/contexts/TraktContext';
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-immersive-mode": "^2.0.2",
|
||||
"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-screens": "~4.4.0",
|
||||
"react-native-svg": "^15.11.2",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
Alert,
|
||||
ActivityIndicator
|
||||
} from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
// Removed react-native-reanimated import
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
|
|
@ -572,7 +572,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleContainer}>
|
||||
<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 */}
|
||||
{deletingItemId === item.id && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={styles.deletingOverlay}
|
||||
>
|
||||
<View style={styles.deletingOverlay}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
|
@ -700,7 +696,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
snapToAlignment="start"
|
||||
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -13,19 +13,6 @@ import {
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
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';
|
||||
|
||||
interface DropUpMenuProps {
|
||||
|
|
@ -36,144 +23,92 @@ interface DropUpMenuProps {
|
|||
}
|
||||
|
||||
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
|
||||
const translateY = useSharedValue(300);
|
||||
const opacity = useSharedValue(0);
|
||||
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 = [
|
||||
{
|
||||
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
|
||||
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
|
||||
action: 'library'
|
||||
},
|
||||
{
|
||||
icon: 'check-circle',
|
||||
label: 'Mark as Watched',
|
||||
action: 'watched'
|
||||
},
|
||||
{
|
||||
icon: 'playlist-add',
|
||||
label: 'Add to Playlist',
|
||||
action: 'playlist'
|
||||
},
|
||||
{
|
||||
icon: 'share',
|
||||
label: 'Share',
|
||||
action: 'share'
|
||||
}
|
||||
{ id: 'play', label: 'Play', icon: 'play-arrow' },
|
||||
{ id: 'info', label: 'More Info', icon: 'info-outline' },
|
||||
{ id: 'save', label: 'Add to My List', icon: 'bookmark-border' },
|
||||
{ id: 'share', label: 'Share', icon: 'share' },
|
||||
];
|
||||
|
||||
const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF';
|
||||
const handleOptionPress = (optionId: string) => {
|
||||
onOptionSelect(optionId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Animated.View style={[styles.modalOverlay, overlayStyle]}>
|
||||
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.menuContainer, menuStyle, { backgroundColor }]}>
|
||||
<View style={styles.dragHandle} />
|
||||
<View style={styles.menuHeader}>
|
||||
<ExpoImage
|
||||
source={{ uri: item.poster }}
|
||||
style={styles.menuPoster}
|
||||
contentFit="cover"
|
||||
<View style={styles.modalOverlay}>
|
||||
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
|
||||
|
||||
<View style={[
|
||||
styles.menuContainer,
|
||||
{ backgroundColor: isDarkMode ? colors.darkBackground : colors.lightBackground }
|
||||
]}>
|
||||
{/* Drag Handle */}
|
||||
<View style={styles.dragHandle} />
|
||||
|
||||
{/* 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={[styles.menuTitle, { color: isDarkMode ? '#FFFFFF' : '#000000' }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={[styles.menuYear, { color: isDarkMode ? '#999999' : '#666666' }]}>
|
||||
{item.year}
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
<Text style={[
|
||||
styles.menuOptionText,
|
||||
{ color: isDarkMode ? colors.white : colors.black }
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -254,4 +189,4 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default DropUpMenu;
|
||||
export default DropUpMenu;
|
||||
|
|
@ -17,14 +17,6 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
Easing,
|
||||
withDelay
|
||||
} from 'react-native-reanimated';
|
||||
import { StreamingContent } from '../../services/catalogService';
|
||||
import { SkeletonFeatured } from './SkeletonLoaders';
|
||||
import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from '../../utils/logoUtils';
|
||||
|
|
@ -49,70 +41,18 @@ const NoFeaturedContent = () => {
|
|||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
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 (
|
||||
<View style={styles.noContentContainer}>
|
||||
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={styles.noContentTitle}>No Featured Content</Text>
|
||||
<Text style={styles.noContentText}>
|
||||
Install addons with catalogs or change the content source in your settings.
|
||||
</Text>
|
||||
<View style={styles.noContentButtons}>
|
||||
<View style={[styles.featuredContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={styles.backgroundFallback}>
|
||||
<MaterialIcons name="movie" size={64} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.noContentText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
No featured content available
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Search')}
|
||||
>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.noContentButton}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
>
|
||||
<Text style={styles.noContentButtonText}>Settings</Text>
|
||||
<Text style={styles.exploreButtonText}>Explore Content</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -122,456 +62,193 @@ const NoFeaturedContent = () => {
|
|||
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
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 logoOpacity = useSharedValue(0);
|
||||
const bannerOpacity = useSharedValue(0);
|
||||
const posterOpacity = useSharedValue(0);
|
||||
const prevContentIdRef = useRef<string | null>(null);
|
||||
// Add state for tracking logo load errors
|
||||
const [logoLoadError, setLogoLoadError] = useState(false);
|
||||
// Add a ref to track logo fetch in progress
|
||||
const logoFetchInProgress = useRef<boolean>(false);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [isLogoLoading, setIsLogoLoading] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
// Removed TMDB service integration
|
||||
|
||||
// Enhanced poster transition animations
|
||||
const posterScale = useSharedValue(1);
|
||||
const posterTranslateY = useSharedValue(0);
|
||||
const overlayOpacity = useSharedValue(0.15);
|
||||
|
||||
// Animation values
|
||||
const posterAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: posterOpacity.value,
|
||||
transform: [
|
||||
{ scale: posterScale.value },
|
||||
{ translateY: posterTranslateY.value }
|
||||
],
|
||||
}));
|
||||
|
||||
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;
|
||||
// Preload image when component mounts
|
||||
useEffect(() => {
|
||||
if (featuredContent?.poster && !imageCache[featuredContent.poster]) {
|
||||
const preloadImage = async () => {
|
||||
try {
|
||||
await imageCacheService.getCachedImageUrl(featuredContent.poster!);
|
||||
imageCache[featuredContent.poster!] = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to preload featured image:', error);
|
||||
}
|
||||
};
|
||||
preloadImage();
|
||||
}
|
||||
};
|
||||
}, [featuredContent?.poster]);
|
||||
|
||||
// Reset logo error state when content changes
|
||||
// TMDB data fetching removed due to API limitations
|
||||
|
||||
// Fetch logo when featured content changes
|
||||
useEffect(() => {
|
||||
setLogoLoadError(false);
|
||||
}, [featuredContent?.id]);
|
||||
|
||||
// Fetch logo based on preference
|
||||
useEffect(() => {
|
||||
if (!featuredContent || logoFetchInProgress.current) return;
|
||||
|
||||
const fetchLogo = async () => {
|
||||
logoFetchInProgress.current = true;
|
||||
|
||||
if (!featuredContent || isLogoLoading) return;
|
||||
|
||||
setIsLogoLoading(true);
|
||||
setLogoUrl(null);
|
||||
|
||||
try {
|
||||
const contentId = featuredContent.id;
|
||||
const contentData = featuredContent; // Use a clearer variable name
|
||||
const currentLogo = contentData.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;
|
||||
// Use existing logo logic
|
||||
if (featuredContent.logo) {
|
||||
setLogoUrl(featuredContent.logo);
|
||||
}
|
||||
|
||||
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) {
|
||||
// logger.error('[FeaturedContent] Error in fetchLogo:', error);
|
||||
setLogoLoadError(true);
|
||||
logger.error('Error fetching logo:', error);
|
||||
} finally {
|
||||
logoFetchInProgress.current = false;
|
||||
setIsLogoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger fetch when content changes
|
||||
fetchLogo();
|
||||
}, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
|
||||
}, [featuredContent]);
|
||||
|
||||
// Load poster and logo
|
||||
useEffect(() => {
|
||||
if (!featuredContent) return;
|
||||
|
||||
const posterUrl = featuredContent.banner || featuredContent.poster;
|
||||
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;
|
||||
const handlePlayPress = () => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatGenres = (genres: string[] | undefined) => {
|
||||
if (!genres || genres.length === 0) return '';
|
||||
return genres.slice(0, 3).join(' • ');
|
||||
};
|
||||
|
||||
if (!featuredContent) {
|
||||
return <NoFeaturedContent />;
|
||||
}
|
||||
|
||||
const posterUrl = featuredContent.poster;
|
||||
const formattedGenres = formatGenres(featuredContent.genres);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.95}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}}
|
||||
style={styles.featuredContainer as ViewStyle}
|
||||
<View style={styles.featuredContainer}>
|
||||
{/* Background Image */}
|
||||
<View style={styles.imageContainer}>
|
||||
{posterUrl && !imageError ? (
|
||||
<ExpoImage
|
||||
source={{ uri: posterUrl }}
|
||||
style={styles.featuredImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={300}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
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]}>
|
||||
<ImageBackground
|
||||
source={{ uri: bannerUrl || featuredContent.poster }}
|
||||
style={styles.featuredImage as ViewStyle}
|
||||
resizeMode="cover"
|
||||
>
|
||||
{/* Subtle content overlay for better readability */}
|
||||
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
||||
<View style={styles.featuredContentContainer}>
|
||||
{/* Logo or Title */}
|
||||
{logoUrl && !isLogoLoading ? (
|
||||
<ExpoImage
|
||||
source={{ uri: logoUrl }}
|
||||
style={styles.featuredLogo}
|
||||
contentFit="contain"
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<Text style={[styles.featuredTitleText, { color: '#FFFFFF' }]} numberOfLines={2}>
|
||||
{featuredContent.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.1)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
currentTheme.colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||
style={styles.featuredGradient as ViewStyle}
|
||||
{/* Genres */}
|
||||
{formattedGenres && (
|
||||
<View style={styles.genreContainer}>
|
||||
<Text style={[styles.genreText, { color: '#FFFFFF' }]}>
|
||||
{formattedGenres}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.featuredButtons}>
|
||||
{/* Play Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton, { backgroundColor: '#FFFFFF' }]}
|
||||
onPress={handlePlayPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Animated.View
|
||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||
>
|
||||
{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>
|
||||
<MaterialIcons name="play-arrow" size={20} color="#000000" />
|
||||
<Text style={[styles.playButtonText, { color: '#000000' }]}>Play</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
||||
<TouchableOpacity
|
||||
style={styles.myListButton as ViewStyle}
|
||||
onPress={handleSaveToLibrary}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||
size={24}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* My List Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.myListButton}
|
||||
onPress={handleSaveToLibrary}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={isSaved ? "check" : "add"}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={[styles.myListButtonText, { color: '#FFFFFF' }]}>
|
||||
{isSaved ? 'Saved' : 'My List'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
||||
onPress={() => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Streams', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
||||
<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>
|
||||
{/* Info Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton}
|
||||
onPress={handleInfoPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={20} color="#FFFFFF" />
|
||||
<Text style={[styles.infoButtonText, { color: '#FFFFFF' }]}>Info</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
featuredContainer: {
|
||||
width: '100%',
|
||||
height: height * 0.55, // Slightly taller for better proportions
|
||||
height: height * 0.55,
|
||||
marginTop: 0,
|
||||
marginBottom: 12,
|
||||
position: 'relative',
|
||||
|
|
@ -596,7 +273,7 @@ const styles = StyleSheet.create({
|
|||
featuredImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transform: [{ scale: 1.05 }], // Subtle zoom for depth
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
backgroundFallback: {
|
||||
position: 'absolute',
|
||||
|
|
@ -724,6 +401,23 @@ const styles = StyleSheet.create({
|
|||
zIndex: 1,
|
||||
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);
|
||||
|
|
@ -20,7 +20,7 @@ import { tmdbService } from '../../services/tmdbService';
|
|||
import { useLibrary } from '../../hooks/useLibrary';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
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';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
|
@ -109,10 +109,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
item.poster);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInRight.delay(index * 50).duration(300)}
|
||||
style={styles.episodeItemContainer}
|
||||
>
|
||||
<View style={styles.episodeItemContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.episodeItem,
|
||||
|
|
@ -177,12 +174,12 @@ export const ThisWeekSection = React.memo(() => {
|
|||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
|
||||
|
|
@ -206,7 +203,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
snapToAlignment="start"
|
||||
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -337,4 +334,4 @@ const styles = StyleSheet.create({
|
|||
marginLeft: 6,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -7,19 +7,11 @@ import {
|
|||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
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 { useTheme } from '../../contexts/ThemeContext';
|
||||
import { Cast } from '../../types/cast';
|
||||
|
|
@ -54,419 +46,360 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
const { currentTheme } = useTheme();
|
||||
const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const modalOpacity = useSharedValue(0);
|
||||
const modalScale = useSharedValue(0.9);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && castMember) {
|
||||
modalOpacity.value = withTiming(1, { duration: 250 });
|
||||
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);
|
||||
}
|
||||
if (visible && castMember?.id) {
|
||||
fetchPersonDetails();
|
||||
}
|
||||
}, [visible, castMember]);
|
||||
}, [visible, castMember?.id]);
|
||||
|
||||
const fetchPersonDetails = async () => {
|
||||
if (!castMember || loading) return;
|
||||
if (!castMember?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const details = await tmdbService.getPersonDetails(castMember.id);
|
||||
setPersonDetails(details);
|
||||
setHasFetched(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching person details:', error);
|
||||
} catch (err) {
|
||||
console.error('Error fetching person details:', err);
|
||||
setError('Failed to load cast member details');
|
||||
} finally {
|
||||
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) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateAge = (birthday: string | null) => {
|
||||
if (!birthday) return null;
|
||||
const today = new Date();
|
||||
const birthDate = new Date(birthday);
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
try {
|
||||
const birthDate = new Date(birthday);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return age;
|
||||
};
|
||||
|
||||
if (!visible || !castMember) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(250)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
padding: 20,
|
||||
}}
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onPress={handleClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: MODAL_WIDTH,
|
||||
height: MODAL_HEIGHT,
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
currentTheme.colors.primary + 'DD',
|
||||
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" />
|
||||
<View style={styles.overlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.backdrop}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
/>
|
||||
|
||||
<View style={[styles.modalContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<BlurView intensity={80} style={styles.blurBackground} tint="dark" />
|
||||
) : (
|
||||
<View style={[styles.androidBackground, { backgroundColor: currentTheme.colors.darkBackground }]} />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Cast Details
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ padding: 20 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{loading ? (
|
||||
<View style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
}}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
}}>
|
||||
Loading details...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{/* Quick Info */}
|
||||
{(personDetails?.known_for_department || personDetails?.birthday || personDetails?.place_of_birth) && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
{personDetails?.known_for_department && (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: personDetails?.birthday || personDetails?.place_of_birth ? 12 : 0
|
||||
}}>
|
||||
<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={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
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 && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="place" size={16} color="#F59E0B" />
|
||||
<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 && (
|
||||
<View style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
Born on {formatDate(personDetails.birthday)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Loading details...
|
||||
</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.error} />
|
||||
<Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={fetchPersonDetails} style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : personDetails ? (
|
||||
<View style={styles.detailsContainer}>
|
||||
{/* Profile Image and Basic Info */}
|
||||
<View style={styles.profileSection}>
|
||||
<View style={styles.imageContainer}>
|
||||
{personDetails.profile_path ? (
|
||||
<Image
|
||||
source={{ uri: `https://image.tmdb.org/t/p/w500${personDetails.profile_path}` }}
|
||||
style={styles.profileImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.placeholderImage, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<MaterialIcons name="person" size={60} color={currentTheme.colors.mediumEmphasis} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.basicInfo}>
|
||||
<Text style={[styles.name, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{personDetails.name}
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.department, { color: currentTheme.colors.primary }]}>
|
||||
{personDetails.known_for_department}
|
||||
</Text>
|
||||
|
||||
{personDetails.birthday && (
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialIcons name="cake" size={16} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{formatDate(personDetails.birthday)}
|
||||
{calculateAge(personDetails.birthday) && ` (${calculateAge(personDetails.birthday)} years old)`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{personDetails.place_of_birth && (
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialIcons name="place" size={16} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{personDetails.place_of_birth}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Biography */}
|
||||
{personDetails.biography && (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Biography
|
||||
</Text>
|
||||
<Text style={[styles.biography, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{personDetails.biography}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Also Known As */}
|
||||
{personDetails.also_known_as && personDetails.also_known_as.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Also Known As
|
||||
</Text>
|
||||
<View style={styles.aliasContainer}>
|
||||
{personDetails.also_known_as.slice(0, 5).map((alias, index) => (
|
||||
<View key={index} style={[styles.aliasChip, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<Text style={[styles.aliasText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{alias}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
{/* Biography */}
|
||||
{personDetails?.biography && (
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Biography
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: '400',
|
||||
}}>
|
||||
{personDetails.biography}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Also Known As - Compact */}
|
||||
{personDetails?.also_known_as && personDetails.also_known_as.length > 0 && (
|
||||
<View>
|
||||
<Text style={{
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Also Known As
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
}}>
|
||||
{personDetails.also_known_as.slice(0, 4).join(' • ')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No details available */}
|
||||
{!loading && !personDetails?.biography && !personDetails?.birthday && !personDetails?.place_of_birth && (
|
||||
<View style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
}}>
|
||||
<MaterialIcons name="info" size={32} color="rgba(255, 255, 255, 0.3)" />
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
No additional details available
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const styles = {
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
justifyContent: 'center' as const,
|
||||
alignItems: 'center' as const,
|
||||
},
|
||||
backdrop: {
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
modalContainer: {
|
||||
width: MODAL_WIDTH,
|
||||
height: MODAL_HEIGHT,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden' as const,
|
||||
elevation: 8,
|
||||
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;
|
||||
|
|
@ -11,11 +11,6 @@ import { BlurView as ExpoBlurView } from 'expo-blur';
|
|||
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolate,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
|
|
@ -27,9 +22,6 @@ interface FloatingHeaderProps {
|
|||
handleBack: () => void;
|
||||
handleToggleLibrary: () => void;
|
||||
inLibrary: boolean;
|
||||
headerOpacity: Animated.SharedValue<number>;
|
||||
headerElementsY: Animated.SharedValue<number>;
|
||||
headerElementsOpacity: Animated.SharedValue<number>;
|
||||
safeAreaTop: number;
|
||||
setLogoLoadError: (error: boolean) => void;
|
||||
}
|
||||
|
|
@ -40,37 +32,20 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
handleBack,
|
||||
handleToggleLibrary,
|
||||
inLibrary,
|
||||
headerOpacity,
|
||||
headerElementsY,
|
||||
headerElementsOpacity,
|
||||
safeAreaTop,
|
||||
setLogoLoadError,
|
||||
}) => {
|
||||
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 (
|
||||
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
|
||||
<View style={styles.floatingHeader}>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<ExpoBlurView
|
||||
intensity={50}
|
||||
tint="dark"
|
||||
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
|
||||
>
|
||||
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
|
||||
<View style={styles.floatingHeaderContent}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBack}
|
||||
|
|
@ -111,7 +86,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
color={currentTheme.colors.highEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</ExpoBlurView>
|
||||
) : (
|
||||
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
|
||||
|
|
@ -121,7 +96,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
blurAmount={15}
|
||||
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
|
||||
/>
|
||||
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
|
||||
<View style={styles.floatingHeaderContent}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBack}
|
||||
|
|
@ -162,11 +137,11 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
|
|||
color={currentTheme.colors.highEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{Platform.OS === 'ios' && <View style={[styles.headerBottomBorder, { backgroundColor: 'rgba(255,255,255,0.15)' }]} />}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -240,4 +215,4 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default React.memo(FloatingHeader);
|
||||
export default React.memo(FloatingHeader);
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
SlideInRight,
|
||||
SlideOutRight,
|
||||
} from 'react-native-reanimated';
|
||||
// Removed react-native-reanimated imports
|
||||
import { styles } from '../utils/playerStyles';
|
||||
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
|
||||
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
|
||||
|
|
@ -89,9 +84,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -107,12 +100,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
onPress={handleClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Side Menu */}
|
||||
<Animated.View
|
||||
entering={SlideInRight.duration(300)}
|
||||
exiting={SlideOutRight.duration(250)}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -527,7 +518,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -539,4 +530,4 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default SubtitleModals;
|
||||
export default SubtitleModals;
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -13,12 +13,13 @@ import {
|
|||
ActivityIndicator,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TVEventHandler,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
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 { catalogService } from '../services/catalogService';
|
||||
import type { StreamingContent } from '../services/catalogService';
|
||||
|
|
@ -203,7 +204,7 @@ const SkeletonLoader = () => {
|
|||
const LibraryScreen = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
const { width } = useWindowDimensions();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
|
||||
|
|
@ -212,6 +213,22 @@ const LibraryScreen = () => {
|
|||
const insets = useSafeAreaInsets();
|
||||
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
|
||||
const {
|
||||
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 => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'movies') return item.type === 'movie';
|
||||
|
|
@ -328,15 +390,37 @@ const LibraryScreen = () => {
|
|||
return folders.filter(folder => folder.itemCount > 0);
|
||||
}, [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 }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
{
|
||||
width: itemWidth,
|
||||
marginHorizontal: itemSpacing / 2,
|
||||
}
|
||||
]}
|
||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||
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
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
|
|
@ -348,13 +432,24 @@ const LibraryScreen = () => {
|
|||
style={styles.posterGradient}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.lastWatched && (
|
||||
<Text style={styles.lastWatched}>
|
||||
<Text style={[
|
||||
styles.lastWatched,
|
||||
{
|
||||
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
|
||||
}
|
||||
]}>
|
||||
{item.lastWatched}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -365,7 +460,11 @@ const LibraryScreen = () => {
|
|||
<View
|
||||
style={[
|
||||
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>
|
||||
|
|
@ -374,11 +473,17 @@ const LibraryScreen = () => {
|
|||
<View style={styles.badgeContainer}>
|
||||
<MaterialIcons
|
||||
name="live-tv"
|
||||
size={14}
|
||||
size={width > 1920 ? 18 : width > 1280 ? 16 : 14} // Responsive icon size
|
||||
color={currentTheme.colors.white}
|
||||
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>
|
||||
|
|
@ -388,31 +493,69 @@ const LibraryScreen = () => {
|
|||
// Render individual Trakt collection folder
|
||||
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
{
|
||||
width: itemWidth,
|
||||
marginHorizontal: itemSpacing / 2,
|
||||
}
|
||||
]}
|
||||
onPress={() => {
|
||||
setSelectedTraktFolder(folder.id);
|
||||
loadAllCollections(); // Load all collections when entering a specific folder
|
||||
}}
|
||||
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
|
||||
colors={folder.gradient}
|
||||
style={styles.folderGradient}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={folder.icon}
|
||||
size={60}
|
||||
size={width > 1920 ? 80 : width > 1280 ? 70 : 60} // Responsive icon size
|
||||
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}
|
||||
</Text>
|
||||
<Text style={styles.folderCount}>
|
||||
<Text style={[
|
||||
styles.folderCount,
|
||||
{
|
||||
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
|
||||
}
|
||||
]}>
|
||||
{folder.itemCount} items
|
||||
</Text>
|
||||
<Text style={styles.folderSubtitle}>
|
||||
<Text style={[
|
||||
styles.folderSubtitle,
|
||||
{
|
||||
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
|
||||
}
|
||||
]}>
|
||||
{folder.description}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
|
|
@ -859,14 +1002,28 @@ const LibraryScreen = () => {
|
|||
return renderItem({ item: item as LibraryItem });
|
||||
}}
|
||||
keyExtractor={item => item.id}
|
||||
numColumns={2}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
numColumns={numColumns}
|
||||
contentContainerStyle={[
|
||||
styles.listContainer,
|
||||
{
|
||||
paddingHorizontal: containerPadding,
|
||||
paddingVertical: width > 1920 ? 24 : width > 1280 ? 20 : 16,
|
||||
paddingBottom: width > 1920 ? 120 : 90,
|
||||
}
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={6}
|
||||
columnWrapperStyle={numColumns > 1 ? [
|
||||
styles.columnWrapper,
|
||||
{
|
||||
marginBottom: width > 1920 ? 24 : width > 1280 ? 20 : 16,
|
||||
}
|
||||
] : undefined}
|
||||
initialNumToRender={numColumns * 3}
|
||||
maxToRenderPerBatch={numColumns * 2}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
// TV optimizations
|
||||
getItemLayout={undefined} // Let FlatList calculate for TV focus
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1017,7 +1174,7 @@ const styles = StyleSheet.create({
|
|||
paddingBottom: 90,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 16,
|
||||
},
|
||||
skeletonContainer: {
|
||||
|
|
@ -1248,4 +1405,4 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default LibraryScreen;
|
||||
export default LibraryScreen;
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ActivityIndicator,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
|
|
@ -21,13 +22,6 @@ import { MovieContent } from '../components/metadata/MovieContent';
|
|||
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||
import { RouteParams, Episode } from '../types/metadata';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolate,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
|
@ -38,7 +32,6 @@ import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScre
|
|||
import HeroSection from '../components/metadata/HeroSection';
|
||||
import FloatingHeader from '../components/metadata/FloatingHeader';
|
||||
import MetadataDetails from '../components/metadata/MetadataDetails';
|
||||
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
|
||||
import { useMetadataAssets } from '../hooks/useMetadataAssets';
|
||||
import { useWatchProgress } from '../hooks/useWatchProgress';
|
||||
import { TraktService, TraktPlaybackItem } from '../services/traktService';
|
||||
|
|
@ -59,7 +52,7 @@ const MetadataScreen: React.FC = () => {
|
|||
const [isContentReady, setIsContentReady] = useState(false);
|
||||
const [showCastModal, setShowCastModal] = useState(false);
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
const transitionOpacity = useSharedValue(1);
|
||||
// Removed animation state
|
||||
|
||||
const {
|
||||
metadata,
|
||||
|
|
@ -84,7 +77,6 @@ const MetadataScreen: React.FC = () => {
|
|||
// Optimized hooks with memoization
|
||||
const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -192,11 +184,10 @@ const MetadataScreen: React.FC = () => {
|
|||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsContentReady(true);
|
||||
transitionOpacity.value = withTiming(1, { duration: 50 });
|
||||
// Removed animation logic
|
||||
} else if (!isReady && isContentReady) {
|
||||
setIsContentReady(false);
|
||||
transitionOpacity.value = 0;
|
||||
}
|
||||
setIsContentReady(false);
|
||||
}
|
||||
}, [isReady, isContentReady]);
|
||||
|
||||
// Optimized callback functions with reduced dependencies
|
||||
|
|
@ -318,19 +309,7 @@ const MetadataScreen: React.FC = () => {
|
|||
setShowCastModal(true);
|
||||
}, []);
|
||||
|
||||
// Ultra-optimized animated styles - minimal calculations
|
||||
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,
|
||||
}), []);
|
||||
// Removed animated styles
|
||||
|
||||
// Memoized error component for performance
|
||||
const ErrorComponent = useMemo(() => {
|
||||
|
|
@ -377,7 +356,7 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||
edges={['bottom']}
|
||||
>
|
||||
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
|
||||
|
|
@ -390,19 +369,14 @@ const MetadataScreen: React.FC = () => {
|
|||
logoLoadError={assetData.logoLoadError}
|
||||
handleBack={handleBack}
|
||||
handleToggleLibrary={handleToggleLibrary}
|
||||
headerElementsY={animations.headerElementsY}
|
||||
inLibrary={inLibrary}
|
||||
headerOpacity={animations.headerOpacity}
|
||||
headerElementsOpacity={animations.headerElementsOpacity}
|
||||
safeAreaTop={safeAreaTop}
|
||||
setLogoLoadError={assetData.setLogoLoadError}
|
||||
/>
|
||||
|
||||
<Animated.ScrollView
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={animations.scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
|
|
@ -413,14 +387,6 @@ const MetadataScreen: React.FC = () => {
|
|||
bannerImage={assetData.bannerImage}
|
||||
loadingBanner={assetData.loadingBanner}
|
||||
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}
|
||||
type={type as 'movie' | 'series'}
|
||||
getEpisodeDetails={watchProgressData.getEpisodeDetails}
|
||||
|
|
@ -436,7 +402,7 @@ const MetadataScreen: React.FC = () => {
|
|||
/>
|
||||
|
||||
{/* Main Content - Optimized */}
|
||||
<Animated.View style={contentStyle}>
|
||||
<View>
|
||||
<MetadataDetails
|
||||
metadata={metadata}
|
||||
imdbId={imdbId}
|
||||
|
|
@ -475,8 +441,8 @@ const MetadataScreen: React.FC = () => {
|
|||
) : (
|
||||
metadata && <MovieContent metadata={metadata} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,7 @@ import {
|
|||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
FadeInDown,
|
||||
FadeInUp,
|
||||
} from 'react-native-reanimated';
|
||||
// Removed react-native-reanimated imports
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
|
@ -77,18 +70,14 @@ const OnboardingScreen = () => {
|
|||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const progressValue = useSharedValue(0);
|
||||
|
||||
const animatedProgressStyle = useAnimatedStyle(() => ({
|
||||
width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`),
|
||||
}));
|
||||
// Removed animated progress values
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex < onboardingData.length - 1) {
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true });
|
||||
progressValue.value = (nextIndex + 1) / onboardingData.length;
|
||||
// Removed progress animation
|
||||
} else {
|
||||
handleGetStarted();
|
||||
}
|
||||
|
|
@ -125,22 +114,16 @@ const OnboardingScreen = () => {
|
|||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(800)}
|
||||
style={styles.iconWrapper}
|
||||
>
|
||||
<View style={styles.iconWrapper}>
|
||||
<MaterialIcons
|
||||
name={item.icon}
|
||||
size={80}
|
||||
color="white"
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(500).duration(800)}
|
||||
style={styles.textContainer}
|
||||
>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
|
@ -150,7 +133,7 @@ const OnboardingScreen = () => {
|
|||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -188,11 +171,10 @@ const OnboardingScreen = () => {
|
|||
|
||||
{/* Progress Bar */}
|
||||
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
animatedProgressStyle
|
||||
{ backgroundColor: currentTheme.colors.primary, width: `${((currentIndex + 1) / onboardingData.length) * 100}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -370,4 +352,4 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default OnboardingScreen;
|
||||
export default OnboardingScreen;
|
||||
Loading…
Reference in a new issue