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 { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { LinearGradient } from 'expo-linear-gradient';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata';
import { tmdbService } from '../../services/tmdbService';
import { storageService } from '../../services/storageService';
@ -34,6 +36,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
metadata
}) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { width } = useWindowDimensions();
const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark';
@ -159,6 +162,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</View>
<Text
style={[
styles.seasonButtonText,
{ color: currentTheme.colors.mediumEmphasis },
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;
if (episode.still_path) {
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
@ -217,9 +222,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<TouchableOpacity
key={episode.id}
style={[
styles.episodeCard,
isTablet && styles.episodeCardTablet,
{ backgroundColor: currentTheme.colors.darkBackground }
styles.episodeCardVertical,
isTablet && styles.episodeCardVerticalTablet,
{ backgroundColor: currentTheme.colors.elevation2 }
]}
onPress={() => onSelectEpisode(episode)}
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] || [];
return (
@ -308,35 +434,62 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContent,
isTablet && styles.episodeListContentTablet
]}
>
{isTablet ? (
<View style={styles.episodeGrid}>
{currentSeasonEpisodes.map((episode, index) => (
{settings.episodeLayoutStyle === 'horizontal' ? (
// Horizontal Layout (Netflix-style)
<ScrollView
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(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
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
</Animated.View>
))
)}
</ScrollView>
))
)}
</ScrollView>
)}
</Animated.View>
</View>
);
@ -366,18 +519,20 @@ const styles = StyleSheet.create({
episodeList: {
flex: 1,
},
episodeListContent: {
// Vertical Layout Styles
episodeListContentVertical: {
paddingBottom: 20,
},
episodeListContentTablet: {
episodeListContentVerticalTablet: {
paddingHorizontal: 8,
},
episodeGrid: {
episodeGridVertical: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
episodeCard: {
episodeCardVertical: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
@ -385,13 +540,13 @@ const styles = StyleSheet.create({
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.35,
shadowRadius: 12,
shadowOpacity: 0.25,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
borderColor: 'rgba(255,255,255,0.1)',
height: 120,
},
episodeCardTablet: {
episodeCardVerticalTablet: {
width: '48%',
flexDirection: 'column',
height: 120,
@ -410,12 +565,12 @@ const styles = StyleSheet.create({
position: 'absolute',
bottom: 8,
right: 4,
backgroundColor: 'rgba(0,0,0,0.9)',
backgroundColor: 'rgba(0,0,0,0.85)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
borderColor: 'rgba(255,255,255,0.2)',
zIndex: 1,
},
episodeNumberText: {
@ -446,7 +601,7 @@ const styles = StyleSheet.create({
ratingContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.85)',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
@ -461,6 +616,19 @@ const styles = StyleSheet.create({
fontWeight: '700',
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: {
fontSize: 12,
opacity: 0.8,
@ -469,6 +637,161 @@ const styles = StyleSheet.create({
fontSize: 13,
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: {
marginBottom: 20,
},
@ -517,54 +840,4 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: {
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
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
enableInternalProviders: boolean; // Toggle for internal providers like HDRezka
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -52,6 +53,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
logoSourcePreference: 'metahub', // Default to Metahub as first source
tmdbLanguagePreference: 'en', // Default to English
enableInternalProviders: true, // Enable internal providers by default
episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
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 { useTheme } from '../contexts/ThemeContext';
@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row',
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 = () => {
@ -1233,7 +1265,24 @@ const AddonsScreen = () => {
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}>
{addonDetails && (
<>
@ -1332,7 +1381,7 @@ const AddonsScreen = () => {
</>
)}
</View>
</BlurView>
</View>
</Modal>
</SafeAreaView>
);

View file

@ -367,6 +367,51 @@ const SettingsScreen: React.FC = () => {
icon="palette"
renderControl={ChevronRight}
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}
/>
</SettingsCard>

View file

@ -26,10 +26,10 @@ import { tmdbService } from '../services/tmdbService';
import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const TMDBSettingsScreen = () => {
const navigation = useNavigation();
@ -41,6 +41,7 @@ const TMDBSettingsScreen = () => {
const [isInputFocused, setIsInputFocused] = useState(false);
const apiKeyInputRef = useRef<TextInput>(null);
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
useEffect(() => {
logger.log('[TMDBSettingsScreen] Component mounted');
@ -222,277 +223,66 @@ const TMDBSettingsScreen = () => {
});
};
const styles = StyleSheet.create({
container: {
flex: 1,
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,
},
});
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing;
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<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>
</SafeAreaView>
</View>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings
</Text>
</View>
<Text style={styles.title}>TMDb Settings</Text>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.switchCard}>
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<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>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }}
thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''}
ios_backgroundColor={currentTheme.colors.lightGray}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</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 && (
<>
<View style={styles.statusCard}>
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
@ -500,10 +290,10 @@ const TMDBSettingsScreen = () => {
style={styles.statusIconContainer}
/>
<View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}>
<Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
{isKeySet ? "API Key Active" : "API Key Required"}
</Text>
<Text style={styles.statusDescription}>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{isKeySet
? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."}
@ -511,19 +301,26 @@ const TMDBSettingsScreen = () => {
</View>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>API Key</Text>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
<View style={styles.inputContainer}>
<TextInput
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}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumGray}
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
@ -540,18 +337,18 @@ const TMDBSettingsScreen = () => {
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.button}
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={styles.buttonText}>Save API Key</Text>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton]}
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
)}
</View>
@ -559,7 +356,7 @@ const TMDBSettingsScreen = () => {
{testResult && (
<View style={[
styles.resultMessage,
testResult.success ? styles.successMessage : styles.errorMessage
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
@ -569,7 +366,7 @@ const TMDBSettingsScreen = () => {
/>
<Text style={[
styles.resultText,
testResult.success ? styles.successText : styles.errorText
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}>
{testResult.message}
</Text>
@ -581,15 +378,15 @@ const TMDBSettingsScreen = () => {
onPress={openTMDBWebsite}
>
<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?
</Text>
</TouchableOpacity>
</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} />
<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.
Using your own API key gives you dedicated quota and may improve app performance.
</Text>
@ -598,17 +395,226 @@ const TMDBSettingsScreen = () => {
)}
{!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} />
<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.
For better performance and reliability, consider using your own API key.
</Text>
</View>
)}
</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;