Ios #35
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