Ios #35

Merged
tapframe merged 30 commits from ios into main 2025-07-28 13:17:13 +00:00
3 changed files with 107 additions and 195 deletions
Showing only changes of commit 87cd33b90b - Show all commits

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)}