439 lines
No EOL
13 KiB
TypeScript
439 lines
No EOL
13 KiB
TypeScript
import React, { useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
StatusBar,
|
|
ActivityIndicator,
|
|
Dimensions,
|
|
TouchableOpacity,
|
|
} from 'react-native';
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useRoute, useNavigation } from '@react-navigation/native';
|
|
import { MaterialIcons } from '@expo/vector-icons';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import { useMetadata } from '../hooks/useMetadata';
|
|
import { CastSection } from '../components/metadata/CastSection';
|
|
import { SeriesContent } from '../components/metadata/SeriesContent';
|
|
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,
|
|
} from 'react-native-reanimated';
|
|
import { RouteProp } from '@react-navigation/native';
|
|
import { NavigationProp } from '@react-navigation/native';
|
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
|
import { useSettings } from '../hooks/useSettings';
|
|
|
|
// Import our new components and hooks
|
|
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';
|
|
|
|
const { height } = Dimensions.get('window');
|
|
|
|
const MetadataScreen = () => {
|
|
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
|
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
|
const { id, type, episodeId } = route.params;
|
|
|
|
// Add settings hook
|
|
const { settings } = useSettings();
|
|
|
|
// Get theme context
|
|
const { currentTheme } = useTheme();
|
|
|
|
// Get safe area insets
|
|
const { top: safeAreaTop } = useSafeAreaInsets();
|
|
|
|
const {
|
|
metadata,
|
|
loading,
|
|
error: metadataError,
|
|
cast,
|
|
loadingCast,
|
|
episodes,
|
|
selectedSeason,
|
|
loadingSeasons,
|
|
loadMetadata,
|
|
handleSeasonChange,
|
|
toggleLibrary,
|
|
inLibrary,
|
|
groupedEpisodes,
|
|
recommendations,
|
|
loadingRecommendations,
|
|
setMetadata,
|
|
imdbId,
|
|
} = useMetadata({ id, type });
|
|
|
|
// Use our new hooks
|
|
const {
|
|
watchProgress,
|
|
getEpisodeDetails,
|
|
getPlayButtonText,
|
|
} = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
|
|
|
|
const {
|
|
bannerImage,
|
|
loadingBanner,
|
|
logoLoadError,
|
|
setLogoLoadError,
|
|
setBannerImage,
|
|
} = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
|
|
|
|
const animations = useMetadataAnimations(safeAreaTop, watchProgress);
|
|
|
|
// Add wrapper for toggleLibrary that includes haptic feedback
|
|
const handleToggleLibrary = useCallback(() => {
|
|
// Trigger appropriate haptic feedback based on action
|
|
if (inLibrary) {
|
|
// Removed from library - light impact
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
} else {
|
|
// Added to library - success feedback
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
}
|
|
|
|
// Call the original toggleLibrary function
|
|
toggleLibrary();
|
|
}, [inLibrary, toggleLibrary]);
|
|
|
|
// Add wrapper for season change with distinctive haptic feedback
|
|
const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => {
|
|
// Change to Light impact for a more subtle feedback
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
|
|
// Wait a tiny bit before changing season, making the feedback more noticeable
|
|
setTimeout(() => {
|
|
handleSeasonChange(seasonNumber);
|
|
}, 10);
|
|
}, [handleSeasonChange]);
|
|
|
|
// Handler functions
|
|
const handleShowStreams = useCallback(() => {
|
|
if (type === 'series') {
|
|
// If we have watch progress with an episodeId, use that
|
|
if (watchProgress?.episodeId) {
|
|
navigation.navigate('Streams', {
|
|
id,
|
|
type,
|
|
episodeId: watchProgress.episodeId
|
|
});
|
|
return;
|
|
}
|
|
|
|
// If we have a specific episodeId from route params, use that
|
|
if (episodeId) {
|
|
navigation.navigate('Streams', { id, type, episodeId });
|
|
return;
|
|
}
|
|
|
|
// Otherwise, if we have episodes, start with the first one
|
|
if (episodes.length > 0) {
|
|
const firstEpisode = episodes[0];
|
|
const newEpisodeId = firstEpisode.stremioId || `${id}:${firstEpisode.season_number}:${firstEpisode.episode_number}`;
|
|
navigation.navigate('Streams', { id, type, episodeId: newEpisodeId });
|
|
return;
|
|
}
|
|
}
|
|
|
|
navigation.navigate('Streams', { id, type, episodeId });
|
|
}, [navigation, id, type, episodes, episodeId, watchProgress]);
|
|
|
|
const handleSelectCastMember = useCallback((castMember: any) => {
|
|
// Future implementation
|
|
}, []);
|
|
|
|
const handleEpisodeSelect = useCallback((episode: Episode) => {
|
|
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
|
|
navigation.navigate('Streams', {
|
|
id,
|
|
type,
|
|
episodeId
|
|
});
|
|
}, [navigation, id, type]);
|
|
|
|
const handleBack = useCallback(() => {
|
|
navigation.goBack();
|
|
}, [navigation]);
|
|
|
|
// Animated styles
|
|
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
|
flex: 1,
|
|
transform: [{ scale: animations.screenScale.value }],
|
|
opacity: animations.screenOpacity.value
|
|
}));
|
|
|
|
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateY: animations.contentTranslateY.value }],
|
|
opacity: interpolate(
|
|
animations.contentTranslateY.value,
|
|
[60, 0],
|
|
[0, 1],
|
|
Extrapolate.CLAMP
|
|
)
|
|
}));
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView
|
|
style={[styles.container, {
|
|
backgroundColor: currentTheme.colors.darkBackground
|
|
}]}
|
|
edges={['bottom']}
|
|
>
|
|
<StatusBar
|
|
translucent={true}
|
|
backgroundColor="transparent"
|
|
barStyle="light-content"
|
|
/>
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
|
<Text style={[styles.loadingText, {
|
|
color: currentTheme.colors.mediumEmphasis
|
|
}]}>
|
|
Loading content...
|
|
</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (metadataError || !metadata) {
|
|
return (
|
|
<SafeAreaView
|
|
style={[styles.container, {
|
|
backgroundColor: currentTheme.colors.darkBackground
|
|
}]}
|
|
edges={['bottom']}
|
|
>
|
|
<StatusBar
|
|
translucent={true}
|
|
backgroundColor="transparent"
|
|
barStyle="light-content"
|
|
/>
|
|
<View style={styles.errorContainer}>
|
|
<MaterialIcons
|
|
name="error-outline"
|
|
size={64}
|
|
color={currentTheme.colors.textMuted}
|
|
/>
|
|
<Text style={[styles.errorText, {
|
|
color: currentTheme.colors.highEmphasis
|
|
}]}>
|
|
{metadataError || 'Content not found'}
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.retryButton,
|
|
{ backgroundColor: currentTheme.colors.primary }
|
|
]}
|
|
onPress={loadMetadata}
|
|
>
|
|
<MaterialIcons
|
|
name="refresh"
|
|
size={20}
|
|
color={currentTheme.colors.white}
|
|
style={{ marginRight: 8 }}
|
|
/>
|
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.backButton,
|
|
{ borderColor: currentTheme.colors.primary }
|
|
]}
|
|
onPress={handleBack}
|
|
>
|
|
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>
|
|
Go Back
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView
|
|
style={[containerAnimatedStyle, styles.container, {
|
|
backgroundColor: currentTheme.colors.darkBackground
|
|
}]}
|
|
edges={['bottom']}
|
|
>
|
|
<StatusBar
|
|
translucent={true}
|
|
backgroundColor="transparent"
|
|
barStyle="light-content"
|
|
animated={true}
|
|
/>
|
|
<Animated.View style={containerAnimatedStyle}>
|
|
{/* Floating Header */}
|
|
<FloatingHeader
|
|
metadata={metadata}
|
|
logoLoadError={logoLoadError}
|
|
handleBack={handleBack}
|
|
handleToggleLibrary={handleToggleLibrary}
|
|
inLibrary={inLibrary}
|
|
headerOpacity={animations.headerOpacity}
|
|
headerElementsY={animations.headerElementsY}
|
|
headerElementsOpacity={animations.headerElementsOpacity}
|
|
safeAreaTop={safeAreaTop}
|
|
setLogoLoadError={setLogoLoadError}
|
|
/>
|
|
|
|
<Animated.ScrollView
|
|
style={styles.scrollView}
|
|
showsVerticalScrollIndicator={false}
|
|
onScroll={animations.scrollHandler}
|
|
scrollEventThrottle={16}
|
|
>
|
|
{/* Hero Section */}
|
|
<HeroSection
|
|
metadata={metadata}
|
|
bannerImage={bannerImage}
|
|
loadingBanner={loadingBanner}
|
|
logoLoadError={logoLoadError}
|
|
scrollY={animations.scrollY}
|
|
dampedScrollY={animations.dampedScrollY}
|
|
heroHeight={animations.heroHeight}
|
|
heroOpacity={animations.heroOpacity}
|
|
heroScale={animations.heroScale}
|
|
logoOpacity={animations.logoOpacity}
|
|
logoScale={animations.logoScale}
|
|
genresOpacity={animations.genresOpacity}
|
|
genresTranslateY={animations.genresTranslateY}
|
|
buttonsOpacity={animations.buttonsOpacity}
|
|
buttonsTranslateY={animations.buttonsTranslateY}
|
|
watchProgressOpacity={animations.watchProgressOpacity}
|
|
watchProgressScaleY={animations.watchProgressScaleY}
|
|
watchProgress={watchProgress}
|
|
type={type as 'movie' | 'series'}
|
|
getEpisodeDetails={getEpisodeDetails}
|
|
handleShowStreams={handleShowStreams}
|
|
handleToggleLibrary={handleToggleLibrary}
|
|
inLibrary={inLibrary}
|
|
id={id}
|
|
navigation={navigation}
|
|
getPlayButtonText={getPlayButtonText}
|
|
setBannerImage={setBannerImage}
|
|
setLogoLoadError={setLogoLoadError}
|
|
/>
|
|
|
|
{/* Main Content */}
|
|
<Animated.View style={contentAnimatedStyle}>
|
|
{/* Metadata Details */}
|
|
<MetadataDetails
|
|
metadata={metadata}
|
|
imdbId={imdbId}
|
|
type={type as 'movie' | 'series'}
|
|
renderRatings={() => imdbId ? (
|
|
<RatingsSection
|
|
imdbId={imdbId}
|
|
type={type === 'series' ? 'show' : 'movie'}
|
|
/>
|
|
) : null}
|
|
/>
|
|
|
|
{/* Cast Section */}
|
|
<CastSection
|
|
cast={cast}
|
|
loadingCast={loadingCast}
|
|
onSelectCastMember={handleSelectCastMember}
|
|
/>
|
|
|
|
{/* More Like This Section - Only for movies */}
|
|
{type === 'movie' && (
|
|
<MoreLikeThisSection
|
|
recommendations={recommendations}
|
|
loadingRecommendations={loadingRecommendations}
|
|
/>
|
|
)}
|
|
|
|
{/* Type-specific content */}
|
|
{type === 'series' ? (
|
|
<SeriesContent
|
|
episodes={episodes}
|
|
selectedSeason={selectedSeason}
|
|
loadingSeasons={loadingSeasons}
|
|
onSeasonChange={handleSeasonChangeWithHaptics}
|
|
onSelectEpisode={handleEpisodeSelect}
|
|
groupedEpisodes={groupedEpisodes}
|
|
metadata={metadata}
|
|
/>
|
|
) : (
|
|
<MovieContent metadata={metadata} />
|
|
)}
|
|
</Animated.View>
|
|
</Animated.ScrollView>
|
|
</Animated.View>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: 'transparent',
|
|
paddingTop: 0,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 16,
|
|
},
|
|
loadingText: {
|
|
marginTop: 16,
|
|
fontSize: 16,
|
|
},
|
|
errorContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 32,
|
|
},
|
|
errorText: {
|
|
fontSize: 18,
|
|
textAlign: 'center',
|
|
marginTop: 16,
|
|
marginBottom: 24,
|
|
},
|
|
retryButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 12,
|
|
borderRadius: 24,
|
|
marginBottom: 16,
|
|
},
|
|
retryButtonText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
backButton: {
|
|
width: 40,
|
|
height: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: 20,
|
|
},
|
|
backButtonText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
});
|
|
|
|
export default MetadataScreen; |