mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +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
|
// Add refs for the scroll views
|
||||||
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
||||||
const episodeScrollViewRef = useRef<ScrollView | null>(null);
|
const episodeScrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loadEpisodesProgress = async () => {
|
const loadEpisodesProgress = async () => {
|
||||||
if (!metadata?.id) return;
|
if (!metadata?.id) return;
|
||||||
|
|
@ -166,6 +168,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
|
}, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (loadingSeasons) {
|
if (loadingSeasons) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centeredContainer}>
|
<View style={styles.centeredContainer}>
|
||||||
|
|
@ -185,10 +189,11 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderSeasonSelector = () => {
|
const renderSeasonSelector = () => {
|
||||||
|
// Show selector if we have grouped episodes data or can derive from episodes
|
||||||
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
|
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -229,6 +234,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
{selectedSeason === season && (
|
{selectedSeason === season && (
|
||||||
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} />
|
<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>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|
@ -544,65 +555,81 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
entering={FadeIn.duration(300).delay(100)}
|
entering={FadeIn.duration(300).delay(100)}
|
||||||
>
|
>
|
||||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
|
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{settings.episodeLayoutStyle === 'horizontal' ? (
|
{/* Show message when no episodes are available for selected season */}
|
||||||
// Horizontal Layout (Netflix-style)
|
{currentSeasonEpisodes.length === 0 && (
|
||||||
<ScrollView
|
<View style={styles.centeredContainer}>
|
||||||
ref={episodeScrollViewRef}
|
<MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} />
|
||||||
horizontal
|
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>
|
||||||
showsHorizontalScrollIndicator={false}
|
No episodes available for Season {selectedSeason}
|
||||||
style={styles.episodeList}
|
</Text>
|
||||||
contentContainerStyle={styles.episodeListContentHorizontal}
|
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
|
||||||
decelerationRate="fast"
|
Episodes may not be released yet
|
||||||
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
|
</Text>
|
||||||
snapToAlignment="start"
|
</View>
|
||||||
>
|
)}
|
||||||
{currentSeasonEpisodes.map((episode, index) => (
|
|
||||||
<Animated.View
|
{/* Only render episode list if there are episodes */}
|
||||||
key={episode.id}
|
{currentSeasonEpisodes.length > 0 && (
|
||||||
entering={FadeIn.duration(300).delay(100 + index * 30)}
|
settings.episodeLayoutStyle === 'horizontal' ? (
|
||||||
style={[
|
// Horizontal Layout (Netflix-style)
|
||||||
styles.episodeCardWrapperHorizontal,
|
<ScrollView
|
||||||
isTablet && styles.episodeCardWrapperHorizontalTablet
|
ref={episodeScrollViewRef}
|
||||||
]}
|
horizontal
|
||||||
>
|
showsHorizontalScrollIndicator={false}
|
||||||
{renderHorizontalEpisodeCard(episode)}
|
style={styles.episodeList}
|
||||||
</Animated.View>
|
contentContainerStyle={styles.episodeListContentHorizontal}
|
||||||
))}
|
decelerationRate="fast"
|
||||||
</ScrollView>
|
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
|
||||||
) : (
|
snapToAlignment="start"
|
||||||
// Vertical Layout (Traditional)
|
>
|
||||||
<ScrollView
|
{currentSeasonEpisodes.map((episode, index) => (
|
||||||
style={styles.episodeList}
|
<Animated.View
|
||||||
contentContainerStyle={[
|
key={episode.id}
|
||||||
styles.episodeListContentVertical,
|
entering={FadeIn.duration(300).delay(100 + index * 30)}
|
||||||
isTablet && styles.episodeListContentVerticalTablet
|
style={[
|
||||||
]}
|
styles.episodeCardWrapperHorizontal,
|
||||||
>
|
isTablet && styles.episodeCardWrapperHorizontalTablet
|
||||||
{isTablet ? (
|
]}
|
||||||
<View style={styles.episodeGridVertical}>
|
>
|
||||||
{currentSeasonEpisodes.map((episode, index) => (
|
{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
|
<Animated.View
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
entering={FadeIn.duration(300).delay(100 + index * 30)}
|
entering={FadeIn.duration(300).delay(100 + index * 30)}
|
||||||
>
|
>
|
||||||
{renderVerticalEpisodeCard(episode)}
|
{renderVerticalEpisodeCard(episode)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
))}
|
))
|
||||||
</View>
|
)}
|
||||||
) : (
|
</ScrollView>
|
||||||
currentSeasonEpisodes.map((episode, index) => (
|
)
|
||||||
<Animated.View
|
|
||||||
key={episode.id}
|
|
||||||
entering={FadeIn.duration(300).delay(100 + index * 30)}
|
|
||||||
>
|
|
||||||
{renderVerticalEpisodeCard(episode)}
|
|
||||||
</Animated.View>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -625,6 +652,12 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
centeredSubText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
|
@ -963,4 +996,18 @@ const styles = StyleSheet.create({
|
||||||
selectedSeasonButtonText: {
|
selectedSeasonButtonText: {
|
||||||
fontWeight: '700',
|
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' ? (
|
{type === 'series' ? (
|
||||||
<SeriesContent
|
<SeriesContent
|
||||||
episodes={episodes}
|
episodes={Object.values(groupedEpisodes).flat()}
|
||||||
selectedSeason={selectedSeason}
|
selectedSeason={selectedSeason}
|
||||||
loadingSeasons={loadingSeasons}
|
loadingSeasons={loadingSeasons}
|
||||||
onSeasonChange={handleSeasonChangeWithHaptics}
|
onSeasonChange={handleSeasonChangeWithHaptics}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
Linking,
|
Linking,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
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 { RouteProp } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
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');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
// Extracted Components
|
// Extracted Components
|
||||||
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, isExiting }: {
|
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: {
|
||||||
stream: Stream;
|
stream: Stream;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
index: number;
|
index: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
theme: any;
|
theme: any;
|
||||||
isExiting?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
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 displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream');
|
||||||
const displayAddonName = isHDRezka ? '' : (stream.title || '');
|
const displayAddonName = isHDRezka ? '' : (stream.title || '');
|
||||||
|
|
||||||
// Animation delay based on index - stagger effect (only if not exiting)
|
// Animation delay based on index - stagger effect
|
||||||
const enterDelay = isExiting ? 0 : 100 + (index * 30);
|
const enterDelay = 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|
@ -328,11 +248,7 @@ export const StreamsScreen = () => {
|
||||||
const loadStartTimeRef = useRef(0);
|
const loadStartTimeRef = useRef(0);
|
||||||
const hasDoneInitialLoadRef = useRef(false);
|
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
|
// Add timing logs
|
||||||
const [loadStartTime, setLoadStartTime] = useState(0);
|
const [loadStartTime, setLoadStartTime] = useState(0);
|
||||||
|
|
@ -506,9 +422,6 @@ export const StreamsScreen = () => {
|
||||||
|
|
||||||
// Memoize handlers
|
// Memoize handlers
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
// Set exit state to prevent animation conflicts and hide content immediately
|
|
||||||
setIsExiting(true);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
headerOpacity.value = withTiming(0, { duration: 100 });
|
headerOpacity.value = withTiming(0, { duration: 100 });
|
||||||
heroScale.value = withTiming(0.95, { duration: 100 });
|
heroScale.value = withTiming(0.95, { duration: 100 });
|
||||||
|
|
@ -1065,10 +978,9 @@ export const StreamsScreen = () => {
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
statusMessage={undefined}
|
statusMessage={undefined}
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
isExiting={isExiting}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [handleStreamPress, currentTheme, isExiting]);
|
}, [handleStreamPress, currentTheme]);
|
||||||
|
|
||||||
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => {
|
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => {
|
||||||
const isProviderLoading = loadingProviders[section.addonId];
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|
@ -1146,18 +1022,7 @@ export const StreamsScreen = () => {
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
barStyle="light-content"
|
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
|
<Animated.View
|
||||||
entering={FadeIn.duration(300)}
|
entering={FadeIn.duration(300)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue