Implement episode layout settings and enhance SeriesContent display

This update introduces a new episode layout setting, allowing users to choose between vertical and horizontal card styles for episode displays. The SeriesContent component has been refactored to support both layouts, improving the user experience with a more dynamic presentation of episodes. Additionally, new styles and components have been added for the horizontal layout, including gradient overlays and progress indicators, enhancing visual appeal and functionality. The settings screen has also been updated to allow users to toggle between layout styles seamlessly.
This commit is contained in:
tapframe 2025-06-18 11:25:54 +05:30
parent 81897b7242
commit 2e5de7216b
5 changed files with 728 additions and 353 deletions

View file

@ -2,7 +2,9 @@ import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
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 { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata'; import { Episode } from '../../types/metadata';
import { tmdbService } from '../../services/tmdbService'; import { tmdbService } from '../../services/tmdbService';
import { storageService } from '../../services/storageService'; import { storageService } from '../../services/storageService';
@ -34,6 +36,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
metadata metadata
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isTablet = width > 768; const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
@ -159,6 +162,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</View> </View>
<Text <Text
style={[ style={[
styles.seasonButtonText,
{ color: currentTheme.colors.mediumEmphasis }, { color: currentTheme.colors.mediumEmphasis },
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }] selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }]
]} ]}
@ -173,7 +177,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
); );
}; };
const renderEpisodeCard = (episode: Episode) => { // Vertical layout episode card (traditional)
const renderVerticalEpisodeCard = (episode: Episode) => {
let episodeImage = EPISODE_PLACEHOLDER; let episodeImage = EPISODE_PLACEHOLDER;
if (episode.still_path) { if (episode.still_path) {
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
@ -217,9 +222,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<TouchableOpacity <TouchableOpacity
key={episode.id} key={episode.id}
style={[ style={[
styles.episodeCard, styles.episodeCardVertical,
isTablet && styles.episodeCardTablet, isTablet && styles.episodeCardVerticalTablet,
{ backgroundColor: currentTheme.colors.darkBackground } { backgroundColor: currentTheme.colors.elevation2 }
]} ]}
onPress={() => onSelectEpisode(episode)} onPress={() => onSelectEpisode(episode)}
activeOpacity={0.7} activeOpacity={0.7}
@ -291,6 +296,127 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
); );
}; };
// Horizontal layout episode card (Netflix-style)
const renderHorizontalEpisodeCard = (episode: Episode) => {
let episodeImage = EPISODE_PLACEHOLDER;
if (episode.still_path) {
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
if (tmdbUrl) episodeImage = tmdbUrl;
} else if (metadata?.poster) {
episodeImage = metadata.poster;
}
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : '';
const formatRuntime = (runtime: number) => {
if (!runtime) return null;
const hours = Math.floor(runtime / 60);
const minutes = runtime % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
// Get episode progress
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
const progress = episodeProgress[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
// Don't show progress bar if episode is complete (>= 95%)
const showProgress = progress && progressPercent < 95;
return (
<TouchableOpacity
key={episode.id}
style={[
styles.episodeCardHorizontal,
isTablet && styles.episodeCardHorizontalTablet
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.85}
>
{/* Background Image */}
<Image
source={{ uri: episodeImage }}
style={styles.episodeBackgroundImage}
contentFit="cover"
/>
{/* Gradient Overlay */}
<LinearGradient
colors={[
'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.95)'
]}
locations={[0, 0.3, 0.7, 1]}
style={styles.episodeGradient}
>
{/* Content Container */}
<View style={styles.episodeContent}>
{/* Episode Number Badge */}
<Text style={styles.episodeNumberHorizontal}>{episodeString}</Text>
{/* Episode Title */}
<Text style={styles.episodeTitleHorizontal} numberOfLines={2}>
{episode.name}
</Text>
{/* Episode Description */}
<Text style={styles.episodeDescriptionHorizontal} numberOfLines={3}>
{episode.overview || 'No description available'}
</Text>
{/* Metadata Row */}
<View style={styles.episodeMetadataRowHorizontal}>
{episode.runtime && (
<Text style={styles.runtimeTextHorizontal}>
{formatRuntime(episode.runtime)}
</Text>
)}
{episode.vote_average > 0 && (
<View style={styles.ratingContainerHorizontal}>
<MaterialIcons name="star" size={14} color="#FFD700" />
<Text style={styles.ratingTextHorizontal}>
{episode.vote_average.toFixed(1)}
</Text>
</View>
)}
</View>
</View>
{/* Progress Bar */}
{showProgress && (
<View style={styles.progressBarContainerHorizontal}>
<View
style={[
styles.progressBarHorizontal,
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
{/* Completed Badge */}
{progressPercent >= 95 && (
<View style={[styles.completedBadgeHorizontal, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="check" size={16} color="#fff" />
</View>
)}
{/* More Options */}
<TouchableOpacity style={styles.moreButton} activeOpacity={0.7}>
<MaterialIcons name="more-horiz" size={24} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
</LinearGradient>
</TouchableOpacity>
);
};
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
return ( return (
@ -308,35 +434,62 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text> </Text>
<ScrollView {settings.episodeLayoutStyle === 'horizontal' ? (
style={styles.episodeList} // Horizontal Layout (Netflix-style)
contentContainerStyle={[ <ScrollView
styles.episodeListContent, horizontal
isTablet && styles.episodeListContentTablet showsHorizontalScrollIndicator={false}
]} style={styles.episodeList}
> contentContainerStyle={styles.episodeListContentHorizontal}
{isTablet ? ( decelerationRate="fast"
<View style={styles.episodeGrid}> snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
{currentSeasonEpisodes.map((episode, index) => ( snapToAlignment="start"
>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
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(400).delay(300 + index * 50)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View <Animated.View
key={episode.id} key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)} entering={FadeIn.duration(400).delay(300 + index * 50)}
> >
{renderEpisodeCard(episode)} {renderVerticalEpisodeCard(episode)}
</Animated.View> </Animated.View>
))} ))
</View> )}
) : ( </ScrollView>
currentSeasonEpisodes.map((episode, index) => ( )}
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
</Animated.View>
))
)}
</ScrollView>
</Animated.View> </Animated.View>
</View> </View>
); );
@ -366,18 +519,20 @@ const styles = StyleSheet.create({
episodeList: { episodeList: {
flex: 1, flex: 1,
}, },
episodeListContent: {
// Vertical Layout Styles
episodeListContentVertical: {
paddingBottom: 20, paddingBottom: 20,
}, },
episodeListContentTablet: { episodeListContentVerticalTablet: {
paddingHorizontal: 8, paddingHorizontal: 8,
}, },
episodeGrid: { episodeGridVertical: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
episodeCard: { episodeCardVertical: {
flexDirection: 'row', flexDirection: 'row',
borderRadius: 16, borderRadius: 16,
marginBottom: 16, marginBottom: 16,
@ -385,13 +540,13 @@ const styles = StyleSheet.create({
elevation: 8, elevation: 8,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.35, shadowOpacity: 0.25,
shadowRadius: 12, shadowRadius: 8,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)',
height: 120, height: 120,
}, },
episodeCardTablet: { episodeCardVerticalTablet: {
width: '48%', width: '48%',
flexDirection: 'column', flexDirection: 'column',
height: 120, height: 120,
@ -410,12 +565,12 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
bottom: 8, bottom: 8,
right: 4, right: 4,
backgroundColor: 'rgba(0,0,0,0.9)', backgroundColor: 'rgba(0,0,0,0.85)',
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 2,
borderRadius: 4, borderRadius: 4,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)', borderColor: 'rgba(255,255,255,0.2)',
zIndex: 1, zIndex: 1,
}, },
episodeNumberText: { episodeNumberText: {
@ -446,7 +601,7 @@ const styles = StyleSheet.create({
ratingContainer: { ratingContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.85)', backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 4, paddingHorizontal: 4,
paddingVertical: 2, paddingVertical: 2,
borderRadius: 4, borderRadius: 4,
@ -461,6 +616,19 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
marginLeft: 4, marginLeft: 4,
}, },
runtimeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
},
runtimeText: {
fontSize: 13,
fontWeight: '600',
marginLeft: 4,
},
airDateText: { airDateText: {
fontSize: 12, fontSize: 12,
opacity: 0.8, opacity: 0.8,
@ -469,6 +637,161 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
lineHeight: 18, lineHeight: 18,
}, },
progressBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressBar: {
height: '100%',
},
completedBadge: {
position: 'absolute',
bottom: 8,
right: 8,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
},
// Horizontal Layout Styles
episodeListContentHorizontal: {
paddingLeft: 0,
paddingRight: 16,
},
episodeCardWrapperHorizontal: {
width: Dimensions.get('window').width * 0.85,
marginRight: 16,
},
episodeCardWrapperHorizontalTablet: {
width: Dimensions.get('window').width * 0.4,
},
episodeCardHorizontal: {
borderRadius: 16,
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.35,
shadowRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
height: 200,
position: 'relative',
width: '100%',
},
episodeCardHorizontalTablet: {
height: 180,
},
episodeBackgroundImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
episodeGradient: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 16,
justifyContent: 'flex-end',
},
episodeContent: {
padding: 16,
paddingBottom: 20,
},
episodeNumberHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 11,
fontWeight: '600',
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: 4,
},
episodeTitleHorizontal: {
color: '#fff',
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3,
marginBottom: 8,
lineHeight: 22,
},
episodeDescriptionHorizontal: {
color: 'rgba(255,255,255,0.85)',
fontSize: 13,
lineHeight: 18,
marginBottom: 12,
opacity: 0.9,
},
episodeMetadataRowHorizontal: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
runtimeTextHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
fontWeight: '500',
},
ratingContainerHorizontal: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.4)',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
gap: 3,
},
ratingTextHorizontal: {
color: '#FFD700',
fontSize: 12,
fontWeight: '600',
},
progressBarContainerHorizontal: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(255,255,255,0.2)',
},
progressBarHorizontal: {
height: '100%',
borderRadius: 2,
},
completedBadgeHorizontal: {
position: 'absolute',
bottom: 12,
right: 12,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#fff',
},
moreButton: {
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
},
// Season Selector Styles
seasonSelectorWrapper: { seasonSelectorWrapper: {
marginBottom: 20, marginBottom: 20,
}, },
@ -517,54 +840,4 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: { selectedSeasonButtonText: {
fontWeight: '700', fontWeight: '700',
}, },
progressBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressBar: {
height: '100%',
},
progressTextContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
marginRight: 8,
},
progressText: {
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
},
completedBadge: {
position: 'absolute',
bottom: 8,
right: 8,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
},
runtimeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.85)',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
},
runtimeText: {
fontSize: 13,
fontWeight: '600',
marginLeft: 4,
},
}); });

