From 9446aced3c53f0c56bb370c08c0f2c7ca12dd8d4 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 17 Aug 2025 21:00:05 +0530 Subject: [PATCH] improved cast section with filmographies --- src/components/metadata/CastDetailsModal.tsx | 250 +++--- src/navigation/AppNavigator.tsx | 24 + src/screens/CastMoviesScreen.tsx | 772 +++++++++++++++++++ src/services/tmdbService.ts | 51 ++ 4 files changed, 969 insertions(+), 128 deletions(-) create mode 100644 src/screens/CastMoviesScreen.tsx diff --git a/src/components/metadata/CastDetailsModal.tsx b/src/components/metadata/CastDetailsModal.tsx index af7f746..20ba824 100644 --- a/src/components/metadata/CastDetailsModal.tsx +++ b/src/components/metadata/CastDetailsModal.tsx @@ -24,6 +24,9 @@ import { LinearGradient } from 'expo-linear-gradient'; import { useTheme } from '../../contexts/ThemeContext'; import { Cast } from '../../types/cast'; import { tmdbService } from '../../services/tmdbService'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; interface CastDetailsModalProps { visible: boolean; @@ -53,6 +56,7 @@ export const CastDetailsModal: React.FC = ({ castMember, }) => { const { currentTheme } = useTheme(); + const navigation = useNavigation>(); const [personDetails, setPersonDetails] = useState(null); const [loading, setLoading] = useState(false); const [hasFetched, setHasFetched] = useState(false); @@ -105,6 +109,16 @@ export const CastDetailsModal: React.FC = ({ }); }; + const handleViewMovies = () => { + if (castMember) { + handleClose(); + // Navigate after modal is closed + setTimeout(() => { + navigation.navigate('CastMovies', { castMember }); + }, 300); + } + }; + const formatDate = (dateString: string | null) => { if (!dateString) return null; const date = new Date(dateString); @@ -197,24 +211,21 @@ export const CastDetailsModal: React.FC = ({ return ( <> {/* Header */} - + {castMember?.profile_path ? ( = ({ justifyContent: 'center', }}> {castMember?.name?.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)} @@ -245,16 +256,16 @@ export const CastDetailsModal: React.FC = ({ {castMember?.name} {castMember?.character && ( as {castMember.character} @@ -264,25 +275,25 @@ export const CastDetailsModal: React.FC = ({ - + - + {/* Content */} {loading ? ( @@ -302,58 +313,33 @@ export const CastDetailsModal: React.FC = ({ ) : ( - {/* Quick Info */} - {(personDetails?.known_for_department || personDetails?.birthday || personDetails?.place_of_birth) && ( + {/* Basic Info */} + {(personDetails?.birthday || personDetails?.place_of_birth) && ( - {personDetails?.known_for_department && ( - - - - Department - - - {personDetails.known_for_department} - - - )} - {personDetails?.birthday && ( - + - Age - - {calculateAge(personDetails.birthday)} years old @@ -362,60 +348,61 @@ export const CastDetailsModal: React.FC = ({ {personDetails?.place_of_birth && ( - + - Born in - - - {personDetails.place_of_birth} - - - )} - - {personDetails?.birthday && ( - - - Born on {formatDate(personDetails.birthday)} + Born in {personDetails.place_of_birth} )} )} + {/* View Movies Button */} + + + + View Filmography + + + {/* Biography */} {personDetails?.biography && ( - Biography - - {personDetails.biography} @@ -423,42 +410,49 @@ export const CastDetailsModal: React.FC = ({ )} - {/* Also Known As - Compact */} + {/* Also Known As - Minimalistic */} {personDetails?.also_known_as && personDetails.also_known_as.length > 0 && ( - + Also Known As - {personDetails.also_known_as.slice(0, 4).join(' • ')} + {personDetails.also_known_as.slice(0, 3).join(' • ')} )} {/* No details available */} - {!loading && !personDetails?.biography && !personDetails?.birthday && !personDetails?.place_of_birth && ( + {!loading && !personDetails?.biography && !personDetails?.birthday && !personDetails?.place_of_birth && !personDetails?.also_known_as?.length && ( - - No additional details available + No additional information available )} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index fa7fdf1..3faa7d1 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -45,6 +45,7 @@ import AuthScreen from '../screens/AuthScreen'; import AccountManageScreen from '../screens/AccountManageScreen'; import { AccountProvider, useAccount } from '../contexts/AccountContext'; import PluginsScreen from '../screens/PluginsScreen'; +import CastMoviesScreen from '../screens/CastMoviesScreen'; // Stack navigator types export type RootStackParamList = { @@ -115,6 +116,14 @@ export type RootStackParamList = { ThemeSettings: undefined; ProfilesSettings: undefined; ScraperSettings: undefined; + CastMovies: { + castMember: { + id: number; + name: string; + profile_path: string | null; + character?: string; + }; + }; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -1054,6 +1063,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + diff --git a/src/screens/CastMoviesScreen.tsx b/src/screens/CastMoviesScreen.tsx new file mode 100644 index 0000000..a3d4d89 --- /dev/null +++ b/src/screens/CastMoviesScreen.tsx @@ -0,0 +1,772 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + View, + Text, + TouchableOpacity, + ScrollView, + ActivityIndicator, + Dimensions, + Platform, + Alert, +} from 'react-native'; +import { FlashList } from '@shopify/flash-list'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import Animated, { + FadeIn, + FadeOut, + SlideInDown, + SlideOutDown, + useAnimatedStyle, + useSharedValue, + withTiming, + withSpring, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; +import { useTheme } from '../contexts/ThemeContext'; +import { Cast } from '../types/cast'; +import { tmdbService } from '../services/tmdbService'; +import { catalogService } from '../services/catalogService'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StackActions } from '@react-navigation/native'; + +const { width, height } = Dimensions.get('window'); +const isTablet = width >= 768; +const numColumns = isTablet ? 4 : 3; +const posterWidth = (width - 60 - (numColumns - 1) * 12) / numColumns; +const posterHeight = posterWidth * 1.5; + +interface CastMovie { + id: number; + title: string; + poster_path: string | null; + release_date: string; + character?: string; + job?: string; + media_type: 'movie' | 'tv'; + popularity?: number; + vote_average?: number; + isUpcoming?: boolean; +} + +type CastMoviesScreenRouteProp = RouteProp; + +const CastMoviesScreen: React.FC = () => { + const { currentTheme } = useTheme(); + const navigation = useNavigation>(); + const route = useRoute(); + const { castMember } = route.params; + const { top: safeAreaTop } = useSafeAreaInsets(); + + const [movies, setMovies] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedFilter, setSelectedFilter] = useState<'all' | 'movies' | 'tv'>('all'); + const [sortBy, setSortBy] = useState<'popularity' | 'latest' | 'upcoming'>('popularity'); + const scrollY = useSharedValue(0); + const [displayLimit, setDisplayLimit] = useState(30); // Start with fewer items for performance + const [isLoadingMore, setIsLoadingMore] = useState(false); + + useEffect(() => { + if (castMember) { + fetchCastCredits(); + } + }, [castMember]); + + // Reset display limit when filters change + useEffect(() => { + setDisplayLimit(30); + }, [selectedFilter, sortBy]); + + const fetchCastCredits = async () => { + if (!castMember) return; + + setLoading(true); + try { + const credits = await tmdbService.getPersonCombinedCredits(castMember.id); + + if (credits && credits.cast) { + const currentDate = new Date(); + + // Combine cast roles with enhanced data, excluding talk shows and variety shows + const allCredits = credits.cast + .filter((item: any) => { + // Filter out talk shows, variety shows, and ensure we have required data + const hasPoster = item.poster_path; + const hasReleaseDate = item.release_date || item.first_air_date; + + if (!hasPoster || !hasReleaseDate) return false; + + // Enhanced talk show filtering + const title = (item.title || item.name || '').toLowerCase(); + const overview = (item.overview || '').toLowerCase(); + + // List of common talk show and variety show keywords + const talkShowKeywords = [ + 'talk', 'show', 'late night', 'tonight show', 'jimmy fallon', 'snl', 'saturday night live', + 'variety', 'sketch comedy', 'stand-up', 'standup', 'comedy central', 'daily show', + 'colbert', 'kimmel', 'conan', 'ellen', 'oprah', 'view', 'today show', 'good morning', + 'interview', 'panel', 'roundtable', 'discussion', 'news', 'current events', 'politics', + 'reality', 'competition', 'game show', 'quiz', 'trivia', 'awards', 'ceremony', + 'red carpet', 'premiere', 'after party', 'behind the scenes', 'making of', 'documentary', + 'special', 'concert', 'live performance', 'mtv', 'vh1', 'bet', 'comedy', 'roast' + ]; + + // Check if any keyword matches + const isTalkShow = talkShowKeywords.some(keyword => + title.includes(keyword) || overview.includes(keyword) + ); + + return !isTalkShow; + }) + .map((item: any) => { + const releaseDate = new Date(item.release_date || item.first_air_date); + const isUpcoming = releaseDate > currentDate; + + return { + id: item.id, + title: item.title || item.name, + poster_path: item.poster_path, + release_date: item.release_date || item.first_air_date, + character: item.character, + media_type: item.media_type, + popularity: item.popularity || 0, + vote_average: item.vote_average || 0, + isUpcoming, + }; + }); + + setMovies(allCredits); + } + } catch (error) { + console.error('Error fetching cast credits:', error); + } finally { + setLoading(false); + } + }; + + const filteredAndSortedMovies = useMemo(() => { + let filtered = movies.filter(movie => { + if (selectedFilter === 'all') return true; + if (selectedFilter === 'movies') return movie.media_type === 'movie'; + if (selectedFilter === 'tv') return movie.media_type === 'tv'; + return true; + }); + + // If sorting by upcoming, only show upcoming content + if (sortBy === 'upcoming') { + filtered = filtered.filter(movie => movie.isUpcoming); + } + + // Apply sorting + filtered.sort((a, b) => { + switch (sortBy) { + case 'popularity': + return (b.popularity || 0) - (a.popularity || 0); + case 'latest': + const dateA = new Date(a.release_date || '1900-01-01'); + const dateB = new Date(b.release_date || '1900-01-01'); + return dateB.getTime() - dateA.getTime(); + case 'upcoming': + // Only show upcoming content, sorted by nearest release date + if (!a.isUpcoming && !b.isUpcoming) return 0; + if (a.isUpcoming && !b.isUpcoming) return -1; + if (!a.isUpcoming && b.isUpcoming) return 1; + const upcomingDateA = new Date(a.release_date || '9999-12-31'); + const upcomingDateB = new Date(b.release_date || '9999-12-31'); + return upcomingDateA.getTime() - upcomingDateB.getTime(); + default: + return 0; + } + }); + + return filtered; + }, [movies, selectedFilter, sortBy]); + + // Performance: Limit displayed items initially for better performance + const displayedMovies = useMemo(() => { + return filteredAndSortedMovies.slice(0, displayLimit); + }, [filteredAndSortedMovies, displayLimit]); + + // Load more items when needed + const handleLoadMore = useCallback(() => { + if (displayLimit < filteredAndSortedMovies.length && !isLoadingMore) { + setIsLoadingMore(true); + // Simulate loading delay for smooth UX + setTimeout(() => { + setDisplayLimit(prev => Math.min(prev + 20, filteredAndSortedMovies.length)); + setIsLoadingMore(false); + }, 200); + } + }, [displayLimit, filteredAndSortedMovies.length, isLoadingMore]); + + const handleMoviePress = async (movie: CastMovie) => { + try { + // Get Stremio ID using catalogService + const stremioId = await catalogService.getStremioId(movie.media_type, movie.id.toString()); + + if (stremioId) { + navigation.dispatch( + StackActions.push('Metadata', { + id: stremioId, + type: movie.media_type + }) + ); + } else { + throw new Error('Could not find Stremio ID'); + } + } catch (error) { + console.error('Error navigating to movie:', error); + Alert.alert( + 'Error', + 'Unable to load this content. Please try again later.', + [{ text: 'OK' }] + ); + } + }; + + const handleBack = () => { + navigation.goBack(); + }; + + const renderFilterButton = (filter: 'all' | 'movies' | 'tv', label: string, count: number) => { + const isSelected = selectedFilter === filter; + + return ( + + setSelectedFilter(filter)} + activeOpacity={0.8} + > + + {count > 0 ? `${label} (${count})` : label} + + + + ); + }; + + const renderSortButton = (sort: 'popularity' | 'latest' | 'upcoming', label: string, icon: string) => { + const isSelected = sortBy === sort; + + return ( + + setSortBy(sort)} + activeOpacity={0.7} + > + + + {label} + + + + ); + }; + + const renderMovieItem = useCallback(({ item, index }: { item: CastMovie; index: number }) => ( + + handleMoviePress(item)} + activeOpacity={0.85} + style={{ + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }} + > + + {item.poster_path ? ( + + ) : ( + + + + )} + + {/* Upcoming indicator */} + {item.isUpcoming && ( + + + + UPCOMING + + + )} + + + + {/* Rating badge */} + {item.vote_average && item.vote_average > 0 && ( + + + + {`${item.vote_average.toFixed(1)}`} + + + )} + + {/* Gradient overlay for better text readability */} + + + + + + {`${item.title}`} + + + {item.character && ( + + {`as ${item.character}`} + + )} + + + {item.release_date && ( + + {`${new Date(item.release_date).getFullYear()}`} + + )} + + {item.isUpcoming && ( + + + + Coming Soon + + + )} + + + + + ), [posterWidth, posterHeight, handleMoviePress]); + + const movieCount = movies.filter(m => m.media_type === 'movie').length; + const tvCount = movies.filter(m => m.media_type === 'tv').length; + const upcomingCount = movies.filter(m => m.isUpcoming).length; + + // Animated header style + const headerAnimatedStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, 100], + [1, 0.9], + Extrapolate.CLAMP + ); + + return { + opacity, + }; + }); + + return ( + + {/* Minimal Header */} + + + + + + + + {castMember?.profile_path ? ( + + ) : ( + + + {castMember?.name ? castMember.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2) : 'NA'} + + + )} + + + + + {`${castMember?.name}`} + + + {`Filmography • ${movies.length} titles`} + + + + + + {/* Filters and Sort */} + + {/* Filter Section */} + + + Filter + + + {renderFilterButton('all', 'All', movies.length)} + {renderFilterButton('movies', 'Movies', movieCount)} + {renderFilterButton('tv', 'TV Shows', tvCount)} + + + + {/* Sort Section */} + + + Sort By + + + {renderSortButton('popularity', 'Popular', 'trending-up')} + {renderSortButton('latest', 'Latest', 'schedule')} + {renderSortButton('upcoming', 'Upcoming', 'event')} + + + + + {/* Content */} + {loading ? ( + + + + Loading filmography... + + + ) : ( + `${item.media_type}-${item.id}`} + numColumns={numColumns} + + contentContainerStyle={{ + paddingHorizontal: 20, + paddingTop: 8, + paddingBottom: Platform.OS === 'ios' ? 120 : 100, + }} + onScroll={(event) => { + scrollY.value = event.nativeEvent.contentOffset.y; + }} + scrollEventThrottle={32} + showsVerticalScrollIndicator={false} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.8} + ListFooterComponent={ + displayLimit < filteredAndSortedMovies.length ? ( + + {isLoadingMore ? ( + + ) : ( + + + {`Load More (${filteredAndSortedMovies.length - displayLimit} remaining)`} + + + )} + + ) : null + } + ListEmptyComponent={ + + + + + + No Content Found + + + {sortBy === 'upcoming' + ? 'No upcoming releases available for this actor' + : selectedFilter === 'all' + ? 'No content available for this actor' + : selectedFilter === 'movies' + ? 'No movies available for this actor' + : 'No TV shows available for this actor' + } + + + } + /> + )} + + ); +}; + +export default CastMoviesScreen; diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 405e4f6..db3894e 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -475,6 +475,57 @@ export class TMDBService { } } + /** + * Get person's movie credits (cast and crew) + */ + async getPersonMovieCredits(personId: number) { + try { + const response = await axios.get(`${BASE_URL}/person/${personId}/movie_credits`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language: 'en-US', + }), + }); + return response.data; + } catch (error) { + return null; + } + } + + /** + * Get person's TV credits (cast and crew) + */ + async getPersonTvCredits(personId: number) { + try { + const response = await axios.get(`${BASE_URL}/person/${personId}/tv_credits`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language: 'en-US', + }), + }); + return response.data; + } catch (error) { + return null; + } + } + + /** + * Get person's combined credits (movies and TV) + */ + async getPersonCombinedCredits(personId: number) { + try { + const response = await axios.get(`${BASE_URL}/person/${personId}/combined_credits`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language: 'en-US', + }), + }); + return response.data; + } catch (error) { + return null; + } + } + /** * Get external IDs for a TV show (including IMDb ID) */