Added collections ection

This commit is contained in:
tapframe 2025-10-23 17:31:49 +05:30
parent f90752bdb7
commit a7fbd567fd
5 changed files with 410 additions and 0 deletions

View file

@ -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<CollectionSectionProps> = ({
collectionName,
collectionMovies,
loadingCollection
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
// 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<any[]>([]);
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 }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: backdropWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.banner || item.poster }}
style={[styles.backdrop, {
backgroundColor: currentTheme.colors.elevation1,
width: backdropWidth,
height: backdropHeight,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
}]}
resizeMode={FastImage.resizeMode.cover}
/>
<Text style={[styles.title, {
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
lineHeight: isTV ? 20 : 18
}]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.year, {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
}]}>
{item.year}
</Text>
)}
</TouchableOpacity>
);
if (loadingCollection) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
);
}
if (!collectionMovies || collectionMovies.length === 0) {
return null; // Don't render anything if there are no collection movies
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<Text style={[styles.sectionTitle, {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
paddingHorizontal: horizontalPadding
}]}>
{collectionName}
</Text>
<FlatList
data={collectionMovies}
renderItem={renderItem}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.listContentContainer, {
paddingHorizontal: horizontalPadding,
paddingRight: horizontalPadding + itemSpacing
}]}
/>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
);
};
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;

View file

@ -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<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [collectionMovies, setCollectionMovies] = useState<StreamingContent[]>([]);
const [loadingCollection, setLoadingCollection] = useState(false);
const [imdbId, setImdbId] = useState<string | null>(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,
};
};

View file

@ -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 = () => {
</View>
)}
{/* Collection Section - Lazy loaded */}
{shouldLoadSecondaryData &&
Object.keys(groupedEpisodes).length === 0 &&
metadata?.collection &&
settings.enrichMetadataWithTMDB && (
<CollectionSection
collectionName={metadata.collection.name}
collectionMovies={collectionMovies}
loadingCollection={loadingCollection}
/>
)}
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
{type === 'movie' && shouldLoadSecondaryData && (
<MemoizedMoreLikeThisSection

View file

@ -125,6 +125,12 @@ export interface StreamingContent {
originCountry?: string[];
tagline?: string;
};
collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
}
export interface CatalogContent {

View file

@ -77,6 +77,32 @@ export interface TMDBTrendingResult {
};
}
export interface TMDBCollection {
id: number;
name: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
parts: TMDBCollectionPart[];
}
export interface TMDBCollectionPart {
id: number;
title: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
release_date: string;
adult: boolean;
video: boolean;
vote_average: number;
vote_count: number;
genre_ids: number[];
original_language: string;
original_title: string;
popularity: number;
}
export class TMDBService {
private static instance: TMDBService;
private static ratingCache: Map<string, number | null> = new Map();
@ -604,6 +630,41 @@ export class TMDBService {
}
}
/**
* Get collection details by collection ID
*/
async getCollectionDetails(collectionId: number, language: string = 'en'): Promise<TMDBCollection | null> {
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<any> {
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
*/