mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Added collections ection
This commit is contained in:
parent
f90752bdb7
commit
a7fbd567fd
5 changed files with 410 additions and 0 deletions
234
src/components/metadata/CollectionSection.tsx
Normal file
234
src/components/metadata/CollectionSection.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue