some changes

This commit is contained in:
tapframe 2025-08-11 16:04:50 +05:30
parent 501bded9ee
commit 9246b26493
6 changed files with 346 additions and 75 deletions

@ -1 +1 @@
Subproject commit cfd669df2258d217c90c58ce9455676c60abd1ac Subproject commit a6239977e8725a631df20154c45bff9572c3ff98

View file

@ -977,6 +977,8 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0,0,0,0.8)', textShadowColor: 'rgba(0,0,0,0.8)',
textShadowOffset: { width: 0, height: 1 }, textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2, textShadowRadius: 2,
maxWidth: '70%',
textAlign: 'center',
}, },
tabletButtons: { tabletButtons: {
flexDirection: 'row', flexDirection: 'row',
@ -1031,4 +1033,4 @@ const styles = StyleSheet.create({
}, },
}); });
export default React.memo(FeaturedContent); export default React.memo(FeaturedContent);

View file

@ -31,6 +31,7 @@ import { logger } from '../../utils/logger';
import { TMDBService } from '../../services/tmdbService'; import { TMDBService } from '../../services/tmdbService';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
// Ultra-optimized animation constants // Ultra-optimized animation constants
const PARALLAX_FACTOR = 0.3; const PARALLAX_FACTOR = 0.3;
@ -240,9 +241,9 @@ const ActionButtons = memo(({
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]); }, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
return ( return (
<Animated.View style={[styles.actionButtons, animatedStyle]}> <Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
<TouchableOpacity <TouchableOpacity
style={playButtonStyle} style={[playButtonStyle, isTablet && styles.tabletPlayButton]}
onPress={handleShowStreams} onPress={handleShowStreams}
activeOpacity={0.85} activeOpacity={0.85}
> >
@ -253,14 +254,14 @@ const ActionButtons = memo(({
} }
return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
})()} })()}
size={24} size={isTablet ? 28 : 24}
color={isWatched && type === 'movie' ? "#fff" : "#000"} color={isWatched && type === 'movie' ? "#fff" : "#000"}
/> />
<Text style={playButtonTextStyle}>{finalPlayButtonText}</Text> <Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, styles.infoButton]} style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton]}
onPress={toggleLibrary} onPress={toggleLibrary}
activeOpacity={0.85} activeOpacity={0.85}
> >
@ -271,17 +272,17 @@ const ActionButtons = memo(({
)} )}
<MaterialIcons <MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'} name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24} size={isTablet ? 28 : 24}
color={currentTheme.colors.white} color={currentTheme.colors.white}
/> />
<Text style={styles.infoButtonText}> <Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
{inLibrary ? 'Saved' : 'Save'} {inLibrary ? 'Saved' : 'Save'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{type === 'series' && ( {type === 'series' && (
<TouchableOpacity <TouchableOpacity
style={styles.iconButton} style={[styles.iconButton, isTablet && styles.tabletIconButton]}
onPress={handleRatingsPress} onPress={handleRatingsPress}
activeOpacity={0.85} activeOpacity={0.85}
> >
@ -292,7 +293,7 @@ const ActionButtons = memo(({
)} )}
<MaterialIcons <MaterialIcons
name="assessment" name="assessment"
size={24} size={isTablet ? 28 : 24}
color={currentTheme.colors.white} color={currentTheme.colors.white}
/> />
</TouchableOpacity> </TouchableOpacity>
@ -534,9 +535,9 @@ const WatchProgressDisplay = memo(({
const isCompleted = progressData.isWatched || progressData.progressPercent >= 85; const isCompleted = progressData.isWatched || progressData.progressPercent >= 85;
return ( return (
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}> <Animated.View style={[isTablet ? styles.tabletWatchProgressContainer : styles.watchProgressContainer, animatedStyle]}>
{/* Glass morphism background with entrance animation */} {/* Glass morphism background with entrance animation */}
<Animated.View style={[styles.progressGlassBackground, progressBoxAnimatedStyle]}> <Animated.View style={[isTablet ? styles.tabletProgressGlassBackground : styles.progressGlassBackground, progressBoxAnimatedStyle]}>
{Platform.OS === 'ios' ? ( {Platform.OS === 'ios' ? (
<ExpoBlurView intensity={20} style={styles.blurBackground} tint="dark" /> <ExpoBlurView intensity={20} style={styles.blurBackground} tint="dark" />
) : ( ) : (
@ -580,9 +581,9 @@ const WatchProgressDisplay = memo(({
{/* Enhanced text container with better typography */} {/* Enhanced text container with better typography */}
<View style={styles.watchProgressTextContainer}> <View style={styles.watchProgressTextContainer}>
<View style={styles.progressInfoMain}> <View style={styles.progressInfoMain}>
<Text style={[styles.watchProgressMainText, { <Text style={[isTablet ? styles.tabletWatchProgressMainText : styles.watchProgressMainText, {
color: isCompleted ? '#00ff88' : currentTheme.colors.white, color: isCompleted ? '#00ff88' : currentTheme.colors.white,
fontSize: isCompleted ? 13 : 12, fontSize: isCompleted ? (isTablet ? 15 : 13) : (isTablet ? 14 : 12),
fontWeight: isCompleted ? '700' : '600' fontWeight: isCompleted ? '700' : '600'
}]}> }]}>
{progressData.displayText} {progressData.displayText}
@ -590,7 +591,7 @@ const WatchProgressDisplay = memo(({
</View> </View>
<Text style={[styles.watchProgressSubText, { <Text style={[isTablet ? styles.tabletWatchProgressSubText : styles.watchProgressSubText, {
color: isCompleted ? 'rgba(0,255,136,0.7)' : currentTheme.colors.textMuted, color: isCompleted ? 'rgba(0,255,136,0.7)' : currentTheme.colors.textMuted,
}]}> }]}>
{progressData.episodeInfo} Last watched {progressData.formattedTime} {progressData.episodeInfo} Last watched {progressData.formattedTime}
@ -839,11 +840,11 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
entering={FadeIn.duration(400).delay(200 + index * 100)} entering={FadeIn.duration(400).delay(200 + index * 100)}
style={{ flexDirection: 'row', alignItems: 'center' }} style={{ flexDirection: 'row', alignItems: 'center' }}
> >
<Text style={[styles.genreText, { color: themeColors.text }]}> <Text style={[isTablet ? styles.tabletGenreText : styles.genreText, { color: themeColors.text }]}>
{genreName} {genreName}
</Text> </Text>
{index < array.length - 1 && ( {index < array.length - 1 && (
<Text style={[styles.genreDot, { color: themeColors.text }]}></Text> <Text style={[isTablet ? styles.tabletGenreDot : styles.genreDot, { color: themeColors.text }]}></Text>
)} )}
</Animated.View> </Animated.View>
)); ));
@ -966,14 +967,14 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
style={styles.bottomFadeGradient} style={styles.bottomFadeGradient}
pointerEvents="none" pointerEvents="none"
/> />
<View style={styles.heroContent}> <View style={[styles.heroContent, isTablet && { maxWidth: 800, alignSelf: 'center' }]}>
{/* Optimized Title/Logo */} {/* Optimized Title/Logo */}
<View style={styles.logoContainer}> <View style={styles.logoContainer}>
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}> <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
{shouldLoadSecondaryData && metadata.logo && !logoLoadError ? ( {shouldLoadSecondaryData && metadata.logo && !logoLoadError ? (
<Image <Image
source={{ uri: metadata.logo }} source={{ uri: metadata.logo }}
style={styles.titleLogo} style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
contentFit="contain" contentFit="contain"
transition={150} transition={150}
onError={() => { onError={() => {
@ -981,7 +982,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
}} }}
/> />
) : ( ) : (
<Text style={[styles.heroTitle, { color: themeColors.highEmphasis }]}> <Text style={[isTablet ? styles.tabletHeroTitle : styles.heroTitle, { color: themeColors.highEmphasis }]}>
{metadata.name} {metadata.name}
</Text> </Text>
)} )}
@ -999,7 +1000,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
{/* Optimized genre display with lazy loading */} {/* Optimized genre display with lazy loading */}
{shouldLoadSecondaryData && genreElements && ( {shouldLoadSecondaryData && genreElements && (
<View style={styles.genreContainer}> <View style={isTablet ? styles.tabletGenreContainer : styles.genreContainer}>
{genreElements} {genreElements}
</View> </View>
)} )}
@ -1041,7 +1042,7 @@ const styles = StyleSheet.create({
backButtonContainer: { backButtonContainer: {
position: 'absolute', position: 'absolute',
top: Platform.OS === 'android' ? 40 : 50, top: Platform.OS === 'android' ? 40 : 50,
left: 16, left: isTablet ? 32 : 16,
zIndex: 10, zIndex: 10,
}, },
backButton: { backButton: {
@ -1066,9 +1067,9 @@ const styles = StyleSheet.create({
zIndex: 1, zIndex: 1,
}, },
heroContent: { heroContent: {
padding: 16, padding: isTablet ? 32 : 16,
paddingTop: 8, paddingTop: isTablet ? 16 : 8,
paddingBottom: 8, paddingBottom: isTablet ? 16 : 8,
position: 'relative', position: 'relative',
zIndex: 2, zIndex: 2,
}, },
@ -1077,16 +1078,25 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
width: '100%', width: '100%',
marginBottom: 4, marginBottom: 4,
flex: 0,
display: 'flex',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
}, },
titleLogoContainer: { titleLogoContainer: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: '100%', width: '100%',
flex: 0,
display: 'flex',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
}, },
titleLogo: { titleLogo: {
width: width * 0.75, width: width * 0.75,
height: 90, height: 90,
alignSelf: 'center', alignSelf: 'center',
textAlign: 'center',
}, },
heroTitle: { heroTitle: {
fontSize: 26, fontSize: 26,
@ -1106,6 +1116,8 @@ const styles = StyleSheet.create({
marginTop: 6, marginTop: 6,
marginBottom: 14, marginBottom: 14,
gap: 6, gap: 6,
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
}, },
genreText: { genreText: {
fontSize: 12, fontSize: 12,
@ -1125,6 +1137,8 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
width: '100%', width: '100%',
position: 'relative', position: 'relative',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
}, },
actionButton: { actionButton: {
flexDirection: 'row', flexDirection: 'row',
@ -1177,6 +1191,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
minHeight: 36, minHeight: 36,
position: 'relative', position: 'relative',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
}, },
progressGlassBackground: { progressGlassBackground: {
width: '75%', width: '75%',
@ -1441,6 +1457,112 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
// Tablet-specific styles
tabletActionButtons: {
flexDirection: 'row',
gap: 16,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
position: 'relative',
maxWidth: 600,
alignSelf: 'center',
},
tabletPlayButton: {
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 32,
minWidth: 180,
},
tabletPlayButtonText: {
fontSize: 18,
fontWeight: '700',
marginLeft: 8,
},
tabletInfoButton: {
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 28,
minWidth: 140,
},
tabletInfoButtonText: {
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
tabletIconButton: {
width: 60,
height: 60,
borderRadius: 30,
},
tabletHeroTitle: {
fontSize: 36,
fontWeight: '900',
marginBottom: 12,
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
letterSpacing: -0.5,
textAlign: 'center',
lineHeight: 42,
},
tabletTitleLogo: {
width: width * 0.5,
height: 120,
alignSelf: 'center',
maxWidth: 400,
textAlign: 'center',
},
tabletGenreContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
marginBottom: 20,
gap: 8,
},
tabletGenreText: {
fontSize: 16,
fontWeight: '500',
opacity: 0.9,
},
tabletGenreDot: {
fontSize: 16,
fontWeight: '500',
opacity: 0.6,
marginHorizontal: 4,
},
tabletWatchProgressContainer: {
marginTop: 8,
marginBottom: 8,
width: '100%',
alignItems: 'center',
minHeight: 44,
position: 'relative',
},
tabletProgressGlassBackground: {
width: '60%',
maxWidth: 500,
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 16,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
},
tabletWatchProgressMainText: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
tabletWatchProgressSubText: {
fontSize: 12,
textAlign: 'center',
opacity: 0.8,
marginBottom: 1,
},
}); });
export default HeroSection; export default HeroSection;

View file

@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { FlashList } from '@shopify/flash-list'; import { FlashList, FlashListRef } from '@shopify/flash-list';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings'; import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata'; import { Episode } from '../../types/metadata';
@ -47,7 +47,7 @@ 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<FlashListRef<Episode>>(null);
@ -224,15 +224,19 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
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 (
<View style={styles.seasonSelectorWrapper}> <View style={[styles.seasonSelectorWrapper, isTablet && styles.seasonSelectorWrapperTablet]}>
<Text style={[styles.seasonSelectorTitle, { color: currentTheme.colors.highEmphasis }]}>Seasons</Text> <Text style={[
styles.seasonSelectorTitle,
isTablet && styles.seasonSelectorTitleTablet,
{ color: currentTheme.colors.highEmphasis }
]}>Seasons</Text>
<FlatList <FlatList
ref={seasonScrollViewRef as React.RefObject<FlatList<any>>} ref={seasonScrollViewRef as React.RefObject<FlatList<any>>}
data={seasons} data={seasons}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer} style={styles.seasonSelectorContainer}
contentContainerStyle={styles.seasonSelectorContent} contentContainerStyle={[styles.seasonSelectorContent, isTablet && styles.seasonSelectorContentTablet]}
initialNumToRender={5} initialNumToRender={5}
maxToRenderPerBatch={5} maxToRenderPerBatch={5}
windowSize={3} windowSize={3}
@ -251,18 +255,23 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
key={season} key={season}
style={[ style={[
styles.seasonButton, styles.seasonButton,
isTablet && styles.seasonButtonTablet,
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }] selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
]} ]}
onPress={() => onSeasonChange(season)} onPress={() => onSeasonChange(season)}
> >
<View style={styles.seasonPosterContainer}> <View style={[styles.seasonPosterContainer, isTablet && styles.seasonPosterContainerTablet]}>
<Image <Image
source={{ uri: seasonPoster }} source={{ uri: seasonPoster }}
style={styles.seasonPoster} style={styles.seasonPoster}
contentFit="cover" contentFit="cover"
/> />
{selectedSeason === season && ( {selectedSeason === season && (
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} /> <View style={[
styles.selectedSeasonIndicator,
isTablet && styles.selectedSeasonIndicatorTablet,
{ backgroundColor: currentTheme.colors.primary }
]} />
)} )}
{/* Show episode count badge, including when there are no episodes */} {/* Show episode count badge, including when there are no episodes */}
<View style={[styles.episodeCountBadge, { backgroundColor: currentTheme.colors.elevation2 }]}> <View style={[styles.episodeCountBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
@ -274,8 +283,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<Text <Text
style={[ style={[
styles.seasonButtonText, styles.seasonButtonText,
isTablet && styles.seasonButtonTextTablet,
{ color: currentTheme.colors.mediumEmphasis }, { color: currentTheme.colors.mediumEmphasis },
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }] selectedSeason === season && [
styles.selectedSeasonButtonText,
isTablet && styles.selectedSeasonButtonTextTablet,
{ color: currentTheme.colors.primary }
]
]} ]}
> >
Season {season} Season {season}
@ -536,19 +550,19 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
style={styles.episodeGradient} style={styles.episodeGradient}
> >
{/* Content Container */} {/* Content Container */}
<View style={styles.episodeContent}> <View style={[styles.episodeContent, isTablet && styles.episodeContentTablet]}>
{/* Episode Number Badge */} {/* Episode Number Badge */}
<View style={styles.episodeNumberBadgeHorizontal}> <View style={[styles.episodeNumberBadgeHorizontal, isTablet && styles.episodeNumberBadgeHorizontalTablet]}>
<Text style={styles.episodeNumberHorizontal}>{episodeString}</Text> <Text style={[styles.episodeNumberHorizontal, isTablet && styles.episodeNumberHorizontalTablet]}>{episodeString}</Text>
</View> </View>
{/* Episode Title */} {/* Episode Title */}
<Text style={styles.episodeTitleHorizontal} numberOfLines={2}> <Text style={[styles.episodeTitleHorizontal, isTablet && styles.episodeTitleHorizontalTablet]} numberOfLines={2}>
{episode.name} {episode.name}
</Text> </Text>
{/* Episode Description */} {/* Episode Description */}
<Text style={styles.episodeDescriptionHorizontal} numberOfLines={3}> <Text style={[styles.episodeDescriptionHorizontal, isTablet && styles.episodeDescriptionHorizontalTablet]} numberOfLines={3}>
{episode.overview || 'No description available'} {episode.overview || 'No description available'}
</Text> </Text>
@ -636,7 +650,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
(settings?.episodeLayoutStyle === 'horizontal') ? ( (settings?.episodeLayoutStyle === 'horizontal') ? (
// Horizontal Layout (Netflix-style) // Horizontal Layout (Netflix-style)
<FlashList <FlashList
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>} ref={episodeScrollViewRef}
data={currentSeasonEpisodes} data={currentSeasonEpisodes}
renderItem={({ item: episode, index }) => ( renderItem={({ item: episode, index }) => (
<Animated.View <Animated.View
@ -653,18 +667,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
keyExtractor={episode => episode.id.toString()} keyExtractor={episode => episode.id.toString()}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.episodeListContentHorizontal} contentContainerStyle={isTablet ? styles.episodeListContentHorizontalTablet : styles.episodeListContentHorizontal}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToAlignment="start"
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
/> />
) : ( ) : (
// Vertical Layout (Traditional) // Vertical Layout (Traditional)
<FlashList <FlashList
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>} ref={episodeScrollViewRef}
data={currentSeasonEpisodes} data={currentSeasonEpisodes}
renderItem={({ item: episode, index }) => ( renderItem={({ item: episode, index }) => (
<Animated.View <Animated.View
@ -675,9 +683,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</Animated.View> </Animated.View>
)} )}
keyExtractor={episode => episode.id.toString()} keyExtractor={episode => episode.id.toString()}
estimatedItemSize={136}
contentContainerStyle={isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical} contentContainerStyle={isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical}
numColumns={isTablet ? 2 : 1}
/> />
) )
)} )}
@ -750,7 +756,7 @@ const styles = StyleSheet.create({
episodeCardVerticalTablet: { episodeCardVerticalTablet: {
width: '100%', width: '100%',
flexDirection: 'row', flexDirection: 'row',
height: 140, height: 160,
marginBottom: 16, marginBottom: 16,
}, },
episodeImageContainer: { episodeImageContainer: {
@ -759,8 +765,8 @@ const styles = StyleSheet.create({
height: 120, height: 120,
}, },
episodeImageContainerTablet: { episodeImageContainerTablet: {
width: 140, width: 160,
height: 140, height: 160,
}, },
episodeImage: { episodeImage: {
width: '100%', width: '100%',
@ -891,12 +897,17 @@ const styles = StyleSheet.create({
paddingLeft: 16, paddingLeft: 16,
paddingRight: 16, paddingRight: 16,
}, },
episodeListContentHorizontalTablet: {
paddingLeft: 24,
paddingRight: 24,
},
episodeCardWrapperHorizontal: { episodeCardWrapperHorizontal: {
width: Dimensions.get('window').width * 0.85, width: Dimensions.get('window').width * 0.75,
marginRight: 16, marginRight: 16,
}, },
episodeCardWrapperHorizontalTablet: { episodeCardWrapperHorizontalTablet: {
width: Dimensions.get('window').width * 0.4, width: Dimensions.get('window').width * 0.4,
marginRight: 20,
}, },
episodeCardHorizontal: { episodeCardHorizontal: {
borderRadius: 16, borderRadius: 16,
@ -914,7 +925,11 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
episodeCardHorizontalTablet: { episodeCardHorizontalTablet: {
height: 180, height: 260,
borderRadius: 20,
elevation: 12,
shadowOpacity: 0.4,
shadowRadius: 16,
}, },
episodeBackgroundImage: { episodeBackgroundImage: {
width: '100%', width: '100%',
@ -934,6 +949,10 @@ const styles = StyleSheet.create({
padding: 12, padding: 12,
paddingBottom: 16, paddingBottom: 16,
}, },
episodeContentTablet: {
padding: 16,
paddingBottom: 20,
},
episodeNumberBadgeHorizontal: { episodeNumberBadgeHorizontal: {
backgroundColor: 'rgba(0,0,0,0.4)', backgroundColor: 'rgba(0,0,0,0.4)',
paddingHorizontal: 6, paddingHorizontal: 6,
@ -942,6 +961,14 @@ const styles = StyleSheet.create({
marginBottom: 6, marginBottom: 6,
alignSelf: 'flex-start', alignSelf: 'flex-start',
}, },
episodeNumberBadgeHorizontalTablet: {
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
marginBottom: 8,
alignSelf: 'flex-start',
},
episodeNumberHorizontal: { episodeNumberHorizontal: {
color: 'rgba(255,255,255,0.8)', color: 'rgba(255,255,255,0.8)',
fontSize: 10, fontSize: 10,
@ -950,6 +977,14 @@ const styles = StyleSheet.create({
textTransform: 'uppercase', textTransform: 'uppercase',
marginBottom: 2, marginBottom: 2,
}, },
episodeNumberHorizontalTablet: {
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
fontWeight: '700',
letterSpacing: 1.0,
textTransform: 'uppercase',
marginBottom: 2,
},
episodeTitleHorizontal: { episodeTitleHorizontal: {
color: '#fff', color: '#fff',
fontSize: 15, fontSize: 15,
@ -958,6 +993,14 @@ const styles = StyleSheet.create({
marginBottom: 4, marginBottom: 4,
lineHeight: 18, lineHeight: 18,
}, },
episodeTitleHorizontalTablet: {
color: '#fff',
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.4,
marginBottom: 6,
lineHeight: 22,
},
episodeDescriptionHorizontal: { episodeDescriptionHorizontal: {
color: 'rgba(255,255,255,0.85)', color: 'rgba(255,255,255,0.85)',
fontSize: 12, fontSize: 12,
@ -965,6 +1008,13 @@ const styles = StyleSheet.create({
marginBottom: 8, marginBottom: 8,
opacity: 0.9, opacity: 0.9,
}, },
episodeDescriptionHorizontalTablet: {
color: 'rgba(255,255,255,0.9)',
fontSize: 14,
lineHeight: 18,
marginBottom: 10,
opacity: 0.95,
},
episodeMetadataRowHorizontal: { episodeMetadataRowHorizontal: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -1027,22 +1077,39 @@ const styles = StyleSheet.create({
marginBottom: 20, marginBottom: 20,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
seasonSelectorWrapperTablet: {
marginBottom: 24,
paddingHorizontal: 24,
},
seasonSelectorTitle: { seasonSelectorTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
marginBottom: 12, marginBottom: 12,
}, },
seasonSelectorTitleTablet: {
fontSize: 22,
fontWeight: '700',
marginBottom: 16,
},
seasonSelectorContainer: { seasonSelectorContainer: {
flexGrow: 0, flexGrow: 0,
}, },
seasonSelectorContent: { seasonSelectorContent: {
paddingBottom: 8, paddingBottom: 8,
}, },
seasonSelectorContentTablet: {
paddingBottom: 12,
},
seasonButton: { seasonButton: {
alignItems: 'center', alignItems: 'center',
marginRight: 16, marginRight: 16,
width: 100, width: 100,
}, },
seasonButtonTablet: {
alignItems: 'center',
marginRight: 20,
width: 120,
},
selectedSeasonButton: { selectedSeasonButton: {
opacity: 1, opacity: 1,
}, },
@ -1054,6 +1121,14 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
marginBottom: 8, marginBottom: 8,
}, },
seasonPosterContainerTablet: {
position: 'relative',
width: 120,
height: 180,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 12,
},
seasonPoster: { seasonPoster: {
width: '100%', width: '100%',
height: '100%', height: '100%',
@ -1065,13 +1140,27 @@ const styles = StyleSheet.create({
right: 0, right: 0,
height: 4, height: 4,
}, },
selectedSeasonIndicatorTablet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 6,
},
seasonButtonText: { seasonButtonText: {
fontSize: 14, fontSize: 14,
fontWeight: '500', fontWeight: '500',
}, },
seasonButtonTextTablet: {
fontSize: 16,
fontWeight: '600',
},
selectedSeasonButtonText: { selectedSeasonButtonText: {
fontWeight: '700', fontWeight: '700',
}, },
selectedSeasonButtonTextTablet: {
fontWeight: '800',
},
episodeCountBadge: { episodeCountBadge: {
position: 'absolute', position: 'absolute',
top: 8, top: 8,

View file

@ -730,6 +730,10 @@ const PluginsScreen: React.FC = () => {
<View style={[styles.availableIndicator, { backgroundColor: colors.mediumGray }]}> <View style={[styles.availableIndicator, { backgroundColor: colors.mediumGray }]}>
<Text style={styles.availableIndicatorText}>Disabled</Text> <Text style={styles.availableIndicatorText}>Disabled</Text>
</View> </View>
) : scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android') ? (
<View style={[styles.availableIndicator, { backgroundColor: '#ff9500' }]}>
<Text style={styles.availableIndicatorText}>Platform Disabled</Text>
</View>
) : !scraper.enabled && ( ) : !scraper.enabled && (
<View style={styles.availableIndicator}> <View style={styles.availableIndicator}>
<Text style={styles.availableIndicatorText}>Available</Text> <Text style={styles.availableIndicatorText}>Available</Text>
@ -758,8 +762,8 @@ const PluginsScreen: React.FC = () => {
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)} onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false} disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
style={{ opacity: (!settings.enableLocalScrapers || scraper.manifestEnabled === false) ? 0.5 : 1 }} style={{ opacity: (!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))) ? 0.5 : 1 }}
/> />
</View> </View>
); );

View file

@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios'; import axios from 'axios';
import { Platform } from 'react-native';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { Stream } from '../types/streams'; import { Stream } from '../types/streams';
import { cacheService } from './cacheService'; import { cacheService } from './cacheService';
@ -24,6 +25,8 @@ export interface ScraperInfo {
logo?: string; logo?: string;
contentLanguage?: string[]; contentLanguage?: string[];
manifestEnabled?: boolean; // Whether the scraper is enabled in the manifest manifestEnabled?: boolean; // Whether the scraper is enabled in the manifest
supportedPlatforms?: ('ios' | 'android')[]; // Platforms where this scraper is supported
disabledPlatforms?: ('ios' | 'android')[]; // Platforms where this scraper is disabled
} }
export interface LocalScraperResult { export interface LocalScraperResult {
@ -181,6 +184,26 @@ class LocalScraperService {
return this.repositoryName || 'Plugins'; return this.repositoryName || 'Plugins';
} }
// Check if a scraper is compatible with the current platform
private isPlatformCompatible(scraper: ScraperInfo): boolean {
const currentPlatform = Platform.OS as 'ios' | 'android';
// If disabledPlatforms is specified and includes current platform, scraper is not compatible
if (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(currentPlatform)) {
logger.log(`[LocalScraperService] Scraper ${scraper.name} is disabled on ${currentPlatform}`);
return false;
}
// If supportedPlatforms is specified and doesn't include current platform, scraper is not compatible
if (scraper.supportedPlatforms && !scraper.supportedPlatforms.includes(currentPlatform)) {
logger.log(`[LocalScraperService] Scraper ${scraper.name} is not supported on ${currentPlatform}`);
return false;
}
// If neither supportedPlatforms nor disabledPlatforms is specified, or current platform is supported
return true;
}
// Fetch and install scrapers from repository // Fetch and install scrapers from repository
async refreshRepository(): Promise<void> { async refreshRepository(): Promise<void> {
await this.ensureInitialized(); await this.ensureInitialized();
@ -244,7 +267,21 @@ class LocalScraperService {
// Download and install each scraper from manifest // Download and install each scraper from manifest
for (const scraperInfo of manifest.scrapers) { for (const scraperInfo of manifest.scrapers) {
await this.downloadScraper(scraperInfo); const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
if (isPlatformCompatible) {
// Download/update the scraper (downloadScraper handles force disabling based on manifest.enabled)
await this.downloadScraper(scraperInfo);
} else {
logger.log('[LocalScraperService] Skipping platform-incompatible scraper:', scraperInfo.name);
// Remove if it was previously installed but is now platform-incompatible
if (this.installedScrapers.has(scraperInfo.id)) {
logger.log('[LocalScraperService] Removing platform-incompatible scraper:', scraperInfo.name);
this.installedScrapers.delete(scraperInfo.id);
this.scraperCode.delete(scraperInfo.id);
await AsyncStorage.removeItem(`scraper-code-${scraperInfo.id}`);
}
}
} }
await this.saveInstalledScrapers(); await this.saveInstalledScrapers();
@ -269,9 +306,18 @@ class LocalScraperService {
const scraperCode = response.data; const scraperCode = response.data;
// Store scraper info and code // Store scraper info and code
const existingScraper = this.installedScrapers.get(scraperInfo.id);
const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
const updatedScraperInfo = { const updatedScraperInfo = {
...scraperInfo, ...scraperInfo,
enabled: this.installedScrapers.get(scraperInfo.id)?.enabled ?? true // Preserve enabled state // Store the manifest's enabled state separately
manifestEnabled: scraperInfo.enabled,
// Force disable if:
// 1. Manifest says enabled: false (globally disabled)
// 2. Platform incompatible
// Otherwise, preserve user's enabled state or default to false
enabled: scraperInfo.enabled && isPlatformCompatible ? (existingScraper?.enabled ?? false) : false
}; };
// Ensure contentLanguage is an array (migration for older scrapers) // Ensure contentLanguage is an array (migration for older scrapers)
@ -370,22 +416,24 @@ class LocalScraperService {
this.repositoryName = manifest.name; this.repositoryName = manifest.name;
} }
// Return scrapers from manifest, respecting manifest's enabled field // Return scrapers from manifest, respecting manifest's enabled field and platform compatibility
const availableScrapers = manifest.scrapers.map(scraperInfo => { const availableScrapers = manifest.scrapers
const installedScraper = this.installedScrapers.get(scraperInfo.id); .filter(scraperInfo => this.isPlatformCompatible(scraperInfo))
.map(scraperInfo => {
// Create a copy with manifest data const installedScraper = this.installedScrapers.get(scraperInfo.id);
const scraperWithManifestData = {
...scraperInfo, // Create a copy with manifest data
// Store the manifest's enabled state separately const scraperWithManifestData = {
manifestEnabled: scraperInfo.enabled, ...scraperInfo,
// If manifest says enabled: false, scraper cannot be enabled // Store the manifest's enabled state separately
// If manifest says enabled: true, use installed state or default to false manifestEnabled: scraperInfo.enabled,
enabled: scraperInfo.enabled ? (installedScraper?.enabled ?? false) : false // If manifest says enabled: false, scraper cannot be enabled
}; // If manifest says enabled: true, use installed state or default to false
enabled: scraperInfo.enabled ? (installedScraper?.enabled ?? false) : false
return scraperWithManifestData; };
});
return scraperWithManifestData;
});
logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository'); logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository');
@ -409,6 +457,12 @@ class LocalScraperService {
const scraper = this.installedScrapers.get(scraperId); const scraper = this.installedScrapers.get(scraperId);
if (scraper) { if (scraper) {
// Prevent enabling if manifest has disabled it or if platform-incompatible
if (enabled && (scraper.manifestEnabled === false || !this.isPlatformCompatible(scraper))) {
logger.log('[LocalScraperService] Cannot enable scraper', scraperId, '- disabled in manifest or platform-incompatible');
return;
}
scraper.enabled = enabled; scraper.enabled = enabled;
this.installedScrapers.set(scraperId, scraper); this.installedScrapers.set(scraperId, scraper);
await this.saveInstalledScrapers(); await this.saveInstalledScrapers();