Enhance SeriesContent component with episode count display and no-episode message

This update modifies the SeriesContent component to include an episode count badge for each season and a message indicating when no episodes are available for the selected season. Additionally, the episodes prop is now derived from groupedEpisodes for improved data handling. These changes aim to enhance user experience by providing clearer information about available content.
This commit is contained in:
tapframe 2025-06-21 19:09:24 +05:30
parent 9ac99ee0a3
commit 87cd33b90b
3 changed files with 107 additions and 195 deletions

View file

@ -45,6 +45,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
// Add refs for the scroll views
const seasonScrollViewRef = useRef<ScrollView | null>(null);
const episodeScrollViewRef = useRef<ScrollView | null>(null);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
@ -166,6 +168,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
}
}, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
if (loadingSeasons) {
return (
<View style={styles.centeredContainer}>
@ -185,10 +189,11 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
}
const renderSeasonSelector = () => {
// Show selector if we have grouped episodes data or can derive from episodes
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
return null;
}
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
return (
@ -229,6 +234,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{selectedSeason === season && (
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} />
)}
{/* Show episode count badge, including when there are no episodes */}
<View style={[styles.episodeCountBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.episodeCountText, { color: currentTheme.colors.textMuted }]}>
{seasonEpisodes.length} ep{seasonEpisodes.length !== 1 ? 's' : ''}
</Text>
</View>
</View>
<Text
style={[
@ -544,65 +555,81 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
entering={FadeIn.duration(300).delay(100)}
>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
{settings.episodeLayoutStyle === 'horizontal' ? (
// Horizontal Layout (Netflix-style)
<ScrollView
ref={episodeScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.episodeList}
contentContainerStyle={styles.episodeListContentHorizontal}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToAlignment="start"
>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
))}
</ScrollView>
) : (
// Vertical Layout (Traditional)
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContentVertical,
isTablet && styles.episodeListContentVerticalTablet
]}
>
{isTablet ? (
<View style={styles.episodeGridVertical}>
{currentSeasonEpisodes.map((episode, index) => (
{/* Show message when no episodes are available for selected season */}
{currentSeasonEpisodes.length === 0 && (
<View style={styles.centeredContainer}>
<MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>
No episodes available for Season {selectedSeason}
</Text>
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
Episodes may not be released yet
</Text>
</View>
)}
{/* Only render episode list if there are episodes */}
{currentSeasonEpisodes.length > 0 && (
settings.episodeLayoutStyle === 'horizontal' ? (
// Horizontal Layout (Netflix-style)
<ScrollView
ref={episodeScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.episodeList}
contentContainerStyle={styles.episodeListContentHorizontal}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToAlignment="start"
>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
))}
</ScrollView>
) : (
// Vertical Layout (Traditional)
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContentVertical,
isTablet && styles.episodeListContentVerticalTablet
]}
>
{isTablet ? (
<View style={styles.episodeGridVertical}>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))
)}
</ScrollView>
))
)}
</ScrollView>
)
)}
</Animated.View>
</View>
@ -625,6 +652,12 @@ const styles = StyleSheet.create({
fontSize: 16,
textAlign: 'center',
},
centeredSubText: {
marginTop: 8,
fontSize: 14,
textAlign: 'center',
opacity: 0.8,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
@ -963,4 +996,18 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: {
fontWeight: '700',
},
episodeCountBadge: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.8)',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
},
episodeCountText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
});

View file

