mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
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:
parent
9ac99ee0a3
commit
87cd33b90b
3 changed files with 107 additions and 195 deletions
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -399,7 +399,7 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
{type === 'series' ? (
|
||||
<SeriesContent
|
||||
episodes={episodes}
|
||||
episodes={Object.values(groupedEpisodes).flat()}
|
||||
selectedSeason={selectedSeason}
|
||||
loadingSeasons={loadingSeasons}
|
||||
onSeasonChange={handleSeasonChangeWithHaptics}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue