From a7fbd567fda65831b05bf8f53db4787dc6fc99d4 Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 23 Oct 2025 17:31:49 +0530 Subject: [PATCH] Added collections ection --- src/components/metadata/CollectionSection.tsx | 234 ++++++++++++++++++ src/hooks/useMetadata.ts | 94 +++++++ src/screens/MetadataScreen.tsx | 15 ++ src/services/catalogService.ts | 6 + src/services/tmdbService.ts | 61 +++++ 5 files changed, 410 insertions(+) create mode 100644 src/components/metadata/CollectionSection.tsx diff --git a/src/components/metadata/CollectionSection.tsx b/src/components/metadata/CollectionSection.tsx new file mode 100644 index 0000000..2b57f58 --- /dev/null +++ b/src/components/metadata/CollectionSection.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + ActivityIndicator, + Dimensions, +} from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import { useNavigation, StackActions } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { StreamingContent } from '../../services/catalogService'; +import { useTheme } from '../../contexts/ThemeContext'; +import { TMDBService } from '../../services/tmdbService'; +import { catalogService } from '../../services/catalogService'; +import CustomAlert from '../../components/CustomAlert'; + +const { width } = Dimensions.get('window'); + +// Breakpoints for responsive sizing +const BREAKPOINTS = { + phone: 0, + tablet: 768, + largeTablet: 1024, + tv: 1440, +} as const; + +interface CollectionSectionProps { + collectionName: string; + collectionMovies: StreamingContent[]; + loadingCollection: boolean; +} + +export const CollectionSection: React.FC = ({ + collectionName, + collectionMovies, + loadingCollection +}) => { + const { currentTheme } = useTheme(); + const navigation = useNavigation>(); + + // Determine device type + const deviceWidth = Dimensions.get('window').width; + const getDeviceType = React.useCallback(() => { + if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; + if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet'; + if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; + return 'phone'; + }, [deviceWidth]); + const deviceType = getDeviceType(); + const isTablet = deviceType === 'tablet'; + const isLargeTablet = deviceType === 'largeTablet'; + const isTV = deviceType === 'tv'; + + // Responsive spacing & sizes + const horizontalPadding = React.useMemo(() => { + switch (deviceType) { + case 'tv': return 32; + case 'largeTablet': return 28; + case 'tablet': return 24; + default: return 16; + } + }, [deviceType]); + + const itemSpacing = React.useMemo(() => { + switch (deviceType) { + case 'tv': return 14; + case 'largeTablet': return 12; + case 'tablet': return 12; + default: return 12; + } + }, [deviceType]); + + const backdropWidth = React.useMemo(() => { + switch (deviceType) { + case 'tv': return 240; + case 'largeTablet': return 220; + case 'tablet': return 200; + default: return 180; + } + }, [deviceType]); + const backdropHeight = React.useMemo(() => backdropWidth * (9/16), [backdropWidth]); // 16:9 aspect ratio + + const [alertVisible, setAlertVisible] = React.useState(false); + const [alertTitle, setAlertTitle] = React.useState(''); + const [alertMessage, setAlertMessage] = React.useState(''); + const [alertActions, setAlertActions] = React.useState([]); + + const handleItemPress = async (item: StreamingContent) => { + try { + // Extract TMDB ID from the tmdb:123456 format + const tmdbId = item.id.replace('tmdb:', ''); + + // Get Stremio ID directly using catalogService + const stremioId = await catalogService.getStremioId(item.type, tmdbId); + + if (stremioId) { + navigation.dispatch( + StackActions.push('Metadata', { + id: stremioId, + type: item.type + }) + ); + } else { + throw new Error('Could not find Stremio ID'); + } + } catch (error) { + if (__DEV__) console.error('Error navigating to collection item:', error); + setAlertTitle('Error'); + setAlertMessage('Unable to load this content. Please try again later.'); + setAlertActions([{ label: 'OK', onPress: () => {} }]); + setAlertVisible(true); + } + }; + + const renderItem = ({ item }: { item: StreamingContent }) => ( + handleItemPress(item)} + > + + + {item.name} + + {item.year && ( + + {item.year} + + )} + + ); + + if (loadingCollection) { + return ( + + + + ); + } + + if (!collectionMovies || collectionMovies.length === 0) { + return null; // Don't render anything if there are no collection movies + } + + return ( + + + {collectionName} + + item.id} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={[styles.listContentContainer, { + paddingHorizontal: horizontalPadding, + paddingRight: horizontalPadding + itemSpacing + }]} + /> + setAlertVisible(false)} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginTop: 16, + marginBottom: 16, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '800', + marginBottom: 12, + marginTop: 8, + }, + listContentContainer: { + paddingRight: 32, // Will be overridden responsively + }, + itemContainer: { + marginRight: 12, // will be overridden responsively + }, + backdrop: { + borderRadius: 8, // overridden responsively + marginBottom: 8, + }, + title: { + fontSize: 13, // overridden responsively + fontWeight: '500', + lineHeight: 18, // overridden responsively + marginBottom: 2, + }, + year: { + fontSize: 11, // overridden responsively + fontWeight: '400', + opacity: 0.8, + }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 20, + }, +}); + +export default CollectionSection; diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 16171e4..d7369da 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -107,6 +107,8 @@ interface UseMetadataReturn { imdbId: string | null; scraperStatuses: ScraperStatus[]; activeFetchingScrapers: string[]; + collectionMovies: StreamingContent[]; + loadingCollection: boolean; } export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { @@ -132,6 +134,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const [loadAttempts, setLoadAttempts] = useState(0); const [recommendations, setRecommendations] = useState([]); const [loadingRecommendations, setLoadingRecommendations] = useState(false); + const [collectionMovies, setCollectionMovies] = useState([]); + const [loadingCollection, setLoadingCollection] = useState(false); const [imdbId, setImdbId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({}); @@ -1939,6 +1943,94 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat tmdbId, movieDetails: movieDetailsObj })); + + // Fetch collection data if movie belongs to a collection + if (movieDetails.belongs_to_collection) { + setLoadingCollection(true); + try { + const collectionDetails = await tmdbService.getCollectionDetails( + movieDetails.belongs_to_collection.id, + lang + ); + + if (collectionDetails && collectionDetails.parts) { + // Fetch individual movie images to get backdrops with embedded titles/logos + const collectionMoviesData = await Promise.all( + collectionDetails.parts.map(async (part: any, index: number) => { + let movieBackdropUrl = undefined; + + // Try to fetch movie images with language parameter + try { + const movieImages = await tmdbService.getMovieImagesFull(part.id); + if (movieImages && movieImages.backdrops && movieImages.backdrops.length > 0) { + // Filter and sort backdrops by language and quality + const languageBackdrops = movieImages.backdrops + .filter((backdrop: any) => backdrop.aspect_ratio > 1.0) // Landscape orientation + .sort((a: any, b: any) => { + // Prioritize backdrops with the requested language + const aHasLang = a.iso_639_1 === lang; + const bHasLang = b.iso_639_1 === lang; + if (aHasLang && !bHasLang) return -1; + if (!aHasLang && bHasLang) return 1; + + // Then prioritize English if requested language not available + const aIsEn = a.iso_639_1 === 'en'; + const bIsEn = b.iso_639_1 === 'en'; + if (aIsEn && !bIsEn) return -1; + if (!aIsEn && bIsEn) return 1; + + // Then sort by vote average (quality), then by resolution + if (a.vote_average !== b.vote_average) { + return b.vote_average - a.vote_average; + } + return (b.width * b.height) - (a.width * a.height); + }); + + if (languageBackdrops.length > 0) { + movieBackdropUrl = tmdbService.getImageUrl(languageBackdrops[0].file_path, 'original'); + } + } + } catch (error) { + if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error); + } + + return { + id: `tmdb:${part.id}`, + type: 'movie', + name: part.title, + poster: part.poster_path ? tmdbService.getImageUrl(part.poster_path, 'w500') : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', + banner: movieBackdropUrl || (part.backdrop_path ? tmdbService.getImageUrl(part.backdrop_path, 'original') : undefined), + year: part.release_date ? new Date(part.release_date).getFullYear() : undefined, + description: part.overview, + collection: { + id: collectionDetails.id, + name: collectionDetails.name, + poster_path: collectionDetails.poster_path, + backdrop_path: collectionDetails.backdrop_path + } + }; + }) + ) as StreamingContent[]; + + setCollectionMovies(collectionMoviesData); + + // Update metadata with collection info + setMetadata((prev: any) => ({ + ...prev, + collection: { + id: collectionDetails.id, + name: collectionDetails.name, + poster_path: collectionDetails.poster_path, + backdrop_path: collectionDetails.backdrop_path + } + })); + } + } catch (error) { + if (__DEV__) console.error('[useMetadata] Error fetching collection:', error); + } finally { + setLoadingCollection(false); + } + } } } @@ -2024,5 +2116,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat imdbId, scraperStatuses, activeFetchingScrapers, + collectionMovies, + loadingCollection, }; }; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index d347925..675a5f3 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -28,6 +28,7 @@ import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection' import { RatingsSection } from '../components/metadata/RatingsSection'; import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection'; import TrailersSection from '../components/metadata/TrailersSection'; +import CollectionSection from '../components/metadata/CollectionSection'; import { RouteParams, Episode } from '../types/metadata'; import Animated, { useAnimatedStyle, @@ -182,6 +183,8 @@ const MetadataScreen: React.FC = () => { setMetadata, imdbId, tmdbId, + collectionMovies, + loadingCollection, } = useMetadata({ id, type, addonId }); @@ -1245,6 +1248,18 @@ const MetadataScreen: React.FC = () => { )} + {/* Collection Section - Lazy loaded */} + {shouldLoadSecondaryData && + Object.keys(groupedEpisodes).length === 0 && + metadata?.collection && + settings.enrichMetadataWithTMDB && ( + + )} + {/* Recommendations Section with skeleton when loading - Lazy loaded */} {type === 'movie' && shouldLoadSecondaryData && ( = new Map(); @@ -604,6 +630,41 @@ export class TMDBService { } } + /** + * Get collection details by collection ID + */ + async getCollectionDetails(collectionId: number, language: string = 'en'): Promise { + try { + const response = await axios.get(`${BASE_URL}/collection/${collectionId}`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language, + }), + }); + return response.data; + } catch (error) { + return null; + } + } + + /** + * Get collection images by collection ID + */ + async getCollectionImages(collectionId: number, language: string = 'en'): Promise { + try { + const response = await axios.get(`${BASE_URL}/collection/${collectionId}/images`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language, + include_image_language: `${language},en,null` + }), + }); + return response.data; + } catch (error) { + return null; + } + } + /** * Get movie images (logos, posters, backdrops) by TMDB ID - returns full images object */