View file

@ -35,6 +35,7 @@ export interface AppSettings {
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
enableInternalProviders: boolean; // Toggle for internal providers like HDRezka enableInternalProviders: boolean; // Toggle for internal providers like HDRezka
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
@ -52,6 +53,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
logoSourcePreference: 'metahub', // Default to Metahub as first source logoSourcePreference: 'metahub', // Default to Metahub as first source
tmdbLanguagePreference: 'en', // Default to English tmdbLanguagePreference: 'en', // Default to English
enableInternalProviders: true, // Enable internal providers by default enableInternalProviders: true, // Enable internal providers by default
episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout
}; };
const SETTINGS_STORAGE_KEY = 'app_settings'; const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { BlurView } from 'expo-blur'; import { BlurView as ExpoBlurView } from 'expo-blur';
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
import Constants, { ExecutionEnvironment } from 'expo-constants';
import axios from 'axios'; import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
blurOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
},
androidBlurContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
androidBlur: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
androidFallbackBlur: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'black',
},
}); });
const AddonsScreen = () => { const AddonsScreen = () => {
@ -1233,7 +1265,24 @@ const AddonsScreen = () => {
setAddonDetails(null); setAddonDetails(null);
}} }}
> >
<BlurView intensity={80} style={styles.modalContainer} tint="dark"> <View style={styles.modalContainer}>
{Platform.OS === 'ios' ? (
<ExpoBlurView intensity={80} style={styles.blurOverlay} tint="dark" />
) : (
Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? (
<View style={[styles.androidBlurContainer, styles.androidFallbackBlur]} />
) : (
<View style={styles.androidBlurContainer}>
<CommunityBlurView
style={styles.androidBlur}
blurType="dark"
blurAmount={8}
overlayColor="rgba(0,0,0,0.4)"
reducedTransparencyFallbackColor="black"
/>
</View>
)
)}
<View style={styles.modalContent}> <View style={styles.modalContent}>
{addonDetails && ( {addonDetails && (
<> <>
@ -1332,7 +1381,7 @@ const AddonsScreen = () => {
</> </>
)} )}
</View> </View>
</BlurView> </View>
</Modal> </Modal>
</SafeAreaView> </SafeAreaView>
); );

