mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-04 01:09:05 +00:00
578 lines
No EOL
16 KiB
TypeScript
578 lines
No EOL
16 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
|
import { colors } from '../../styles/colors';
|
|
import { Episode } from '../../types/metadata';
|
|
import { tmdbService } from '../../services/tmdbService';
|
|
import { storageService } from '../../services/storageService';
|
|
import { useFocusEffect } from '@react-navigation/native';
|
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
|
|
|
interface SeriesContentProps {
|
|
episodes: Episode[];
|
|
selectedSeason: number;
|
|
loadingSeasons: boolean;
|
|
onSeasonChange: (season: number) => void;
|
|
onSelectEpisode: (episode: Episode) => void;
|
|
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
|
|
metadata?: { poster?: string; id?: string };
|
|
}
|
|
|
|
// Add placeholder constant at the top
|
|
const DEFAULT_PLACEHOLDER = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=No+Image';
|
|
const EPISODE_PLACEHOLDER = 'https://via.placeholder.com/500x280/1a1a1a/666666?text=No+Preview';
|
|
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
|
|
|
export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|
episodes,
|
|
selectedSeason,
|
|
loadingSeasons,
|
|
onSeasonChange,
|
|
onSelectEpisode,
|
|
groupedEpisodes = {},
|
|
metadata
|
|
}) => {
|
|
const { width } = useWindowDimensions();
|
|
const isTablet = width > 768;
|
|
const isDarkMode = useColorScheme() === 'dark';
|
|
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({});
|
|
|
|
// Add ref for the season selector ScrollView
|
|
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
|
|
|
const loadEpisodesProgress = async () => {
|
|
if (!metadata?.id) return;
|
|
|
|
const allProgress = await storageService.getAllWatchProgress();
|
|
const progress: { [key: string]: { currentTime: number; duration: number } } = {};
|
|
|
|
episodes.forEach(episode => {
|
|
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
|
const key = `series:${metadata.id}:${episodeId}`;
|
|
if (allProgress[key]) {
|
|
progress[episodeId] = {
|
|
currentTime: allProgress[key].currentTime,
|
|
duration: allProgress[key].duration
|
|
};
|
|
}
|
|
});
|
|
|
|
setEpisodeProgress(progress);
|
|
};
|
|
|
|
// Initial load of watch progress
|
|
useEffect(() => {
|
|
loadEpisodesProgress();
|
|
}, [episodes, metadata?.id]);
|
|
|
|
// Refresh watch progress when screen comes into focus
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
loadEpisodesProgress();
|
|
}, [episodes, metadata?.id])
|
|
);
|
|
|
|
// Add effect to scroll to selected season
|
|
useEffect(() => {
|
|
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
|
|
// Find the index of the selected season
|
|
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
|
const selectedIndex = seasons.findIndex(season => season === selectedSeason);
|
|
|
|
if (selectedIndex !== -1) {
|
|
// Wait a small amount of time for layout to be ready
|
|
setTimeout(() => {
|
|
seasonScrollViewRef.current?.scrollTo({
|
|
x: selectedIndex * 116, // 100px width + 16px margin
|
|
animated: true
|
|
});
|
|
}, 300);
|
|
}
|
|
}
|
|
}, [selectedSeason, groupedEpisodes]);
|
|
|
|
if (loadingSeasons) {
|
|
return (
|
|
<View style={styles.centeredContainer}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
<Text style={styles.centeredText}>Loading episodes...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (episodes.length === 0) {
|
|
return (
|
|
<View style={styles.centeredContainer}>
|
|
<MaterialIcons name="error-outline" size={48} color="#666" />
|
|
<Text style={styles.centeredText}>No episodes available</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const renderSeasonSelector = () => {
|
|
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
|
|
|
return (
|
|
<View style={styles.seasonSelectorWrapper}>
|
|
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
|
|
<ScrollView
|
|
ref={seasonScrollViewRef}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.seasonSelectorContainer}
|
|
contentContainerStyle={styles.seasonSelectorContent}
|
|
>
|
|
{seasons.map(season => {
|
|
const seasonEpisodes = groupedEpisodes[season] || [];
|
|
let seasonPoster = DEFAULT_PLACEHOLDER;
|
|
if (seasonEpisodes[0]?.season_poster_path) {
|
|
const tmdbUrl = tmdbService.getImageUrl(seasonEpisodes[0].season_poster_path, 'w500');
|
|
if (tmdbUrl) seasonPoster = tmdbUrl;
|
|
} else if (metadata?.poster) {
|
|
seasonPoster = metadata.poster;
|
|
}
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={season}
|
|
style={[
|
|
styles.seasonButton,
|
|
selectedSeason === season && styles.selectedSeasonButton
|
|
]}
|
|
onPress={() => onSeasonChange(season)}
|
|
>
|
|
<View style={styles.seasonPosterContainer}>
|
|
<Image
|
|
source={{ uri: seasonPoster }}
|
|
style={styles.seasonPoster}
|
|
contentFit="cover"
|
|
/>
|
|
{selectedSeason === season && (
|
|
<View style={styles.selectedSeasonIndicator} />
|
|
)}
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.seasonButtonText,
|
|
selectedSeason === season && styles.selectedSeasonButtonText
|
|
]}
|
|
>
|
|
Season {season}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderEpisodeCard = (episode: Episode) => {
|
|
let episodeImage = EPISODE_PLACEHOLDER;
|
|
if (episode.still_path) {
|
|
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
|
if (tmdbUrl) episodeImage = tmdbUrl;
|
|
} else if (metadata?.poster) {
|
|
episodeImage = metadata.poster;
|
|
}
|
|
|
|
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
|
|
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
|
|
const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : '';
|
|
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const formatRuntime = (runtime: number) => {
|
|
if (!runtime) return null;
|
|
const hours = Math.floor(runtime / 60);
|
|
const minutes = runtime % 60;
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
return `${minutes}m`;
|
|
};
|
|
|
|
// Get episode progress
|
|
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
|
const progress = episodeProgress[episodeId];
|
|
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
|
|
|
// Don't show progress bar if episode is complete (>= 95%)
|
|
const showProgress = progress && progressPercent < 95;
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={episode.id}
|
|
style={[styles.episodeCard, isTablet && styles.episodeCardTablet]}
|
|
onPress={() => onSelectEpisode(episode)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.episodeImageContainer}>
|
|
<Image
|
|
source={{ uri: episodeImage }}
|
|
style={styles.episodeImage}
|
|
contentFit="cover"
|
|
/>
|
|
<View style={styles.episodeNumberBadge}>
|
|
<Text style={styles.episodeNumberText}>{episodeString}</Text>
|
|
</View>
|
|
{showProgress && (
|
|
<View style={styles.progressBarContainer}>
|
|
<View
|
|
style={[
|
|
styles.progressBar,
|
|
{ width: `${progressPercent}%` }
|
|
]}
|
|
/>
|
|
</View>
|
|
)}
|
|
{progressPercent >= 95 && (
|
|
<View style={styles.completedBadge}>
|
|
<MaterialIcons name="check" size={12} color={colors.white} />
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.episodeInfo}>
|
|
<View style={styles.episodeHeader}>
|
|
<Text style={styles.episodeTitle} numberOfLines={2}>
|
|
{episode.name}
|
|
</Text>
|
|
<View style={styles.episodeMetadata}>
|
|
{episode.vote_average > 0 && (
|
|
<View style={styles.ratingContainer}>
|
|
<Image
|
|
source={{ uri: TMDB_LOGO }}
|
|
style={styles.tmdbLogo}
|
|
contentFit="contain"
|
|
/>
|
|
<Text style={styles.ratingText}>
|
|
{episode.vote_average.toFixed(1)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{episode.runtime && (
|
|
<View style={styles.runtimeContainer}>
|
|
<MaterialIcons name="schedule" size={14} color={colors.textMuted} />
|
|
<Text style={styles.runtimeText}>
|
|
{formatRuntime(episode.runtime)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{episode.air_date && (
|
|
<Text style={styles.airDateText}>
|
|
{formatDate(episode.air_date)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<Text style={styles.episodeOverview} numberOfLines={2}>
|
|
{episode.overview || 'No description available'}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Animated.View
|
|
entering={FadeIn.duration(500).delay(100)}
|
|
>
|
|
{renderSeasonSelector()}
|
|
</Animated.View>
|
|
|
|
<Animated.View
|
|
entering={FadeIn.duration(500).delay(200)}
|
|
>
|
|
<Text style={styles.sectionTitle}>
|
|
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
|
|
</Text>
|
|
|
|
<ScrollView
|
|
style={styles.episodeList}
|
|
contentContainerStyle={[
|
|
styles.episodeListContent,
|
|
isTablet && styles.episodeListContentTablet
|
|
]}
|
|
>
|
|
{isTablet ? (
|
|
<View style={styles.episodeGrid}>
|
|
{episodes.map((episode, index) => (
|
|
<Animated.View
|
|
key={episode.id}
|
|
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
|
>
|
|
{renderEpisodeCard(episode)}
|
|
</Animated.View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
episodes.map((episode, index) => (
|
|
<Animated.View
|
|
key={episode.id}
|
|
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
|
>
|
|
{renderEpisodeCard(episode)}
|
|
</Animated.View>
|
|
))
|
|
)}
|
|
</ScrollView>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
padding: 16,
|
|
},
|
|
centeredContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 20,
|
|
},
|
|
centeredText: {
|
|
marginTop: 12,
|
|
fontSize: 16,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
marginBottom: 16,
|
|
color: colors.text,
|
|
},
|
|
episodeList: {
|
|
flex: 1,
|
|
},
|
|
episodeListContent: {
|
|
paddingBottom: 20,
|
|
},
|
|
episodeListContentTablet: {
|
|
paddingHorizontal: 8,
|
|
},
|
|
episodeGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
justifyContent: 'space-between',
|
|
},
|
|
episodeCard: {
|
|
flexDirection: 'row',
|
|
backgroundColor: colors.darkBackground,
|
|
borderRadius: 16,
|
|
marginBottom: 16,
|
|
overflow: 'hidden',
|
|
elevation: 8,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
height: 120,
|
|
},
|
|
episodeCardTablet: {
|
|
width: '48%',
|
|
flexDirection: 'column',
|
|
height: 120,
|
|
},
|
|
episodeImageContainer: {
|
|
position: 'relative',
|
|
width: 120,
|
|
height: 120,
|
|
backgroundColor: colors.darkBackground,
|
|
},
|
|
episodeImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
transform: [{ scale: 1.02 }],
|
|
},
|
|
episodeNumberBadge: {
|
|
position: 'absolute',
|
|
bottom: 8,
|
|
right: 4,
|
|
backgroundColor: 'rgba(0,0,0,0.85)',
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.2)',
|
|
zIndex: 1,
|
|
},
|
|
episodeNumberText: {
|
|
color: '#fff',
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
letterSpacing: 0.3,
|
|
},
|
|
episodeInfo: {
|
|
flex: 1,
|
|
padding: 12,
|
|
justifyContent: 'center',
|
|
},
|
|
episodeHeader: {
|
|
marginBottom: 4,
|
|
},
|
|
episodeTitle: {
|
|
fontSize: 15,
|
|
fontWeight: '700',
|
|
color: colors.text,
|
|
letterSpacing: 0.3,
|
|
marginBottom: 2,
|
|
},
|
|
episodeMetadata: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
ratingContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
},
|
|
tmdbLogo: {
|
|
width: 20,
|
|
height: 14,
|
|
},
|
|
ratingText: {
|
|
color: '#01b4e4',
|
|
fontSize: 13,
|
|
fontWeight: '700',
|
|
marginLeft: 4,
|
|
},
|
|
airDateText: {
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
opacity: 0.8,
|
|
},
|
|
episodeOverview: {
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
color: colors.textMuted,
|
|
},
|
|
seasonSelectorWrapper: {
|
|
marginBottom: 20,
|
|
},
|
|
seasonSelectorTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
marginBottom: 12,
|
|
color: colors.text,
|
|
},
|
|
seasonSelectorContainer: {
|
|
flexGrow: 0,
|
|
},
|
|
seasonSelectorContent: {
|
|
paddingBottom: 8,
|
|
},
|
|
seasonButton: {
|
|
alignItems: 'center',
|
|
marginRight: 16,
|
|
width: 100,
|
|
},
|
|
selectedSeasonButton: {
|
|
opacity: 1,
|
|
},
|
|
seasonPosterContainer: {
|
|
position: 'relative',
|
|
width: 100,
|
|
height: 150,
|
|
borderRadius: 8,
|
|
overflow: 'hidden',
|
|
marginBottom: 8,
|
|
},
|
|
seasonPoster: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
selectedSeasonIndicator: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 4,
|
|
backgroundColor: colors.primary,
|
|
},
|
|
seasonButtonText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
color: colors.textMuted,
|
|
},
|
|
selectedSeasonButtonText: {
|
|
color: colors.text,
|
|
fontWeight: '700',
|
|
},
|
|
progressBarContainer: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 3,
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
},
|
|
progressBar: {
|
|
height: '100%',
|
|
backgroundColor: colors.primary,
|
|
},
|
|
progressTextContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 3,
|
|
borderRadius: 4,
|
|
marginRight: 8,
|
|
},
|
|
progressText: {
|
|
color: colors.primary,
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
marginLeft: 4,
|
|
},
|
|
completedBadge: {
|
|
position: 'absolute',
|
|
bottom: 8,
|
|
right: 8,
|
|
backgroundColor: colors.success,
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: 10,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.3)',
|
|
},
|
|
runtimeContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
},
|
|
runtimeText: {
|
|
color: colors.textMuted,
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
marginLeft: 4,
|
|
},
|
|
});
|