@ -399,7 +399,7 @@ const MetadataScreen: React.FC = () => {
{type === 'series' ? (
<SeriesContent
episodes={episodes}
episodes={Object.values(groupedEpisodes).flat()}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}

View file

@ -16,7 +16,7 @@ import {
Linking,
} from 'react-native';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
@ -56,14 +56,13 @@ const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_V
const { width, height } = Dimensions.get('window');
// Extracted Components
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, isExiting }: {
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: {
stream: Stream;
onPress: () => void;
index: number;
isLoading?: boolean;
statusMessage?: string;
theme: any;
isExiting?: boolean;
}) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
@ -80,87 +79,8 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, i
const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream');
const displayAddonName = isHDRezka ? '' : (stream.title || '');
// Animation delay based on index - stagger effect (only if not exiting)
const enterDelay = isExiting ? 0 : 100 + (index * 30);
// Use simple View when exiting to prevent animation conflicts
if (isExiting) {
return (
<View>
<TouchableOpacity
style={[
styles.streamCard,
isLoading && styles.streamCardLoading
]}
onPress={onPress}
disabled={isLoading}
activeOpacity={0.7}
>
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{displayTitle}
</Text>
{displayAddonName && displayAddonName !== displayTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{displayAddonName}
</Text>
)}
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
<View style={styles.streamMetaRow}>
{quality && quality >= "720" && (
<QualityBadge type="HD" />
)}
{isDolby && (
<QualityBadge type="VISION" />
)}
{size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text>
</View>
)}
{isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
</View>
)}
{/* Special badge for HDRezka streams */}
{isHDRezka && (
<View style={[styles.chip, { backgroundColor: theme.colors.accent }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text>
</View>
)}
</View>
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={24}
color={theme.colors.primary}
/>
</View>
</TouchableOpacity>
</View>
);
}
// Animation delay based on index - stagger effect
const enterDelay = 100 + (index * 30);
return (
<Animated.View
@ -328,11 +248,7 @@ export const StreamsScreen = () => {
const loadStartTimeRef = useRef(0);
const hasDoneInitialLoadRef = useRef(false);
// Add state for handling orientation transition
const [isTransitioning, setIsTransitioning] = useState(false);
// Add state to prevent animation conflicts during exit
const [isExiting, setIsExiting] = useState(false);
// Add timing logs
const [loadStartTime, setLoadStartTime] = useState(0);
@ -506,9 +422,6 @@ export const StreamsScreen = () => {
// Memoize handlers
const handleBack = useCallback(() => {
// Set exit state to prevent animation conflicts and hide content immediately
setIsExiting(true);
const cleanup = () => {
headerOpacity.value = withTiming(0, { duration: 100 });
heroScale.value = withTiming(0.95, { duration: 100 });
@ -1065,10 +978,9 @@ export const StreamsScreen = () => {
isLoading={isLoading}
statusMessage={undefined}
theme={currentTheme}
isExiting={isExiting}
/>
);
}, [handleStreamPress, currentTheme, isExiting]);
}, [handleStreamPress, currentTheme]);
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => {
const isProviderLoading = loadingProviders[section.addonId];
@ -1101,43 +1013,7 @@ export const StreamsScreen = () => {
};
}, []);
// Add orientation handling when screen comes into focus
useFocusEffect(
useCallback(() => {
// Set transitioning state to mask any visual glitches
setIsTransitioning(true);
// Immediately lock to portrait when returning to this screen
const lockToPortrait = async () => {
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
// Small delay then unlock to allow natural portrait orientation
setTimeout(async () => {
try {
await ScreenOrientation.unlockAsync();
// Clear transition state after orientation is handled
setTimeout(() => {
setIsTransitioning(false);
}, 100);
} catch (error) {
logger.error('[StreamsScreen] Error unlocking orientation:', error);
setIsTransitioning(false);
}
}, 200);
} catch (error) {
logger.error('[StreamsScreen] Error locking to portrait:', error);
setIsTransitioning(false);
}
};
lockToPortrait();
return () => {
// Cleanup when screen loses focus
setIsTransitioning(false);
};
}, [])
);
return (
<View style={styles.container}>
@ -1146,18 +1022,7 @@ export const StreamsScreen = () => {
backgroundColor="transparent"
barStyle="light-content"
/>
{/* Instant overlay when exiting to prevent glitches */}
{isExiting && (
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.darkBackground, zIndex: 100 }]} />
)}
{/* Transition overlay to mask orientation changes */}
{isTransitioning && (
<View style={styles.transitionOverlay}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
)}
<Animated.View
entering={FadeIn.duration(300)}