View file

@ -367,6 +367,51 @@ const SettingsScreen: React.FC = () => {
icon="palette" icon="palette"
renderControl={ChevronRight} renderControl={ChevronRight}
onPress={() => navigation.navigate('ThemeSettings')} onPress={() => navigation.navigate('ThemeSettings')}
/>
<SettingItem
title="Episode Layout"
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
icon="view-module"
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'vertical' && {
backgroundColor: currentTheme.colors.primary
}
]}
onPress={() => updateSetting('episodeLayoutStyle', 'vertical')}
>
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'vertical' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
]}>Vertical</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'horizontal' && {
backgroundColor: currentTheme.colors.primary
}
]}
onPress={() => updateSetting('episodeLayoutStyle', 'horizontal')}
>
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'horizontal' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
]}>Horizontal</Text>
</TouchableOpacity>
</View>
)}
isLast={true} isLast={true}
/> />
</SettingsCard> </SettingsCard>

View file

@ -26,10 +26,10 @@ import { tmdbService } from '../services/tmdbService';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const TMDBSettingsScreen = () => { const TMDBSettingsScreen = () => {
const navigation = useNavigation(); const navigation = useNavigation();
@ -41,6 +41,7 @@ const TMDBSettingsScreen = () => {
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const apiKeyInputRef = useRef<TextInput>(null); const apiKeyInputRef = useRef<TextInput>(null);
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
logger.log('[TMDBSettingsScreen] Component mounted'); logger.log('[TMDBSettingsScreen] Component mounted');
@ -222,277 +223,66 @@ const TMDBSettingsScreen = () => {
}); });
}; };
const styles = StyleSheet.create({ const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
container: { const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
flex: 1, const headerHeight = headerBaseHeight + topSpacing;
backgroundColor: currentTheme.colors.darkBackground,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: currentTheme.colors.white,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
paddingHorizontal: 16,
paddingBottom: 16,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
color: currentTheme.colors.primary,
fontSize: 16,
fontWeight: '500',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
titleContainer: {
paddingTop: 8,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: currentTheme.colors.white,
marginHorizontal: 16,
marginBottom: 16,
},
switchCard: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
switchTextContainer: {
flex: 1,
marginRight: 12,
},
switchTitle: {
fontSize: 16,
fontWeight: '500',
color: currentTheme.colors.white,
},
switchDescription: {
fontSize: 14,
color: currentTheme.colors.mediumEmphasis,
lineHeight: 20,
},
statusCard: {
flexDirection: 'row',
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
},
statusIconContainer: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '500',
color: currentTheme.colors.white,
marginBottom: 4,
},
statusDescription: {
fontSize: 14,
color: currentTheme.colors.mediumEmphasis,
},
card: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
},
cardTitle: {
fontSize: 16,
fontWeight: '500',
color: currentTheme.colors.white,
marginBottom: 16,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
input: {
flex: 1,
backgroundColor: currentTheme.colors.elevation1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
color: currentTheme.colors.white,
fontSize: 15,
borderWidth: 1,
borderColor: 'transparent',
},
inputFocused: {
borderColor: currentTheme.colors.primary,
},
pasteButton: {
position: 'absolute',
right: 8,
padding: 4,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
button: {
backgroundColor: currentTheme.colors.primary,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
flex: 1,
marginRight: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: currentTheme.colors.error,
marginRight: 0,
marginLeft: 8,
flex: 0,
},
buttonText: {
color: currentTheme.colors.white,
fontWeight: '500',
fontSize: 15,
},
clearButtonText: {
color: currentTheme.colors.error,
},
resultMessage: {
borderRadius: 8,
padding: 12,
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
},
successMessage: {
backgroundColor: currentTheme.colors.success + '1A', // 10% opacity
},
errorMessage: {
backgroundColor: currentTheme.colors.error + '1A', // 10% opacity
},
resultIcon: {
marginRight: 8,
},
resultText: {
flex: 1,
},
successText: {
color: currentTheme.colors.success,
},
errorText: {
color: currentTheme.colors.error,
},
helpLink: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
helpIcon: {
marginRight: 4,
},
helpText: {
color: currentTheme.colors.primary,
fontSize: 14,
},
infoCard: {
backgroundColor: currentTheme.colors.elevation1,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
},
infoIcon: {
marginRight: 8,
marginTop: 2,
},
infoText: {
color: currentTheme.colors.mediumEmphasis,
fontSize: 14,
flex: 1,
lineHeight: 20,
},
});
if (isLoading) { if (isLoading) {
return ( return (
<SafeAreaView style={styles.container}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={styles.loadingText}>Loading Settings...</Text> <Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading Settings...</Text>
</View> </View>
</SafeAreaView> </View>
); );
} }
return ( return (
<SafeAreaView style={styles.container}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.header}> <View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<TouchableOpacity <View style={styles.header}>
style={styles.backButton} <TouchableOpacity
onPress={() => navigation.goBack()} style={styles.backButton}
> onPress={() => navigation.goBack()}
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} /> >
<Text style={styles.backText}>Settings</Text> <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
</TouchableOpacity> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings
</Text>
</View> </View>
<Text style={styles.title}>TMDb Settings</Text>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
> >
<View style={styles.switchCard}> <View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.switchTextContainer}> <View style={styles.switchTextContainer}>
<Text style={styles.switchTitle}>Use Custom TMDb API Key</Text> <Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text>
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
</View> </View>
<Switch <Switch
value={useCustomKey} value={useCustomKey}
onValueChange={toggleUseCustomKey} onValueChange={toggleUseCustomKey}
trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }} trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''} thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={currentTheme.colors.lightGray} ios_backgroundColor={'rgba(255,255,255,0.1)'}
/> />
</View> </View>
<Text style={styles.switchDescription}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
{useCustomKey && ( {useCustomKey && (
<> <>
<View style={styles.statusCard}> <View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons <MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"} name={isKeySet ? "check-circle" : "error-outline"}
size={28} size={28}
@ -500,10 +290,10 @@ const TMDBSettingsScreen = () => {
style={styles.statusIconContainer} style={styles.statusIconContainer}
/> />
<View style={styles.statusTextContainer}> <View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}> <Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
{isKeySet ? "API Key Active" : "API Key Required"} {isKeySet ? "API Key Active" : "API Key Required"}
</Text> </Text>
<Text style={styles.statusDescription}> <Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{isKeySet {isKeySet
? "Your custom TMDb API key is set and active." ? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."} : "Add your TMDb API key below."}
@ -511,19 +301,26 @@ const TMDBSettingsScreen = () => {
</View> </View>
</View> </View>
<View style={styles.card}> <View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={styles.cardTitle}>API Key</Text> <Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<TextInput <TextInput
ref={apiKeyInputRef} ref={apiKeyInputRef}
style={[styles.input, isInputFocused && styles.inputFocused]} style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation1,
color: currentTheme.colors.text,
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
}
]}
value={apiKey} value={apiKey}
onChangeText={(text) => { onChangeText={(text) => {
setApiKey(text); setApiKey(text);
if (testResult) setTestResult(null); if (testResult) setTestResult(null);
}} }}
placeholder="Paste your TMDb API key (v3)" placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumGray} placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
@ -540,18 +337,18 @@ const TMDBSettingsScreen = () => {
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
<TouchableOpacity <TouchableOpacity
style={styles.button} style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey} onPress={saveApiKey}
> >
<Text style={styles.buttonText}>Save API Key</Text> <Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
</TouchableOpacity> </TouchableOpacity>
{isKeySet && ( {isKeySet && (
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.clearButton]} style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey} onPress={clearApiKey}
> >
<Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text> <Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@ -559,7 +356,7 @@ const TMDBSettingsScreen = () => {
{testResult && ( {testResult && (
<View style={[ <View style={[
styles.resultMessage, styles.resultMessage,
testResult.success ? styles.successMessage : styles.errorMessage { backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}> ]}>
<MaterialIcons <MaterialIcons
name={testResult.success ? "check-circle" : "error"} name={testResult.success ? "check-circle" : "error"}
@ -569,7 +366,7 @@ const TMDBSettingsScreen = () => {
/> />
<Text style={[ <Text style={[
styles.resultText, styles.resultText,
testResult.success ? styles.successText : styles.errorText { color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}> ]}>
{testResult.message} {testResult.message}
</Text> </Text>
@ -581,15 +378,15 @@ const TMDBSettingsScreen = () => {
onPress={openTMDBWebsite} onPress={openTMDBWebsite}
> >
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} /> <MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={styles.helpText}> <Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key? How to get a TMDb API key?
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.infoCard}> <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}> <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website. To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website.
Using your own API key gives you dedicated quota and may improve app performance. Using your own API key gives you dedicated quota and may improve app performance.
</Text> </Text>
@ -598,17 +395,226 @@ const TMDBSettingsScreen = () => {
)} )}
{!useCustomKey && ( {!useCustomKey && (
<View style={styles.infoCard}> <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}> <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Currently using the built-in TMDb API key. This key is shared among all users. Currently using the built-in TMDb API key. This key is shared among all users.
For better performance and reliability, consider using your own API key. For better performance and reliability, consider using your own API key.
</Text> </Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>
</SafeAreaView> </View>
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
},
headerContainer: {
paddingHorizontal: 20,
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
fontSize: 16,
fontWeight: '500',
marginLeft: 4,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.3,
paddingLeft: 4,
},
scrollView: {
flex: 1,
zIndex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
switchCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
switchTextContainer: {
flex: 1,
marginRight: 16,
},
switchTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
switchDescription: {
fontSize: 14,
lineHeight: 20,
opacity: 0.8,
},
statusCard: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
statusIconContainer: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
statusDescription: {
fontSize: 14,
opacity: 0.8,
},
card: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 16,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
input: {
flex: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
borderWidth: 2,
},
pasteButton: {
position: 'absolute',
right: 12,
padding: 8,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
button: {
borderRadius: 12,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
flex: 1,
marginRight: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 2,
marginRight: 0,
marginLeft: 8,
flex: 0,
paddingHorizontal: 16,
},
buttonText: {
fontWeight: '600',
fontSize: 15,
},
resultMessage: {
borderRadius: 12,
padding: 16,
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
},
resultIcon: {
marginRight: 12,
},
resultText: {
flex: 1,
fontSize: 14,
fontWeight: '500',
},
helpLink: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
paddingVertical: 8,
},
helpIcon: {
marginRight: 8,
},
helpText: {
fontSize: 14,
fontWeight: '500',
},
infoCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoText: {
fontSize: 14,
flex: 1,
lineHeight: 20,
opacity: 0.8,
},
});
export default TMDBSettingsScreen; export default TMDBSettingsScreen;