From cf03a44fab0ef608b0ad780f2d8beba845a8f79b Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 15:56:00 +0530 Subject: [PATCH 01/38] Refactor DiscoverScreen component by removing unused imports and optimizing structure. Introduce CategorySelector and GenreSelector components for better organization. Update loading state handling and improve empty state rendering. --- src/components/discover/CatalogSection.tsx | 132 +++++ src/components/discover/CatalogsList.tsx | 43 ++ src/components/discover/CategorySelector.tsx | 101 ++++ src/components/discover/ContentItem.tsx | 94 +++ src/components/discover/GenreSelector.tsx | 94 +++ src/constants/discover.ts | 42 ++ src/screens/DiscoverScreen.tsx | 578 ++----------------- src/styles/screens/discoverStyles.ts | 67 +++ 8 files changed, 609 insertions(+), 542 deletions(-) create mode 100644 src/components/discover/CatalogSection.tsx create mode 100644 src/components/discover/CatalogsList.tsx create mode 100644 src/components/discover/CategorySelector.tsx create mode 100644 src/components/discover/ContentItem.tsx create mode 100644 src/components/discover/GenreSelector.tsx create mode 100644 src/constants/discover.ts create mode 100644 src/styles/screens/discoverStyles.ts diff --git a/src/components/discover/CatalogSection.tsx b/src/components/discover/CatalogSection.tsx new file mode 100644 index 0000000..44bffde --- /dev/null +++ b/src/components/discover/CatalogSection.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useMemo } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, FlatList, Dimensions } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { colors } from '../../styles'; +import { GenreCatalog, Category } from '../../constants/discover'; +import { StreamingContent } from '../../services/catalogService'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import ContentItem from './ContentItem'; + +interface CatalogSectionProps { + catalog: GenreCatalog; + selectedCategory: Category; +} + +const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => { + const navigation = useNavigation>(); + const { width } = Dimensions.get('window'); + const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing + + // Only display first 3 items in each section + const displayItems = useMemo(() => + catalog.items.slice(0, 3), + [catalog.items] + ); + + const handleContentPress = useCallback((item: StreamingContent) => { + navigation.navigate('Metadata', { id: item.id, type: item.type }); + }, [navigation]); + + const handleSeeMorePress = useCallback(() => { + navigation.navigate('Catalog', { + id: catalog.genre, + type: selectedCategory.type, + name: `${catalog.genre} ${selectedCategory.name}`, + genreFilter: catalog.genre + }); + }, [navigation, selectedCategory, catalog.genre]); + + const renderItem = useCallback(({ item }: { item: StreamingContent }) => ( + handleContentPress(item)} + width={itemWidth} + /> + ), [handleContentPress, itemWidth]); + + const keyExtractor = useCallback((item: StreamingContent) => item.id, []); + + const ItemSeparator = useCallback(() => ( + + ), []); + + return ( + + + + {catalog.genre} + + + + See All + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginBottom: 32, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + marginBottom: 16, + }, + titleContainer: { + flexDirection: 'column', + }, + titleBar: { + width: 32, + height: 3, + backgroundColor: colors.primary, + marginTop: 6, + borderRadius: 2, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: colors.white, + }, + seeAllButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 6, + paddingHorizontal: 4, + }, + seeAllText: { + color: colors.primary, + fontWeight: '600', + fontSize: 14, + }, +}); + +export default React.memo(CatalogSection); \ No newline at end of file diff --git a/src/components/discover/CatalogsList.tsx b/src/components/discover/CatalogsList.tsx new file mode 100644 index 0000000..5b07495 --- /dev/null +++ b/src/components/discover/CatalogsList.tsx @@ -0,0 +1,43 @@ +import React, { useCallback } from 'react'; +import { FlatList, StyleSheet, Platform } from 'react-native'; +import { GenreCatalog, Category } from '../../constants/discover'; +import CatalogSection from './CatalogSection'; + +interface CatalogsListProps { + catalogs: GenreCatalog[]; + selectedCategory: Category; +} + +const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => { + const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => ( + + ), [selectedCategory]); + + // Memoize list key extractor + const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 8, + }, +}); + +export default React.memo(CatalogsList); \ No newline at end of file diff --git a/src/components/discover/CategorySelector.tsx b/src/components/discover/CategorySelector.tsx new file mode 100644 index 0000000..a5db821 --- /dev/null +++ b/src/components/discover/CategorySelector.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; +import { Category } from '../../constants/discover'; + +interface CategorySelectorProps { + categories: Category[]; + selectedCategory: Category; + onSelectCategory: (category: Category) => void; +} + +const CategorySelector = ({ + categories, + selectedCategory, + onSelectCategory +}: CategorySelectorProps) => { + + const renderCategoryButton = useCallback((category: Category) => { + const isSelected = selectedCategory.id === category.id; + + return ( + onSelectCategory(category)} + activeOpacity={0.7} + > + + + {category.name} + + + ); + }, [selectedCategory, onSelectCategory]); + + return ( + + + {categories.map(renderCategoryButton)} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 20, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.05)', + }, + content: { + flexDirection: 'row', + justifyContent: 'center', + paddingHorizontal: 20, + gap: 16, + }, + categoryButton: { + paddingHorizontal: 20, + paddingVertical: 14, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.05)', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + flex: 1, + maxWidth: 160, + justifyContent: 'center', + shadowColor: colors.black, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 4, + }, + selectedCategoryButton: { + backgroundColor: colors.primary, + }, + categoryText: { + color: colors.mediumGray, + fontWeight: '600', + fontSize: 16, + }, + selectedCategoryText: { + color: colors.white, + fontWeight: '700', + }, +}); + +export default React.memo(CategorySelector); \ No newline at end of file diff --git a/src/components/discover/ContentItem.tsx b/src/components/discover/ContentItem.tsx new file mode 100644 index 0000000..263dad9 --- /dev/null +++ b/src/components/discover/ContentItem.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { colors } from '../../styles'; +import { StreamingContent } from '../../services/catalogService'; + +interface ContentItemProps { + item: StreamingContent; + onPress: () => void; + width?: number; +} + +const ContentItem = ({ item, onPress, width }: ContentItemProps) => { + const { width: screenWidth } = Dimensions.get('window'); + const itemWidth = width || (screenWidth - 48) / 2.2; // Default to 2 items per row with spacing + + return ( + + + + + + {item.name} + + {item.year && ( + {item.year} + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 0, + }, + posterContainer: { + borderRadius: 16, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.03)', + elevation: 5, + shadowColor: colors.black, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + }, + poster: { + aspectRatio: 2/3, + width: '100%', + }, + gradient: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 16, + justifyContent: 'flex-end', + height: '45%', + }, + title: { + fontSize: 15, + fontWeight: '700', + color: colors.white, + marginBottom: 4, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + letterSpacing: 0.3, + }, + year: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, +}); + +export default React.memo(ContentItem); \ No newline at end of file diff --git a/src/components/discover/GenreSelector.tsx b/src/components/discover/GenreSelector.tsx new file mode 100644 index 0000000..7cd4119 --- /dev/null +++ b/src/components/discover/GenreSelector.tsx @@ -0,0 +1,94 @@ +import React, { useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +import { colors } from '../../styles'; + +interface GenreSelectorProps { + genres: string[]; + selectedGenre: string; + onSelectGenre: (genre: string) => void; +} + +const GenreSelector = ({ + genres, + selectedGenre, + onSelectGenre +}: GenreSelectorProps) => { + + const renderGenreButton = useCallback((genre: string) => { + const isSelected = selectedGenre === genre; + + return ( + onSelectGenre(genre)} + activeOpacity={0.7} + > + + {genre} + + + ); + }, [selectedGenre, onSelectGenre]); + + return ( + + + {genres.map(renderGenreButton)} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingTop: 20, + paddingBottom: 12, + zIndex: 10, + }, + scrollViewContent: { + paddingHorizontal: 20, + paddingBottom: 8, + }, + genreButton: { + paddingHorizontal: 18, + paddingVertical: 10, + marginRight: 12, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.05)', + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + overflow: 'hidden', + }, + selectedGenreButton: { + backgroundColor: colors.primary, + }, + genreText: { + color: colors.mediumGray, + fontWeight: '500', + fontSize: 14, + }, + selectedGenreText: { + color: colors.white, + fontWeight: '600', + }, +}); + +export default React.memo(GenreSelector); \ No newline at end of file diff --git a/src/constants/discover.ts b/src/constants/discover.ts new file mode 100644 index 0000000..1af9de2 --- /dev/null +++ b/src/constants/discover.ts @@ -0,0 +1,42 @@ +import { MaterialIcons } from '@expo/vector-icons'; +import { StreamingContent } from '../services/catalogService'; + +export interface Category { + id: string; + name: string; + type: 'movie' | 'series' | 'channel' | 'tv'; + icon: keyof typeof MaterialIcons.glyphMap; +} + +export interface GenreCatalog { + genre: string; + items: StreamingContent[]; +} + +export const CATEGORIES: Category[] = [ + { id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' }, + { id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' } +]; + +// Common genres for movies and TV shows +export const COMMON_GENRES = [ + 'All', + 'Action', + 'Adventure', + 'Animation', + 'Comedy', + 'Crime', + 'Documentary', + 'Drama', + 'Family', + 'Fantasy', + 'History', + 'Horror', + 'Music', + 'Mystery', + 'Romance', + 'Science Fiction', + 'Thriller', + 'War', + 'Western' +]; \ No newline at end of file diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx index 008d01a..ee6de4f 100644 --- a/src/screens/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -1,507 +1,41 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, StyleSheet, - FlatList, TouchableOpacity, ActivityIndicator, - SafeAreaView, StatusBar, Dimensions, - ScrollView, Platform, - Animated, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { colors } from '../styles'; -import { catalogService, StreamingContent, CatalogContent } from '../services/catalogService'; -import { Image } from 'expo-image'; -import { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated'; -import { LinearGradient } from 'expo-linear-gradient'; +import { catalogService, StreamingContent } from '../services/catalogService'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; -import { BlurView } from 'expo-blur'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -interface Category { - id: string; - name: string; - type: 'movie' | 'series' | 'channel' | 'tv'; - icon: keyof typeof MaterialIcons.glyphMap; -} +// Components +import CategorySelector from '../components/discover/CategorySelector'; +import GenreSelector from '../components/discover/GenreSelector'; +import CatalogsList from '../components/discover/CatalogsList'; -interface GenreCatalog { - genre: string; - items: StreamingContent[]; -} +// Constants and types +import { CATEGORIES, COMMON_GENRES, Category, GenreCatalog } from '../constants/discover'; -const CATEGORIES: Category[] = [ - { id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' }, - { id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' } -]; - -// Common genres for movies and TV shows -const COMMON_GENRES = [ - 'All', - 'Action', - 'Adventure', - 'Animation', - 'Comedy', - 'Crime', - 'Documentary', - 'Drama', - 'Family', - 'Fantasy', - 'History', - 'Horror', - 'Music', - 'Mystery', - 'Romance', - 'Science Fiction', - 'Thriller', - 'War', - 'Western' -]; - -const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; - -// Memoized child components -const CategoryButton = React.memo(({ - category, - isSelected, - onPress -}: { - category: Category; - isSelected: boolean; - onPress: () => void; -}) => { - const styles = useStyles(); - return ( - - - - {category.name} - - - ); -}); - -const GenreButton = React.memo(({ - genre, - isSelected, - onPress -}: { - genre: string; - isSelected: boolean; - onPress: () => void; -}) => { - const styles = useStyles(); - return ( - - - {genre} - - - ); -}); - -const ContentItem = React.memo(({ - item, - onPress -}: { - item: StreamingContent; - onPress: () => void; -}) => { - const styles = useStyles(); - const { width } = Dimensions.get('window'); - const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing - - return ( - - - - - - {item.name} - - {item.year && ( - {item.year} - )} - - - - ); -}); - -const CatalogSection = React.memo(({ - catalog, - selectedCategory, - navigation -}: { - catalog: GenreCatalog; - selectedCategory: Category; - navigation: NavigationProp; -}) => { - const styles = useStyles(); - const { width } = Dimensions.get('window'); - const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing - - const displayItems = useMemo(() => - catalog.items.slice(0, 3), - [catalog.items] - ); - - const handleContentPress = useCallback((item: StreamingContent) => { - navigation.navigate('Metadata', { id: item.id, type: item.type }); - }, [navigation]); - - const renderItem = useCallback(({ item }: { item: StreamingContent }) => ( - handleContentPress(item)} - /> - ), [handleContentPress]); - - const handleSeeMorePress = useCallback(() => { - // Get addon/catalog info from the first item (assuming homogeneity) - const firstItem = catalog.items[0]; - if (!firstItem) return; // Should not happen if section exists - - // We need addonId and catalogId. These aren't directly on StreamingContent. - // We might need to fetch this or adjust the GenreCatalog structure. - // FOR NOW: Assuming CatalogScreen can handle potentially missing addonId/catalogId - // OR: We could pass the *genre* as the name and let CatalogScreen figure it out? - // Let's pass the necessary info if available, assuming StreamingContent might have it - // (Requires checking StreamingContent interface or how it's populated) - - // --- TEMPORARY/PLACEHOLDER --- - // Ideally, GenreCatalog should contain addonId/catalogId for the group. - // If not, CatalogScreen needs modification or we fetch IDs here. - // Let's stick to passing genre and type for now, CatalogScreen logic might suffice? - navigation.navigate('Catalog', { - // Don't pass an addonId since we want to filter by genre across all addons - id: catalog.genre, - type: selectedCategory.type, - name: `${catalog.genre} ${selectedCategory.name}`, - genreFilter: catalog.genre // This will trigger the genre-based filtering logic in CatalogScreen - }); - // --- END TEMPORARY --- - - }, [navigation, selectedCategory, catalog.genre, catalog.items]); - - const keyExtractor = useCallback((item: StreamingContent) => item.id, []); - const ItemSeparator = useCallback(() => , []); - - return ( - - - - {catalog.genre} - - - - See All - - - - - - - ); -}); - -// Extract styles into a hook for better performance with dimensions -const useStyles = () => { - const { width } = Dimensions.get('window'); - - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - backgroundColor: colors.darkBackground, - zIndex: 1, - }, - contentContainer: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - header: { - paddingHorizontal: 20, - justifyContent: 'flex-end', - paddingBottom: 8, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerTitle: { - fontSize: 32, - fontWeight: '800', - color: colors.white, - letterSpacing: 0.3, - }, - searchButton: { - padding: 10, - borderRadius: 24, - backgroundColor: 'rgba(255,255,255,0.08)', - }, - categoryContainer: { - paddingVertical: 20, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.05)', - }, - categoriesContent: { - flexDirection: 'row', - justifyContent: 'center', - paddingHorizontal: 20, - gap: 16, - }, - categoryButton: { - paddingHorizontal: 20, - paddingVertical: 14, - borderRadius: 24, - backgroundColor: 'rgba(255,255,255,0.05)', - flexDirection: 'row', - alignItems: 'center', - gap: 10, - flex: 1, - maxWidth: 160, - justifyContent: 'center', - shadowColor: colors.black, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 4, - }, - selectedCategoryButton: { - backgroundColor: colors.primary, - }, - categoryText: { - color: colors.mediumGray, - fontWeight: '600', - fontSize: 16, - }, - selectedCategoryText: { - color: colors.white, - fontWeight: '700', - }, - genreContainer: { - paddingTop: 20, - paddingBottom: 12, - zIndex: 10, - }, - genresScrollView: { - paddingHorizontal: 20, - paddingBottom: 8, - }, - genreButton: { - paddingHorizontal: 18, - paddingVertical: 10, - marginRight: 12, - borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.05)', - shadowColor: colors.black, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - overflow: 'hidden', - }, - selectedGenreButton: { - backgroundColor: colors.primary, - }, - genreText: { - color: colors.mediumGray, - fontWeight: '500', - fontSize: 14, - }, - selectedGenreText: { - color: colors.white, - fontWeight: '600', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - catalogsContainer: { - paddingVertical: 8, - }, - catalogContainer: { - marginBottom: 32, - }, - catalogHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - marginBottom: 16, - }, - catalogTitleContainer: { - flexDirection: 'column', - }, - catalogTitleBar: { - width: 32, - height: 3, - backgroundColor: colors.primary, - marginTop: 6, - borderRadius: 2, - }, - catalogTitle: { - fontSize: 20, - fontWeight: '700', - color: colors.white, - }, - seeAllButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 6, - paddingHorizontal: 4, - }, - seeAllText: { - color: colors.primary, - fontWeight: '600', - fontSize: 14, - }, - contentItem: { - marginHorizontal: 0, - }, - posterContainer: { - borderRadius: 16, - overflow: 'hidden', - backgroundColor: 'rgba(255,255,255,0.03)', - elevation: 5, - shadowColor: colors.black, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - }, - poster: { - aspectRatio: 2/3, - width: '100%', - }, - posterGradient: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - padding: 16, - justifyContent: 'flex-end', - height: '45%', - }, - contentTitle: { - fontSize: 15, - fontWeight: '700', - color: colors.white, - marginBottom: 4, - textShadowColor: 'rgba(0, 0, 0, 0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - letterSpacing: 0.3, - }, - contentYear: { - fontSize: 12, - color: 'rgba(255,255,255,0.7)', - textShadowColor: 'rgba(0, 0, 0, 0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingTop: 80, - }, - emptyText: { - color: colors.mediumGray, - fontSize: 16, - textAlign: 'center', - paddingHorizontal: 32, - }, - }); -}; +// Styles +import useDiscoverStyles from '../styles/screens/discoverStyles'; const DiscoverScreen = () => { const navigation = useNavigation>(); const [selectedCategory, setSelectedCategory] = useState(CATEGORIES[0]); const [selectedGenre, setSelectedGenre] = useState('All'); const [catalogs, setCatalogs] = useState([]); - const [allContent, setAllContent] = useState([]); const [loading, setLoading] = useState(true); - const styles = useStyles(); + const styles = useDiscoverStyles(); const insets = useSafeAreaInsets(); // Force consistent status bar settings @@ -539,8 +73,6 @@ const DiscoverScreen = () => { content.push(...catalog.items); }); - setAllContent(content); - if (genre === 'All') { // Group by genres when "All" is selected const genreCatalogs: GenreCatalog[] = []; @@ -578,7 +110,6 @@ const DiscoverScreen = () => { } catch (error) { logger.error('Failed to load content:', error); setCatalogs([]); - setAllContent([]); } finally { setLoading(false); } @@ -601,29 +132,25 @@ const DiscoverScreen = () => { navigation.navigate('Search'); }, [navigation]); - // Memoize rendering functions - const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => ( - - ), [selectedCategory, navigation]); - - // Memoize list key extractor - const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []); - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const headerHeight = headerBaseHeight + topSpacing; + const renderEmptyState = () => ( + + + No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'} + + + ); + return ( - {/* Fixed position header background to prevent shifts */} + {/* Fixed position header background */} - {/* Header Section with proper top spacing */} + {/* Header Section */} Discover @@ -641,41 +168,21 @@ const DiscoverScreen = () => { - {/* Rest of the content */} + {/* Content Container */} {/* Categories Section */} - - - {CATEGORIES.map((category) => ( - handleCategoryPress(category)} - /> - ))} - - + {/* Genres Section */} - - - {COMMON_GENRES.map(genre => ( - handleGenrePress(genre)} - /> - ))} - - + {/* Content Section */} {loading ? ( @@ -683,24 +190,11 @@ const DiscoverScreen = () => { ) : catalogs.length > 0 ? ( - - ) : ( - - - No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'} - - - )} + ) : renderEmptyState()} diff --git a/src/styles/screens/discoverStyles.ts b/src/styles/screens/discoverStyles.ts new file mode 100644 index 0000000..1010d2a --- /dev/null +++ b/src/styles/screens/discoverStyles.ts @@ -0,0 +1,67 @@ +import { StyleSheet, Dimensions } from 'react-native'; +import { colors } from '../index'; + +const useDiscoverStyles = () => { + const { width } = Dimensions.get('window'); + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.darkBackground, + zIndex: 1, + }, + contentContainer: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + paddingHorizontal: 20, + justifyContent: 'flex-end', + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerTitle: { + fontSize: 32, + fontWeight: '800', + color: colors.white, + letterSpacing: 0.3, + }, + searchButton: { + padding: 10, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.08)', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingTop: 80, + }, + emptyText: { + color: colors.mediumGray, + fontSize: 16, + textAlign: 'center', + paddingHorizontal: 32, + }, + }); +}; + +export default useDiscoverStyles; \ No newline at end of file From f457ade0711bda1a8a11620b0b04390f5ec56ac5 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 16:02:27 +0530 Subject: [PATCH 02/38] Add search components: SearchBar, RecentSearches, ResultsCarousel, EmptyResults, SkeletonLoader, and SearchResultItem Introduce modular search components to enhance the SearchScreen functionality. Implement a SearchBar for user input, RecentSearches for displaying past queries, ResultsCarousel for showcasing search results, and EmptyResults for no-result scenarios. Include SkeletonLoader for loading states and SearchResultItem for individual result display. Update SearchScreen to utilize these components for improved organization and user experience. --- src/components/search/EmptyResults.tsx | 54 +++ src/components/search/README.md | 34 ++ src/components/search/RecentSearches.tsx | 75 ++++ src/components/search/ResultsCarousel.tsx | 62 ++++ src/components/search/SearchBar.tsx | 84 +++++ src/components/search/SearchResultItem.tsx | 75 ++++ src/components/search/SkeletonLoader.tsx | 108 ++++++ src/components/search/index.ts | 6 + src/screens/SearchScreen.tsx | 396 +++------------------ 9 files changed, 540 insertions(+), 354 deletions(-) create mode 100644 src/components/search/EmptyResults.tsx create mode 100644 src/components/search/README.md create mode 100644 src/components/search/RecentSearches.tsx create mode 100644 src/components/search/ResultsCarousel.tsx create mode 100644 src/components/search/SearchBar.tsx create mode 100644 src/components/search/SearchResultItem.tsx create mode 100644 src/components/search/SkeletonLoader.tsx create mode 100644 src/components/search/index.ts diff --git a/src/components/search/EmptyResults.tsx b/src/components/search/EmptyResults.tsx new file mode 100644 index 0000000..81524e8 --- /dev/null +++ b/src/components/search/EmptyResults.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; + +interface EmptyResultsProps { + isDarkMode?: boolean; +} + +const EmptyResults: React.FC = ({ isDarkMode = true }) => { + return ( + + + + No results found + + + Try different keywords or check your spelling + + + ); +}; + +const styles = StyleSheet.create({ + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + emptyText: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 16, + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, +}); + +export default EmptyResults; \ No newline at end of file diff --git a/src/components/search/README.md b/src/components/search/README.md new file mode 100644 index 0000000..e941eee --- /dev/null +++ b/src/components/search/README.md @@ -0,0 +1,34 @@ +# Search Components + +This directory contains modular components used in the SearchScreen. + +## Components + +- **SearchBar**: Input field with search icon and clear button +- **SkeletonLoader**: Loading animation shown while searching +- **RecentSearches**: Shows recent search history +- **ResultsCarousel**: Horizontal scrolling list of search results by category +- **SearchResultItem**: Individual content card in the search results +- **EmptyResults**: Displayed when no search results are found + +## Usage + +```jsx +import { + SearchBar, + SkeletonLoader, + RecentSearches, + ResultsCarousel, + EmptyResults +} from '../components/search'; + +// Use components in your screen... +``` + +## Refactoring Benefits + +- Improved code organization +- Smaller, reusable components +- Better separation of concerns +- Easier maintenance and testing +- Reduced file size of main screen component \ No newline at end of file diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx new file mode 100644 index 0000000..7308b40 --- /dev/null +++ b/src/components/search/RecentSearches.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; + +interface RecentSearchesProps { + searches: string[]; + onSearchSelect: (search: string) => void; + isDarkMode?: boolean; +} + +const RecentSearches: React.FC = ({ + searches, + onSearchSelect, + isDarkMode = true, +}) => { + if (searches.length === 0) return null; + + return ( + + + Recent Searches + + {searches.map((search, index) => ( + onSearchSelect(search)} + > + + + {search} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + recentSearchesContainer: { + paddingHorizontal: 0, + paddingBottom: 16, + }, + carouselTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.white, + marginBottom: 12, + paddingHorizontal: 16, + }, + recentSearchItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + }, + recentSearchIcon: { + marginRight: 12, + }, + recentSearchText: { + fontSize: 16, + flex: 1, + }, +}); + +export default RecentSearches; \ No newline at end of file diff --git a/src/components/search/ResultsCarousel.tsx b/src/components/search/ResultsCarousel.tsx new file mode 100644 index 0000000..d24d670 --- /dev/null +++ b/src/components/search/ResultsCarousel.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { View, Text, StyleSheet, FlatList } from 'react-native'; +import { colors } from '../../styles'; +import { StreamingContent } from '../../services/catalogService'; +import SearchResultItem from './SearchResultItem'; + +interface ResultsCarouselProps { + title: string; + items: StreamingContent[]; + onItemPress: (item: StreamingContent) => void; + isDarkMode?: boolean; +} + +const ResultsCarousel: React.FC = ({ + title, + items, + onItemPress, + isDarkMode = true, +}) => { + if (items.length === 0) return null; + + return ( + + + {title} ({items.length}) + + ( + + )} + keyExtractor={item => `${item.type}-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + + ); +}; + +const styles = StyleSheet.create({ + carouselContainer: { + marginBottom: 24, + }, + carouselTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.white, + marginBottom: 12, + paddingHorizontal: 16, + }, + horizontalListContent: { + paddingHorizontal: 16, + paddingRight: 8, + }, +}); + +export default ResultsCarousel; \ No newline at end of file diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..889e537 --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../../styles'; + +interface SearchBarProps { + query: string; + onChangeQuery: (text: string) => void; + onClear: () => void; + autoFocus?: boolean; +} + +const SearchBar: React.FC = ({ + query, + onChangeQuery, + onClear, + autoFocus = true +}) => { + return ( + + + + {query.length > 0 && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + searchBar: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 24, + paddingHorizontal: 16, + height: 48, + }, + searchIcon: { + marginRight: 12, + }, + searchInput: { + flex: 1, + fontSize: 16, + height: '100%', + }, + clearButton: { + padding: 4, + }, +}); + +export default SearchBar; \ No newline at end of file diff --git a/src/components/search/SearchResultItem.tsx b/src/components/search/SearchResultItem.tsx new file mode 100644 index 0000000..33dc73a --- /dev/null +++ b/src/components/search/SearchResultItem.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native'; +import { Image } from 'expo-image'; +import { colors } from '../../styles'; +import { StreamingContent } from '../../services/catalogService'; + +const { width } = Dimensions.get('window'); +const HORIZONTAL_ITEM_WIDTH = width * 0.3; +const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; + +const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; + +interface SearchResultItemProps { + item: StreamingContent; + onPress: (item: StreamingContent) => void; + isDarkMode?: boolean; +} + +const SearchResultItem: React.FC = ({ + item, + onPress, + isDarkMode = true +}) => { + return ( + onPress(item)} + > + + + + + {item.name} + + + ); +}; + +const styles = StyleSheet.create({ + horizontalItem: { + width: HORIZONTAL_ITEM_WIDTH, + marginRight: 12, + }, + horizontalItemPosterContainer: { + width: HORIZONTAL_ITEM_WIDTH, + height: HORIZONTAL_POSTER_HEIGHT, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: colors.darkBackground, + marginBottom: 8, + }, + horizontalItemPoster: { + width: '100%', + height: '100%', + }, + horizontalItemTitle: { + fontSize: 14, + fontWeight: '500', + lineHeight: 18, + textAlign: 'left', + }, +}); + +export default SearchResultItem; \ No newline at end of file diff --git a/src/components/search/SkeletonLoader.tsx b/src/components/search/SkeletonLoader.tsx new file mode 100644 index 0000000..0608d32 --- /dev/null +++ b/src/components/search/SkeletonLoader.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { View, StyleSheet, Animated } from 'react-native'; +import { colors } from '../../styles'; + +const POSTER_WIDTH = 90; +const POSTER_HEIGHT = 135; + +const SkeletonLoader: React.FC = () => { + const pulseAnim = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + pulse.start(); + return () => pulse.stop(); + }, [pulseAnim]); + + const opacity = pulseAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.3, 0.7], + }); + + const renderSkeletonItem = () => ( + + + + + + + + + + + ); + + return ( + + {[...Array(5)].map((_, index) => ( + + {index === 0 && ( + + )} + {renderSkeletonItem()} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + skeletonContainer: { + padding: 16, + }, + skeletonVerticalItem: { + flexDirection: 'row', + marginBottom: 16, + }, + skeletonPoster: { + width: POSTER_WIDTH, + height: POSTER_HEIGHT, + borderRadius: 8, + backgroundColor: colors.darkBackground, + }, + skeletonItemDetails: { + flex: 1, + marginLeft: 16, + justifyContent: 'center', + }, + skeletonMetaRow: { + flexDirection: 'row', + gap: 8, + marginTop: 8, + }, + skeletonTitle: { + height: 20, + width: '80%', + marginBottom: 8, + backgroundColor: colors.darkBackground, + borderRadius: 4, + }, + skeletonMeta: { + height: 14, + width: '30%', + backgroundColor: colors.darkBackground, + borderRadius: 4, + }, + skeletonSectionHeader: { + height: 24, + width: '40%', + backgroundColor: colors.darkBackground, + marginBottom: 16, + borderRadius: 4, + }, +}); + +export default SkeletonLoader; \ No newline at end of file diff --git a/src/components/search/index.ts b/src/components/search/index.ts new file mode 100644 index 0000000..0bd40d4 --- /dev/null +++ b/src/components/search/index.ts @@ -0,0 +1,6 @@ +export { default as SearchBar } from './SearchBar'; +export { default as SkeletonLoader } from './SkeletonLoader'; +export { default as RecentSearches } from './RecentSearches'; +export { default as SearchResultItem } from './SearchResultItem'; +export { default as ResultsCarousel } from './ResultsCarousel'; +export { default as EmptyResults } from './EmptyResults'; \ No newline at end of file diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 63459f8..5a4c375 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -3,95 +3,32 @@ import { View, Text, StyleSheet, - TextInput, - FlatList, - TouchableOpacity, - ActivityIndicator, - useColorScheme, + Keyboard, SafeAreaView, StatusBar, - Keyboard, - Dimensions, ScrollView, - Animated as RNAnimated, + Dimensions, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import { MaterialIcons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import debounce from 'lodash/debounce'; import { colors } from '../styles'; import { catalogService, StreamingContent } from '../services/catalogService'; -import { Image } from 'expo-image'; -import debounce from 'lodash/debounce'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import { + SearchBar, + SkeletonLoader, + RecentSearches, + ResultsCarousel, + EmptyResults +} from '../components/search'; -const { width } = Dimensions.get('window'); -const HORIZONTAL_ITEM_WIDTH = width * 0.3; -const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; -const POSTER_WIDTH = 90; -const POSTER_HEIGHT = 135; const RECENT_SEARCHES_KEY = 'recent_searches'; const MAX_RECENT_SEARCHES = 10; -const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; - -const SkeletonLoader = () => { - const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; - - React.useEffect(() => { - const pulse = RNAnimated.loop( - RNAnimated.sequence([ - RNAnimated.timing(pulseAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - RNAnimated.timing(pulseAnim, { - toValue: 0, - duration: 1000, - useNativeDriver: true, - }), - ]) - ); - pulse.start(); - return () => pulse.stop(); - }, [pulseAnim]); - - const opacity = pulseAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0.3, 0.7], - }); - - const renderSkeletonItem = () => ( - - - - - - - - - - - ); - - return ( - - {[...Array(5)].map((_, index) => ( - - {index === 0 && ( - - )} - {renderSkeletonItem()} - - ))} - - ); -}; - -const SearchScreen = () => { +const SearchScreen: React.FC = () => { const navigation = useNavigation>(); const isDarkMode = true; const [query, setQuery] = useState(''); @@ -180,65 +117,13 @@ const SearchScreen = () => { loadRecentSearches(); }; - const renderRecentSearches = () => { - if (!showRecent || recentSearches.length === 0) return null; - - return ( - - - Recent Searches - - {recentSearches.map((search, index) => ( - { - setQuery(search); - Keyboard.dismiss(); - }} - > - - - {search} - - - ))} - - ); + const handleRecentSearchSelect = (search: string) => { + setQuery(search); + Keyboard.dismiss(); }; - const renderHorizontalItem = ({ item }: { item: StreamingContent }) => { - return ( - { - navigation.navigate('Metadata', { id: item.id, type: item.type }); - }} - > - - - - - {item.name} - - - ); + const handleItemPress = (item: StreamingContent) => { + navigation.navigate('Metadata', { id: item.id, type: item.type }); }; const movieResults = useMemo(() => { @@ -265,70 +150,17 @@ const SearchScreen = () => { Search - - - - {query.length > 0 && ( - - - - )} - + {searching ? ( ) : searched && !hasResultsToShow ? ( - - - - No results found - - - Try different keywords or check your spelling - - + ) : ( { keyboardShouldPersistTaps="handled" onScrollBeginDrag={Keyboard.dismiss} > - {!query.trim() && renderRecentSearches()} + {showRecent && ( + + )} {movieResults.length > 0 && ( - - Movies ({movieResults.length}) - `movie-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - + )} {seriesResults.length > 0 && ( - - TV Shows ({seriesResults.length}) - `series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - + )} @@ -389,152 +217,12 @@ const styles = StyleSheet.create({ color: colors.white, letterSpacing: 0.5, }, - searchBar: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 24, - paddingHorizontal: 16, - height: 48, - }, - searchIcon: { - marginRight: 12, - }, - searchInput: { - flex: 1, - fontSize: 16, - height: '100%', - }, - clearButton: { - padding: 4, - }, scrollView: { flex: 1, }, scrollViewContent: { paddingBottom: 20, }, - carouselContainer: { - marginBottom: 24, - }, - carouselTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.white, - marginBottom: 12, - paddingHorizontal: 16, - }, - horizontalListContent: { - paddingHorizontal: 16, - paddingRight: 8, - }, - horizontalItem: { - width: HORIZONTAL_ITEM_WIDTH, - marginRight: 12, - }, - horizontalItemPosterContainer: { - width: HORIZONTAL_ITEM_WIDTH, - height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 8, - overflow: 'hidden', - backgroundColor: colors.darkBackground, - marginBottom: 8, - }, - horizontalItemPoster: { - width: '100%', - height: '100%', - }, - horizontalItemTitle: { - fontSize: 14, - fontWeight: '500', - lineHeight: 18, - textAlign: 'left', - }, - recentSearchesContainer: { - paddingHorizontal: 0, - paddingBottom: 16, - }, - recentSearchItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 16, - }, - recentSearchIcon: { - marginRight: 12, - }, - recentSearchText: { - fontSize: 16, - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: 16, - fontSize: 16, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 32, - }, - emptyText: { - fontSize: 18, - fontWeight: 'bold', - marginTop: 16, - marginBottom: 8, - }, - emptySubtext: { - fontSize: 14, - textAlign: 'center', - lineHeight: 20, - }, - skeletonContainer: { - padding: 16, - }, - skeletonVerticalItem: { - flexDirection: 'row', - marginBottom: 16, - }, - skeletonPoster: { - width: POSTER_WIDTH, - height: POSTER_HEIGHT, - borderRadius: 8, - backgroundColor: colors.darkBackground, - }, - skeletonItemDetails: { - flex: 1, - marginLeft: 16, - justifyContent: 'center', - }, - skeletonMetaRow: { - flexDirection: 'row', - gap: 8, - marginTop: 8, - }, - skeletonTitle: { - height: 20, - width: '80%', - marginBottom: 8, - backgroundColor: colors.darkBackground, - borderRadius: 4, - }, - skeletonMeta: { - height: 14, - width: '30%', - backgroundColor: colors.darkBackground, - borderRadius: 4, - }, - skeletonSectionHeader: { - height: 24, - width: '40%', - backgroundColor: colors.darkBackground, - marginBottom: 16, - borderRadius: 4, - }, }); export default SearchScreen; \ No newline at end of file From 5e81a14ebb936df6ec80ecdedae8ee49c9b61927 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 16:07:38 +0530 Subject: [PATCH 03/38] Remove search components: EmptyResults, SearchBar, RecentSearches, ResultsCarousel, SkeletonLoader, and SearchResultItem. Refactor SearchScreen to integrate their functionality directly, enhancing code organization and reducing component complexity. --- src/components/search/EmptyResults.tsx | 54 --- src/components/search/README.md | 34 -- src/components/search/RecentSearches.tsx | 75 ---- src/components/search/ResultsCarousel.tsx | 62 ---- src/components/search/SearchBar.tsx | 84 ----- src/components/search/SearchResultItem.tsx | 75 ---- src/components/search/SkeletonLoader.tsx | 108 ------ src/components/search/index.ts | 6 - src/screens/SearchScreen.tsx | 396 ++++++++++++++++++--- 9 files changed, 354 insertions(+), 540 deletions(-) delete mode 100644 src/components/search/EmptyResults.tsx delete mode 100644 src/components/search/README.md delete mode 100644 src/components/search/RecentSearches.tsx delete mode 100644 src/components/search/ResultsCarousel.tsx delete mode 100644 src/components/search/SearchBar.tsx delete mode 100644 src/components/search/SearchResultItem.tsx delete mode 100644 src/components/search/SkeletonLoader.tsx delete mode 100644 src/components/search/index.ts diff --git a/src/components/search/EmptyResults.tsx b/src/components/search/EmptyResults.tsx deleted file mode 100644 index 81524e8..0000000 --- a/src/components/search/EmptyResults.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles'; - -interface EmptyResultsProps { - isDarkMode?: boolean; -} - -const EmptyResults: React.FC = ({ isDarkMode = true }) => { - return ( - - - - No results found - - - Try different keywords or check your spelling - - - ); -}; - -const styles = StyleSheet.create({ - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 32, - }, - emptyText: { - fontSize: 18, - fontWeight: 'bold', - marginTop: 16, - marginBottom: 8, - }, - emptySubtext: { - fontSize: 14, - textAlign: 'center', - lineHeight: 20, - }, -}); - -export default EmptyResults; \ No newline at end of file diff --git a/src/components/search/README.md b/src/components/search/README.md deleted file mode 100644 index e941eee..0000000 --- a/src/components/search/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Search Components - -This directory contains modular components used in the SearchScreen. - -## Components - -- **SearchBar**: Input field with search icon and clear button -- **SkeletonLoader**: Loading animation shown while searching -- **RecentSearches**: Shows recent search history -- **ResultsCarousel**: Horizontal scrolling list of search results by category -- **SearchResultItem**: Individual content card in the search results -- **EmptyResults**: Displayed when no search results are found - -## Usage - -```jsx -import { - SearchBar, - SkeletonLoader, - RecentSearches, - ResultsCarousel, - EmptyResults -} from '../components/search'; - -// Use components in your screen... -``` - -## Refactoring Benefits - -- Improved code organization -- Smaller, reusable components -- Better separation of concerns -- Easier maintenance and testing -- Reduced file size of main screen component \ No newline at end of file diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx deleted file mode 100644 index 7308b40..0000000 --- a/src/components/search/RecentSearches.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles'; - -interface RecentSearchesProps { - searches: string[]; - onSearchSelect: (search: string) => void; - isDarkMode?: boolean; -} - -const RecentSearches: React.FC = ({ - searches, - onSearchSelect, - isDarkMode = true, -}) => { - if (searches.length === 0) return null; - - return ( - - - Recent Searches - - {searches.map((search, index) => ( - onSearchSelect(search)} - > - - - {search} - - - ))} - - ); -}; - -const styles = StyleSheet.create({ - recentSearchesContainer: { - paddingHorizontal: 0, - paddingBottom: 16, - }, - carouselTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.white, - marginBottom: 12, - paddingHorizontal: 16, - }, - recentSearchItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 16, - }, - recentSearchIcon: { - marginRight: 12, - }, - recentSearchText: { - fontSize: 16, - flex: 1, - }, -}); - -export default RecentSearches; \ No newline at end of file diff --git a/src/components/search/ResultsCarousel.tsx b/src/components/search/ResultsCarousel.tsx deleted file mode 100644 index d24d670..0000000 --- a/src/components/search/ResultsCarousel.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet, FlatList } from 'react-native'; -import { colors } from '../../styles'; -import { StreamingContent } from '../../services/catalogService'; -import SearchResultItem from './SearchResultItem'; - -interface ResultsCarouselProps { - title: string; - items: StreamingContent[]; - onItemPress: (item: StreamingContent) => void; - isDarkMode?: boolean; -} - -const ResultsCarousel: React.FC = ({ - title, - items, - onItemPress, - isDarkMode = true, -}) => { - if (items.length === 0) return null; - - return ( - - - {title} ({items.length}) - - ( - - )} - keyExtractor={item => `${item.type}-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - ); -}; - -const styles = StyleSheet.create({ - carouselContainer: { - marginBottom: 24, - }, - carouselTitle: { - fontSize: 18, - fontWeight: '700', - color: colors.white, - marginBottom: 12, - paddingHorizontal: 16, - }, - horizontalListContent: { - paddingHorizontal: 16, - paddingRight: 8, - }, -}); - -export default ResultsCarousel; \ No newline at end of file diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx deleted file mode 100644 index 889e537..0000000 --- a/src/components/search/SearchBar.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles'; - -interface SearchBarProps { - query: string; - onChangeQuery: (text: string) => void; - onClear: () => void; - autoFocus?: boolean; -} - -const SearchBar: React.FC = ({ - query, - onChangeQuery, - onClear, - autoFocus = true -}) => { - return ( - - - - {query.length > 0 && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - searchBar: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 24, - paddingHorizontal: 16, - height: 48, - }, - searchIcon: { - marginRight: 12, - }, - searchInput: { - flex: 1, - fontSize: 16, - height: '100%', - }, - clearButton: { - padding: 4, - }, -}); - -export default SearchBar; \ No newline at end of file diff --git a/src/components/search/SearchResultItem.tsx b/src/components/search/SearchResultItem.tsx deleted file mode 100644 index 33dc73a..0000000 --- a/src/components/search/SearchResultItem.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native'; -import { Image } from 'expo-image'; -import { colors } from '../../styles'; -import { StreamingContent } from '../../services/catalogService'; - -const { width } = Dimensions.get('window'); -const HORIZONTAL_ITEM_WIDTH = width * 0.3; -const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; - -const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; - -interface SearchResultItemProps { - item: StreamingContent; - onPress: (item: StreamingContent) => void; - isDarkMode?: boolean; -} - -const SearchResultItem: React.FC = ({ - item, - onPress, - isDarkMode = true -}) => { - return ( - onPress(item)} - > - - - - - {item.name} - - - ); -}; - -const styles = StyleSheet.create({ - horizontalItem: { - width: HORIZONTAL_ITEM_WIDTH, - marginRight: 12, - }, - horizontalItemPosterContainer: { - width: HORIZONTAL_ITEM_WIDTH, - height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 8, - overflow: 'hidden', - backgroundColor: colors.darkBackground, - marginBottom: 8, - }, - horizontalItemPoster: { - width: '100%', - height: '100%', - }, - horizontalItemTitle: { - fontSize: 14, - fontWeight: '500', - lineHeight: 18, - textAlign: 'left', - }, -}); - -export default SearchResultItem; \ No newline at end of file diff --git a/src/components/search/SkeletonLoader.tsx b/src/components/search/SkeletonLoader.tsx deleted file mode 100644 index 0608d32..0000000 --- a/src/components/search/SkeletonLoader.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { View, StyleSheet, Animated } from 'react-native'; -import { colors } from '../../styles'; - -const POSTER_WIDTH = 90; -const POSTER_HEIGHT = 135; - -const SkeletonLoader: React.FC = () => { - const pulseAnim = React.useRef(new Animated.Value(0)).current; - - React.useEffect(() => { - const pulse = Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 0, - duration: 1000, - useNativeDriver: true, - }), - ]) - ); - pulse.start(); - return () => pulse.stop(); - }, [pulseAnim]); - - const opacity = pulseAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0.3, 0.7], - }); - - const renderSkeletonItem = () => ( - - - - - - - - - - - ); - - return ( - - {[...Array(5)].map((_, index) => ( - - {index === 0 && ( - - )} - {renderSkeletonItem()} - - ))} - - ); -}; - -const styles = StyleSheet.create({ - skeletonContainer: { - padding: 16, - }, - skeletonVerticalItem: { - flexDirection: 'row', - marginBottom: 16, - }, - skeletonPoster: { - width: POSTER_WIDTH, - height: POSTER_HEIGHT, - borderRadius: 8, - backgroundColor: colors.darkBackground, - }, - skeletonItemDetails: { - flex: 1, - marginLeft: 16, - justifyContent: 'center', - }, - skeletonMetaRow: { - flexDirection: 'row', - gap: 8, - marginTop: 8, - }, - skeletonTitle: { - height: 20, - width: '80%', - marginBottom: 8, - backgroundColor: colors.darkBackground, - borderRadius: 4, - }, - skeletonMeta: { - height: 14, - width: '30%', - backgroundColor: colors.darkBackground, - borderRadius: 4, - }, - skeletonSectionHeader: { - height: 24, - width: '40%', - backgroundColor: colors.darkBackground, - marginBottom: 16, - borderRadius: 4, - }, -}); - -export default SkeletonLoader; \ No newline at end of file diff --git a/src/components/search/index.ts b/src/components/search/index.ts deleted file mode 100644 index 0bd40d4..0000000 --- a/src/components/search/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as SearchBar } from './SearchBar'; -export { default as SkeletonLoader } from './SkeletonLoader'; -export { default as RecentSearches } from './RecentSearches'; -export { default as SearchResultItem } from './SearchResultItem'; -export { default as ResultsCarousel } from './ResultsCarousel'; -export { default as EmptyResults } from './EmptyResults'; \ No newline at end of file diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 5a4c375..63459f8 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -3,32 +3,95 @@ import { View, Text, StyleSheet, - Keyboard, + TextInput, + FlatList, + TouchableOpacity, + ActivityIndicator, + useColorScheme, SafeAreaView, StatusBar, - ScrollView, + Keyboard, Dimensions, + ScrollView, + Animated as RNAnimated, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import debounce from 'lodash/debounce'; +import { MaterialIcons } from '@expo/vector-icons'; import { colors } from '../styles'; import { catalogService, StreamingContent } from '../services/catalogService'; +import { Image } from 'expo-image'; +import debounce from 'lodash/debounce'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; -import { - SearchBar, - SkeletonLoader, - RecentSearches, - ResultsCarousel, - EmptyResults -} from '../components/search'; +const { width } = Dimensions.get('window'); +const HORIZONTAL_ITEM_WIDTH = width * 0.3; +const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; +const POSTER_WIDTH = 90; +const POSTER_HEIGHT = 135; const RECENT_SEARCHES_KEY = 'recent_searches'; const MAX_RECENT_SEARCHES = 10; -const SearchScreen: React.FC = () => { +const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; + +const SkeletonLoader = () => { + const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; + + React.useEffect(() => { + const pulse = RNAnimated.loop( + RNAnimated.sequence([ + RNAnimated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + RNAnimated.timing(pulseAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + pulse.start(); + return () => pulse.stop(); + }, [pulseAnim]); + + const opacity = pulseAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.3, 0.7], + }); + + const renderSkeletonItem = () => ( + + + + + + + + + + + ); + + return ( + + {[...Array(5)].map((_, index) => ( + + {index === 0 && ( + + )} + {renderSkeletonItem()} + + ))} + + ); +}; + +const SearchScreen = () => { const navigation = useNavigation>(); const isDarkMode = true; const [query, setQuery] = useState(''); @@ -117,13 +180,65 @@ const SearchScreen: React.FC = () => { loadRecentSearches(); }; - const handleRecentSearchSelect = (search: string) => { - setQuery(search); - Keyboard.dismiss(); + const renderRecentSearches = () => { + if (!showRecent || recentSearches.length === 0) return null; + + return ( + + + Recent Searches + + {recentSearches.map((search, index) => ( + { + setQuery(search); + Keyboard.dismiss(); + }} + > + + + {search} + + + ))} + + ); }; - const handleItemPress = (item: StreamingContent) => { - navigation.navigate('Metadata', { id: item.id, type: item.type }); + const renderHorizontalItem = ({ item }: { item: StreamingContent }) => { + return ( + { + navigation.navigate('Metadata', { id: item.id, type: item.type }); + }} + > + + + + + {item.name} + + + ); }; const movieResults = useMemo(() => { @@ -150,17 +265,70 @@ const SearchScreen: React.FC = () => { Search - + + + + {query.length > 0 && ( + + + + )} + {searching ? ( ) : searched && !hasResultsToShow ? ( - + + + + No results found + + + Try different keywords or check your spelling + + ) : ( { keyboardShouldPersistTaps="handled" onScrollBeginDrag={Keyboard.dismiss} > - {showRecent && ( - - )} + {!query.trim() && renderRecentSearches()} {movieResults.length > 0 && ( - + + Movies ({movieResults.length}) + `movie-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + )} {seriesResults.length > 0 && ( - + + TV Shows ({seriesResults.length}) + `series-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + )} @@ -217,12 +389,152 @@ const styles = StyleSheet.create({ color: colors.white, letterSpacing: 0.5, }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 24, + paddingHorizontal: 16, + height: 48, + }, + searchIcon: { + marginRight: 12, + }, + searchInput: { + flex: 1, + fontSize: 16, + height: '100%', + }, + clearButton: { + padding: 4, + }, scrollView: { flex: 1, }, scrollViewContent: { paddingBottom: 20, }, + carouselContainer: { + marginBottom: 24, + }, + carouselTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.white, + marginBottom: 12, + paddingHorizontal: 16, + }, + horizontalListContent: { + paddingHorizontal: 16, + paddingRight: 8, + }, + horizontalItem: { + width: HORIZONTAL_ITEM_WIDTH, + marginRight: 12, + }, + horizontalItemPosterContainer: { + width: HORIZONTAL_ITEM_WIDTH, + height: HORIZONTAL_POSTER_HEIGHT, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: colors.darkBackground, + marginBottom: 8, + }, + horizontalItemPoster: { + width: '100%', + height: '100%', + }, + horizontalItemTitle: { + fontSize: 14, + fontWeight: '500', + lineHeight: 18, + textAlign: 'left', + }, + recentSearchesContainer: { + paddingHorizontal: 0, + paddingBottom: 16, + }, + recentSearchItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + }, + recentSearchIcon: { + marginRight: 12, + }, + recentSearchText: { + fontSize: 16, + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 32, + }, + emptyText: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 16, + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, + skeletonContainer: { + padding: 16, + }, + skeletonVerticalItem: { + flexDirection: 'row', + marginBottom: 16, + }, + skeletonPoster: { + width: POSTER_WIDTH, + height: POSTER_HEIGHT, + borderRadius: 8, + backgroundColor: colors.darkBackground, + }, + skeletonItemDetails: { + flex: 1, + marginLeft: 16, + justifyContent: 'center', + }, + skeletonMetaRow: { + flexDirection: 'row', + gap: 8, + marginTop: 8, + }, + skeletonTitle: { + height: 20, + width: '80%', + marginBottom: 8, + backgroundColor: colors.darkBackground, + borderRadius: 4, + }, + skeletonMeta: { + height: 14, + width: '30%', + backgroundColor: colors.darkBackground, + borderRadius: 4, + }, + skeletonSectionHeader: { + height: 24, + width: '40%', + backgroundColor: colors.darkBackground, + marginBottom: 16, + borderRadius: 4, + }, }); export default SearchScreen; \ No newline at end of file From 5a64adec225c05441563647e3540429981f2e489 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 17:11:16 +0530 Subject: [PATCH 04/38] Enhance logo loading and error handling in FeaturedContent, MetadataScreen, and SearchScreen components. Introduce state management for logo load errors, improve image prefetching logic, and update UI to fallback to text when logos fail to load. Refactor TMDBService to include detailed logging for image URL construction and fetching processes. --- src/components/home/FeaturedContent.tsx | 37 +- src/screens/MetadataScreen.tsx | 42 +- src/screens/SearchScreen.tsx | 594 ++++++++++++++++++------ src/services/tmdbService.ts | 52 ++- src/utils/logoUtils.ts | 83 ++++ 5 files changed, 663 insertions(+), 145 deletions(-) create mode 100644 src/utils/logoUtils.ts diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 405208d..da73b27 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -28,6 +28,7 @@ import Animated, { } from 'react-native-reanimated'; import { StreamingContent } from '../../services/catalogService'; import { SkeletonFeatured } from './SkeletonLoaders'; +import { isValidMetahubLogo, hasValidLogoFormat } from '../../utils/logoUtils'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -45,6 +46,8 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const [logoUrl, setLogoUrl] = useState(null); const [bannerUrl, setBannerUrl] = useState(null); const prevContentIdRef = useRef(null); + // Add state for tracking logo load errors + const [logoLoadError, setLogoLoadError] = useState(false); // Animation values const posterOpacity = useSharedValue(0); @@ -74,15 +77,37 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat if (imageCache[url]) return true; try { + // For Metahub logos, only do validation if enabled + // Note: Temporarily disable metahub validation until fixed + if (false && url.includes('metahub.space')) { + try { + const isValid = await isValidMetahubLogo(url); + if (!isValid) { + console.warn(`[FeaturedContent] Metahub logo validation failed: ${url}`); + return false; + } + } catch (validationError) { + // If validation fails, still try to load the image + console.warn(`[FeaturedContent] Logo validation error, will try to load anyway: ${url}`, validationError); + } + } + + // Always attempt to prefetch the image regardless of format validation await ExpoImage.prefetch(url); imageCache[url] = true; + console.log(`[FeaturedContent] Successfully preloaded image: ${url}`); return true; } catch (error) { - console.error('Error preloading image:', error); + console.error('[FeaturedContent] Error preloading image:', error); return false; } }; + // Reset logo error state when content changes + useEffect(() => { + setLogoLoadError(false); + }, [featuredContent?.id]); + // Load poster and logo useEffect(() => { if (!featuredContent) return; @@ -124,6 +149,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat duration: 500, easing: Easing.bezier(0.25, 0.1, 0.25, 1) })); + } else { + // If prefetch fails, mark as error to show title text instead + setLogoLoadError(true); + console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${titleLogo}`); } } }; @@ -165,7 +194,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - {featuredContent.logo ? ( + {featuredContent.logo && !logoLoadError ? ( { + console.warn(`[FeaturedContent] Logo failed to load: ${featuredContent.logo}`); + setLogoLoadError(true); + }} /> ) : ( diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 75fa51a..246fdfe 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -56,6 +56,7 @@ import { TMDBService } from '../services/tmdbService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; import { useGenres } from '../contexts/GenreContext'; +import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; const { width, height } = Dimensions.get('window'); @@ -264,6 +265,14 @@ const MetadataScreen = () => { episodeId?: string; } | null>(null); + // Add state to track image load errors + const [logoLoadError, setLogoLoadError] = useState(false); + + // Reset logo load error when metadata changes + useEffect(() => { + setLogoLoadError(false); + }, [metadata?.logo]); + // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { // Trigger appropriate haptic feedback based on action @@ -324,7 +333,7 @@ const MetadataScreen = () => { logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); - // Test if Metahub logo exists with a HEAD request + // For now, skip detailed validation and just check if URL is accessible try { const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { @@ -340,6 +349,8 @@ const MetadataScreen = () => { logo: metahubUrl })); return; // Exit if Metahub logo was found + } else { + logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); } } catch (metahubError) { logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); @@ -367,8 +378,16 @@ const MetadataScreen = () => { logo: logoUrl })); } else { - logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id})`); + // If both Metahub and TMDB fail, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text } + } else { + // If no TMDB ID and Metahub failed, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text } } catch (error) { logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { @@ -385,6 +404,7 @@ const MetadataScreen = () => { - Content ID: ${id} - Content Type: ${type} - Logo URL: ${metadata.logo} + - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} `); } }, [id, type, metadata, setMetadata, imdbId]); @@ -1077,12 +1097,16 @@ const MetadataScreen = () => { - {metadata.logo ? ( + {metadata.logo && !logoLoadError ? ( { + logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} /> ) : ( {metadata.name} @@ -1120,12 +1144,16 @@ const MetadataScreen = () => { - {metadata.logo ? ( + {metadata.logo && !logoLoadError ? ( { + logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} /> ) : ( {metadata.name} @@ -1181,12 +1209,16 @@ const MetadataScreen = () => { {/* Title */} - {metadata.logo ? ( + {metadata.logo && !logoLoadError ? ( { + logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} /> ) : ( {metadata.name} diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 63459f8..4e7591c 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, Text, @@ -14,6 +14,9 @@ import { Dimensions, ScrollView, Animated as RNAnimated, + Pressable, + Platform, + Easing, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -23,9 +26,22 @@ import { catalogService, StreamingContent } from '../services/catalogService'; import { Image } from 'expo-image'; import debounce from 'lodash/debounce'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated'; +import Animated, { + FadeIn, + FadeOut, + SlideInRight, + useAnimatedStyle, + useSharedValue, + withTiming, + interpolate, + withSpring, + withDelay, + ZoomIn +} from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import { BlurView } from 'expo-blur'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; const { width } = Dimensions.get('window'); const HORIZONTAL_ITEM_WIDTH = width * 0.3; @@ -37,6 +53,8 @@ const MAX_RECENT_SEARCHES = 10; const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); + const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; @@ -91,6 +109,72 @@ const SkeletonLoader = () => { ); }; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +// Create a simple, elegant animation component +const SimpleSearchAnimation = () => { + // Simple animation values that work reliably + const spinAnim = React.useRef(new RNAnimated.Value(0)).current; + const fadeAnim = React.useRef(new RNAnimated.Value(0)).current; + + React.useEffect(() => { + // Rotation animation + const spin = RNAnimated.loop( + RNAnimated.timing(spinAnim, { + toValue: 1, + duration: 1500, + easing: Easing.linear, + useNativeDriver: true, + }) + ); + + // Fade animation + const fade = RNAnimated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }); + + // Start animations + spin.start(); + fade.start(); + + // Clean up + return () => { + spin.stop(); + }; + }, [spinAnim, fadeAnim]); + + // Simple rotation interpolation + const spin = spinAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + return ( + + + + + + Searching + + + ); +}; + const SearchScreen = () => { const navigation = useNavigation>(); const isDarkMode = true; @@ -100,6 +184,30 @@ const SearchScreen = () => { const [searched, setSearched] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const [showRecent, setShowRecent] = useState(true); + const inputRef = useRef(null); + const insets = useSafeAreaInsets(); + + // Animation values + const searchBarWidth = useSharedValue(width - 32); + const searchBarOpacity = useSharedValue(1); + const backButtonOpacity = useSharedValue(0); + + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + StatusBar.setBarStyle('light-content'); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + } + }; + + applyStatusBarConfig(); + + // Re-apply on focus + const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); + return unsubscribe; + }, [navigation]); React.useLayoutEffect(() => { navigation.setOptions({ @@ -111,6 +219,55 @@ const SearchScreen = () => { loadRecentSearches(); }, []); + const animatedSearchBarStyle = useAnimatedStyle(() => { + return { + width: searchBarWidth.value, + opacity: searchBarOpacity.value, + }; + }); + + const animatedBackButtonStyle = useAnimatedStyle(() => { + return { + opacity: backButtonOpacity.value, + transform: [ + { + translateX: interpolate( + backButtonOpacity.value, + [0, 1], + [-20, 0] + ) + } + ] + }; + }); + + const handleSearchFocus = () => { + // Animate search bar when focused + searchBarWidth.value = withTiming(width - 80); + backButtonOpacity.value = withTiming(1); + }; + + const handleSearchBlur = () => { + if (!query) { + // Only animate back if query is empty + searchBarWidth.value = withTiming(width - 32); + backButtonOpacity.value = withTiming(0); + } + }; + + const handleBackPress = () => { + Keyboard.dismiss(); + if (query) { + setQuery(''); + setResults([]); + setSearched(false); + setShowRecent(true); + loadRecentSearches(); + } else { + navigation.goBack(); + } + }; + const loadRecentSearches = async () => { try { const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY); @@ -147,7 +304,9 @@ const SearchScreen = () => { try { const searchResults = await catalogService.searchContentCinemeta(searchQuery); setResults(searchResults); - await saveRecentSearch(searchQuery); + if (searchResults.length > 0) { + await saveRecentSearch(searchQuery); + } } catch (error) { logger.error('Search failed:', error); setResults([]); @@ -178,50 +337,66 @@ const SearchScreen = () => { setSearched(false); setShowRecent(true); loadRecentSearches(); + inputRef.current?.focus(); }; const renderRecentSearches = () => { if (!showRecent || recentSearches.length === 0) return null; return ( - - + + Recent Searches {recentSearches.map((search, index) => ( - { setQuery(search); Keyboard.dismiss(); }} + entering={FadeIn.duration(300).delay(index * 50)} > - + {search} - + { + const newRecentSearches = [...recentSearches]; + newRecentSearches.splice(index, 1); + setRecentSearches(newRecentSearches); + AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={styles.recentSearchDeleteButton} + > + + + ))} - + ); }; - const renderHorizontalItem = ({ item }: { item: StreamingContent }) => { + const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => { return ( - { navigation.navigate('Metadata', { id: item.id, type: item.type }); }} + entering={FadeIn.duration(500).delay(index * 100)} + activeOpacity={0.7} > { contentFit="cover" transition={300} /> + + {item.type === 'movie' ? 'MOVIE' : 'SERIES'} + + {item.imdbRating && ( + + + {item.imdbRating} + + )} {item.name} - + {item.year && ( + {item.year} + )} + ); }; @@ -253,148 +440,204 @@ const SearchScreen = () => { return movieResults.length > 0 || seriesResults.length > 0; }, [movieResults, seriesResults]); + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; + const headerHeight = headerBaseHeight + topSpacing + 60; + return ( - + - - Search - - - - {query.length > 0 && ( - + + + {/* Header Section with proper top spacing */} + + Search + + + + {query.length > 0 && ( + + + + )} + + + + {/* Content Container */} + + {searching ? ( + + ) : searched && !hasResultsToShow ? ( + - + + No results found + + + Try different keywords or check your spelling + + + ) : ( + + {!query.trim() && renderRecentSearches()} + + {movieResults.length > 0 && ( + + Movies ({movieResults.length}) + `movie-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + + )} + + {seriesResults.length > 0 && ( + + TV Shows ({seriesResults.length}) + `series-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + + )} + + )} - - {searching ? ( - - ) : searched && !hasResultsToShow ? ( - - - - No results found - - - Try different keywords or check your spelling - - - ) : ( - - {!query.trim() && renderRecentSearches()} - - {movieResults.length > 0 && ( - - Movies ({movieResults.length}) - `movie-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - {seriesResults.length > 0 && ( - - TV Shows ({seriesResults.length}) - `series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - - )} - + ); }; const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.black, + }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.black, + zIndex: 1, + }, + contentContainer: { + flex: 1, + backgroundColor: colors.black, + paddingTop: 0, }, header: { - paddingHorizontal: 16, - paddingTop: 40, - paddingBottom: 12, - backgroundColor: colors.black, - gap: 16, + paddingHorizontal: 20, + justifyContent: 'flex-end', + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, }, headerTitle: { fontSize: 32, fontWeight: '800', color: colors.white, letterSpacing: 0.5, + marginBottom: 12, + }, + searchBarContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 8, + }, + searchBarWrapper: { + flex: 1, }, searchBar: { flexDirection: 'row', alignItems: 'center', - borderRadius: 24, + borderRadius: 12, paddingHorizontal: 16, height: 48, + backgroundColor: colors.darkGray, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + backButton: { + marginRight: 10, + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', }, searchIcon: { marginRight: 12, @@ -403,6 +646,7 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, height: '100%', + color: colors.white, }, clearButton: { padding: 4, @@ -412,6 +656,7 @@ const styles = StyleSheet.create({ }, scrollViewContent: { paddingBottom: 20, + paddingHorizontal: 0, }, carouselContainer: { marginBottom: 24, @@ -424,7 +669,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, }, horizontalListContent: { - paddingHorizontal: 16, + paddingHorizontal: 12, paddingRight: 8, }, horizontalItem: { @@ -434,10 +679,12 @@ const styles = StyleSheet.create({ horizontalItemPosterContainer: { width: HORIZONTAL_ITEM_WIDTH, height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 8, + borderRadius: 12, overflow: 'hidden', backgroundColor: colors.darkBackground, marginBottom: 8, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.05)', }, horizontalItemPoster: { width: '100%', @@ -445,19 +692,30 @@ const styles = StyleSheet.create({ }, horizontalItemTitle: { fontSize: 14, - fontWeight: '500', + fontWeight: '600', lineHeight: 18, textAlign: 'left', + color: colors.white, + }, + yearText: { + fontSize: 12, + color: colors.mediumGray, + marginTop: 2, }, recentSearchesContainer: { - paddingHorizontal: 0, + paddingHorizontal: 16, paddingBottom: 16, + paddingTop: 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.05)', + marginBottom: 8, }, recentSearchItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 16, + marginVertical: 1, }, recentSearchIcon: { marginRight: 12, @@ -465,6 +723,10 @@ const styles = StyleSheet.create({ recentSearchText: { fontSize: 16, flex: 1, + color: colors.white, + }, + recentSearchDeleteButton: { + padding: 4, }, loadingContainer: { flex: 1, @@ -474,6 +736,7 @@ const styles = StyleSheet.create({ loadingText: { marginTop: 16, fontSize: 16, + color: colors.white, }, emptyContainer: { flex: 1, @@ -486,14 +749,20 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginTop: 16, marginBottom: 8, + color: colors.white, }, emptySubtext: { fontSize: 14, textAlign: 'center', lineHeight: 20, + color: colors.lightGray, }, skeletonContainer: { - padding: 16, + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 12, + paddingTop: 16, + justifyContent: 'space-between', }, skeletonVerticalItem: { flexDirection: 'row', @@ -535,6 +804,67 @@ const styles = StyleSheet.create({ marginBottom: 16, borderRadius: 4, }, + itemTypeContainer: { + position: 'absolute', + top: 8, + left: 8, + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + itemTypeText: { + color: colors.white, + fontSize: 8, + fontWeight: '700', + }, + ratingContainer: { + position: 'absolute', + bottom: 8, + right: 8, + backgroundColor: 'rgba(0,0,0,0.7)', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + }, + ratingText: { + color: colors.white, + fontSize: 10, + fontWeight: '700', + marginLeft: 2, + }, + simpleAnimationContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + simpleAnimationContent: { + alignItems: 'center', + }, + spinnerContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 3, + }, + simpleAnimationText: { + color: colors.white, + fontSize: 16, + fontWeight: '600', + }, }); export default SearchScreen; \ No newline at end of file diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index a216196..3a1d2df 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -375,8 +375,16 @@ export class TMDBService { * Get image URL for TMDB images */ getImageUrl(path: string | null, size: 'original' | 'w500' | 'w300' | 'w185' | 'profile' = 'original'): string | null { - if (!path) return null; - return `https://image.tmdb.org/t/p/${size}${path}`; + if (!path) { + logger.warn(`[TMDBService] Cannot construct image URL from null path`); + return null; + } + + const baseImageUrl = 'https://image.tmdb.org/t/p/'; + const fullUrl = `${baseImageUrl}${size}${path}`; + logger.log(`[TMDBService] Constructed image URL: ${fullUrl}`); + + return fullUrl; } /** @@ -562,6 +570,8 @@ export class TMDBService { */ async getMovieImages(movieId: number | string): Promise { try { + logger.log(`[TMDBService] Fetching movie images for TMDB ID: ${movieId}`); + const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -570,6 +580,8 @@ export class TMDBService { }); const images = response.data; + logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for movie ID ${movieId}`); + if (images && images.logos && images.logos.length > 0) { // First prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => @@ -578,6 +590,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enSvgLogo) { + logger.log(`[TMDBService] Found English SVG logo for movie ID ${movieId}: ${enSvgLogo.file_path}`); return this.getImageUrl(enSvgLogo.file_path); } @@ -588,6 +601,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enPngLogo) { + logger.log(`[TMDBService] Found English PNG logo for movie ID ${movieId}: ${enPngLogo.file_path}`); return this.getImageUrl(enPngLogo.file_path); } @@ -596,6 +610,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enLogo) { + logger.log(`[TMDBService] Found English logo for movie ID ${movieId}: ${enLogo.file_path}`); return this.getImageUrl(enLogo.file_path); } @@ -604,6 +619,7 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { + logger.log(`[TMDBService] Found SVG logo for movie ID ${movieId}: ${svgLogo.file_path}`); return this.getImageUrl(svgLogo.file_path); } @@ -612,17 +628,20 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { + logger.log(`[TMDBService] Found PNG logo for movie ID ${movieId}: ${pngLogo.file_path}`); return this.getImageUrl(pngLogo.file_path); } // Last resort: any logo + logger.log(`[TMDBService] Using first available logo for movie ID ${movieId}: ${images.logos[0].file_path}`); return this.getImageUrl(images.logos[0].file_path); } + logger.warn(`[TMDBService] No logos found for movie ID ${movieId}`); return null; // No logos found } catch (error) { // Log error but don't throw, just return null if fetching images fails - logger.error(`Failed to get movie images for ID ${movieId}:`, error); + logger.error(`[TMDBService] Failed to get movie images for ID ${movieId}:`, error); return null; } } @@ -632,6 +651,8 @@ export class TMDBService { */ async getTvShowImages(showId: number | string): Promise { try { + logger.log(`[TMDBService] Fetching TV show images for TMDB ID: ${showId}`); + const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -640,6 +661,8 @@ export class TMDBService { }); const images = response.data; + logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for TV show ID ${showId}`); + if (images && images.logos && images.logos.length > 0) { // First prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => @@ -648,6 +671,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enSvgLogo) { + logger.log(`[TMDBService] Found English SVG logo for TV show ID ${showId}: ${enSvgLogo.file_path}`); return this.getImageUrl(enSvgLogo.file_path); } @@ -658,6 +682,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enPngLogo) { + logger.log(`[TMDBService] Found English PNG logo for TV show ID ${showId}: ${enPngLogo.file_path}`); return this.getImageUrl(enPngLogo.file_path); } @@ -666,6 +691,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enLogo) { + logger.log(`[TMDBService] Found English logo for TV show ID ${showId}: ${enLogo.file_path}`); return this.getImageUrl(enLogo.file_path); } @@ -674,6 +700,7 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { + logger.log(`[TMDBService] Found SVG logo for TV show ID ${showId}: ${svgLogo.file_path}`); return this.getImageUrl(svgLogo.file_path); } @@ -682,17 +709,20 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { + logger.log(`[TMDBService] Found PNG logo for TV show ID ${showId}: ${pngLogo.file_path}`); return this.getImageUrl(pngLogo.file_path); } // Last resort: any logo + logger.log(`[TMDBService] Using first available logo for TV show ID ${showId}: ${images.logos[0].file_path}`); return this.getImageUrl(images.logos[0].file_path); } + logger.warn(`[TMDBService] No logos found for TV show ID ${showId}`); return null; // No logos found } catch (error) { // Log error but don't throw, just return null if fetching images fails - logger.error(`Failed to get TV show images for ID ${showId}:`, error); + logger.error(`[TMDBService] Failed to get TV show images for ID ${showId}:`, error); return null; } } @@ -702,11 +732,21 @@ export class TMDBService { */ async getContentLogo(type: 'movie' | 'tv', id: number | string): Promise { try { - return type === 'movie' + logger.log(`[TMDBService] Getting content logo for ${type} with ID ${id}`); + + const result = type === 'movie' ? await this.getMovieImages(id) : await this.getTvShowImages(id); + + if (result) { + logger.log(`[TMDBService] Successfully retrieved logo for ${type} ID ${id}: ${result}`); + } else { + logger.warn(`[TMDBService] No logo found for ${type} ID ${id}`); + } + + return result; } catch (error) { - logger.error(`Failed to get content logo for ${type} ID ${id}:`, error); + logger.error(`[TMDBService] Failed to get content logo for ${type} ID ${id}:`, error); return null; } } diff --git a/src/utils/logoUtils.ts b/src/utils/logoUtils.ts new file mode 100644 index 0000000..f82700b --- /dev/null +++ b/src/utils/logoUtils.ts @@ -0,0 +1,83 @@ +import { logger } from './logger'; + +/** + * Checks if a URL is a valid Metahub logo by performing a HEAD request + * @param url The Metahub logo URL to check + * @returns True if the logo is valid, false otherwise + */ +export const isValidMetahubLogo = async (url: string): Promise => { + if (!url || !url.includes('metahub.space')) { + return false; + } + + try { + const response = await fetch(url, { method: 'HEAD' }); + + // Check if request was successful + if (!response.ok) { + logger.warn(`[logoUtils] Logo URL returned status ${response.status}: ${url}`); + return false; + } + + // Check file size to detect "Missing Image" placeholders + const contentLength = response.headers.get('content-length'); + const fileSize = contentLength ? parseInt(contentLength, 10) : 0; + + // If content-length header is missing, we can't check file size, so assume it's valid + if (!contentLength) { + logger.warn(`[logoUtils] No content-length header for URL: ${url}`); + return true; // Give it the benefit of the doubt + } + + // If file size is suspiciously small, it might be a "Missing Image" placeholder + // Check for extremely small files (less than 100 bytes) which are definitely placeholders + if (fileSize < 100) { + logger.warn(`[logoUtils] Logo URL returned extremely small file (${fileSize} bytes), likely a placeholder: ${url}`); + return false; + } + + // For file sizes between 100-500 bytes, they might be small legitimate SVG files + // So we'll allow them through + return true; + } catch (error) { + logger.error(`[logoUtils] Error checking logo URL: ${url}`, error); + // Don't fail hard on network errors, let the image component try to load it + return true; + } +}; + +/** + * Utility to determine if a URL is likely to be a valid logo + * @param url The logo URL to check + * @returns True if the URL pattern suggests a valid logo + */ +export const hasValidLogoFormat = (url: string | null): boolean => { + if (!url) return false; + + // Only reject explicit placeholders, otherwise be permissive + if (url.includes('missing') || url.includes('placeholder.') || url.includes('not-found')) { + return false; + } + + return true; // Allow most URLs to pass through +}; + +/** + * Checks if a URL is from Metahub + * @param url The URL to check + * @returns True if the URL is from Metahub + */ +export const isMetahubUrl = (url: string | null): boolean => { + if (!url) return false; + return url.includes('metahub.space'); +}; + +/** + * Checks if a URL is from TMDB + * @param url The URL to check + * @returns True if the URL is from TMDB + */ +export const isTmdbUrl = (url: string | null): boolean => { + if (!url) return false; + return url.includes('themoviedb.org') || url.includes('tmdb.org') || url.includes('image.tmdb.org'); +}; \ No newline at end of file From e686caebb86c76b15b5d38a844fca9aedc5cd7db Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 17:56:47 +0530 Subject: [PATCH 05/38] Add logo source preference setting and enhance logo fetching logic in MetadataScreen Introduce a new setting for logo source preference in the SettingsScreen, allowing users to choose between 'metahub' and 'tmdb' as the primary source for title logos. Update the MetadataScreen to utilize this preference, implementing logic to refresh logos based on the selected source. Enhance error handling and logging during logo fetching to improve user experience and debugging capabilities. --- src/hooks/useSettings.ts | 2 + src/screens/MetadataScreen.tsx | 315 +++++++++++++++++++++++++++------ src/screens/SettingsScreen.tsx | 72 ++++++++ 3 files changed, 337 insertions(+), 52 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 1899269..576ff3a 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -32,6 +32,7 @@ export interface AppSettings { showHeroSection: boolean; featuredContentSource: 'tmdb' | 'catalogs'; selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section + logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos } export const DEFAULT_SETTINGS: AppSettings = { @@ -46,6 +47,7 @@ export const DEFAULT_SETTINGS: AppSettings = { showHeroSection: true, featuredContentSource: 'tmdb', selectedHeroCatalogs: [], // Empty array means all catalogs are selected + logoSourcePreference: 'metahub', // Default to Metahub as first source }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 246fdfe..bcc3dd5 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -57,6 +57,7 @@ import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; import { useGenres } from '../contexts/GenreContext'; import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; +import { useSettings } from '../hooks/useSettings'; const { width, height } = Dimensions.get('window'); @@ -211,6 +212,17 @@ const MetadataScreen = () => { const route = useRoute, string>>(); const navigation = useNavigation>(); const { id, type, episodeId } = route.params; + + // Add settings hook + const { settings } = useSettings(); + + // Add a flag to track if we need to do a forced initial logo refresh + const forcedLogoRefreshDone = useRef(false); + + // Add debug log for settings when component mounts + useEffect(() => { + logger.log(`[MetadataScreen] Component mounted with logo preference setting: ${settings.logoSourcePreference}`); + }, [settings.logoSourcePreference]); const { metadata, @@ -232,6 +244,30 @@ const MetadataScreen = () => { imdbId, } = useMetadata({ id, type }); + // Force an initial logo refresh when component mounts and logo doesn't match preference + useEffect(() => { + if (metadata?.logo && !forcedLogoRefreshDone.current) { + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + + // Check if logo source doesn't match preference + if ((preferenceIsMetahub && !currentLogoIsMetahub) || + (!preferenceIsMetahub && !currentLogoIsTmdb)) { + logger.log(`[MetadataScreen] Initial load: Logo source doesn't match preference. Forcing refresh.`); + + // Clear logo to force a new fetch according to preference + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: undefined + })); + } + + // Mark that we've checked this so we don't endlessly loop + forcedLogoRefreshDone.current = true; + } + }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); + // Get genres from context const { genreMap, loadingGenres } = useGenres(); @@ -273,6 +309,76 @@ const MetadataScreen = () => { setLogoLoadError(false); }, [metadata?.logo]); + // Add a ref to track logo fetch in progress + const logoFetchInProgress = useRef(false); + + // Add a ref to track logo preference changes to prevent infinite loops + const logoRefreshCounter = useRef(0); + const MAX_LOGO_REFRESHES = 2; + + // Force refresh logo when logo preference changes - only when preference actually changes + useEffect(() => { + // Reset the counter when preference actually changes + if (logoRefreshCounter.current === 0) { + logoRefreshCounter.current = 1; // Mark that we've started a refresh cycle + + // Only clear logo if we already have metadata with a logo + if (metadata?.logo) { + // Check if the current logo source doesn't match the preference + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + + // Only refresh if the current logo doesn't match the preference + if ((preferenceIsMetahub && !currentLogoIsMetahub) || + (!preferenceIsMetahub && !currentLogoIsTmdb)) { + logger.log(`[MetadataScreen] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, refreshing`); + + // Prevent endless refreshes + if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { + logoRefreshCounter.current++; + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: undefined + })); + } else { + logger.warn(`[MetadataScreen] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); + } + } else { + logger.log(`[MetadataScreen] Logo source already matches preference, no refresh needed`); + logoRefreshCounter.current = 0; // Reset for future changes + } + } + } else { + logoRefreshCounter.current++; + logger.log(`[MetadataScreen] Logo refresh already in progress (${logoRefreshCounter.current}/${MAX_LOGO_REFRESHES})`); + + // Reset counter after max refreshes to allow future preference changes to work + if (logoRefreshCounter.current >= MAX_LOGO_REFRESHES) { + logger.warn(`[MetadataScreen] Maximum refreshes reached, resetting counter`); + // After a timeout to avoid immediate re-triggering + setTimeout(() => { + logoRefreshCounter.current = 0; + }, 1000); + } + } + }, [settings.logoSourcePreference, metadata?.logo, setMetadata]); + + // Add effect to track when logo source matches preference + useEffect(() => { + if (metadata?.logo) { + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + + if ((preferenceIsMetahub && currentLogoIsMetahub) || + (!preferenceIsMetahub && currentLogoIsTmdb)) { + logger.log('[MetadataScreen] Logo source now matches preference, refresh complete'); + logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal + } + } + }, [metadata?.logo, settings.logoSourcePreference]); + // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { // Trigger appropriate haptic feedback based on action @@ -323,71 +429,171 @@ const MetadataScreen = () => { // Debug log for route params // logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId }); - // Fetch logo immediately for TMDB content + // Fetch logo immediately for TMDB content - with guard against recursive updates useEffect(() => { - if (metadata && !metadata.logo) { + // Guard against infinite loops by checking if we're already fetching + if (metadata && !metadata.logo && !logoFetchInProgress.current) { + console.log('[MetadataScreen] Current settings:', JSON.stringify(settings)); + console.log('[MetadataScreen] Current metadata:', JSON.stringify(metadata, null, 2)); + const fetchLogo = async () => { + // Set fetch in progress flag + logoFetchInProgress.current = true; + try { - // First try to get logo from Metahub - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + // Get logo source preference from settings + const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); + console.log(`[MetadataScreen] Using logo preference: ${logoPreference}, TMDB first: ${logoPreference === 'tmdb'}`); + logger.log(`[MetadataScreen] Logo source preference: ${logoPreference}`); - // For now, skip detailed validation and just check if URL is accessible - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched logo from Metahub: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metahubUrl} - `); - - // Update metadata with Metahub logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: metahubUrl - })); - return; // Exit if Metahub logo was found - } else { - logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); + // First source based on preference + if (logoPreference === 'metahub') { + // Try to get logo from Metahub first + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[MetadataScreen] Successfully fetched logo from Metahub: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metahubUrl} + `); + + // Update metadata with Metahub logo + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: metahubUrl + })); + return; // Exit if Metahub logo was found + } else { + logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); + } + } catch (metahubError) { + logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); } - } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); - } - - // If Metahub fails, try TMDB as fallback - if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; + + // If Metahub fails, try TMDB as fallback + if (id.startsWith('tmdb:')) { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; + + logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); + + const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + logger.log(`[MetadataScreen] Successfully fetched fallback logo from TMDB: + - Content Type: ${tmdbType} + - TMDB ID: ${tmdbId} + - Logo URL: ${logoUrl} + `); + + // Update metadata with TMDB logo + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: logoUrl + })); + } else { + // If both Metahub and TMDB fail, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text + } + } + } else { // TMDB first + // Try to get logo from TMDB first + let tmdbId = null; const tmdbType = type === 'series' ? 'tv' : 'movie'; - logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); + if (id.startsWith('tmdb:')) { + // Direct TMDB ID + tmdbId = id.split(':')[1]; + logger.log(`[MetadataScreen] Content has direct TMDB ID: ${tmdbId}`); + } else if (id.startsWith('tt')) { + // IMDB ID - need to find the corresponding TMDB ID + logger.log(`[MetadataScreen] Content has IMDB ID (${id}), looking up TMDB ID`); + try { + // Use the passed imdbId if available, otherwise use id directly + const imdbIdToUse = imdbId || id; + logger.log(`[MetadataScreen] Using IMDB ID for lookup: ${imdbIdToUse}`); + + tmdbId = await TMDBService.getInstance().findTMDBIdByIMDB(imdbIdToUse); + if (tmdbId) { + logger.log(`[MetadataScreen] Found TMDB ID ${tmdbId} for IMDB ID ${imdbIdToUse}`); + } else { + logger.warn(`[MetadataScreen] Could not find TMDB ID for IMDB ID ${imdbIdToUse}`); + } + } catch (error) { + logger.error(`[MetadataScreen] Error finding TMDB ID for IMDB ID ${id}:`, error); + } + } - const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); - - if (logoUrl) { - logger.log(`[MetadataScreen] Successfully fetched fallback logo from TMDB: - - Content Type: ${tmdbType} - - TMDB ID: ${tmdbId} - - Logo URL: ${logoUrl} - `); + if (tmdbId) { + logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId})`); - // Update metadata with TMDB logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: logoUrl - })); + try { + const tmdbService = TMDBService.getInstance(); + logger.log(`[MetadataScreen] Calling getContentLogo with type=${tmdbType}, id=${tmdbId}`); + + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: + - Content Type: ${tmdbType} + - TMDB ID: ${tmdbId} + - Logo URL: ${logoUrl} + `); + + // Update metadata with TMDB logo + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: logoUrl + })); + return; // Exit if TMDB logo was found + } else { + logger.warn(`[MetadataScreen] No logo found from TMDB for ${type} (ID: ${tmdbId}), trying Metahub`); + } + } catch (error) { + logger.error(`[MetadataScreen] Error fetching TMDB logo for ID ${tmdbId}:`, error); + } } else { - // If both Metahub and TMDB fail, use the title as text instead of a logo - logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); + logger.warn(`[MetadataScreen] No TMDB ID available, falling back to Metahub`); + } + + // If TMDB fails or isn't a TMDB ID, try Metahub as fallback + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metahubUrl} + `); + + // Update metadata with Metahub logo + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: metahubUrl + })); + } else { + // If both TMDB and Metahub fail, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found from either source for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text + } + } catch (metahubError) { + logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); // Leave logo as null/undefined to trigger fallback to text } - } else { - // If no TMDB ID and Metahub failed, use the title as text instead of a logo - logger.warn(`[MetadataScreen] No logo found for ${type} (ID: ${id}), using title text instead`); - - // Leave logo as null/undefined to trigger fallback to text } } catch (error) { logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { @@ -395,10 +601,15 @@ const MetadataScreen = () => { contentId: id, contentType: type }); + } finally { + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; } }; fetchLogo(); + } else if (logoFetchInProgress.current) { + console.log('[MetadataScreen] Logo fetch already in progress, skipping'); } else if (metadata?.logo) { logger.log(`[MetadataScreen] Using existing logo from metadata: - Content ID: ${id} @@ -407,7 +618,7 @@ const MetadataScreen = () => { - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} `); } - }, [id, type, metadata, setMetadata, imdbId]); + }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); // Function to get episode details from episodeId const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 47a5757..7437d32 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -350,6 +350,78 @@ const SettingsScreen: React.FC = () => { renderControl={ChevronRight} onPress={() => navigation.navigate('MDBListSettings')} /> + ( + + { + console.log('Setting logo source preference to Metahub'); + updateSetting('logoSourcePreference', 'metahub'); + console.log('New logo source preference:', 'metahub'); + + // Clear any cached logo data in storage + try { + // This is just to help clear any cached state - the exact implementation may vary + AsyncStorage.removeItem('_last_logos_'); + } catch (e) { + console.error('Error clearing logo cache:', e); + } + + // Show alert that settings have been updated + Alert.alert( + 'Settings Updated', + 'Logo source preference set to Metahub. Changes will apply when you navigate to content.', + [{ text: 'OK' }] + ); + }} + > + Metahub + + { + console.log('Setting logo source preference to TMDB'); + updateSetting('logoSourcePreference', 'tmdb'); + console.log('New logo source preference:', 'tmdb'); + + // Clear any cached logo data in storage + try { + // This is just to help clear any cached state - the exact implementation may vary + AsyncStorage.removeItem('_last_logos_'); + } catch (e) { + console.error('Error clearing logo cache:', e); + } + + // Show alert that settings have been updated + Alert.alert( + 'Settings Updated', + 'Logo source preference set to TMDB. Changes will apply when you navigate to content.', + [{ text: 'OK' }] + ); + }} + > + TMDB + + + )} + /> Date: Sat, 3 May 2025 18:15:54 +0530 Subject: [PATCH 06/38] Add LogoSourceSettings screen and enhance logo fetching in MetadataScreen Introduce a new LogoSourceSettings screen for users to select their logo source preference. Update the MetadataScreen to fetch banner images based on the selected logo source, improving the logic for handling logo refreshes and error states. Enhance the logoUtils with a new function to fetch banners according to user preferences, ensuring a more robust and user-friendly experience. --- src/navigation/AppNavigator.tsx | 17 ++ src/screens/LogoSourceSettings.tsx | 256 +++++++++++++++++++++++++++++ src/screens/MetadataScreen.tsx | 164 ++++++++++++++---- src/screens/SettingsScreen.tsx | 70 +------- src/utils/logoUtils.ts | 87 ++++++++++ 5 files changed, 491 insertions(+), 103 deletions(-) create mode 100644 src/screens/LogoSourceSettings.tsx diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 896d15c..24abbe9 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -35,6 +35,7 @@ import HomeScreenSettings from '../screens/HomeScreenSettings'; import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; import TraktSettingsScreen from '../screens/TraktSettingsScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; +import LogoSourceSettings from '../screens/LogoSourceSettings'; // Stack navigator types export type RootStackParamList = { @@ -90,6 +91,7 @@ export type RootStackParamList = { HeroCatalogs: undefined; TraktSettings: undefined; PlayerSettings: undefined; + LogoSourceSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -823,6 +825,21 @@ const AppNavigator = () => { }, }} /> + diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx new file mode 100644 index 0000000..45b930e --- /dev/null +++ b/src/screens/LogoSourceSettings.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + Switch, + SafeAreaView, + Image, + Alert, + StatusBar, + Platform +} from 'react-native'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { colors } from '../styles/colors'; +import { useSettings } from '../hooks/useSettings'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const LogoSourceSettings = () => { + const { settings, updateSetting } = useSettings(); + const navigation = useNavigation>(); + const insets = useSafeAreaInsets(); + + // Get current preference + const [logoSource, setLogoSource] = useState<'metahub' | 'tmdb'>( + settings.logoSourcePreference || 'metahub' + ); + + // Apply setting and show confirmation + const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { + setLogoSource(source); + updateSetting('logoSourcePreference', source); + + // Clear any cached logo data in storage + try { + AsyncStorage.removeItem('_last_logos_'); + } catch (e) { + console.error('Error clearing logo cache:', e); + } + + // Show confirmation alert + Alert.alert( + 'Settings Updated', + `Logo and background source preference set to ${source === 'metahub' ? 'Metahub' : 'TMDB'}. Changes will apply when you navigate to content.`, + [{ text: 'OK' }] + ); + }; + + // Handle back navigation + const handleBack = () => { + navigation.goBack(); + }; + + return ( + + + + {/* Header */} + + + + + Logo Source + + + + + {/* Description */} + + + Choose the primary source for content logos and background images. This affects the appearance + of titles in the metadata screen. + + + + {/* Options */} + + applyLogoSourceSetting('metahub')} + > + + Metahub + {logoSource === 'metahub' && ( + + )} + + + + Prioritizes high-quality title logos from the Metahub image repository. + Offers good coverage for popular titles. + + + + Example: + + + + + applyLogoSourceSetting('tmdb')} + > + + TMDB + {logoSource === 'tmdb' && ( + + )} + + + + Uses logos from The Movie Database. Often includes more localized and newer logos, + with better coverage for recent content. + + + + Example: + + + + + + {/* Additional Info */} + + + If a logo is not available from your preferred source, the app will automatically fall back to the other source. + If no logo is found, the title text will be shown instead. + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + height: 56, + backgroundColor: colors.elevation2, + }, + backButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + color: colors.white, + fontSize: 20, + fontWeight: '600', + }, + headerRight: { + width: 40, + }, + scrollView: { + flex: 1, + }, + descriptionContainer: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.1)', + }, + description: { + color: colors.text, + fontSize: 16, + lineHeight: 24, + }, + optionsContainer: { + padding: 16, + gap: 16, + }, + optionCard: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + borderWidth: 2, + borderColor: 'transparent', + }, + selectedCard: { + borderColor: colors.primary, + }, + optionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + optionTitle: { + color: colors.white, + fontSize: 18, + fontWeight: '600', + }, + optionDescription: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + marginBottom: 16, + }, + exampleContainer: { + marginTop: 8, + }, + exampleLabel: { + color: colors.mediumEmphasis, + fontSize: 14, + marginBottom: 8, + }, + exampleImage: { + height: 60, + width: '100%', + backgroundColor: 'rgba(0,0,0,0.3)', + borderRadius: 8, + }, + infoBox: { + margin: 16, + padding: 16, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 8, + borderLeftWidth: 4, + borderLeftColor: colors.primary, + }, + infoText: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + }, +}); + +export default LogoSourceSettings; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index bcc3dd5..16e8c93 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -56,7 +56,7 @@ import { TMDBService } from '../services/tmdbService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; import { useGenres } from '../contexts/GenreContext'; -import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; +import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl, fetchBannerWithPreference } from '../utils/logoUtils'; import { useSettings } from '../hooks/useSettings'; const { width, height } = Dimensions.get('window'); @@ -218,6 +218,10 @@ const MetadataScreen = () => { // Add a flag to track if we need to do a forced initial logo refresh const forcedLogoRefreshDone = useRef(false); + + // Add state for custom banner + const [bannerImage, setBannerImage] = useState(null); + const forcedBannerRefreshDone = useRef(false); // Add debug log for settings when component mounts useEffect(() => { @@ -268,6 +272,65 @@ const MetadataScreen = () => { } }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); + // Store found TMDB ID for banner fetching + const [foundTmdbId, setFoundTmdbId] = useState(null); + + // Fetch banner image based on logo source preference + useEffect(() => { + const fetchBanner = async () => { + if (metadata && (!forcedBannerRefreshDone.current || foundTmdbId)) { + // Extract any existing TMDB ID if available + let tmdbId = null; + if (id.startsWith('tmdb:')) { + tmdbId = id.split(':')[1]; + } + + // Use our stored TMDB ID if we have one + const effectiveTmdbId = foundTmdbId || tmdbId || (metadata as any).tmdbId; + + logger.log(`[MetadataScreen] Fetching banner with preference: ${settings.logoSourcePreference}, TMDB ID: ${effectiveTmdbId}`); + + try { + // Use our utility function to get the banner based on preference + const newBanner = await fetchBannerWithPreference( + imdbId, + effectiveTmdbId, + type as 'movie' | 'series', + settings.logoSourcePreference + ); + + if (newBanner) { + logger.log(`[MetadataScreen] Setting new banner: ${newBanner}`); + setBannerImage(newBanner); + } else { + // If no banner found from preferred source, use the existing one from metadata + logger.log(`[MetadataScreen] Using existing banner from metadata: ${metadata.banner}`); + setBannerImage(metadata.banner || metadata.poster); + } + } catch (error) { + logger.error(`[MetadataScreen] Error fetching banner:`, error); + // Use existing banner as fallback + setBannerImage(metadata.banner || metadata.poster); + } + + forcedBannerRefreshDone.current = true; + } + }; + + fetchBanner(); + }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); + + // Reset forced refresh when preference changes + useEffect(() => { + if (forcedBannerRefreshDone.current) { + logger.log(`[MetadataScreen] Logo preference changed, resetting banner refresh flag`); + forcedBannerRefreshDone.current = false; + // Clear the banner image to force a new fetch + setBannerImage(null); + // This will trigger the banner fetch effect to run again + } + }, [settings.logoSourcePreference]); + // Get genres from context const { genreMap, loadingGenres } = useGenres(); @@ -328,11 +391,14 @@ const MetadataScreen = () => { const currentLogoIsMetahub = isMetahubUrl(metadata.logo); const currentLogoIsTmdb = isTmdbUrl(metadata.logo); const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - // Only refresh if the current logo doesn't match the preference - if ((preferenceIsMetahub && !currentLogoIsMetahub) || - (!preferenceIsMetahub && !currentLogoIsTmdb)) { - logger.log(`[MetadataScreen] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, refreshing`); + // Only refresh if the current logo source clearly doesn't match the preference + const needsRefresh = (preferenceIsMetahub && currentLogoIsTmdb) || + (preferenceIsTmdb && currentLogoIsMetahub); + + if (needsRefresh) { + logger.log(`[MetadataScreen] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, triggering one-time refresh`); // Prevent endless refreshes if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { @@ -345,7 +411,7 @@ const MetadataScreen = () => { logger.warn(`[MetadataScreen] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); } } else { - logger.log(`[MetadataScreen] Logo source already matches preference, no refresh needed`); + logger.log(`[MetadataScreen] Logo source already matches preference (${settings.logoSourcePreference}), no refresh needed`); logoRefreshCounter.current = 0; // Reset for future changes } } @@ -370,10 +436,14 @@ const MetadataScreen = () => { const currentLogoIsMetahub = isMetahubUrl(metadata.logo); const currentLogoIsTmdb = isTmdbUrl(metadata.logo); const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - if ((preferenceIsMetahub && currentLogoIsMetahub) || - (!preferenceIsMetahub && currentLogoIsTmdb)) { - logger.log('[MetadataScreen] Logo source now matches preference, refresh complete'); + // Check if current logo source matches preference + const logoSourceMatches = (preferenceIsMetahub && currentLogoIsMetahub) || + (preferenceIsTmdb && currentLogoIsTmdb); + + if (logoSourceMatches) { + logger.log(`[MetadataScreen] Logo source (${currentLogoIsMetahub ? 'Metahub' : 'TMDB'}) now matches preference (${settings.logoSourcePreference}), refresh complete`); logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal } } @@ -468,6 +538,9 @@ const MetadataScreen = () => { ...prevMetadata!, logo: metahubUrl })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; return; // Exit if Metahub logo was found } else { logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); @@ -486,7 +559,7 @@ const MetadataScreen = () => { const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); if (logoUrl) { - logger.log(`[MetadataScreen] Successfully fetched fallback logo from TMDB: + logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: - Content Type: ${tmdbType} - TMDB ID: ${tmdbId} - Logo URL: ${logoUrl} @@ -497,6 +570,10 @@ const MetadataScreen = () => { ...prevMetadata!, logo: logoUrl })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found } else { // If both Metahub and TMDB fail, use the title as text instead of a logo logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); @@ -524,6 +601,9 @@ const MetadataScreen = () => { tmdbId = await TMDBService.getInstance().findTMDBIdByIMDB(imdbIdToUse); if (tmdbId) { logger.log(`[MetadataScreen] Found TMDB ID ${tmdbId} for IMDB ID ${imdbIdToUse}`); + + // Save the TMDB ID for banner fetching + setFoundTmdbId(String(tmdbId)); } else { logger.warn(`[MetadataScreen] Could not find TMDB ID for IMDB ID ${imdbIdToUse}`); } @@ -553,6 +633,9 @@ const MetadataScreen = () => { ...prevMetadata!, logo: logoUrl })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; return; // Exit if TMDB logo was found } else { logger.warn(`[MetadataScreen] No logo found from TMDB for ${type} (ID: ${tmdbId}), trying Metahub`); @@ -565,34 +648,36 @@ const MetadataScreen = () => { } // If TMDB fails or isn't a TMDB ID, try Metahub as fallback - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); - - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metahubUrl} - `); - - // Update metadata with Metahub logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: metahubUrl - })); - } else { - // If both TMDB and Metahub fail, use the title as text instead of a logo - logger.warn(`[MetadataScreen] No logo found from either source for ${type} (ID: ${id}), using title text instead`); + if (imdbId) { + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metahubUrl} + `); + + // Update metadata with Metahub logo + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: metahubUrl + })); + } else { + // If both TMDB and Metahub fail, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found from either source for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text + } + } catch (metahubError) { + logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); // Leave logo as null/undefined to trigger fallback to text } - } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); - - // Leave logo as null/undefined to trigger fallback to text } } } catch (error) { @@ -1400,9 +1485,16 @@ const MetadataScreen = () => { {/* Use Animated.Image directly instead of ImageBackground with imageStyle */} { + logger.warn(`[MetadataScreen] Banner failed to load: ${bannerImage}`); + // If custom banner fails, fall back to original metadata banner + if (bannerImage !== metadata.banner) { + setBannerImage(metadata.banner || metadata.poster); + } + }} /> { /> ( - - { - console.log('Setting logo source preference to Metahub'); - updateSetting('logoSourcePreference', 'metahub'); - console.log('New logo source preference:', 'metahub'); - - // Clear any cached logo data in storage - try { - // This is just to help clear any cached state - the exact implementation may vary - AsyncStorage.removeItem('_last_logos_'); - } catch (e) { - console.error('Error clearing logo cache:', e); - } - - // Show alert that settings have been updated - Alert.alert( - 'Settings Updated', - 'Logo source preference set to Metahub. Changes will apply when you navigate to content.', - [{ text: 'OK' }] - ); - }} - > - Metahub - - { - console.log('Setting logo source preference to TMDB'); - updateSetting('logoSourcePreference', 'tmdb'); - console.log('New logo source preference:', 'tmdb'); - - // Clear any cached logo data in storage - try { - // This is just to help clear any cached state - the exact implementation may vary - AsyncStorage.removeItem('_last_logos_'); - } catch (e) { - console.error('Error clearing logo cache:', e); - } - - // Show alert that settings have been updated - Alert.alert( - 'Settings Updated', - 'Logo source preference set to TMDB. Changes will apply when you navigate to content.', - [{ text: 'OK' }] - ); - }} - > - TMDB - - - )} + renderControl={ChevronRight} + onPress={() => navigation.navigate('LogoSourceSettings')} /> { export const isTmdbUrl = (url: string | null): boolean => { if (!url) return false; return url.includes('themoviedb.org') || url.includes('tmdb.org') || url.includes('image.tmdb.org'); +}; + +/** + * Fetches a banner image based on logo source preference + * @param imdbId The IMDB ID of the content + * @param tmdbId The TMDB ID of the content (if available) + * @param type The content type ('movie' or 'series') + * @param preference The logo source preference ('metahub' or 'tmdb') + * @returns The URL of the banner image, or null if none found + */ +export const fetchBannerWithPreference = async ( + imdbId: string | null, + tmdbId: number | string | null, + type: 'movie' | 'series', + preference: 'metahub' | 'tmdb' +): Promise => { + logger.log(`[logoUtils] Fetching banner with preference ${preference} for ${type} (IMDB: ${imdbId}, TMDB: ${tmdbId})`); + + // Determine which source to try first based on preference + if (preference === 'tmdb') { + // Try TMDB first if it's the preferred source + if (tmdbId) { + try { + const tmdbService = TMDBService.getInstance(); + + // Get backdrop from TMDB + const tmdbType = type === 'series' ? 'tv' : 'movie'; + logger.log(`[logoUtils] Attempting to fetch banner from TMDB for ${tmdbType} (ID: ${tmdbId})`); + + let bannerUrl = null; + if (tmdbType === 'movie') { + const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); + if (movieDetails && movieDetails.backdrop_path) { + bannerUrl = tmdbService.getImageUrl(movieDetails.backdrop_path, 'original'); + logger.log(`[logoUtils] Found backdrop_path: ${movieDetails.backdrop_path}`); + } else { + logger.warn(`[logoUtils] No backdrop_path found in movie details for ID ${tmdbId}`); + } + } else { + const showDetails = await tmdbService.getTVShowDetails(Number(tmdbId)); + if (showDetails && showDetails.backdrop_path) { + bannerUrl = tmdbService.getImageUrl(showDetails.backdrop_path, 'original'); + logger.log(`[logoUtils] Found backdrop_path: ${showDetails.backdrop_path}`); + } else { + logger.warn(`[logoUtils] No backdrop_path found in TV show details for ID ${tmdbId}`); + } + } + + if (bannerUrl) { + logger.log(`[logoUtils] Successfully fetched ${tmdbType} banner from TMDB: ${bannerUrl}`); + return bannerUrl; + } + } catch (error) { + logger.error(`[logoUtils] Error fetching banner from TMDB for ID ${tmdbId}:`, error); + } + + logger.warn(`[logoUtils] No banner found from TMDB for ${type} (ID: ${tmdbId}), falling back to Metahub`); + } else { + logger.warn(`[logoUtils] Cannot fetch from TMDB - no TMDB ID provided, falling back to Metahub`); + } + } + + // Try Metahub if it's preferred or TMDB failed + if (imdbId) { + const metahubUrl = `https://images.metahub.space/background/large/${imdbId}/img`; + + logger.log(`[logoUtils] Attempting to fetch banner from Metahub for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[logoUtils] Successfully fetched banner from Metahub: ${metahubUrl}`); + return metahubUrl; + } else { + logger.warn(`[logoUtils] Metahub banner request failed with status ${response.status}`); + } + } catch (error) { + logger.warn(`[logoUtils] Failed to fetch banner from Metahub:`, error); + } + } else { + logger.warn(`[logoUtils] Cannot fetch from Metahub - no IMDB ID provided`); + } + + // If both sources fail or aren't available, return null + logger.warn(`[logoUtils] No banner found from any source for ${type} (IMDB: ${imdbId}, TMDB: ${tmdbId})`); + return null; }; \ No newline at end of file From e1eb88c9baee9d86bbd99d5d4490f434d9158e8f Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 18:31:56 +0530 Subject: [PATCH 07/38] Implement logo fetching for Breaking Bad in LogoSourceSettings screen Enhance the LogoSourceSettings component to fetch and display logos for the TV show "Breaking Bad" from both TMDB and Metahub. Introduce loading states and error handling for logo retrieval, utilizing the TMDB API for dynamic logo fetching and providing fallback options. Update the UI to reflect the selected logo source preference, improving user experience and visual feedback during loading. --- src/screens/LogoSourceSettings.tsx | 166 ++++++++++++++++++++++++++--- 1 file changed, 153 insertions(+), 13 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 45b930e..30bb1ab 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, @@ -10,7 +10,8 @@ import { Image, Alert, StatusBar, - Platform + Platform, + ActivityIndicator } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; @@ -18,6 +19,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { colors } from '../styles/colors'; import { useSettings } from '../hooks/useSettings'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { TMDBService } from '../services/tmdbService'; +import { logger } from '../utils/logger'; + +// TMDB API key - since the default key might be private in the service, we'll use our own +const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c'; const LogoSourceSettings = () => { const { settings, updateSetting } = useSettings(); @@ -28,6 +34,118 @@ const LogoSourceSettings = () => { const [logoSource, setLogoSource] = useState<'metahub' | 'tmdb'>( settings.logoSourcePreference || 'metahub' ); + + // Add state for example logos + const [tmdbLogo, setTmdbLogo] = useState(null); + const [metahubLogo, setMetahubLogo] = useState(null); + const [loadingLogos, setLoadingLogos] = useState(true); + + // Load example logos on mount + useEffect(() => { + const fetchExampleLogos = async () => { + setLoadingLogos(true); + + try { + const tmdbService = TMDBService.getInstance(); + + // Specifically search for Breaking Bad + const searchResults = await tmdbService.searchTVShow("Breaking Bad"); + + if (searchResults && searchResults.length > 0) { + // Get Breaking Bad (should be the first result) + const breakingBad = searchResults[0]; + const breakingBadId = breakingBad.id; + + logger.log(`[LogoSourceSettings] Found Breaking Bad with TMDB ID: ${breakingBadId}`); + + // Get the external IDs to get IMDB ID + const externalIds = await tmdbService.getShowExternalIds(breakingBadId); + + if (externalIds?.imdb_id) { + const imdbId = externalIds.imdb_id; + logger.log(`[LogoSourceSettings] Breaking Bad IMDB ID: ${imdbId}`); + + // Get TMDB logo using the images endpoint + try { + // Manually fetch images from TMDB API + const apiKey = TMDB_API_KEY; // Use the TMDB API key + const response = await fetch(`https://api.themoviedb.org/3/tv/${breakingBadId}/images?api_key=${apiKey}`); + const imagesData = await response.json(); + + if (imagesData.logos && imagesData.logos.length > 0) { + // Look for English logo first + let logoPath = null; + + // First try to find an English logo + const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => + logo.iso_639_1 === 'en' + ); + if (englishLogo) { + logoPath = englishLogo.file_path; + } else if (imagesData.logos[0]) { + // Fallback to the first logo + logoPath = imagesData.logos[0].file_path; + } + + if (logoPath) { + const tmdbLogoUrl = `https://image.tmdb.org/t/p/original${logoPath}`; + setTmdbLogo(tmdbLogoUrl); + logger.log(`[LogoSourceSettings] Got Breaking Bad TMDB logo: ${tmdbLogoUrl}`); + } else { + // Fallback to hardcoded Breaking Bad TMDB logo + setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); + logger.log(`[LogoSourceSettings] Using fallback Breaking Bad TMDB logo`); + } + } else { + // No logos found in the response + setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); + logger.log(`[LogoSourceSettings] No logos found in TMDB response, using fallback`); + } + } catch (tmdbError) { + logger.error(`[LogoSourceSettings] Error fetching TMDB images:`, tmdbError); + // Fallback to hardcoded Breaking Bad TMDB logo + setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); + } + + // Get Metahub logo + const metahubLogoUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + // Check if Metahub logo exists + try { + const metahubResponse = await fetch(metahubLogoUrl, { method: 'HEAD' }); + if (metahubResponse.ok) { + setMetahubLogo(metahubLogoUrl); + logger.log(`[LogoSourceSettings] Got Breaking Bad Metahub logo: ${metahubLogoUrl}`); + } else { + // Fallback to hardcoded Breaking Bad Metahub logo + setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); + logger.log(`[LogoSourceSettings] Using fallback Breaking Bad Metahub logo`); + } + } catch (metahubErr) { + logger.error(`[LogoSourceSettings] Error checking Metahub logo:`, metahubErr); + // Fallback to hardcoded Breaking Bad Metahub logo + setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); + } + } + } else { + logger.warn(`[LogoSourceSettings] Breaking Bad not found in search results`); + // Use hardcoded Breaking Bad logos + setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); + setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); + } + } catch (err) { + logger.error('[LogoSourceSettings] Error fetching Breaking Bad logos:', err); + + // Use hardcoded Breaking Bad logos + setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); + setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); + } finally { + setLoadingLogos(false); + } + }; + + fetchExampleLogos(); + }, []); // Apply setting and show confirmation const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { @@ -54,6 +172,25 @@ const LogoSourceSettings = () => { navigation.goBack(); }; + // Render logo example with loading state + const renderLogoExample = (url: string | null, isLoading: boolean) => { + if (isLoading) { + return ( + + + + ); + } + + return ( + + ); + }; + return ( @@ -103,11 +240,8 @@ const LogoSourceSettings = () => { Example: - + {renderLogoExample(metahubLogo, loadingLogos)} + Breaking Bad logo from Metahub @@ -132,11 +266,8 @@ const LogoSourceSettings = () => { Example: - + {renderLogoExample(tmdbLogo, loadingLogos)} + Breaking Bad logo from TMDB @@ -235,9 +366,13 @@ const styles = StyleSheet.create({ exampleImage: { height: 60, width: '100%', - backgroundColor: 'rgba(0,0,0,0.3)', + backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 8, }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + }, infoBox: { margin: 16, padding: 16, @@ -251,6 +386,11 @@ const styles = StyleSheet.create({ fontSize: 14, lineHeight: 20, }, + logoSourceLabel: { + color: colors.mediumEmphasis, + fontSize: 12, + marginTop: 4, + }, }); export default LogoSourceSettings; \ No newline at end of file From ba834ed3a8c38d1131ee29ab21e415021a9db531 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:10:27 +0530 Subject: [PATCH 08/38] Enhance logo fetching logic in FeaturedContent and MetadataScreen components Refactor the FeaturedContent component to dynamically fetch logos based on user preferences for TMDB or Metahub, improving error handling and loading states. Update the MetadataScreen to implement a more robust banner fetching mechanism, utilizing both sources with appropriate fallbacks. Introduce loading indicators and ensure seamless user experience during logo and banner retrieval processes. --- src/components/home/FeaturedContent.tsx | 185 +++++++++- src/screens/LogoSourceSettings.tsx | 438 +++++++++++++++++------- src/screens/MetadataScreen.tsx | 312 +++++++++++------ 3 files changed, 696 insertions(+), 239 deletions(-) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index da73b27..f161d8d 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -28,7 +28,10 @@ import Animated, { } from 'react-native-reanimated'; import { StreamingContent } from '../../services/catalogService'; import { SkeletonFeatured } from './SkeletonLoaders'; -import { isValidMetahubLogo, hasValidLogoFormat } from '../../utils/logoUtils'; +import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from '../../utils/logoUtils'; +import { useSettings } from '../../hooks/useSettings'; +import { TMDBService } from '../../services/tmdbService'; +import { logger } from '../../utils/logger'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -43,11 +46,14 @@ const { width, height } = Dimensions.get('window'); const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const navigation = useNavigation>(); + const { settings } = useSettings(); const [logoUrl, setLogoUrl] = useState(null); const [bannerUrl, setBannerUrl] = useState(null); const prevContentIdRef = useRef(null); // Add state for tracking logo load errors const [logoLoadError, setLogoLoadError] = useState(false); + // Add a ref to track logo fetch in progress + const logoFetchInProgress = useRef(false); // Animation values const posterOpacity = useSharedValue(0); @@ -107,13 +113,171 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat useEffect(() => { setLogoLoadError(false); }, [featuredContent?.id]); + + // Fetch logo based on preference + useEffect(() => { + if (!featuredContent || logoFetchInProgress.current) return; + + const fetchLogo = async () => { + // Set fetch in progress flag + logoFetchInProgress.current = true; + + try { + const contentId = featuredContent.id; + + // Get logo source preference from settings + const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set + + // Check if current logo matches preferences + const currentLogo = featuredContent.logo; + if (currentLogo) { + const isCurrentMetahub = isMetahubUrl(currentLogo); + const isCurrentTmdb = isTmdbUrl(currentLogo); + + // If logo already matches preference, use it + if ((logoPreference === 'metahub' && isCurrentMetahub) || + (logoPreference === 'tmdb' && isCurrentTmdb)) { + setLogoUrl(currentLogo); + logoFetchInProgress.current = false; + return; + } + } + + logger.log(`[FeaturedContent] Fetching logo with preference: ${logoPreference}, ID: ${contentId}`); + + // Extract IMDB ID if available + let imdbId = null; + if (featuredContent.id.startsWith('tt')) { + // If the ID itself is an IMDB ID + imdbId = featuredContent.id; + } else if ((featuredContent as any).imdbId) { + // Try to get IMDB ID from the content object if available + imdbId = (featuredContent as any).imdbId; + } + + // Extract TMDB ID if available + let tmdbId = null; + if (contentId.startsWith('tmdb:')) { + tmdbId = contentId.split(':')[1]; + } + + // First source based on preference + if (logoPreference === 'metahub' && imdbId) { + // Try to get logo from Metahub first + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[FeaturedContent] Using Metahub logo: ${metahubUrl}`); + setLogoUrl(metahubUrl); + logoFetchInProgress.current = false; + return; // Exit if Metahub logo was found + } + } catch (error) { + logger.warn(`[FeaturedContent] Failed to fetch Metahub logo:`, error); + } + + // Fall back to TMDB if Metahub fails and we have a TMDB ID + if (tmdbId) { + const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; + try { + const tmdbService = TMDBService.getInstance(); + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + logger.log(`[FeaturedContent] Using fallback TMDB logo: ${logoUrl}`); + setLogoUrl(logoUrl); + } else if (currentLogo) { + // If TMDB fails too, use existing logo if any + setLogoUrl(currentLogo); + } + } catch (error) { + logger.error('[FeaturedContent] Error fetching TMDB logo:', error); + if (currentLogo) setLogoUrl(currentLogo); + } + } else if (currentLogo) { + // Use existing logo if we don't have TMDB ID + setLogoUrl(currentLogo); + } + } else if (logoPreference === 'tmdb') { + // Try to get logo from TMDB first + if (tmdbId) { + const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; + try { + const tmdbService = TMDBService.getInstance(); + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + logger.log(`[FeaturedContent] Using TMDB logo: ${logoUrl}`); + setLogoUrl(logoUrl); + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found + } + } catch (error) { + logger.error('[FeaturedContent] Error fetching TMDB logo:', error); + } + } else if (imdbId) { + // If we have IMDB ID but no TMDB ID, try to find TMDB ID + try { + const tmdbService = TMDBService.getInstance(); + const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); + + if (foundTmdbId) { + const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; + const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString()); + + if (logoUrl) { + logger.log(`[FeaturedContent] Using TMDB logo via IMDB lookup: ${logoUrl}`); + setLogoUrl(logoUrl); + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found + } + } + } catch (error) { + logger.error('[FeaturedContent] Error finding TMDB ID from IMDB:', error); + } + } + + // Fall back to Metahub if TMDB fails and we have an IMDB ID + if (imdbId) { + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[FeaturedContent] Using fallback Metahub logo: ${metahubUrl}`); + setLogoUrl(metahubUrl); + } else if (currentLogo) { + // If Metahub fails too, use existing logo if any + setLogoUrl(currentLogo); + } + } catch (error) { + logger.warn(`[FeaturedContent] Failed to fetch fallback Metahub logo:`, error); + if (currentLogo) setLogoUrl(currentLogo); + } + } else if (currentLogo) { + // Use existing logo if we don't have IMDB ID + setLogoUrl(currentLogo); + } + } + } catch (error) { + logger.error('[FeaturedContent] Error fetching logo:', error); + if (featuredContent?.logo) setLogoUrl(featuredContent.logo); + } finally { + // Clear fetch in progress flag + logoFetchInProgress.current = false; + } + }; + + fetchLogo(); + }, [featuredContent?.id, settings.logoSourcePreference]); // Load poster and logo useEffect(() => { if (!featuredContent) return; const posterUrl = featuredContent.banner || featuredContent.poster; - const titleLogo = featuredContent.logo; const contentId = featuredContent.id; // Reset states for new content @@ -124,9 +288,8 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat prevContentIdRef.current = contentId; - // Set URLs immediately for instant display + // Set poster URL immediately for instant display if (posterUrl) setBannerUrl(posterUrl); - if (titleLogo) setLogoUrl(titleLogo); // Load images in background const loadImages = async () => { @@ -142,8 +305,8 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } // Load logo if available - if (titleLogo) { - const logoSuccess = await preloadImage(titleLogo); + if (logoUrl) { + const logoSuccess = await preloadImage(logoUrl); if (logoSuccess) { logoOpacity.value = withDelay(300, withTiming(1, { duration: 500, @@ -152,13 +315,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } else { // If prefetch fails, mark as error to show title text instead setLogoLoadError(true); - console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${titleLogo}`); + console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`); } } }; loadImages(); - }, [featuredContent?.id]); + }, [featuredContent?.id, logoUrl]); if (!featuredContent) { return ; @@ -194,16 +357,16 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - {featuredContent.logo && !logoLoadError ? ( + {logoUrl && !logoLoadError ? ( { - console.warn(`[FeaturedContent] Logo failed to load: ${featuredContent.logo}`); + console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`); setLogoLoadError(true); }} /> diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 30bb1ab..e27ee9e 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -11,7 +11,7 @@ import { Alert, StatusBar, Platform, - ActivityIndicator + ActivityIndicator, } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; @@ -25,6 +25,52 @@ import { logger } from '../utils/logger'; // TMDB API key - since the default key might be private in the service, we'll use our own const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c'; +// Define example shows with their IMDB IDs and TMDB IDs +const EXAMPLE_SHOWS = [ + { + name: 'Breaking Bad', + imdbId: 'tt0903747', + tmdbId: '1396', + type: 'tv' as const + }, + { + name: 'Friends', + imdbId: 'tt0108778', + tmdbId: '1668', + type: 'tv' as const + }, + { + name: 'Game of Thrones', + imdbId: 'tt0944947', + tmdbId: '1399', + type: 'tv' as const + }, + { + name: 'Stranger Things', + imdbId: 'tt4574334', + tmdbId: '66732', + type: 'tv' as const + }, + { + name: 'Squid Game', + imdbId: 'tt10919420', + tmdbId: '93405', + type: 'tv' as const + }, + { + name: 'Avatar', + imdbId: 'tt0499549', + tmdbId: '19995', + type: 'movie' as const + }, + { + name: 'The Witcher', + imdbId: 'tt5180504', + tmdbId: '71912', + type: 'tv' as const + } +]; + const LogoSourceSettings = () => { const { settings, updateSetting } = useSettings(); const navigation = useNavigation>(); @@ -35,117 +81,125 @@ const LogoSourceSettings = () => { settings.logoSourcePreference || 'metahub' ); - // Add state for example logos + // Make sure logoSource stays in sync with settings + useEffect(() => { + setLogoSource(settings.logoSourcePreference || 'metahub'); + }, [settings.logoSourcePreference]); + + // Selected example show + const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]); + + // Add state for example logos and banners const [tmdbLogo, setTmdbLogo] = useState(null); const [metahubLogo, setMetahubLogo] = useState(null); + const [tmdbBanner, setTmdbBanner] = useState(null); + const [metahubBanner, setMetahubBanner] = useState(null); const [loadingLogos, setLoadingLogos] = useState(true); - // Load example logos on mount + // Load example logos for selected show useEffect(() => { - const fetchExampleLogos = async () => { - setLoadingLogos(true); - - try { - const tmdbService = TMDBService.getInstance(); - - // Specifically search for Breaking Bad - const searchResults = await tmdbService.searchTVShow("Breaking Bad"); - - if (searchResults && searchResults.length > 0) { - // Get Breaking Bad (should be the first result) - const breakingBad = searchResults[0]; - const breakingBadId = breakingBad.id; - - logger.log(`[LogoSourceSettings] Found Breaking Bad with TMDB ID: ${breakingBadId}`); - - // Get the external IDs to get IMDB ID - const externalIds = await tmdbService.getShowExternalIds(breakingBadId); - - if (externalIds?.imdb_id) { - const imdbId = externalIds.imdb_id; - logger.log(`[LogoSourceSettings] Breaking Bad IMDB ID: ${imdbId}`); - - // Get TMDB logo using the images endpoint - try { - // Manually fetch images from TMDB API - const apiKey = TMDB_API_KEY; // Use the TMDB API key - const response = await fetch(`https://api.themoviedb.org/3/tv/${breakingBadId}/images?api_key=${apiKey}`); - const imagesData = await response.json(); - - if (imagesData.logos && imagesData.logos.length > 0) { - // Look for English logo first - let logoPath = null; - - // First try to find an English logo - const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => - logo.iso_639_1 === 'en' - ); - if (englishLogo) { - logoPath = englishLogo.file_path; - } else if (imagesData.logos[0]) { - // Fallback to the first logo - logoPath = imagesData.logos[0].file_path; - } - - if (logoPath) { - const tmdbLogoUrl = `https://image.tmdb.org/t/p/original${logoPath}`; - setTmdbLogo(tmdbLogoUrl); - logger.log(`[LogoSourceSettings] Got Breaking Bad TMDB logo: ${tmdbLogoUrl}`); - } else { - // Fallback to hardcoded Breaking Bad TMDB logo - setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); - logger.log(`[LogoSourceSettings] Using fallback Breaking Bad TMDB logo`); - } - } else { - // No logos found in the response - setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); - logger.log(`[LogoSourceSettings] No logos found in TMDB response, using fallback`); - } - } catch (tmdbError) { - logger.error(`[LogoSourceSettings] Error fetching TMDB images:`, tmdbError); - // Fallback to hardcoded Breaking Bad TMDB logo - setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); - } - - // Get Metahub logo - const metahubLogoUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - - // Check if Metahub logo exists - try { - const metahubResponse = await fetch(metahubLogoUrl, { method: 'HEAD' }); - if (metahubResponse.ok) { - setMetahubLogo(metahubLogoUrl); - logger.log(`[LogoSourceSettings] Got Breaking Bad Metahub logo: ${metahubLogoUrl}`); - } else { - // Fallback to hardcoded Breaking Bad Metahub logo - setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); - logger.log(`[LogoSourceSettings] Using fallback Breaking Bad Metahub logo`); - } - } catch (metahubErr) { - logger.error(`[LogoSourceSettings] Error checking Metahub logo:`, metahubErr); - // Fallback to hardcoded Breaking Bad Metahub logo - setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); - } - } - } else { - logger.warn(`[LogoSourceSettings] Breaking Bad not found in search results`); - // Use hardcoded Breaking Bad logos - setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); - setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); - } - } catch (err) { - logger.error('[LogoSourceSettings] Error fetching Breaking Bad logos:', err); - - // Use hardcoded Breaking Bad logos - setTmdbLogo('https://image.tmdb.org/t/p/original/ggFHVNu6YYI5L9pCfOacjizRGt.png'); - setMetahubLogo('https://images.metahub.space/logo/medium/tt0903747/img'); - } finally { - setLoadingLogos(false); - } - }; + fetchExampleLogos(selectedShow); + }, [selectedShow]); + + // Function to fetch logos and banners for a specific show + const fetchExampleLogos = async (show: typeof EXAMPLE_SHOWS[0]) => { + setLoadingLogos(true); + setTmdbLogo(null); + setMetahubLogo(null); + setTmdbBanner(null); + setMetahubBanner(null); - fetchExampleLogos(); - }, []); + try { + const tmdbService = TMDBService.getInstance(); + const imdbId = show.imdbId; + const tmdbId = show.tmdbId; + const contentType = show.type; + + logger.log(`[LogoSourceSettings] Fetching ${show.name} with TMDB ID: ${tmdbId}, IMDB ID: ${imdbId}`); + + // Get TMDB logo and banner + try { + // Manually fetch images from TMDB API + const apiKey = TMDB_API_KEY; + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}/images?api_key=${apiKey}`); + const imagesData = await response.json(); + + // Get TMDB logo + if (imagesData.logos && imagesData.logos.length > 0) { + // Look for English logo first + let logoPath = null; + + // First try to find an English logo + const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => + logo.iso_639_1 === 'en' + ); + if (englishLogo) { + logoPath = englishLogo.file_path; + } else if (imagesData.logos[0]) { + // Fallback to the first logo + logoPath = imagesData.logos[0].file_path; + } + + if (logoPath) { + const tmdbLogoUrl = `https://image.tmdb.org/t/p/original${logoPath}`; + setTmdbLogo(tmdbLogoUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} TMDB logo: ${tmdbLogoUrl}`); + } + } + + // Get TMDB banner (backdrop) + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + const backdropPath = imagesData.backdrops[0].file_path; + const tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; + setTmdbBanner(tmdbBannerUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} TMDB banner: ${tmdbBannerUrl}`); + } else { + // Try to get backdrop from details + const detailsResponse = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}?api_key=${apiKey}`); + const details = await detailsResponse.json(); + + if (details.backdrop_path) { + const tmdbBannerUrl = `https://image.tmdb.org/t/p/original${details.backdrop_path}`; + setTmdbBanner(tmdbBannerUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} TMDB banner from details: ${tmdbBannerUrl}`); + } + } + } catch (tmdbError) { + logger.error(`[LogoSourceSettings] Error fetching TMDB images:`, tmdbError); + } + + // Get Metahub logo and banner + try { + // Metahub logo + const metahubLogoUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + const logoResponse = await fetch(metahubLogoUrl, { method: 'HEAD' }); + + if (logoResponse.ok) { + setMetahubLogo(metahubLogoUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} Metahub logo: ${metahubLogoUrl}`); + } + + // Metahub banner + const metahubBannerUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; + const bannerResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); + + if (bannerResponse.ok) { + setMetahubBanner(metahubBannerUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} Metahub banner: ${metahubBannerUrl}`); + } else if (tmdbBanner) { + // If Metahub banner doesn't exist, use TMDB banner + setMetahubBanner(tmdbBanner); + } + } catch (metahubErr) { + logger.error(`[LogoSourceSettings] Error checking Metahub images:`, metahubErr); + } + } catch (err) { + logger.error(`[LogoSourceSettings] Error fetching ${show.name} logos:`, err); + } finally { + setLoadingLogos(false); + } + }; // Apply setting and show confirmation const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { @@ -167,13 +221,47 @@ const LogoSourceSettings = () => { ); }; + // Save selected show to AsyncStorage to persist across navigation + const saveSelectedShow = async (show: typeof EXAMPLE_SHOWS[0]) => { + try { + await AsyncStorage.setItem('logo_settings_selected_show', show.imdbId); + } catch (e) { + console.error('Error saving selected show:', e); + } + }; + + // Load selected show from AsyncStorage on mount + useEffect(() => { + const loadSelectedShow = async () => { + try { + const savedShowId = await AsyncStorage.getItem('logo_settings_selected_show'); + if (savedShowId) { + const foundShow = EXAMPLE_SHOWS.find(show => show.imdbId === savedShowId); + if (foundShow) { + setSelectedShow(foundShow); + } + } + } catch (e) { + console.error('Error loading selected show:', e); + } + }; + + loadSelectedShow(); + }, []); + + // Update selected show and save to AsyncStorage + const handleShowSelect = (show: typeof EXAMPLE_SHOWS[0]) => { + setSelectedShow(show); + saveSelectedShow(show); + }; + // Handle back navigation const handleBack = () => { navigation.goBack(); }; - // Render logo example with loading state - const renderLogoExample = (url: string | null, isLoading: boolean) => { + // Render logo example with loading state and background + const renderLogoExample = (logo: string | null, banner: string | null, isLoading: boolean) => { if (isLoading) { return ( @@ -183,11 +271,26 @@ const LogoSourceSettings = () => { } return ( - + + + + {logo && ( + + )} + {!logo && ( + + No logo available + + )} + ); }; @@ -217,6 +320,36 @@ const LogoSourceSettings = () => { + {/* Show selector */} + + Select a show/movie to preview: + + {EXAMPLE_SHOWS.map((show) => ( + handleShowSelect(show)} + > + + {show.name} + + + ))} + + + {/* Options */} { Example: - {renderLogoExample(metahubLogo, loadingLogos)} - Breaking Bad logo from Metahub + {renderLogoExample(metahubLogo, metahubBanner, loadingLogos)} + {selectedShow.name} logo from Metahub @@ -266,8 +399,8 @@ const LogoSourceSettings = () => { Example: - {renderLogoExample(tmdbLogo, loadingLogos)} - Breaking Bad logo from TMDB + {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} + {selectedShow.name} logo from TMDB @@ -324,6 +457,39 @@ const styles = StyleSheet.create({ fontSize: 16, lineHeight: 24, }, + showSelectorContainer: { + padding: 16, + paddingBottom: 8, + }, + selectorLabel: { + color: colors.text, + fontSize: 16, + marginBottom: 12, + }, + showsScrollContent: { + paddingRight: 16, + }, + showItem: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: colors.elevation2, + borderRadius: 20, + marginRight: 8, + borderWidth: 1, + borderColor: 'transparent', + }, + selectedShowItem: { + borderColor: colors.primary, + backgroundColor: colors.elevation3, + }, + showItemText: { + color: colors.mediumEmphasis, + fontSize: 14, + }, + selectedShowItemText: { + color: colors.white, + fontWeight: '600', + }, optionsContainer: { padding: 16, gap: 16, @@ -391,6 +557,44 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: 4, }, + bannerContainer: { + height: 120, + width: '100%', + borderRadius: 8, + overflow: 'hidden', + position: 'relative', + }, + bannerImage: { + ...StyleSheet.absoluteFillObject, + }, + bannerOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + logoOverBanner: { + position: 'absolute', + width: '80%', + height: '80%', + alignSelf: 'center', + top: '10%', + }, + noLogoContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + noLogoText: { + color: colors.white, + fontSize: 14, + backgroundColor: 'rgba(0,0,0,0.5)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 4, + }, }); export default LogoSourceSettings; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 16e8c93..db02a12 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -222,6 +222,7 @@ const MetadataScreen = () => { // Add state for custom banner const [bannerImage, setBannerImage] = useState(null); const forcedBannerRefreshDone = useRef(false); + const [loadingBanner, setLoadingBanner] = useState(false); // Add debug log for settings when component mounts useEffect(() => { @@ -278,46 +279,147 @@ const MetadataScreen = () => { // Fetch banner image based on logo source preference useEffect(() => { const fetchBanner = async () => { - if (metadata && (!forcedBannerRefreshDone.current || foundTmdbId)) { - // Extract any existing TMDB ID if available - let tmdbId = null; + if (metadata) { + setLoadingBanner(true); + + // Clear the banner initially when starting a preference-driven fetch + setBannerImage(null); + + let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback + const preference = settings.logoSourcePreference || 'metahub'; + const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key + + // Extract IDs + let currentTmdbId = null; if (id.startsWith('tmdb:')) { - tmdbId = id.split(':')[1]; + currentTmdbId = id.split(':')[1]; + } else if (foundTmdbId) { + currentTmdbId = foundTmdbId; + } else if ((metadata as any).tmdbId) { + currentTmdbId = (metadata as any).tmdbId; } - // Use our stored TMDB ID if we have one - const effectiveTmdbId = foundTmdbId || tmdbId || (metadata as any).tmdbId; + const currentImdbId = imdbId; + const contentType = type === 'series' ? 'tv' : 'movie'; - logger.log(`[MetadataScreen] Fetching banner with preference: ${settings.logoSourcePreference}, TMDB ID: ${effectiveTmdbId}`); + logger.log(`[MetadataScreen] Fetching banner with preference: ${preference}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); try { - // Use our utility function to get the banner based on preference - const newBanner = await fetchBannerWithPreference( - imdbId, - effectiveTmdbId, - type as 'movie' | 'series', - settings.logoSourcePreference - ); - - if (newBanner) { - logger.log(`[MetadataScreen] Setting new banner: ${newBanner}`); - setBannerImage(newBanner); - } else { - // If no banner found from preferred source, use the existing one from metadata - logger.log(`[MetadataScreen] Using existing banner from metadata: ${metadata.banner}`); - setBannerImage(metadata.banner || metadata.poster); + if (preference === 'tmdb') { + // 1. Try TMDB first + let tmdbBannerUrl: string | null = null; + if (currentTmdbId) { + logger.log(`[MetadataScreen] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); + try { + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); + const imagesData = await response.json(); + + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + const backdropPath = imagesData.backdrops[0].file_path; + tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; + logger.log(`[MetadataScreen] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); + } else { + // Add log for when no backdrops are found + logger.warn(`[MetadataScreen] TMDB API successful, but no backdrops found for ID: ${currentTmdbId}`); + } + } catch (err) { + logger.error(`[MetadataScreen] Error fetching TMDB banner via images endpoint:`, err); + } + } else { + // Add log for when no TMDB ID is available + logger.warn(`[MetadataScreen] No TMDB ID available to fetch TMDB banner.`); + } + + if (tmdbBannerUrl) { + // TMDB SUCCESS: Set banner and EXIT + finalBanner = tmdbBannerUrl; + logger.log(`[MetadataScreen] Setting final banner to TMDB source: ${finalBanner}`); + setBannerImage(finalBanner); + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; + return; // <-- Exit here, don't attempt fallback + } else { + // TMDB FAILED: Proceed to Metahub fallback + logger.log(`[MetadataScreen] TMDB banner failed, trying Metahub fallback.`); + if (currentImdbId) { + const metahubBannerUrl = `https://images.metahub.space/background/medium/${currentImdbId}/img`; + try { + const metahubResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); + if (metahubResponse.ok) { + finalBanner = metahubBannerUrl; + logger.log(`[MetadataScreen] Found Metahub banner as fallback: ${finalBanner}`); + } + } catch (err) { + logger.error(`[MetadataScreen] Error fetching Metahub fallback banner:`, err); + } + } + } + } else { // Preference is Metahub + // 1. Try Metahub first + let metahubBannerUrl: string | null = null; + if (currentImdbId) { + const url = `https://images.metahub.space/background/medium/${currentImdbId}/img`; + try { + const metahubResponse = await fetch(url, { method: 'HEAD' }); + if (metahubResponse.ok) { + metahubBannerUrl = url; + logger.log(`[MetadataScreen] Found Metahub banner: ${metahubBannerUrl}`); + } + } catch (err) { + logger.error(`[MetadataScreen] Error fetching Metahub banner:`, err); + } + } + + if (metahubBannerUrl) { + // METAHUB SUCCESS: Set banner and EXIT + finalBanner = metahubBannerUrl; + logger.log(`[MetadataScreen] Setting final banner to Metahub source: ${finalBanner}`); + setBannerImage(finalBanner); + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; + return; // <-- Exit here, don't attempt fallback + } else { + // METAHUB FAILED: Proceed to TMDB fallback + logger.log(`[MetadataScreen] Metahub banner failed, trying TMDB fallback.`); + if (currentTmdbId) { + try { + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); + const imagesData = await response.json(); + + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + const backdropPath = imagesData.backdrops[0].file_path; + finalBanner = `https://image.tmdb.org/t/p/original${backdropPath}`; + logger.log(`[MetadataScreen] Found TMDB banner as fallback: ${finalBanner}`); + } + } catch (err) { + logger.error(`[MetadataScreen] Error fetching TMDB fallback banner:`, err); + } + } + } } + + // Set the final determined banner (could be fallback or initial default) + setBannerImage(finalBanner); + logger.log(`[MetadataScreen] Final banner set after fallbacks (if any): ${finalBanner}`); + } catch (error) { - logger.error(`[MetadataScreen] Error fetching banner:`, error); - // Use existing banner as fallback + logger.error(`[MetadataScreen] General error fetching banner:`, error); + // Fallback to initial banner on general error setBannerImage(metadata.banner || metadata.poster); + } finally { + // Only set loading to false here if we didn't exit early + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; // Mark refresh as done } - - forcedBannerRefreshDone.current = true; } }; + // Only run fetchBanner if metadata exists and preference/content might have changed + // The dependencies array handles triggering this effect fetchBanner(); + }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); // Reset forced refresh when preference changes @@ -325,7 +427,7 @@ const MetadataScreen = () => { if (forcedBannerRefreshDone.current) { logger.log(`[MetadataScreen] Logo preference changed, resetting banner refresh flag`); forcedBannerRefreshDone.current = false; - // Clear the banner image to force a new fetch + // Clear the banner image immediately to prevent showing the wrong source briefly setBannerImage(null); // This will trigger the banner fetch effect to run again } @@ -582,102 +684,84 @@ const MetadataScreen = () => { } } } else { // TMDB first - // Try to get logo from TMDB first - let tmdbId = null; - const tmdbType = type === 'series' ? 'tv' : 'movie'; + let tmdbLogoUrl: string | null = null; + // 1. Attempt to fetch TMDB logo if (id.startsWith('tmdb:')) { - // Direct TMDB ID - tmdbId = id.split(':')[1]; - logger.log(`[MetadataScreen] Content has direct TMDB ID: ${tmdbId}`); - } else if (id.startsWith('tt')) { - // IMDB ID - need to find the corresponding TMDB ID - logger.log(`[MetadataScreen] Content has IMDB ID (${id}), looking up TMDB ID`); - try { - // Use the passed imdbId if available, otherwise use id directly - const imdbIdToUse = imdbId || id; - logger.log(`[MetadataScreen] Using IMDB ID for lookup: ${imdbIdToUse}`); - - tmdbId = await TMDBService.getInstance().findTMDBIdByIMDB(imdbIdToUse); - if (tmdbId) { - logger.log(`[MetadataScreen] Found TMDB ID ${tmdbId} for IMDB ID ${imdbIdToUse}`); - - // Save the TMDB ID for banner fetching - setFoundTmdbId(String(tmdbId)); - } else { - logger.warn(`[MetadataScreen] Could not find TMDB ID for IMDB ID ${imdbIdToUse}`); - } - } catch (error) { - logger.error(`[MetadataScreen] Error finding TMDB ID for IMDB ID ${id}:`, error); - } - } - - if (tmdbId) { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId})`); - try { const tmdbService = TMDBService.getInstance(); - logger.log(`[MetadataScreen] Calling getContentLogo with type=${tmdbType}, id=${tmdbId}`); + tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); - const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); - - if (logoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: - - Content Type: ${tmdbType} - - TMDB ID: ${tmdbId} - - Logo URL: ${logoUrl} - `); - - // Update metadata with TMDB logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: logoUrl - })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if TMDB logo was found + if (tmdbLogoUrl) { + logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); } else { - logger.warn(`[MetadataScreen] No logo found from TMDB for ${type} (ID: ${tmdbId}), trying Metahub`); + logger.warn(`[MetadataScreen] No logo found from TMDB for ${type} (ID: ${tmdbId})`); } } catch (error) { logger.error(`[MetadataScreen] Error fetching TMDB logo for ID ${tmdbId}:`, error); } - } else { - logger.warn(`[MetadataScreen] No TMDB ID available, falling back to Metahub`); + } else if (imdbId) { + // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID + logger.log(`[MetadataScreen] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo`); + try { + const tmdbService = TMDBService.getInstance(); + const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); + + if (foundTmdbId) { + logger.log(`[MetadataScreen] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); + setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching + + tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString()); + + if (tmdbLogoUrl) { + logger.log(`[MetadataScreen] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); + } else { + logger.warn(`[MetadataScreen] No logo found from TMDB via IMDB lookup for ${type} (IMDB: ${imdbId})`); + } + } else { + logger.warn(`[MetadataScreen] Could not find TMDB ID for IMDB ID ${imdbId}`); + } + } catch (error) { + logger.error(`[MetadataScreen] Error finding TMDB ID or fetching logo for IMDB ID ${imdbId}:`, error); + } } - // If TMDB fails or isn't a TMDB ID, try Metahub as fallback + // 2. If TMDB logo was fetched successfully, update and return + if (tmdbLogoUrl) { + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: tmdbLogoUrl + })); + logoFetchInProgress.current = false; + return; + } + + // 3. If TMDB failed, try Metahub as fallback + logger.log(`[MetadataScreen] TMDB logo fetch failed or not applicable. Attempting Metahub fallback.`); if (imdbId) { const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); try { const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metahubUrl} - `); - - // Update metadata with Metahub logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: metahubUrl - })); + logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: ${metahubUrl}`); + setMetadata(prevMetadata => ({ ...prevMetadata!, logo: metahubUrl })); } else { - // If both TMDB and Metahub fail, use the title as text instead of a logo - logger.warn(`[MetadataScreen] No logo found from either source for ${type} (ID: ${id}), using title text instead`); - - // Leave logo as null/undefined to trigger fallback to text + logger.warn(`[MetadataScreen] Metahub fallback failed. Using title text.`); + setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); } } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); - - // Leave logo as null/undefined to trigger fallback to text + logger.warn(`[MetadataScreen] Failed to fetch fallback logo from Metahub:`, metahubError); + setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); } + } else { + // No IMDB ID for Metahub fallback + logger.warn(`[MetadataScreen] No IMDB ID for Metahub fallback. Using title text.`); + setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); } } } catch (error) { @@ -686,6 +770,8 @@ const MetadataScreen = () => { contentId: id, contentType: type }); + // Fallback to text on general error + setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); } finally { // Clear fetch in progress flag when done logoFetchInProgress.current = false; @@ -1484,18 +1570,22 @@ const MetadataScreen = () => { {/* Use Animated.Image directly instead of ImageBackground with imageStyle */} - { - logger.warn(`[MetadataScreen] Banner failed to load: ${bannerImage}`); - // If custom banner fails, fall back to original metadata banner - if (bannerImage !== metadata.banner) { - setBannerImage(metadata.banner || metadata.poster); - } - }} - /> + {loadingBanner ? ( + + ) : ( + { + logger.warn(`[MetadataScreen] Banner failed to load: ${bannerImage}`); + // If custom banner fails, fall back to original metadata banner + if (bannerImage !== metadata.banner) { + setBannerImage(metadata.banner || metadata.poster); + } + }} + /> + )} Date: Sat, 3 May 2025 19:32:19 +0530 Subject: [PATCH 09/38] Refactor logo fetching logic in LogoSourceSettings and MetadataScreen components Update the LogoSourceSettings component to improve logo source selection and enhance the MetadataScreen's banner fetching mechanism. Implement better error handling and loading states for a smoother user experience during logo and banner retrieval. Ensure that user preferences are effectively utilized in the fetching process. --- package-lock.json | 1 + src/screens/LogoSourceSettings.tsx | 996 ++++++++++++++++------------- 2 files changed, 552 insertions(+), 445 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3f5b58..99bfb79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "nuvio", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "^14.1.0", diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index e27ee9e..2ebb9be 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -95,6 +95,12 @@ const LogoSourceSettings = () => { const [tmdbBanner, setTmdbBanner] = useState(null); const [metahubBanner, setMetahubBanner] = useState(null); const [loadingLogos, setLoadingLogos] = useState(true); + + // State for TMDB language selection + const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState('en'); + // Store unique language codes as strings + const [uniqueTmdbLanguages, setUniqueTmdbLanguages] = useState([]); + const [tmdbLogosData, setTmdbLogosData] = useState | null>(null); // Load example logos for selected show useEffect(() => { @@ -108,6 +114,10 @@ const LogoSourceSettings = () => { setMetahubLogo(null); setTmdbBanner(null); setMetahubBanner(null); + // Reset unique languages and logos data + setUniqueTmdbLanguages([]); + setTmdbLogosData(null); + setSelectedTmdbLanguage('en'); // Reset to default language try { const tmdbService = TMDBService.getInstance(); @@ -119,482 +129,578 @@ const LogoSourceSettings = () => { // Get TMDB logo and banner try { - // Manually fetch images from TMDB API const apiKey = TMDB_API_KEY; const endpoint = contentType === 'tv' ? 'tv' : 'movie'; const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}/images?api_key=${apiKey}`); const imagesData = await response.json(); - // Get TMDB logo + // Store all TMDB logos data and extract unique languages if (imagesData.logos && imagesData.logos.length > 0) { - // Look for English logo first - let logoPath = null; + setTmdbLogosData(imagesData.logos); - // First try to find an English logo - const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => - logo.iso_639_1 === 'en' - ); - if (englishLogo) { - logoPath = englishLogo.file_path; - } else if (imagesData.logos[0]) { - // Fallback to the first logo - logoPath = imagesData.logos[0].file_path; + // Filter for logos with valid language codes and get unique codes + const validLogoLanguages = imagesData.logos + .map((logo: { iso_639_1: string | null }) => logo.iso_639_1) + .filter((lang: string | null): lang is string => lang !== null && typeof lang === 'string'); + + // Explicitly type the Set and resulting array + const uniqueCodes: string[] = [...new Set(validLogoLanguages)]; + setUniqueTmdbLanguages(uniqueCodes); + + // Find initial logo (prefer 'en') + let initialLogoPath: string | null = null; + let initialLanguage = 'en'; + + const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en'); + + if (englishLogo) { + initialLogoPath = englishLogo.file_path; + initialLanguage = 'en'; + logger.log(`[LogoSourceSettings] Found initial English TMDB logo for ${show.name}`); + } else if (imagesData.logos[0]) { + // Fallback to the first available logo + initialLogoPath = imagesData.logos[0].file_path; + initialLanguage = imagesData.logos[0].iso_639_1; + logger.log(`[LogoSourceSettings] No English logo, using first available (${initialLanguage}) TMDB logo for ${show.name}`); + } + + if (initialLogoPath) { + setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`); + setSelectedTmdbLanguage(initialLanguage); // Set selected language based on found logo + } else { + logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`); + } + } else { + logger.warn(`[LogoSourceSettings] No TMDB logos found in response for ${show.name}`); + setUniqueTmdbLanguages([]); // Ensure it's empty if no logos } - if (logoPath) { - const tmdbLogoUrl = `https://image.tmdb.org/t/p/original${logoPath}`; - setTmdbLogo(tmdbLogoUrl); - logger.log(`[LogoSourceSettings] Got ${show.name} TMDB logo: ${tmdbLogoUrl}`); + // Get TMDB banner (backdrop) + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + const backdropPath = imagesData.backdrops[0].file_path; + const tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; + setTmdbBanner(tmdbBannerUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} TMDB banner: ${tmdbBannerUrl}`); + } else { + // Try to get backdrop from details + const detailsResponse = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}?api_key=${apiKey}`); + const details = await detailsResponse.json(); + + if (details.backdrop_path) { + const tmdbBannerUrl = `https://image.tmdb.org/t/p/original${details.backdrop_path}`; + setTmdbBanner(tmdbBannerUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} TMDB banner from details: ${tmdbBannerUrl}`); + } } + } catch (tmdbError) { + logger.error(`[LogoSourceSettings] Error fetching TMDB images:`, tmdbError); } - // Get TMDB banner (backdrop) - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - const backdropPath = imagesData.backdrops[0].file_path; - const tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; - setTmdbBanner(tmdbBannerUrl); - logger.log(`[LogoSourceSettings] Got ${show.name} TMDB banner: ${tmdbBannerUrl}`); - } else { - // Try to get backdrop from details - const detailsResponse = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}?api_key=${apiKey}`); - const details = await detailsResponse.json(); + // Get Metahub logo and banner + try { + // Metahub logo + const metahubLogoUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + const logoResponse = await fetch(metahubLogoUrl, { method: 'HEAD' }); - if (details.backdrop_path) { - const tmdbBannerUrl = `https://image.tmdb.org/t/p/original${details.backdrop_path}`; - setTmdbBanner(tmdbBannerUrl); - logger.log(`[LogoSourceSettings] Got ${show.name} TMDB banner from details: ${tmdbBannerUrl}`); + if (logoResponse.ok) { + setMetahubLogo(metahubLogoUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} Metahub logo: ${metahubLogoUrl}`); } + + // Metahub banner + const metahubBannerUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; + const bannerResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); + + if (bannerResponse.ok) { + setMetahubBanner(metahubBannerUrl); + logger.log(`[LogoSourceSettings] Got ${show.name} Metahub banner: ${metahubBannerUrl}`); + } else if (tmdbBanner) { + // If Metahub banner doesn't exist, use TMDB banner + setMetahubBanner(tmdbBanner); + } + } catch (metahubErr) { + logger.error(`[LogoSourceSettings] Error checking Metahub images:`, metahubErr); } - } catch (tmdbError) { - logger.error(`[LogoSourceSettings] Error fetching TMDB images:`, tmdbError); + } catch (err) { + logger.error(`[LogoSourceSettings] Error fetching ${show.name} logos:`, err); + } finally { + setLoadingLogos(false); + } + }; + + // Apply setting and show confirmation + const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { + setLogoSource(source); + updateSetting('logoSourcePreference', source); + + // Clear any cached logo data in storage + try { + AsyncStorage.removeItem('_last_logos_'); + } catch (e) { + console.error('Error clearing logo cache:', e); } - // Get Metahub logo and banner - try { - // Metahub logo - const metahubLogoUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - const logoResponse = await fetch(metahubLogoUrl, { method: 'HEAD' }); - - if (logoResponse.ok) { - setMetahubLogo(metahubLogoUrl); - logger.log(`[LogoSourceSettings] Got ${show.name} Metahub logo: ${metahubLogoUrl}`); - } - - // Metahub banner - const metahubBannerUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; - const bannerResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); - - if (bannerResponse.ok) { - setMetahubBanner(metahubBannerUrl); - logger.log(`[LogoSourceSettings] Got ${show.name} Metahub banner: ${metahubBannerUrl}`); - } else if (tmdbBanner) { - // If Metahub banner doesn't exist, use TMDB banner - setMetahubBanner(tmdbBanner); - } - } catch (metahubErr) { - logger.error(`[LogoSourceSettings] Error checking Metahub images:`, metahubErr); - } - } catch (err) { - logger.error(`[LogoSourceSettings] Error fetching ${show.name} logos:`, err); - } finally { - setLoadingLogos(false); - } - }; - - // Apply setting and show confirmation - const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { - setLogoSource(source); - updateSetting('logoSourcePreference', source); + // Show confirmation alert + Alert.alert( + 'Settings Updated', + `Logo and background source preference set to ${source === 'metahub' ? 'Metahub' : 'TMDB'}. Changes will apply when you navigate to content.`, + [{ text: 'OK' }] + ); + }; - // Clear any cached logo data in storage - try { - AsyncStorage.removeItem('_last_logos_'); - } catch (e) { - console.error('Error clearing logo cache:', e); - } - - // Show confirmation alert - Alert.alert( - 'Settings Updated', - `Logo and background source preference set to ${source === 'metahub' ? 'Metahub' : 'TMDB'}. Changes will apply when you navigate to content.`, - [{ text: 'OK' }] - ); - }; - - // Save selected show to AsyncStorage to persist across navigation - const saveSelectedShow = async (show: typeof EXAMPLE_SHOWS[0]) => { - try { - await AsyncStorage.setItem('logo_settings_selected_show', show.imdbId); - } catch (e) { - console.error('Error saving selected show:', e); - } - }; - - // Load selected show from AsyncStorage on mount - useEffect(() => { - const loadSelectedShow = async () => { + // Save selected show to AsyncStorage to persist across navigation + const saveSelectedShow = async (show: typeof EXAMPLE_SHOWS[0]) => { try { - const savedShowId = await AsyncStorage.getItem('logo_settings_selected_show'); - if (savedShowId) { - const foundShow = EXAMPLE_SHOWS.find(show => show.imdbId === savedShowId); - if (foundShow) { - setSelectedShow(foundShow); - } - } + await AsyncStorage.setItem('logo_settings_selected_show', show.imdbId); } catch (e) { - console.error('Error loading selected show:', e); + console.error('Error saving selected show:', e); } }; - loadSelectedShow(); - }, []); - - // Update selected show and save to AsyncStorage - const handleShowSelect = (show: typeof EXAMPLE_SHOWS[0]) => { - setSelectedShow(show); - saveSelectedShow(show); - }; + // Load selected show from AsyncStorage on mount + useEffect(() => { + const loadSelectedShow = async () => { + try { + const savedShowId = await AsyncStorage.getItem('logo_settings_selected_show'); + if (savedShowId) { + const foundShow = EXAMPLE_SHOWS.find(show => show.imdbId === savedShowId); + if (foundShow) { + setSelectedShow(foundShow); + } + } + } catch (e) { + console.error('Error loading selected show:', e); + } + }; + + loadSelectedShow(); + }, []); + + // Update selected show and save to AsyncStorage + const handleShowSelect = (show: typeof EXAMPLE_SHOWS[0]) => { + setSelectedShow(show); + saveSelectedShow(show); + }; - // Handle back navigation - const handleBack = () => { - navigation.goBack(); - }; + // Handle back navigation + const handleBack = () => { + navigation.goBack(); + }; - // Render logo example with loading state and background - const renderLogoExample = (logo: string | null, banner: string | null, isLoading: boolean) => { - if (isLoading) { + // Render logo example with loading state and background + const renderLogoExample = (logo: string | null, banner: string | null, isLoading: boolean) => { + if (isLoading) { + return ( + + + + ); + } + return ( - - + + + + {logo && ( + + )} + {!logo && ( + + No logo available + + )} ); - } - + }; + + // Handle TMDB language selection + const handleTmdbLanguageSelect = (languageCode: string) => { + setSelectedTmdbLanguage(languageCode); + if (tmdbLogosData) { + const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode); + if (selectedLogoData) { + setTmdbLogo(`https://image.tmdb.org/t/p/original${selectedLogoData.file_path}`); + logger.log(`[LogoSourceSettings] Switched TMDB logo to language: ${languageCode}`); + } else { + logger.warn(`[LogoSourceSettings] Could not find logo data for selected language: ${languageCode}`); + } + } + }; + return ( - - - - {logo && ( - - )} - {!logo && ( - - No logo available + + + + {/* Header */} + + + + + Logo Source + + + + + {/* Description */} + + + Choose the primary source for content logos and background images. This affects the appearance + of titles in the metadata screen. + - )} - + + {/* Show selector */} + + Select a show/movie to preview: + + {EXAMPLE_SHOWS.map((show) => ( + handleShowSelect(show)} + > + + {show.name} + + + ))} + + + + {/* Options */} + + applyLogoSourceSetting('metahub')} + > + + Metahub + {logoSource === 'metahub' && ( + + )} + + + + Prioritizes high-quality title logos from the Metahub image repository. + Offers good coverage for popular titles. + + + + Example: + {renderLogoExample(metahubLogo, metahubBanner, loadingLogos)} + {selectedShow.name} logo from Metahub + + + + applyLogoSourceSetting('tmdb')} + > + + TMDB + {logoSource === 'tmdb' && ( + + )} + + + + Uses logos from The Movie Database. Often includes more localized and newer logos, + with better coverage for recent content. + + + + Example: + {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} + {selectedShow.name} logo from TMDB + + + {/* TMDB Language Selector */} + {uniqueTmdbLanguages.length > 1 && ( + + Available logo languages: + + {/* Iterate over unique language codes */} + {uniqueTmdbLanguages.map((langCode) => ( + handleTmdbLanguageSelect(langCode)} + > + + {(langCode || '').toUpperCase() || '??'} + + + ))} + + + )} + + + + {/* Additional Info */} + + + If a logo is not available from your preferred source, the app will automatically fall back to the other source. + If no logo is found, the title text will be shown instead. + + + + ); }; - return ( - - - - {/* Header */} - - - - - Logo Source - - - - - {/* Description */} - - - Choose the primary source for content logos and background images. This affects the appearance - of titles in the metadata screen. - - - - {/* Show selector */} - - Select a show/movie to preview: - - {EXAMPLE_SHOWS.map((show) => ( - handleShowSelect(show)} - > - - {show.name} - - - ))} - - - - {/* Options */} - - applyLogoSourceSetting('metahub')} - > - - Metahub - {logoSource === 'metahub' && ( - - )} - - - - Prioritizes high-quality title logos from the Metahub image repository. - Offers good coverage for popular titles. - - - - Example: - {renderLogoExample(metahubLogo, metahubBanner, loadingLogos)} - {selectedShow.name} logo from Metahub - - - - applyLogoSourceSetting('tmdb')} - > - - TMDB - {logoSource === 'tmdb' && ( - - )} - - - - Uses logos from The Movie Database. Often includes more localized and newer logos, - with better coverage for recent content. - - - - Example: - {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} - {selectedShow.name} logo from TMDB - - - - - {/* Additional Info */} - - - If a logo is not available from your preferred source, the app will automatically fall back to the other source. - If no logo is found, the title text will be shown instead. - - - - - ); -}; + const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + height: 56, + backgroundColor: colors.elevation2, + }, + backButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + color: colors.white, + fontSize: 20, + fontWeight: '600', + }, + headerRight: { + width: 40, + }, + scrollView: { + flex: 1, + }, + descriptionContainer: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.1)', + }, + description: { + color: colors.text, + fontSize: 16, + lineHeight: 24, + }, + showSelectorContainer: { + padding: 16, + paddingBottom: 8, + }, + selectorLabel: { + color: colors.text, + fontSize: 16, + marginBottom: 12, + }, + showsScrollContent: { + paddingRight: 16, + }, + showItem: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: colors.elevation2, + borderRadius: 20, + marginRight: 8, + borderWidth: 1, + borderColor: 'transparent', + }, + selectedShowItem: { + borderColor: colors.primary, + backgroundColor: colors.elevation3, + }, + showItemText: { + color: colors.mediumEmphasis, + fontSize: 14, + }, + selectedShowItemText: { + color: colors.white, + fontWeight: '600', + }, + optionsContainer: { + padding: 16, + gap: 16, + }, + optionCard: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + borderWidth: 2, + borderColor: 'transparent', + }, + selectedCard: { + borderColor: colors.primary, + }, + optionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + optionTitle: { + color: colors.white, + fontSize: 18, + fontWeight: '600', + }, + optionDescription: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + marginBottom: 16, + }, + exampleContainer: { + marginTop: 8, + }, + exampleLabel: { + color: colors.mediumEmphasis, + fontSize: 14, + marginBottom: 8, + }, + exampleImage: { + height: 60, + width: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 8, + }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + infoBox: { + margin: 16, + padding: 16, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 8, + borderLeftWidth: 4, + borderLeftColor: colors.primary, + }, + infoText: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + }, + logoSourceLabel: { + color: colors.mediumEmphasis, + fontSize: 12, + marginTop: 4, + }, + languageSelectorContainer: { + marginTop: 16, + }, + languageSelectorLabel: { + color: colors.mediumEmphasis, + fontSize: 13, + marginBottom: 8, + }, + languageScrollContent: { + paddingRight: 16, // Match container padding + }, + languageItem: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: colors.elevation1, + borderRadius: 16, + marginRight: 8, + borderWidth: 1, + borderColor: colors.elevation3, + }, + selectedLanguageItem: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + languageItemText: { + color: colors.mediumEmphasis, + fontSize: 13, + fontWeight: '600', + }, + selectedLanguageItemText: { + color: colors.white, + }, + bannerContainer: { + height: 120, + width: '100%', + borderRadius: 8, + overflow: 'hidden', + position: 'relative', + }, + bannerImage: { + ...StyleSheet.absoluteFillObject, + }, + bannerOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + logoOverBanner: { + position: 'absolute', + width: '80%', + height: '80%', + alignSelf: 'center', + top: '10%', + }, + noLogoContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + noLogoText: { + color: colors.white, + fontSize: 14, + backgroundColor: 'rgba(0,0,0,0.5)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 4, + }, + }); -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - height: 56, - backgroundColor: colors.elevation2, - }, - backButton: { - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - }, - headerTitle: { - color: colors.white, - fontSize: 20, - fontWeight: '600', - }, - headerRight: { - width: 40, - }, - scrollView: { - flex: 1, - }, - descriptionContainer: { - padding: 16, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - }, - description: { - color: colors.text, - fontSize: 16, - lineHeight: 24, - }, - showSelectorContainer: { - padding: 16, - paddingBottom: 8, - }, - selectorLabel: { - color: colors.text, - fontSize: 16, - marginBottom: 12, - }, - showsScrollContent: { - paddingRight: 16, - }, - showItem: { - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: colors.elevation2, - borderRadius: 20, - marginRight: 8, - borderWidth: 1, - borderColor: 'transparent', - }, - selectedShowItem: { - borderColor: colors.primary, - backgroundColor: colors.elevation3, - }, - showItemText: { - color: colors.mediumEmphasis, - fontSize: 14, - }, - selectedShowItemText: { - color: colors.white, - fontWeight: '600', - }, - optionsContainer: { - padding: 16, - gap: 16, - }, - optionCard: { - backgroundColor: colors.elevation2, - borderRadius: 12, - padding: 16, - borderWidth: 2, - borderColor: 'transparent', - }, - selectedCard: { - borderColor: colors.primary, - }, - optionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - optionTitle: { - color: colors.white, - fontSize: 18, - fontWeight: '600', - }, - optionDescription: { - color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, - marginBottom: 16, - }, - exampleContainer: { - marginTop: 8, - }, - exampleLabel: { - color: colors.mediumEmphasis, - fontSize: 14, - marginBottom: 8, - }, - exampleImage: { - height: 60, - width: '100%', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: 8, - }, - loadingContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - infoBox: { - margin: 16, - padding: 16, - backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 8, - borderLeftWidth: 4, - borderLeftColor: colors.primary, - }, - infoText: { - color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, - }, - logoSourceLabel: { - color: colors.mediumEmphasis, - fontSize: 12, - marginTop: 4, - }, - bannerContainer: { - height: 120, - width: '100%', - borderRadius: 8, - overflow: 'hidden', - position: 'relative', - }, - bannerImage: { - ...StyleSheet.absoluteFillObject, - }, - bannerOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.5)', - }, - logoOverBanner: { - position: 'absolute', - width: '80%', - height: '80%', - alignSelf: 'center', - top: '10%', - }, - noLogoContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'center', - alignItems: 'center', - }, - noLogoText: { - color: colors.white, - fontSize: 14, - backgroundColor: 'rgba(0,0,0,0.5)', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 4, - }, -}); - -export default LogoSourceSettings; \ No newline at end of file + export default LogoSourceSettings; \ No newline at end of file From d39f3723599bf6216acb023aa1fa8d641c96c984 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:44:21 +0530 Subject: [PATCH 10/38] Enhance TMDB language preference functionality across components Add support for user-selected TMDB language preference in the LogoSourceSettings and MetadataScreen components. Update the settings interface to include a language preference, synchronize state with user selections, and improve logo fetching logic in TMDBService to prioritize the selected language. Enhance UI elements to provide clear feedback on language selection and ensure a seamless user experience when fetching logos. --- src/hooks/useSettings.ts | 2 + src/screens/LogoSourceSettings.tsx | 104 ++++++++++++++++++++++++----- src/screens/MetadataScreen.tsx | 11 +-- src/services/tmdbService.ts | 88 +++++++++++++++++++++--- 4 files changed, 173 insertions(+), 32 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 576ff3a..3f55f4e 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -33,6 +33,7 @@ export interface AppSettings { featuredContentSource: 'tmdb' | 'catalogs'; selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos + tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) } export const DEFAULT_SETTINGS: AppSettings = { @@ -48,6 +49,7 @@ export const DEFAULT_SETTINGS: AppSettings = { featuredContentSource: 'tmdb', selectedHeroCatalogs: [], // Empty array means all catalogs are selected logoSourcePreference: 'metahub', // Default to Metahub as first source + tmdbLanguagePreference: 'en', // Default to English }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 2ebb9be..1ae37e7 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -81,11 +81,21 @@ const LogoSourceSettings = () => { settings.logoSourcePreference || 'metahub' ); + // TMDB Language Preference + const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState( + settings.tmdbLanguagePreference || 'en' + ); + // Make sure logoSource stays in sync with settings useEffect(() => { setLogoSource(settings.logoSourcePreference || 'metahub'); }, [settings.logoSourcePreference]); + // Keep selectedTmdbLanguage in sync with settings + useEffect(() => { + setSelectedTmdbLanguage(settings.tmdbLanguagePreference || 'en'); + }, [settings.tmdbLanguagePreference]); + // Selected example show const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]); @@ -97,7 +107,6 @@ const LogoSourceSettings = () => { const [loadingLogos, setLoadingLogos] = useState(true); // State for TMDB language selection - const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState('en'); // Store unique language codes as strings const [uniqueTmdbLanguages, setUniqueTmdbLanguages] = useState([]); const [tmdbLogosData, setTmdbLogosData] = useState | null>(null); @@ -117,7 +126,6 @@ const LogoSourceSettings = () => { // Reset unique languages and logos data setUniqueTmdbLanguages([]); setTmdbLogosData(null); - setSelectedTmdbLanguage('en'); // Reset to default language try { const tmdbService = TMDBService.getInstance(); @@ -147,21 +155,31 @@ const LogoSourceSettings = () => { const uniqueCodes: string[] = [...new Set(validLogoLanguages)]; setUniqueTmdbLanguages(uniqueCodes); - // Find initial logo (prefer 'en') + // Find initial logo (prefer selectedTmdbLanguage, then 'en') let initialLogoPath: string | null = null; - let initialLanguage = 'en'; + let initialLanguage = selectedTmdbLanguage; - const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en'); + // First try to find a logo in the user's preferred language + const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === selectedTmdbLanguage); - if (englishLogo) { - initialLogoPath = englishLogo.file_path; - initialLanguage = 'en'; - logger.log(`[LogoSourceSettings] Found initial English TMDB logo for ${show.name}`); - } else if (imagesData.logos[0]) { - // Fallback to the first available logo - initialLogoPath = imagesData.logos[0].file_path; - initialLanguage = imagesData.logos[0].iso_639_1; - logger.log(`[LogoSourceSettings] No English logo, using first available (${initialLanguage}) TMDB logo for ${show.name}`); + if (preferredLogo) { + initialLogoPath = preferredLogo.file_path; + initialLanguage = selectedTmdbLanguage; + logger.log(`[LogoSourceSettings] Found initial ${selectedTmdbLanguage} TMDB logo for ${show.name}`); + } else { + // Fallback to English logo + const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en'); + + if (englishLogo) { + initialLogoPath = englishLogo.file_path; + initialLanguage = 'en'; + logger.log(`[LogoSourceSettings] Found initial English TMDB logo for ${show.name}`); + } else if (imagesData.logos[0]) { + // Fallback to the first available logo + initialLogoPath = imagesData.logos[0].file_path; + initialLanguage = imagesData.logos[0].iso_639_1; + logger.log(`[LogoSourceSettings] No English logo, using first available (${initialLanguage}) TMDB logo for ${show.name}`); + } } if (initialLogoPath) { @@ -228,7 +246,7 @@ const LogoSourceSettings = () => { } }; - // Apply setting and show confirmation + // Apply logo source setting and show confirmation const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { setLogoSource(source); updateSetting('logoSourcePreference', source); @@ -248,6 +266,26 @@ const LogoSourceSettings = () => { ); }; + // Apply TMDB language setting + const applyTmdbLanguageSetting = (languageCode: string) => { + setSelectedTmdbLanguage(languageCode); + updateSetting('tmdbLanguagePreference', languageCode); + + // Clear any cached logo data in storage + try { + AsyncStorage.removeItem('_last_logos_'); + } catch (e) { + console.error('Error clearing logo cache:', e); + } + + // Show confirmation toast or feedback + Alert.alert( + 'TMDB Language Updated', + `TMDB logo language preference set to ${languageCode.toUpperCase()}. Changes will apply when you navigate to content.`, + [{ text: 'OK' }] + ); + }; + // Save selected show to AsyncStorage to persist across navigation const saveSelectedShow = async (show: typeof EXAMPLE_SHOWS[0]) => { try { @@ -323,7 +361,9 @@ const LogoSourceSettings = () => { // Handle TMDB language selection const handleTmdbLanguageSelect = (languageCode: string) => { + // Update local state for the example setSelectedTmdbLanguage(languageCode); + if (tmdbLogosData) { const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode); if (selectedLogoData) { @@ -333,6 +373,9 @@ const LogoSourceSettings = () => { logger.warn(`[LogoSourceSettings] Could not find logo data for selected language: ${languageCode}`); } } + + // Also update the app-wide setting + applyTmdbLanguageSetting(languageCode); }; return ( @@ -447,7 +490,10 @@ const LogoSourceSettings = () => { {/* TMDB Language Selector */} {uniqueTmdbLanguages.length > 1 && ( - Available logo languages: + Logo Language + + Select your preferred language for TMDB logos. This affects all content when TMDB is used as the logo source. + { ))} + + Note: Not all titles have logos in all languages. If a logo isn't available in your preferred language, English will be used as a fallback. + )} @@ -633,6 +682,21 @@ const LogoSourceSettings = () => { }, languageSelectorContainer: { marginTop: 16, + padding: 12, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 8, + }, + languageSelectorTitle: { + color: colors.white, + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + }, + languageSelectorDescription: { + color: colors.mediumEmphasis, + fontSize: 14, + lineHeight: 20, + marginBottom: 12, }, languageSelectorLabel: { color: colors.mediumEmphasis, @@ -640,7 +704,7 @@ const LogoSourceSettings = () => { marginBottom: 8, }, languageScrollContent: { - paddingRight: 16, // Match container padding + paddingVertical: 4, }, languageItem: { paddingHorizontal: 12, @@ -663,6 +727,12 @@ const LogoSourceSettings = () => { selectedLanguageItemText: { color: colors.white, }, + noteText: { + color: colors.mediumEmphasis, + fontSize: 12, + marginTop: 12, + fontStyle: 'italic', + }, bannerContainer: { height: 120, width: '100%', diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index db02a12..d2ba5a9 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -690,10 +690,12 @@ const MetadataScreen = () => { if (id.startsWith('tmdb:')) { const tmdbId = id.split(':')[1]; const tmdbType = type === 'series' ? 'tv' : 'movie'; - logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId})`); + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + + logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId}, preferred language: ${preferredLanguage})`); try { const tmdbService = TMDBService.getInstance(); - tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); + tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); if (tmdbLogoUrl) { logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); @@ -705,7 +707,8 @@ const MetadataScreen = () => { } } else if (imdbId) { // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID - logger.log(`[MetadataScreen] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo`); + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + logger.log(`[MetadataScreen] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo, preferred language: ${preferredLanguage}`); try { const tmdbService = TMDBService.getInstance(); const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); @@ -714,7 +717,7 @@ const MetadataScreen = () => { logger.log(`[MetadataScreen] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching - tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString()); + tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString(), preferredLanguage); if (tmdbLogoUrl) { logger.log(`[MetadataScreen] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 3a1d2df..5fca12e 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -568,14 +568,14 @@ export class TMDBService { /** * Get movie images (logos, posters, backdrops) by TMDB ID */ - async getMovieImages(movieId: number | string): Promise { + async getMovieImages(movieId: number | string, preferredLanguage: string = 'en'): Promise { try { - logger.log(`[TMDBService] Fetching movie images for TMDB ID: ${movieId}`); + logger.log(`[TMDBService] Fetching movie images for TMDB ID: ${movieId}, preferred language: ${preferredLanguage}`); const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ - include_image_language: 'en,null' + include_image_language: `${preferredLanguage},en,null` }), }); @@ -583,7 +583,40 @@ export class TMDBService { logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for movie ID ${movieId}`); if (images && images.logos && images.logos.length > 0) { - // First prioritize English SVG logos + // First prioritize preferred language SVG logos if not English + if (preferredLanguage !== 'en') { + const preferredSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && + logo.iso_639_1 === preferredLanguage + ); + if (preferredSvgLogo) { + logger.log(`[TMDBService] Found ${preferredLanguage} SVG logo for movie ID ${movieId}: ${preferredSvgLogo.file_path}`); + return this.getImageUrl(preferredSvgLogo.file_path); + } + + // Then preferred language PNG logos + const preferredPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && + logo.iso_639_1 === preferredLanguage + ); + if (preferredPngLogo) { + logger.log(`[TMDBService] Found ${preferredLanguage} PNG logo for movie ID ${movieId}: ${preferredPngLogo.file_path}`); + return this.getImageUrl(preferredPngLogo.file_path); + } + + // Then any preferred language logo + const preferredLogo = images.logos.find((logo: any) => + logo.iso_639_1 === preferredLanguage + ); + if (preferredLogo) { + logger.log(`[TMDBService] Found ${preferredLanguage} logo for movie ID ${movieId}: ${preferredLogo.file_path}`); + return this.getImageUrl(preferredLogo.file_path); + } + } + + // Then prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.svg') && @@ -649,14 +682,14 @@ export class TMDBService { /** * Get TV show images (logos, posters, backdrops) by TMDB ID */ - async getTvShowImages(showId: number | string): Promise { + async getTvShowImages(showId: number | string, preferredLanguage: string = 'en'): Promise { try { - logger.log(`[TMDBService] Fetching TV show images for TMDB ID: ${showId}`); + logger.log(`[TMDBService] Fetching TV show images for TMDB ID: ${showId}, preferred language: ${preferredLanguage}`); const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ - include_image_language: 'en,null' + include_image_language: `${preferredLanguage},en,null` }), }); @@ -664,6 +697,39 @@ export class TMDBService { logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for TV show ID ${showId}`); if (images && images.logos && images.logos.length > 0) { + // First prioritize preferred language SVG logos if not English + if (preferredLanguage !== 'en') { + const preferredSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && + logo.iso_639_1 === preferredLanguage + ); + if (preferredSvgLogo) { + logger.log(`[TMDBService] Found ${preferredLanguage} SVG logo for TV show ID ${showId}: ${preferredSvgLogo.file_path}`); + return this.getImageUrl(preferredSvgLogo.file_path); + } + + // Then preferred language PNG logos + const preferredPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && + logo.iso_639_1 === preferredLanguage + ); + if (preferredPngLogo) { + logger.log(`[TMDBService] Found ${preferredLanguage} PNG logo for TV show ID ${showId}: ${preferredPngLogo.file_path}`); + return this.getImageUrl(preferredPngLogo.file_path); + } + + // Then any preferred language logo + const preferredLogo = images.logos.find((logo: any) => + logo.iso_639_1 === preferredLanguage + ); + if (preferredLogo) { + logger.log(`[TMDBService] Found ${preferredLanguage} logo for TV show ID ${showId}: ${preferredLogo.file_path}`); + return this.getImageUrl(preferredLogo.file_path); + } + } + // First prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => logo.file_path && @@ -730,13 +796,13 @@ export class TMDBService { /** * Get content logo based on type (movie or TV show) */ - async getContentLogo(type: 'movie' | 'tv', id: number | string): Promise { + async getContentLogo(type: 'movie' | 'tv', id: number | string, preferredLanguage: string = 'en'): Promise { try { - logger.log(`[TMDBService] Getting content logo for ${type} with ID ${id}`); + logger.log(`[TMDBService] Getting content logo for ${type} with ID ${id}, preferred language: ${preferredLanguage}`); const result = type === 'movie' - ? await this.getMovieImages(id) - : await this.getTvShowImages(id); + ? await this.getMovieImages(id, preferredLanguage) + : await this.getTvShowImages(id, preferredLanguage); if (result) { logger.log(`[TMDBService] Successfully retrieved logo for ${type} ID ${id}: ${result}`); From 9753101ef140552fabe5ac5a38786584a125ea20 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:46:43 +0530 Subject: [PATCH 11/38] Implement AsyncStorage integration for logo source and TMDB language preferences in LogoSourceSettings Enhance the LogoSourceSettings component to load and persist user preferences for logo source and TMDB language using AsyncStorage. Update the applyLogoSourceSetting and saveLanguagePreference functions to ensure settings are synchronized with local storage, improving user experience and data consistency. Additionally, refine the MetadataScreen to utilize the selected language preference when fetching banners, ensuring accurate and localized content retrieval. --- src/screens/LogoSourceSettings.tsx | 127 +++++++++++++++++++++++------ src/screens/MetadataScreen.tsx | 30 ++++++- 2 files changed, 129 insertions(+), 28 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 1ae37e7..7201700 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -96,6 +96,33 @@ const LogoSourceSettings = () => { setSelectedTmdbLanguage(settings.tmdbLanguagePreference || 'en'); }, [settings.tmdbLanguagePreference]); + // Force reload settings from AsyncStorage when component mounts + useEffect(() => { + const loadSettingsFromStorage = async () => { + try { + const settingsJson = await AsyncStorage.getItem('app_settings'); + if (settingsJson) { + const storedSettings = JSON.parse(settingsJson); + + // Update local state to match stored settings + if (storedSettings.logoSourcePreference) { + setLogoSource(storedSettings.logoSourcePreference); + } + + if (storedSettings.tmdbLanguagePreference) { + setSelectedTmdbLanguage(storedSettings.tmdbLanguagePreference); + } + + logger.log('[LogoSourceSettings] Successfully loaded settings from AsyncStorage'); + } + } catch (error) { + logger.error('[LogoSourceSettings] Error loading settings from AsyncStorage:', error); + } + }; + + loadSettingsFromStorage(); + }, []); + // Selected example show const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]); @@ -248,14 +275,40 @@ const LogoSourceSettings = () => { // Apply logo source setting and show confirmation const applyLogoSourceSetting = (source: 'metahub' | 'tmdb') => { + // Update local state first setLogoSource(source); + + // Update using the settings hook updateSetting('logoSourcePreference', source); - // Clear any cached logo data in storage + // Also save directly to AsyncStorage for extra assurance try { + // Get current settings + AsyncStorage.getItem('app_settings').then((settingsJson) => { + if (settingsJson) { + const currentSettings = JSON.parse(settingsJson); + // Update the logo source preference + const updatedSettings = { + ...currentSettings, + logoSourcePreference: source + }; + // Save back to AsyncStorage + AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings)) + .then(() => { + logger.log(`[LogoSourceSettings] Successfully saved logo source preference '${source}' to AsyncStorage`); + }) + .catch((error) => { + logger.error(`[LogoSourceSettings] Error saving logo source preference to AsyncStorage:`, error); + }); + } + }).catch((error) => { + logger.error(`[LogoSourceSettings] Error getting current settings:`, error); + }); + + // Clear any cached logo data AsyncStorage.removeItem('_last_logos_'); } catch (e) { - console.error('Error clearing logo cache:', e); + logger.error(`[LogoSourceSettings] Error in applyLogoSourceSetting:`, e); } // Show confirmation alert @@ -266,16 +319,59 @@ const LogoSourceSettings = () => { ); }; - // Apply TMDB language setting - const applyTmdbLanguageSetting = (languageCode: string) => { + // Handle TMDB language selection + const handleTmdbLanguageSelect = (languageCode: string) => { + // First set local state for immediate UI updates setSelectedTmdbLanguage(languageCode); + + // Update the preview logo if possible + if (tmdbLogosData) { + const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode); + if (selectedLogoData) { + setTmdbLogo(`https://image.tmdb.org/t/p/original${selectedLogoData.file_path}`); + logger.log(`[LogoSourceSettings] Switched TMDB logo preview to language: ${languageCode}`); + } else { + logger.warn(`[LogoSourceSettings] Could not find logo data for selected language: ${languageCode}`); + } + } + + // Then persist the setting globally + saveLanguagePreference(languageCode); + }; + + // Save language preference with proper persistence + const saveLanguagePreference = (languageCode: string) => { + // First use the settings hook to update the setting updateSetting('tmdbLanguagePreference', languageCode); - // Clear any cached logo data in storage + // For extra assurance, also save directly to AsyncStorage try { + // Get current settings + AsyncStorage.getItem('app_settings').then((settingsJson) => { + if (settingsJson) { + const currentSettings = JSON.parse(settingsJson); + // Update the language preference + const updatedSettings = { + ...currentSettings, + tmdbLanguagePreference: languageCode + }; + // Save back to AsyncStorage + AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings)) + .then(() => { + logger.log(`[LogoSourceSettings] Successfully saved TMDB language preference '${languageCode}' to AsyncStorage`); + }) + .catch((error) => { + logger.error(`[LogoSourceSettings] Error saving TMDB language preference to AsyncStorage:`, error); + }); + } + }).catch((error) => { + logger.error(`[LogoSourceSettings] Error getting current settings:`, error); + }); + + // Clear any cached logo data AsyncStorage.removeItem('_last_logos_'); } catch (e) { - console.error('Error clearing logo cache:', e); + logger.error(`[LogoSourceSettings] Error in saveLanguagePreference:`, e); } // Show confirmation toast or feedback @@ -359,25 +455,6 @@ const LogoSourceSettings = () => { ); }; - // Handle TMDB language selection - const handleTmdbLanguageSelect = (languageCode: string) => { - // Update local state for the example - setSelectedTmdbLanguage(languageCode); - - if (tmdbLogosData) { - const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode); - if (selectedLogoData) { - setTmdbLogo(`https://image.tmdb.org/t/p/original${selectedLogoData.file_path}`); - logger.log(`[LogoSourceSettings] Switched TMDB logo to language: ${languageCode}`); - } else { - logger.warn(`[LogoSourceSettings] Could not find logo data for selected language: ${languageCode}`); - } - } - - // Also update the app-wide setting - applyTmdbLanguageSetting(languageCode); - }; - return ( diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index d2ba5a9..a6ca4d1 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -287,6 +287,7 @@ const MetadataScreen = () => { let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback const preference = settings.logoSourcePreference || 'metahub'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key // Extract IDs @@ -302,7 +303,7 @@ const MetadataScreen = () => { const currentImdbId = imdbId; const contentType = type === 'series' ? 'tv' : 'movie'; - logger.log(`[MetadataScreen] Fetching banner with preference: ${preference}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); + logger.log(`[MetadataScreen] Fetching banner with preference: ${preference}, language: ${preferredLanguage}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); try { if (preference === 'tmdb') { @@ -312,11 +313,34 @@ const MetadataScreen = () => { logger.log(`[MetadataScreen] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); try { const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}&include_image_language=${preferredLanguage},en,null`); const imagesData = await response.json(); if (imagesData.backdrops && imagesData.backdrops.length > 0) { - const backdropPath = imagesData.backdrops[0].file_path; + // Try to find backdrop in preferred language first + let backdropPath = null; + + if (preferredLanguage !== 'en') { + const preferredBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === preferredLanguage); + if (preferredBackdrop) { + backdropPath = preferredBackdrop.file_path; + logger.log(`[MetadataScreen] Found ${preferredLanguage} backdrop for ID: ${currentTmdbId}`); + } + } + + // Fall back to English backdrop + if (!backdropPath) { + const englishBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === 'en'); + if (englishBackdrop) { + backdropPath = englishBackdrop.file_path; + logger.log(`[MetadataScreen] Found English backdrop for ID: ${currentTmdbId}`); + } else { + // Last resort: use the first backdrop + backdropPath = imagesData.backdrops[0].file_path; + logger.log(`[MetadataScreen] Using first available backdrop for ID: ${currentTmdbId}`); + } + } + tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; logger.log(`[MetadataScreen] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); } else { From bf40002dec809dbe2da483a344b2882047e5a26f Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:48:26 +0530 Subject: [PATCH 12/38] Enhance logo fetching in FeaturedContent to support TMDB language preference Update the FeaturedContent component to incorporate user-selected TMDB language preference when fetching logos. Modify logging statements to include the preferred language for better debugging. Adjust dependencies in the useEffect hook to ensure the component reacts to changes in language preference, improving the overall user experience and content localization. --- src/components/home/FeaturedContent.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index f161d8d..3de6723 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -127,6 +127,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // Get logo source preference from settings const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; // Get preferred language // Check if current logo matches preferences const currentLogo = featuredContent.logo; @@ -143,7 +144,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } - logger.log(`[FeaturedContent] Fetching logo with preference: ${logoPreference}, ID: ${contentId}`); + logger.log(`[FeaturedContent] Fetching logo with preference: ${logoPreference}, language: ${preferredLanguage}, ID: ${contentId}`); // Extract IMDB ID if available let imdbId = null; @@ -183,10 +184,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; try { const tmdbService = TMDBService.getInstance(); - const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); if (logoUrl) { - logger.log(`[FeaturedContent] Using fallback TMDB logo: ${logoUrl}`); + logger.log(`[FeaturedContent] Using fallback TMDB logo (${preferredLanguage}): ${logoUrl}`); setLogoUrl(logoUrl); } else if (currentLogo) { // If TMDB fails too, use existing logo if any @@ -206,10 +207,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; try { const tmdbService = TMDBService.getInstance(); - const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId); + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); if (logoUrl) { - logger.log(`[FeaturedContent] Using TMDB logo: ${logoUrl}`); + logger.log(`[FeaturedContent] Using TMDB logo (${preferredLanguage}): ${logoUrl}`); setLogoUrl(logoUrl); logoFetchInProgress.current = false; return; // Exit if TMDB logo was found @@ -225,10 +226,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat if (foundTmdbId) { const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; - const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString()); + const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString(), preferredLanguage); if (logoUrl) { - logger.log(`[FeaturedContent] Using TMDB logo via IMDB lookup: ${logoUrl}`); + logger.log(`[FeaturedContent] Using TMDB logo (${preferredLanguage}) via IMDB lookup: ${logoUrl}`); setLogoUrl(logoUrl); logoFetchInProgress.current = false; return; // Exit if TMDB logo was found @@ -271,7 +272,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat }; fetchLogo(); - }, [featuredContent?.id, settings.logoSourcePreference]); + }, [featuredContent?.id, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Load poster and logo useEffect(() => { From 612bb5a0d046e78c167315ab26798f84005b92b9 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:50:55 +0530 Subject: [PATCH 13/38] Enhance LogoSourceSettings UI with improved scroll performance and visual feedback Update the LogoSourceSettings component to enhance user experience by adding scroll performance optimizations, including vertical scroll indicators and adjusted scroll event throttling. Introduce active opacity and delay press settings for touchable elements to improve responsiveness. Additionally, refine styling with shadows and elevation for better visual hierarchy and feedback on selected items, ensuring a more polished and user-friendly interface. --- src/screens/LogoSourceSettings.tsx | 51 ++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 7201700..7682636 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -472,7 +472,12 @@ const LogoSourceSettings = () => { - + {/* Description */} @@ -488,6 +493,8 @@ const LogoSourceSettings = () => { horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.showsScrollContent} + scrollEventThrottle={32} + decelerationRate="normal" > {EXAMPLE_SHOWS.map((show) => ( { selectedShow.imdbId === show.imdbId && styles.selectedShowItem ]} onPress={() => handleShowSelect(show)} + activeOpacity={0.7} + delayPressIn={100} > { logoSource === 'metahub' && styles.selectedCard ]} onPress={() => applyLogoSourceSetting('metahub')} + activeOpacity={0.7} + delayPressIn={100} > Metahub @@ -545,6 +556,8 @@ const LogoSourceSettings = () => { logoSource === 'tmdb' && styles.selectedCard ]} onPress={() => applyLogoSourceSetting('tmdb')} + activeOpacity={0.7} + delayPressIn={100} > TMDB @@ -575,6 +588,8 @@ const LogoSourceSettings = () => { horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.languageScrollContent} + scrollEventThrottle={32} + decelerationRate="normal" > {/* Iterate over unique language codes */} {uniqueTmdbLanguages.map((langCode) => ( @@ -585,6 +600,8 @@ const LogoSourceSettings = () => { selectedTmdbLanguage === langCode && styles.selectedLanguageItem ]} onPress={() => handleTmdbLanguageSelect(langCode)} + activeOpacity={0.7} + delayPressIn={150} > { marginRight: 8, borderWidth: 1, borderColor: 'transparent', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 1, }, selectedShowItem: { borderColor: colors.primary, backgroundColor: colors.elevation3, + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + elevation: 2, }, showItemText: { color: colors.mediumEmphasis, @@ -700,9 +727,18 @@ const LogoSourceSettings = () => { padding: 16, borderWidth: 2, borderColor: 'transparent', + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + elevation: 2, }, selectedCard: { borderColor: colors.primary, + shadowColor: colors.primary, + shadowOpacity: 0.3, + elevation: 3, }, optionHeader: { flexDirection: 'row', @@ -785,16 +821,27 @@ const LogoSourceSettings = () => { }, languageItem: { paddingHorizontal: 12, - paddingVertical: 6, + paddingVertical: 8, backgroundColor: colors.elevation1, borderRadius: 16, marginRight: 8, borderWidth: 1, borderColor: colors.elevation3, + marginVertical: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 1, + elevation: 1, }, selectedLanguageItem: { backgroundColor: colors.primary, borderColor: colors.primary, + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 2, }, languageItemText: { color: colors.mediumEmphasis, From 4e73409d01e453896179619fe17dd895fc875db1 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:53:41 +0530 Subject: [PATCH 14/38] Refactor LogoSourceSettings UI for clarity and consistency Simplify text descriptions and improve layout in the LogoSourceSettings component for better user understanding. Adjust styling properties for various elements, including padding, font sizes, and margins, to create a more cohesive and visually appealing interface. Ensure that fallback messaging is clear and concise, enhancing overall user experience. --- src/screens/LogoSourceSettings.tsx | 148 ++++++++++++++--------------- 1 file changed, 72 insertions(+), 76 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 7682636..f3d67df 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -481,8 +481,7 @@ const LogoSourceSettings = () => { {/* Description */} - Choose the primary source for content logos and background images. This affects the appearance - of titles in the metadata screen. + Choose the primary source for content logos and backgrounds. @@ -539,8 +538,7 @@ const LogoSourceSettings = () => { - Prioritizes high-quality title logos from the Metahub image repository. - Offers good coverage for popular titles. + High-quality logos from Metahub. Best for popular titles. @@ -567,8 +565,7 @@ const LogoSourceSettings = () => { - Uses logos from The Movie Database. Often includes more localized and newer logos, - with better coverage for recent content. + Logos from TMDB. Offers localized options and better coverage for recent content. @@ -582,7 +579,7 @@ const LogoSourceSettings = () => { Logo Language - Select your preferred language for TMDB logos. This affects all content when TMDB is used as the logo source. + Select your preferred language for TMDB logos. { ))} - Note: Not all titles have logos in all languages. If a logo isn't available in your preferred language, English will be used as a fallback. + If unavailable in preferred language, English will be used as fallback. )} @@ -625,8 +622,7 @@ const LogoSourceSettings = () => { {/* Additional Info */} - If a logo is not available from your preferred source, the app will automatically fall back to the other source. - If no logo is found, the title text will be shown instead. + Unavailable logos will fall back to the alternate source, or display text if none found. @@ -643,70 +639,70 @@ const LogoSourceSettings = () => { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 16, - height: 56, + paddingHorizontal: 12, + height: 48, backgroundColor: colors.elevation2, }, backButton: { - width: 40, - height: 40, + width: 36, + height: 36, alignItems: 'center', justifyContent: 'center', }, headerTitle: { color: colors.white, - fontSize: 20, + fontSize: 18, fontWeight: '600', }, headerRight: { - width: 40, + width: 36, }, scrollView: { flex: 1, }, descriptionContainer: { - padding: 16, + padding: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.1)', }, description: { color: colors.text, - fontSize: 16, - lineHeight: 24, + fontSize: 14, + lineHeight: 20, }, showSelectorContainer: { - padding: 16, - paddingBottom: 8, + padding: 12, + paddingBottom: 6, }, selectorLabel: { color: colors.text, - fontSize: 16, - marginBottom: 12, + fontSize: 14, + marginBottom: 8, }, showsScrollContent: { paddingRight: 16, }, showItem: { - paddingHorizontal: 16, - paddingVertical: 8, + paddingHorizontal: 12, + paddingVertical: 6, backgroundColor: colors.elevation2, - borderRadius: 20, - marginRight: 8, + borderRadius: 16, + marginRight: 6, borderWidth: 1, borderColor: 'transparent', shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, - shadowRadius: 2, + shadowRadius: 1, elevation: 1, }, selectedShowItem: { borderColor: colors.primary, backgroundColor: colors.elevation3, shadowColor: colors.primary, - shadowOffset: { width: 0, height: 2 }, + shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, - shadowRadius: 3, + shadowRadius: 2, elevation: 2, }, showItemText: { @@ -718,16 +714,16 @@ const LogoSourceSettings = () => { fontWeight: '600', }, optionsContainer: { - padding: 16, - gap: 16, + padding: 10, + gap: 10, }, optionCard: { backgroundColor: colors.elevation2, - borderRadius: 12, - padding: 16, + borderRadius: 8, + padding: 12, borderWidth: 2, borderColor: 'transparent', - marginBottom: 12, + marginBottom: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, @@ -744,26 +740,26 @@ const LogoSourceSettings = () => { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 8, + marginBottom: 6, }, optionTitle: { color: colors.white, - fontSize: 18, + fontSize: 16, fontWeight: '600', }, optionDescription: { color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, - marginBottom: 16, + fontSize: 13, + lineHeight: 18, + marginBottom: 10, }, exampleContainer: { - marginTop: 8, + marginTop: 4, }, exampleLabel: { color: colors.mediumEmphasis, - fontSize: 14, - marginBottom: 8, + fontSize: 13, + marginBottom: 4, }, exampleImage: { height: 60, @@ -776,58 +772,58 @@ const LogoSourceSettings = () => { alignItems: 'center', }, infoBox: { - margin: 16, - padding: 16, + margin: 10, + padding: 10, backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 8, - borderLeftWidth: 4, + borderRadius: 6, + borderLeftWidth: 3, borderLeftColor: colors.primary, }, infoText: { color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, + fontSize: 12, + lineHeight: 18, }, logoSourceLabel: { color: colors.mediumEmphasis, - fontSize: 12, - marginTop: 4, + fontSize: 11, + marginTop: 2, }, languageSelectorContainer: { - marginTop: 16, - padding: 12, + marginTop: 10, + padding: 10, backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 8, + borderRadius: 6, }, languageSelectorTitle: { color: colors.white, - fontSize: 16, + fontSize: 14, fontWeight: '600', - marginBottom: 8, + marginBottom: 4, }, languageSelectorDescription: { color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, - marginBottom: 12, + fontSize: 12, + lineHeight: 18, + marginBottom: 8, }, languageSelectorLabel: { color: colors.mediumEmphasis, - fontSize: 13, - marginBottom: 8, + fontSize: 12, + marginBottom: 6, }, languageScrollContent: { - paddingVertical: 4, + paddingVertical: 2, }, languageItem: { - paddingHorizontal: 12, - paddingVertical: 8, + paddingHorizontal: 10, + paddingVertical: 6, backgroundColor: colors.elevation1, - borderRadius: 16, - marginRight: 8, + borderRadius: 12, + marginRight: 6, borderWidth: 1, borderColor: colors.elevation3, - marginVertical: 2, + marginVertical: 1, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, @@ -838,14 +834,14 @@ const LogoSourceSettings = () => { backgroundColor: colors.primary, borderColor: colors.primary, shadowColor: colors.primary, - shadowOffset: { width: 0, height: 2 }, + shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, - shadowRadius: 2, + shadowRadius: 1, elevation: 2, }, languageItemText: { color: colors.mediumEmphasis, - fontSize: 13, + fontSize: 12, fontWeight: '600', }, selectedLanguageItemText: { @@ -853,14 +849,14 @@ const LogoSourceSettings = () => { }, noteText: { color: colors.mediumEmphasis, - fontSize: 12, - marginTop: 12, + fontSize: 11, + marginTop: 8, fontStyle: 'italic', }, bannerContainer: { - height: 120, + height: 90, width: '100%', - borderRadius: 8, + borderRadius: 6, overflow: 'hidden', position: 'relative', }, @@ -874,9 +870,9 @@ const LogoSourceSettings = () => { logoOverBanner: { position: 'absolute', width: '80%', - height: '80%', + height: '75%', alignSelf: 'center', - top: '10%', + top: '12.5%', }, noLogoContainer: { position: 'absolute', From 303c1ce88381f04f6806f954d1b10e1c2a7c85c3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 19:57:17 +0530 Subject: [PATCH 15/38] Refactor LogoSourceSettings layout and styling for improved usability Adjust the layout and styling of the LogoSourceSettings component to enhance visual consistency and user experience. Changes include updated padding, margins, and font sizes for various elements, as well as the introduction of a new content container style for better scroll performance. These refinements aim to create a more cohesive and user-friendly interface. --- src/screens/LogoSourceSettings.tsx | 58 +++++++++++++++--------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index f3d67df..7f51efc 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -460,7 +460,7 @@ const LogoSourceSettings = () => { {/* Header */} - + { Logo Source - { header: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 12, - height: 48, - backgroundColor: colors.elevation2, + paddingHorizontal: 16, + paddingVertical: 16, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 16 : 16, + backgroundColor: colors.darkBackground, }, backButton: { - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', + padding: 4, }, headerTitle: { - color: colors.white, - fontSize: 18, + fontSize: 22, fontWeight: '600', + marginLeft: 16, + color: colors.white, }, headerRight: { - width: 36, + width: 24, }, scrollView: { flex: 1, }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 24, + }, descriptionContainer: { - padding: 12, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', + marginBottom: 16, }, description: { - color: colors.text, - fontSize: 14, - lineHeight: 20, + color: colors.mediumEmphasis, + fontSize: 15, + lineHeight: 22, }, showSelectorContainer: { - padding: 12, - paddingBottom: 6, + marginBottom: 16, }, selectorLabel: { - color: colors.text, - fontSize: 14, - marginBottom: 8, + color: colors.highEmphasis, + fontSize: 16, + fontWeight: '500', + marginBottom: 12, }, showsScrollContent: { paddingRight: 16, @@ -714,8 +714,8 @@ const LogoSourceSettings = () => { fontWeight: '600', }, optionsContainer: { - padding: 10, - gap: 10, + marginBottom: 16, + gap: 12, }, optionCard: { backgroundColor: colors.elevation2, @@ -772,10 +772,10 @@ const LogoSourceSettings = () => { alignItems: 'center', }, infoBox: { - margin: 10, - padding: 10, + marginBottom: 16, + padding: 12, backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 6, + borderRadius: 8, borderLeftWidth: 3, borderLeftColor: colors.primary, }, From 843d31707c59d9d792c788eafb0121470ff38289 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 20:00:30 +0530 Subject: [PATCH 16/38] Refactor saveLanguagePreference function in LogoSourceSettings for improved AsyncStorage handling Update the saveLanguagePreference function to utilize async/await for better error handling and clarity. Introduce logic to create new settings if none exist, ensuring user preferences are correctly saved to AsyncStorage. Enhance logging and user feedback with alerts for successful updates and error notifications, improving overall user experience. --- src/screens/LogoSourceSettings.tsx | 82 +++++++++++++++++------------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 7f51efc..1d45b27 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -17,7 +17,7 @@ import { NavigationProp, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { colors } from '../styles/colors'; -import { useSettings } from '../hooks/useSettings'; +import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { TMDBService } from '../services/tmdbService'; import { logger } from '../utils/logger'; @@ -340,46 +340,60 @@ const LogoSourceSettings = () => { }; // Save language preference with proper persistence - const saveLanguagePreference = (languageCode: string) => { - // First use the settings hook to update the setting - updateSetting('tmdbLanguagePreference', languageCode); + const saveLanguagePreference = async (languageCode: string) => { + logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`); - // For extra assurance, also save directly to AsyncStorage try { - // Get current settings - AsyncStorage.getItem('app_settings').then((settingsJson) => { - if (settingsJson) { - const currentSettings = JSON.parse(settingsJson); - // Update the language preference - const updatedSettings = { - ...currentSettings, - tmdbLanguagePreference: languageCode - }; - // Save back to AsyncStorage - AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings)) - .then(() => { - logger.log(`[LogoSourceSettings] Successfully saved TMDB language preference '${languageCode}' to AsyncStorage`); - }) - .catch((error) => { - logger.error(`[LogoSourceSettings] Error saving TMDB language preference to AsyncStorage:`, error); - }); - } - }).catch((error) => { - logger.error(`[LogoSourceSettings] Error getting current settings:`, error); - }); + // First use the settings hook to update the setting - this is crucial + updateSetting('tmdbLanguagePreference', languageCode); + + // For extra assurance, also save directly to AsyncStorage + // Get current settings from AsyncStorage + const settingsJson = await AsyncStorage.getItem('app_settings'); + + if (settingsJson) { + const currentSettings = JSON.parse(settingsJson); + + // Update the language preference + const updatedSettings = { + ...currentSettings, + tmdbLanguagePreference: languageCode + }; + + // Save back to AsyncStorage using await to ensure it completes + await AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings)); + logger.log(`[LogoSourceSettings] Successfully saved TMDB language preference '${languageCode}' to AsyncStorage`); + } else { + // If no settings exist yet, create new settings object with this preference + const newSettings = { + ...DEFAULT_SETTINGS, + tmdbLanguagePreference: languageCode + }; + + // Save to AsyncStorage + await AsyncStorage.setItem('app_settings', JSON.stringify(newSettings)); + logger.log(`[LogoSourceSettings] Created new settings with TMDB language preference '${languageCode}'`); + } // Clear any cached logo data - AsyncStorage.removeItem('_last_logos_'); + await AsyncStorage.removeItem('_last_logos_'); + + // Show confirmation toast or feedback + Alert.alert( + 'TMDB Language Updated', + `TMDB logo language preference set to ${languageCode.toUpperCase()}. Changes will apply when you navigate to content.`, + [{ text: 'OK' }] + ); } catch (e) { logger.error(`[LogoSourceSettings] Error in saveLanguagePreference:`, e); + + // Show error notification + Alert.alert( + 'Error Saving Preference', + 'There was a problem saving your language preference. Please try again.', + [{ text: 'OK' }] + ); } - - // Show confirmation toast or feedback - Alert.alert( - 'TMDB Language Updated', - `TMDB logo language preference set to ${languageCode.toUpperCase()}. Changes will apply when you navigate to content.`, - [{ text: 'OK' }] - ); }; // Save selected show to AsyncStorage to persist across navigation From ed358c85feac614d139560106641c0daaff71ad2 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 21:49:20 +0530 Subject: [PATCH 17/38] Implement theme context integration across components for improved UI consistency Refactor various components to utilize the new ThemeContext for dynamic theming. This includes updating styles in the App, NuvioHeader, CatalogSection, and other components to reflect the current theme colors. Additionally, introduce a ThemedApp component to centralize theme management and enhance the overall user experience by ensuring consistent styling throughout the application. Update package dependencies to include react-native-wheel-color-picker for enhanced color selection capabilities. --- App.tsx | 62 +- package-lock.json | 16 + package.json | 1 + src/components/NuvioHeader.tsx | 5 +- src/components/discover/CatalogSection.tsx | 16 +- src/components/discover/CategorySelector.tsx | 22 +- src/components/discover/ContentItem.tsx | 9 +- src/components/discover/GenreSelector.tsx | 20 +- src/components/home/ContentItem.tsx | 18 +- src/components/home/FeaturedContent.tsx | 43 +- src/components/home/SkeletonLoaders.tsx | 39 +- src/contexts/ThemeContext.tsx | 233 ++++++++ src/navigation/AppNavigator.tsx | 38 +- src/screens/DiscoverScreen.tsx | 7 +- src/screens/HomeScreen.tsx | 183 +++--- src/screens/LibraryScreen.tsx | 69 +-- src/screens/SearchScreen.tsx | 207 +++---- src/screens/SettingsScreen.tsx | 115 ++-- src/screens/TMDBSettingsScreen.tsx | 516 +++++++++-------- src/screens/ThemeScreen.tsx | 569 +++++++++++++++++++ src/screens/TraktSettingsScreen.tsx | 41 +- src/styles/homeStyles.ts | 57 +- src/styles/screens/discoverStyles.ts | 13 +- 23 files changed, 1577 insertions(+), 722 deletions(-) create mode 100644 src/contexts/ThemeContext.tsx create mode 100644 src/screens/ThemeScreen.tsx diff --git a/App.tsx b/App.tsx index d6d1680..df02cd8 100644 --- a/App.tsx +++ b/App.tsx @@ -23,33 +23,61 @@ import 'react-native-reanimated'; import { CatalogProvider } from './src/contexts/CatalogContext'; import { GenreProvider } from './src/contexts/GenreContext'; import { TraktProvider } from './src/contexts/TraktContext'; +import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; // This fixes many navigation layout issues by using native screen containers enableScreens(true); -function App(): React.JSX.Element { - // Always use dark mode - const isDarkMode = true; +// Inner app component that uses the theme context +const ThemedApp = () => { + const { currentTheme } = useTheme(); + + // Create custom themes based on current theme + const customDarkTheme = { + ...CustomDarkTheme, + colors: { + ...CustomDarkTheme.colors, + primary: currentTheme.colors.primary, + } + }; + + const customNavigationTheme = { + ...CustomNavigationDarkTheme, + colors: { + ...CustomNavigationDarkTheme.colors, + primary: currentTheme.colors.primary, + card: currentTheme.colors.darkBackground, + background: currentTheme.colors.darkBackground, + } + }; + + return ( + + + + + + + + + ); +} +function App(): React.JSX.Element { return ( - - - - - - - - + + + diff --git a/package-lock.json b/package-lock.json index 99bfb79..183de35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", + "react-native-wheel-color-picker": "^1.3.1", "subsrt": "^1.1.1" }, "devDependencies": { @@ -10840,6 +10841,12 @@ "react-native-reanimated": ">=2.8.0" } }, + "node_modules/react-native-elevation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-native-elevation/-/react-native-elevation-1.0.0.tgz", + "integrity": "sha512-BWIKcEYtzjRV6GpkX0Km5/w2E7fgIcywiQOT7JZTc5NSbv/YI9kpFinB9lRFsOoRVGmiqq/O3VfP/oH2clIiBA==", + "license": "MIT" + }, "node_modules/react-native-gesture-handler": { "version": "2.20.2", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz", @@ -11197,6 +11204,15 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-wheel-color-picker": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-wheel-color-picker/-/react-native-wheel-color-picker-1.3.1.tgz", + "integrity": "sha512-ojuajzwEkgIHa4Iw94K9FlwA1iifslMo+HDrOFQMBTMCXm1HaFhtQpDZ5upV9y8vujviDko3hDkVqB7/eV0dzg==", + "license": "MIT", + "dependencies": { + "react-native-elevation": "^1.0.0" + } + }, "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.23.1.tgz", diff --git a/package.json b/package.json index d78bd87..116472a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", + "react-native-wheel-color-picker": "^1.3.1", "subsrt": "^1.1.1" }, "devDependencies": { diff --git a/src/components/NuvioHeader.tsx b/src/components/NuvioHeader.tsx index b2478af..d6c10c3 100644 --- a/src/components/NuvioHeader.tsx +++ b/src/components/NuvioHeader.tsx @@ -1,19 +1,20 @@ import React from 'react'; import { View, TouchableOpacity, Platform, StyleSheet, Image } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors } from '../styles/colors'; import { useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/AppNavigator'; import { BlurView as ExpoBlurView } from 'expo-blur'; import { BlurView as CommunityBlurView } from '@react-native-community/blur'; import Constants, { ExecutionEnvironment } from 'expo-constants'; +import { useTheme } from '../contexts/ThemeContext'; type NavigationProp = NativeStackNavigationProp; export const NuvioHeader = () => { const navigation = useNavigation(); const route = useRoute(); + const { currentTheme } = useTheme(); // Only render the header if the current route is 'Home' if (route.name !== 'Home') { @@ -59,7 +60,7 @@ export const NuvioHeader = () => { diff --git a/src/components/discover/CatalogSection.tsx b/src/components/discover/CatalogSection.tsx index 44bffde..68b0251 100644 --- a/src/components/discover/CatalogSection.tsx +++ b/src/components/discover/CatalogSection.tsx @@ -3,7 +3,7 @@ import { View, Text, TouchableOpacity, StyleSheet, FlatList, Dimensions } from ' import { MaterialIcons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import { colors } from '../../styles'; +import { useTheme } from '../../contexts/ThemeContext'; import { GenreCatalog, Category } from '../../constants/discover'; import { StreamingContent } from '../../services/catalogService'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -16,6 +16,7 @@ interface CatalogSectionProps { const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => { const navigation = useNavigation>(); + const { currentTheme } = useTheme(); const { width } = Dimensions.get('window'); const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing @@ -56,16 +57,18 @@ const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => { - {catalog.genre} - + + {catalog.genre} + + - See All - + See All + @@ -106,14 +109,12 @@ const styles = StyleSheet.create({ titleBar: { width: 32, height: 3, - backgroundColor: colors.primary, marginTop: 6, borderRadius: 2, }, title: { fontSize: 20, fontWeight: '700', - color: colors.white, }, seeAllButton: { flexDirection: 'row', @@ -123,7 +124,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 4, }, seeAllText: { - color: colors.primary, fontWeight: '600', fontSize: 14, }, diff --git a/src/components/discover/CategorySelector.tsx b/src/components/discover/CategorySelector.tsx index a5db821..c090e8a 100644 --- a/src/components/discover/CategorySelector.tsx +++ b/src/components/discover/CategorySelector.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles'; +import { useTheme } from '../../contexts/ThemeContext'; import { Category } from '../../constants/discover'; interface CategorySelectorProps { @@ -15,6 +15,7 @@ const CategorySelector = ({ selectedCategory, onSelectCategory }: CategorySelectorProps) => { + const { currentTheme } = useTheme(); const renderCategoryButton = useCallback((category: Category) => { const isSelected = selectedCategory.id === category.id; @@ -24,7 +25,7 @@ const CategorySelector = ({ key={category.id} style={[ styles.categoryButton, - isSelected && styles.selectedCategoryButton + isSelected && { backgroundColor: currentTheme.colors.primary } ]} onPress={() => onSelectCategory(category)} activeOpacity={0.7} @@ -32,19 +33,19 @@ const CategorySelector = ({ {category.name} ); - }, [selectedCategory, onSelectCategory]); + }, [selectedCategory, onSelectCategory, currentTheme]); return ( @@ -78,24 +79,17 @@ const styles = StyleSheet.create({ flex: 1, maxWidth: 160, justifyContent: 'center', - shadowColor: colors.black, + shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 8, elevation: 4, }, - selectedCategoryButton: { - backgroundColor: colors.primary, - }, categoryText: { - color: colors.mediumGray, + color: '#9e9e9e', // Default medium gray fontWeight: '600', fontSize: 16, }, - selectedCategoryText: { - color: colors.white, - fontWeight: '700', - }, }); export default React.memo(CategorySelector); \ No newline at end of file diff --git a/src/components/discover/ContentItem.tsx b/src/components/discover/ContentItem.tsx index 263dad9..015db8c 100644 --- a/src/components/discover/ContentItem.tsx +++ b/src/components/discover/ContentItem.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors } from '../../styles'; +import { useTheme } from '../../contexts/ThemeContext'; import { StreamingContent } from '../../services/catalogService'; interface ContentItemProps { @@ -13,6 +13,7 @@ interface ContentItemProps { const ContentItem = ({ item, onPress, width }: ContentItemProps) => { const { width: screenWidth } = Dimensions.get('window'); + const { currentTheme } = useTheme(); const itemWidth = width || (screenWidth - 48) / 2.2; // Default to 2 items per row with spacing return ( @@ -21,7 +22,7 @@ const ContentItem = ({ item, onPress, width }: ContentItemProps) => { onPress={onPress} activeOpacity={0.6} > - + { colors={['transparent', 'rgba(0,0,0,0.85)']} style={styles.gradient} > - + {item.name} {item.year && ( @@ -54,7 +55,6 @@ const styles = StyleSheet.create({ overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', elevation: 5, - shadowColor: colors.black, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, @@ -75,7 +75,6 @@ const styles = StyleSheet.create({ title: { fontSize: 15, fontWeight: '700', - color: colors.white, marginBottom: 4, textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowOffset: { width: 0, height: 1 }, diff --git a/src/components/discover/GenreSelector.tsx b/src/components/discover/GenreSelector.tsx index 7cd4119..7cc4df0 100644 --- a/src/components/discover/GenreSelector.tsx +++ b/src/components/discover/GenreSelector.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; -import { colors } from '../../styles'; +import { useTheme } from '../../contexts/ThemeContext'; interface GenreSelectorProps { genres: string[]; @@ -13,6 +13,7 @@ const GenreSelector = ({ selectedGenre, onSelectGenre }: GenreSelectorProps) => { + const { currentTheme } = useTheme(); const renderGenreButton = useCallback((genre: string) => { const isSelected = selectedGenre === genre; @@ -22,7 +23,7 @@ const GenreSelector = ({ key={genre} style={[ styles.genreButton, - isSelected && styles.selectedGenreButton + isSelected && { backgroundColor: currentTheme.colors.primary } ]} onPress={() => onSelectGenre(genre)} activeOpacity={0.7} @@ -30,14 +31,14 @@ const GenreSelector = ({ {genre} ); - }, [selectedGenre, onSelectGenre]); + }, [selectedGenre, onSelectGenre, currentTheme]); return ( @@ -70,25 +71,18 @@ const styles = StyleSheet.create({ marginRight: 12, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.05)', - shadowColor: colors.black, + shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, overflow: 'hidden', }, - selectedGenreButton: { - backgroundColor: colors.primary, - }, genreText: { - color: colors.mediumGray, + color: '#9e9e9e', // Default medium gray fontWeight: '500', fontSize: 14, }, - selectedGenreText: { - color: colors.white, - fontWeight: '600', - }, }); export default React.memo(GenreSelector); \ No newline at end of file diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 47e810d..c116271 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { catalogService, StreamingContent } from '../../services/catalogService'; import DropUpMenu from './DropUpMenu'; @@ -20,6 +20,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { const [isWatched, setIsWatched] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); + const { currentTheme } = useTheme(); const handleLongPress = useCallback(() => { setMenuVisible(true); @@ -95,22 +96,22 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { }} /> {(!imageLoaded || imageError) && ( - + {!imageError ? ( - + ) : ( - + )} )} {isWatched && ( - + )} {localItem.inLibrary && ( - + )} @@ -160,7 +161,6 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', borderRadius: 16, @@ -169,7 +169,6 @@ const styles = StyleSheet.create({ position: 'absolute', top: 8, right: 8, - backgroundColor: colors.transparentDark, borderRadius: 12, padding: 2, }, @@ -177,7 +176,6 @@ const styles = StyleSheet.create({ position: 'absolute', top: 8, left: 8, - backgroundColor: colors.transparentDark, borderRadius: 8, padding: 4, }, diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 3de6723..5da195d 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -17,7 +17,6 @@ import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles/colors'; import Animated, { FadeIn, useAnimatedStyle, @@ -32,6 +31,8 @@ import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from import { useSettings } from '../../hooks/useSettings'; import { TMDBService } from '../../services/tmdbService'; import { logger } from '../../utils/logger'; +import { useTheme } from '../../contexts/ThemeContext'; +import type { Theme } from '../../contexts/ThemeContext'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -47,6 +48,7 @@ const { width, height } = Dimensions.get('window'); const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const navigation = useNavigation>(); const { settings } = useSettings(); + const { currentTheme } = useTheme(); const [logoUrl, setLogoUrl] = useState(null); const [bannerUrl, setBannerUrl] = useState(null); const prevContentIdRef = useRef(null); @@ -350,7 +352,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat 'transparent', 'rgba(0,0,0,0.1)', 'rgba(0,0,0,0.7)', - colors.darkBackground, + currentTheme.colors.darkBackground, ]} locations={[0, 0.3, 0.7, 1]} style={styles.featuredGradient as ViewStyle} @@ -373,14 +375,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat /> ) : ( - {featuredContent.name} + + {featuredContent.name} + )} {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( - {genre} + + {genre} + {index < array.length - 1 && ( - + )} ))} @@ -395,15 +401,15 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - + {isSaved ? "Saved" : "Save"} { if (featuredContent) { navigation.navigate('Streams', { @@ -413,8 +419,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } }} > - - Play + + + Play + - - Info + + + Info + @@ -446,7 +456,6 @@ const styles = StyleSheet.create({ marginTop: 0, marginBottom: 8, position: 'relative', - backgroundColor: colors.elevation1, }, imageContainer: { width: '100%', @@ -468,7 +477,6 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: colors.elevation1, justifyContent: 'center', alignItems: 'center', zIndex: 1, @@ -491,7 +499,6 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, featuredTitleText: { - color: colors.highEmphasis, fontSize: 28, fontWeight: '900', marginBottom: 8, @@ -510,13 +517,11 @@ const styles = StyleSheet.create({ gap: 4, }, genreText: { - color: colors.white, fontSize: 14, fontWeight: '500', opacity: 0.9, }, genreDot: { - color: colors.white, fontSize: 14, fontWeight: '500', opacity: 0.6, @@ -538,7 +543,6 @@ const styles = StyleSheet.create({ paddingVertical: 14, paddingHorizontal: 32, borderRadius: 30, - backgroundColor: colors.white, elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, @@ -568,18 +572,15 @@ const styles = StyleSheet.create({ flex: undefined, }, playButtonText: { - color: colors.black, fontWeight: '600', marginLeft: 8, fontSize: 16, }, myListButtonText: { - color: colors.white, fontSize: 12, fontWeight: '500', }, infoButtonText: { - color: colors.white, fontSize: 12, fontWeight: '500', }, diff --git a/src/components/home/SkeletonLoaders.tsx b/src/components/home/SkeletonLoaders.tsx index 0127899..1a51a3e 100644 --- a/src/components/home/SkeletonLoaders.tsx +++ b/src/components/home/SkeletonLoaders.tsx @@ -1,23 +1,30 @@ import React from 'react'; import { View, Text, ActivityIndicator, StyleSheet, Dimensions } from 'react-native'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; +import type { Theme } from '../../contexts/ThemeContext'; const { height } = Dimensions.get('window'); -export const SkeletonCatalog = () => ( - - - +export const SkeletonCatalog = () => { + const { currentTheme } = useTheme(); + return ( + + + + - -); + ); +}; -export const SkeletonFeatured = () => ( - - - Loading featured content... - -); +export const SkeletonFeatured = () => { + const { currentTheme } = useTheme(); + return ( + + + Loading featured content... + + ); +}; const styles = StyleSheet.create({ catalogContainer: { @@ -29,7 +36,6 @@ const styles = StyleSheet.create({ height: 200, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.elevation1, borderRadius: 12, marginHorizontal: 16, }, @@ -37,28 +43,23 @@ const styles = StyleSheet.create({ height: height * 0.4, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.elevation1, }, loadingText: { - color: colors.textMuted, marginTop: 12, fontSize: 14, }, skeletonBox: { - backgroundColor: colors.elevation2, borderRadius: 16, overflow: 'hidden', }, skeletonFeatured: { width: '100%', height: height * 0.6, - backgroundColor: colors.elevation2, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0, }, skeletonPoster: { - backgroundColor: colors.elevation1, marginHorizontal: 4, borderRadius: 16, }, diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..084c3c3 --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,233 @@ +import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { colors as defaultColors } from '../styles/colors'; + +// Define the Theme interface +export interface Theme { + id: string; + name: string; + colors: typeof defaultColors; + isEditable: boolean; +} + +// Default built-in themes +export const DEFAULT_THEMES: Theme[] = [ + { + id: 'default', + name: 'Default Dark', + colors: defaultColors, + isEditable: false, + }, + { + id: 'ocean', + name: 'Ocean Blue', + colors: { + ...defaultColors, + primary: '#3498db', + secondary: '#2ecc71', + darkBackground: '#0a192f', + }, + isEditable: false, + }, + { + id: 'sunset', + name: 'Sunset', + colors: { + ...defaultColors, + primary: '#ff7e5f', + secondary: '#feb47b', + darkBackground: '#1a0f0b', + }, + isEditable: false, + }, + { + id: 'moonlight', + name: 'Moonlight', + colors: { + ...defaultColors, + primary: '#a786df', + secondary: '#5e72e4', + darkBackground: '#0f0f1a', + }, + isEditable: false, + }, +]; + +// Theme context props +interface ThemeContextProps { + currentTheme: Theme; + availableThemes: Theme[]; + setCurrentTheme: (themeId: string) => void; + addCustomTheme: (theme: Omit) => void; + updateCustomTheme: (theme: Theme) => void; + deleteCustomTheme: (themeId: string) => void; +} + +// Create the context +const ThemeContext = createContext(undefined); + +// Storage keys +const CURRENT_THEME_KEY = 'current_theme'; +const CUSTOM_THEMES_KEY = 'custom_themes'; + +// Provider component +export function ThemeProvider({ children }: { children: ReactNode }) { + const [currentTheme, setCurrentThemeState] = useState(DEFAULT_THEMES[0]); + const [availableThemes, setAvailableThemes] = useState(DEFAULT_THEMES); + + // Load themes from AsyncStorage on mount + useEffect(() => { + const loadThemes = async () => { + try { + // Load current theme ID + const savedThemeId = await AsyncStorage.getItem(CURRENT_THEME_KEY); + + // Load custom themes + const customThemesJson = await AsyncStorage.getItem(CUSTOM_THEMES_KEY); + const customThemes = customThemesJson ? JSON.parse(customThemesJson) : []; + + // Combine default and custom themes + const allThemes = [...DEFAULT_THEMES, ...customThemes]; + setAvailableThemes(allThemes); + + // Set current theme + if (savedThemeId) { + const theme = allThemes.find(t => t.id === savedThemeId); + if (theme) { + setCurrentThemeState(theme); + } + } + } catch (error) { + console.error('Failed to load themes:', error); + } + }; + + loadThemes(); + }, []); + + // Set current theme + const setCurrentTheme = async (themeId: string) => { + const theme = availableThemes.find(t => t.id === themeId); + if (theme) { + setCurrentThemeState(theme); + await AsyncStorage.setItem(CURRENT_THEME_KEY, themeId); + } + }; + + // Add custom theme + const addCustomTheme = async (themeData: Omit) => { + try { + // Generate unique ID + const id = `custom_${Date.now()}`; + + // Create new theme object + const newTheme: Theme = { + id, + ...themeData, + isEditable: true, + }; + + // Add to available themes + const customThemes = availableThemes.filter(t => t.isEditable); + const updatedCustomThemes = [...customThemes, newTheme]; + const updatedAllThemes = [...DEFAULT_THEMES, ...updatedCustomThemes]; + + // Save to storage + await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updatedCustomThemes)); + + // Update state + setAvailableThemes(updatedAllThemes); + + // Set as current theme + setCurrentThemeState(newTheme); + await AsyncStorage.setItem(CURRENT_THEME_KEY, id); + } catch (error) { + console.error('Failed to add custom theme:', error); + } + }; + + // Update custom theme + const updateCustomTheme = async (updatedTheme: Theme) => { + try { + if (!updatedTheme.isEditable) { + throw new Error('Cannot edit built-in themes'); + } + + // Find and update the theme + const customThemes = availableThemes.filter(t => t.isEditable); + const updatedCustomThemes = customThemes.map(t => + t.id === updatedTheme.id ? updatedTheme : t + ); + + // Update available themes + const updatedAllThemes = [...DEFAULT_THEMES, ...updatedCustomThemes]; + + // Save to storage + await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(updatedCustomThemes)); + + // Update state + setAvailableThemes(updatedAllThemes); + + // Update current theme if needed + if (currentTheme.id === updatedTheme.id) { + setCurrentThemeState(updatedTheme); + } + } catch (error) { + console.error('Failed to update custom theme:', error); + } + }; + + // Delete custom theme + const deleteCustomTheme = async (themeId: string) => { + try { + // Find theme to delete + const themeToDelete = availableThemes.find(t => t.id === themeId); + + if (!themeToDelete || !themeToDelete.isEditable) { + throw new Error('Cannot delete built-in themes or theme not found'); + } + + // Filter out the theme + const customThemes = availableThemes.filter(t => t.isEditable && t.id !== themeId); + const updatedAllThemes = [...DEFAULT_THEMES, ...customThemes]; + + // Save to storage + await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(customThemes)); + + // Update state + setAvailableThemes(updatedAllThemes); + + // Reset to default theme if current theme was deleted + if (currentTheme.id === themeId) { + setCurrentThemeState(DEFAULT_THEMES[0]); + await AsyncStorage.setItem(CURRENT_THEME_KEY, DEFAULT_THEMES[0].id); + } + } catch (error) { + console.error('Failed to delete custom theme:', error); + } + }; + + return ( + + {children} + + ); +} + +// Custom hook to use the theme context +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 24abbe9..5e4b70e 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -13,6 +13,8 @@ import { colors } from '../styles/colors'; import { NuvioHeader } from '../components/NuvioHeader'; import { Stream } from '../types/streams'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { AnimationFade, AnimationSlideHorizontal } from '../utils/animations'; +import { useTheme } from '../contexts/ThemeContext'; // Import screens with their proper types import HomeScreen from '../screens/HomeScreen'; @@ -36,6 +38,7 @@ import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; import TraktSettingsScreen from '../screens/TraktSettingsScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import LogoSourceSettings from '../screens/LogoSourceSettings'; +import ThemeScreen from '../screens/ThemeScreen'; // Stack navigator types export type RootStackParamList = { @@ -92,6 +95,7 @@ export type RootStackParamList = { TraktSettings: undefined; PlayerSettings: undefined; LogoSourceSettings: undefined; + ThemeSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -653,8 +657,7 @@ const MainTabs = () => { // Stack Navigator const AppNavigator = () => { - // Always use dark mode - const isDarkMode = true; + const { currentTheme } = useTheme(); return ( @@ -671,7 +674,7 @@ const AppNavigator = () => { animation: 'none', // Ensure content is not popping in and out contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, } }} > @@ -723,7 +726,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -738,7 +741,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -776,7 +779,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -791,7 +794,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -806,7 +809,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -821,7 +824,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -836,7 +839,22 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx index ee6de4f..3df7368 100644 --- a/src/screens/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -12,11 +12,11 @@ import { import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../styles'; import { catalogService, StreamingContent } from '../services/catalogService'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../contexts/ThemeContext'; // Components import CategorySelector from '../components/discover/CategorySelector'; @@ -37,6 +37,7 @@ const DiscoverScreen = () => { const [loading, setLoading] = useState(true); const styles = useDiscoverStyles(); const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); // Force consistent status bar settings useEffect(() => { @@ -162,7 +163,7 @@ const DiscoverScreen = () => { @@ -187,7 +188,7 @@ const DiscoverScreen = () => { {/* Content Section */} {loading ? ( - + ) : catalogs.length > 0 ? ( { @@ -126,12 +128,14 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) const overlayStyle = useAnimatedStyle(() => ({ opacity: opacity.value, + backgroundColor: currentTheme.colors.transparentDark, })); const menuStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], borderTopLeftRadius: 24, borderTopRightRadius: 24, + backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white, })); const menuOptions = [ @@ -157,8 +161,6 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) } ]; - const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF'; - return ( - - - + + + - + {item.name} {item.year && ( - + {item.year} )} @@ -206,11 +208,11 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) {option.label} @@ -231,6 +233,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { const [isWatched, setIsWatched] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); + const { currentTheme } = useTheme(); const handleLongPress = useCallback(() => { setMenuVisible(true); @@ -306,22 +309,22 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { }} /> {(!imageLoaded || imageError) && ( - + {!imageError ? ( - + ) : ( - + )} )} {isWatched && ( - + )} {localItem.inLibrary && ( - + )} @@ -344,17 +347,21 @@ const SAMPLE_CATEGORIES: Category[] = [ { id: 'channel', name: 'Channels' }, ]; -const SkeletonCatalog = () => ( - - - +const SkeletonCatalog = () => { + const { currentTheme } = useTheme(); + return ( + + + + - -); + ); +}; const HomeScreen = () => { const navigation = useNavigation>(); const isDarkMode = useColorScheme() === 'dark'; + const { currentTheme } = useTheme(); const continueWatchingRef = useRef(null); const { settings } = useSettings(); const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); @@ -436,9 +443,9 @@ const HomeScreen = () => { // Only run cleanup when component unmounts completely, not on unfocus return () => { StatusBar.setTranslucent(false); - StatusBar.setBackgroundColor(colors.darkBackground); + StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); }; - }, []); + }, [currentTheme.colors.darkBackground]); useEffect(() => { navigation.addListener('beforeRemove', () => {}); @@ -531,22 +538,22 @@ const HomeScreen = () => { if (isLoading && !isRefreshing) { return ( - + - - - Loading your content... + + + Loading your content... ); } return ( - + { } contentContainerStyle={[ - homeStyles.scrollContent, + styles.scrollContent, { paddingTop: Platform.OS === 'ios' ? 39 : 90 } ]} showsVerticalScrollIndicator={false} @@ -594,17 +601,17 @@ const HomeScreen = () => { )) ) : ( !catalogsLoading && ( - - - + + + No content available navigation.navigate('Settings')} > - - Add Catalogs + + Add Catalogs ) @@ -620,11 +627,44 @@ const POSTER_WIDTH = (width - 50) / 3; const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.darkBackground, }, scrollContent: { paddingBottom: 40, }, + loadingMainContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 40, + }, + loadingText: { + marginTop: 12, + fontSize: 14, + }, + emptyCatalog: { + padding: 32, + alignItems: 'center', + margin: 16, + borderRadius: 16, + }, + addCatalogButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 30, + marginTop: 16, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + }, + addCatalogButtonText: { + fontSize: 14, + fontWeight: '600', + marginLeft: 8, + }, loadingContainer: { flex: 1, justifyContent: 'center', @@ -661,7 +701,6 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, featuredTitle: { - color: colors.white, fontSize: 32, fontWeight: '900', marginBottom: 0, @@ -679,13 +718,11 @@ const styles = StyleSheet.create({ gap: 4, }, genreText: { - color: colors.white, fontSize: 14, fontWeight: '500', opacity: 0.9, }, genreDot: { - color: colors.white, fontSize: 14, fontWeight: '500', opacity: 0.6, @@ -707,7 +744,7 @@ const styles = StyleSheet.create({ paddingVertical: 14, paddingHorizontal: 32, borderRadius: 30, - backgroundColor: colors.white, + backgroundColor: '#FFFFFF', elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, @@ -737,18 +774,16 @@ const styles = StyleSheet.create({ flex: null, }, playButtonText: { - color: colors.black, + color: '#000000', fontWeight: '600', marginLeft: 8, fontSize: 16, }, myListButtonText: { - color: colors.white, fontSize: 12, fontWeight: '500', }, infoButtonText: { - color: colors.white, fontSize: 12, fontWeight: '500', }, @@ -770,7 +805,6 @@ const styles = StyleSheet.create({ catalogTitle: { fontSize: 18, fontWeight: '800', - color: colors.highEmphasis, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, @@ -786,13 +820,11 @@ const styles = StyleSheet.create({ seeAllButton: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.elevation1, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, }, seeAllText: { - color: colors.primary, fontSize: 13, fontWeight: '700', marginRight: 4, @@ -841,28 +873,18 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginLeft: 3, }, - emptyCatalog: { - padding: 32, - alignItems: 'center', - backgroundColor: colors.elevation1, - margin: 16, - borderRadius: 16, - }, skeletonBox: { - backgroundColor: colors.elevation2, borderRadius: 16, overflow: 'hidden', }, skeletonFeatured: { width: '100%', height: height * 0.6, - backgroundColor: colors.elevation2, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0, }, skeletonPoster: { - backgroundColor: colors.elevation1, marginHorizontal: 4, borderRadius: 16, }, @@ -888,7 +910,6 @@ const styles = StyleSheet.create({ modalOverlay: { flex: 1, justifyContent: 'flex-end', - backgroundColor: colors.transparentDark, }, modalOverlayPressable: { flex: 1, @@ -896,7 +917,6 @@ const styles = StyleSheet.create({ dragHandle: { width: 40, height: 4, - backgroundColor: colors.transparentLight, borderRadius: 2, alignSelf: 'center', marginTop: 12, @@ -908,7 +928,7 @@ const styles = StyleSheet.create({ paddingBottom: Platform.select({ ios: 40, android: 24 }), ...Platform.select({ ios: { - shadowColor: colors.black, + shadowColor: '#000', shadowOffset: { width: 0, height: -3 }, shadowOpacity: 0.1, shadowRadius: 5, @@ -922,7 +942,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.border, }, menuPoster: { width: 60, @@ -962,7 +981,7 @@ const styles = StyleSheet.create({ position: 'absolute', top: 8, right: 8, - backgroundColor: colors.transparentDark, + backgroundColor: 'rgba(0, 0, 0, 0.7)', borderRadius: 12, padding: 2, }, @@ -970,7 +989,7 @@ const styles = StyleSheet.create({ position: 'absolute', top: 8, left: 8, - backgroundColor: colors.transparentDark, + backgroundColor: 'rgba(0, 0, 0, 0.7)', borderRadius: 8, padding: 4, }, @@ -996,7 +1015,6 @@ const styles = StyleSheet.create({ paddingBottom: 20, }, featuredTitleText: { - color: colors.highEmphasis, fontSize: 28, fontWeight: '900', marginBottom: 8, @@ -1006,42 +1024,10 @@ const styles = StyleSheet.create({ textAlign: 'center', paddingHorizontal: 16, }, - addCatalogButton: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary, - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 30, - marginTop: 16, - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 3, - }, - addCatalogButtonText: { - color: colors.white, - fontSize: 14, - fontWeight: '600', - marginLeft: 8, - }, - loadingMainContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingBottom: 40, - }, - loadingText: { - color: colors.textMuted, - marginTop: 12, - fontSize: 14, - }, loadingPlaceholder: { height: 200, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.elevation1, borderRadius: 12, marginHorizontal: 16, }, @@ -1049,7 +1035,6 @@ const styles = StyleSheet.create({ height: height * 0.4, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.elevation1, }, }); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index e8b3581..160ac13 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -16,7 +16,6 @@ import { import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../styles'; import { Image } from 'expo-image'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; @@ -25,6 +24,7 @@ import type { StreamingContent } from '../services/catalogService'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../contexts/ThemeContext'; // Types interface LibraryItem extends StreamingContent { @@ -38,6 +38,7 @@ const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; const { width } = useWindowDimensions(); const itemWidth = (width - 48) / 2; + const { currentTheme } = useTheme(); React.useEffect(() => { const pulse = RNAnimated.loop( @@ -68,13 +69,13 @@ const SkeletonLoader = () => { @@ -99,6 +100,7 @@ const LibraryScreen = () => { const [libraryItems, setLibraryItems] = useState([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); // Force consistent status bar settings useEffect(() => { @@ -157,7 +159,7 @@ const LibraryScreen = () => { onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} activeOpacity={0.7} > - + { style={styles.posterGradient} > {item.name} @@ -186,7 +188,7 @@ const LibraryScreen = () => { @@ -196,10 +198,10 @@ const LibraryScreen = () => { - Series + Series )} @@ -212,7 +214,8 @@ const LibraryScreen = () => { setFilter(filterType)} activeOpacity={0.7} @@ -220,13 +223,14 @@ const LibraryScreen = () => { {label} @@ -240,20 +244,20 @@ const LibraryScreen = () => { const headerHeight = headerBaseHeight + topSpacing; return ( - + {/* Fixed position header background to prevent shifts */} - + {/* Header Section with proper top spacing */} - Library + Library {/* Content Container */} - + {renderFilter('all', 'All', 'apps')} {renderFilter('movies', 'Movies', 'movie')} @@ -267,19 +271,22 @@ const LibraryScreen = () => { - Your library is empty - + Your library is empty + Add content to your library to keep track of what you're watching navigation.navigate('Discover')} activeOpacity={0.7} > - Explore Content + Explore Content ) : ( @@ -306,19 +313,16 @@ const LibraryScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.darkBackground, }, headerBackground: { position: 'absolute', top: 0, left: 0, right: 0, - backgroundColor: colors.darkBackground, zIndex: 1, }, contentContainer: { flex: 1, - backgroundColor: colors.darkBackground, }, header: { paddingHorizontal: 20, @@ -335,7 +339,6 @@ const styles = StyleSheet.create({ headerTitle: { fontSize: 32, fontWeight: '800', - color: colors.white, letterSpacing: 0.3, }, filtersContainer: { @@ -355,26 +358,17 @@ const styles = StyleSheet.create({ marginHorizontal: 4, borderRadius: 24, backgroundColor: 'rgba(255,255,255,0.05)', - shadowColor: colors.black, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, }, - filterButtonActive: { - backgroundColor: colors.primary, - }, filterIcon: { marginRight: 8, }, filterText: { fontSize: 15, fontWeight: '500', - color: colors.mediumGray, - }, - filterTextActive: { - fontWeight: '600', - color: colors.white, }, listContainer: { paddingHorizontal: 12, @@ -400,7 +394,6 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.03)', aspectRatio: 2/3, elevation: 5, - shadowColor: colors.black, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, @@ -428,7 +421,6 @@ const styles = StyleSheet.create({ }, progressBar: { height: '100%', - backgroundColor: colors.primary, }, badgeContainer: { position: 'absolute', @@ -442,14 +434,12 @@ const styles = StyleSheet.create({ alignItems: 'center', }, badgeText: { - color: colors.white, fontSize: 10, fontWeight: '600', }, itemTitle: { fontSize: 15, fontWeight: '700', - color: colors.white, marginBottom: 4, textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowOffset: { width: 0, height: 1 }, @@ -477,29 +467,24 @@ const styles = StyleSheet.create({ emptyText: { fontSize: 20, fontWeight: '700', - color: colors.white, marginTop: 16, marginBottom: 8, }, emptySubtext: { fontSize: 15, - color: colors.mediumGray, textAlign: 'center', marginBottom: 24, }, exploreButton: { - backgroundColor: colors.primary, paddingVertical: 12, paddingHorizontal: 24, borderRadius: 24, elevation: 3, - shadowColor: colors.black, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, }, exploreButtonText: { - color: colors.white, fontSize: 16, fontWeight: '600', } diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 4e7591c..0e2d471 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -21,7 +21,6 @@ import { import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../styles'; import { catalogService, StreamingContent } from '../services/catalogService'; import { Image } from 'expo-image'; import debounce from 'lodash/debounce'; @@ -42,6 +41,7 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { BlurView } from 'expo-blur'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../contexts/ThemeContext'; const { width } = Dimensions.get('window'); const HORIZONTAL_ITEM_WIDTH = width * 0.3; @@ -57,6 +57,7 @@ const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; + const { currentTheme } = useTheme(); React.useEffect(() => { const pulse = RNAnimated.loop( @@ -84,12 +85,24 @@ const SkeletonLoader = () => { const renderSkeletonItem = () => ( - + - + - - + + @@ -100,7 +113,10 @@ const SkeletonLoader = () => { {[...Array(5)].map((_, index) => ( {index === 0 && ( - + )} {renderSkeletonItem()} @@ -116,6 +132,7 @@ const SimpleSearchAnimation = () => { // Simple animation values that work reliably const spinAnim = React.useRef(new RNAnimated.Value(0)).current; const fadeAnim = React.useRef(new RNAnimated.Value(0)).current; + const { currentTheme } = useTheme(); React.useEffect(() => { // Rotation animation @@ -161,15 +178,15 @@ const SimpleSearchAnimation = () => { - Searching + Searching ); @@ -186,6 +203,7 @@ const SearchScreen = () => { const [showRecent, setShowRecent] = useState(true); const inputRef = useRef(null); const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); // Animation values const searchBarWidth = useSharedValue(width - 32); @@ -348,7 +366,7 @@ const SearchScreen = () => { style={styles.recentSearchesContainer} entering={FadeIn.duration(300)} > - + Recent Searches {recentSearches.map((search, index) => ( @@ -364,10 +382,10 @@ const SearchScreen = () => { - + {search} { hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={styles.recentSearchDeleteButton} > - + ))} @@ -398,7 +416,10 @@ const SearchScreen = () => { entering={FadeIn.duration(500).delay(index * 100)} activeOpacity={0.7} > - + { transition={300} /> - {item.type === 'movie' ? 'MOVIE' : 'SERIES'} + + {item.type === 'movie' ? 'MOVIE' : 'SERIES'} + {item.imdbRating && ( - {item.imdbRating} + + {item.imdbRating} + )} {item.name} {item.year && ( - {item.year} + + {item.year} + )} ); @@ -445,7 +472,7 @@ const SearchScreen = () => { const headerHeight = headerBaseHeight + topSpacing + 60; return ( - + { /> {/* Fixed position header background to prevent shifts */} - + {/* Header Section with proper top spacing */} - Search - - - - {query.length > 0 && ( - + Search + + + - - )} + + {query.length > 0 && ( + + + + )} + + {/* Content Container */} - + {searching ? ( ) : searched && !hasResultsToShow ? ( @@ -513,12 +552,12 @@ const SearchScreen = () => { - + No results found - + Try different keywords or check your spelling @@ -538,7 +577,9 @@ const SearchScreen = () => { style={styles.carouselContainer} entering={FadeIn.duration(300)} > - Movies ({movieResults.length}) + + Movies ({movieResults.length}) + { style={styles.carouselContainer} entering={FadeIn.duration(300).delay(100)} > - TV Shows ({seriesResults.length}) + + TV Shows ({seriesResults.length}) + { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.black, }, headerBackground: { position: 'absolute', top: 0, left: 0, right: 0, - backgroundColor: colors.black, zIndex: 1, }, contentContainer: { flex: 1, - backgroundColor: colors.black, paddingTop: 0, }, header: { @@ -603,26 +643,26 @@ const styles = StyleSheet.create({ headerTitle: { fontSize: 32, fontWeight: '800', - color: colors.white, letterSpacing: 0.5, marginBottom: 12, }, searchBarContainer: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', + justifyContent: 'space-between', marginBottom: 8, + height: 48, }, searchBarWrapper: { flex: 1, + height: 48, }, searchBar: { flexDirection: 'row', alignItems: 'center', borderRadius: 12, paddingHorizontal: 16, - height: 48, - backgroundColor: colors.darkGray, + height: '100%', shadowColor: "#000", shadowOffset: { width: 0, @@ -632,13 +672,6 @@ const styles = StyleSheet.create({ shadowRadius: 3.84, elevation: 5, }, - backButton: { - marginRight: 10, - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - }, searchIcon: { marginRight: 12, }, @@ -646,7 +679,6 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, height: '100%', - color: colors.white, }, clearButton: { padding: 4, @@ -664,7 +696,6 @@ const styles = StyleSheet.create({ carouselTitle: { fontSize: 18, fontWeight: '700', - color: colors.white, marginBottom: 12, paddingHorizontal: 16, }, @@ -681,10 +712,8 @@ const styles = StyleSheet.create({ height: HORIZONTAL_POSTER_HEIGHT, borderRadius: 12, overflow: 'hidden', - backgroundColor: colors.darkBackground, marginBottom: 8, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.05)', }, horizontalItemPoster: { width: '100%', @@ -695,11 +724,9 @@ const styles = StyleSheet.create({ fontWeight: '600', lineHeight: 18, textAlign: 'left', - color: colors.white, }, yearText: { fontSize: 12, - color: colors.mediumGray, marginTop: 2, }, recentSearchesContainer: { @@ -723,7 +750,6 @@ const styles = StyleSheet.create({ recentSearchText: { fontSize: 16, flex: 1, - color: colors.white, }, recentSearchDeleteButton: { padding: 4, @@ -736,7 +762,6 @@ const styles = StyleSheet.create({ loadingText: { marginTop: 16, fontSize: 16, - color: colors.white, }, emptyContainer: { flex: 1, @@ -749,13 +774,11 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginTop: 16, marginBottom: 8, - color: colors.white, }, emptySubtext: { fontSize: 14, textAlign: 'center', lineHeight: 20, - color: colors.lightGray, }, skeletonContainer: { flexDirection: 'row', @@ -772,7 +795,6 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, height: POSTER_HEIGHT, borderRadius: 8, - backgroundColor: colors.darkBackground, }, skeletonItemDetails: { flex: 1, @@ -788,19 +810,16 @@ const styles = StyleSheet.create({ height: 20, width: '80%', marginBottom: 8, - backgroundColor: colors.darkBackground, borderRadius: 4, }, skeletonMeta: { height: 14, width: '30%', - backgroundColor: colors.darkBackground, borderRadius: 4, }, skeletonSectionHeader: { height: 24, width: '40%', - backgroundColor: colors.darkBackground, marginBottom: 16, borderRadius: 4, }, @@ -814,7 +833,6 @@ const styles = StyleSheet.create({ borderRadius: 4, }, itemTypeText: { - color: colors.white, fontSize: 8, fontWeight: '700', }, @@ -830,7 +848,6 @@ const styles = StyleSheet.create({ borderRadius: 4, }, ratingText: { - color: colors.white, fontSize: 10, fontWeight: '700', marginLeft: 2, @@ -847,7 +864,6 @@ const styles = StyleSheet.create({ width: 64, height: 64, borderRadius: 32, - backgroundColor: colors.primary, justifyContent: 'center', alignItems: 'center', marginBottom: 16, @@ -861,7 +877,6 @@ const styles = StyleSheet.create({ elevation: 3, }, simpleAnimationText: { - color: colors.white, fontSize: 16, fontWeight: '600', }, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3330686..a2e739c 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -19,12 +19,12 @@ import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { Picker } from '@react-native-picker/picker'; -import { colors } from '../styles/colors'; import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { RootStackParamList } from '../navigation/AppNavigator'; import { stremioService } from '../services/stremioService'; import { useCatalogContext } from '../contexts/CatalogContext'; import { useTraktContext } from '../contexts/TraktContext'; +import { useTheme } from '../contexts/ThemeContext'; import { catalogService, DataSource } from '../services/catalogService'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -39,24 +39,28 @@ interface SettingsCardProps { title?: string; } -const SettingsCard: React.FC = ({ children, isDarkMode, title }) => ( - - {title && ( - = ({ children, isDarkMode, title }) => { + const { currentTheme } = useTheme(); + + return ( + + {title && ( + + {title.toUpperCase()} + + )} + - {title.toUpperCase()} - - )} - - {children} + {children} + - -); + ); +}; interface SettingItemProps { title: string; @@ -79,6 +83,8 @@ const SettingItem: React.FC = ({ isDarkMode, badge }) => { + const { currentTheme } = useTheme(); + return ( = ({ styles.settingIconContainer, { backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' } ]}> - + - + {title} {description && ( - + {description} )} {badge && ( - + {badge} )} @@ -126,6 +132,7 @@ const SettingsScreen: React.FC = () => { const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile } = useTraktContext(); + const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); // States for dynamic content @@ -229,8 +236,8 @@ const SettingsScreen: React.FC = () => { ); @@ -257,22 +264,22 @@ const SettingsScreen: React.FC = () => { return ( {/* Fixed position header background to prevent shifts */} {/* Header Section with proper top spacing */} - + Settings - Reset + Reset @@ -399,25 +406,37 @@ const SettingsScreen: React.FC = () => { handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)} > Addons handleDiscoverDataSourceChange(DataSource.TMDB)} > TMDB @@ -425,8 +444,32 @@ const SettingsScreen: React.FC = () => { /> + + ( + updateSetting('enableDarkMode', value)} + /> + )} + isDarkMode={isDarkMode} + /> + navigation.navigate('ThemeSettings')} + isDarkMode={isDarkMode} + isLast + /> + + - + Version 1.0.0 @@ -597,17 +640,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, backgroundColor: 'rgba(255,255,255,0.08)', }, - selectorButtonActive: { - backgroundColor: colors.primary, - }, selectorText: { fontSize: 14, fontWeight: '500', - color: colors.mediumEmphasis, - }, - selectorTextActive: { - color: colors.white, - fontWeight: '600', }, }); diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index c07c562..2503999 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, Text, @@ -15,12 +15,17 @@ import { Keyboard, Clipboard, Switch, + Image, + KeyboardAvoidingView, + TouchableWithoutFeedback, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { colors } from '../styles/colors'; +import { tmdbService } from '../services/tmdbService'; +import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; +import { useTheme } from '../contexts/ThemeContext'; const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; @@ -35,6 +40,7 @@ const TMDBSettingsScreen = () => { const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [isInputFocused, setIsInputFocused] = useState(false); const apiKeyInputRef = useRef(null); + const { currentTheme } = useTheme(); useEffect(() => { logger.log('[TMDBSettingsScreen] Component mounted'); @@ -217,12 +223,231 @@ 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, + }, + }); + if (isLoading) { return ( - + Loading Settings... @@ -237,42 +462,43 @@ const TMDBSettingsScreen = () => { style={styles.backButton} onPress={() => navigation.goBack()} > - + Settings - TMDb Settings + TMDb Settings - - Use Custom TMDb API Key - + + Use Custom TMDb API Key - - 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. - + + + 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. + + {useCustomKey && ( <> @@ -287,8 +513,8 @@ const TMDBSettingsScreen = () => { - API Key - + API Key + { if (testResult) setTestResult(null); }} placeholder="Paste your TMDb API key (v4 auth)" - placeholderTextColor={colors.mediumGray} + placeholderTextColor={currentTheme.colors.mediumGray} autoCapitalize="none" autoCorrect={false} spellCheck={false} @@ -309,7 +535,7 @@ const TMDBSettingsScreen = () => { style={styles.pasteButton} onPress={pasteFromClipboard} > - + @@ -339,7 +565,7 @@ const TMDBSettingsScreen = () => { { style={styles.helpLink} onPress={openTMDBWebsite} > - + How to get a TMDb API key? @@ -363,7 +589,7 @@ const TMDBSettingsScreen = () => { - + To get your own TMDb API key (v4 auth token), 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. @@ -374,7 +600,7 @@ const TMDBSettingsScreen = () => { {!useCustomKey && ( - + 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. @@ -386,236 +612,4 @@ const TMDBSettingsScreen = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: 12, - fontSize: 16, - color: colors.white, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, - paddingBottom: 8, - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - }, - backText: { - color: colors.primary, - fontSize: 16, - fontWeight: '500', - }, - headerTitle: { - fontSize: 28, - fontWeight: 'bold', - color: colors.white, - marginHorizontal: 16, - marginBottom: 16, - }, - content: { - flex: 1, - }, - scrollContent: { - paddingBottom: 40, - }, - switchCard: { - backgroundColor: colors.elevation2, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - switchRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - switchLabel: { - fontSize: 16, - fontWeight: '500', - color: colors.white, - }, - switchDescription: { - fontSize: 14, - color: colors.mediumEmphasis, - lineHeight: 20, - }, - statusCard: { - flexDirection: 'row', - backgroundColor: colors.elevation2, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - statusIcon: { - marginRight: 12, - }, - statusTextContainer: { - flex: 1, - }, - statusTitle: { - fontSize: 16, - fontWeight: '500', - color: colors.white, - marginBottom: 4, - }, - statusDescription: { - fontSize: 14, - color: colors.mediumEmphasis, - }, - card: { - backgroundColor: colors.elevation2, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - }, - sectionTitle: { - fontSize: 16, - fontWeight: '500', - color: colors.white, - marginBottom: 16, - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - input: { - flex: 1, - backgroundColor: colors.elevation1, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - color: colors.white, - fontSize: 15, - borderWidth: 1, - borderColor: 'transparent', - }, - inputFocused: { - borderColor: colors.primary, - }, - pasteButton: { - position: 'absolute', - right: 8, - padding: 8, - }, - buttonRow: { - flexDirection: 'row', - marginBottom: 16, - }, - button: { - backgroundColor: colors.primary, - borderRadius: 8, - paddingVertical: 12, - paddingHorizontal: 20, - alignItems: 'center', - flex: 1, - marginRight: 8, - }, - clearButton: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: colors.error, - marginRight: 0, - marginLeft: 8, - flex: 0, - }, - buttonText: { - color: colors.white, - fontWeight: '500', - fontSize: 15, - }, - clearButtonText: { - color: colors.error, - }, - resultMessage: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 8, - padding: 12, - marginBottom: 16, - }, - successMessage: { - backgroundColor: colors.success + '1A', // 10% opacity - }, - errorMessage: { - backgroundColor: colors.error + '1A', // 10% opacity - }, - resultIcon: { - marginRight: 8, - }, - resultText: { - fontSize: 14, - flex: 1, - }, - successText: { - color: colors.success, - }, - errorText: { - color: colors.error, - }, - helpLink: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 8, - }, - helpIcon: { - marginRight: 6, - }, - helpText: { - color: colors.primary, - fontSize: 14, - }, - infoCard: { - backgroundColor: colors.elevation1, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - flexDirection: 'row', - alignItems: 'flex-start', - }, - infoIcon: { - marginRight: 12, - marginTop: 2, - }, - infoText: { - color: colors.mediumEmphasis, - fontSize: 14, - flex: 1, - lineHeight: 20, - }, -}); - export default TMDBSettingsScreen; \ No newline at end of file diff --git a/src/screens/ThemeScreen.tsx b/src/screens/ThemeScreen.tsx new file mode 100644 index 0000000..5305810 --- /dev/null +++ b/src/screens/ThemeScreen.tsx @@ -0,0 +1,569 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Switch, + ScrollView, + Alert, + Platform, + TextInput, + Dimensions, + StatusBar, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import ColorPicker from 'react-native-wheel-color-picker'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors } from '../styles/colors'; +import { useTheme, Theme, DEFAULT_THEMES } from '../contexts/ThemeContext'; +import { RootStackParamList } from '../navigation/AppNavigator'; + +const { width } = Dimensions.get('window'); + +interface ThemeCardProps { + theme: Theme; + isSelected: boolean; + onSelect: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +const ThemeCard: React.FC = ({ + theme, + isSelected, + onSelect, + onEdit, + onDelete +}) => { + return ( + + + + {theme.name} + + {isSelected && ( + + )} + + + + + Primary + + + Secondary + + + Background + + + + {theme.isEditable && ( + + {onEdit && ( + + + Edit + + )} + {onDelete && ( + + + Delete + + )} + + )} + + ); +}; + +type ColorKey = 'primary' | 'secondary' | 'darkBackground'; + +interface ThemeColorEditorProps { + initialColors: { + primary: string; + secondary: string; + darkBackground: string; + }; + onSave: (colors: { + primary: string; + secondary: string; + darkBackground: string; + name: string; + }) => void; + onCancel: () => void; +} + +const ThemeColorEditor: React.FC = ({ + initialColors, + onSave, + onCancel +}) => { + const [themeName, setThemeName] = useState('Custom Theme'); + const [selectedColorKey, setSelectedColorKey] = useState('primary'); + const [themeColors, setThemeColors] = useState({ + primary: initialColors.primary, + secondary: initialColors.secondary, + darkBackground: initialColors.darkBackground, + }); + + const handleColorChange = useCallback((color: string) => { + setThemeColors(prev => ({ + ...prev, + [selectedColorKey]: color, + })); + }, [selectedColorKey]); + + const handleSave = () => { + if (!themeName.trim()) { + Alert.alert('Invalid Name', 'Please enter a valid theme name'); + return; + } + onSave({ + ...themeColors, + name: themeName + }); + }; + + return ( + + Custom Theme + + + Theme Name + + + + + setSelectedColorKey('primary')} + > + Primary + + + setSelectedColorKey('secondary')} + > + Secondary + + + setSelectedColorKey('darkBackground')} + > + Background + + + + + + + + + + Cancel + + + Save Theme + + + + ); +}; + +const ThemeScreen: React.FC = () => { + const { + currentTheme, + availableThemes, + setCurrentTheme, + addCustomTheme, + updateCustomTheme, + deleteCustomTheme + } = useTheme(); + const navigation = useNavigation>(); + const insets = useSafeAreaInsets(); + + const [isEditMode, setIsEditMode] = useState(false); + const [editingTheme, setEditingTheme] = useState(null); + + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + if (Platform.OS === 'android') { + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + } + }; + + applyStatusBarConfig(); + + // Re-apply on focus + const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); + return unsubscribe; + }, [navigation]); + + const handleThemeSelect = useCallback((themeId: string) => { + setCurrentTheme(themeId); + }, [setCurrentTheme]); + + const handleEditTheme = useCallback((theme: Theme) => { + setEditingTheme(theme); + setIsEditMode(true); + }, []); + + const handleDeleteTheme = useCallback((theme: Theme) => { + Alert.alert( + 'Delete Theme', + `Are you sure you want to delete "${theme.name}"?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => deleteCustomTheme(theme.id) + } + ] + ); + }, [deleteCustomTheme]); + + const handleCreateTheme = useCallback(() => { + setEditingTheme(null); + setIsEditMode(true); + }, []); + + const handleSaveTheme = useCallback((themeData: any) => { + if (editingTheme) { + // Update existing theme + updateCustomTheme({ + ...editingTheme, + name: themeData.name || editingTheme.name, + colors: { + ...editingTheme.colors, + primary: themeData.primary, + secondary: themeData.secondary, + darkBackground: themeData.darkBackground, + } + }); + } else { + // Create new theme + addCustomTheme({ + name: themeData.name || 'Custom Theme', + colors: { + ...currentTheme.colors, + primary: themeData.primary, + secondary: themeData.secondary, + darkBackground: themeData.darkBackground, + } + }); + } + + setIsEditMode(false); + setEditingTheme(null); + }, [editingTheme, updateCustomTheme, addCustomTheme, currentTheme]); + + const handleCancelEdit = useCallback(() => { + setIsEditMode(false); + setEditingTheme(null); + }, []); + + if (isEditMode) { + const initialColors = editingTheme ? { + primary: editingTheme.colors.primary, + secondary: editingTheme.colors.secondary, + darkBackground: editingTheme.colors.darkBackground, + } : { + primary: currentTheme.colors.primary, + secondary: currentTheme.colors.secondary, + darkBackground: currentTheme.colors.darkBackground, + }; + + return ( + + + + ); + } + + return ( + + + navigation.goBack()} + > + + + App Themes + + + + + SELECT THEME + + + + {availableThemes.map(theme => ( + handleThemeSelect(theme.id)} + onEdit={theme.isEditable ? () => handleEditTheme(theme) : undefined} + onDelete={theme.isEditable ? () => handleDeleteTheme(theme) : undefined} + /> + ))} + + + + + Create Custom Theme + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + }, + backButton: { + padding: 8, + }, + headerTitle: { + fontSize: 20, + fontWeight: 'bold', + marginLeft: 16, + }, + content: { + flex: 1, + }, + contentContainer: { + padding: 16, + }, + sectionTitle: { + fontSize: 12, + fontWeight: 'bold', + marginBottom: 16, + }, + themeGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + themeCard: { + width: (width - 48) / 2, + marginBottom: 16, + borderRadius: 12, + padding: 12, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderWidth: 2, + borderColor: 'transparent', + }, + selectedThemeCard: { + borderWidth: 2, + }, + themeCardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + themeCardTitle: { + fontSize: 16, + fontWeight: 'bold', + }, + colorPreviewContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 12, + }, + colorPreview: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + }, + colorPreviewLabel: { + fontSize: 6, + color: '#FFFFFF', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + themeCardActions: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + themeCardAction: { + flexDirection: 'row', + alignItems: 'center', + padding: 4, + }, + themeCardActionText: { + fontSize: 12, + marginLeft: 4, + }, + createButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + borderRadius: 12, + marginTop: 16, + }, + createButtonText: { + color: '#FFFFFF', + fontWeight: 'bold', + marginLeft: 8, + }, + editorContainer: { + flex: 1, + padding: 16, + }, + editorTitle: { + fontSize: 22, + fontWeight: 'bold', + color: '#FFFFFF', + marginBottom: 24, + }, + inputContainer: { + marginBottom: 24, + }, + inputLabel: { + fontSize: 14, + color: 'rgba(255,255,255,0.7)', + marginBottom: 8, + }, + textInput: { + backgroundColor: 'rgba(255,255,255,0.1)', + borderRadius: 8, + padding: 12, + color: '#FFFFFF', + fontSize: 16, + }, + colorSelectorContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 24, + }, + colorSelectorButton: { + width: (width - 64) / 3, + padding: 12, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + selectedColorButton: { + borderWidth: 2, + borderColor: '#FFFFFF', + }, + colorButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: 'bold', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + colorPickerContainer: { + height: 300, + marginBottom: 24, + }, + editorActions: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + cancelButton: { + width: (width - 48) / 2, + padding: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.1)', + }, + cancelButtonText: { + color: '#FFFFFF', + fontWeight: 'bold', + }, + saveButton: { + width: (width - 48) / 2, + padding: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary, + }, + saveButtonText: { + color: '#FFFFFF', + fontWeight: 'bold', + }, +}); + +export default ThemeScreen; \ No newline at end of file diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 8c87af7..bf995e8 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -16,10 +16,10 @@ import { useNavigation } from '@react-navigation/native'; import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { traktService, TraktUser } from '../services/traktService'; -import { colors } from '../styles/colors'; import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; import TraktIcon from '../../assets/rating-icons/trakt.svg'; +import { useTheme } from '../contexts/ThemeContext'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -43,6 +43,7 @@ const TraktSettingsScreen: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState(null); + const { currentTheme } = useTheme(); const checkAuthStatus = useCallback(async () => { setIsLoading(true); @@ -151,7 +152,7 @@ const TraktSettingsScreen: React.FC = () => { return ( @@ -162,12 +163,12 @@ const TraktSettingsScreen: React.FC = () => { Trakt Settings @@ -179,11 +180,11 @@ const TraktSettingsScreen: React.FC = () => { > {isLoading ? ( - + ) : isAuthenticated && userProfile ? ( @@ -194,7 +195,7 @@ const TraktSettingsScreen: React.FC = () => { style={styles.avatar} /> ) : ( - + {userProfile.name?.charAt(0) || userProfile.username.charAt(0)} @@ -203,13 +204,13 @@ const TraktSettingsScreen: React.FC = () => { {userProfile.name || userProfile.username} @{userProfile.username} @@ -224,7 +225,7 @@ const TraktSettingsScreen: React.FC = () => { Joined {new Date(userProfile.joined_at).toLocaleDateString()} @@ -252,20 +253,20 @@ const TraktSettingsScreen: React.FC = () => { /> Connect with Trakt Sync your watch history, watchlist, and collection with Trakt.tv { {isAuthenticated && ( Sync Settings Auto-sync playback progress Coming soon @@ -311,13 +312,13 @@ const TraktSettingsScreen: React.FC = () => { Import watched history Coming soon @@ -331,7 +332,7 @@ const TraktSettingsScreen: React.FC = () => { > Sync Now (Coming Soon) diff --git a/src/styles/homeStyles.ts b/src/styles/homeStyles.ts index b8b0566..dff5863 100644 --- a/src/styles/homeStyles.ts +++ b/src/styles/homeStyles.ts @@ -1,55 +1,40 @@ import { StyleSheet, Dimensions, Platform } from 'react-native'; -import { colors } from './colors'; const { width, height } = Dimensions.get('window'); export const POSTER_WIDTH = (width - 50) / 3; +export const POSTER_HEIGHT = POSTER_WIDTH * 1.5; +export const HORIZONTAL_PADDING = 16; -export const homeStyles = StyleSheet.create({ +export const sharedStyles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.darkBackground, }, - scrollContent: { - paddingBottom: 40, + section: { + marginBottom: 24, }, - loadingMainContainer: { - flex: 1, - justifyContent: 'center', + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', alignItems: 'center', - paddingBottom: 40, + marginBottom: 12, + paddingHorizontal: HORIZONTAL_PADDING, }, - loadingText: { - color: colors.textMuted, - marginTop: 12, - fontSize: 14, + sectionTitle: { + fontSize: 18, + fontWeight: '700', }, - emptyCatalog: { - padding: 32, - alignItems: 'center', - backgroundColor: colors.elevation1, - margin: 16, - borderRadius: 16, - }, - addCatalogButton: { + seeAllButton: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.primary, - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 30, - marginTop: 16, - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 3, }, - addCatalogButtonText: { - color: colors.white, + seeAllText: { fontSize: 14, - fontWeight: '600', - marginLeft: 8, + marginRight: 4, }, }); -export default homeStyles; \ No newline at end of file +export default { + POSTER_WIDTH, + POSTER_HEIGHT, + HORIZONTAL_PADDING, +}; \ No newline at end of file diff --git a/src/styles/screens/discoverStyles.ts b/src/styles/screens/discoverStyles.ts index 1010d2a..18d1bee 100644 --- a/src/styles/screens/discoverStyles.ts +++ b/src/styles/screens/discoverStyles.ts @@ -1,25 +1,26 @@ import { StyleSheet, Dimensions } from 'react-native'; -import { colors } from '../index'; +import { useTheme } from '../../contexts/ThemeContext'; const useDiscoverStyles = () => { const { width } = Dimensions.get('window'); + const { currentTheme } = useTheme(); return StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, headerBackground: { position: 'absolute', top: 0, left: 0, right: 0, - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, zIndex: 1, }, contentContainer: { flex: 1, - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, header: { paddingHorizontal: 20, @@ -36,7 +37,7 @@ const useDiscoverStyles = () => { headerTitle: { fontSize: 32, fontWeight: '800', - color: colors.white, + color: currentTheme.colors.white, letterSpacing: 0.3, }, searchButton: { @@ -56,7 +57,7 @@ const useDiscoverStyles = () => { paddingTop: 80, }, emptyText: { - color: colors.mediumGray, + color: currentTheme.colors.mediumGray, fontSize: 16, textAlign: 'center', paddingHorizontal: 32, From 333c9712a0c9d044a927feff2dafd1b1787a62a3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 21:49:37 +0530 Subject: [PATCH 18/38] Remove unused animation imports from AppNavigator to streamline code --- src/navigation/AppNavigator.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 5e4b70e..36985f5 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -13,7 +13,6 @@ import { colors } from '../styles/colors'; import { NuvioHeader } from '../components/NuvioHeader'; import { Stream } from '../types/streams'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { AnimationFade, AnimationSlideHorizontal } from '../utils/animations'; import { useTheme } from '../contexts/ThemeContext'; // Import screens with their proper types From 802118e476f94580c4fe41c6830c8c1c36285b24 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 21:56:33 +0530 Subject: [PATCH 19/38] Refactor AddonsScreen to integrate theme context and improve UI consistency Update the AddonsScreen component to utilize the new ThemeContext for dynamic theming, enhancing visual consistency across the application. Refactor styles to be theme-aware, ensuring that colors adapt based on the current theme. Additionally, streamline the component's logic and improve the user experience by optimizing the layout and functionality for managing addons and community addons. --- src/screens/AddonsScreen.tsx | 1007 +++++++++++++++++----------------- 1 file changed, 505 insertions(+), 502 deletions(-) diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 4af15cd..1761a9a 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -22,7 +22,6 @@ import { } from 'react-native'; import { stremioService, Manifest } from '../services/stremioService'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../styles'; import { Image as ExpoImage } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useNavigation } from '@react-navigation/native'; @@ -32,6 +31,7 @@ import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { BlurView } from 'expo-blur'; import axios from 'axios'; +import { useTheme } from '../contexts/ThemeContext'; // Extend Manifest type to include logo only (remove disabled status) interface ExtendedManifest extends Manifest { @@ -54,6 +54,506 @@ const { width } = Dimensions.get('window'); const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +// Create a styles creator function that accepts the theme colors +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + headerButton: { + padding: 8, + marginLeft: 8, + }, + activeHeaderButton: { + backgroundColor: 'rgba(45, 156, 219, 0.2)', + borderRadius: 6, + }, + reorderModeText: { + color: colors.primary, + fontSize: 18, + fontWeight: '400', + }, + reorderInfoBanner: { + backgroundColor: 'rgba(45, 156, 219, 0.15)', + paddingHorizontal: 16, + paddingVertical: 10, + marginHorizontal: 16, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + reorderInfoText: { + color: colors.white, + fontSize: 14, + marginLeft: 8, + }, + reorderButtons: { + position: 'absolute', + left: -12, + top: '50%', + marginTop: -40, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10, + }, + reorderButton: { + backgroundColor: colors.elevation3, + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + marginVertical: 4, + }, + disabledButton: { + opacity: 0.5, + backgroundColor: colors.elevation2, + }, + priorityBadge: { + backgroundColor: colors.primary, + borderRadius: 12, + paddingHorizontal: 8, + paddingVertical: 3, + }, + priorityText: { + color: colors.white, + fontSize: 12, + fontWeight: 'bold', + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + scrollView: { + flex: 1, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + color: colors.mediumGray, + marginHorizontal: 16, + marginBottom: 8, + letterSpacing: 0.5, + textTransform: 'uppercase', + }, + statsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginHorizontal: 16, + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + statsCard: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + statsDivider: { + width: 1, + height: '80%', + backgroundColor: 'rgba(150, 150, 150, 0.2)', + alignSelf: 'center', + }, + statsValue: { + fontSize: 24, + fontWeight: 'bold', + color: colors.white, + marginBottom: 4, + }, + statsLabel: { + fontSize: 13, + color: colors.mediumGray, + }, + addAddonContainer: { + marginHorizontal: 16, + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + addonInput: { + backgroundColor: colors.elevation1, + borderRadius: 8, + padding: 12, + color: colors.white, + marginBottom: 16, + fontSize: 15, + }, + addButton: { + backgroundColor: colors.primary, + borderRadius: 8, + padding: 12, + alignItems: 'center', + }, + addButtonText: { + color: colors.white, + fontWeight: '600', + fontSize: 16, + }, + addonList: { + paddingHorizontal: 16, + }, + emptyContainer: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 32, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + emptyText: { + marginTop: 8, + color: colors.mediumGray, + fontSize: 15, + }, + addonItem: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + marginBottom: 16, + }, + addonHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + addonIcon: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: colors.elevation3, + }, + addonIconPlaceholder: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: colors.elevation3, + justifyContent: 'center', + alignItems: 'center', + }, + addonTitleContainer: { + flex: 1, + marginLeft: 12, + marginRight: 16, + }, + addonName: { + fontSize: 17, + fontWeight: '600', + color: colors.white, + marginBottom: 2, + }, + addonMetaContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + addonVersion: { + fontSize: 13, + color: colors.mediumGray, + }, + addonDot: { + fontSize: 13, + color: colors.mediumGray, + marginHorizontal: 4, + }, + addonCategory: { + fontSize: 13, + color: colors.mediumGray, + flex: 1, + }, + addonDescription: { + fontSize: 14, + color: colors.mediumEmphasis, + marginTop: 6, + marginBottom: 4, + lineHeight: 20, + marginLeft: 48, // Align with title, accounting for icon width + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + modalContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { + backgroundColor: colors.elevation2, + borderRadius: 14, + width: '85%', + maxHeight: '85%', + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 5, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, + }, + modalTitle: { + fontSize: 17, + fontWeight: 'bold', + color: colors.white, + }, + modalScrollContent: { + maxHeight: 400, + }, + addonDetailHeader: { + alignItems: 'center', + padding: 24, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, + }, + addonLogo: { + width: 64, + height: 64, + borderRadius: 12, + marginBottom: 16, + backgroundColor: colors.elevation3, + }, + addonLogoPlaceholder: { + width: 64, + height: 64, + borderRadius: 12, + backgroundColor: colors.elevation3, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + addonDetailName: { + fontSize: 20, + fontWeight: 'bold', + color: colors.white, + marginBottom: 4, + textAlign: 'center', + }, + addonDetailVersion: { + fontSize: 14, + color: colors.mediumGray, + }, + addonDetailSection: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, + }, + addonDetailSectionTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 8, + }, + addonDetailDescription: { + fontSize: 15, + color: colors.mediumEmphasis, + lineHeight: 20, + }, + addonDetailChips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + addonDetailChip: { + backgroundColor: colors.elevation3, + borderRadius: 12, + paddingHorizontal: 8, + paddingVertical: 4, + }, + addonDetailChipText: { + fontSize: 13, + color: colors.white, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + padding: 16, + borderTopWidth: 1, + borderTopColor: colors.elevation3, + }, + modalButton: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + minWidth: 80, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: colors.elevation3, + marginRight: 8, + }, + installButton: { + backgroundColor: colors.success, + borderRadius: 6, + padding: 8, + justifyContent: 'center', + alignItems: 'center', + }, + modalButtonText: { + color: colors.white, + fontWeight: '600', + }, + addonActions: { + flexDirection: 'row', + alignItems: 'center', + }, + deleteButton: { + padding: 6, + }, + configButton: { + padding: 6, + marginRight: 8, + }, + communityAddonsList: { + paddingHorizontal: 20, + }, + communityAddonItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: 8, + padding: 15, + marginBottom: 10, + }, + communityAddonIcon: { + width: 40, + height: 40, + borderRadius: 6, + marginRight: 15, + }, + communityAddonIconPlaceholder: { + width: 40, + height: 40, + borderRadius: 6, + marginRight: 15, + backgroundColor: colors.darkGray, + justifyContent: 'center', + alignItems: 'center', + }, + communityAddonDetails: { + flex: 1, + marginRight: 10, + }, + communityAddonName: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 3, + }, + communityAddonDesc: { + fontSize: 13, + color: colors.lightGray, + marginBottom: 5, + opacity: 0.9, + }, + communityAddonMetaContainer: { + flexDirection: 'row', + alignItems: 'center', + opacity: 0.8, + }, + communityAddonVersion: { + fontSize: 12, + color: colors.lightGray, + }, + communityAddonDot: { + fontSize: 12, + color: colors.lightGray, + marginHorizontal: 5, + }, + communityAddonCategory: { + fontSize: 12, + color: colors.lightGray, + flexShrink: 1, + }, + separator: { + height: 10, + }, + sectionSeparator: { + height: 1, + backgroundColor: colors.border, + marginHorizontal: 20, + marginVertical: 20, + }, + emptyMessage: { + textAlign: 'center', + color: colors.mediumGray, + marginTop: 20, + fontSize: 16, + paddingHorizontal: 20, + }, + errorMessage: { + textAlign: 'center', + color: colors.error, + marginTop: 20, + fontSize: 16, + paddingHorizontal: 20, + }, + loader: { + marginTop: 30, + alignSelf: 'center', + }, + addonActionButtons: { + flexDirection: 'row', + alignItems: 'center', + }, +}); + const AddonsScreen = () => { const navigation = useNavigation>(); const [addons, setAddons] = useState([]); @@ -65,8 +565,10 @@ const AddonsScreen = () => { const [catalogCount, setCatalogCount] = useState(0); // Add state for reorder mode const [reorderMode, setReorderMode] = useState(false); - // Force dark mode - const isDarkMode = true; + // Use ThemeContext + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); // State for community addons const [communityAddons, setCommunityAddons] = useState([]); @@ -836,503 +1338,4 @@ const AddonsScreen = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, - }, - headerActions: { - flexDirection: 'row', - alignItems: 'center', - }, - headerButton: { - padding: 8, - marginLeft: 8, - }, - activeHeaderButton: { - backgroundColor: 'rgba(45, 156, 219, 0.2)', - borderRadius: 6, - }, - reorderModeText: { - color: colors.primary, - fontSize: 18, - fontWeight: '400', - }, - reorderInfoBanner: { - backgroundColor: 'rgba(45, 156, 219, 0.15)', - paddingHorizontal: 16, - paddingVertical: 10, - marginHorizontal: 16, - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - reorderInfoText: { - color: colors.white, - fontSize: 14, - marginLeft: 8, - }, - reorderButtons: { - position: 'absolute', - left: -12, - top: '50%', - marginTop: -40, - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - zIndex: 10, - }, - reorderButton: { - backgroundColor: colors.elevation3, - width: 30, - height: 30, - borderRadius: 15, - justifyContent: 'center', - alignItems: 'center', - marginVertical: 4, - }, - disabledButton: { - opacity: 0.5, - backgroundColor: colors.elevation2, - }, - priorityBadge: { - backgroundColor: colors.primary, - borderRadius: 12, - paddingHorizontal: 8, - paddingVertical: 3, - }, - priorityText: { - color: colors.white, - fontSize: 12, - fontWeight: 'bold', - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - padding: 8, - }, - backText: { - fontSize: 17, - fontWeight: '400', - color: colors.primary, - }, - headerTitle: { - fontSize: 34, - fontWeight: '700', - color: colors.white, - paddingHorizontal: 16, - paddingBottom: 16, - paddingTop: 8, - }, - scrollView: { - flex: 1, - }, - section: { - marginBottom: 24, - }, - sectionTitle: { - fontSize: 13, - fontWeight: '600', - color: colors.mediumGray, - marginHorizontal: 16, - marginBottom: 8, - letterSpacing: 0.5, - textTransform: 'uppercase', - }, - statsContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - marginHorizontal: 16, - backgroundColor: colors.elevation2, - borderRadius: 12, - padding: 16, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - statsCard: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - statsDivider: { - width: 1, - height: '80%', - backgroundColor: 'rgba(150, 150, 150, 0.2)', - alignSelf: 'center', - }, - statsValue: { - fontSize: 24, - fontWeight: 'bold', - color: colors.white, - marginBottom: 4, - }, - statsLabel: { - fontSize: 13, - color: colors.mediumGray, - }, - addAddonContainer: { - marginHorizontal: 16, - backgroundColor: colors.elevation2, - borderRadius: 12, - padding: 16, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - addonInput: { - backgroundColor: colors.elevation1, - borderRadius: 8, - padding: 12, - color: colors.white, - marginBottom: 16, - fontSize: 15, - }, - addButton: { - backgroundColor: colors.primary, - borderRadius: 8, - padding: 12, - alignItems: 'center', - }, - addButtonText: { - color: colors.white, - fontWeight: '600', - fontSize: 16, - }, - addonList: { - paddingHorizontal: 16, - }, - emptyContainer: { - backgroundColor: colors.elevation2, - borderRadius: 12, - padding: 32, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - emptyText: { - marginTop: 8, - color: colors.mediumGray, - fontSize: 15, - }, - addonItem: { - backgroundColor: colors.elevation2, - borderRadius: 12, - padding: 16, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - marginBottom: 16, - }, - addonHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - addonIcon: { - width: 36, - height: 36, - borderRadius: 8, - backgroundColor: colors.elevation3, - }, - addonIconPlaceholder: { - width: 36, - height: 36, - borderRadius: 8, - backgroundColor: colors.elevation3, - justifyContent: 'center', - alignItems: 'center', - }, - addonTitleContainer: { - flex: 1, - marginLeft: 12, - marginRight: 16, - }, - addonName: { - fontSize: 17, - fontWeight: '600', - color: colors.white, - marginBottom: 2, - }, - addonMetaContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - addonVersion: { - fontSize: 13, - color: colors.mediumGray, - }, - addonDot: { - fontSize: 13, - color: colors.mediumGray, - marginHorizontal: 4, - }, - addonCategory: { - fontSize: 13, - color: colors.mediumGray, - flex: 1, - }, - addonDescription: { - fontSize: 14, - color: colors.mediumEmphasis, - marginTop: 6, - marginBottom: 4, - lineHeight: 20, - marginLeft: 48, // Align with title, accounting for icon width - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - modalContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - modalContent: { - backgroundColor: colors.elevation2, - borderRadius: 14, - width: '85%', - maxHeight: '85%', - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 5, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - borderBottomColor: colors.elevation3, - }, - modalTitle: { - fontSize: 17, - fontWeight: 'bold', - color: colors.white, - }, - modalScrollContent: { - maxHeight: 400, - }, - addonDetailHeader: { - alignItems: 'center', - padding: 24, - borderBottomWidth: 1, - borderBottomColor: colors.elevation3, - }, - addonLogo: { - width: 64, - height: 64, - borderRadius: 12, - marginBottom: 16, - backgroundColor: colors.elevation3, - }, - addonLogoPlaceholder: { - width: 64, - height: 64, - borderRadius: 12, - backgroundColor: colors.elevation3, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - addonDetailName: { - fontSize: 20, - fontWeight: 'bold', - color: colors.white, - marginBottom: 4, - textAlign: 'center', - }, - addonDetailVersion: { - fontSize: 14, - color: colors.mediumGray, - }, - addonDetailSection: { - padding: 16, - borderBottomWidth: 1, - borderBottomColor: colors.elevation3, - }, - addonDetailSectionTitle: { - fontSize: 16, - fontWeight: '600', - color: colors.white, - marginBottom: 8, - }, - addonDetailDescription: { - fontSize: 15, - color: colors.mediumEmphasis, - lineHeight: 20, - }, - addonDetailChips: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - addonDetailChip: { - backgroundColor: colors.elevation3, - borderRadius: 12, - paddingHorizontal: 8, - paddingVertical: 4, - }, - addonDetailChipText: { - fontSize: 13, - color: colors.white, - }, - modalActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - padding: 16, - borderTopWidth: 1, - borderTopColor: colors.elevation3, - }, - modalButton: { - paddingVertical: 8, - paddingHorizontal: 16, - borderRadius: 8, - minWidth: 80, - alignItems: 'center', - }, - cancelButton: { - backgroundColor: colors.elevation3, - marginRight: 8, - }, - installButton: { - backgroundColor: colors.success, - borderRadius: 6, - padding: 8, - justifyContent: 'center', - alignItems: 'center', - }, - modalButtonText: { - color: colors.white, - fontWeight: '600', - }, - addonActions: { - flexDirection: 'row', - alignItems: 'center', - }, - deleteButton: { - padding: 6, - }, - configButton: { - padding: 6, - marginRight: 8, - }, - communityAddonsList: { - paddingHorizontal: 20, - }, - communityAddonItem: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.card, - borderRadius: 8, - padding: 15, - marginBottom: 10, - }, - communityAddonIcon: { - width: 40, - height: 40, - borderRadius: 6, - marginRight: 15, - }, - communityAddonIconPlaceholder: { - width: 40, - height: 40, - borderRadius: 6, - marginRight: 15, - backgroundColor: colors.darkGray, - justifyContent: 'center', - alignItems: 'center', - }, - communityAddonDetails: { - flex: 1, - marginRight: 10, - }, - communityAddonName: { - fontSize: 16, - fontWeight: '600', - color: colors.white, - marginBottom: 3, - }, - communityAddonDesc: { - fontSize: 13, - color: colors.lightGray, - marginBottom: 5, - opacity: 0.9, - }, - communityAddonMetaContainer: { - flexDirection: 'row', - alignItems: 'center', - opacity: 0.8, - }, - communityAddonVersion: { - fontSize: 12, - color: colors.lightGray, - }, - communityAddonDot: { - fontSize: 12, - color: colors.lightGray, - marginHorizontal: 5, - }, - communityAddonCategory: { - fontSize: 12, - color: colors.lightGray, - flexShrink: 1, - }, - separator: { - height: 10, - }, - sectionSeparator: { - height: 1, - backgroundColor: colors.border, - marginHorizontal: 20, - marginVertical: 20, - }, - emptyMessage: { - textAlign: 'center', - color: colors.mediumGray, - marginTop: 20, - fontSize: 16, - paddingHorizontal: 20, - }, - errorMessage: { - textAlign: 'center', - color: colors.error, - marginTop: 20, - fontSize: 16, - paddingHorizontal: 20, - }, - loader: { - marginTop: 30, - alignSelf: 'center', - }, - addonActionButtons: { - flexDirection: 'row', - alignItems: 'center', - }, -}); - export default AddonsScreen; \ No newline at end of file From 76aa6d21fbfbc7c2321e715d08c0860bdc59d167 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 22:01:50 +0530 Subject: [PATCH 20/38] f --- src/screens/LogoSourceSettings.tsx | 532 +++++++++++++++-------------- 1 file changed, 268 insertions(+), 264 deletions(-) diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 1d45b27..20f5826 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -16,11 +16,11 @@ import { import { NavigationProp, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { colors } from '../styles/colors'; import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { TMDBService } from '../services/tmdbService'; import { logger } from '../utils/logger'; +import { useTheme } from '../contexts/ThemeContext'; // TMDB API key - since the default key might be private in the service, we'll use our own const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c'; @@ -71,10 +71,277 @@ const EXAMPLE_SHOWS = [ } ]; +// Create a styles creator function that accepts the theme colors +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 16 : 16, + backgroundColor: colors.darkBackground, + }, + backButton: { + padding: 4, + }, + headerTitle: { + fontSize: 22, + fontWeight: '600', + marginLeft: 16, + color: colors.white, + }, + headerRight: { + width: 24, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 24, + }, + descriptionContainer: { + marginBottom: 16, + }, + description: { + color: colors.mediumEmphasis, + fontSize: 15, + lineHeight: 22, + }, + showSelectorContainer: { + marginBottom: 16, + }, + selectorLabel: { + color: colors.highEmphasis, + fontSize: 16, + fontWeight: '500', + marginBottom: 12, + }, + showsScrollContent: { + paddingRight: 16, + }, + showItem: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: colors.elevation2, + borderRadius: 16, + marginRight: 6, + borderWidth: 1, + borderColor: 'transparent', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 1, + elevation: 1, + }, + selectedShowItem: { + borderColor: colors.primary, + backgroundColor: colors.elevation3, + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 2, + }, + showItemText: { + color: colors.mediumEmphasis, + fontSize: 14, + }, + selectedShowItemText: { + color: colors.white, + fontWeight: '600', + }, + optionsContainer: { + marginBottom: 16, + gap: 12, + }, + optionCard: { + backgroundColor: colors.elevation2, + borderRadius: 8, + padding: 12, + borderWidth: 2, + borderColor: 'transparent', + marginBottom: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + elevation: 2, + }, + selectedCard: { + borderColor: colors.primary, + shadowColor: colors.primary, + shadowOpacity: 0.3, + elevation: 3, + }, + optionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 6, + }, + optionTitle: { + color: colors.white, + fontSize: 16, + fontWeight: '600', + }, + optionDescription: { + color: colors.mediumEmphasis, + fontSize: 13, + lineHeight: 18, + marginBottom: 10, + }, + exampleContainer: { + marginTop: 4, + }, + exampleLabel: { + color: colors.mediumEmphasis, + fontSize: 13, + marginBottom: 4, + }, + exampleImage: { + height: 60, + width: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 8, + }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + infoBox: { + marginBottom: 16, + padding: 12, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 8, + borderLeftWidth: 3, + borderLeftColor: colors.primary, + }, + infoText: { + color: colors.mediumEmphasis, + fontSize: 12, + lineHeight: 18, + }, + logoSourceLabel: { + color: colors.mediumEmphasis, + fontSize: 11, + marginTop: 2, + }, + languageSelectorContainer: { + marginTop: 10, + padding: 10, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 6, + }, + languageSelectorTitle: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + marginBottom: 4, + }, + languageSelectorDescription: { + color: colors.mediumEmphasis, + fontSize: 12, + lineHeight: 18, + marginBottom: 8, + }, + languageSelectorLabel: { + color: colors.mediumEmphasis, + fontSize: 12, + marginBottom: 6, + }, + languageScrollContent: { + paddingVertical: 2, + }, + languageItem: { + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: colors.elevation1, + borderRadius: 12, + marginRight: 6, + borderWidth: 1, + borderColor: colors.elevation3, + marginVertical: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 1, + elevation: 1, + }, + selectedLanguageItem: { + backgroundColor: colors.primary, + borderColor: colors.primary, + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 1, + elevation: 2, + }, + languageItemText: { + color: colors.mediumEmphasis, + fontSize: 12, + fontWeight: '600', + }, + selectedLanguageItemText: { + color: colors.white, + }, + noteText: { + color: colors.mediumEmphasis, + fontSize: 11, + marginTop: 8, + fontStyle: 'italic', + }, + bannerContainer: { + height: 90, + width: '100%', + borderRadius: 6, + overflow: 'hidden', + position: 'relative', + }, + bannerImage: { + ...StyleSheet.absoluteFillObject, + }, + bannerOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + logoOverBanner: { + position: 'absolute', + width: '80%', + height: '75%', + alignSelf: 'center', + top: '12.5%', + }, + noLogoContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }, + noLogoText: { + color: colors.white, + fontSize: 14, + backgroundColor: 'rgba(0,0,0,0.5)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 4, + }, +}); + const LogoSourceSettings = () => { const { settings, updateSetting } = useSettings(); const navigation = useNavigation>(); const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); // Get current preference const [logoSource, setLogoSource] = useState<'metahub' | 'tmdb'>( @@ -644,267 +911,4 @@ const LogoSourceSettings = () => { ); }; - const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 16, - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 16 : 16, - backgroundColor: colors.darkBackground, - }, - backButton: { - padding: 4, - }, - headerTitle: { - fontSize: 22, - fontWeight: '600', - marginLeft: 16, - color: colors.white, - }, - headerRight: { - width: 24, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 16, - paddingBottom: 24, - }, - descriptionContainer: { - marginBottom: 16, - }, - description: { - color: colors.mediumEmphasis, - fontSize: 15, - lineHeight: 22, - }, - showSelectorContainer: { - marginBottom: 16, - }, - selectorLabel: { - color: colors.highEmphasis, - fontSize: 16, - fontWeight: '500', - marginBottom: 12, - }, - showsScrollContent: { - paddingRight: 16, - }, - showItem: { - paddingHorizontal: 12, - paddingVertical: 6, - backgroundColor: colors.elevation2, - borderRadius: 16, - marginRight: 6, - borderWidth: 1, - borderColor: 'transparent', - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 1, - elevation: 1, - }, - selectedShowItem: { - borderColor: colors.primary, - backgroundColor: colors.elevation3, - shadowColor: colors.primary, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, - elevation: 2, - }, - showItemText: { - color: colors.mediumEmphasis, - fontSize: 14, - }, - selectedShowItemText: { - color: colors.white, - fontWeight: '600', - }, - optionsContainer: { - marginBottom: 16, - gap: 12, - }, - optionCard: { - backgroundColor: colors.elevation2, - borderRadius: 8, - padding: 12, - borderWidth: 2, - borderColor: 'transparent', - marginBottom: 8, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 3, - elevation: 2, - }, - selectedCard: { - borderColor: colors.primary, - shadowColor: colors.primary, - shadowOpacity: 0.3, - elevation: 3, - }, - optionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 6, - }, - optionTitle: { - color: colors.white, - fontSize: 16, - fontWeight: '600', - }, - optionDescription: { - color: colors.mediumEmphasis, - fontSize: 13, - lineHeight: 18, - marginBottom: 10, - }, - exampleContainer: { - marginTop: 4, - }, - exampleLabel: { - color: colors.mediumEmphasis, - fontSize: 13, - marginBottom: 4, - }, - exampleImage: { - height: 60, - width: '100%', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: 8, - }, - loadingContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - infoBox: { - marginBottom: 16, - padding: 12, - backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 8, - borderLeftWidth: 3, - borderLeftColor: colors.primary, - }, - infoText: { - color: colors.mediumEmphasis, - fontSize: 12, - lineHeight: 18, - }, - logoSourceLabel: { - color: colors.mediumEmphasis, - fontSize: 11, - marginTop: 2, - }, - languageSelectorContainer: { - marginTop: 10, - padding: 10, - backgroundColor: 'rgba(255,255,255,0.05)', - borderRadius: 6, - }, - languageSelectorTitle: { - color: colors.white, - fontSize: 14, - fontWeight: '600', - marginBottom: 4, - }, - languageSelectorDescription: { - color: colors.mediumEmphasis, - fontSize: 12, - lineHeight: 18, - marginBottom: 8, - }, - languageSelectorLabel: { - color: colors.mediumEmphasis, - fontSize: 12, - marginBottom: 6, - }, - languageScrollContent: { - paddingVertical: 2, - }, - languageItem: { - paddingHorizontal: 10, - paddingVertical: 6, - backgroundColor: colors.elevation1, - borderRadius: 12, - marginRight: 6, - borderWidth: 1, - borderColor: colors.elevation3, - marginVertical: 1, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 1, - elevation: 1, - }, - selectedLanguageItem: { - backgroundColor: colors.primary, - borderColor: colors.primary, - shadowColor: colors.primary, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 1, - elevation: 2, - }, - languageItemText: { - color: colors.mediumEmphasis, - fontSize: 12, - fontWeight: '600', - }, - selectedLanguageItemText: { - color: colors.white, - }, - noteText: { - color: colors.mediumEmphasis, - fontSize: 11, - marginTop: 8, - fontStyle: 'italic', - }, - bannerContainer: { - height: 90, - width: '100%', - borderRadius: 6, - overflow: 'hidden', - position: 'relative', - }, - bannerImage: { - ...StyleSheet.absoluteFillObject, - }, - bannerOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0,0,0,0.5)', - }, - logoOverBanner: { - position: 'absolute', - width: '80%', - height: '75%', - alignSelf: 'center', - top: '12.5%', - }, - noLogoContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'center', - alignItems: 'center', - }, - noLogoText: { - color: colors.white, - fontSize: 14, - backgroundColor: 'rgba(0,0,0,0.5)', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 4, - }, - }); - export default LogoSourceSettings; \ No newline at end of file From ccff34016b8116a6a60ef0421298b933ea44202b Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 22:04:03 +0530 Subject: [PATCH 21/38] f --- src/screens/HomeScreenSettings.tsx | 27 +- src/screens/MDBListSettingsScreen.tsx | 604 +++++++++++++------------- 2 files changed, 322 insertions(+), 309 deletions(-) diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index 9b0d9b0..dc07580 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -16,7 +16,7 @@ import { useSettings } from '../hooks/useSettings'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../styles/colors'; +import { useTheme } from '../contexts/ThemeContext'; import { RootStackParamList } from '../navigation/AppNavigator'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -24,9 +24,10 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; interface SettingsCardProps { children: React.ReactNode; isDarkMode: boolean; + colors: any; } -const SettingsCard: React.FC = ({ children, isDarkMode }) => ( +const SettingsCard: React.FC = ({ children, isDarkMode, colors }) => ( void; isDarkMode: boolean; + colors: any; } const SettingItem: React.FC = ({ @@ -55,7 +57,8 @@ const SettingItem: React.FC = ({ renderControl, isLast = false, onPress, - isDarkMode + isDarkMode, + colors }) => { return ( = ({ ); }; -const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => ( +const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any }> = ({ title, isDarkMode, colors }) => ( = ({ title const HomeScreenSettings: React.FC = () => { const { settings, updateSetting } = useSettings(); const systemColorScheme = useColorScheme(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; const navigation = useNavigation>(); const [showSavedIndicator, setShowSavedIndicator] = useState(false); @@ -161,7 +166,7 @@ const HomeScreenSettings: React.FC = () => { styles.radio, { borderColor: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark } ]}> - {selected && } + {selected && } { showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent} > - - + + ( { description={settings.featuredContentSource === 'tmdb' ? 'TMDB Trending' : 'From Catalogs'} icon="settings-input-component" isDarkMode={isDarkMode} + colors={colors} renderControl={() => } isLast={!settings.showHeroSection || settings.featuredContentSource !== 'catalogs'} /> @@ -257,6 +264,7 @@ const HomeScreenSettings: React.FC = () => { description={getSelectedCatalogsText()} icon="list" isDarkMode={isDarkMode} + colors={colors} renderControl={ChevronRight} onPress={() => navigation.navigate('HeroCatalogs')} isLast={true} @@ -300,7 +308,7 @@ const HomeScreenSettings: React.FC = () => { )} - + These settings control how content is displayed on your Home screen. Changes are applied immediately without requiring an app restart. @@ -401,7 +409,7 @@ const styles = StyleSheet.create({ marginHorizontal: 16, marginVertical: 8, borderRadius: 12, - backgroundColor: colors.elevation1, + backgroundColor: 'rgba(255,255,255,0.05)', overflow: 'hidden', }, radioOption: { @@ -424,7 +432,6 @@ const styles = StyleSheet.create({ width: 10, height: 10, borderRadius: 5, - backgroundColor: colors.primary, }, radioLabel: { fontSize: 16, diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx index 73dc1f3..7dd3dda 100644 --- a/src/screens/MDBListSettingsScreen.tsx +++ b/src/screens/MDBListSettingsScreen.tsx @@ -19,7 +19,7 @@ import { import { useNavigation } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { colors } from '../styles/colors'; +import { useTheme } from '../contexts/ThemeContext'; import { logger } from '../utils/logger'; import { RATING_PROVIDERS } from '../components/metadata/RatingsSection'; @@ -55,8 +55,312 @@ export const getMDBListAPIKey = async (): Promise => { } }; +// Create a styles creator function that accepts the theme colors +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + marginLeft: 0, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + content: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 12, + paddingTop: 10, + paddingBottom: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.darkBackground, + }, + loadingText: { + marginTop: 12, + fontSize: 15, + color: colors.mediumGray, + }, + card: { + backgroundColor: colors.elevation2, + borderRadius: 10, + padding: 12, + marginBottom: 16, + }, + statusCard: { + backgroundColor: colors.elevation1, + borderRadius: 10, + paddingVertical: 12, + paddingHorizontal: 16, + marginBottom: 16, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + infoCard: { + backgroundColor: colors.elevation1, + borderRadius: 10, + padding: 12, + }, + statusIcon: { + marginRight: 12, + }, + statusTextContainer: { + flex: 1, + }, + statusTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 2, + }, + statusDescription: { + fontSize: 13, + color: colors.mediumGray, + lineHeight: 18, + }, + sectionTitle: { + fontSize: 15, + fontWeight: '600', + color: colors.lightGray, + marginBottom: 10, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.elevation2, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + }, + input: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 10, + color: colors.white, + fontSize: 15, + }, + inputFocused: { + borderColor: colors.primary, + }, + pasteButton: { + padding: 8, + marginRight: 2, + }, + testResultContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 6, + marginTop: 10, + borderWidth: 1, + }, + testResultSuccess: { + backgroundColor: colors.success + '15', + borderColor: colors.success + '40', + }, + testResultError: { + backgroundColor: colors.error + '15', + borderColor: colors.error + '40', + }, + testResultText: { + marginLeft: 8, + fontSize: 13, + flex: 1, + }, + buttonContainer: { + marginTop: 12, + gap: 10, + }, + buttonIcon: { + marginRight: 6, + }, + saveButton: { + backgroundColor: colors.primary, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + }, + saveButtonDisabled: { + backgroundColor: colors.elevation2, + opacity: 0.8, + }, + saveButtonText: { + color: colors.white, + fontSize: 15, + fontWeight: '600', + }, + clearButton: { + backgroundColor: 'transparent', + borderRadius: 8, + borderWidth: 1, + borderColor: colors.error + '40', + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + }, + clearButtonDisabled: { + borderColor: colors.border, + }, + clearButtonText: { + color: colors.error, + fontSize: 15, + fontWeight: '600', + }, + clearButtonTextDisabled: { + color: colors.darkGray, + }, + infoHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + infoHeaderText: { + fontSize: 15, + fontWeight: '600', + color: colors.white, + marginLeft: 8, + }, + infoSteps: { + marginBottom: 12, + gap: 6, + }, + infoStep: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + infoStepNumber: { + fontSize: 13, + color: colors.mediumGray, + width: 20, + }, + infoStepText: { + color: colors.mediumGray, + fontSize: 13, + flex: 1, + lineHeight: 18, + }, + boldText: { + fontWeight: '600', + color: colors.lightGray, + }, + websiteButton: { + backgroundColor: colors.primary + '20', + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + marginTop: 12, + }, + websiteButtonText: { + color: colors.primary, + fontSize: 15, + fontWeight: '600', + }, + websiteButtonDisabled: { + backgroundColor: colors.elevation1, + }, + websiteButtonTextDisabled: { + color: colors.darkGray, + }, + sectionDescription: { + fontSize: 13, + color: colors.mediumGray, + marginBottom: 12, + }, + providerItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + providerInfo: { + flex: 1, + }, + providerName: { + fontSize: 15, + color: colors.white, + fontWeight: '500', + }, + masterToggleContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 4, + }, + masterToggleInfo: { + flex: 1, + }, + masterToggleTitle: { + fontSize: 15, + color: colors.white, + fontWeight: '600', + }, + masterToggleDescription: { + fontSize: 13, + color: colors.mediumGray, + marginTop: 2, + }, + disabledCard: { + opacity: 0.7, + }, + disabledInput: { + borderColor: colors.border, + backgroundColor: colors.elevation1, + }, + disabledText: { + color: colors.darkGray, + }, + disabledBoldText: { + color: colors.darkGray, + }, + darkGray: { + color: colors.darkGray || '#555555', + }, +}); + const MDBListSettingsScreen = () => { const navigation = useNavigation(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); + const [apiKey, setApiKey] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isKeySet, setIsKeySet] = useState(false); @@ -523,302 +827,4 @@ const MDBListSettingsScreen = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - padding: 8, - }, - backText: { - fontSize: 17, - fontWeight: '400', - color: colors.primary, - marginLeft: 0, - }, - headerTitle: { - fontSize: 34, - fontWeight: '700', - color: colors.white, - paddingHorizontal: 16, - paddingBottom: 16, - paddingTop: 8, - }, - content: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 12, - paddingTop: 10, - paddingBottom: 20, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.darkBackground, - }, - loadingText: { - marginTop: 12, - fontSize: 15, - color: colors.mediumGray, - }, - card: { - backgroundColor: colors.elevation2, - borderRadius: 10, - padding: 12, - marginBottom: 16, - }, - statusCard: { - backgroundColor: colors.elevation1, - borderRadius: 10, - paddingVertical: 12, - paddingHorizontal: 16, - marginBottom: 16, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderColor: colors.border, - }, - infoCard: { - backgroundColor: colors.elevation1, - borderRadius: 10, - padding: 12, - }, - statusIcon: { - marginRight: 12, - }, - statusTextContainer: { - flex: 1, - }, - statusTitle: { - fontSize: 16, - fontWeight: '600', - color: colors.white, - marginBottom: 2, - }, - statusDescription: { - fontSize: 13, - color: colors.mediumGray, - lineHeight: 18, - }, - sectionTitle: { - fontSize: 15, - fontWeight: '600', - color: colors.lightGray, - marginBottom: 10, - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.elevation2, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - }, - input: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 10, - color: colors.white, - fontSize: 15, - }, - inputFocused: { - borderColor: colors.primary, - }, - pasteButton: { - padding: 8, - marginRight: 2, - }, - testResultContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 8, - paddingHorizontal: 10, - borderRadius: 6, - marginTop: 10, - borderWidth: 1, - }, - testResultSuccess: { - backgroundColor: colors.success + '15', - borderColor: colors.success + '40', - }, - testResultError: { - backgroundColor: colors.error + '15', - borderColor: colors.error + '40', - }, - testResultText: { - marginLeft: 8, - fontSize: 13, - flex: 1, - }, - buttonContainer: { - marginTop: 12, - gap: 10, - }, - buttonIcon: { - marginRight: 6, - }, - saveButton: { - backgroundColor: colors.primary, - borderRadius: 8, - paddingVertical: 12, - paddingHorizontal: 12, - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - }, - saveButtonDisabled: { - backgroundColor: colors.elevation2, - opacity: 0.8, - }, - saveButtonText: { - color: colors.white, - fontSize: 15, - fontWeight: '600', - }, - clearButton: { - backgroundColor: 'transparent', - borderRadius: 8, - borderWidth: 1, - borderColor: colors.error + '40', - paddingVertical: 12, - paddingHorizontal: 12, - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - }, - clearButtonDisabled: { - borderColor: colors.border, - }, - clearButtonText: { - color: colors.error, - fontSize: 15, - fontWeight: '600', - }, - clearButtonTextDisabled: { - color: colors.darkGray, - }, - infoHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 10, - }, - infoHeaderText: { - fontSize: 15, - fontWeight: '600', - color: colors.white, - marginLeft: 8, - }, - infoSteps: { - marginBottom: 12, - gap: 6, - }, - infoStep: { - flexDirection: 'row', - alignItems: 'flex-start', - }, - infoStepNumber: { - fontSize: 13, - color: colors.mediumGray, - width: 20, - }, - infoStepText: { - color: colors.mediumGray, - fontSize: 13, - flex: 1, - lineHeight: 18, - }, - boldText: { - fontWeight: '600', - color: colors.lightGray, - }, - websiteButton: { - backgroundColor: colors.primary + '20', - borderRadius: 8, - paddingVertical: 12, - paddingHorizontal: 12, - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'center', - marginTop: 12, - }, - websiteButtonText: { - color: colors.primary, - fontSize: 15, - fontWeight: '600', - }, - websiteButtonDisabled: { - backgroundColor: colors.elevation1, - }, - websiteButtonTextDisabled: { - color: colors.darkGray, - }, - sectionDescription: { - fontSize: 13, - color: colors.mediumGray, - marginBottom: 12, - }, - providerItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - providerInfo: { - flex: 1, - }, - providerName: { - fontSize: 15, - color: colors.white, - fontWeight: '500', - }, - masterToggleContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 4, - }, - masterToggleInfo: { - flex: 1, - }, - masterToggleTitle: { - fontSize: 15, - color: colors.white, - fontWeight: '600', - }, - masterToggleDescription: { - fontSize: 13, - color: colors.mediumGray, - marginTop: 2, - }, - disabledCard: { - opacity: 0.7, - }, - disabledInput: { - borderColor: colors.border, - backgroundColor: colors.elevation1, - }, - disabledText: { - color: colors.darkGray, - }, - disabledBoldText: { - color: colors.darkGray, - }, - darkGray: { - color: colors.darkGray || '#555555', - }, -}); - export default MDBListSettingsScreen; \ No newline at end of file From 12379b9e346a43623f0776e8f9578a87f32cc924 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 22:08:04 +0530 Subject: [PATCH 22/38] Refactor LogoSourceSettings to integrate theme context and enhance styling Update the LogoSourceSettings component to utilize the new ThemeContext for dynamic theming, improving visual consistency. Refactor styles into a dedicated function to adapt to the current theme colors, enhancing the overall user experience. This change streamlines the component's layout and ensures a cohesive interface across different themes. --- src/screens/CatalogScreen.tsx | 234 +++++++++---------- src/screens/CatalogSettingsScreen.tsx | 316 +++++++++++++------------- 2 files changed, 279 insertions(+), 271 deletions(-) diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index eafb36e..597ce7c 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -16,7 +16,7 @@ import { RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { RootStackParamList } from '../navigation/AppNavigator'; import { Meta, stremioService } from '../services/stremioService'; -import { colors } from '../styles'; +import { useTheme } from '../contexts/ThemeContext'; import { Image } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { logger } from '../utils/logger'; @@ -45,6 +45,120 @@ const NUM_COLUMNS = 3; const ITEM_MARGIN = SPACING.sm; const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS; +// Create a styles creator function that accepts the theme colors +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + list: { + padding: SPACING.lg, + paddingTop: SPACING.sm, + }, + columnWrapper: { + justifyContent: 'space-between', + }, + item: { + width: ITEM_WIDTH, + marginBottom: SPACING.lg, + borderRadius: 12, + overflow: 'hidden', + backgroundColor: colors.elevation2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + poster: { + width: '100%', + aspectRatio: 2/3, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + backgroundColor: colors.elevation3, + }, + itemContent: { + padding: SPACING.sm, + }, + title: { + fontSize: 14, + fontWeight: '600', + color: colors.white, + lineHeight: 18, + }, + releaseInfo: { + fontSize: 12, + marginTop: SPACING.xs, + color: colors.mediumGray, + }, + footer: { + padding: SPACING.lg, + alignItems: 'center', + }, + button: { + marginTop: SPACING.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.xl, + backgroundColor: colors.primary, + borderRadius: 8, + elevation: 2, + }, + buttonText: { + color: colors.white, + fontWeight: '600', + fontSize: 16, + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: SPACING.xl, + }, + emptyText: { + color: colors.white, + fontSize: 16, + textAlign: 'center', + marginTop: SPACING.md, + marginBottom: SPACING.sm, + }, + errorText: { + color: colors.white, + fontSize: 16, + textAlign: 'center', + marginTop: SPACING.md, + marginBottom: SPACING.sm, + }, + loadingText: { + color: colors.white, + fontSize: 16, + marginTop: SPACING.lg, + } +}); + const CatalogScreen: React.FC = ({ route, navigation }) => { const { addonId, type, id, name: originalName, genreFilter } = route.params; const [items, setItems] = useState([]); @@ -54,6 +168,9 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); const [dataSource, setDataSource] = useState(DataSource.STREMIO_ADDONS); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); const isDarkMode = true; const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); @@ -326,7 +443,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { ); - }, [navigation]); + }, [navigation, styles]); const renderEmptyState = () => ( @@ -451,117 +568,4 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - padding: 8, - }, - backText: { - fontSize: 17, - fontWeight: '400', - color: colors.primary, - }, - headerTitle: { - fontSize: 34, - fontWeight: '700', - color: colors.white, - paddingHorizontal: 16, - paddingBottom: 16, - paddingTop: 8, - }, - list: { - padding: SPACING.lg, - paddingTop: SPACING.sm, - }, - columnWrapper: { - justifyContent: 'space-between', - }, - item: { - width: ITEM_WIDTH, - marginBottom: SPACING.lg, - borderRadius: 12, - overflow: 'hidden', - backgroundColor: colors.elevation2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - poster: { - width: '100%', - aspectRatio: 2/3, - borderTopLeftRadius: 12, - borderTopRightRadius: 12, - backgroundColor: colors.elevation3, - }, - itemContent: { - padding: SPACING.sm, - }, - title: { - fontSize: 14, - fontWeight: '600', - color: colors.white, - lineHeight: 18, - }, - releaseInfo: { - fontSize: 12, - marginTop: SPACING.xs, - color: colors.mediumGray, - }, - footer: { - padding: SPACING.lg, - alignItems: 'center', - }, - button: { - marginTop: SPACING.md, - paddingVertical: SPACING.md, - paddingHorizontal: SPACING.xl, - backgroundColor: colors.primary, - borderRadius: 8, - elevation: 2, - }, - buttonText: { - color: colors.white, - fontWeight: '600', - fontSize: 16, - }, - centered: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: SPACING.xl, - }, - emptyText: { - color: colors.white, - fontSize: 16, - textAlign: 'center', - marginTop: SPACING.md, - marginBottom: SPACING.sm, - }, - errorText: { - color: colors.white, - fontSize: 16, - textAlign: 'center', - marginTop: SPACING.md, - marginBottom: SPACING.sm, - }, - loadingText: { - color: colors.white, - fontSize: 16, - marginTop: SPACING.lg, - } -}); - export default CatalogScreen; \ No newline at end of file diff --git a/src/screens/CatalogSettingsScreen.tsx b/src/screens/CatalogSettingsScreen.tsx index 1765584..de053a9 100644 --- a/src/screens/CatalogSettingsScreen.tsx +++ b/src/screens/CatalogSettingsScreen.tsx @@ -18,7 +18,7 @@ import { } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; -import { colors } from '../styles'; +import { useTheme } from '../contexts/ThemeContext'; import { stremioService } from '../services/stremioService'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { useCatalogContext } from '../contexts/CatalogContext'; @@ -52,12 +52,171 @@ const CATALOG_SETTINGS_KEY = 'catalog_settings'; const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +// Create a styles creator function that accepts the theme colors +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 32, + }, + addonSection: { + marginBottom: 24, + }, + addonTitle: { + fontSize: 13, + fontWeight: '600', + color: colors.mediumGray, + marginHorizontal: 16, + marginBottom: 8, + letterSpacing: 0.8, + }, + card: { + marginHorizontal: 16, + borderRadius: 12, + overflow: 'hidden', + backgroundColor: colors.elevation2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + groupHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 0.5, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + groupTitle: { + fontSize: 17, + fontWeight: '600', + color: colors.white, + }, + groupHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + }, + enabledCount: { + fontSize: 15, + color: colors.mediumGray, + marginRight: 8, + }, + catalogItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 0.5, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + // Ensure last item doesn't have border if needed (check logic) + }, + catalogItemPressed: { + backgroundColor: 'rgba(255, 255, 255, 0.05)', // Subtle feedback for press + }, + catalogInfo: { + flex: 1, + marginRight: 8, // Add space before switch + }, + catalogName: { + fontSize: 15, + color: colors.white, + marginBottom: 2, + }, + catalogType: { + fontSize: 13, + color: colors.mediumGray, + }, + + // Modal Styles + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.6)', + }, + modalContent: { + backgroundColor: Platform.OS === 'ios' ? undefined : colors.elevation3, + borderRadius: 14, + padding: 20, + width: '85%', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 10, + elevation: 10, + overflow: 'hidden', + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.white, + marginBottom: 15, + textAlign: 'center', + }, + modalInput: { + backgroundColor: colors.elevation1, // Darker input background + color: colors.white, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + marginBottom: 20, + borderWidth: 1, + borderColor: colors.border, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-around', // Adjust as needed (e.g., 'flex-end') + }, +}); + const CatalogSettingsScreen = () => { const [loading, setLoading] = useState(true); const [settings, setSettings] = useState([]); const [groupedSettings, setGroupedSettings] = useState({}); const navigation = useNavigation(); const { refreshCatalogs } = useCatalogContext(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); const isDarkMode = true; // Force dark mode // Modal State @@ -390,159 +549,4 @@ const CatalogSettingsScreen = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - padding: 8, - }, - backText: { - fontSize: 17, - fontWeight: '400', - color: colors.primary, - }, - headerTitle: { - fontSize: 34, - fontWeight: '700', - color: colors.white, - paddingHorizontal: 16, - paddingBottom: 16, - paddingTop: 8, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingBottom: 32, - }, - addonSection: { - marginBottom: 24, - }, - addonTitle: { - fontSize: 13, - fontWeight: '600', - color: colors.mediumGray, - marginHorizontal: 16, - marginBottom: 8, - letterSpacing: 0.8, - }, - card: { - marginHorizontal: 16, - borderRadius: 12, - overflow: 'hidden', - backgroundColor: colors.elevation2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - groupHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - borderBottomWidth: 0.5, - borderBottomColor: 'rgba(255, 255, 255, 0.1)', - }, - groupTitle: { - fontSize: 17, - fontWeight: '600', - color: colors.white, - }, - groupHeaderRight: { - flexDirection: 'row', - alignItems: 'center', - }, - enabledCount: { - fontSize: 15, - color: colors.mediumGray, - marginRight: 8, - }, - catalogItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - borderBottomWidth: 0.5, - borderBottomColor: 'rgba(255, 255, 255, 0.1)', - // Ensure last item doesn't have border if needed (check logic) - }, - catalogItemPressed: { - backgroundColor: 'rgba(255, 255, 255, 0.05)', // Subtle feedback for press - }, - catalogInfo: { - flex: 1, - marginRight: 8, // Add space before switch - }, - catalogName: { - fontSize: 15, - color: colors.white, - marginBottom: 2, - }, - catalogType: { - fontSize: 13, - color: colors.mediumGray, - }, - - // Modal Styles - modalOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.6)', - }, - modalContent: { - backgroundColor: Platform.OS === 'ios' ? undefined : colors.elevation3, - borderRadius: 14, - padding: 20, - width: '85%', - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 10, - elevation: 10, - overflow: 'hidden', - }, - modalTitle: { - fontSize: 18, - fontWeight: '600', - color: colors.white, - marginBottom: 15, - textAlign: 'center', - }, - modalInput: { - backgroundColor: colors.elevation1, // Darker input background - color: colors.white, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, - marginBottom: 20, - borderWidth: 1, - borderColor: colors.border, - }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'space-around', // Adjust as needed (e.g., 'flex-end') - }, -}); - export default CatalogSettingsScreen; \ No newline at end of file From 10cbf077d656751a99ab81c2d6a99e59660ef98a Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 22:25:42 +0530 Subject: [PATCH 23/38] Refactor MetadataScreen to enhance modularity and integrate new components This update significantly refactors the MetadataScreen component by removing unused imports and consolidating logic into dedicated hooks and components. The new structure improves readability and maintainability, while also enhancing the user experience with a more cohesive layout. Key changes include the introduction of HeroSection, FloatingHeader, and MetadataDetails components, which streamline the rendering process and improve the overall UI consistency. Additionally, the integration of new hooks for managing metadata assets and animations optimizes performance and responsiveness. --- src/components/metadata/FloatingHeader.tsx | 235 +++ src/components/metadata/HeroSection.tsx | 519 +++++ src/components/metadata/MetadataDetails.tsx | 180 ++ src/hooks/useMetadataAnimations.ts | 247 +++ src/hooks/useMetadataAssets.ts | 510 +++++ src/hooks/useWatchProgress.ts | 216 +++ src/screens/MetadataScreen.tsx | 1879 +------------------ 7 files changed, 1992 insertions(+), 1794 deletions(-) create mode 100644 src/components/metadata/FloatingHeader.tsx create mode 100644 src/components/metadata/HeroSection.tsx create mode 100644 src/components/metadata/MetadataDetails.tsx create mode 100644 src/hooks/useMetadataAnimations.ts create mode 100644 src/hooks/useMetadataAssets.ts create mode 100644 src/hooks/useWatchProgress.ts diff --git a/src/components/metadata/FloatingHeader.tsx b/src/components/metadata/FloatingHeader.tsx new file mode 100644 index 0000000..619723f --- /dev/null +++ b/src/components/metadata/FloatingHeader.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Platform, + Dimensions, +} from 'react-native'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { BlurView as CommunityBlurView } from '@react-native-community/blur'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import Animated, { + useAnimatedStyle, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; +import { colors } from '../../styles/colors'; +import { logger } from '../../utils/logger'; + +const { width } = Dimensions.get('window'); + +interface FloatingHeaderProps { + metadata: any; + logoLoadError: boolean; + handleBack: () => void; + handleToggleLibrary: () => void; + inLibrary: boolean; + headerOpacity: Animated.SharedValue; + headerElementsY: Animated.SharedValue; + headerElementsOpacity: Animated.SharedValue; + safeAreaTop: number; + setLogoLoadError: (error: boolean) => void; +} + +const FloatingHeader: React.FC = ({ + metadata, + logoLoadError, + handleBack, + handleToggleLibrary, + inLibrary, + headerOpacity, + headerElementsY, + headerElementsOpacity, + safeAreaTop, + setLogoLoadError, +}) => { + // Animated styles for the header + const headerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: headerOpacity.value, + transform: [ + { translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) } + ] + })); + + // Animated style for header elements + const headerElementsStyle = useAnimatedStyle(() => ({ + opacity: headerElementsOpacity.value, + transform: [{ translateY: headerElementsY.value }] + })); + + return ( + + {Platform.OS === 'ios' ? ( + + + + + + + + {metadata.logo && !logoLoadError ? ( + { + logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + {metadata.name} + )} + + + + + + + + ) : ( + + + + + + + + + {metadata.logo && !logoLoadError ? ( + { + logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + {metadata.name} + )} + + + + + + + + )} + {Platform.OS === 'ios' && } + + ); +}; + +const styles = StyleSheet.create({ + floatingHeader: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + overflow: 'hidden', + elevation: 4, // for Android shadow + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + }, + blurContainer: { + width: '100%', + }, + floatingHeaderContent: { + height: 56, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + headerBottomBorder: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 0.5, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + headerTitleContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 10, + }, + backButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 20, + }, + headerActionButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 20, + }, + floatingHeaderLogo: { + height: 42, + width: width * 0.6, + maxWidth: 240, + }, + floatingHeaderTitle: { + color: colors.highEmphasis, + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); + +export default React.memo(FloatingHeader); \ No newline at end of file diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx new file mode 100644 index 0000000..74c641b --- /dev/null +++ b/src/components/metadata/HeroSection.tsx @@ -0,0 +1,519 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Dimensions, + TouchableOpacity, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Image } from 'expo-image'; +import Animated, { + useAnimatedStyle, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; +import { colors } from '../../styles/colors'; +import { logger } from '../../utils/logger'; + +const { width, height } = Dimensions.get('window'); + +// Types +interface HeroSectionProps { + metadata: any; + bannerImage: string | null; + loadingBanner: boolean; + logoLoadError: boolean; + scrollY: Animated.SharedValue; + dampedScrollY: Animated.SharedValue; + heroHeight: Animated.SharedValue; + heroOpacity: Animated.SharedValue; + heroScale: Animated.SharedValue; + logoOpacity: Animated.SharedValue; + logoScale: Animated.SharedValue; + genresOpacity: Animated.SharedValue; + genresTranslateY: Animated.SharedValue; + buttonsOpacity: Animated.SharedValue; + buttonsTranslateY: Animated.SharedValue; + watchProgressOpacity: Animated.SharedValue; + watchProgressScaleY: Animated.SharedValue; + watchProgress: { + currentTime: number; + duration: number; + lastUpdated: number; + episodeId?: string; + } | null; + type: 'movie' | 'series'; + getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; + handleShowStreams: () => void; + handleToggleLibrary: () => void; + inLibrary: boolean; + id: string; + navigation: any; + getPlayButtonText: () => string; + setBannerImage: (bannerImage: string | null) => void; + setLogoLoadError: (error: boolean) => void; +} + +// Memoized ActionButtons Component +const ActionButtons = React.memo(({ + handleShowStreams, + toggleLibrary, + inLibrary, + type, + id, + navigation, + playButtonText, + animatedStyle +}: { + handleShowStreams: () => void; + toggleLibrary: () => void; + inLibrary: boolean; + type: 'movie' | 'series'; + id: string; + navigation: any; + playButtonText: string; + animatedStyle: any; +}) => { + return ( + + + + + {playButtonText} + + + + + + + {inLibrary ? 'Saved' : 'Save'} + + + + {type === 'series' && ( + { + navigation.navigate('ShowRatings', { showId: id.split(':')[1] }); + }} + > + + + )} + + ); +}); + +// Memoized WatchProgress Component +const WatchProgressDisplay = React.memo(({ + watchProgress, + type, + getEpisodeDetails, + animatedStyle +}: { + watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; + type: 'movie' | 'series'; + getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; + animatedStyle: any; +}) => { + if (!watchProgress || watchProgress.duration === 0) { + return null; + } + + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); + let episodeInfo = ''; + + if (type === 'series' && watchProgress.episodeId) { + const details = getEpisodeDetails(watchProgress.episodeId); + if (details) { + episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; + } + } + + return ( + + + + + + {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} + + + ); +}); + +const HeroSection: React.FC = ({ + metadata, + bannerImage, + loadingBanner, + logoLoadError, + scrollY, + dampedScrollY, + heroHeight, + heroOpacity, + heroScale, + logoOpacity, + logoScale, + genresOpacity, + genresTranslateY, + buttonsOpacity, + buttonsTranslateY, + watchProgressOpacity, + watchProgressScaleY, + watchProgress, + type, + getEpisodeDetails, + handleShowStreams, + handleToggleLibrary, + inLibrary, + id, + navigation, + getPlayButtonText, + setBannerImage, + setLogoLoadError, +}) => { + // Animated styles + const heroAnimatedStyle = useAnimatedStyle(() => ({ + width: '100%', + height: heroHeight.value, + backgroundColor: colors.black, + transform: [{ scale: heroScale.value }], + opacity: heroOpacity.value, + })); + + const logoAnimatedStyle = useAnimatedStyle(() => ({ + opacity: logoOpacity.value, + transform: [{ scale: logoScale.value }] + })); + + const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ + opacity: watchProgressOpacity.value, + transform: [ + { + translateY: interpolate( + watchProgressScaleY.value, + [0, 1], + [-8, 0], + Extrapolate.CLAMP + ) + }, + { scaleY: watchProgressScaleY.value } + ] + })); + + const genresAnimatedStyle = useAnimatedStyle(() => ({ + opacity: genresOpacity.value, + transform: [{ translateY: genresTranslateY.value }] + })); + + const buttonsAnimatedStyle = useAnimatedStyle(() => ({ + opacity: buttonsOpacity.value, + transform: [{ translateY: buttonsTranslateY.value }] + })); + + const parallaxImageStyle = useAnimatedStyle(() => ({ + width: '100%', + height: '120%', + top: '-10%', + transform: [ + { + translateY: interpolate( + dampedScrollY.value, + [0, 100, 300], + [20, -20, -60], + Extrapolate.CLAMP + ) + }, + { + scale: interpolate( + dampedScrollY.value, + [0, 150, 300], + [1.1, 1.02, 0.95], + Extrapolate.CLAMP + ) + } + ], + })); + + // Render genres + const renderGenres = () => { + if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { + return null; + } + + const genresToDisplay: string[] = metadata.genres as string[]; + + return genresToDisplay.slice(0, 4).map((genreName, index, array) => ( + + {genreName} + {index < array.length - 1 && ( + + )} + + )); + }; + + return ( + + + {loadingBanner ? ( + + ) : ( + { + logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`); + if (bannerImage !== metadata.banner) { + setBannerImage(metadata.banner || metadata.poster); + } + }} + /> + )} + + + {/* Title/Logo */} + + + {metadata.logo && !logoLoadError ? ( + { + logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + {metadata.name} + )} + + + + {/* Watch Progress */} + + + {/* Genre Tags */} + + + {renderGenres()} + + + + {/* Action Buttons */} + + + + + + ); +}; + +const styles = StyleSheet.create({ + heroSection: { + width: '100%', + height: height * 0.5, + backgroundColor: colors.black, + overflow: 'hidden', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + heroGradient: { + flex: 1, + justifyContent: 'flex-end', + paddingBottom: 24, + }, + heroContent: { + padding: 16, + paddingTop: 12, + paddingBottom: 12, + }, + logoContainer: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + titleLogoContainer: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + titleLogo: { + width: width * 0.8, + height: 100, + marginBottom: 0, + alignSelf: 'center', + }, + heroTitle: { + color: colors.highEmphasis, + fontSize: 28, + fontWeight: '900', + marginBottom: 12, + textShadowColor: 'rgba(0,0,0,0.75)', + textShadowOffset: { width: 0, height: 2 }, + textShadowRadius: 4, + letterSpacing: -0.5, + }, + genreContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + marginBottom: 16, + gap: 4, + }, + genreText: { + color: colors.text, + fontSize: 12, + fontWeight: '500', + }, + genreDot: { + color: colors.text, + fontSize: 12, + fontWeight: '500', + opacity: 0.6, + marginHorizontal: 4, + }, + actionButtons: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + marginBottom: -12, + justifyContent: 'center', + width: '100%', + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + borderRadius: 100, + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + flex: 1, + }, + playButton: { + backgroundColor: colors.white, + }, + infoButton: { + backgroundColor: 'rgba(255,255,255,0.2)', + borderWidth: 2, + borderColor: '#fff', + }, + iconButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.2)', + borderWidth: 2, + borderColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + elevation: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + }, + playButtonText: { + color: '#000', + fontWeight: '600', + marginLeft: 8, + fontSize: 16, + }, + infoButtonText: { + color: '#fff', + marginLeft: 8, + fontWeight: '600', + fontSize: 16, + }, + watchProgressContainer: { + marginTop: 6, + marginBottom: 8, + width: '100%', + alignItems: 'center', + overflow: 'hidden', + height: 48, + }, + watchProgressBar: { + width: '75%', + height: 3, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + borderRadius: 1.5, + overflow: 'hidden', + marginBottom: 6 + }, + watchProgressFill: { + height: '100%', + backgroundColor: colors.primary, + borderRadius: 1.5, + }, + watchProgressText: { + color: colors.textMuted, + fontSize: 12, + textAlign: 'center', + opacity: 0.9, + letterSpacing: 0.2 + }, +}); + +export default React.memo(HeroSection); \ No newline at end of file diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx new file mode 100644 index 0000000..a32b67b --- /dev/null +++ b/src/components/metadata/MetadataDetails.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import Animated, { + Layout, + Easing, + FadeIn, +} from 'react-native-reanimated'; +import { colors } from '../../styles/colors'; + +interface MetadataDetailsProps { + metadata: any; + imdbId: string | null; + type: 'movie' | 'series'; +} + +const MetadataDetails: React.FC = ({ + metadata, + imdbId, + type, +}) => { + const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); + + return ( + <> + {/* Meta Info */} + + {metadata.year && ( + {metadata.year} + )} + {metadata.runtime && ( + {metadata.runtime} + )} + {metadata.certification && ( + {metadata.certification} + )} + {metadata.imdbRating && ( + + + {metadata.imdbRating} + + )} + + + {/* Creator/Director Info */} + + {metadata.directors && metadata.directors.length > 0 && ( + + Director{metadata.directors.length > 1 ? 's' : ''}: + {metadata.directors.join(', ')} + + )} + {metadata.creators && metadata.creators.length > 0 && ( + + Creator{metadata.creators.length > 1 ? 's' : ''}: + {metadata.creators.join(', ')} + + )} + + + {/* Description */} + {metadata.description && ( + + setIsFullDescriptionOpen(!isFullDescriptionOpen)} + activeOpacity={0.7} + > + + {metadata.description} + + + + {isFullDescriptionOpen ? 'Show Less' : 'Show More'} + + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + metaInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 16, + marginBottom: 12, + }, + metaText: { + color: colors.text, + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.3, + textTransform: 'uppercase', + opacity: 0.9, + }, + ratingContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + imdbLogo: { + width: 35, + height: 18, + marginRight: 4, + }, + ratingText: { + color: colors.text, + fontWeight: '700', + fontSize: 15, + letterSpacing: 0.3, + }, + creatorContainer: { + marginBottom: 2, + paddingHorizontal: 16, + }, + creatorSection: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + height: 20 + }, + creatorLabel: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + marginRight: 8, + lineHeight: 20 + }, + creatorText: { + color: colors.lightGray, + fontSize: 14, + flex: 1, + lineHeight: 20 + }, + descriptionContainer: { + marginBottom: 16, + paddingHorizontal: 16, + }, + description: { + color: colors.mediumEmphasis, + fontSize: 15, + lineHeight: 24, + }, + showMoreButton: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + paddingVertical: 4, + }, + showMoreText: { + color: colors.textMuted, + fontSize: 14, + marginRight: 4, + }, +}); + +export default React.memo(MetadataDetails); \ No newline at end of file diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts new file mode 100644 index 0000000..7ef53e6 --- /dev/null +++ b/src/hooks/useMetadataAnimations.ts @@ -0,0 +1,247 @@ +import { useEffect } from 'react'; +import { Dimensions } from 'react-native'; +import { + useSharedValue, + withTiming, + withSpring, + Easing, + useAnimatedScrollHandler, + interpolate, + Extrapolate, +} from 'react-native-reanimated'; + +const { width, height } = Dimensions.get('window'); + +// Animation constants +const springConfig = { + damping: 20, + mass: 1, + stiffness: 100 +}; + +// Animation timing constants for staggered appearance +const ANIMATION_DELAY_CONSTANTS = { + HERO: 100, + LOGO: 250, + PROGRESS: 350, + GENRES: 400, + BUTTONS: 450, + CONTENT: 500 +}; + +export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => { + // Animation values for screen entrance + const screenScale = useSharedValue(0.92); + const screenOpacity = useSharedValue(0); + + // Animation values for hero section + const heroHeight = useSharedValue(height * 0.5); + const heroScale = useSharedValue(1.05); + const heroOpacity = useSharedValue(0); + + // Animation values for content + const contentTranslateY = useSharedValue(60); + + // Animation values for logo + const logoOpacity = useSharedValue(0); + const logoScale = useSharedValue(0.9); + + // Animation values for progress + const watchProgressOpacity = useSharedValue(0); + const watchProgressScaleY = useSharedValue(0); + + // Animation values for genres + const genresOpacity = useSharedValue(0); + const genresTranslateY = useSharedValue(20); + + // Animation values for buttons + const buttonsOpacity = useSharedValue(0); + const buttonsTranslateY = useSharedValue(30); + + // Scroll values for parallax effect + const scrollY = useSharedValue(0); + const dampedScrollY = useSharedValue(0); + + // Header animation values + const headerOpacity = useSharedValue(0); + const headerElementsY = useSharedValue(-10); + const headerElementsOpacity = useSharedValue(0); + + // Start entrance animation + useEffect(() => { + // Use a timeout to ensure the animations starts after the component is mounted + const animationTimeout = setTimeout(() => { + // 1. First animate the container + screenScale.value = withSpring(1, springConfig); + screenOpacity.value = withSpring(1, springConfig); + + // 2. Then animate the hero section with a slight delay + setTimeout(() => { + heroOpacity.value = withSpring(1, { + damping: 14, + stiffness: 80 + }); + heroScale.value = withSpring(1, { + damping: 18, + stiffness: 100 + }); + }, ANIMATION_DELAY_CONSTANTS.HERO); + + // 3. Then animate the logo + setTimeout(() => { + logoOpacity.value = withSpring(1, { + damping: 12, + stiffness: 100 + }); + logoScale.value = withSpring(1, { + damping: 14, + stiffness: 90 + }); + }, ANIMATION_DELAY_CONSTANTS.LOGO); + + // 4. Then animate the watch progress if applicable + setTimeout(() => { + if (watchProgress && watchProgress.duration > 0) { + watchProgressOpacity.value = withSpring(1, { + damping: 14, + stiffness: 100 + }); + watchProgressScaleY.value = withSpring(1, { + damping: 18, + stiffness: 120 + }); + } + }, ANIMATION_DELAY_CONSTANTS.PROGRESS); + + // 5. Then animate the genres + setTimeout(() => { + genresOpacity.value = withSpring(1, { + damping: 14, + stiffness: 100 + }); + genresTranslateY.value = withSpring(0, { + damping: 18, + stiffness: 120 + }); + }, ANIMATION_DELAY_CONSTANTS.GENRES); + + // 6. Then animate the buttons + setTimeout(() => { + buttonsOpacity.value = withSpring(1, { + damping: 14, + stiffness: 100 + }); + buttonsTranslateY.value = withSpring(0, { + damping: 18, + stiffness: 120 + }); + }, ANIMATION_DELAY_CONSTANTS.BUTTONS); + + // 7. Finally animate the content section + setTimeout(() => { + contentTranslateY.value = withSpring(0, { + damping: 25, + mass: 1, + stiffness: 100 + }); + }, ANIMATION_DELAY_CONSTANTS.CONTENT); + }, 50); // Small timeout to ensure component is fully mounted + + return () => clearTimeout(animationTimeout); + }, []); + + // Effect to animate watch progress when it changes + useEffect(() => { + if (watchProgress && watchProgress.duration > 0) { + watchProgressOpacity.value = withSpring(1, { + mass: 0.2, + stiffness: 100, + damping: 14 + }); + watchProgressScaleY.value = withSpring(1, { + mass: 0.3, + stiffness: 120, + damping: 18 + }); + } else { + watchProgressOpacity.value = withSpring(0, { + mass: 0.2, + stiffness: 100, + damping: 14 + }); + watchProgressScaleY.value = withSpring(0, { + mass: 0.3, + stiffness: 120, + damping: 18 + }); + } + }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); + + // Effect to animate logo when it's available + const animateLogo = (hasLogo: boolean) => { + if (hasLogo) { + logoOpacity.value = withTiming(1, { + duration: 500, + easing: Easing.out(Easing.ease) + }); + } else { + logoOpacity.value = withTiming(0, { + duration: 200, + easing: Easing.in(Easing.ease) + }); + } + }; + + // Scroll handler + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + const rawScrollY = event.contentOffset.y; + scrollY.value = rawScrollY; + + // Apply spring-like damping for smoother transitions + dampedScrollY.value = withTiming(rawScrollY, { + duration: 300, + easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve + }); + + // Update header opacity based on scroll position + const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer + if (rawScrollY > headerThreshold) { + headerOpacity.value = withTiming(1, { duration: 200 }); + headerElementsY.value = withTiming(0, { duration: 300 }); + headerElementsOpacity.value = withTiming(1, { duration: 450 }); + } else { + headerOpacity.value = withTiming(0, { duration: 150 }); + headerElementsY.value = withTiming(-10, { duration: 200 }); + headerElementsOpacity.value = withTiming(0, { duration: 200 }); + } + }, + }); + + return { + // Animated values + screenScale, + screenOpacity, + heroHeight, + heroScale, + heroOpacity, + contentTranslateY, + logoOpacity, + logoScale, + watchProgressOpacity, + watchProgressScaleY, + genresOpacity, + genresTranslateY, + buttonsOpacity, + buttonsTranslateY, + scrollY, + dampedScrollY, + headerOpacity, + headerElementsY, + headerElementsOpacity, + + // Functions + scrollHandler, + animateLogo, + }; +}; \ No newline at end of file diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts new file mode 100644 index 0000000..3ee0a94 --- /dev/null +++ b/src/hooks/useMetadataAssets.ts @@ -0,0 +1,510 @@ +import { useState, useEffect, useRef } from 'react'; +import { logger } from '../utils/logger'; +import { TMDBService } from '../services/tmdbService'; +import { isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; + +export const useMetadataAssets = ( + metadata: any, + id: string, + type: string, + imdbId: string | null, + settings: any, + setMetadata: (metadata: any) => void +) => { + // State for banner image + const [bannerImage, setBannerImage] = useState(null); + const [loadingBanner, setLoadingBanner] = useState(false); + const forcedBannerRefreshDone = useRef(false); + + // State for logo loading + const [logoLoadError, setLogoLoadError] = useState(false); + const logoFetchInProgress = useRef(false); + const logoRefreshCounter = useRef(0); + const MAX_LOGO_REFRESHES = 2; + const forcedLogoRefreshDone = useRef(false); + + // For TMDB ID tracking + const [foundTmdbId, setFoundTmdbId] = useState(null); + + // Effect to force-refresh the logo when it doesn't match the preference + useEffect(() => { + if (metadata?.logo && !forcedLogoRefreshDone.current) { + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + + // Check if logo source doesn't match preference + if ((preferenceIsMetahub && !currentLogoIsMetahub) || + (!preferenceIsMetahub && !currentLogoIsTmdb)) { + logger.log(`[useMetadataAssets] Initial load: Logo source doesn't match preference. Forcing refresh.`); + + // Clear logo to force a new fetch according to preference + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); + } + + // Mark that we've checked this so we don't endlessly loop + forcedLogoRefreshDone.current = true; + } + }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); + + // Reset logo load error when metadata changes + useEffect(() => { + setLogoLoadError(false); + }, [metadata?.logo]); + + // Force refresh logo when logo preference changes - only when preference actually changes + useEffect(() => { + // Reset the counter when preference actually changes + if (logoRefreshCounter.current === 0) { + logoRefreshCounter.current = 1; // Mark that we've started a refresh cycle + + // Only clear logo if we already have metadata with a logo + if (metadata?.logo) { + // Check if the current logo source doesn't match the preference + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; + + // Only refresh if the current logo source clearly doesn't match the preference + const needsRefresh = (preferenceIsMetahub && currentLogoIsTmdb) || + (preferenceIsTmdb && currentLogoIsMetahub); + + if (needsRefresh) { + logger.log(`[useMetadataAssets] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, triggering one-time refresh`); + + // Prevent endless refreshes + if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { + logoRefreshCounter.current++; + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); + } else { + logger.warn(`[useMetadataAssets] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); + } + } else { + logger.log(`[useMetadataAssets] Logo source already matches preference (${settings.logoSourcePreference}), no refresh needed`); + logoRefreshCounter.current = 0; // Reset for future changes + } + } + } else { + logoRefreshCounter.current++; + logger.log(`[useMetadataAssets] Logo refresh already in progress (${logoRefreshCounter.current}/${MAX_LOGO_REFRESHES})`); + + // Reset counter after max refreshes to allow future preference changes to work + if (logoRefreshCounter.current >= MAX_LOGO_REFRESHES) { + logger.warn(`[useMetadataAssets] Maximum refreshes reached, resetting counter`); + // After a timeout to avoid immediate re-triggering + setTimeout(() => { + logoRefreshCounter.current = 0; + }, 1000); + } + } + }, [settings.logoSourcePreference, metadata?.logo, setMetadata]); + + // Add effect to track when logo source matches preference + useEffect(() => { + if (metadata?.logo) { + const currentLogoIsMetahub = isMetahubUrl(metadata.logo); + const currentLogoIsTmdb = isTmdbUrl(metadata.logo); + const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; + + // Check if current logo source matches preference + const logoSourceMatches = (preferenceIsMetahub && currentLogoIsMetahub) || + (preferenceIsTmdb && currentLogoIsTmdb); + + if (logoSourceMatches) { + logger.log(`[useMetadataAssets] Logo source (${currentLogoIsMetahub ? 'Metahub' : 'TMDB'}) now matches preference (${settings.logoSourcePreference}), refresh complete`); + logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal + } + } + }, [metadata?.logo, settings.logoSourcePreference]); + + // Fetch logo immediately for TMDB content - with guard against recursive updates + useEffect(() => { + // Guard against infinite loops by checking if we're already fetching + if (metadata && !metadata.logo && !logoFetchInProgress.current) { + console.log('[useMetadataAssets] Current settings:', JSON.stringify(settings)); + console.log('[useMetadataAssets] Current metadata:', JSON.stringify(metadata, null, 2)); + + const fetchLogo = async () => { + // Set fetch in progress flag + logoFetchInProgress.current = true; + + try { + // Get logo source preference from settings + const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set + + console.log(`[useMetadataAssets] Using logo preference: ${logoPreference}, TMDB first: ${logoPreference === 'tmdb'}`); + logger.log(`[useMetadataAssets] Logo source preference: ${logoPreference}`); + + // First source based on preference + if (logoPreference === 'metahub') { + // Try to get logo from Metahub first + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + + logger.log(`[useMetadataAssets] Attempting to fetch logo from Metahub for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[useMetadataAssets] Successfully fetched logo from Metahub: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metahubUrl} + `); + + // Update metadata with Metahub logo + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: metahubUrl + })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + return; // Exit if Metahub logo was found + } else { + logger.warn(`[useMetadataAssets] Metahub logo request failed with status ${response.status}`); + } + } catch (metahubError) { + logger.warn(`[useMetadataAssets] Failed to fetch logo from Metahub:`, metahubError); + } + + // If Metahub fails, try TMDB as fallback + if (id.startsWith('tmdb:')) { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; + + logger.log(`[useMetadataAssets] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); + + const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB: + - Content Type: ${tmdbType} + - TMDB ID: ${tmdbId} + - Logo URL: ${logoUrl} + `); + + // Update metadata with TMDB logo + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: logoUrl + })); + + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found + } else { + // If both Metahub and TMDB fail, use the title as text instead of a logo + logger.warn(`[useMetadataAssets] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text + } + } + } else { // TMDB first + let tmdbLogoUrl: string | null = null; + + // 1. Attempt to fetch TMDB logo + if (id.startsWith('tmdb:')) { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + + logger.log(`[useMetadataAssets] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId}, preferred language: ${preferredLanguage})`); + try { + const tmdbService = TMDBService.getInstance(); + tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); + + if (tmdbLogoUrl) { + logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); + } else { + logger.warn(`[useMetadataAssets] No logo found from TMDB for ${type} (ID: ${tmdbId})`); + } + } catch (error) { + logger.error(`[useMetadataAssets] Error fetching TMDB logo for ID ${tmdbId}:`, error); + } + } else if (imdbId) { + // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + logger.log(`[useMetadataAssets] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo, preferred language: ${preferredLanguage}`); + try { + const tmdbService = TMDBService.getInstance(); + const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); + + if (foundTmdbId) { + logger.log(`[useMetadataAssets] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); + setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching + + tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString(), preferredLanguage); + + if (tmdbLogoUrl) { + logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); + } else { + logger.warn(`[useMetadataAssets] No logo found from TMDB via IMDB lookup for ${type} (IMDB: ${imdbId})`); + } + } else { + logger.warn(`[useMetadataAssets] Could not find TMDB ID for IMDB ID ${imdbId}`); + } + } catch (error) { + logger.error(`[useMetadataAssets] Error finding TMDB ID or fetching logo for IMDB ID ${imdbId}:`, error); + } + } + + // 2. If TMDB logo was fetched successfully, update and return + if (tmdbLogoUrl) { + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: tmdbLogoUrl + })); + logoFetchInProgress.current = false; + return; + } + + // 3. If TMDB failed, try Metahub as fallback + logger.log(`[useMetadataAssets] TMDB logo fetch failed or not applicable. Attempting Metahub fallback.`); + if (imdbId) { + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + logger.log(`[useMetadataAssets] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + logger.log(`[useMetadataAssets] Successfully fetched fallback logo from Metahub: ${metahubUrl}`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: metahubUrl })); + } else { + logger.warn(`[useMetadataAssets] Metahub fallback failed. Using title text.`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + } catch (metahubError) { + logger.warn(`[useMetadataAssets] Failed to fetch fallback logo from Metahub:`, metahubError); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + } else { + // No IMDB ID for Metahub fallback + logger.warn(`[useMetadataAssets] No IMDB ID for Metahub fallback. Using title text.`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + } + } catch (error) { + logger.error('[useMetadataAssets] Failed to fetch logo from all sources:', { + error, + contentId: id, + contentType: type + }); + // Fallback to text on general error + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } finally { + // Clear fetch in progress flag when done + logoFetchInProgress.current = false; + } + }; + + fetchLogo(); + } else if (logoFetchInProgress.current) { + console.log('[useMetadataAssets] Logo fetch already in progress, skipping'); + } else if (metadata?.logo) { + logger.log(`[useMetadataAssets] Using existing logo from metadata: + - Content ID: ${id} + - Content Type: ${type} + - Logo URL: ${metadata.logo} + - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} + `); + } + }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); + + // Fetch banner image based on logo source preference + useEffect(() => { + const fetchBanner = async () => { + if (metadata) { + setLoadingBanner(true); + + // Clear the banner initially when starting a preference-driven fetch + setBannerImage(null); + + let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback + const preference = settings.logoSourcePreference || 'metahub'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key + + // Extract IDs + let currentTmdbId = null; + if (id.startsWith('tmdb:')) { + currentTmdbId = id.split(':')[1]; + } else if (foundTmdbId) { + currentTmdbId = foundTmdbId; + } else if ((metadata as any).tmdbId) { + currentTmdbId = (metadata as any).tmdbId; + } + + const currentImdbId = imdbId; + const contentType = type === 'series' ? 'tv' : 'movie'; + + logger.log(`[useMetadataAssets] Fetching banner with preference: ${preference}, language: ${preferredLanguage}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); + + try { + if (preference === 'tmdb') { + // 1. Try TMDB first + let tmdbBannerUrl: string | null = null; + if (currentTmdbId) { + logger.log(`[useMetadataAssets] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); + try { + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}&include_image_language=${preferredLanguage},en,null`); + const imagesData = await response.json(); + + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + // Try to find backdrop in preferred language first + let backdropPath = null; + + if (preferredLanguage !== 'en') { + const preferredBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === preferredLanguage); + if (preferredBackdrop) { + backdropPath = preferredBackdrop.file_path; + logger.log(`[useMetadataAssets] Found ${preferredLanguage} backdrop for ID: ${currentTmdbId}`); + } + } + + // Fall back to English backdrop + if (!backdropPath) { + const englishBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === 'en'); + if (englishBackdrop) { + backdropPath = englishBackdrop.file_path; + logger.log(`[useMetadataAssets] Found English backdrop for ID: ${currentTmdbId}`); + } else { + // Last resort: use the first backdrop + backdropPath = imagesData.backdrops[0].file_path; + logger.log(`[useMetadataAssets] Using first available backdrop for ID: ${currentTmdbId}`); + } + } + + tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; + logger.log(`[useMetadataAssets] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); + } else { + // Add log for when no backdrops are found + logger.warn(`[useMetadataAssets] TMDB API successful, but no backdrops found for ID: ${currentTmdbId}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching TMDB banner via images endpoint:`, err); + } + } else { + // Add log for when no TMDB ID is available + logger.warn(`[useMetadataAssets] No TMDB ID available to fetch TMDB banner.`); + } + + if (tmdbBannerUrl) { + // TMDB SUCCESS: Set banner and EXIT + finalBanner = tmdbBannerUrl; + logger.log(`[useMetadataAssets] Setting final banner to TMDB source: ${finalBanner}`); + setBannerImage(finalBanner); + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; + return; // <-- Exit here, don't attempt fallback + } else { + // TMDB FAILED: Proceed to Metahub fallback + logger.log(`[useMetadataAssets] TMDB banner failed, trying Metahub fallback.`); + if (currentImdbId) { + const metahubBannerUrl = `https://images.metahub.space/background/medium/${currentImdbId}/img`; + try { + const metahubResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); + if (metahubResponse.ok) { + finalBanner = metahubBannerUrl; + logger.log(`[useMetadataAssets] Found Metahub banner as fallback: ${finalBanner}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching Metahub fallback banner:`, err); + } + } + } + } else { // Preference is Metahub + // 1. Try Metahub first + let metahubBannerUrl: string | null = null; + if (currentImdbId) { + const url = `https://images.metahub.space/background/medium/${currentImdbId}/img`; + try { + const metahubResponse = await fetch(url, { method: 'HEAD' }); + if (metahubResponse.ok) { + metahubBannerUrl = url; + logger.log(`[useMetadataAssets] Found Metahub banner: ${metahubBannerUrl}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching Metahub banner:`, err); + } + } + + if (metahubBannerUrl) { + // METAHUB SUCCESS: Set banner and EXIT + finalBanner = metahubBannerUrl; + logger.log(`[useMetadataAssets] Setting final banner to Metahub source: ${finalBanner}`); + setBannerImage(finalBanner); + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; + return; // <-- Exit here, don't attempt fallback + } else { + // METAHUB FAILED: Proceed to TMDB fallback + logger.log(`[useMetadataAssets] Metahub banner failed, trying TMDB fallback.`); + if (currentTmdbId) { + try { + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); + const imagesData = await response.json(); + + if (imagesData.backdrops && imagesData.backdrops.length > 0) { + const backdropPath = imagesData.backdrops[0].file_path; + finalBanner = `https://image.tmdb.org/t/p/original${backdropPath}`; + logger.log(`[useMetadataAssets] Found TMDB banner as fallback: ${finalBanner}`); + } + } catch (err) { + logger.error(`[useMetadataAssets] Error fetching TMDB fallback banner:`, err); + } + } + } + } + + // Set the final determined banner (could be fallback or initial default) + setBannerImage(finalBanner); + logger.log(`[useMetadataAssets] Final banner set after fallbacks (if any): ${finalBanner}`); + + } catch (error) { + logger.error(`[useMetadataAssets] General error fetching banner:`, error); + // Fallback to initial banner on general error + setBannerImage(metadata.banner || metadata.poster); + } finally { + // Only set loading to false here if we didn't exit early + setLoadingBanner(false); + forcedBannerRefreshDone.current = true; // Mark refresh as done + } + } + }; + + // Only run fetchBanner if metadata exists and preference/content might have changed + // The dependencies array handles triggering this effect + fetchBanner(); + + }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); + + // Reset forced refresh when preference changes + useEffect(() => { + if (forcedBannerRefreshDone.current) { + logger.log(`[useMetadataAssets] Logo preference changed, resetting banner refresh flag`); + forcedBannerRefreshDone.current = false; + // Clear the banner image immediately to prevent showing the wrong source briefly + setBannerImage(null); + // This will trigger the banner fetch effect to run again + } + }, [settings.logoSourcePreference]); + + return { + bannerImage, + loadingBanner, + logoLoadError, + foundTmdbId, + setLogoLoadError, + setBannerImage, + }; +}; \ No newline at end of file diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts new file mode 100644 index 0000000..7c71539 --- /dev/null +++ b/src/hooks/useWatchProgress.ts @@ -0,0 +1,216 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { logger } from '../utils/logger'; +import { storageService } from '../services/storageService'; + +interface WatchProgressData { + currentTime: number; + duration: number; + lastUpdated: number; + episodeId?: string; +} + +export const useWatchProgress = ( + id: string, + type: 'movie' | 'series', + episodeId?: string, + episodes: any[] = [] +) => { + const [watchProgress, setWatchProgress] = useState(null); + + // Function to get episode details from episodeId + const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { + // Try to parse from format "seriesId:season:episode" + const parts = episodeId.split(':'); + if (parts.length === 3) { + const [, seasonNum, episodeNum] = parts; + // Find episode in our local episodes array + const episode = episodes.find( + ep => ep.season_number === parseInt(seasonNum) && + ep.episode_number === parseInt(episodeNum) + ); + + if (episode) { + return { + seasonNumber: seasonNum, + episodeNumber: episodeNum, + episodeName: episode.name + }; + } + } + + // If not found by season/episode, try stremioId + const episodeByStremioId = episodes.find(ep => ep.stremioId === episodeId); + if (episodeByStremioId) { + return { + seasonNumber: episodeByStremioId.season_number.toString(), + episodeNumber: episodeByStremioId.episode_number.toString(), + episodeName: episodeByStremioId.name + }; + } + + return null; + }, [episodes]); + + // Load watch progress + const loadWatchProgress = useCallback(async () => { + try { + if (id && type) { + if (type === 'series') { + const allProgress = await storageService.getAllWatchProgress(); + + // Function to get episode number from episodeId + const getEpisodeNumber = (epId: string) => { + const parts = epId.split(':'); + if (parts.length === 3) { + return { + season: parseInt(parts[1]), + episode: parseInt(parts[2]) + }; + } + return null; + }; + + // Get all episodes for this series with progress + const seriesProgresses = Object.entries(allProgress) + .filter(([key]) => key.includes(`${type}:${id}:`)) + .map(([key, value]) => ({ + episodeId: key.split(`${type}:${id}:`)[1], + progress: value + })) + .filter(({ episodeId, progress }) => { + const progressPercent = (progress.currentTime / progress.duration) * 100; + return progressPercent > 0; + }); + + // If we have a specific episodeId in route params + if (episodeId) { + const progress = await storageService.getWatchProgress(id, type, episodeId); + if (progress) { + const progressPercent = (progress.currentTime / progress.duration) * 100; + + // If current episode is finished (≥95%), try to find next unwatched episode + if (progressPercent >= 95) { + const currentEpNum = getEpisodeNumber(episodeId); + if (currentEpNum && episodes.length > 0) { + // Find the next episode + const nextEpisode = episodes.find(ep => { + // First check in same season + if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) { + const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + const epProgress = seriesProgresses.find(p => p.episodeId === epId); + if (!epProgress) return true; + const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; + return percent < 95; + } + // Then check next seasons + if (ep.season_number > currentEpNum.season) { + const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + const epProgress = seriesProgresses.find(p => p.episodeId === epId); + if (!epProgress) return true; + const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; + return percent < 95; + } + return false; + }); + + if (nextEpisode) { + const nextEpisodeId = nextEpisode.stremioId || + `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; + const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId); + if (nextProgress) { + setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId }); + } else { + setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId }); + } + return; + } + } + // If no next episode found or current episode is finished, show no progress + setWatchProgress(null); + return; + } + + // If current episode is not finished, show its progress + setWatchProgress({ ...progress, episodeId }); + } else { + setWatchProgress(null); + } + } else { + // Find the first unfinished episode + const unfinishedEpisode = episodes.find(ep => { + const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + const progress = seriesProgresses.find(p => p.episodeId === epId); + if (!progress) return true; + const percent = (progress.progress.currentTime / progress.progress.duration) * 100; + return percent < 95; + }); + + if (unfinishedEpisode) { + const epId = unfinishedEpisode.stremioId || + `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; + const progress = await storageService.getWatchProgress(id, type, epId); + if (progress) { + setWatchProgress({ ...progress, episodeId: epId }); + } else { + setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId }); + } + } else { + setWatchProgress(null); + } + } + } else { + // For movies + const progress = await storageService.getWatchProgress(id, type, episodeId); + if (progress && progress.currentTime > 0) { + const progressPercent = (progress.currentTime / progress.duration) * 100; + if (progressPercent >= 95) { + setWatchProgress(null); + } else { + setWatchProgress({ ...progress, episodeId }); + } + } else { + setWatchProgress(null); + } + } + } + } catch (error) { + logger.error('[useWatchProgress] Error loading watch progress:', error); + setWatchProgress(null); + } + }, [id, type, episodeId, episodes]); + + // Function to get play button text based on watch progress + const getPlayButtonText = useCallback(() => { + if (!watchProgress || watchProgress.currentTime <= 0) { + return 'Play'; + } + + // Consider episode complete if progress is >= 95% + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + if (progressPercent >= 95) { + return 'Play'; + } + + return 'Resume'; + }, [watchProgress]); + + // Initial load + useEffect(() => { + loadWatchProgress(); + }, [loadWatchProgress]); + + // Refresh when screen comes into focus + useFocusEffect( + useCallback(() => { + loadWatchProgress(); + }, [loadWatchProgress]) + ); + + return { + watchProgress, + getEpisodeDetails, + getPlayButtonText, + loadWatchProgress + }; +}; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index a6ca4d1..881bdd0 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,212 +1,44 @@ -import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { View, Text, StyleSheet, - ScrollView, - TouchableOpacity, - ActivityIndicator, - useColorScheme, StatusBar, - ImageBackground, + ActivityIndicator, Dimensions, - Platform, - TouchableWithoutFeedback, - NativeSyntheticEvent, - NativeScrollEvent, + TouchableOpacity, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useRoute, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Image } from 'expo-image'; -import { BlurView as ExpoBlurView } from 'expo-blur'; -import { BlurView as CommunityBlurView } from '@react-native-community/blur'; import * as Haptics from 'expo-haptics'; import { colors } from '../styles/colors'; import { useMetadata } from '../hooks/useMetadata'; -import { CastSection as OriginalCastSection } from '../components/metadata/CastSection'; -import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent'; -import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent'; -import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; -import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection'; -import { StreamingContent } from '../services/catalogService'; -import { GroupedStreams } from '../types/streams'; -import { TMDBEpisode } from '../services/tmdbService'; -import { Cast } from '../types/cast'; +import { CastSection } from '../components/metadata/CastSection'; +import { SeriesContent } from '../components/metadata/SeriesContent'; +import { MovieContent } from '../components/metadata/MovieContent'; +import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; +import { RatingsSection } from '../components/metadata/RatingsSection'; import { RouteParams, Episode } from '../types/metadata'; import Animated, { useAnimatedStyle, - withTiming, - useSharedValue, - Easing, - FadeInDown, interpolate, Extrapolate, - withSpring, - FadeIn, - runOnJS, - Layout, - useAnimatedScrollHandler, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; -import { TMDBService } from '../services/tmdbService'; -import { storageService } from '../services/storageService'; -import { logger } from '../utils/logger'; -import { useGenres } from '../contexts/GenreContext'; -import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl, fetchBannerWithPreference } from '../utils/logoUtils'; import { useSettings } from '../hooks/useSettings'; -const { width, height } = Dimensions.get('window'); +// Import our new components and hooks +import HeroSection from '../components/metadata/HeroSection'; +import FloatingHeader from '../components/metadata/FloatingHeader'; +import MetadataDetails from '../components/metadata/MetadataDetails'; +import { useMetadataAnimations } from '../hooks/useMetadataAnimations'; +import { useMetadataAssets } from '../hooks/useMetadataAssets'; +import { useWatchProgress } from '../hooks/useWatchProgress'; -// Memoize child components -const CastSection = React.memo(OriginalCastSection); -const SeriesContent = React.memo(OriginalSeriesContent); -const MovieContent = React.memo(OriginalMovieContent); -const MoreLikeThisSection = React.memo(OriginalMoreLikeThisSection); -const RatingsSection = React.memo(OriginalRatingsSection); - -// Animation constants -const springConfig = { - damping: 20, - mass: 1, - stiffness: 100 -}; - -// Animation timing constants for staggered appearance -const ANIMATION_DELAY_CONSTANTS = { - HERO: 100, - LOGO: 250, - PROGRESS: 350, - GENRES: 400, - BUTTONS: 450, - CONTENT: 500 -}; - -// Add debug log for storageService -logger.log('[MetadataScreen] StorageService instance:', storageService); - -// Memoized ActionButtons Component -const ActionButtons = React.memo(({ - handleShowStreams, - toggleLibrary, - inLibrary, - type, - id, - navigation, - playButtonText, - animatedStyle -}: { - handleShowStreams: () => void; - toggleLibrary: () => void; - inLibrary: boolean; - type: 'movie' | 'series'; - id: string; - navigation: NavigationProp; - playButtonText: string; - animatedStyle: any; -}) => { - // Add wrapper for play button with haptic feedback - const handlePlay = () => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - handleShowStreams(); - }; - - return ( - - - - - {playButtonText} - - - - - - - {inLibrary ? 'Saved' : 'Save'} - - - - {type === 'series' && ( - { - const tmdb = TMDBService.getInstance(); - const tmdbId = await tmdb.extractTMDBIdFromStremioId(id); - if (tmdbId) { - navigation.navigate('ShowRatings', { showId: tmdbId }); - } else { - logger.error('Could not find TMDB ID for show'); - } - }} - > - - - )} - - ); -}); - -// Memoized WatchProgress Component -const WatchProgressDisplay = React.memo(({ - watchProgress, - type, - getEpisodeDetails, - animatedStyle -}: { - watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; - type: 'movie' | 'series'; - getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; - animatedStyle: any; -}) => { - if (!watchProgress || watchProgress.duration === 0) { - return null; - } - - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); - let episodeInfo = ''; - - if (type === 'series' && watchProgress.episodeId) { - const details = getEpisodeDetails(watchProgress.episodeId); - if (details) { - episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; - } - } - - return ( - - - - - - {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} - - - ); -}); +const { height } = Dimensions.get('window'); const MetadataScreen = () => { const route = useRoute, string>>(); @@ -216,18 +48,8 @@ const MetadataScreen = () => { // Add settings hook const { settings } = useSettings(); - // Add a flag to track if we need to do a forced initial logo refresh - const forcedLogoRefreshDone = useRef(false); - - // Add state for custom banner - const [bannerImage, setBannerImage] = useState(null); - const forcedBannerRefreshDone = useRef(false); - const [loadingBanner, setLoadingBanner] = useState(false); - - // Add debug log for settings when component mounts - useEffect(() => { - logger.log(`[MetadataScreen] Component mounted with logo preference setting: ${settings.logoSourcePreference}`); - }, [settings.logoSourcePreference]); + // Get safe area insets + const { top: safeAreaTop } = useSafeAreaInsets(); const { metadata, @@ -249,331 +71,22 @@ const MetadataScreen = () => { imdbId, } = useMetadata({ id, type }); - // Force an initial logo refresh when component mounts and logo doesn't match preference - useEffect(() => { - if (metadata?.logo && !forcedLogoRefreshDone.current) { - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; + // Use our new hooks + const { + watchProgress, + getEpisodeDetails, + getPlayButtonText, + } = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); - // Check if logo source doesn't match preference - if ((preferenceIsMetahub && !currentLogoIsMetahub) || - (!preferenceIsMetahub && !currentLogoIsTmdb)) { - logger.log(`[MetadataScreen] Initial load: Logo source doesn't match preference. Forcing refresh.`); - - // Clear logo to force a new fetch according to preference - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: undefined - })); - } - - // Mark that we've checked this so we don't endlessly loop - forcedLogoRefreshDone.current = true; - } - }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); + const { + bannerImage, + loadingBanner, + logoLoadError, + setLogoLoadError, + setBannerImage, + } = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); - // Store found TMDB ID for banner fetching - const [foundTmdbId, setFoundTmdbId] = useState(null); - - // Fetch banner image based on logo source preference - useEffect(() => { - const fetchBanner = async () => { - if (metadata) { - setLoadingBanner(true); - - // Clear the banner initially when starting a preference-driven fetch - setBannerImage(null); - - let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback - const preference = settings.logoSourcePreference || 'metahub'; - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key - - // Extract IDs - let currentTmdbId = null; - if (id.startsWith('tmdb:')) { - currentTmdbId = id.split(':')[1]; - } else if (foundTmdbId) { - currentTmdbId = foundTmdbId; - } else if ((metadata as any).tmdbId) { - currentTmdbId = (metadata as any).tmdbId; - } - - const currentImdbId = imdbId; - const contentType = type === 'series' ? 'tv' : 'movie'; - - logger.log(`[MetadataScreen] Fetching banner with preference: ${preference}, language: ${preferredLanguage}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); - - try { - if (preference === 'tmdb') { - // 1. Try TMDB first - let tmdbBannerUrl: string | null = null; - if (currentTmdbId) { - logger.log(`[MetadataScreen] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); - try { - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}&include_image_language=${preferredLanguage},en,null`); - const imagesData = await response.json(); - - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - // Try to find backdrop in preferred language first - let backdropPath = null; - - if (preferredLanguage !== 'en') { - const preferredBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === preferredLanguage); - if (preferredBackdrop) { - backdropPath = preferredBackdrop.file_path; - logger.log(`[MetadataScreen] Found ${preferredLanguage} backdrop for ID: ${currentTmdbId}`); - } - } - - // Fall back to English backdrop - if (!backdropPath) { - const englishBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === 'en'); - if (englishBackdrop) { - backdropPath = englishBackdrop.file_path; - logger.log(`[MetadataScreen] Found English backdrop for ID: ${currentTmdbId}`); - } else { - // Last resort: use the first backdrop - backdropPath = imagesData.backdrops[0].file_path; - logger.log(`[MetadataScreen] Using first available backdrop for ID: ${currentTmdbId}`); - } - } - - tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; - logger.log(`[MetadataScreen] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); - } else { - // Add log for when no backdrops are found - logger.warn(`[MetadataScreen] TMDB API successful, but no backdrops found for ID: ${currentTmdbId}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching TMDB banner via images endpoint:`, err); - } - } else { - // Add log for when no TMDB ID is available - logger.warn(`[MetadataScreen] No TMDB ID available to fetch TMDB banner.`); - } - - if (tmdbBannerUrl) { - // TMDB SUCCESS: Set banner and EXIT - finalBanner = tmdbBannerUrl; - logger.log(`[MetadataScreen] Setting final banner to TMDB source: ${finalBanner}`); - setBannerImage(finalBanner); - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; - return; // <-- Exit here, don't attempt fallback - } else { - // TMDB FAILED: Proceed to Metahub fallback - logger.log(`[MetadataScreen] TMDB banner failed, trying Metahub fallback.`); - if (currentImdbId) { - const metahubBannerUrl = `https://images.metahub.space/background/medium/${currentImdbId}/img`; - try { - const metahubResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); - if (metahubResponse.ok) { - finalBanner = metahubBannerUrl; - logger.log(`[MetadataScreen] Found Metahub banner as fallback: ${finalBanner}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching Metahub fallback banner:`, err); - } - } - } - } else { // Preference is Metahub - // 1. Try Metahub first - let metahubBannerUrl: string | null = null; - if (currentImdbId) { - const url = `https://images.metahub.space/background/medium/${currentImdbId}/img`; - try { - const metahubResponse = await fetch(url, { method: 'HEAD' }); - if (metahubResponse.ok) { - metahubBannerUrl = url; - logger.log(`[MetadataScreen] Found Metahub banner: ${metahubBannerUrl}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching Metahub banner:`, err); - } - } - - if (metahubBannerUrl) { - // METAHUB SUCCESS: Set banner and EXIT - finalBanner = metahubBannerUrl; - logger.log(`[MetadataScreen] Setting final banner to Metahub source: ${finalBanner}`); - setBannerImage(finalBanner); - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; - return; // <-- Exit here, don't attempt fallback - } else { - // METAHUB FAILED: Proceed to TMDB fallback - logger.log(`[MetadataScreen] Metahub banner failed, trying TMDB fallback.`); - if (currentTmdbId) { - try { - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); - const imagesData = await response.json(); - - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - const backdropPath = imagesData.backdrops[0].file_path; - finalBanner = `https://image.tmdb.org/t/p/original${backdropPath}`; - logger.log(`[MetadataScreen] Found TMDB banner as fallback: ${finalBanner}`); - } - } catch (err) { - logger.error(`[MetadataScreen] Error fetching TMDB fallback banner:`, err); - } - } - } - } - - // Set the final determined banner (could be fallback or initial default) - setBannerImage(finalBanner); - logger.log(`[MetadataScreen] Final banner set after fallbacks (if any): ${finalBanner}`); - - } catch (error) { - logger.error(`[MetadataScreen] General error fetching banner:`, error); - // Fallback to initial banner on general error - setBannerImage(metadata.banner || metadata.poster); - } finally { - // Only set loading to false here if we didn't exit early - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; // Mark refresh as done - } - } - }; - - // Only run fetchBanner if metadata exists and preference/content might have changed - // The dependencies array handles triggering this effect - fetchBanner(); - - }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); - - // Reset forced refresh when preference changes - useEffect(() => { - if (forcedBannerRefreshDone.current) { - logger.log(`[MetadataScreen] Logo preference changed, resetting banner refresh flag`); - forcedBannerRefreshDone.current = false; - // Clear the banner image immediately to prevent showing the wrong source briefly - setBannerImage(null); - // This will trigger the banner fetch effect to run again - } - }, [settings.logoSourcePreference]); - - // Get genres from context - const { genreMap, loadingGenres } = useGenres(); - - // Update the ref type to be compatible with Animated.ScrollView - const contentRef = useRef(null); - const [lastScrollTop, setLastScrollTop] = useState(0); - const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); - - // Get safe area insets - const { top: safeAreaTop } = useSafeAreaInsets(); - - // Animation values - const screenScale = useSharedValue(0.92); - const screenOpacity = useSharedValue(0); - const heroHeight = useSharedValue(height * 0.5); - const contentTranslateY = useSharedValue(60); - - // Additional animation values for staggered entrance - const heroScale = useSharedValue(1.05); - const heroOpacity = useSharedValue(0); - const genresOpacity = useSharedValue(0); - const genresTranslateY = useSharedValue(20); - const buttonsOpacity = useSharedValue(0); - const buttonsTranslateY = useSharedValue(30); - - // Add state for watch progress - const [watchProgress, setWatchProgress] = useState<{ - currentTime: number; - duration: number; - lastUpdated: number; - episodeId?: string; - } | null>(null); - - // Add state to track image load errors - const [logoLoadError, setLogoLoadError] = useState(false); - - // Reset logo load error when metadata changes - useEffect(() => { - setLogoLoadError(false); - }, [metadata?.logo]); - - // Add a ref to track logo fetch in progress - const logoFetchInProgress = useRef(false); - - // Add a ref to track logo preference changes to prevent infinite loops - const logoRefreshCounter = useRef(0); - const MAX_LOGO_REFRESHES = 2; - - // Force refresh logo when logo preference changes - only when preference actually changes - useEffect(() => { - // Reset the counter when preference actually changes - if (logoRefreshCounter.current === 0) { - logoRefreshCounter.current = 1; // Mark that we've started a refresh cycle - - // Only clear logo if we already have metadata with a logo - if (metadata?.logo) { - // Check if the current logo source doesn't match the preference - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - - // Only refresh if the current logo source clearly doesn't match the preference - const needsRefresh = (preferenceIsMetahub && currentLogoIsTmdb) || - (preferenceIsTmdb && currentLogoIsMetahub); - - if (needsRefresh) { - logger.log(`[MetadataScreen] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, triggering one-time refresh`); - - // Prevent endless refreshes - if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { - logoRefreshCounter.current++; - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: undefined - })); - } else { - logger.warn(`[MetadataScreen] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); - } - } else { - logger.log(`[MetadataScreen] Logo source already matches preference (${settings.logoSourcePreference}), no refresh needed`); - logoRefreshCounter.current = 0; // Reset for future changes - } - } - } else { - logoRefreshCounter.current++; - logger.log(`[MetadataScreen] Logo refresh already in progress (${logoRefreshCounter.current}/${MAX_LOGO_REFRESHES})`); - - // Reset counter after max refreshes to allow future preference changes to work - if (logoRefreshCounter.current >= MAX_LOGO_REFRESHES) { - logger.warn(`[MetadataScreen] Maximum refreshes reached, resetting counter`); - // After a timeout to avoid immediate re-triggering - setTimeout(() => { - logoRefreshCounter.current = 0; - }, 1000); - } - } - }, [settings.logoSourcePreference, metadata?.logo, setMetadata]); - - // Add effect to track when logo source matches preference - useEffect(() => { - if (metadata?.logo) { - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - - // Check if current logo source matches preference - const logoSourceMatches = (preferenceIsMetahub && currentLogoIsMetahub) || - (preferenceIsTmdb && currentLogoIsTmdb); - - if (logoSourceMatches) { - logger.log(`[MetadataScreen] Logo source (${currentLogoIsMetahub ? 'Metahub' : 'TMDB'}) now matches preference (${settings.logoSourcePreference}), refresh complete`); - logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal - } - } - }, [metadata?.logo, settings.logoSourcePreference]); + const animations = useMetadataAnimations(safeAreaTop, watchProgress); // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { @@ -601,482 +114,6 @@ const MetadataScreen = () => { }, 10); }, [handleSeasonChange]); - // Add new animated value for watch progress - const watchProgressOpacity = useSharedValue(0); - const watchProgressScaleY = useSharedValue(0); - - // Add animated value for logo - const logoOpacity = useSharedValue(0); - const logoScale = useSharedValue(0.9); - - // Add shared value for parallax effect - const scrollY = useSharedValue(0); - - // Create a dampened scroll value for smoother parallax - const dampedScrollY = useSharedValue(0); - - // Add shared value for floating header opacity - const headerOpacity = useSharedValue(0); - - // Add values for animated header elements - const headerElementsY = useSharedValue(-10); - const headerElementsOpacity = useSharedValue(0); - - // Debug log for route params - // logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId }); - - // Fetch logo immediately for TMDB content - with guard against recursive updates - useEffect(() => { - // Guard against infinite loops by checking if we're already fetching - if (metadata && !metadata.logo && !logoFetchInProgress.current) { - console.log('[MetadataScreen] Current settings:', JSON.stringify(settings)); - console.log('[MetadataScreen] Current metadata:', JSON.stringify(metadata, null, 2)); - - const fetchLogo = async () => { - // Set fetch in progress flag - logoFetchInProgress.current = true; - - try { - // Get logo source preference from settings - const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set - - console.log(`[MetadataScreen] Using logo preference: ${logoPreference}, TMDB first: ${logoPreference === 'tmdb'}`); - logger.log(`[MetadataScreen] Logo source preference: ${logoPreference}`); - - // First source based on preference - if (logoPreference === 'metahub') { - // Try to get logo from Metahub first - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); - - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched logo from Metahub: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metahubUrl} - `); - - // Update metadata with Metahub logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: metahubUrl - })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if Metahub logo was found - } else { - logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); - } - } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); - } - - // If Metahub fails, try TMDB as fallback - if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; - const tmdbType = type === 'series' ? 'tv' : 'movie'; - - logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); - - const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); - - if (logoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: - - Content Type: ${tmdbType} - - TMDB ID: ${tmdbId} - - Logo URL: ${logoUrl} - `); - - // Update metadata with TMDB logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: logoUrl - })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if TMDB logo was found - } else { - // If both Metahub and TMDB fail, use the title as text instead of a logo - logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); - - // Leave logo as null/undefined to trigger fallback to text - } - } - } else { // TMDB first - let tmdbLogoUrl: string | null = null; - - // 1. Attempt to fetch TMDB logo - if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; - const tmdbType = type === 'series' ? 'tv' : 'movie'; - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - - logger.log(`[MetadataScreen] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId}, preferred language: ${preferredLanguage})`); - try { - const tmdbService = TMDBService.getInstance(); - tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); - - if (tmdbLogoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); - } else { - logger.warn(`[MetadataScreen] No logo found from TMDB for ${type} (ID: ${tmdbId})`); - } - } catch (error) { - logger.error(`[MetadataScreen] Error fetching TMDB logo for ID ${tmdbId}:`, error); - } - } else if (imdbId) { - // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - logger.log(`[MetadataScreen] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo, preferred language: ${preferredLanguage}`); - try { - const tmdbService = TMDBService.getInstance(); - const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); - - if (foundTmdbId) { - logger.log(`[MetadataScreen] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); - setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching - - tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString(), preferredLanguage); - - if (tmdbLogoUrl) { - logger.log(`[MetadataScreen] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); - } else { - logger.warn(`[MetadataScreen] No logo found from TMDB via IMDB lookup for ${type} (IMDB: ${imdbId})`); - } - } else { - logger.warn(`[MetadataScreen] Could not find TMDB ID for IMDB ID ${imdbId}`); - } - } catch (error) { - logger.error(`[MetadataScreen] Error finding TMDB ID or fetching logo for IMDB ID ${imdbId}:`, error); - } - } - - // 2. If TMDB logo was fetched successfully, update and return - if (tmdbLogoUrl) { - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: tmdbLogoUrl - })); - logoFetchInProgress.current = false; - return; - } - - // 3. If TMDB failed, try Metahub as fallback - logger.log(`[MetadataScreen] TMDB logo fetch failed or not applicable. Attempting Metahub fallback.`); - if (imdbId) { - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); - - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[MetadataScreen] Successfully fetched fallback logo from Metahub: ${metahubUrl}`); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: metahubUrl })); - } else { - logger.warn(`[MetadataScreen] Metahub fallback failed. Using title text.`); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } - } catch (metahubError) { - logger.warn(`[MetadataScreen] Failed to fetch fallback logo from Metahub:`, metahubError); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } - } else { - // No IMDB ID for Metahub fallback - logger.warn(`[MetadataScreen] No IMDB ID for Metahub fallback. Using title text.`); - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } - } - } catch (error) { - logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { - error, - contentId: id, - contentType: type - }); - // Fallback to text on general error - setMetadata(prevMetadata => ({ ...prevMetadata!, logo: undefined })); - } finally { - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - } - }; - - fetchLogo(); - } else if (logoFetchInProgress.current) { - console.log('[MetadataScreen] Logo fetch already in progress, skipping'); - } else if (metadata?.logo) { - logger.log(`[MetadataScreen] Using existing logo from metadata: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metadata.logo} - - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} - `); - } - }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); - - // Function to get episode details from episodeId - const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { - // Try to parse from format "seriesId:season:episode" - const parts = episodeId.split(':'); - if (parts.length === 3) { - const [, seasonNum, episodeNum] = parts; - // Find episode in our local episodes array - const episode = episodes.find( - ep => ep.season_number === parseInt(seasonNum) && - ep.episode_number === parseInt(episodeNum) - ); - - if (episode) { - return { - seasonNumber: seasonNum, - episodeNumber: episodeNum, - episodeName: episode.name - }; - } - } - - // If not found by season/episode, try stremioId - const episodeByStremioId = episodes.find(ep => ep.stremioId === episodeId); - if (episodeByStremioId) { - return { - seasonNumber: episodeByStremioId.season_number.toString(), - episodeNumber: episodeByStremioId.episode_number.toString(), - episodeName: episodeByStremioId.name - }; - } - - return null; - }, [episodes]); - - const loadWatchProgress = useCallback(async () => { - try { - if (id && type) { - if (type === 'series') { - const allProgress = await storageService.getAllWatchProgress(); - - // Function to get episode number from episodeId - const getEpisodeNumber = (epId: string) => { - const parts = epId.split(':'); - if (parts.length === 3) { - return { - season: parseInt(parts[1]), - episode: parseInt(parts[2]) - }; - } - return null; - }; - - // Get all episodes for this series with progress - const seriesProgresses = Object.entries(allProgress) - .filter(([key]) => key.includes(`${type}:${id}:`)) - .map(([key, value]) => ({ - episodeId: key.split(`${type}:${id}:`)[1], - progress: value - })) - .filter(({ episodeId, progress }) => { - const progressPercent = (progress.currentTime / progress.duration) * 100; - return progressPercent > 0; - }); - - // If we have a specific episodeId in route params - if (episodeId) { - const progress = await storageService.getWatchProgress(id, type, episodeId); - if (progress) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - - // If current episode is finished (≥95%), try to find next unwatched episode - if (progressPercent >= 95) { - const currentEpNum = getEpisodeNumber(episodeId); - if (currentEpNum && episodes.length > 0) { - // Find the next episode - const nextEpisode = episodes.find(ep => { - // First check in same season - if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const epProgress = seriesProgresses.find(p => p.episodeId === epId); - if (!epProgress) return true; - const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; - return percent < 95; - } - // Then check next seasons - if (ep.season_number > currentEpNum.season) { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const epProgress = seriesProgresses.find(p => p.episodeId === epId); - if (!epProgress) return true; - const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; - return percent < 95; - } - return false; - }); - - if (nextEpisode) { - const nextEpisodeId = nextEpisode.stremioId || - `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; - const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId); - if (nextProgress) { - setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId }); - } else { - setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId }); - } - return; - } - } - // If no next episode found or current episode is finished, show no progress - setWatchProgress(null); - return; - } - - // If current episode is not finished, show its progress - setWatchProgress({ ...progress, episodeId }); - } else { - setWatchProgress(null); - } - } else { - // Find the first unfinished episode - const unfinishedEpisode = episodes.find(ep => { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const progress = seriesProgresses.find(p => p.episodeId === epId); - if (!progress) return true; - const percent = (progress.progress.currentTime / progress.progress.duration) * 100; - return percent < 95; - }); - - if (unfinishedEpisode) { - const epId = unfinishedEpisode.stremioId || - `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; - const progress = await storageService.getWatchProgress(id, type, epId); - if (progress) { - setWatchProgress({ ...progress, episodeId: epId }); - } else { - setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId }); - } - } else { - setWatchProgress(null); - } - } - } else { - // For movies - const progress = await storageService.getWatchProgress(id, type, episodeId); - if (progress && progress.currentTime > 0) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 95) { - setWatchProgress(null); - } else { - setWatchProgress({ ...progress, episodeId }); - } - } else { - setWatchProgress(null); - } - } - } - } catch (error) { - logger.error('[MetadataScreen] Error loading watch progress:', error); - setWatchProgress(null); - } - }, [id, type, episodeId, episodes, getEpisodeDetails]); - - // Initial load - useEffect(() => { - loadWatchProgress(); - }, [loadWatchProgress]); - - // Refresh when screen comes into focus - useFocusEffect( - useCallback(() => { - loadWatchProgress(); - }, [loadWatchProgress]) - ); - - // Function to get play button text - const getPlayButtonText = useCallback(() => { - if (!watchProgress || watchProgress.currentTime <= 0) { - return 'Play'; - } - - // Consider episode complete if progress is >= 95% - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - if (progressPercent >= 95) { - return 'Play'; - } - - return 'Resume'; - }, [watchProgress]); - - // Add effect to animate watch progress when it changes - useEffect(() => { - if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - mass: 0.2, - stiffness: 100, - damping: 14 - }); - watchProgressScaleY.value = withSpring(1, { - mass: 0.3, - stiffness: 120, - damping: 18 - }); - } else { - watchProgressOpacity.value = withSpring(0, { - mass: 0.2, - stiffness: 100, - damping: 14 - }); - watchProgressScaleY.value = withSpring(0, { - mass: 0.3, - stiffness: 120, - damping: 18 - }); - } - }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); - - // Add animated style for watch progress - const watchProgressAnimatedStyle = useAnimatedStyle(() => { - const translateY = interpolate( - watchProgressScaleY.value, - [0, 1], - [-8, 0], - Extrapolate.CLAMP - ); - - return { - opacity: watchProgressOpacity.value, - transform: [ - { translateY: translateY }, - { scaleY: watchProgressScaleY.value } - ] - }; - }); - - // Add animated style for logo - const logoAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: logoOpacity.value, - transform: [{ scale: logoScale.value }] - }; - }); - - // Effect to animate logo when it's available - useEffect(() => { - if (metadata?.logo) { - logoOpacity.value = withTiming(1, { - duration: 500, - easing: Easing.out(Easing.ease) - }); - } else { - logoOpacity.value = withTiming(0, { - duration: 200, - easing: Easing.in(Easing.ease) - }); - } - }, [metadata?.logo, logoOpacity]); - - // Update the watch progress render function - Now uses WatchProgressDisplay component - // const renderWatchProgress = () => { ... }; // Removed old inline function - // Handler functions const handleShowStreams = useCallback(() => { if (type === 'series') { @@ -1109,13 +146,10 @@ const MetadataScreen = () => { }, [navigation, id, type, episodes, episodeId, watchProgress]); const handleSelectCastMember = useCallback((castMember: any) => { - // Potentially navigate to a cast member screen or show details - logger.log('Cast member selected:', castMember); - }, []); // Empty dependency array as it doesn't depend on component state/props currently + // Future implementation + }, []); const handleEpisodeSelect = useCallback((episode: Episode) => { - // Removed haptic feedback - const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; navigation.navigate('Streams', { id, @@ -1124,286 +158,27 @@ const MetadataScreen = () => { }); }, [navigation, id, type]); + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + // Animated styles const containerAnimatedStyle = useAnimatedStyle(() => ({ flex: 1, - transform: [{ scale: screenScale.value }], - opacity: screenOpacity.value + transform: [{ scale: animations.screenScale.value }], + opacity: animations.screenOpacity.value })); const contentAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: contentTranslateY.value }], + transform: [{ translateY: animations.contentTranslateY.value }], opacity: interpolate( - contentTranslateY.value, + animations.contentTranslateY.value, [60, 0], [0, 1], Extrapolate.CLAMP ) })); - // Add animated style for genres - const genresAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: genresOpacity.value, - transform: [{ translateY: genresTranslateY.value }] - }; - }); - - // Add animated style for buttons - const buttonsAnimatedStyle = useAnimatedStyle(() => { - return { - opacity: buttonsOpacity.value, - transform: [{ translateY: buttonsTranslateY.value }] - }; - }); - - // Debug logs for director/creator data - React.useEffect(() => { - if (metadata && metadata.id) { - const fetchCrewData = async () => { - try { - const tmdb = TMDBService.getInstance(); - const tmdbId = await tmdb.extractTMDBIdFromStremioId(id); - - if (tmdbId) { - const credits = await tmdb.getCredits(tmdbId, type); - // logger.log("Credits data structure:", JSON.stringify(credits).substring(0, 300)); - - // Extract directors for movies - if (type === 'movie' && credits.crew) { - const directors = credits.crew - .filter((person: { job: string }) => person.job === 'Director') - .map((director: { name: string }) => director.name); - - if (directors.length > 0 && metadata) { - // Update metadata with directors - setMetadata({ - ...metadata, - directors - }); - // logger.log("Updated directors:", directors); - } - } - - // Extract creators for TV shows - if (type === 'series' && credits.crew) { - const creators = credits.crew - .filter((person: { job?: string; department?: string }) => - person.job === 'Creator' || - person.job === 'Series Creator' || - person.department === 'Production' || - person.job === 'Executive Producer' - ) - .map((creator: { name: string }) => creator.name); - - if (creators.length > 0 && metadata) { - // Update metadata with creators - setMetadata({ - ...metadata, - creators: creators.slice(0, 3) // Limit to first 3 creators - }); - // logger.log("Updated creators:", creators.slice(0, 3)); - } - } - } - } catch (error) { - logger.error('Error fetching crew data:', error); - } - }; - - fetchCrewData(); - } - }, [metadata?.id, id, type, setMetadata]); - - // Start entrance animation - React.useEffect(() => { - // Use a timeout to ensure the animations starts after the component is mounted - const animationTimeout = setTimeout(() => { - // 1. First animate the container - screenScale.value = withSpring(1, springConfig); - screenOpacity.value = withSpring(1, springConfig); - - // 2. Then animate the hero section with a slight delay - setTimeout(() => { - heroOpacity.value = withSpring(1, { - damping: 14, - stiffness: 80 - }); - heroScale.value = withSpring(1, { - damping: 18, - stiffness: 100 - }); - }, ANIMATION_DELAY_CONSTANTS.HERO); - - // 3. Then animate the logo - setTimeout(() => { - logoOpacity.value = withSpring(1, { - damping: 12, - stiffness: 100 - }); - logoScale.value = withSpring(1, { - damping: 14, - stiffness: 90 - }); - }, ANIMATION_DELAY_CONSTANTS.LOGO); - - // 4. Then animate the watch progress if applicable - setTimeout(() => { - if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - watchProgressScaleY.value = withSpring(1, { - damping: 18, - stiffness: 120 - }); - } - }, ANIMATION_DELAY_CONSTANTS.PROGRESS); - - // 5. Then animate the genres - setTimeout(() => { - genresOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - genresTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.GENRES); - - // 6. Then animate the buttons - setTimeout(() => { - buttonsOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - buttonsTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.BUTTONS); - - // 7. Finally animate the content section - setTimeout(() => { - contentTranslateY.value = withSpring(0, { - damping: 25, - mass: 1, - stiffness: 100 - }); - }, ANIMATION_DELAY_CONSTANTS.CONTENT); - }, 50); // Small timeout to ensure component is fully mounted - - return () => clearTimeout(animationTimeout); - }, []); - - const handleBack = useCallback(() => { - // Use goBack() which will return to the previous screen in the navigation stack - // This will work for both cases: - // 1. Coming from Calendar/ThisWeek - goes back to them - // 2. Coming from StreamsScreen - goes back to Calendar/ThisWeek - navigation.goBack(); - }, [navigation]); - - // Function to render genres (updated to handle string array and use useMemo) - const renderGenres = useMemo(() => { - if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { - return null; - } - - // Since metadata.genres is string[], we display them directly - const genresToDisplay: string[] = metadata.genres as string[]; - - return genresToDisplay.slice(0, 4).map((genreName, index, array) => ( - // Use React.Fragment to avoid extra View wrappers - - {genreName} - {/* Add dot separator */} - {index < array.length - 1 && ( - - )} - - )); - }, [metadata?.genres]); // Dependency on metadata.genres - - // Update the heroAnimatedStyle for parallax effect - const heroAnimatedStyle = useAnimatedStyle(() => ({ - width: '100%', - height: heroHeight.value, - backgroundColor: colors.black, - transform: [{ scale: heroScale.value }], - opacity: heroOpacity.value, - })); - - // Replace direct onScroll with useAnimatedScrollHandler - const scrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const rawScrollY = event.contentOffset.y; - scrollY.value = rawScrollY; - - // Apply spring-like damping for smoother transitions - dampedScrollY.value = withTiming(rawScrollY, { - duration: 300, - easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve - }); - - // Update header opacity based on scroll position - const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer - if (rawScrollY > headerThreshold) { - headerOpacity.value = withTiming(1, { duration: 200 }); - headerElementsY.value = withTiming(0, { duration: 300 }); - headerElementsOpacity.value = withTiming(1, { duration: 450 }); - } else { - headerOpacity.value = withTiming(0, { duration: 150 }); - headerElementsY.value = withTiming(-10, { duration: 200 }); - headerElementsOpacity.value = withTiming(0, { duration: 200 }); - } - }, - }); - - // Add a new animated style for the parallax image - const parallaxImageStyle = useAnimatedStyle(() => { - // Use dampedScrollY instead of direct scrollY for smoother effect - return { - width: '100%', - height: '120%', // Increase height for more movement range - top: '-10%', // Start image slightly higher to allow more upward movement - transform: [ - { - translateY: interpolate( - dampedScrollY.value, - [0, 100, 300], - [20, -20, -60], // Start with a lower position, then move up - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - dampedScrollY.value, - [0, 150, 300], - [1.1, 1.02, 0.95], // More dramatic scale changes - Extrapolate.CLAMP - ) - } - ], - }; - }); - - // Add animated style for floating header - const headerAnimatedStyle = useAnimatedStyle(() => ({ - opacity: headerOpacity.value, - transform: [ - { translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) } - ] - })); - - // Add animated style for header elements - const headerElementsStyle = useAnimatedStyle(() => ({ - opacity: headerElementsOpacity.value, - transform: [{ translateY: headerElementsY.value }] - })); - if (loading) { return ( { /> {/* Floating Header */} - - {Platform.OS === 'ios' ? ( - - - - - - - - {metadata.logo && !logoLoadError ? ( - { - logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - - - - - ) : ( - - - - - - - - - {metadata.logo && !logoLoadError ? ( - { - logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - - - - - )} - {Platform.OS === 'ios' && } - + {/* Hero Section */} - - - {/* Use Animated.Image directly instead of ImageBackground with imageStyle */} - {loadingBanner ? ( - - ) : ( - { - logger.warn(`[MetadataScreen] Banner failed to load: ${bannerImage}`); - // If custom banner fails, fall back to original metadata banner - if (bannerImage !== metadata.banner) { - setBannerImage(metadata.banner || metadata.poster); - } - }} - /> - )} - - - {/* Title */} - - - {metadata.logo && !logoLoadError ? ( - { - logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - {/* Watch Progress */} - - - {/* Genre Tags */} - - - {renderGenres} - - - - {/* Action Buttons */} - - - - - {/* Main Content */} - {/* Meta Info */} - - {metadata.year && ( - {metadata.year} - )} - {metadata.runtime && ( - {metadata.runtime} - )} - {metadata.certification && ( - {metadata.certification} - )} - {metadata.imdbRating && ( - - - {metadata.imdbRating} - - )} - + {/* Metadata Details */} + {/* Add RatingsSection right under the main metadata */} {imdbId && ( @@ -1710,52 +332,6 @@ const MetadataScreen = () => { /> )} - {/* Creator/Director Info */} - - {metadata.directors && metadata.directors.length > 0 && ( - - Director{metadata.directors.length > 1 ? 's' : ''}: - {metadata.directors.join(', ')} - - )} - {metadata.creators && metadata.creators.length > 0 && ( - - Creator{metadata.creators.length > 1 ? 's' : ''}: - {metadata.creators.join(', ')} - - )} - - - {/* Description */} - {metadata.description && ( - - setIsFullDescriptionOpen(!isFullDescriptionOpen)} - activeOpacity={0.7} - > - - {metadata.description} - - - - {isFullDescriptionOpen ? 'Show Less' : 'Show More'} - - - - - - )} - {/* Cast Section */} Date: Sun, 4 May 2025 00:08:33 +0530 Subject: [PATCH 24/38] Enhance HeroSection and useMetadataAssets for improved ID handling and asset fetching This update introduces robust ID parsing and conversion logic in the HeroSection component, allowing for seamless navigation based on TMDB and IMDb IDs. Additionally, the useMetadataAssets hook has been optimized to manage logo and banner fetching more effectively, incorporating source tracking to prevent mixing assets from different sources. The changes improve error handling and logging for better debugging and user experience. The LogoSourceSettings component has also been updated to clarify the source selection process for logos and backgrounds. --- src/components/metadata/HeroSection.tsx | 44 +- src/hooks/useMetadataAssets.ts | 570 ++++++++---------------- src/screens/LogoSourceSettings.tsx | 4 +- src/screens/ShowRatingsScreen.tsx | 4 + 4 files changed, 236 insertions(+), 386 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 74c641b..bd1c1cd 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -16,6 +16,7 @@ import Animated, { } from 'react-native-reanimated'; import { colors } from '../../styles/colors'; import { logger } from '../../utils/logger'; +import { TMDBService } from '../../services/tmdbService'; const { width, height } = Dimensions.get('window'); @@ -110,7 +111,48 @@ const ActionButtons = React.memo(({ { - navigation.navigate('ShowRatings', { showId: id.split(':')[1] }); + let finalTmdbId: number | null = null; + + if (id && id.startsWith('tmdb:')) { + const numericPart = id.split(':')[1]; + const parsedId = parseInt(numericPart, 10); + if (!isNaN(parsedId)) { + finalTmdbId = parsedId; + } else { + logger.error(`[HeroSection] Failed to parse TMDB ID from: ${id}`); + } + } else if (id && id.startsWith('tt')) { + // It's an IMDb ID, convert it + logger.log(`[HeroSection] Detected IMDb ID: ${id}, attempting conversion to TMDB ID.`); + try { + const tmdbService = TMDBService.getInstance(); + const convertedId = await tmdbService.findTMDBIdByIMDB(id); + if (convertedId) { + finalTmdbId = convertedId; + logger.log(`[HeroSection] Successfully converted IMDb ID ${id} to TMDB ID: ${finalTmdbId}`); + } else { + logger.error(`[HeroSection] Could not convert IMDb ID ${id} to TMDB ID.`); + } + } catch (error) { + logger.error(`[HeroSection] Error converting IMDb ID ${id}:`, error); + } + } else if (id) { + // Assume it might be a raw TMDB ID (numeric string) + const parsedId = parseInt(id, 10); + if (!isNaN(parsedId)) { + finalTmdbId = parsedId; + } else { + logger.error(`[HeroSection] Unrecognized ID format or invalid numeric ID: ${id}`); + } + } + + // Navigate if we have a valid TMDB ID + if (finalTmdbId !== null) { + navigation.navigate('ShowRatings', { showId: finalTmdbId }); + } else { + logger.error(`[HeroSection] Could not navigate to ShowRatings, failed to obtain a valid TMDB ID from original id: ${id}`); + // Optionally show an error message to the user here + } }} > diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index 3ee0a94..8aaef10 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -16,6 +16,9 @@ export const useMetadataAssets = ( const [loadingBanner, setLoadingBanner] = useState(false); const forcedBannerRefreshDone = useRef(false); + // Add source tracking to prevent mixing sources + const [bannerSource, setBannerSource] = useState<'tmdb' | 'metahub' | 'default' | null>(null); + // State for logo loading const [logoLoadError, setLogoLoadError] = useState(false); const logoFetchInProgress = useRef(false); @@ -26,475 +29,275 @@ export const useMetadataAssets = ( // For TMDB ID tracking const [foundTmdbId, setFoundTmdbId] = useState(null); - // Effect to force-refresh the logo when it doesn't match the preference - useEffect(() => { - if (metadata?.logo && !forcedLogoRefreshDone.current) { - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - - // Check if logo source doesn't match preference - if ((preferenceIsMetahub && !currentLogoIsMetahub) || - (!preferenceIsMetahub && !currentLogoIsTmdb)) { - logger.log(`[useMetadataAssets] Initial load: Logo source doesn't match preference. Forcing refresh.`); - - // Clear logo to force a new fetch according to preference - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: undefined - })); - } - - // Mark that we've checked this so we don't endlessly loop - forcedLogoRefreshDone.current = true; - } - }, [metadata?.logo, settings.logoSourcePreference, setMetadata]); - - // Reset logo load error when metadata changes - useEffect(() => { - setLogoLoadError(false); - }, [metadata?.logo]); - - // Force refresh logo when logo preference changes - only when preference actually changes - useEffect(() => { - // Reset the counter when preference actually changes - if (logoRefreshCounter.current === 0) { - logoRefreshCounter.current = 1; // Mark that we've started a refresh cycle - - // Only clear logo if we already have metadata with a logo - if (metadata?.logo) { - // Check if the current logo source doesn't match the preference - const currentLogoIsMetahub = isMetahubUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; - - // Only refresh if the current logo source clearly doesn't match the preference - const needsRefresh = (preferenceIsMetahub && currentLogoIsTmdb) || - (preferenceIsTmdb && currentLogoIsMetahub); - - if (needsRefresh) { - logger.log(`[useMetadataAssets] Logo preference (${settings.logoSourcePreference}) doesn't match current logo source, triggering one-time refresh`); - - // Prevent endless refreshes - if (logoRefreshCounter.current < MAX_LOGO_REFRESHES) { - logoRefreshCounter.current++; - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: undefined - })); - } else { - logger.warn(`[useMetadataAssets] Maximum logo refreshes (${MAX_LOGO_REFRESHES}) reached, stopping to prevent loop`); - } - } else { - logger.log(`[useMetadataAssets] Logo source already matches preference (${settings.logoSourcePreference}), no refresh needed`); - logoRefreshCounter.current = 0; // Reset for future changes - } - } - } else { - logoRefreshCounter.current++; - logger.log(`[useMetadataAssets] Logo refresh already in progress (${logoRefreshCounter.current}/${MAX_LOGO_REFRESHES})`); - - // Reset counter after max refreshes to allow future preference changes to work - if (logoRefreshCounter.current >= MAX_LOGO_REFRESHES) { - logger.warn(`[useMetadataAssets] Maximum refreshes reached, resetting counter`); - // After a timeout to avoid immediate re-triggering - setTimeout(() => { - logoRefreshCounter.current = 0; - }, 1000); - } - } - }, [settings.logoSourcePreference, metadata?.logo, setMetadata]); - - // Add effect to track when logo source matches preference + // Force reset when preference changes useEffect(() => { + // Reset all cached data when preference changes + setBannerImage(null); + setBannerSource(null); + forcedBannerRefreshDone.current = false; + forcedLogoRefreshDone.current = false; + logoRefreshCounter.current = 0; + + // Force logo refresh on preference change if (metadata?.logo) { const currentLogoIsMetahub = isMetahubUrl(metadata.logo); const currentLogoIsTmdb = isTmdbUrl(metadata.logo); const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - const preferenceIsTmdb = settings.logoSourcePreference === 'tmdb'; + + // Always clear logo on preference change to force proper refresh + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); - // Check if current logo source matches preference - const logoSourceMatches = (preferenceIsMetahub && currentLogoIsMetahub) || - (preferenceIsTmdb && currentLogoIsTmdb); - - if (logoSourceMatches) { - logger.log(`[useMetadataAssets] Logo source (${currentLogoIsMetahub ? 'Metahub' : 'TMDB'}) now matches preference (${settings.logoSourcePreference}), refresh complete`); - logoRefreshCounter.current = 0; // Reset counter since we've achieved our goal - } + logger.log(`[useMetadataAssets] Preference changed to ${settings.logoSourcePreference}, forcing refresh of all assets`); } - }, [metadata?.logo, settings.logoSourcePreference]); + }, [settings.logoSourcePreference, setMetadata]); + + // Original reset logo load error effect + useEffect(() => { + setLogoLoadError(false); + }, [metadata?.logo]); // Fetch logo immediately for TMDB content - with guard against recursive updates useEffect(() => { // Guard against infinite loops by checking if we're already fetching if (metadata && !metadata.logo && !logoFetchInProgress.current) { - console.log('[useMetadataAssets] Current settings:', JSON.stringify(settings)); - console.log('[useMetadataAssets] Current metadata:', JSON.stringify(metadata, null, 2)); + logoFetchInProgress.current = true; const fetchLogo = async () => { - // Set fetch in progress flag - logoFetchInProgress.current = true; - try { // Get logo source preference from settings - const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set + const logoPreference = settings.logoSourcePreference || 'metahub'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - console.log(`[useMetadataAssets] Using logo preference: ${logoPreference}, TMDB first: ${logoPreference === 'tmdb'}`); - logger.log(`[useMetadataAssets] Logo source preference: ${logoPreference}`); + logger.log(`[useMetadataAssets] Fetching logo with strict preference: ${logoPreference}`); - // First source based on preference - if (logoPreference === 'metahub') { - // Try to get logo from Metahub first + if (logoPreference === 'metahub' && imdbId) { + // Metahub path - direct fetch without HEAD request for speed const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - logger.log(`[useMetadataAssets] Attempting to fetch logo from Metahub for ${imdbId}`); - try { + // Verify Metahub image exists to prevent showing broken images const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { - logger.log(`[useMetadataAssets] Successfully fetched logo from Metahub: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metahubUrl} - `); - // Update metadata with Metahub logo setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: metahubUrl })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if Metahub logo was found + logger.log(`[useMetadataAssets] Set Metahub logo: ${metahubUrl}`); } else { - logger.warn(`[useMetadataAssets] Metahub logo request failed with status ${response.status}`); + logger.warn(`[useMetadataAssets] Metahub logo not found for ${imdbId}`); } - } catch (metahubError) { - logger.warn(`[useMetadataAssets] Failed to fetch logo from Metahub:`, metahubError); + } catch (error) { + logger.error(`[useMetadataAssets] Error checking Metahub logo:`, error); } + } else if (logoPreference === 'tmdb') { + // TMDB path - optimized flow + let tmdbId: string | null = null; + let contentType = type === 'series' ? 'tv' : 'movie'; - // If Metahub fails, try TMDB as fallback + // Extract or find TMDB ID in one step if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; - const tmdbType = type === 'series' ? 'tv' : 'movie'; - - logger.log(`[useMetadataAssets] Attempting to fetch logo from TMDB as fallback for ${tmdbType} (ID: ${tmdbId})`); - - const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); - - if (logoUrl) { - logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB: - - Content Type: ${tmdbType} - - TMDB ID: ${tmdbId} - - Logo URL: ${logoUrl} - `); - - // Update metadata with TMDB logo - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: logoUrl - })); - - // Clear fetch in progress flag when done - logoFetchInProgress.current = false; - return; // Exit if TMDB logo was found - } else { - // If both Metahub and TMDB fail, use the title as text instead of a logo - logger.warn(`[useMetadataAssets] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); - - // Leave logo as null/undefined to trigger fallback to text - } - } - } else { // TMDB first - let tmdbLogoUrl: string | null = null; - - // 1. Attempt to fetch TMDB logo - if (id.startsWith('tmdb:')) { - const tmdbId = id.split(':')[1]; - const tmdbType = type === 'series' ? 'tv' : 'movie'; - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - - logger.log(`[useMetadataAssets] Attempting to fetch logo from TMDB for ${tmdbType} (ID: ${tmdbId}, preferred language: ${preferredLanguage})`); - try { - const tmdbService = TMDBService.getInstance(); - tmdbLogoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); - - if (tmdbLogoUrl) { - logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB: ${tmdbLogoUrl}`); - } else { - logger.warn(`[useMetadataAssets] No logo found from TMDB for ${type} (ID: ${tmdbId})`); - } - } catch (error) { - logger.error(`[useMetadataAssets] Error fetching TMDB logo for ID ${tmdbId}:`, error); - } + tmdbId = id.split(':')[1]; } else if (imdbId) { - // If we have IMDB ID but no direct TMDB ID, try to find TMDB ID - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - logger.log(`[useMetadataAssets] Content has IMDB ID (${imdbId}), looking up TMDB ID for TMDB logo, preferred language: ${preferredLanguage}`); + // Only look up TMDB ID if we don't already have it try { const tmdbService = TMDBService.getInstance(); - const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); - - if (foundTmdbId) { - logger.log(`[useMetadataAssets] Found TMDB ID ${foundTmdbId} for IMDB ID ${imdbId}`); - setFoundTmdbId(String(foundTmdbId)); // Save for banner fetching - - tmdbLogoUrl = await tmdbService.getContentLogo(type === 'series' ? 'tv' : 'movie', foundTmdbId.toString(), preferredLanguage); - - if (tmdbLogoUrl) { - logger.log(`[useMetadataAssets] Successfully fetched logo from TMDB via IMDB lookup: ${tmdbLogoUrl}`); - } else { - logger.warn(`[useMetadataAssets] No logo found from TMDB via IMDB lookup for ${type} (IMDB: ${imdbId})`); - } - } else { - logger.warn(`[useMetadataAssets] Could not find TMDB ID for IMDB ID ${imdbId}`); + const foundId = await tmdbService.findTMDBIdByIMDB(imdbId); + if (foundId) { + tmdbId = String(foundId); + setFoundTmdbId(tmdbId); // Save for banner fetching } } catch (error) { - logger.error(`[useMetadataAssets] Error finding TMDB ID or fetching logo for IMDB ID ${imdbId}:`, error); + logger.error(`[useMetadataAssets] Error finding TMDB ID:`, error); } } - // 2. If TMDB logo was fetched successfully, update and return - if (tmdbLogoUrl) { - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: tmdbLogoUrl - })); - logoFetchInProgress.current = false; - return; - } - - // 3. If TMDB failed, try Metahub as fallback - logger.log(`[useMetadataAssets] TMDB logo fetch failed or not applicable. Attempting Metahub fallback.`); - if (imdbId) { - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - logger.log(`[useMetadataAssets] Attempting to fetch logo from Metahub as fallback for ${imdbId}`); - + if (tmdbId) { try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - logger.log(`[useMetadataAssets] Successfully fetched fallback logo from Metahub: ${metahubUrl}`); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: metahubUrl })); - } else { - logger.warn(`[useMetadataAssets] Metahub fallback failed. Using title text.`); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + // Direct fetch - avoid multiple service calls + const tmdbService = TMDBService.getInstance(); + const logoUrl = await tmdbService.getContentLogo(contentType as 'tv' | 'movie', tmdbId, preferredLanguage); + + if (logoUrl) { + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: logoUrl + })); + logger.log(`[useMetadataAssets] Set TMDB logo: ${logoUrl}`); } - } catch (metahubError) { - logger.warn(`[useMetadataAssets] Failed to fetch fallback logo from Metahub:`, metahubError); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } catch (error) { + logger.error(`[useMetadataAssets] Error fetching TMDB logo:`, error); } - } else { - // No IMDB ID for Metahub fallback - logger.warn(`[useMetadataAssets] No IMDB ID for Metahub fallback. Using title text.`); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); } } } catch (error) { - logger.error('[useMetadataAssets] Failed to fetch logo from all sources:', { - error, - contentId: id, - contentType: type - }); - // Fallback to text on general error - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + logger.error(`[useMetadataAssets] Error in fetchLogo:`, error); } finally { - // Clear fetch in progress flag when done logoFetchInProgress.current = false; } }; + // Execute fetch without awaiting fetchLogo(); - } else if (logoFetchInProgress.current) { - console.log('[useMetadataAssets] Logo fetch already in progress, skipping'); - } else if (metadata?.logo) { - logger.log(`[useMetadataAssets] Using existing logo from metadata: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metadata.logo} - - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} - `); } }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); - // Fetch banner image based on logo source preference + // Fetch banner image based on logo source preference - optimized version useEffect(() => { + // Skip if no metadata or already completed with the correct source + if (!metadata) return; + + // Check if we need to refresh the banner based on source + const currentPreference = settings.logoSourcePreference || 'metahub'; + if (bannerSource === currentPreference && forcedBannerRefreshDone.current) { + return; // Already have the correct source, no need to refresh + } + const fetchBanner = async () => { - if (metadata) { setLoadingBanner(true); - - // Clear the banner initially when starting a preference-driven fetch - setBannerImage(null); - - let finalBanner: string | null = metadata.banner || metadata.poster; // Default fallback - const preference = settings.logoSourcePreference || 'metahub'; + setBannerImage(null); // Clear existing banner to prevent mixed sources + + try { + // Extract all possible IDs at once const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - const apiKey = '439c478a771f35c05022f9feabcca01c'; // Re-using API key + const contentType = type === 'series' ? 'tv' : 'movie'; - // Extract IDs - let currentTmdbId = null; + // Get TMDB ID once + let tmdbId = null; if (id.startsWith('tmdb:')) { - currentTmdbId = id.split(':')[1]; + tmdbId = id.split(':')[1]; } else if (foundTmdbId) { - currentTmdbId = foundTmdbId; + tmdbId = foundTmdbId; } else if ((metadata as any).tmdbId) { - currentTmdbId = (metadata as any).tmdbId; + tmdbId = (metadata as any).tmdbId; } - const currentImdbId = imdbId; - const contentType = type === 'series' ? 'tv' : 'movie'; + // Default fallback to use if nothing else works + let finalBanner: string | null = null; + let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; - logger.log(`[useMetadataAssets] Fetching banner with preference: ${preference}, language: ${preferredLanguage}, TMDB ID: ${currentTmdbId}, IMDB ID: ${currentImdbId}`); - - try { - if (preference === 'tmdb') { - // 1. Try TMDB first - let tmdbBannerUrl: string | null = null; - if (currentTmdbId) { - logger.log(`[useMetadataAssets] Attempting TMDB banner fetch with ID: ${currentTmdbId}`); - try { + if (currentPreference === 'tmdb' && tmdbId) { + // TMDB direct path const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}&include_image_language=${preferredLanguage},en,null`); - const imagesData = await response.json(); - - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - // Try to find backdrop in preferred language first - let backdropPath = null; - - if (preferredLanguage !== 'en') { - const preferredBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === preferredLanguage); - if (preferredBackdrop) { - backdropPath = preferredBackdrop.file_path; - logger.log(`[useMetadataAssets] Found ${preferredLanguage} backdrop for ID: ${currentTmdbId}`); - } - } - - // Fall back to English backdrop - if (!backdropPath) { - const englishBackdrop = imagesData.backdrops.find((backdrop: any) => backdrop.iso_639_1 === 'en'); - if (englishBackdrop) { - backdropPath = englishBackdrop.file_path; - logger.log(`[useMetadataAssets] Found English backdrop for ID: ${currentTmdbId}`); - } else { - // Last resort: use the first backdrop - backdropPath = imagesData.backdrops[0].file_path; - logger.log(`[useMetadataAssets] Using first available backdrop for ID: ${currentTmdbId}`); - } - } - - tmdbBannerUrl = `https://image.tmdb.org/t/p/original${backdropPath}`; - logger.log(`[useMetadataAssets] Found TMDB banner via images endpoint: ${tmdbBannerUrl}`); - } else { - // Add log for when no backdrops are found - logger.warn(`[useMetadataAssets] TMDB API successful, but no backdrops found for ID: ${currentTmdbId}`); - } - } catch (err) { - logger.error(`[useMetadataAssets] Error fetching TMDB banner via images endpoint:`, err); - } - } else { - // Add log for when no TMDB ID is available - logger.warn(`[useMetadataAssets] No TMDB ID available to fetch TMDB banner.`); - } - - if (tmdbBannerUrl) { - // TMDB SUCCESS: Set banner and EXIT - finalBanner = tmdbBannerUrl; - logger.log(`[useMetadataAssets] Setting final banner to TMDB source: ${finalBanner}`); - setBannerImage(finalBanner); - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; - return; // <-- Exit here, don't attempt fallback - } else { - // TMDB FAILED: Proceed to Metahub fallback - logger.log(`[useMetadataAssets] TMDB banner failed, trying Metahub fallback.`); - if (currentImdbId) { - const metahubBannerUrl = `https://images.metahub.space/background/medium/${currentImdbId}/img`; - try { - const metahubResponse = await fetch(metahubBannerUrl, { method: 'HEAD' }); - if (metahubResponse.ok) { - finalBanner = metahubBannerUrl; - logger.log(`[useMetadataAssets] Found Metahub banner as fallback: ${finalBanner}`); - } - } catch (err) { - logger.error(`[useMetadataAssets] Error fetching Metahub fallback banner:`, err); - } - } - } - } else { // Preference is Metahub - // 1. Try Metahub first - let metahubBannerUrl: string | null = null; - if (currentImdbId) { - const url = `https://images.metahub.space/background/medium/${currentImdbId}/img`; - try { - const metahubResponse = await fetch(url, { method: 'HEAD' }); - if (metahubResponse.ok) { - metahubBannerUrl = url; - logger.log(`[useMetadataAssets] Found Metahub banner: ${metahubBannerUrl}`); - } - } catch (err) { - logger.error(`[useMetadataAssets] Error fetching Metahub banner:`, err); - } - } - - if (metahubBannerUrl) { - // METAHUB SUCCESS: Set banner and EXIT - finalBanner = metahubBannerUrl; - logger.log(`[useMetadataAssets] Setting final banner to Metahub source: ${finalBanner}`); - setBannerImage(finalBanner); - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; - return; // <-- Exit here, don't attempt fallback - } else { - // METAHUB FAILED: Proceed to TMDB fallback - logger.log(`[useMetadataAssets] Metahub banner failed, trying TMDB fallback.`); - if (currentTmdbId) { - try { - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${currentTmdbId}/images?api_key=${apiKey}`); - const imagesData = await response.json(); - - if (imagesData.backdrops && imagesData.backdrops.length > 0) { - const backdropPath = imagesData.backdrops[0].file_path; - finalBanner = `https://image.tmdb.org/t/p/original${backdropPath}`; - logger.log(`[useMetadataAssets] Found TMDB banner as fallback: ${finalBanner}`); - } - } catch (err) { - logger.error(`[useMetadataAssets] Error fetching TMDB fallback banner:`, err); - } - } - } - } - // Set the final determined banner (could be fallback or initial default) + try { + // Use TMDBService instead of direct fetch with hardcoded API key + const tmdbService = TMDBService.getInstance(); + logger.log(`[useMetadataAssets] Fetching TMDB details for ${endpoint}/${tmdbId}`); + + try { + // Get details with backdrop path using TMDBService + let details; + let images = null; + + // Step 1: Get basic details + if (endpoint === 'movie') { + details = await tmdbService.getMovieDetails(tmdbId); + + // Step 2: Get images separately if details succeeded + if (details) { + try { + // Use getMovieImages to get image data - this returns a logo URL but we need more + await tmdbService.getMovieImages(tmdbId, preferredLanguage); + + // We'll use the backdrop from the details + logger.log(`[useMetadataAssets] Got movie details for ${tmdbId}`); + } catch (imageError) { + logger.warn(`[useMetadataAssets] Could not get movie images: ${imageError}`); + } + } + } else { + details = await tmdbService.getTVShowDetails(Number(tmdbId)); + + // Step 2: Get images separately if details succeeded + if (details) { + try { + // Use getTvShowImages to get image data - this returns a logo URL but we need more + await tmdbService.getTvShowImages(tmdbId, preferredLanguage); + + // We'll use the backdrop from the details + logger.log(`[useMetadataAssets] Got TV details for ${tmdbId}`); + } catch (imageError) { + logger.warn(`[useMetadataAssets] Could not get TV images: ${imageError}`); + } + } + } + + // Check if we have a backdrop path from details + if (details && details.backdrop_path) { + finalBanner = tmdbService.getImageUrl(details.backdrop_path); + bannerSourceType = 'tmdb'; + logger.log(`[useMetadataAssets] Using TMDB backdrop from details: ${finalBanner}`); + } + // If no backdrop, try poster as fallback + else if (details && details.poster_path) { + logger.warn(`[useMetadataAssets] No backdrop available, using poster as fallback`); + finalBanner = tmdbService.getImageUrl(details.poster_path); + bannerSourceType = 'tmdb'; + } + else { + logger.warn(`[useMetadataAssets] No backdrop or poster found for ${endpoint}/${tmdbId}`); + } + } catch (innerErr) { + logger.error(`[useMetadataAssets] Error fetching TMDB details/images:`, innerErr); + } + } catch (err) { + logger.error(`[useMetadataAssets] TMDB service initialization error:`, err); + } + } else if (currentPreference === 'metahub' && imdbId) { + // Metahub path - verify it exists to prevent broken images + const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; + + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + finalBanner = metahubUrl; + bannerSourceType = 'metahub'; + logger.log(`[useMetadataAssets] Using Metahub banner: ${finalBanner}`); + } else { + logger.warn(`[useMetadataAssets] Metahub banner not found, using default`); + } + } catch (error) { + logger.error(`[useMetadataAssets] Error checking Metahub banner:`, error); + } + } + + // If no source-specific banner was found, use default + if (!finalBanner) { + finalBanner = metadata.banner || metadata.poster; + bannerSourceType = 'default'; + logger.log(`[useMetadataAssets] Using default banner: ${finalBanner}`); + } + + // Set banner image once at the end setBannerImage(finalBanner); - logger.log(`[useMetadataAssets] Final banner set after fallbacks (if any): ${finalBanner}`); + setBannerSource(bannerSourceType); } catch (error) { - logger.error(`[useMetadataAssets] General error fetching banner:`, error); - // Fallback to initial banner on general error + logger.error(`[useMetadataAssets] Banner fetch error:`, error); + // Use default banner if error occurred setBannerImage(metadata.banner || metadata.poster); + setBannerSource('default'); } finally { - // Only set loading to false here if we didn't exit early setLoadingBanner(false); - forcedBannerRefreshDone.current = true; // Mark refresh as done - } + forcedBannerRefreshDone.current = true; } }; - // Only run fetchBanner if metadata exists and preference/content might have changed - // The dependencies array handles triggering this effect fetchBanner(); + }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId, bannerSource]); - }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId]); - - // Reset forced refresh when preference changes + // Original reset forced refresh effect useEffect(() => { if (forcedBannerRefreshDone.current) { logger.log(`[useMetadataAssets] Logo preference changed, resetting banner refresh flag`); forcedBannerRefreshDone.current = false; // Clear the banner image immediately to prevent showing the wrong source briefly setBannerImage(null); + setBannerSource(null); // This will trigger the banner fetch effect to run again } }, [settings.logoSourcePreference]); @@ -506,5 +309,6 @@ export const useMetadataAssets = ( foundTmdbId, setLogoLoadError, setBannerImage, + bannerSource, // Export banner source for debugging }; }; \ No newline at end of file diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 20f5826..a5e09b0 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -762,7 +762,7 @@ const LogoSourceSettings = () => { {/* Description */} - Choose the primary source for content logos and backgrounds. + Choose the primary source for content logos and backgrounds. The selected source will be used exclusively. @@ -903,7 +903,7 @@ const LogoSourceSettings = () => { {/* Additional Info */} - Unavailable logos will fall back to the alternate source, or display text if none found. + The app will use only the selected source for logos and backgrounds. If no image is available from your chosen source, a text fallback will be used. diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 04e8e1b..051c8a4 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -301,6 +301,10 @@ const ShowRatingsScreen = ({ route }: Props) => { const fetchShowData = async () => { try { const tmdb = TMDBService.getInstance(); + + // Log the showId being used + logger.log(`[ShowRatingsScreen] Fetching show details for ID: ${showId}`); + const showData = await tmdb.getTVShowDetails(showId); if (showData) { setShow(showData); From 190c1a73710b5b9822772170d4fb2183921eb52a Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 01:03:38 +0530 Subject: [PATCH 25/38] Refactor FeaturedContent and RatingsSection components to enhance logging and error handling This update removes unnecessary logging statements from the FeaturedContent and RatingsSection components, streamlining the code for better readability. Additionally, it improves error handling by ensuring fallback logos are set appropriately when fetching fails. The useMetadataAssets hook is also optimized for logo and banner fetching, incorporating clearer conditions for asset retrieval based on user preferences. Overall, these changes enhance the maintainability and performance of the components. --- src/components/home/FeaturedContent.tsx | 27 +- src/components/metadata/RatingsSection.tsx | 15 - src/hooks/useMetadataAssets.ts | 314 +++++++++++++++------ src/services/tmdbService.ts | 69 ----- 4 files changed, 230 insertions(+), 195 deletions(-) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 5da195d..b155ae5 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -91,22 +91,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat try { const isValid = await isValidMetahubLogo(url); if (!isValid) { - console.warn(`[FeaturedContent] Metahub logo validation failed: ${url}`); return false; } } catch (validationError) { // If validation fails, still try to load the image - console.warn(`[FeaturedContent] Logo validation error, will try to load anyway: ${url}`, validationError); } } // Always attempt to prefetch the image regardless of format validation await ExpoImage.prefetch(url); imageCache[url] = true; - console.log(`[FeaturedContent] Successfully preloaded image: ${url}`); return true; } catch (error) { - console.error('[FeaturedContent] Error preloading image:', error); return false; } }; @@ -146,8 +142,6 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } - logger.log(`[FeaturedContent] Fetching logo with preference: ${logoPreference}, language: ${preferredLanguage}, ID: ${contentId}`); - // Extract IMDB ID if available let imdbId = null; if (featuredContent.id.startsWith('tt')) { @@ -172,13 +166,12 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat try { const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { - logger.log(`[FeaturedContent] Using Metahub logo: ${metahubUrl}`); setLogoUrl(metahubUrl); logoFetchInProgress.current = false; return; // Exit if Metahub logo was found } } catch (error) { - logger.warn(`[FeaturedContent] Failed to fetch Metahub logo:`, error); + // Removed logger.warn } // Fall back to TMDB if Metahub fails and we have a TMDB ID @@ -189,14 +182,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); if (logoUrl) { - logger.log(`[FeaturedContent] Using fallback TMDB logo (${preferredLanguage}): ${logoUrl}`); setLogoUrl(logoUrl); } else if (currentLogo) { // If TMDB fails too, use existing logo if any setLogoUrl(currentLogo); } } catch (error) { - logger.error('[FeaturedContent] Error fetching TMDB logo:', error); + // Removed logger.error if (currentLogo) setLogoUrl(currentLogo); } } else if (currentLogo) { @@ -212,13 +204,12 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); if (logoUrl) { - logger.log(`[FeaturedContent] Using TMDB logo (${preferredLanguage}): ${logoUrl}`); setLogoUrl(logoUrl); logoFetchInProgress.current = false; return; // Exit if TMDB logo was found } } catch (error) { - logger.error('[FeaturedContent] Error fetching TMDB logo:', error); + // Removed logger.error } } else if (imdbId) { // If we have IMDB ID but no TMDB ID, try to find TMDB ID @@ -231,14 +222,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString(), preferredLanguage); if (logoUrl) { - logger.log(`[FeaturedContent] Using TMDB logo (${preferredLanguage}) via IMDB lookup: ${logoUrl}`); setLogoUrl(logoUrl); logoFetchInProgress.current = false; return; // Exit if TMDB logo was found } } } catch (error) { - logger.error('[FeaturedContent] Error finding TMDB ID from IMDB:', error); + // Removed logger.error } } @@ -249,14 +239,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat try { const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { - logger.log(`[FeaturedContent] Using fallback Metahub logo: ${metahubUrl}`); setLogoUrl(metahubUrl); } else if (currentLogo) { // If Metahub fails too, use existing logo if any setLogoUrl(currentLogo); } } catch (error) { - logger.warn(`[FeaturedContent] Failed to fetch fallback Metahub logo:`, error); + // Removed logger.warn if (currentLogo) setLogoUrl(currentLogo); } } else if (currentLogo) { @@ -265,10 +254,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } } catch (error) { - logger.error('[FeaturedContent] Error fetching logo:', error); - if (featuredContent?.logo) setLogoUrl(featuredContent.logo); + // Removed logger.error + // Optionally set a fallback logo or handle the error state + setLogoUrl(featuredContent.logo ?? null); // Fallback to initial logo or null } finally { - // Clear fetch in progress flag logoFetchInProgress.current = false; } }; diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index 208cb8b..a52076e 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -2,9 +2,6 @@ import React, { useEffect, useState, useRef } from 'react'; import { View, Text, StyleSheet, ActivityIndicator, Image, Animated } from 'react-native'; import { colors } from '../../styles/colors'; import { useMDBListRatings } from '../../hooks/useMDBListRatings'; -import { logger } from '../../utils/logger'; -import { MaterialIcons } from '@expo/vector-icons'; -import { FontAwesome } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../screens/MDBListSettingsScreen'; @@ -67,9 +64,7 @@ export const RatingsSection: React.FC = ({ imdbId, type }) try { const enabled = await isMDBListEnabled(); setIsMDBEnabled(enabled); - logger.log('[RatingsSection] MDBList enabled:', enabled); } catch (error) { - logger.error('[RatingsSection] Failed to check if MDBList is enabled:', error); setIsMDBEnabled(true); // Default to enabled } }; @@ -88,26 +83,21 @@ export const RatingsSection: React.FC = ({ imdbId, type }) setEnabledProviders(defaultSettings); } } catch (error) { - logger.error('[RatingsSection] Failed to load provider settings:', error); } }; useEffect(() => { - logger.log(`[RatingsSection] Mounted for ${type}:`, imdbId); return () => { - logger.log(`[RatingsSection] Unmounted for ${type}:`, imdbId); }; }, [imdbId, type]); useEffect(() => { if (error) { - logger.error('[RatingsSection] Error state:', error); } }, [error]); useEffect(() => { if (ratings) { - logger.log('[RatingsSection] Received ratings:', ratings); } }, [ratings]); @@ -124,12 +114,10 @@ export const RatingsSection: React.FC = ({ imdbId, type }) // If MDBList is disabled, don't show anything if (!isMDBEnabled) { - logger.log('[RatingsSection] MDBList is disabled, not showing ratings'); return null; } if (loading) { - logger.log('[RatingsSection] Loading state'); return ( @@ -138,12 +126,9 @@ export const RatingsSection: React.FC = ({ imdbId, type }) } if (error || !ratings || Object.keys(ratings).length === 0) { - logger.log('[RatingsSection] No ratings to display'); return null; } - logger.log('[RatingsSection] Rendering ratings:', Object.keys(ratings).length); - // Define the order and icons/colors for the ratings const ratingConfig = { imdb: { diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index 8aaef10..0b44bf0 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -61,47 +61,83 @@ export const useMetadataAssets = ( // Fetch logo immediately for TMDB content - with guard against recursive updates useEffect(() => { + const logoPreference = settings.logoSourcePreference || 'metahub'; + const currentLogoUrl = metadata?.logo; + let shouldFetchLogo = false; + + // Determine if we need to fetch a new logo + if (!currentLogoUrl) { + logger.log(`[useMetadataAssets:Logo] Condition check: No current logo exists. Proceeding with fetch.`); + shouldFetchLogo = true; + } else { + const isCurrentLogoMetahub = isMetahubUrl(currentLogoUrl); + const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl); + + if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) { + logger.log(`[useMetadataAssets:Logo] Condition check: Preference is TMDB, but current logo is not TMDB (${currentLogoUrl}). Proceeding with fetch.`); + shouldFetchLogo = true; + } else if (logoPreference === 'metahub' && !isCurrentLogoMetahub) { + logger.log(`[useMetadataAssets:Logo] Condition check: Preference is Metahub, but current logo is not Metahub (${currentLogoUrl}). Proceeding with fetch.`); + shouldFetchLogo = true; + } else { + logger.log(`[useMetadataAssets:Logo] Condition check: Skipping fetch. Preference (${logoPreference}) matches existing logo source. Current logo: ${currentLogoUrl}`); + } + } + // Guard against infinite loops by checking if we're already fetching - if (metadata && !metadata.logo && !logoFetchInProgress.current) { + if (shouldFetchLogo && !logoFetchInProgress.current) { + logger.log(`[useMetadataAssets:Logo] Starting logo fetch. Current metadata logo: ${currentLogoUrl}`); logoFetchInProgress.current = true; const fetchLogo = async () => { + // Clear existing logo before fetching new one to avoid briefly showing wrong logo + // Only do this if we decided to fetch because of a mismatch or non-existence + if (shouldFetchLogo) { + logger.log(`[useMetadataAssets:Logo] Clearing existing logo in metadata state before fetch.`); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + } + try { // Get logo source preference from settings - const logoPreference = settings.logoSourcePreference || 'metahub'; + // const logoPreference = settings.logoSourcePreference || 'metahub'; // Already defined above const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - logger.log(`[useMetadataAssets] Fetching logo with strict preference: ${logoPreference}`); + logger.log(`[useMetadataAssets:Logo] Fetching logo. Preference: ${logoPreference}, Language: ${preferredLanguage}, IMDB ID: ${imdbId}`); if (logoPreference === 'metahub' && imdbId) { // Metahub path - direct fetch without HEAD request for speed + logger.log(`[useMetadataAssets:Logo] Preference is Metahub. Attempting Metahub fetch for ${imdbId}.`); const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; try { // Verify Metahub image exists to prevent showing broken images + logger.log(`[useMetadataAssets:Logo] Checking Metahub logo existence: ${metahubUrl}`); const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { // Update metadata with Metahub logo - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: metahubUrl - })); - logger.log(`[useMetadataAssets] Set Metahub logo: ${metahubUrl}`); + logger.log(`[useMetadataAssets:Logo] Metahub logo found. Updating metadata state.`); + setMetadata((prevMetadata: any) => { + logger.log(`[useMetadataAssets:Logo] setMetadata called with Metahub logo: ${metahubUrl}`); + return { ...prevMetadata!, logo: metahubUrl }; + }); } else { - logger.warn(`[useMetadataAssets] Metahub logo not found for ${imdbId}`); + logger.warn(`[useMetadataAssets:Logo] Metahub logo HEAD request failed with status ${response.status} for ${imdbId}`); } } catch (error) { - logger.error(`[useMetadataAssets] Error checking Metahub logo:`, error); + logger.error(`[useMetadataAssets:Logo] Error checking Metahub logo:`, error); } } else if (logoPreference === 'tmdb') { // TMDB path - optimized flow + logger.log(`[useMetadataAssets:Logo] Preference is TMDB. Attempting TMDB fetch.`); let tmdbId: string | null = null; let contentType = type === 'series' ? 'tv' : 'movie'; // Extract or find TMDB ID in one step if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; + logger.log(`[useMetadataAssets:Logo] Extracted TMDB ID from route ID: ${tmdbId}`); } else if (imdbId) { + logger.log(`[useMetadataAssets:Logo] Attempting to find TMDB ID from IMDB ID: ${imdbId}`); // Only look up TMDB ID if we don't already have it try { const tmdbService = TMDBService.getInstance(); @@ -109,33 +145,46 @@ export const useMetadataAssets = ( if (foundId) { tmdbId = String(foundId); setFoundTmdbId(tmdbId); // Save for banner fetching + logger.log(`[useMetadataAssets:Logo] Found TMDB ID: ${tmdbId}`); + } else { + logger.warn(`[useMetadataAssets:Logo] Could not find TMDB ID for IMDB ID: ${imdbId}`); } } catch (error) { - logger.error(`[useMetadataAssets] Error finding TMDB ID:`, error); + logger.error(`[useMetadataAssets:Logo] Error finding TMDB ID:`, error); } + } else { + logger.warn(`[useMetadataAssets:Logo] Cannot attempt TMDB fetch: No TMDB ID in route and no IMDB ID provided.`); } if (tmdbId) { try { // Direct fetch - avoid multiple service calls + logger.log(`[useMetadataAssets:Logo] Fetching TMDB logo for ${contentType} ID: ${tmdbId}, Language: ${preferredLanguage}`); const tmdbService = TMDBService.getInstance(); const logoUrl = await tmdbService.getContentLogo(contentType as 'tv' | 'movie', tmdbId, preferredLanguage); if (logoUrl) { - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: logoUrl - })); - logger.log(`[useMetadataAssets] Set TMDB logo: ${logoUrl}`); + logger.log(`[useMetadataAssets:Logo] TMDB logo found. Updating metadata state.`); + setMetadata((prevMetadata: any) => { + logger.log(`[useMetadataAssets:Logo] setMetadata called with TMDB logo: ${logoUrl}`); + return { ...prevMetadata!, logo: logoUrl }; + }); + } else { + logger.warn(`[useMetadataAssets:Logo] No TMDB logo found for ${contentType}/${tmdbId}.`); } } catch (error) { - logger.error(`[useMetadataAssets] Error fetching TMDB logo:`, error); + logger.error(`[useMetadataAssets:Logo] Error fetching TMDB logo:`, error); } + } else { + logger.warn(`[useMetadataAssets:Logo] Skipping TMDB logo fetch as no TMDB ID was determined.`); } + } else { + logger.log(`[useMetadataAssets:Logo] Preference not Metahub and no IMDB ID, or preference not TMDB. No logo fetched.`); } } catch (error) { - logger.error(`[useMetadataAssets] Error in fetchLogo:`, error); + logger.error(`[useMetadataAssets:Logo] Error in outer fetchLogo try block:`, error); } finally { + logger.log(`[useMetadataAssets:Logo] Finished logo fetch attempt.`); logoFetchInProgress.current = false; } }; @@ -143,23 +192,38 @@ export const useMetadataAssets = ( // Execute fetch without awaiting fetchLogo(); } - }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference]); + // Add logging for when fetch is skipped due to already fetching + else if (shouldFetchLogo && logoFetchInProgress.current) { + logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`); + } + }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Added tmdbLanguagePreference dependency // Fetch banner image based on logo source preference - optimized version useEffect(() => { // Skip if no metadata or already completed with the correct source - if (!metadata) return; + if (!metadata) { + logger.log(`[useMetadataAssets:Banner] Skipping banner fetch: No metadata.`); + return; + } // Check if we need to refresh the banner based on source const currentPreference = settings.logoSourcePreference || 'metahub'; + logger.log(`[useMetadataAssets:Banner] Checking banner fetch. Preference: ${currentPreference}, Current Banner Source: ${bannerSource}, Forced Refresh Done: ${forcedBannerRefreshDone.current}`); + if (bannerSource === currentPreference && forcedBannerRefreshDone.current) { + logger.log(`[useMetadataAssets:Banner] Skipping fetch: Banner already loaded with correct source (${currentPreference}).`); return; // Already have the correct source, no need to refresh } const fetchBanner = async () => { + logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`); setLoadingBanner(true); setBannerImage(null); // Clear existing banner to prevent mixed sources + setBannerSource(null); // Clear source tracking + let finalBanner: string | null = null; + let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; + try { // Extract all possible IDs at once const preferredLanguage = settings.tmdbLanguagePreference || 'en'; @@ -173,20 +237,36 @@ export const useMetadataAssets = ( tmdbId = foundTmdbId; } else if ((metadata as any).tmdbId) { tmdbId = (metadata as any).tmdbId; + } else if (imdbId) { + // Last attempt: Look up TMDB ID if we haven't yet + logger.log(`[useMetadataAssets:Banner] Attempting TMDB ID lookup from IMDB ID: ${imdbId} for banner fetch.`); + try { + const tmdbService = TMDBService.getInstance(); + const foundId = await tmdbService.findTMDBIdByIMDB(imdbId); + if (foundId) { + tmdbId = String(foundId); + logger.log(`[useMetadataAssets:Banner] Found TMDB ID: ${tmdbId}`); + } else { + logger.warn(`[useMetadataAssets:Banner] Could not find TMDB ID for IMDB ID: ${imdbId}`); + } + } catch (lookupError) { + logger.error(`[useMetadataAssets:Banner] Error looking up TMDB ID:`, lookupError); + } } + logger.log(`[useMetadataAssets:Banner] Determined TMDB ID for banner fetch: ${tmdbId}`); + // Default fallback to use if nothing else works - let finalBanner: string | null = null; - let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; if (currentPreference === 'tmdb' && tmdbId) { // TMDB direct path - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + logger.log(`[useMetadataAssets:Banner] Preference is TMDB. Attempting TMDB banner fetch for ${contentType}/${tmdbId}.`); + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; try { // Use TMDBService instead of direct fetch with hardcoded API key const tmdbService = TMDBService.getInstance(); - logger.log(`[useMetadataAssets] Fetching TMDB details for ${endpoint}/${tmdbId}`); + logger.log(`[useMetadataAssets:Banner] Fetching TMDB details for ${endpoint}/${tmdbId}`); try { // Get details with backdrop path using TMDBService @@ -196,111 +276,161 @@ export const useMetadataAssets = ( // Step 1: Get basic details if (endpoint === 'movie') { details = await tmdbService.getMovieDetails(tmdbId); + logger.log(`[useMetadataAssets:Banner] TMDB getMovieDetails result:`, details ? `Found backdrop: ${!!details.backdrop_path}, Found poster: ${!!details.poster_path}` : 'null'); - // Step 2: Get images separately if details succeeded - if (details) { - try { - // Use getMovieImages to get image data - this returns a logo URL but we need more - await tmdbService.getMovieImages(tmdbId, preferredLanguage); - - // We'll use the backdrop from the details - logger.log(`[useMetadataAssets] Got movie details for ${tmdbId}`); - } catch (imageError) { - logger.warn(`[useMetadataAssets] Could not get movie images: ${imageError}`); - } - } - } else { + // Step 2: Get images separately if details succeeded (This call might not be needed for banner) + // if (details) { + // try { + // await tmdbService.getMovieImages(tmdbId, preferredLanguage); + // logger.log(`[useMetadataAssets:Banner] Got movie images for ${tmdbId}`); + // } catch (imageError) { + // logger.warn(`[useMetadataAssets:Banner] Could not get movie images: ${imageError}`); + // } + //} + } else { // TV Show details = await tmdbService.getTVShowDetails(Number(tmdbId)); - - // Step 2: Get images separately if details succeeded - if (details) { - try { - // Use getTvShowImages to get image data - this returns a logo URL but we need more - await tmdbService.getTvShowImages(tmdbId, preferredLanguage); - - // We'll use the backdrop from the details - logger.log(`[useMetadataAssets] Got TV details for ${tmdbId}`); - } catch (imageError) { - logger.warn(`[useMetadataAssets] Could not get TV images: ${imageError}`); - } - } + logger.log(`[useMetadataAssets:Banner] TMDB getTVShowDetails result:`, details ? `Found backdrop: ${!!details.backdrop_path}, Found poster: ${!!details.poster_path}` : 'null'); + + // Step 2: Get images separately if details succeeded (This call might not be needed for banner) + // if (details) { + // try { + // await tmdbService.getTvShowImages(tmdbId, preferredLanguage); + // logger.log(`[useMetadataAssets:Banner] Got TV images for ${tmdbId}`); + // } catch (imageError) { + // logger.warn(`[useMetadataAssets:Banner] Could not get TV images: ${imageError}`); + // } + // } } // Check if we have a backdrop path from details if (details && details.backdrop_path) { finalBanner = tmdbService.getImageUrl(details.backdrop_path); bannerSourceType = 'tmdb'; - logger.log(`[useMetadataAssets] Using TMDB backdrop from details: ${finalBanner}`); + logger.log(`[useMetadataAssets:Banner] Using TMDB backdrop from details: ${finalBanner}`); } // If no backdrop, try poster as fallback else if (details && details.poster_path) { - logger.warn(`[useMetadataAssets] No backdrop available, using poster as fallback`); + logger.warn(`[useMetadataAssets:Banner] No TMDB backdrop available, using poster as fallback.`); finalBanner = tmdbService.getImageUrl(details.poster_path); bannerSourceType = 'tmdb'; } else { - logger.warn(`[useMetadataAssets] No backdrop or poster found for ${endpoint}/${tmdbId}`); + logger.warn(`[useMetadataAssets:Banner] No TMDB backdrop or poster found for ${endpoint}/${tmdbId}. TMDB path failed.`); + // Explicitly set finalBanner to null if TMDB fails + finalBanner = null; } } catch (innerErr) { - logger.error(`[useMetadataAssets] Error fetching TMDB details/images:`, innerErr); - } - } catch (err) { - logger.error(`[useMetadataAssets] TMDB service initialization error:`, err); + logger.error(`[useMetadataAssets:Banner] Error fetching TMDB details/images:`, innerErr); + finalBanner = null; // Ensure failure case nullifies banner } + } catch (err) { + logger.error(`[useMetadataAssets:Banner] TMDB service initialization error:`, err); + finalBanner = null; // Ensure failure case nullifies banner + } } else if (currentPreference === 'metahub' && imdbId) { // Metahub path - verify it exists to prevent broken images + logger.log(`[useMetadataAssets:Banner] Preference is Metahub. Attempting Metahub banner fetch for ${imdbId}.`); const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; - try { + try { + logger.log(`[useMetadataAssets:Banner] Checking Metahub banner existence: ${metahubUrl}`); const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { finalBanner = metahubUrl; bannerSourceType = 'metahub'; - logger.log(`[useMetadataAssets] Using Metahub banner: ${finalBanner}`); + logger.log(`[useMetadataAssets:Banner] Metahub banner found: ${finalBanner}`); } else { - logger.warn(`[useMetadataAssets] Metahub banner not found, using default`); + logger.warn(`[useMetadataAssets:Banner] Metahub banner HEAD request failed with status ${response.status}, using default.`); + finalBanner = null; // Ensure fallback if Metahub fails } } catch (error) { - logger.error(`[useMetadataAssets] Error checking Metahub banner:`, error); + logger.error(`[useMetadataAssets:Banner] Error checking Metahub banner:`, error); + finalBanner = null; // Ensure fallback if Metahub errors } + } else { + // This case handles: + // 1. Preference is TMDB but no tmdbId could be found. + // 2. Preference is Metahub but no imdbId was provided. + logger.log(`[useMetadataAssets:Banner] Skipping direct fetch: Preference=${currentPreference}, tmdbId=${tmdbId}, imdbId=${imdbId}. Will rely on default/fallback.`); + finalBanner = null; // Explicitly nullify banner if preference conditions aren't met + } + + // Fallback logic if preferred source failed or wasn't attempted + if (!finalBanner) { + logger.log(`[useMetadataAssets:Banner] Preferred source (${currentPreference}) did not yield a banner. Checking fallbacks.`); + // Fallback 1: Try the *other* source if the preferred one failed + if (currentPreference === 'tmdb' && imdbId) { // If preferred was TMDB, try Metahub + logger.log(`[useMetadataAssets:Banner] Fallback: Trying Metahub for ${imdbId}.`); + const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + finalBanner = metahubUrl; + bannerSourceType = 'metahub'; + logger.log(`[useMetadataAssets:Banner] Fallback Metahub banner found: ${finalBanner}`); + } else { + logger.warn(`[useMetadataAssets:Banner] Fallback Metahub HEAD failed: ${response.status}`); + } + } catch (fallbackError) { + logger.error(`[useMetadataAssets:Banner] Fallback Metahub check error:`, fallbackError); + } + } else if (currentPreference === 'metahub' && tmdbId) { // If preferred was Metahub, try TMDB + logger.log(`[useMetadataAssets:Banner] Fallback: Trying TMDB for ${contentType}/${tmdbId}.`); + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + try { + const tmdbService = TMDBService.getInstance(); + let details = endpoint === 'movie' ? await tmdbService.getMovieDetails(tmdbId) : await tmdbService.getTVShowDetails(Number(tmdbId)); + if (details?.backdrop_path) { + finalBanner = tmdbService.getImageUrl(details.backdrop_path); + bannerSourceType = 'tmdb'; + logger.log(`[useMetadataAssets:Banner] Fallback TMDB banner found (backdrop): ${finalBanner}`); + } else if (details?.poster_path) { + finalBanner = tmdbService.getImageUrl(details.poster_path); + bannerSourceType = 'tmdb'; + logger.log(`[useMetadataAssets:Banner] Fallback TMDB banner found (poster): ${finalBanner}`); + } else { + logger.warn(`[useMetadataAssets:Banner] Fallback TMDB fetch found no backdrop or poster.`); + } + } catch (fallbackError) { + logger.error(`[useMetadataAssets:Banner] Fallback TMDB check error:`, fallbackError); + } + } + + // Fallback 2: Use metadata banner/poster if other source also failed + if (!finalBanner) { + logger.log(`[useMetadataAssets:Banner] Fallback source also failed or not applicable. Using metadata.banner or metadata.poster.`); + finalBanner = metadata?.banner || metadata?.poster || null; + bannerSourceType = 'default'; + if (finalBanner) { + logger.log(`[useMetadataAssets:Banner] Using default banner from metadata: ${finalBanner}`); + } else { + logger.warn(`[useMetadataAssets:Banner] No default banner found in metadata either.`); + } + } } - // If no source-specific banner was found, use default - if (!finalBanner) { - finalBanner = metadata.banner || metadata.poster; - bannerSourceType = 'default'; - logger.log(`[useMetadataAssets] Using default banner: ${finalBanner}`); - } - - // Set banner image once at the end - setBannerImage(finalBanner); - setBannerSource(bannerSourceType); - - } catch (error) { - logger.error(`[useMetadataAssets] Banner fetch error:`, error); - // Use default banner if error occurred - setBannerImage(metadata.banner || metadata.poster); + // Set the final state + logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`); + setBannerImage(finalBanner); + setBannerSource(bannerSourceType); // Track the source of the final image + forcedBannerRefreshDone.current = true; // Mark this cycle as complete + + } catch (error) { + logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error); + // Ensure fallback to default even on outer error + const defaultBanner = metadata?.banner || metadata?.poster || null; + setBannerImage(defaultBanner); setBannerSource('default'); - } finally { - setLoadingBanner(false); - forcedBannerRefreshDone.current = true; + logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); + } finally { + logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`); + setLoadingBanner(false); } }; fetchBanner(); - }, [metadata, id, type, imdbId, settings.logoSourcePreference, foundTmdbId, bannerSource]); - - // Original reset forced refresh effect - useEffect(() => { - if (forcedBannerRefreshDone.current) { - logger.log(`[useMetadataAssets] Logo preference changed, resetting banner refresh flag`); - forcedBannerRefreshDone.current = false; - // Clear the banner image immediately to prevent showing the wrong source briefly - setBannerImage(null); - setBannerSource(null); - // This will trigger the banner fetch effect to run again - } - }, [settings.logoSourcePreference]); + + }, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, setMetadata, foundTmdbId, bannerSource]); // Added bannerSource dependency to re-evaluate if it changes unexpectedly return { bannerImage, diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 5fca12e..405e4f6 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -1,5 +1,4 @@ import axios from 'axios'; -import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; // TMDB API configuration @@ -100,15 +99,12 @@ export class TMDBService { if (this.useCustomKey && savedKey) { this.apiKey = savedKey; - logger.log('Using custom TMDb API key'); } else { this.apiKey = DEFAULT_API_KEY; - logger.log('Using default TMDb API key'); } this.apiKeyLoaded = true; } catch (error) { - logger.error('Failed to load TMDb API key from storage, using default:', error); this.apiKey = DEFAULT_API_KEY; this.apiKeyLoaded = true; } @@ -157,7 +153,6 @@ export class TMDBService { }); return response.data.results; } catch (error) { - logger.error('Failed to search TV show:', error); return []; } } @@ -175,7 +170,6 @@ export class TMDBService { }); return response.data; } catch (error) { - logger.error('Failed to get TV show details:', error); return null; } } @@ -198,7 +192,6 @@ export class TMDBService { ); return response.data; } catch (error) { - logger.error('Failed to get episode external IDs:', error); return null; } } @@ -234,7 +227,6 @@ export class TMDBService { TMDBService.ratingCache.set(cacheKey, rating); return rating; } catch (error) { - logger.error('Failed to get IMDb rating:', error); // Cache the failed result too to prevent repeated failed requests TMDBService.ratingCache.set(cacheKey, null); return null; @@ -289,7 +281,6 @@ export class TMDBService { return season; } catch (error) { - logger.error('Failed to get season details:', error); return null; } } @@ -314,7 +305,6 @@ export class TMDBService { ); return response.data; } catch (error) { - logger.error('Failed to get episode details:', error); return null; } } @@ -333,7 +323,6 @@ export class TMDBService { const tmdbId = await this.findTMDBIdByIMDB(imdbId); return tmdbId; } catch (error) { - logger.error('Failed to extract TMDB ID from Stremio ID:', error); return null; } } @@ -366,7 +355,6 @@ export class TMDBService { return null; } catch (error) { - logger.error('Failed to find TMDB ID by IMDB ID:', error); return null; } } @@ -376,13 +364,11 @@ export class TMDBService { */ getImageUrl(path: string | null, size: 'original' | 'w500' | 'w300' | 'w185' | 'profile' = 'original'): string | null { if (!path) { - logger.warn(`[TMDBService] Cannot construct image URL from null path`); return null; } const baseImageUrl = 'https://image.tmdb.org/t/p/'; const fullUrl = `${baseImageUrl}${size}${path}`; - logger.log(`[TMDBService] Constructed image URL: ${fullUrl}`); return fullUrl; } @@ -411,7 +397,6 @@ export class TMDBService { await Promise.all(seasonPromises); return allEpisodes; } catch (error) { - logger.error('Failed to get all episodes:', error); return {}; } } @@ -472,7 +457,6 @@ export class TMDBService { crew: response.data.crew || [] }; } catch (error) { - logger.error('Failed to fetch credits:', error); return { cast: [], crew: [] }; } } @@ -487,7 +471,6 @@ export class TMDBService { }); return response.data; } catch (error) { - logger.error('Failed to fetch person details:', error); return null; } } @@ -506,14 +489,12 @@ export class TMDBService { ); return response.data; } catch (error) { - logger.error('Failed to get show external IDs:', error); return null; } } async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise { if (!this.apiKey) { - logger.error('TMDB API key not set'); return []; } try { @@ -523,7 +504,6 @@ export class TMDBService { }); return response.data.results || []; } catch (error) { - logger.error(`Error fetching TMDB ${type} recommendations for ID ${tmdbId}:`, error); return []; } } @@ -541,7 +521,6 @@ export class TMDBService { }); return response.data.results; } catch (error) { - logger.error('Failed to search multi:', error); return []; } } @@ -560,7 +539,6 @@ export class TMDBService { }); return response.data; } catch (error) { - logger.error('Failed to get movie details:', error); return null; } } @@ -570,8 +548,6 @@ export class TMDBService { */ async getMovieImages(movieId: number | string, preferredLanguage: string = 'en'): Promise { try { - logger.log(`[TMDBService] Fetching movie images for TMDB ID: ${movieId}, preferred language: ${preferredLanguage}`); - const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -580,7 +556,6 @@ export class TMDBService { }); const images = response.data; - logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for movie ID ${movieId}`); if (images && images.logos && images.logos.length > 0) { // First prioritize preferred language SVG logos if not English @@ -591,7 +566,6 @@ export class TMDBService { logo.iso_639_1 === preferredLanguage ); if (preferredSvgLogo) { - logger.log(`[TMDBService] Found ${preferredLanguage} SVG logo for movie ID ${movieId}: ${preferredSvgLogo.file_path}`); return this.getImageUrl(preferredSvgLogo.file_path); } @@ -602,7 +576,6 @@ export class TMDBService { logo.iso_639_1 === preferredLanguage ); if (preferredPngLogo) { - logger.log(`[TMDBService] Found ${preferredLanguage} PNG logo for movie ID ${movieId}: ${preferredPngLogo.file_path}`); return this.getImageUrl(preferredPngLogo.file_path); } @@ -611,7 +584,6 @@ export class TMDBService { logo.iso_639_1 === preferredLanguage ); if (preferredLogo) { - logger.log(`[TMDBService] Found ${preferredLanguage} logo for movie ID ${movieId}: ${preferredLogo.file_path}`); return this.getImageUrl(preferredLogo.file_path); } } @@ -623,7 +595,6 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enSvgLogo) { - logger.log(`[TMDBService] Found English SVG logo for movie ID ${movieId}: ${enSvgLogo.file_path}`); return this.getImageUrl(enSvgLogo.file_path); } @@ -634,7 +605,6 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enPngLogo) { - logger.log(`[TMDBService] Found English PNG logo for movie ID ${movieId}: ${enPngLogo.file_path}`); return this.getImageUrl(enPngLogo.file_path); } @@ -643,7 +613,6 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enLogo) { - logger.log(`[TMDBService] Found English logo for movie ID ${movieId}: ${enLogo.file_path}`); return this.getImageUrl(enLogo.file_path); } @@ -652,7 +621,6 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { - logger.log(`[TMDBService] Found SVG logo for movie ID ${movieId}: ${svgLogo.file_path}`); return this.getImageUrl(svgLogo.file_path); } @@ -661,20 +629,15 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { - logger.log(`[TMDBService] Found PNG logo for movie ID ${movieId}: ${pngLogo.file_path}`); return this.getImageUrl(pngLogo.file_path); } // Last resort: any logo - logger.log(`[TMDBService] Using first available logo for movie ID ${movieId}: ${images.logos[0].file_path}`); return this.getImageUrl(images.logos[0].file_path); } - logger.warn(`[TMDBService] No logos found for movie ID ${movieId}`); return null; // No logos found } catch (error) { - // Log error but don't throw, just return null if fetching images fails - logger.error(`[TMDBService] Failed to get movie images for ID ${movieId}:`, error); return null; } } @@ -684,8 +647,6 @@ export class TMDBService { */ async getTvShowImages(showId: number | string, preferredLanguage: string = 'en'): Promise { try { - logger.log(`[TMDBService] Fetching TV show images for TMDB ID: ${showId}, preferred language: ${preferredLanguage}`); - const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -694,7 +655,6 @@ export class TMDBService { }); const images = response.data; - logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for TV show ID ${showId}`); if (images && images.logos && images.logos.length > 0) { // First prioritize preferred language SVG logos if not English @@ -705,7 +665,6 @@ export class TMDBService { logo.iso_639_1 === preferredLanguage ); if (preferredSvgLogo) { - logger.log(`[TMDBService] Found ${preferredLanguage} SVG logo for TV show ID ${showId}: ${preferredSvgLogo.file_path}`); return this.getImageUrl(preferredSvgLogo.file_path); } @@ -716,7 +675,6 @@ export class TMDBService { logo.iso_639_1 === preferredLanguage ); if (preferredPngLogo) { - logger.log(`[TMDBService] Found ${preferredLanguage} PNG logo for TV show ID ${showId}: ${preferredPngLogo.file_path}`); return this.getImageUrl(preferredPngLogo.file_path); } @@ -725,7 +683,6 @@ export class TMDBService { logo.iso_639_1 === preferredLanguage ); if (preferredLogo) { - logger.log(`[TMDBService] Found ${preferredLanguage} logo for TV show ID ${showId}: ${preferredLogo.file_path}`); return this.getImageUrl(preferredLogo.file_path); } } @@ -737,7 +694,6 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enSvgLogo) { - logger.log(`[TMDBService] Found English SVG logo for TV show ID ${showId}: ${enSvgLogo.file_path}`); return this.getImageUrl(enSvgLogo.file_path); } @@ -748,7 +704,6 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enPngLogo) { - logger.log(`[TMDBService] Found English PNG logo for TV show ID ${showId}: ${enPngLogo.file_path}`); return this.getImageUrl(enPngLogo.file_path); } @@ -757,7 +712,6 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enLogo) { - logger.log(`[TMDBService] Found English logo for TV show ID ${showId}: ${enLogo.file_path}`); return this.getImageUrl(enLogo.file_path); } @@ -766,7 +720,6 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { - logger.log(`[TMDBService] Found SVG logo for TV show ID ${showId}: ${svgLogo.file_path}`); return this.getImageUrl(svgLogo.file_path); } @@ -775,20 +728,15 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { - logger.log(`[TMDBService] Found PNG logo for TV show ID ${showId}: ${pngLogo.file_path}`); return this.getImageUrl(pngLogo.file_path); } // Last resort: any logo - logger.log(`[TMDBService] Using first available logo for TV show ID ${showId}: ${images.logos[0].file_path}`); return this.getImageUrl(images.logos[0].file_path); } - logger.warn(`[TMDBService] No logos found for TV show ID ${showId}`); return null; // No logos found } catch (error) { - // Log error but don't throw, just return null if fetching images fails - logger.error(`[TMDBService] Failed to get TV show images for ID ${showId}:`, error); return null; } } @@ -798,21 +746,16 @@ export class TMDBService { */ async getContentLogo(type: 'movie' | 'tv', id: number | string, preferredLanguage: string = 'en'): Promise { try { - logger.log(`[TMDBService] Getting content logo for ${type} with ID ${id}, preferred language: ${preferredLanguage}`); - const result = type === 'movie' ? await this.getMovieImages(id, preferredLanguage) : await this.getTvShowImages(id, preferredLanguage); if (result) { - logger.log(`[TMDBService] Successfully retrieved logo for ${type} ID ${id}: ${result}`); } else { - logger.warn(`[TMDBService] No logo found for ${type} ID ${id}`); } return result; } catch (error) { - logger.error(`[TMDBService] Failed to get content logo for ${type} ID ${id}:`, error); return null; } } @@ -847,7 +790,6 @@ export class TMDBService { } return null; } catch (error) { - logger.error('Error fetching certification:', error); return null; } } @@ -883,7 +825,6 @@ export class TMDBService { external_ids: externalIdsResponse.data }; } catch (error) { - logger.error(`Failed to get external IDs for ${type} ${item.id}:`, error); return item; } }) @@ -891,7 +832,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to get trending ${type} content:`, error); return []; } } @@ -928,7 +868,6 @@ export class TMDBService { external_ids: externalIdsResponse.data }; } catch (error) { - logger.error(`Failed to get external IDs for ${type} ${item.id}:`, error); return item; } }) @@ -936,7 +875,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to get popular ${type} content:`, error); return []; } } @@ -976,7 +914,6 @@ export class TMDBService { external_ids: externalIdsResponse.data }; } catch (error) { - logger.error(`Failed to get external IDs for ${type} ${item.id}:`, error); return item; } }) @@ -984,7 +921,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to get upcoming ${type} content:`, error); return []; } } @@ -1002,7 +938,6 @@ export class TMDBService { }); return response.data.genres || []; } catch (error) { - logger.error('Failed to fetch movie genres:', error); return []; } } @@ -1020,7 +955,6 @@ export class TMDBService { }); return response.data.genres || []; } catch (error) { - logger.error('Failed to fetch TV genres:', error); return []; } } @@ -1041,7 +975,6 @@ export class TMDBService { const genre = genreList.find(g => g.name.toLowerCase() === genreName.toLowerCase()); if (!genre) { - logger.error(`Genre ${genreName} not found`); return []; } @@ -1075,7 +1008,6 @@ export class TMDBService { external_ids: externalIdsResponse.data }; } catch (error) { - logger.error(`Failed to get external IDs for ${type} ${item.id}:`, error); return item; } }) @@ -1083,7 +1015,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to discover ${type} by genre ${genreName}:`, error); return []; } } From 188c6e37f1f4dc6d5faf7f77253fa7d9111d3f67 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 01:17:08 +0530 Subject: [PATCH 26/38] Integrate theme context across metadata components for enhanced UI consistency This update refactors multiple metadata components, including CastSection, FloatingHeader, HeroSection, and RatingsSection, to utilize the new ThemeContext for dynamic theming. Styles have been adjusted to reflect the current theme colors, improving visual consistency throughout the application. Additionally, loading indicators and text colors have been updated to align with the theme, enhancing the overall user experience. These changes streamline the components and ensure a cohesive interface across different themes. --- src/components/metadata/CastSection.tsx | 181 ++++++++++-------- src/components/metadata/FloatingHeader.tsx | 28 ++- src/components/metadata/HeroSection.tsx | 56 +++--- src/components/metadata/MetadataDetails.tsx | 31 ++- .../metadata/MoreLikeThisSection.tsx | 14 +- src/components/metadata/MovieContent.tsx | 17 +- src/components/metadata/RatingsSection.tsx | 175 ++++++++++------- src/screens/MetadataScreen.tsx | 42 ++-- 8 files changed, 310 insertions(+), 234 deletions(-) diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx index 8c9e3e6..f904eb7 100644 --- a/src/components/metadata/CastSection.tsx +++ b/src/components/metadata/CastSection.tsx @@ -3,20 +3,21 @@ import { View, Text, StyleSheet, + FlatList, TouchableOpacity, ActivityIndicator, - ScrollView, } from 'react-native'; import { Image } from 'expo-image'; -import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import { colors } from '../../styles/colors'; -import { Cast } from '../../types/metadata'; -import { tmdbService } from '../../services/tmdbService'; +import Animated, { + FadeIn, + Layout, +} from 'react-native-reanimated'; +import { useTheme } from '../../contexts/ThemeContext'; interface CastSectionProps { - cast: Cast[]; + cast: any[]; loadingCast: boolean; - onSelectCastMember: (member: Cast) => void; + onSelectCastMember: (castMember: any) => void; } export const CastSection: React.FC = ({ @@ -24,123 +25,137 @@ export const CastSection: React.FC = ({ loadingCast, onSelectCastMember, }) => { + const { currentTheme } = useTheme(); + if (loadingCast) { return ( - + ); } - if (!cast.length) { + if (!cast || cast.length === 0) { return null; } return ( - - Cast - + + Cast + + - {cast.map((member) => ( - onSelectCastMember(member)} + contentContainerStyle={styles.castList} + keyExtractor={(item) => item.id.toString()} + renderItem={({ item, index }) => ( + - - {member.profile_path ? ( - - ) : ( - + onSelectCastMember(item)} + activeOpacity={0.7} + > + + {item.profile_path ? ( + + ) : ( + + + {item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)} + + + )} + + {item.name} + {item.character && ( + {item.character} )} - - - {member.name} - {member.character} - - - ))} - - + + + )} + /> + ); }; const styles = StyleSheet.create({ + castSection: { + marginBottom: 24, + paddingHorizontal: 0, + }, loadingContainer: { + paddingVertical: 20, alignItems: 'center', justifyContent: 'center', - padding: 12, }, - castSection: { - marginTop: 0, - paddingLeft: 0, - }, - sectionTitle: { - color: colors.highEmphasis, - fontSize: 18, - fontWeight: '700', - marginBottom: 10, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, paddingHorizontal: 16, }, - castScrollContainer: { - marginTop: 4, + sectionTitle: { + fontSize: 18, + fontWeight: '700', }, - castContainer: { - paddingHorizontal: 12, - paddingVertical: 4, + castList: { + paddingHorizontal: 16, + paddingBottom: 4, }, - castMember: { - width: 80, - marginRight: 12, + castCard: { + marginRight: 16, + width: 90, alignItems: 'center', }, castImageContainer: { - width: 64, - height: 64, - borderRadius: 32, - backgroundColor: colors.elevation2, - justifyContent: 'center', - alignItems: 'center', + width: 80, + height: 80, + borderRadius: 40, overflow: 'hidden', - marginBottom: 6, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', + marginBottom: 8, }, castImage: { - width: 64, - height: 64, - borderRadius: 32, - }, - castTextContainer: { width: '100%', + height: '100%', + }, + castImagePlaceholder: { + width: '100%', + height: '100%', + borderRadius: 40, alignItems: 'center', + justifyContent: 'center', + }, + placeholderText: { + fontSize: 24, + fontWeight: '600', }, castName: { - color: colors.highEmphasis, - fontSize: 13, + fontSize: 14, fontWeight: '600', textAlign: 'center', + width: 90, }, - castCharacter: { - color: colors.mediumEmphasis, + characterName: { fontSize: 12, textAlign: 'center', + width: 90, marginTop: 2, - opacity: 0.8, }, }); \ No newline at end of file diff --git a/src/components/metadata/FloatingHeader.tsx b/src/components/metadata/FloatingHeader.tsx index 619723f..30bdfbb 100644 --- a/src/components/metadata/FloatingHeader.tsx +++ b/src/components/metadata/FloatingHeader.tsx @@ -16,7 +16,7 @@ import Animated, { interpolate, Extrapolate, } from 'react-native-reanimated'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; const { width } = Dimensions.get('window'); @@ -46,6 +46,8 @@ const FloatingHeader: React.FC = ({ safeAreaTop, setLogoLoadError, }) => { + const { currentTheme } = useTheme(); + // Animated styles for the header const headerAnimatedStyle = useAnimatedStyle(() => ({ opacity: headerOpacity.value, @@ -74,7 +76,11 @@ const FloatingHeader: React.FC = ({ onPress={handleBack} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > - + @@ -90,7 +96,7 @@ const FloatingHeader: React.FC = ({ }} /> ) : ( - {metadata.name} + {metadata.name} )} @@ -102,7 +108,7 @@ const FloatingHeader: React.FC = ({ @@ -121,7 +127,11 @@ const FloatingHeader: React.FC = ({ onPress={handleBack} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > - + @@ -137,7 +147,7 @@ const FloatingHeader: React.FC = ({ }} /> ) : ( - {metadata.name} + {metadata.name} )} @@ -149,13 +159,13 @@ const FloatingHeader: React.FC = ({ )} - {Platform.OS === 'ios' && } + {Platform.OS === 'ios' && } ); }; @@ -190,7 +200,6 @@ const styles = StyleSheet.create({ left: 0, right: 0, height: 0.5, - backgroundColor: 'rgba(255,255,255,0.15)', }, headerTitleContainer: { flex: 1, @@ -218,7 +227,6 @@ const styles = StyleSheet.create({ maxWidth: 240, }, floatingHeaderTitle: { - color: colors.highEmphasis, fontSize: 18, fontWeight: '700', textAlign: 'center', diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index bd1c1cd..cef9ec4 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -14,7 +14,7 @@ import Animated, { interpolate, Extrapolate, } from 'react-native-reanimated'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; import { TMDBService } from '../../services/tmdbService'; @@ -77,6 +77,7 @@ const ActionButtons = React.memo(({ playButtonText: string; animatedStyle: any; }) => { + const { currentTheme } = useTheme(); return ( {inLibrary ? 'Saved' : 'Save'} @@ -155,7 +156,11 @@ const ActionButtons = React.memo(({ } }} > - + )} @@ -174,6 +179,7 @@ const WatchProgressDisplay = React.memo(({ getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; animatedStyle: any; }) => { + const { currentTheme } = useTheme(); if (!watchProgress || watchProgress.duration === 0) { return null; } @@ -195,11 +201,14 @@ const WatchProgressDisplay = React.memo(({ - + {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} @@ -236,11 +245,12 @@ const HeroSection: React.FC = ({ setBannerImage, setLogoLoadError, }) => { + const { currentTheme } = useTheme(); // Animated styles const heroAnimatedStyle = useAnimatedStyle(() => ({ width: '100%', height: heroHeight.value, - backgroundColor: colors.black, + backgroundColor: currentTheme.colors.black, transform: [{ scale: heroScale.value }], opacity: heroOpacity.value, })); @@ -309,9 +319,13 @@ const HeroSection: React.FC = ({ return genresToDisplay.slice(0, 4).map((genreName, index, array) => ( - {genreName} + + {genreName} + {index < array.length - 1 && ( - + + • + )} )); @@ -321,7 +335,7 @@ const HeroSection: React.FC = ({ {loadingBanner ? ( - + ) : ( = ({ )} = ({ }} /> ) : ( - {metadata.name} + {metadata.name} )} @@ -405,7 +419,7 @@ const styles = StyleSheet.create({ heroSection: { width: '100%', height: height * 0.5, - backgroundColor: colors.black, + backgroundColor: '#000', overflow: 'hidden', }, absoluteFill: { @@ -442,7 +456,6 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, heroTitle: { - color: colors.highEmphasis, fontSize: 28, fontWeight: '900', marginBottom: 12, @@ -461,15 +474,12 @@ const styles = StyleSheet.create({ gap: 4, }, genreText: { - color: colors.text, fontSize: 12, fontWeight: '500', }, genreDot: { - color: colors.text, fontSize: 12, fontWeight: '500', - opacity: 0.6, marginHorizontal: 4, }, actionButtons: { @@ -494,7 +504,7 @@ const styles = StyleSheet.create({ flex: 1, }, playButton: { - backgroundColor: colors.white, + backgroundColor: '#fff', }, infoButton: { backgroundColor: 'rgba(255,255,255,0.2)', @@ -546,11 +556,9 @@ const styles = StyleSheet.create({ }, watchProgressFill: { height: '100%', - backgroundColor: colors.primary, borderRadius: 1.5, }, watchProgressText: { - color: colors.textMuted, fontSize: 12, textAlign: 'center', opacity: 0.9, diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index a32b67b..905dc5a 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -12,7 +12,7 @@ import Animated, { Easing, FadeIn, } from 'react-native-reanimated'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; interface MetadataDetailsProps { metadata: any; @@ -25,6 +25,7 @@ const MetadataDetails: React.FC = ({ imdbId, type, }) => { + const { currentTheme } = useTheme(); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); return ( @@ -32,13 +33,13 @@ const MetadataDetails: React.FC = ({ {/* Meta Info */} {metadata.year && ( - {metadata.year} + {metadata.year} )} {metadata.runtime && ( - {metadata.runtime} + {metadata.runtime} )} {metadata.certification && ( - {metadata.certification} + {metadata.certification} )} {metadata.imdbRating && ( @@ -47,7 +48,7 @@ const MetadataDetails: React.FC = ({ style={styles.imdbLogo} contentFit="contain" /> - {metadata.imdbRating} + {metadata.imdbRating} )} @@ -59,14 +60,14 @@ const MetadataDetails: React.FC = ({ > {metadata.directors && metadata.directors.length > 0 && ( - Director{metadata.directors.length > 1 ? 's' : ''}: - {metadata.directors.join(', ')} + Director{metadata.directors.length > 1 ? 's' : ''}: + {metadata.directors.join(', ')} )} {metadata.creators && metadata.creators.length > 0 && ( - Creator{metadata.creators.length > 1 ? 's' : ''}: - {metadata.creators.join(', ')} + Creator{metadata.creators.length > 1 ? 's' : ''}: + {metadata.creators.join(', ')} )} @@ -81,17 +82,17 @@ const MetadataDetails: React.FC = ({ onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)} activeOpacity={0.7} > - + {metadata.description} - + {isFullDescriptionOpen ? 'Show Less' : 'Show More'} @@ -110,7 +111,6 @@ const styles = StyleSheet.create({ marginBottom: 12, }, metaText: { - color: colors.text, fontSize: 15, fontWeight: '700', letterSpacing: 0.3, @@ -127,7 +127,6 @@ const styles = StyleSheet.create({ marginRight: 4, }, ratingText: { - color: colors.text, fontWeight: '700', fontSize: 15, letterSpacing: 0.3, @@ -143,14 +142,12 @@ const styles = StyleSheet.create({ height: 20 }, creatorLabel: { - color: colors.white, fontSize: 14, fontWeight: '600', marginRight: 8, lineHeight: 20 }, creatorText: { - color: colors.lightGray, fontSize: 14, flex: 1, lineHeight: 20 @@ -160,7 +157,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, }, description: { - color: colors.mediumEmphasis, fontSize: 15, lineHeight: 24, }, @@ -171,7 +167,6 @@ const styles = StyleSheet.create({ paddingVertical: 4, }, showMoreText: { - color: colors.textMuted, fontSize: 14, marginRight: 4, }, diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx index f9c8563..f69cc69 100644 --- a/src/components/metadata/MoreLikeThisSection.tsx +++ b/src/components/metadata/MoreLikeThisSection.tsx @@ -14,7 +14,7 @@ import { useNavigation, StackActions } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { StreamingContent } from '../../types/metadata'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { TMDBService } from '../../services/tmdbService'; import { catalogService } from '../../services/catalogService'; @@ -31,6 +31,7 @@ export const MoreLikeThisSection: React.FC = ({ recommendations, loadingRecommendations }) => { + const { currentTheme } = useTheme(); const navigation = useNavigation>(); const handleItemPress = async (item: StreamingContent) => { @@ -69,11 +70,11 @@ export const MoreLikeThisSection: React.FC = ({ > - + {item.name} @@ -82,7 +83,7 @@ export const MoreLikeThisSection: React.FC = ({ if (loadingRecommendations) { return ( - + ); } @@ -93,7 +94,7 @@ export const MoreLikeThisSection: React.FC = ({ return ( - More Like This + More Like This = ({ metadata }) => { + const { currentTheme } = useTheme(); const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0; const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : ''; @@ -17,22 +18,22 @@ export const MovieContent: React.FC = ({ metadata }) => { {metadata.director && ( - Director: - {metadata.director} + Director: + {metadata.director} )} {metadata.writer && ( - Writer: - {metadata.writer} + Writer: + {metadata.writer} )} {hasCast && ( - Cast: - {castDisplay} + Cast: + {castDisplay} )} @@ -53,12 +54,10 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', }, metadataLabel: { - color: colors.textMuted, fontSize: 15, width: 70, }, metadataValue: { - color: colors.text, fontSize: 15, flex: 1, lineHeight: 24, diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index a52076e..f09ea70 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; import { View, Text, StyleSheet, ActivityIndicator, Image, Animated } from 'react-native'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { useMDBListRatings } from '../../hooks/useMDBListRatings'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../screens/MDBListSettingsScreen'; @@ -54,6 +54,7 @@ export const RatingsSection: React.FC = ({ imdbId, type }) const [enabledProviders, setEnabledProviders] = useState>({}); const [isMDBEnabled, setIsMDBEnabled] = useState(true); const fadeAnim = useRef(new Animated.Value(0)).current; + const { currentTheme } = useTheme(); useEffect(() => { loadProviderSettings(); @@ -120,7 +121,7 @@ export const RatingsSection: React.FC = ({ imdbId, type }) if (loading) { return ( - + ); } @@ -214,86 +215,128 @@ export const RatingsSection: React.FC = ({ imdbId, type }) }, ]} > - {displayRatings.map(([source, value]) => { - const config = ratingConfig[source as keyof typeof ratingConfig]; - const numericValue = typeof value === 'string' ? parseFloat(value) : value; - const displayValue = config.transform(numericValue); - - // Get a short display name for the rating source - const getSourceLabel = (src: string): string => { - switch(src) { - case 'imdb': return 'IMDb'; - case 'tmdb': return 'TMDB'; - case 'tomatoes': return 'RT'; - case 'audience': return 'Aud'; - case 'metacritic': return 'Meta'; - case 'letterboxd': return 'LBXD'; - case 'trakt': return 'Trakt'; - default: return src; - } - }; - - return ( - - {config.isImage ? ( - - ) : ( - - )} - - {displayValue}{config.suffix} - - - ); - })} + + Ratings + + + {displayRatings.map(([source, value]) => { + const config = ratingConfig[source as keyof typeof ratingConfig]; + const displayValue = config.transform(parseFloat(value as string)); + + // Get a short display name for the rating source + const getSourceLabel = (src: string): string => { + switch(src) { + case 'imdb': return 'IMDb'; + case 'tmdb': return 'TMDB'; + case 'tomatoes': return 'RT'; + case 'audience': return 'Aud'; + case 'metacritic': return 'Meta'; + case 'letterboxd': return 'LBXD'; + case 'trakt': return 'Trakt'; + default: return src; + } + }; + + return ( + + + {config.isImage ? ( + + ) : ( + + {React.createElement(config.icon as any, { + width: 24, + height: 24, + })} + + )} + + + {config.prefix}{displayValue}{config.suffix} + + {getSourceLabel(source)} + + ); + })} + ); }; const styles = StyleSheet.create({ container: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginTop: 8, - marginBottom: 16, - paddingHorizontal: 12, - gap: 4, + marginBottom: 20, + paddingHorizontal: 16, }, loadingContainer: { - alignItems: 'center', + height: 80, justifyContent: 'center', - height: 40, - marginVertical: 16, + alignItems: 'center', }, - ratingItem: { + header: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.4)', - paddingVertical: 3, - paddingHorizontal: 4, - borderRadius: 4, + justifyContent: 'space-between', + marginBottom: 12, }, - ratingIcon: { - width: 16, - height: 16, - marginRight: 3, - alignSelf: 'center', + title: { + fontSize: 18, + fontWeight: '700', + }, + ratingsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + ratingItem: { + flexDirection: 'column', + alignItems: 'center', + width: 55, + }, + ratingIconContainer: { + width: 32, + height: 32, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 4, + }, + ratingIconImage: { + width: 32, + height: 32, + }, + svgContainer: { + alignItems: 'center', + justifyContent: 'center', }, ratingValue: { - fontSize: 13, - fontWeight: 'bold', + fontSize: 16, + fontWeight: '700', + marginVertical: 2, }, - ratingLabel: { + ratingSource: { fontSize: 11, - opacity: 0.9, + textAlign: 'center', + }, + noRatingsText: { + fontSize: 14, + color: 'gray', + fontStyle: 'italic', + textAlign: 'center', + marginVertical: 16, + }, + errorText: { + fontSize: 12, + color: '#ff0000', + textAlign: 'center', + marginVertical: 8, }, }); \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 881bdd0..7648477 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -12,7 +12,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRoute, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; -import { colors } from '../styles/colors'; +import { useTheme } from '../contexts/ThemeContext'; import { useMetadata } from '../hooks/useMetadata'; import { CastSection } from '../components/metadata/CastSection'; import { SeriesContent } from '../components/metadata/SeriesContent'; @@ -48,6 +48,9 @@ const MetadataScreen = () => { // Add settings hook const { settings } = useSettings(); + // Get theme context + const { currentTheme } = useTheme(); + // Get safe area insets const { top: safeAreaTop } = useSafeAreaInsets(); @@ -182,7 +185,9 @@ const MetadataScreen = () => { if (loading) { return ( { barStyle="light-content" /> - - + + Loading content... @@ -203,7 +210,9 @@ const MetadataScreen = () => { if (metadataError || !metadata) { return ( { - + {metadataError || 'Content not found'} Try Again @@ -238,11 +249,11 @@ const MetadataScreen = () => { - + Go Back @@ -253,7 +264,9 @@ const MetadataScreen = () => { return ( Date: Sun, 4 May 2025 01:21:08 +0530 Subject: [PATCH 27/38] Refactor SeriesContent component to integrate theme context for improved UI consistency This update modifies the SeriesContent component to utilize the new ThemeContext, allowing for dynamic theming throughout the component. Styles have been adjusted to reflect the current theme colors, enhancing visual consistency and user experience. Key changes include updates to loading indicators, text colors, and background styles, ensuring a cohesive interface that adapts to different themes. --- src/components/metadata/SeriesContent.tsx | 68 ++++++++++------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index c41cb89..3ad9b8e 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -2,7 +2,7 @@ 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 { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { Episode } from '../../types/metadata'; import { tmdbService } from '../../services/tmdbService'; import { storageService } from '../../services/storageService'; @@ -33,6 +33,7 @@ export const SeriesContent: React.FC = ({ groupedEpisodes = {}, metadata }) => { + const { currentTheme } = useTheme(); const { width } = useWindowDimensions(); const isTablet = width > 768; const isDarkMode = useColorScheme() === 'dark'; @@ -95,8 +96,8 @@ export const SeriesContent: React.FC = ({ if (loadingSeasons) { return ( - - Loading episodes... + + Loading episodes... ); } @@ -104,8 +105,8 @@ export const SeriesContent: React.FC = ({ if (episodes.length === 0) { return ( - - No episodes available + + No episodes available ); } @@ -119,7 +120,7 @@ export const SeriesContent: React.FC = ({ return ( - Seasons + Seasons = ({ key={season} style={[ styles.seasonButton, - selectedSeason === season && styles.selectedSeasonButton + selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }] ]} onPress={() => onSeasonChange(season)} > @@ -153,13 +154,13 @@ export const SeriesContent: React.FC = ({ contentFit="cover" /> {selectedSeason === season && ( - + )} Season {season} @@ -215,7 +216,11 @@ export const SeriesContent: React.FC = ({ return ( onSelectEpisode(episode)} activeOpacity={0.7} > @@ -233,21 +238,21 @@ export const SeriesContent: React.FC = ({ )} {progressPercent >= 95 && ( - - + + )} - + {episode.name} @@ -258,27 +263,27 @@ export const SeriesContent: React.FC = ({ style={styles.tmdbLogo} contentFit="contain" /> - + {episode.vote_average.toFixed(1)} )} {episode.runtime && ( - - + + {formatRuntime(episode.runtime)} )} {episode.air_date && ( - + {formatDate(episode.air_date)} )} - + {episode.overview || 'No description available'} @@ -286,6 +291,8 @@ export const SeriesContent: React.FC = ({ ); }; + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; + return ( = ({ - + {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} @@ -310,7 +317,7 @@ export const SeriesContent: React.FC = ({ > {isTablet ? ( - {episodes.map((episode, index) => ( + {currentSeasonEpisodes.map((episode, index) => ( = ({ ))} ) : ( - episodes.map((episode, index) => ( + currentSeasonEpisodes.map((episode, index) => ( Date: Sun, 4 May 2025 02:03:59 +0530 Subject: [PATCH 28/38] Enhance ThemeScreen and ThemeContext with new themes and filtering functionality This update introduces several new themes (Emerald, Ruby, Amethyst, Amber, Mint, Slate, Neon, and Retro Wave) to the ThemeContext, expanding the available options for users. Additionally, the ThemeScreen has been refactored to include a category filter for better organization of themes, allowing users to easily navigate between different theme types. The UI has been improved with updated styles and components, enhancing the overall user experience and visual consistency across the application. --- src/contexts/ThemeContext.tsx | 88 +++++ src/screens/ThemeScreen.tsx | 628 ++++++++++++++++++++++++++-------- 2 files changed, 566 insertions(+), 150 deletions(-) diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 084c3c3..5eb8c96 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -51,6 +51,94 @@ export const DEFAULT_THEMES: Theme[] = [ }, isEditable: false, }, + { + id: 'emerald', + name: 'Emerald', + colors: { + ...defaultColors, + primary: '#2ecc71', + secondary: '#3498db', + darkBackground: '#0e1e13', + }, + isEditable: false, + }, + { + id: 'ruby', + name: 'Ruby', + colors: { + ...defaultColors, + primary: '#e74c3c', + secondary: '#9b59b6', + darkBackground: '#1a0a0a', + }, + isEditable: false, + }, + { + id: 'amethyst', + name: 'Amethyst', + colors: { + ...defaultColors, + primary: '#9b59b6', + secondary: '#3498db', + darkBackground: '#140a1c', + }, + isEditable: false, + }, + { + id: 'amber', + name: 'Amber', + colors: { + ...defaultColors, + primary: '#f39c12', + secondary: '#d35400', + darkBackground: '#1a140a', + }, + isEditable: false, + }, + { + id: 'mint', + name: 'Mint', + colors: { + ...defaultColors, + primary: '#1abc9c', + secondary: '#16a085', + darkBackground: '#0a1a17', + }, + isEditable: false, + }, + { + id: 'slate', + name: 'Slate', + colors: { + ...defaultColors, + primary: '#7f8c8d', + secondary: '#95a5a6', + darkBackground: '#10191a', + }, + isEditable: false, + }, + { + id: 'neon', + name: 'Neon', + colors: { + ...defaultColors, + primary: '#00ff00', + secondary: '#ff00ff', + darkBackground: '#0a0a0a', + }, + isEditable: false, + }, + { + id: 'retro', + name: 'Retro Wave', + colors: { + ...defaultColors, + primary: '#ff00ff', + secondary: '#00ffff', + darkBackground: '#150036', + }, + isEditable: false, + }, ]; // Theme context props diff --git a/src/screens/ThemeScreen.tsx b/src/screens/ThemeScreen.tsx index 5305810..0f1e909 100644 --- a/src/screens/ThemeScreen.tsx +++ b/src/screens/ThemeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { View, Text, @@ -11,6 +11,7 @@ import { TextInput, Dimensions, StatusBar, + FlatList, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -23,6 +24,14 @@ import { RootStackParamList } from '../navigation/AppNavigator'; const { width } = Dimensions.get('window'); +// Theme categories for organization +const THEME_CATEGORIES = [ + { id: 'all', name: 'All Themes' }, + { id: 'dark', name: 'Dark Themes' }, + { id: 'colorful', name: 'Colorful' }, + { id: 'custom', name: 'My Themes' }, +]; + interface ThemeCardProps { theme: Theme; isSelected: boolean; @@ -43,7 +52,12 @@ const ThemeCard: React.FC = ({ style={[ styles.themeCard, isSelected && styles.selectedThemeCard, - { borderColor: theme.colors.primary } + { + borderColor: isSelected ? theme.colors.primary : 'transparent', + backgroundColor: Platform.OS === 'ios' + ? `${theme.colors.darkBackground}60` + : 'rgba(255, 255, 255, 0.07)' + } ]} onPress={onSelect} activeOpacity={0.7} @@ -53,34 +67,32 @@ const ThemeCard: React.FC = ({ {theme.name} {isSelected && ( - + )} - - Primary - - - Secondary - - - Background - + + + {theme.isEditable && ( {onEdit && ( - - - Edit + + )} {onDelete && ( - - - Delete + + )} @@ -89,6 +101,39 @@ const ThemeCard: React.FC = ({ ); }; +// Filter tab component +interface FilterTabProps { + category: { id: string; name: string }; + isActive: boolean; + onPress: () => void; + primaryColor: string; +} + +const FilterTab: React.FC = ({ + category, + isActive, + onPress, + primaryColor +}) => ( + + + {category.name} + + +); + type ColorKey = 'primary' | 'secondary' | 'darkBackground'; interface ThemeColorEditorProps { @@ -137,74 +182,119 @@ const ThemeColorEditor: React.FC = ({ }); }; + // Compact preview component + const ThemePreview = () => ( + + + {/* App header */} + + + + + + + + + {/* Content area */} + + {/* Featured content poster */} + + + + + + + + + + {/* Content row */} + + + + + + + + + + + + ); + return ( - Custom Theme - - - Theme Name + + + + + + Save + - - setSelectedColorKey('primary')} - > - Primary - + + + + + + setSelectedColorKey('primary')} + > + Primary + + + setSelectedColorKey('secondary')} + > + Secondary + + + setSelectedColorKey('darkBackground')} + > + Background + + + - setSelectedColorKey('secondary')} - > - Secondary - - - setSelectedColorKey('darkBackground')} - > - Background - - - - - - - - - - Cancel - - - Save Theme - + + + ); @@ -224,6 +314,7 @@ const ThemeScreen: React.FC = () => { const [isEditMode, setIsEditMode] = useState(false); const [editingTheme, setEditingTheme] = useState(null); + const [activeFilter, setActiveFilter] = useState('all'); // Force consistent status bar settings useEffect(() => { @@ -241,6 +332,34 @@ const ThemeScreen: React.FC = () => { return unsubscribe; }, [navigation]); + // Filter themes based on selected category + const filteredThemes = useMemo(() => { + switch (activeFilter) { + case 'dark': + // Themes with darker colors + return availableThemes.filter(theme => + !theme.isEditable && + theme.id !== 'neon' && + theme.id !== 'retro' + ); + case 'colorful': + // Themes with vibrant colors + return availableThemes.filter(theme => + !theme.isEditable && + (theme.id === 'neon' || + theme.id === 'retro' || + theme.id === 'sunset' || + theme.id === 'amber') + ); + case 'custom': + // User's custom themes + return availableThemes.filter(theme => theme.isEditable); + default: + // All themes + return availableThemes; + } + }, [availableThemes, activeFilter]); + const handleThemeSelect = useCallback((themeId: string) => { setCurrentTheme(themeId); }, [setCurrentTheme]); @@ -345,7 +464,7 @@ const ThemeScreen: React.FC = () => { ]}> navigation.goBack()} > @@ -353,13 +472,36 @@ const ThemeScreen: React.FC = () => { App Themes - + {/* Category filter */} + + item.id} + renderItem={({ item }) => ( + setActiveFilter(item.id)} + primaryColor={currentTheme.colors.primary} + /> + )} + contentContainerStyle={styles.filterList} + /> + + + SELECT THEME - {availableThemes.map(theme => ( + {filteredThemes.map(theme => ( { - + Create Custom Theme @@ -390,26 +536,50 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', - padding: 16, + paddingHorizontal: 12, + paddingVertical: 8, }, backButton: { - padding: 8, + padding: 6, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', }, headerTitle: { - fontSize: 20, + fontSize: 18, fontWeight: 'bold', - marginLeft: 16, + marginLeft: 12, }, content: { flex: 1, }, contentContainer: { - padding: 16, + padding: 12, + paddingBottom: 24, + }, + filterContainer: { + marginBottom: 8, + }, + filterList: { + paddingHorizontal: 12, + }, + filterTab: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + marginRight: 8, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + filterTabText: { + fontSize: 12, + fontWeight: '600', + color: 'rgba(255, 255, 255, 0.8)', }, sectionTitle: { fontSize: 12, fontWeight: 'bold', - marginBottom: 16, + marginBottom: 10, + letterSpacing: 0.5, + textTransform: 'uppercase', }, themeGrid: { flexDirection: 'row', @@ -417,13 +587,20 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', }, themeCard: { - width: (width - 48) / 2, - marginBottom: 16, + width: (width - 36) / 2, + marginBottom: 12, borderRadius: 12, - padding: 12, - backgroundColor: 'rgba(255, 255, 255, 0.05)', + padding: 10, borderWidth: 2, borderColor: 'transparent', + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, }, selectedThemeCard: { borderWidth: 2, @@ -432,93 +609,221 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 12, + marginBottom: 8, }, themeCardTitle: { - fontSize: 16, + fontSize: 14, fontWeight: 'bold', }, colorPreviewContainer: { flexDirection: 'row', justifyContent: 'space-between', - marginBottom: 12, + marginBottom: 8, }, colorPreview: { - width: 30, - height: 30, - borderRadius: 15, - justifyContent: 'center', - alignItems: 'center', + width: 24, + height: 24, + borderRadius: 12, }, - colorPreviewLabel: { - fontSize: 6, - color: '#FFFFFF', - textShadowColor: 'rgba(0, 0, 0, 0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, + colorPreviewShadow: { + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 1.5, + elevation: 2, }, themeCardActions: { flexDirection: 'row', - justifyContent: 'space-between', + justifyContent: 'flex-end', }, themeCardAction: { - flexDirection: 'row', - alignItems: 'center', - padding: 4, + padding: 6, + marginLeft: 8, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 16, }, - themeCardActionText: { - fontSize: 12, - marginLeft: 4, + buttonShadow: { + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 1.41, + elevation: 2, }, createButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - padding: 16, - borderRadius: 12, - marginTop: 16, + padding: 12, + borderRadius: 10, + marginTop: 12, }, createButtonText: { color: '#FFFFFF', fontWeight: 'bold', + fontSize: 14, marginLeft: 8, }, + + // Editor styles editorContainer: { flex: 1, - padding: 16, }, - editorTitle: { - fontSize: 22, - fontWeight: 'bold', + editorHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 10, + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + editorBackButton: { + padding: 5, + borderRadius: 16, + backgroundColor: 'rgba(255, 255, 255, 0.12)', + }, + editorTitleInput: { + flex: 1, color: '#FFFFFF', - marginBottom: 24, - }, - inputContainer: { - marginBottom: 24, - }, - inputLabel: { fontSize: 14, - color: 'rgba(255,255,255,0.7)', - marginBottom: 8, + fontWeight: 'bold', + marginHorizontal: 10, + padding: 0, + height: 28, }, - textInput: { - backgroundColor: 'rgba(255,255,255,0.1)', + editorSaveButton: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + backgroundColor: colors.primary, + }, + editorBody: { + flex: 1, + padding: 10, + }, + colorSectionRow: { + flexDirection: 'row', + marginBottom: 10, + }, + colorButtonsColumn: { + width: width * 0.4 - 20, // 40% minus padding + marginLeft: 10, + justifyContent: 'space-between', + }, + previewContainer: { + width: width * 0.6, + height: 120, borderRadius: 8, - padding: 12, - color: '#FFFFFF', - fontSize: 16, + overflow: 'hidden', + padding: 4, }, - colorSelectorContainer: { + previewContent: { + flex: 1, + borderRadius: 4, + overflow: 'hidden', + }, + previewHeader: { + height: 16, flexDirection: 'row', justifyContent: 'space-between', - marginBottom: 24, + alignItems: 'center', + paddingHorizontal: 4, + backgroundColor: 'rgba(0,0,0,0.3)', + }, + previewHeaderTitle: { + width: 40, + height: 8, + borderRadius: 2, + backgroundColor: 'rgba(255,255,255,0.4)', + }, + previewIconGroup: { + flexDirection: 'row', + }, + previewIcon: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: 'rgba(255,255,255,0.4)', + marginLeft: 4, + }, + previewBody: { + flex: 1, + padding: 2, + }, + previewFeatured: { + height: 50, + borderRadius: 4, + backgroundColor: 'rgba(0,0,0,0.5)', + marginBottom: 4, + justifyContent: 'flex-end', + padding: 4, + }, + previewPosterGradient: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 30, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + previewTitle: { + width: 60, + height: 6, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.7)', + marginBottom: 4, + }, + previewButtonRow: { + flexDirection: 'row', + alignItems: 'center', + }, + previewPlayButton: { + width: 35, + height: 12, + borderRadius: 3, + marginRight: 4, + }, + previewActionButton: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + previewSectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 2, + }, + previewSectionTitle: { + width: 40, + height: 6, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.4)', + }, + previewPosterRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + previewPoster: { + width: '30%', + height: 30, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.15)', }, colorSelectorButton: { - width: (width - 64) / 3, - padding: 12, - borderRadius: 8, + height: 36, + paddingVertical: 5, + borderRadius: 6, alignItems: 'center', justifyContent: 'center', + marginBottom: 6, }, selectedColorButton: { borderWidth: 2, @@ -526,36 +831,58 @@ const styles = StyleSheet.create({ }, colorButtonText: { color: '#FFFFFF', - fontSize: 12, + fontSize: 10, fontWeight: 'bold', textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, }, colorPickerContainer: { - height: 300, - marginBottom: 24, + flex: 1, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 8, + padding: 8, + marginBottom: 10, }, - editorActions: { - flexDirection: 'row', - justifyContent: 'space-between', + + // Legacy styles - keep for backward compatibility + editorTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#FFFFFF', + }, + inputContainer: { + marginBottom: 16, + }, + inputLabel: { + fontSize: 14, + color: 'rgba(255,255,255,0.7)', + marginBottom: 6, + }, + textInput: { + backgroundColor: 'rgba(255,255,255,0.1)', + borderRadius: 8, + padding: 10, + color: '#FFFFFF', + fontSize: 14, }, cancelButton: { - width: (width - 48) / 2, - padding: 16, - borderRadius: 8, + width: (width - 36) / 2, + padding: 12, + borderRadius: 10, alignItems: 'center', justifyContent: 'center', - backgroundColor: 'rgba(255,255,255,0.1)', + backgroundColor: 'rgba(255, 255, 255, 0.1)', }, cancelButtonText: { color: '#FFFFFF', fontWeight: 'bold', + fontSize: 14, }, saveButton: { - width: (width - 48) / 2, - padding: 16, - borderRadius: 8, + width: (width - 36) / 2, + padding: 12, + borderRadius: 10, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primary, @@ -563,6 +890,7 @@ const styles = StyleSheet.create({ saveButtonText: { color: '#FFFFFF', fontWeight: 'bold', + fontSize: 14, }, }); From 6f2ccfa38b6d91e6c527f23ba471687f10e535ae Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 02:36:17 +0530 Subject: [PATCH 29/38] m --- src/components/metadata/MetadataDetails.tsx | 5 + src/components/metadata/RatingsSection.tsx | 176 +++++--------------- src/navigation/AppNavigator.tsx | 21 +-- src/screens/MetadataScreen.tsx | 14 +- 4 files changed, 60 insertions(+), 156 deletions(-) diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index 905dc5a..1011359 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -18,12 +18,14 @@ interface MetadataDetailsProps { metadata: any; imdbId: string | null; type: 'movie' | 'series'; + renderRatings?: () => React.ReactNode; } const MetadataDetails: React.FC = ({ metadata, imdbId, type, + renderRatings, }) => { const { currentTheme } = useTheme(); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); @@ -53,6 +55,9 @@ const MetadataDetails: React.FC = ({ )} + {/* Ratings Section */} + {renderRatings && renderRatings()} + {/* Creator/Director Info */} = ({ imdbId, type }) } }; - useEffect(() => { - return () => { - }; - }, [imdbId, type]); - - useEffect(() => { - if (error) { - } - }, [error]); - - useEffect(() => { - if (ratings) { - } - }, [ratings]); - useEffect(() => { if (ratings && Object.keys(ratings).length > 0) { // Start fade-in animation when ratings are loaded @@ -114,21 +99,9 @@ export const RatingsSection: React.FC = ({ imdbId, type }) }, [ratings, fadeAnim]); // If MDBList is disabled, don't show anything - if (!isMDBEnabled) { - return null; - } - - if (loading) { - return ( - - - - ); - } - - if (error || !ratings || Object.keys(ratings).length === 0) { - return null; - } + if (!isMDBEnabled) return null; + if (loading) return ; + if (error || !ratings || Object.keys(ratings).length === 0) return null; // Define the order and icons/colors for the ratings const ratingConfig = { @@ -136,56 +109,42 @@ export const RatingsSection: React.FC = ({ imdbId, type }) icon: require('../../../assets/rating-icons/imdb.png'), isImage: true, color: '#F5C518', - prefix: '', - suffix: '', transform: (value: number) => value.toFixed(1) }, tmdb: { icon: TMDBIcon, isImage: false, color: '#01B4E4', - prefix: '', - suffix: '', transform: (value: number) => value.toFixed(0) }, trakt: { icon: TraktIcon, isImage: false, color: '#ED1C24', - prefix: '', - suffix: '', transform: (value: number) => value.toFixed(0) }, letterboxd: { icon: LetterboxdIcon, isImage: false, color: '#00E054', - prefix: '', - suffix: '', transform: (value: number) => value.toFixed(1) }, tomatoes: { icon: RottenTomatoesIcon, isImage: false, color: '#FA320A', - prefix: '', - suffix: '%', - transform: (value: number) => Math.round(value).toString() + transform: (value: number) => Math.round(value).toString() + '%' }, audience: { icon: AudienceScoreIcon, isImage: true, color: '#FA320A', - prefix: '', - suffix: '%', - transform: (value: number) => Math.round(value).toString() + transform: (value: number) => Math.round(value).toString() + '%' }, metacritic: { icon: MetacriticIcon, isImage: true, color: '#FFCC33', - prefix: '', - suffix: '', transform: (value: number) => Math.round(value).toString() } }; @@ -215,55 +174,30 @@ export const RatingsSection: React.FC = ({ imdbId, type }) }, ]} > - - Ratings - - + {displayRatings.map(([source, value]) => { const config = ratingConfig[source as keyof typeof ratingConfig]; const displayValue = config.transform(parseFloat(value as string)); - // Get a short display name for the rating source - const getSourceLabel = (src: string): string => { - switch(src) { - case 'imdb': return 'IMDb'; - case 'tmdb': return 'TMDB'; - case 'tomatoes': return 'RT'; - case 'audience': return 'Aud'; - case 'metacritic': return 'Meta'; - case 'letterboxd': return 'LBXD'; - case 'trakt': return 'Trakt'; - default: return src; - } - }; - return ( - - - {config.isImage ? ( - - ) : ( - - {React.createElement(config.icon as any, { - width: 24, - height: 24, - })} - - )} - - - {config.prefix}{displayValue}{config.suffix} + + {config.isImage ? ( + + ) : ( + + {React.createElement(config.icon as any, { + width: 16, + height: 16, + })} + + )} + + {displayValue} - {getSourceLabel(source)} ); })} @@ -274,69 +208,35 @@ export const RatingsSection: React.FC = ({ imdbId, type }) const styles = StyleSheet.create({ container: { - marginBottom: 20, + marginTop: 2, + marginBottom: 8, paddingHorizontal: 16, }, loadingContainer: { - height: 80, + height: 40, justifyContent: 'center', alignItems: 'center', }, - header: { + compactRatingsContainer: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 12, + flexWrap: 'nowrap', }, - title: { - fontSize: 18, - fontWeight: '700', - }, - ratingsContainer: { + compactRatingItem: { flexDirection: 'row', - flexWrap: 'wrap', - gap: 10, - }, - ratingItem: { - flexDirection: 'column', alignItems: 'center', - width: 55, + marginRight: 12, }, - ratingIconContainer: { - width: 32, - height: 32, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 4, + compactRatingIcon: { + width: 16, + height: 16, + marginRight: 4, }, - ratingIconImage: { - width: 32, - height: 32, + compactSvgContainer: { + marginRight: 4, }, - svgContainer: { - alignItems: 'center', - justifyContent: 'center', - }, - ratingValue: { - fontSize: 16, - fontWeight: '700', - marginVertical: 2, - }, - ratingSource: { - fontSize: 11, - textAlign: 'center', - }, - noRatingsText: { + compactRatingValue: { fontSize: 14, - color: 'gray', - fontStyle: 'italic', - textAlign: 'center', - marginVertical: 16, - }, - errorText: { - fontSize: 12, - color: '#ff0000', - textAlign: 'center', - marginVertical: 8, + fontWeight: '600', }, }); \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 36985f5..2ee6a77 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -389,6 +389,7 @@ const WrappedScreen: React.FC<{Screen: React.ComponentType}> = ({ Screen }) const MainTabs = () => { // Always use dark mode const isDarkMode = true; + const { currentTheme } = useTheme(); const renderTabBar = (props: BottomTabBarProps) => { return ( @@ -409,9 +410,9 @@ const MainTabs = () => { position: 'absolute', height: '100%', width: '100%', - borderTopColor: 'rgba(255,255,255,0.2)', + borderTopColor: currentTheme.colors.border, borderTopWidth: 0.5, - shadowColor: '#000', + shadowColor: currentTheme.colors.black, shadowOffset: { width: 0, height: -2 }, shadowOpacity: 0.1, shadowRadius: 3, @@ -495,7 +496,7 @@ const MainTabs = () => { > { fontSize: 12, fontWeight: '600', marginTop: 4, - color: isFocused ? colors.primary : '#FFFFFF', + color: isFocused ? currentTheme.colors.primary : currentTheme.colors.white, opacity: isFocused ? 1 : 0.7, }} > @@ -519,7 +520,7 @@ const MainTabs = () => { }; return ( - + {/* Common StatusBar for all tabs */} { return ; }, - tabBarActiveTintColor: colors.primary, - tabBarInactiveTintColor: '#FFFFFF', + tabBarActiveTintColor: currentTheme.colors.primary, + tabBarInactiveTintColor: currentTheme.colors.white, tabBarStyle: { position: 'absolute', backgroundColor: 'transparent', @@ -583,9 +584,9 @@ const MainTabs = () => { position: 'absolute', height: '100%', width: '100%', - borderTopColor: 'rgba(255,255,255,0.2)', + borderTopColor: currentTheme.colors.border, borderTopWidth: 0.5, - shadowColor: '#000', + shadowColor: currentTheme.colors.black, shadowOffset: { width: 0, height: -2 }, shadowOpacity: 0.1, shadowRadius: 3, @@ -612,7 +613,7 @@ const MainTabs = () => { headerShown: route.name === 'Home', // Add fixed screen styling to help with consistency contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, })} // Global configuration for the tab navigator diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 7648477..2cf547e 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -335,16 +335,14 @@ const MetadataScreen = () => { metadata={metadata} imdbId={imdbId} type={type as 'movie' | 'series'} + renderRatings={() => imdbId ? ( + + ) : null} /> - {/* Add RatingsSection right under the main metadata */} - {imdbId && ( - - )} - {/* Cast Section */} Date: Sun, 4 May 2025 02:37:39 +0530 Subject: [PATCH 30/38] This update introduces a new optional prop, enderRatings, to the MetadataDetails component, allowing for customizable rendering of ratings. The RatingsSection component has been refactored to improve code readability by consolidating conditional rendering logic. Additionally, styles have been adjusted for a more compact display of ratings, enhancing the overall user experience. These changes streamline the integration of ratings into the metadata display, ensuring a cohesive and visually appealing interface. --- src/components/home/CatalogSection.tsx | 27 ++++------ .../home/ContinueWatchingSection.tsx | 39 +++++---------- src/components/home/FeaturedContent.tsx | 21 +++++--- src/components/home/ThisWeekSection.tsx | 50 +++++++------------ 4 files changed, 54 insertions(+), 83 deletions(-) diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index a1da2c8..8f4ed81 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -5,7 +5,7 @@ import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { FadeIn } from 'react-native-reanimated'; import { CatalogContent, StreamingContent } from '../../services/catalogService'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import ContentItem from './ContentItem'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -18,6 +18,7 @@ const POSTER_WIDTH = (width - 50) / 3; const CatalogSection = ({ catalog }: CatalogSectionProps) => { const navigation = useNavigation>(); + const { currentTheme } = useTheme(); const handleContentPress = (id: string, type: string) => { navigation.navigate('Metadata', { id, type }); @@ -43,9 +44,9 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { > - {catalog.name} + {catalog.name} { } style={styles.seeAllButton} > - See More - + See More + @@ -94,8 +95,6 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { const styles = StyleSheet.create({ catalogContainer: { marginBottom: 24, - paddingTop: 0, - marginTop: 16, }, catalogHeader: { flexDirection: 'row', @@ -110,7 +109,6 @@ const styles = StyleSheet.create({ catalogTitle: { fontSize: 18, fontWeight: '800', - color: colors.highEmphasis, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, @@ -126,21 +124,14 @@ const styles = StyleSheet.create({ seeAllButton: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.elevation1, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, + gap: 4, }, seeAllText: { - color: colors.primary, - fontSize: 13, - fontWeight: '700', - marginRight: 4, + fontSize: 14, + fontWeight: '600', }, catalogList: { paddingHorizontal: 16, - paddingBottom: 12, - paddingTop: 6, }, }); diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 683de09..d0d156f 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -15,7 +15,7 @@ import { RootStackParamList } from '../../navigation/AppNavigator'; import { StreamingContent, catalogService } from '../../services/catalogService'; import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; @@ -39,6 +39,7 @@ const POSTER_WIDTH = (width - 40) / 2.7; // Create a proper imperative handle with React.forwardRef and updated type const ContinueWatchingSection = React.forwardRef((props, ref) => { const navigation = useNavigation>(); + const { currentTheme } = useTheme(); const [continueWatchingItems, setContinueWatchingItems] = useState([]); const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); @@ -213,9 +214,9 @@ const ContinueWatchingSection = React.forwardRef((props, re - Continue Watching + Continue Watching ((props, re data={continueWatchingItems} renderItem={({ item }) => ( handleContentPress(item.id, item.type)} > @@ -240,12 +244,12 @@ const ContinueWatchingSection = React.forwardRef((props, re cachePolicy="memory-disk" /> {item.type === 'series' && item.season && item.episode && ( - - + + S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')} {item.episodeTitle && ( - + {item.episodeTitle} )} @@ -256,7 +260,7 @@ const ContinueWatchingSection = React.forwardRef((props, re @@ -295,7 +299,6 @@ const styles = StyleSheet.create({ title: { fontSize: 18, fontWeight: '800', - color: colors.highEmphasis, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, @@ -321,12 +324,10 @@ const styles = StyleSheet.create({ overflow: 'hidden', position: 'relative', elevation: 8, - shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', }, contentItemContainer: { width: '100%', @@ -347,17 +348,13 @@ const styles = StyleSheet.create({ right: 0, padding: 4, paddingHorizontal: 8, - backgroundColor: 'rgba(0, 0, 0, 0.7)', }, episodeInfo: { fontSize: 12, fontWeight: 'bold', - color: colors.white, }, episodeTitle: { fontSize: 10, - color: colors.white, - opacity: 0.9, }, progressBarContainer: { position: 'absolute', @@ -365,20 +362,10 @@ const styles = StyleSheet.create({ left: 0, right: 0, height: 3, - backgroundColor: 'rgba(0, 0, 0, 0.5)', + backgroundColor: 'rgba(0,0,0,0.5)', }, progressBar: { height: '100%', - backgroundColor: colors.primary, - }, - emptyContainer: { - paddingHorizontal: 16, - justifyContent: 'center', - alignItems: 'center', - }, - emptyText: { - color: colors.textMuted, - fontSize: 14, }, }); diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index b155ae5..eb4467f 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -32,7 +32,6 @@ import { useSettings } from '../../hooks/useSettings'; import { TMDBService } from '../../services/tmdbService'; import { logger } from '../../utils/logger'; import { useTheme } from '../../contexts/ThemeContext'; -import type { Theme } from '../../contexts/ThemeContext'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -47,10 +46,18 @@ const { width, height } = Dimensions.get('window'); const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const navigation = useNavigation>(); - const { settings } = useSettings(); const { currentTheme } = useTheme(); - const [logoUrl, setLogoUrl] = useState(null); const [bannerUrl, setBannerUrl] = useState(null); + const [logoUrl, setLogoUrl] = useState(null); + const [logoLoaded, setLogoLoaded] = useState(false); + const [bannerLoaded, setBannerLoaded] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(true); + const [logoError, setLogoError] = useState(false); + const [bannerError, setBannerError] = useState(false); + const { settings } = useSettings(); + const logoOpacity = useSharedValue(0); + const bannerOpacity = useSharedValue(0); + const posterOpacity = useSharedValue(0); const prevContentIdRef = useRef(null); // Add state for tracking logo load errors const [logoLoadError, setLogoLoadError] = useState(false); @@ -58,11 +65,6 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const logoFetchInProgress = useRef(false); // Animation values - const posterOpacity = useSharedValue(0); - const logoOpacity = useSharedValue(0); - const contentOpacity = useSharedValue(1); // Start visible - const buttonsOpacity = useSharedValue(1); - const posterAnimatedStyle = useAnimatedStyle(() => ({ opacity: posterOpacity.value, })); @@ -71,6 +73,9 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat opacity: logoOpacity.value, })); + const contentOpacity = useSharedValue(1); // Start visible + const buttonsOpacity = useSharedValue(1); + const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: contentOpacity.value, })); diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index 3948394..d9eb511 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -13,7 +13,7 @@ import { NavigationProp } from '@react-navigation/native'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles/colors'; +import { useTheme } from '../../contexts/ThemeContext'; import { stremioService } from '../../services/stremioService'; import { tmdbService } from '../../services/tmdbService'; import { useLibrary } from '../../hooks/useLibrary'; @@ -47,6 +47,7 @@ export const ThisWeekSection = () => { const { libraryItems, loading: libraryLoading } = useLibrary(); const [episodes, setEpisodes] = useState([]); const [loading, setLoading] = useState(true); + const { currentTheme } = useTheme(); const fetchThisWeekEpisodes = useCallback(async () => { if (libraryItems.length === 0) { @@ -172,7 +173,7 @@ export const ThisWeekSection = () => { if (loading) { return ( - + ); } @@ -217,26 +218,27 @@ export const ThisWeekSection = () => { - + {isReleased ? 'Released' : 'Coming Soon'} {item.vote_average > 0 && ( - + - + {item.vote_average.toFixed(1)} @@ -244,18 +246,18 @@ export const ThisWeekSection = () => { - + {item.seriesName} - + S{item.season}:E{item.episode} - {item.title} {item.overview ? ( - + {item.overview} ) : null} - + {formattedDate} @@ -268,10 +270,10 @@ export const ThisWeekSection = () => { return ( - This Week + This Week - View All - + View All + @@ -303,7 +305,6 @@ const styles = StyleSheet.create({ title: { fontSize: 18, fontWeight: 'bold', - color: colors.text, }, viewAllButton: { flexDirection: 'row', @@ -311,7 +312,6 @@ const styles = StyleSheet.create({ }, viewAllText: { fontSize: 14, - color: colors.lightGray, marginRight: 4, }, listContent: { @@ -358,14 +358,9 @@ const styles = StyleSheet.create({ paddingVertical: 4, borderRadius: 4, }, - releasedBadge: { - backgroundColor: colors.success + 'CC', // 80% opacity - }, - upcomingBadge: { - backgroundColor: colors.primary + 'CC', // 80% opacity - }, + releasedBadge: {}, + upcomingBadge: {}, badgeText: { - color: '#ffffff', fontSize: 10, fontWeight: 'bold', marginLeft: 4, @@ -373,13 +368,11 @@ const styles = StyleSheet.create({ ratingBadge: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.8)', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, }, ratingText: { - color: colors.primary, fontSize: 10, fontWeight: 'bold', marginLeft: 4, @@ -388,24 +381,19 @@ const styles = StyleSheet.create({ width: '100%', }, seriesName: { - color: colors.text, fontSize: 16, fontWeight: 'bold', marginBottom: 4, }, episodeTitle: { - color: colors.lightGray, fontSize: 14, marginBottom: 4, }, overview: { - color: colors.lightGray, fontSize: 12, marginBottom: 4, - opacity: 0.8, }, releaseDate: { - color: colors.primary, fontSize: 12, fontWeight: 'bold', }, From 29347ee028fae9491bd8bda78f30adc8d9cf0a0d Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 02:44:38 +0530 Subject: [PATCH 31/38] Refactor CalendarSection and CalendarScreen components to integrate theme context for improved UI consistency This update enhances the CalendarSection and CalendarScreen components by incorporating the ThemeContext, allowing for dynamic theming throughout the calendar interface. Styles have been adjusted to reflect the current theme colors, improving visual consistency and user experience. Key changes include updates to button styles, text colors, and background settings, ensuring a cohesive interface that adapts to different themes. Additionally, the CalendarSection's date handling logic has been optimized for better performance. --- src/components/calendar/CalendarSection.tsx | 329 ++++++++++---------- src/screens/CalendarScreen.tsx | 107 +++---- src/screens/NotificationSettingsScreen.tsx | 151 +++++---- 3 files changed, 276 insertions(+), 311 deletions(-) diff --git a/src/components/calendar/CalendarSection.tsx b/src/components/calendar/CalendarSection.tsx index 7bafa91..7050b90 100644 --- a/src/components/calendar/CalendarSection.tsx +++ b/src/components/calendar/CalendarSection.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, @@ -8,24 +8,14 @@ import { Dimensions } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../../styles/colors'; -import { - format, - addMonths, - subMonths, - startOfMonth, - endOfMonth, - isSameMonth, - isSameDay, - getDay, - isToday, - parseISO -} from 'date-fns'; +import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { useTheme } from '../../contexts/ThemeContext'; const { width } = Dimensions.get('window'); const COLUMN_COUNT = 7; // 7 days in a week -const DAY_ITEM_SIZE = width / 9; // Slightly smaller than 1/7 to fit all days +const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days +const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; interface CalendarEpisode { id: string; @@ -54,37 +44,40 @@ const DayItem = ({ isSelected, hasEvents, onPress -}: DayItemProps) => ( - onPress(date)} - > - - {date.getDate()} - - {hasEvents && ( - - )} - -); +}: DayItemProps) => { + const { currentTheme } = useTheme(); + return ( + onPress(date)} + > + + {date.getDate()} + + {hasEvents && ( + + )} + + ); +}; export const CalendarSection: React.FC = ({ episodes = [], onSelectDate }) => { - console.log(`[CalendarSection] Rendering with ${episodes.length} episodes`); + const { currentTheme } = useTheme(); const [currentDate, setCurrentDate] = useState(new Date()); - const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); const scrollViewRef = useRef(null); // Map of dates with episodes @@ -97,7 +90,7 @@ export const CalendarSection: React.FC = ({ episodes.forEach(episode => { if (episode.releaseDate) { - const releaseDate = parseISO(episode.releaseDate); + const releaseDate = new Date(episode.releaseDate); const dateKey = format(releaseDate, 'yyyy-MM-dd'); dateMap[dateKey] = true; } @@ -107,201 +100,194 @@ export const CalendarSection: React.FC = ({ setDatesWithEpisodes(dateMap); }, [episodes]); - const goToPreviousMonth = () => { - setCurrentDate(prevDate => subMonths(prevDate, 1)); - }; + const goToPreviousMonth = useCallback(() => { + setCurrentDate(prev => subMonths(prev, 1)); + }, []); - const goToNextMonth = () => { - setCurrentDate(prevDate => addMonths(prevDate, 1)); - }; + const goToNextMonth = useCallback(() => { + setCurrentDate(prev => addMonths(prev, 1)); + }, []); - const handleDayPress = (date: Date) => { + const handleDateSelect = useCallback((date: Date) => { setSelectedDate(date); - if (onSelectDate) { - onSelectDate(date); + onSelectDate?.(date); + }, [onSelectDate]); + + const renderDays = () => { + const start = startOfMonth(currentDate); + const end = endOfMonth(currentDate); + const days = eachDayOfInterval({ start, end }); + + // Get the day of the week for the first day (0-6) + const firstDayOfWeek = start.getDay(); + + // Add empty days at the start + const emptyDays = Array(firstDayOfWeek).fill(null); + + // Calculate remaining days to fill the last row + const totalDays = emptyDays.length + days.length; + const remainingDays = 7 - (totalDays % 7); + const endEmptyDays = remainingDays === 7 ? [] : Array(remainingDays).fill(null); + + const allDays = [...emptyDays, ...days, ...endEmptyDays]; + const weeks = []; + + for (let i = 0; i < allDays.length; i += 7) { + weeks.push(allDays.slice(i, i + 7)); } + + return weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + if (!day) { + return ; + } + + const isCurrentMonth = isSameMonth(day, currentDate); + const isCurrentDay = isToday(day); + const isSelected = selectedDate && isSameDay(day, selectedDate); + const hasEvents = datesWithEpisodes[format(day, 'yyyy-MM-dd')] || false; + + return ( + handleDateSelect(day)} + > + + {format(day, 'd')} + + {hasEvents && ( + + )} + + ); + })} + + )); }; - // Generate days for the current month view - const generateDaysForMonth = () => { - const monthStart = startOfMonth(currentDate); - const monthEnd = endOfMonth(currentDate); - const startDate = new Date(monthStart); - - // Adjust the start date to the beginning of the week - const dayOfWeek = getDay(startDate); - startDate.setDate(startDate.getDate() - dayOfWeek); - - // Ensure we have 6 complete weeks in our view - const endDate = new Date(monthEnd); - const lastDayOfWeek = getDay(endDate); - if (lastDayOfWeek < 6) { - endDate.setDate(endDate.getDate() + (6 - lastDayOfWeek)); - } - - // Get dates for a complete 6-week calendar - const totalDaysNeeded = 42; // 6 weeks × 7 days - const daysInView = []; - - let currentDateInView = new Date(startDate); - for (let i = 0; i < totalDaysNeeded; i++) { - daysInView.push(new Date(currentDateInView)); - currentDateInView.setDate(currentDateInView.getDate() + 1); - } - - return daysInView; - }; - - const dayItems = generateDaysForMonth(); - - // Break days into rows (6 rows of 7 days each) - const rows = []; - for (let i = 0; i < dayItems.length; i += COLUMN_COUNT) { - rows.push(dayItems.slice(i, i + COLUMN_COUNT)); - } - - // Get weekday names for header - const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - return ( - - - - + + + + - + {format(currentDate, 'MMMM yyyy')} - - + + - - + + {weekDays.map((day, index) => ( - - {day} - + + {day} + ))} - - - {rows.map((row, rowIndex) => ( - - {row.map((date, cellIndex) => { - const isCurrentMonthDay = isSameMonth(date, currentDate); - const isSelectedToday = isToday(date); - const isDateSelected = isSameDay(date, selectedDate); - - // Check if this date has episodes - const dateKey = format(date, 'yyyy-MM-dd'); - const hasEvents = datesWithEpisodes[dateKey] || false; - - // Log every 7 days to avoid console spam - if (cellIndex === 0 && rowIndex === 0) { - console.log(`[CalendarSection] Sample date check - ${dateKey}: hasEvents=${hasEvents}`); - } - - return ( - - ); - })} - - ))} + + + {renderDays()} - + ); }; const styles = StyleSheet.create({ container: { - backgroundColor: colors.darkBackground, - marginBottom: 12, - borderRadius: 8, - overflow: 'hidden', - borderWidth: 1, - borderColor: colors.border, + width: '100%', }, header: { flexDirection: 'row', - alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 12, - paddingHorizontal: 16, + alignItems: 'center', + padding: 16, borderBottomWidth: 1, - borderBottomColor: colors.border, }, headerButton: { padding: 8, }, - monthTitle: { + headerTitle: { fontSize: 18, fontWeight: 'bold', - color: colors.text, }, - weekHeader: { + weekDaysContainer: { flexDirection: 'row', + justifyContent: 'space-around', padding: 8, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - weekHeaderItem: { - width: DAY_ITEM_SIZE, - alignItems: 'center', }, weekDayText: { fontSize: 12, - color: colors.lightGray, }, - calendarGrid: { + daysContainer: { padding: 8, }, - row: { + weekRow: { flexDirection: 'row', justifyContent: 'space-around', marginBottom: 8, }, - dayItem: { - width: DAY_ITEM_SIZE, - height: DAY_ITEM_SIZE, + dayButton: { + width: 36, + height: 36, justifyContent: 'center', alignItems: 'center', - borderRadius: DAY_ITEM_SIZE / 2, + borderRadius: 18, + borderWidth: 1, + borderColor: 'transparent', }, dayText: { fontSize: 14, - color: colors.text, }, - otherMonthDay: { - color: colors.lightGray + '80', // 50% opacity + emptyDay: { + width: 36, + height: 36, + }, + eventDot: { + width: 4, + height: 4, + borderRadius: 2, + position: 'absolute', + bottom: 6, }, todayItem: { - backgroundColor: colors.primary + '30', // 30% opacity borderWidth: 1, - borderColor: colors.primary, }, selectedItem: { - backgroundColor: colors.primary + '60', // 60% opacity borderWidth: 1, - borderColor: colors.primary, }, todayText: { fontWeight: 'bold', - color: colors.primary, }, selectedDayText: { fontWeight: 'bold', - color: colors.text, }, dayWithEvents: { position: 'relative', @@ -312,6 +298,5 @@ const styles = StyleSheet.create({ width: 4, height: 4, borderRadius: 2, - backgroundColor: colors.primary, }, }); \ No newline at end of file diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 55a3050..dd7b0c2 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -9,7 +9,6 @@ import { RefreshControl, SafeAreaView, StatusBar, - useColorScheme, Dimensions, SectionList } from 'react-native'; @@ -18,7 +17,7 @@ import { NavigationProp } from '@react-navigation/native'; import { Image } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors } from '../styles/colors'; +import { useTheme } from '../contexts/ThemeContext'; import { RootStackParamList } from '../navigation/AppNavigator'; import { stremioService } from '../services/stremioService'; import { useLibrary } from '../hooks/useLibrary'; @@ -53,6 +52,7 @@ interface CalendarSection { const CalendarScreen = () => { const navigation = useNavigation>(); const { libraryItems, loading: libraryLoading } = useLibrary(); + const { currentTheme } = useTheme(); logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`); const [calendarData, setCalendarData] = useState([]); const [loading, setLoading] = useState(true); @@ -270,7 +270,7 @@ const CalendarScreen = () => { return ( handleEpisodePress(item)} activeOpacity={0.7} > @@ -287,18 +287,18 @@ const CalendarScreen = () => { - + {item.seriesName} {hasReleaseDate ? ( <> - + S{item.season}:E{item.episode} - {item.title} {item.overview ? ( - + {item.overview} ) : null} @@ -308,9 +308,9 @@ const CalendarScreen = () => { - {formattedDate} + {formattedDate} {item.vote_average > 0 && ( @@ -318,9 +318,9 @@ const CalendarScreen = () => { - + {item.vote_average.toFixed(1)} @@ -329,16 +329,16 @@ const CalendarScreen = () => { ) : ( <> - + No scheduled episodes - Check back later + Check back later )} @@ -349,8 +349,13 @@ const CalendarScreen = () => { }; const renderSectionHeader = ({ section }: { section: CalendarSection }) => ( - - {section.title} + + + {section.title} + ); @@ -386,22 +391,22 @@ const CalendarScreen = () => { if (libraryItems.length === 0 && !libraryLoading) { return ( - + - + navigation.goBack()} > - + - Calendar + Calendar - + Your library is empty @@ -423,10 +428,10 @@ const CalendarScreen = () => { if (loading && !refreshing) { return ( - + - + Loading calendar... @@ -434,27 +439,27 @@ const CalendarScreen = () => { } return ( - + - + navigation.goBack()} > - + - Calendar + Calendar {selectedDate && filteredEpisodes.length > 0 && ( - - + + Showing episodes for {format(selectedDate, 'MMMM d, yyyy')} - + )} @@ -474,22 +479,22 @@ const CalendarScreen = () => { } /> ) : selectedDate && filteredEpisodes.length === 0 ? ( - - + + No episodes for {format(selectedDate, 'MMMM d, yyyy')} - + Show All Episodes @@ -505,18 +510,18 @@ const CalendarScreen = () => { } /> ) : ( - - + + No upcoming episodes found - + Add series to your library to see their upcoming episodes here @@ -528,7 +533,6 @@ const CalendarScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.darkBackground, }, listContent: { paddingBottom: 20, @@ -539,19 +543,15 @@ const styles = StyleSheet.create({ alignItems: 'center', }, loadingText: { - color: colors.text, marginTop: 10, fontSize: 16, }, sectionHeader: { - backgroundColor: colors.darkBackground, paddingVertical: 8, paddingHorizontal: 16, borderBottomWidth: 1, - borderBottomColor: colors.border, }, sectionTitle: { - color: colors.text, fontSize: 18, fontWeight: 'bold', }, @@ -559,7 +559,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', padding: 12, borderBottomWidth: 1, - borderBottomColor: colors.border + '20', }, poster: { width: 120, @@ -572,18 +571,15 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', }, seriesName: { - color: colors.text, fontSize: 16, fontWeight: 'bold', marginBottom: 4, }, episodeTitle: { - color: colors.lightGray, fontSize: 14, lineHeight: 20, }, overview: { - color: colors.lightGray, fontSize: 12, marginTop: 4, lineHeight: 16, @@ -599,7 +595,6 @@ const styles = StyleSheet.create({ alignItems: 'center', }, date: { - color: colors.lightGray, fontSize: 14, marginLeft: 4, }, @@ -608,7 +603,6 @@ const styles = StyleSheet.create({ alignItems: 'center', }, rating: { - color: colors.primary, fontSize: 14, marginLeft: 4, fontWeight: 'bold', @@ -620,14 +614,12 @@ const styles = StyleSheet.create({ padding: 20, }, emptyText: { - color: colors.text, fontSize: 18, fontWeight: 'bold', marginTop: 16, textAlign: 'center', }, emptySubtext: { - color: colors.lightGray, fontSize: 14, marginTop: 8, textAlign: 'center', @@ -638,10 +630,8 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 12, borderBottomWidth: 1, - borderBottomColor: colors.border, }, filterInfoText: { - color: colors.text, fontSize: 16, fontWeight: 'bold', }, @@ -655,7 +645,6 @@ const styles = StyleSheet.create({ padding: 20, }, emptyFilterText: { - color: colors.text, fontSize: 18, fontWeight: 'bold', marginTop: 16, @@ -664,11 +653,9 @@ const styles = StyleSheet.create({ clearFilterButtonLarge: { marginTop: 20, padding: 16, - backgroundColor: colors.primary, borderRadius: 8, }, clearFilterButtonText: { - color: colors.text, fontSize: 16, fontWeight: 'bold', }, @@ -681,7 +668,6 @@ const styles = StyleSheet.create({ padding: 8, }, headerTitle: { - color: colors.text, fontSize: 18, fontWeight: 'bold', marginLeft: 12, @@ -694,16 +680,13 @@ const styles = StyleSheet.create({ }, discoverButton: { padding: 16, - backgroundColor: colors.primary, borderRadius: 8, }, discoverButtonText: { - color: colors.text, fontSize: 16, fontWeight: 'bold', }, noEpisodesText: { - color: colors.text, fontSize: 14, marginBottom: 4, }, diff --git a/src/screens/NotificationSettingsScreen.tsx b/src/screens/NotificationSettingsScreen.tsx index 68eb422..360dfb4 100644 --- a/src/screens/NotificationSettingsScreen.tsx +++ b/src/screens/NotificationSettingsScreen.tsx @@ -11,7 +11,7 @@ import { StatusBar, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { colors } from '../styles/colors'; +import { useTheme } from '../contexts/ThemeContext'; import { notificationService, NotificationSettings } from '../services/notificationService'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; @@ -19,6 +19,7 @@ import { logger } from '../utils/logger'; const NotificationSettingsScreen = () => { const navigation = useNavigation(); + const { currentTheme } = useTheme(); const [settings, setSettings] = useState({ enabled: true, newEpisodeNotifications: true, @@ -155,36 +156,36 @@ const NotificationSettingsScreen = () => { if (loading) { return ( - - + + navigation.goBack()} > - + - Notification Settings + Notification Settings - Loading settings... + Loading settings... ); } return ( - + - + navigation.goBack()} > - + - Notification Settings + Notification Settings @@ -193,72 +194,72 @@ const NotificationSettingsScreen = () => { entering={FadeIn.duration(300)} exiting={FadeOut.duration(200)} > - - General + + General - + - - Enable Notifications + + Enable Notifications updateSetting('enabled', value)} - trackColor={{ false: colors.border, true: colors.primary + '80' }} - thumbColor={settings.enabled ? colors.primary : colors.lightGray} + trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }} + thumbColor={settings.enabled ? currentTheme.colors.primary : currentTheme.colors.lightGray} /> {settings.enabled && ( <> - - Notification Types + + Notification Types - + - - New Episodes + + New Episodes updateSetting('newEpisodeNotifications', value)} - trackColor={{ false: colors.border, true: colors.primary + '80' }} - thumbColor={settings.newEpisodeNotifications ? colors.primary : colors.lightGray} + trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }} + thumbColor={settings.newEpisodeNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray} /> - + - - Upcoming Shows + + Upcoming Shows updateSetting('upcomingShowsNotifications', value)} - trackColor={{ false: colors.border, true: colors.primary + '80' }} - thumbColor={settings.upcomingShowsNotifications ? colors.primary : colors.lightGray} + trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }} + thumbColor={settings.upcomingShowsNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray} /> - + - - Reminders + + Reminders updateSetting('reminderNotifications', value)} - trackColor={{ false: colors.border, true: colors.primary + '80' }} - thumbColor={settings.reminderNotifications ? colors.primary : colors.lightGray} + trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }} + thumbColor={settings.reminderNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray} /> - - Notification Timing + + Notification Timing - + When should you be notified before an episode airs? @@ -268,13 +269,24 @@ const NotificationSettingsScreen = () => { key={hours} style={[ styles.timingOption, - settings.timeBeforeAiring === hours && styles.selectedTimingOption + { + backgroundColor: currentTheme.colors.elevation1, + borderColor: currentTheme.colors.border + }, + settings.timeBeforeAiring === hours && { + backgroundColor: currentTheme.colors.primary + '30', + borderColor: currentTheme.colors.primary, + } ]} onPress={() => setTimeBeforeAiring(hours)} > {hours === 1 ? '1 hour' : `${hours} hours`} @@ -283,27 +295,37 @@ const NotificationSettingsScreen = () => { - - Advanced + + Advanced - - Reset All Notifications + + Reset All Notifications - - + + {countdown !== null ? `Notification in ${countdown}s...` : 'Test Notification (1min)'} @@ -315,16 +337,16 @@ const NotificationSettingsScreen = () => { - + Notification will appear in {countdown} seconds )} - + This will cancel all scheduled notifications. You'll need to re-enable them manually. @@ -339,7 +361,6 @@ const NotificationSettingsScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.darkBackground, }, header: { flexDirection: 'row', @@ -348,7 +369,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, - borderBottomColor: colors.border, }, backButton: { padding: 8, @@ -356,7 +376,6 @@ const styles = StyleSheet.create({ headerTitle: { fontSize: 18, fontWeight: 'bold', - color: colors.text, }, content: { flex: 1, @@ -367,18 +386,15 @@ const styles = StyleSheet.create({ alignItems: 'center', }, loadingText: { - color: colors.text, fontSize: 16, }, section: { padding: 16, borderBottomWidth: 1, - borderBottomColor: colors.border, }, sectionTitle: { fontSize: 16, fontWeight: 'bold', - color: colors.text, marginBottom: 16, }, settingItem: { @@ -387,7 +403,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, - borderBottomColor: colors.border + '50', }, settingInfo: { flexDirection: 'row', @@ -395,12 +410,10 @@ const styles = StyleSheet.create({ }, settingText: { fontSize: 16, - color: colors.text, marginLeft: 12, }, settingDescription: { fontSize: 14, - color: colors.lightGray, marginBottom: 16, }, timingOptions: { @@ -410,47 +423,32 @@ const styles = StyleSheet.create({ marginTop: 8, }, timingOption: { - backgroundColor: colors.elevation1, paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, borderWidth: 1, - borderColor: colors.border, marginBottom: 8, width: '48%', alignItems: 'center', }, - selectedTimingOption: { - backgroundColor: colors.primary + '30', - borderColor: colors.primary, - }, timingText: { - color: colors.text, fontSize: 14, }, - selectedTimingText: { - color: colors.primary, - fontWeight: 'bold', - }, resetButton: { flexDirection: 'row', alignItems: 'center', padding: 12, - backgroundColor: colors.error + '20', borderRadius: 8, borderWidth: 1, - borderColor: colors.error + '50', marginBottom: 8, }, resetButtonText: { - color: colors.error, fontSize: 16, fontWeight: 'bold', marginLeft: 8, }, resetDescription: { fontSize: 12, - color: colors.lightGray, fontStyle: 'italic', }, countdownContainer: { @@ -458,14 +456,13 @@ const styles = StyleSheet.create({ alignItems: 'center', marginTop: 8, padding: 8, - backgroundColor: colors.primary + '10', + backgroundColor: 'rgba(0, 0, 0, 0.1)', borderRadius: 4, }, countdownIcon: { marginRight: 8, }, countdownText: { - color: colors.primary, fontSize: 14, }, }); From d126d0ec409b7a893c8df7f8356dde2a88e01e9e Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 02:46:35 +0530 Subject: [PATCH 32/38] Refactor PlayerSettingsScreen to integrate ThemeContext for dynamic theming This update modifies the PlayerSettingsScreen component to utilize the ThemeContext, allowing for dynamic theming throughout the settings interface. Styles have been adjusted to reflect the current theme colors, enhancing visual consistency and user experience. Key changes include the removal of the isDarkMode prop, updates to text colors, and background settings, ensuring a cohesive interface that adapts to different themes. --- src/screens/PlayerSettingsScreen.tsx | 117 +++++++++++++-------------- 1 file changed, 57 insertions(+), 60 deletions(-) diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index f1530dc..93fd342 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -6,14 +6,13 @@ import { ScrollView, SafeAreaView, Platform, - useColorScheme, TouchableOpacity, StatusBar, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSettings, AppSettings } from '../hooks/useSettings'; -import { colors } from '../styles/colors'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { useTheme } from '../contexts/ThemeContext'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -21,7 +20,6 @@ interface SettingItemProps { title: string; description?: string; icon: string; - isDarkMode: boolean; isSelected: boolean; onPress: () => void; isLast?: boolean; @@ -31,67 +29,69 @@ const SettingItem: React.FC = ({ title, description, icon, - isDarkMode, isSelected, onPress, isLast, -}) => ( - - - - - - - - {title} - - {description && ( +}) => { + const { currentTheme } = useTheme(); + + return ( + + + + + + - {description} + {title} + {description && ( + + {description} + + )} + + {isSelected && ( + )} - {isSelected && ( - - )} - - -); + + ); +}; const PlayerSettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); - const systemColorScheme = useColorScheme(); - const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; + const { currentTheme } = useTheme(); const navigation = useNavigation(); const playerOptions = [ @@ -144,13 +144,13 @@ const PlayerSettingsScreen: React.FC = () => { @@ -162,13 +162,13 @@ const PlayerSettingsScreen: React.FC = () => { Video Player @@ -183,7 +183,7 @@ const PlayerSettingsScreen: React.FC = () => { PLAYER SELECTION @@ -192,9 +192,7 @@ const PlayerSettingsScreen: React.FC = () => { style={[ styles.card, { - backgroundColor: isDarkMode - ? colors.elevation2 - : colors.white, + backgroundColor: currentTheme.colors.elevation2, }, ]} > @@ -204,7 +202,6 @@ const PlayerSettingsScreen: React.FC = () => { title={option.title} description={option.description} icon={option.icon} - isDarkMode={isDarkMode} isSelected={ Platform.OS === 'ios' ? settings.preferredPlayer === option.id From 953556c65a5721f9a374eed1e42872d3fb1fed4a Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 02:52:47 +0530 Subject: [PATCH 33/38] Refactor CalendarSection, NotificationSettingsScreen, and SettingsScreen for improved theme integration and code consistency This update enhances the CalendarSection, NotificationSettingsScreen, and SettingsScreen components by removing unnecessary props related to dark mode and integrating the ThemeContext for dynamic theming. Styles have been adjusted to reflect the current theme colors, improving visual consistency across the application. Key changes include updates to button styles, text colors, and background settings, ensuring a cohesive interface that adapts to different themes. Additionally, code formatting has been improved for better readability. --- src/components/calendar/CalendarSection.tsx | 48 +++---- src/screens/NotificationSettingsScreen.tsx | 2 +- src/screens/SettingsScreen.tsx | 151 +++++--------------- 3 files changed, 62 insertions(+), 139 deletions(-) diff --git a/src/components/calendar/CalendarSection.tsx b/src/components/calendar/CalendarSection.tsx index 7050b90..2d443e1 100644 --- a/src/components/calendar/CalendarSection.tsx +++ b/src/components/calendar/CalendarSection.tsx @@ -47,28 +47,28 @@ const DayItem = ({ }: DayItemProps) => { const { currentTheme } = useTheme(); return ( - onPress(date)} - > - onPress(date)} + > + - {date.getDate()} - - {hasEvents && ( + today && styles.todayText, + isSelected && styles.selectedDayText + ]}> + {date.getDate()} + + {hasEvents && ( - )} - - ); + )} + +); }; export const CalendarSection: React.FC = ({ @@ -117,13 +117,13 @@ export const CalendarSection: React.FC = ({ const start = startOfMonth(currentDate); const end = endOfMonth(currentDate); const days = eachDayOfInterval({ start, end }); - + // Get the day of the week for the first day (0-6) const firstDayOfWeek = start.getDay(); // Add empty days at the start const emptyDays = Array(firstDayOfWeek).fill(null); - + // Calculate remaining days to fill the last row const totalDays = emptyDays.length + days.length; const remainingDays = 7 - (totalDays % 7); @@ -135,7 +135,7 @@ export const CalendarSection: React.FC = ({ for (let i = 0; i < allDays.length; i += 7) { weeks.push(allDays.slice(i, i + 7)); } - + return weeks.map((week, weekIndex) => ( {week.map((day, dayIndex) => { @@ -201,7 +201,7 @@ export const CalendarSection: React.FC = ({ - + {weekDays.map((day, index) => ( = ({ ))} - + {renderDays()} diff --git a/src/screens/NotificationSettingsScreen.tsx b/src/screens/NotificationSettingsScreen.tsx index 360dfb4..dda3dca 100644 --- a/src/screens/NotificationSettingsScreen.tsx +++ b/src/screens/NotificationSettingsScreen.tsx @@ -314,7 +314,7 @@ const NotificationSettingsScreen = () => { = ({ children, isDarkMode, title }) => { +const SettingsCard: React.FC = ({ children, title }) => { const { currentTheme } = useTheme(); return ( @@ -47,14 +45,14 @@ const SettingsCard: React.FC = ({ children, isDarkMode, title {title && ( {title.toUpperCase()} )} {children} @@ -69,7 +67,6 @@ interface SettingItemProps { renderControl: () => React.ReactNode; isLast?: boolean; onPress?: () => void; - isDarkMode: boolean; badge?: string | number; } @@ -80,7 +77,6 @@ const SettingItem: React.FC = ({ renderControl, isLast = false, onPress, - isDarkMode, badge }) => { const { currentTheme } = useTheme(); @@ -92,22 +88,22 @@ const SettingItem: React.FC = ({ style={[ styles.settingItem, !isLast && styles.settingItemBorder, - { borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' } + { borderBottomColor: 'rgba(255,255,255,0.08)' } ]} > - + {title} {description && ( - + {description} )} @@ -127,8 +123,6 @@ const SettingItem: React.FC = ({ const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); - const systemColorScheme = useColorScheme(); - const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile } = useTraktContext(); @@ -141,23 +135,6 @@ const SettingsScreen: React.FC = () => { const [mdblistKeySet, setMdblistKeySet] = useState(false); const [discoverDataSource, setDiscoverDataSource] = useState(DataSource.STREMIO_ADDONS); - // Force consistent status bar settings - useEffect(() => { - const applyStatusBarConfig = () => { - StatusBar.setBarStyle('light-content'); - if (Platform.OS === 'android') { - StatusBar.setTranslucent(true); - StatusBar.setBackgroundColor('transparent'); - } - }; - - applyStatusBarConfig(); - - // Re-apply on focus - const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); - return unsubscribe; - }, [navigation]); - const loadData = useCallback(async () => { try { // Load addon count and get their catalogs @@ -236,9 +213,9 @@ const SettingsScreen: React.FC = () => { ); @@ -246,7 +223,7 @@ const SettingsScreen: React.FC = () => { ); @@ -264,52 +241,51 @@ const SettingsScreen: React.FC = () => { return ( - {/* Fixed position header background to prevent shifts */} - - + - {/* Header Section with proper top spacing */} - + Settings - - Reset - - {/* Content Container */} - + navigation.navigate('TraktSettings')} + isLast={false} + /> + + + + navigation.navigate('ThemeSettings')} isLast={true} /> - + navigation.navigate('Calendar')} - isDarkMode={isDarkMode} /> { icon="notifications" renderControl={ChevronRight} onPress={() => navigation.navigate('NotificationSettings')} - isDarkMode={isDarkMode} isLast={true} /> - + navigation.navigate('Addons')} badge={addonCount} @@ -336,7 +310,6 @@ const SettingsScreen: React.FC = () => { title="Catalogs" description="Configure content sources" icon="view-list" - isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('CatalogSettings')} badge={catalogCount} @@ -345,23 +318,20 @@ const SettingsScreen: React.FC = () => { title="Home Screen" description="Customize layout and content" icon="home" - isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('HomeScreenSettings')} /> navigation.navigate('MDBListSettings')} /> navigation.navigate('LogoSourceSettings')} /> @@ -369,14 +339,13 @@ const SettingsScreen: React.FC = () => { title="TMDB" description="API & Metadata Settings" icon="movie-filter" - isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('TMDBSettings')} isLast={true} /> - + { : (settings.useExternalPlayer ? 'External Player' : 'Built-in Player') } icon="play-arrow" - isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('PlayerSettings')} isLast={true} /> - + ( { /> - - ( - updateSetting('enableDarkMode', value)} - /> - )} - isDarkMode={isDarkMode} - /> - navigation.navigate('ThemeSettings')} - isDarkMode={isDarkMode} - isLast - /> - - - + Version 1.0.0 @@ -484,18 +427,6 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 1, - }, - contentContainer: { - flex: 1, - zIndex: 1, - width: '100%', - }, header: { paddingHorizontal: 20, flexDirection: 'row', @@ -510,13 +441,10 @@ const styles = StyleSheet.create({ fontWeight: '800', letterSpacing: 0.3, }, - resetButton: { - paddingVertical: 8, - paddingHorizontal: 12, - }, - resetButtonText: { - fontSize: 16, - fontWeight: '600', + contentContainer: { + flex: 1, + zIndex: 1, + width: '100%', }, scrollView: { flex: 1, @@ -577,11 +505,6 @@ const styles = StyleSheet.create({ settingTextContainer: { flex: 1, }, - settingTitleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, settingTitle: { fontSize: 16, fontWeight: '500', From 64193b41549b2dfefea1e895ece754f70619f65d Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 03:01:21 +0530 Subject: [PATCH 34/38] Enhance Trakt integration by adding refreshAuthStatus function and ProfilesSettings screen This update introduces a new `refreshAuthStatus` function in the TraktContext and hooks, allowing for manual refresh of authentication status. Additionally, a new `ProfilesSettings` screen has been added to the navigation stack, enabling users to manage profiles. The SettingsScreen has been updated to trigger a refresh of the auth status when focused, improving user experience. Styling adjustments have been made to accommodate the new profiles feature. --- src/contexts/TraktContext.tsx | 1 + src/hooks/useTraktIntegration.ts | 13 +- src/navigation/AppNavigator.tsx | 17 ++ src/screens/ProfilesScreen.tsx | 441 ++++++++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 150 +++++++++- src/screens/TraktSettingsScreen.tsx | 16 +- 6 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 src/screens/ProfilesScreen.tsx diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx index b7949cb..05c27d1 100644 --- a/src/contexts/TraktContext.tsx +++ b/src/contexts/TraktContext.tsx @@ -9,6 +9,7 @@ interface TraktContextProps { watchedMovies: TraktWatchedItem[]; watchedShows: TraktWatchedItem[]; checkAuthStatus: () => Promise; + refreshAuthStatus: () => Promise; loadWatchedItems: () => Promise; isMovieWatched: (imdbId: string) => Promise; isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise; diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index d89ac19..692cdaa 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -8,6 +8,7 @@ export function useTraktIntegration() { const [userProfile, setUserProfile] = useState(null); const [watchedMovies, setWatchedMovies] = useState([]); const [watchedShows, setWatchedShows] = useState([]); + const [lastAuthCheck, setLastAuthCheck] = useState(Date.now()); // Check authentication status const checkAuthStatus = useCallback(async () => { @@ -22,6 +23,9 @@ export function useTraktIntegration() { } else { setUserProfile(null); } + + // Update the last auth check timestamp to trigger dependent components to update + setLastAuthCheck(Date.now()); } catch (error) { logger.error('[useTraktIntegration] Error checking auth status:', error); } finally { @@ -29,6 +33,12 @@ export function useTraktIntegration() { } }, []); + // Function to force refresh the auth status + const refreshAuthStatus = useCallback(async () => { + logger.log('[useTraktIntegration] Refreshing auth status'); + await checkAuthStatus(); + }, [checkAuthStatus]); + // Load watched items const loadWatchedItems = useCallback(async () => { if (!isAuthenticated) return; @@ -141,6 +151,7 @@ export function useTraktIntegration() { isMovieWatched, isEpisodeWatched, markMovieAsWatched, - markEpisodeAsWatched + markEpisodeAsWatched, + refreshAuthStatus }; } \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 2ee6a77..6720cda 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -38,6 +38,7 @@ import TraktSettingsScreen from '../screens/TraktSettingsScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import LogoSourceSettings from '../screens/LogoSourceSettings'; import ThemeScreen from '../screens/ThemeScreen'; +import ProfilesScreen from '../screens/ProfilesScreen'; // Stack navigator types export type RootStackParamList = { @@ -95,6 +96,7 @@ export type RootStackParamList = { PlayerSettings: undefined; LogoSourceSettings: undefined; ThemeSettings: undefined; + ProfilesSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -858,6 +860,21 @@ const AppNavigator = () => { }, }} /> + diff --git a/src/screens/ProfilesScreen.tsx b/src/screens/ProfilesScreen.tsx new file mode 100644 index 0000000..18c6130 --- /dev/null +++ b/src/screens/ProfilesScreen.tsx @@ -0,0 +1,441 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + FlatList, + Alert, + StatusBar, + Platform, + SafeAreaView, + TextInput, + Modal +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { useTheme } from '../contexts/ThemeContext'; +import { useTraktContext } from '../contexts/TraktContext'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +const PROFILE_STORAGE_KEY = 'user_profiles'; + +interface Profile { + id: string; + name: string; + avatar?: string; + isActive: boolean; + createdAt: number; +} + +const ProfilesScreen: React.FC = () => { + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); + + const [profiles, setProfiles] = useState([]); + const [showAddModal, setShowAddModal] = useState(false); + const [newProfileName, setNewProfileName] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + // Load profiles from AsyncStorage + const loadProfiles = useCallback(async () => { + try { + setIsLoading(true); + const storedProfiles = await AsyncStorage.getItem(PROFILE_STORAGE_KEY); + if (storedProfiles) { + setProfiles(JSON.parse(storedProfiles)); + } else { + // If no profiles exist, create a default one with the Trakt username + const defaultProfile: Profile = { + id: new Date().getTime().toString(), + name: userProfile?.username || 'Default', + isActive: true, + createdAt: new Date().getTime() + }; + setProfiles([defaultProfile]); + await AsyncStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify([defaultProfile])); + } + } catch (error) { + console.error('Error loading profiles:', error); + Alert.alert('Error', 'Failed to load profiles'); + } finally { + setIsLoading(false); + } + }, [userProfile]); + + // Add a focus listener to refresh authentication status + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + // Refresh the auth status when the screen comes into focus + refreshAuthStatus().then(() => { + if (isAuthenticated) { + loadProfiles(); + } + }); + }); + + return unsubscribe; + }, [navigation, refreshAuthStatus, isAuthenticated, loadProfiles]); + + // Save profiles to AsyncStorage + const saveProfiles = useCallback(async (updatedProfiles: Profile[]) => { + try { + await AsyncStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(updatedProfiles)); + } catch (error) { + console.error('Error saving profiles:', error); + Alert.alert('Error', 'Failed to save profiles'); + } + }, []); + + useEffect(() => { + // Only authenticated users can access profiles + if (!isAuthenticated) { + navigation.goBack(); + return; + } + + loadProfiles(); + }, [isAuthenticated, loadProfiles, navigation]); + + const handleAddProfile = useCallback(() => { + if (!newProfileName.trim()) { + Alert.alert('Error', 'Please enter a profile name'); + return; + } + + const newProfile: Profile = { + id: new Date().getTime().toString(), + name: newProfileName.trim(), + isActive: false, + createdAt: new Date().getTime() + }; + + const updatedProfiles = [...profiles, newProfile]; + setProfiles(updatedProfiles); + saveProfiles(updatedProfiles); + setNewProfileName(''); + setShowAddModal(false); + }, [newProfileName, profiles, saveProfiles]); + + const handleSelectProfile = useCallback((id: string) => { + const updatedProfiles = profiles.map(profile => ({ + ...profile, + isActive: profile.id === id + })); + + setProfiles(updatedProfiles); + saveProfiles(updatedProfiles); + }, [profiles, saveProfiles]); + + const handleDeleteProfile = useCallback((id: string) => { + // Prevent deleting the active profile + const isActiveProfile = profiles.find(p => p.id === id)?.isActive; + if (isActiveProfile) { + Alert.alert('Error', 'Cannot delete the active profile. Switch to another profile first.'); + return; + } + + // Prevent deleting the last profile + if (profiles.length <= 1) { + Alert.alert('Error', 'Cannot delete the only profile'); + return; + } + + Alert.alert( + 'Delete Profile', + 'Are you sure you want to delete this profile? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + const updatedProfiles = profiles.filter(profile => profile.id !== id); + setProfiles(updatedProfiles); + saveProfiles(updatedProfiles); + } + } + ] + ); + }, [profiles, saveProfiles]); + + const handleBack = () => { + navigation.goBack(); + }; + + const renderItem = ({ item }: { item: Profile }) => ( + + handleSelectProfile(item.id)} + > + + + + + + {item.name} + + {item.isActive && ( + + Active + + )} + + {!item.isActive && ( + handleDeleteProfile(item.id)} + > + + + )} + + + ); + + return ( + + + + + + + + + Profiles + + + + + item.id} + contentContainerStyle={styles.listContent} + ListHeaderComponent={ + + MANAGE PROFILES + + } + ListFooterComponent={ + setShowAddModal(true)} + > + + + Add New Profile + + + } + /> + + + {/* Modal for adding a new profile */} + setShowAddModal(false)} + > + + + + Create New Profile + + + + + + { + setNewProfileName(''); + setShowAddModal(false); + }} + > + Cancel + + + Create + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + paddingBottom: 8, + }, + backButton: { + padding: 8, + marginRight: 16, + borderRadius: 20, + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + }, + content: { + flex: 1, + paddingHorizontal: 16, + }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + marginTop: 24, + marginBottom: 12, + letterSpacing: 0.5, + }, + listContent: { + paddingBottom: 24, + }, + profileItem: { + marginBottom: 12, + }, + profileContent: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + }, + avatarContainer: { + marginRight: 16, + }, + profileInfo: { + flex: 1, + }, + profileName: { + fontSize: 16, + fontWeight: '500', + }, + activeLabel: { + fontSize: 12, + marginTop: 4, + fontWeight: '500', + }, + deleteButton: { + padding: 8, + }, + addButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + borderRadius: 12, + marginTop: 12, + }, + addButtonText: { + fontSize: 16, + fontWeight: '500', + marginLeft: 8, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.7)', + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + modalContent: { + width: '100%', + borderRadius: 16, + padding: 24, + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 24, + textAlign: 'center', + }, + input: { + width: '100%', + height: 50, + borderRadius: 8, + paddingHorizontal: 16, + marginBottom: 24, + borderWidth: 1, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + modalButton: { + flex: 1, + height: 44, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + cancelButton: { + marginRight: 8, + }, + createButton: { + marginLeft: 8, + }, +}); + +export default ProfilesScreen; \ No newline at end of file diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 23b0c4d..a0cd3b7 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -125,10 +125,27 @@ const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); - const { isAuthenticated, userProfile } = useTraktContext(); + const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); + // Add a useEffect to check authentication status on focus + useEffect(() => { + // This will reload the Trakt auth status whenever the settings screen is focused + const unsubscribe = navigation.addListener('focus', () => { + // Force a re-render when returning to this screen + // This will reflect the updated isAuthenticated state from the TraktContext + // Refresh auth status + if (isAuthenticated || userProfile) { + // Just to be cautious, log the current state + console.log('SettingsScreen focused, refreshing auth status. Current state:', { isAuthenticated, userProfile: userProfile?.username }); + } + refreshAuthStatus(); + }); + + return unsubscribe; + }, [navigation, isAuthenticated, userProfile, refreshAuthStatus]); + // States for dynamic content const [addonCount, setAddonCount] = useState(0); const [catalogCount, setCatalogCount] = useState(0); @@ -268,6 +285,81 @@ const SettingsScreen: React.FC = () => { /> + + {isAuthenticated ? ( + navigation.navigate('ProfilesSettings')} + isLast={true} + /> + ) : ( + + + + + + Sign in to use Profiles + + + Create multiple profiles for different users and preferences + + + + + + + + + Separate watchlists + + + + + + Content preferences + + + + + + + + Personalized recommendations + + + + + + Individual viewing history + + + + + navigation.navigate('TraktSettings')} + > + Connect with Trakt + + + + )} + + { .then(success => { if (success) { logger.log('[TraktSettingsScreen] Token exchange successful'); - checkAuthStatus(); + checkAuthStatus().then(() => { + // Show success message + Alert.alert( + 'Successfully Connected', + 'Your Trakt account has been connected successfully.', + [ + { + text: 'OK', + onPress: () => navigation.goBack() + } + ] + ); + }); } else { logger.error('[TraktSettingsScreen] Token exchange failed'); Alert.alert('Authentication Error', 'Failed to complete authentication with Trakt.'); @@ -116,7 +128,7 @@ const TraktSettingsScreen: React.FC = () => { setIsExchangingCode(false); } } - }, [response, checkAuthStatus, request?.codeVerifier]); + }, [response, checkAuthStatus, request?.codeVerifier, navigation]); const handleSignIn = () => { promptAsync(); // Trigger the authentication flow From 3cd345feadfab47e79e03516a7db4facf7018016 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 03:11:14 +0530 Subject: [PATCH 35/38] Refactor ShowRatingsScreen and related components for improved UI and performance This update enhances the ShowRatingsScreen by introducing animated views for better visual feedback and refactoring the RatingSourceToggle to use a map for button generation, improving code readability. Additionally, loading indicators have been updated with descriptive text, and various styles have been adjusted for consistency and improved aesthetics. Key changes include increased padding, margin adjustments, and the introduction of shadow effects for a more modern look. Overall, these modifications enhance user experience and maintainability of the code. --- src/screens/ShowRatingsScreen.tsx | 256 ++++++++++++++---------------- 1 file changed, 118 insertions(+), 138 deletions(-) diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 051c8a4..4f4560d 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -114,16 +114,16 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeas } return ( - - + {rating.toFixed(1)} - + {(isInaccurate || isCurrent) && ( )} - + ); }); @@ -141,44 +141,23 @@ const RatingSourceToggle = memo(({ ratingSource, setRatingSource }: { setRatingSource: (source: RatingSource) => void; }) => ( - Rating Source: + Rating Source: - setRatingSource('imdb')} - > - IMDb - - setRatingSource('tmdb')} - > - TMDB - - setRatingSource('tvmaze')} - > - TVMaze - + {['imdb', 'tmdb', 'tvmaze'].map((source) => ( + setRatingSource(source as RatingSource)} + > + {source.toUpperCase()} + + ))} )); @@ -189,6 +168,7 @@ const ShowInfo = memo(({ show }: { show: Show | null }) => ( source={{ uri: `https://image.tmdb.org/t/p/w500${show?.poster_path}` }} style={styles.poster} contentFit="cover" + transition={200} /> {show?.name} @@ -365,6 +345,7 @@ const ShowRatingsScreen = ({ route }: Props) => { + Loading show data... @@ -389,68 +370,59 @@ const ShowRatingsScreen = ({ route }: Props) => { + Loading content... }> {/* Legend */} - Rating Scale + Rating Scale - - - Awesome (9.0+) - - - - Great (8.0-8.9) - - - - Good (7.5-7.9) - - - - Regular (7.0-7.4) - - - - Bad (6.0-6.9) - - - - Garbage ({'<'}6.0) - + {[ + { color: '#186A3B', text: 'Awesome (9.0+)' }, + { color: '#28B463', text: 'Great (8.0-8.9)' }, + { color: '#F4D03F', text: 'Good (7.5-7.9)' }, + { color: '#F39C12', text: 'Regular (7.0-7.4)' }, + { color: '#E74C3C', text: 'Bad (6.0-6.9)' }, + { color: '#633974', text: 'Garbage (<6.0)' } + ].map((item, index) => ( + + + {item.text} + + ))} - + Rating differs significantly from IMDb - + Current season (ratings may change) @@ -458,10 +430,11 @@ const ShowRatingsScreen = ({ route }: Props) => { {/* Ratings Grid */} + Episode Ratings {/* Fixed Episode Column */} @@ -488,9 +461,13 @@ const ShowRatingsScreen = ({ route }: Props) => { {/* Seasons Header */} {seasons.map((season) => ( - + S{season.season_number} - + ))} {loadingSeasons && ( @@ -510,7 +487,11 @@ const ShowRatingsScreen = ({ route }: Props) => { {Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => ( {seasons.map((season) => ( - + {season.episodes[episodeIndex] && { isCurrentSeason={isCurrentSeason} /> } - + ))} {loadingSeasons && } @@ -544,24 +525,40 @@ const styles = StyleSheet.create({ scrollView: { flex: 1, }, + scrollViewContent: { + flexGrow: 1, + }, content: { - padding: 8, - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8, + padding: 12, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 12 : 12, }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', + gap: 12, + }, + loadingText: { + color: colors.lightGray, + fontSize: 14, + fontWeight: '500', }, showInfoContainer: { marginBottom: 12, }, + section: { + marginBottom: 12, + }, showInfo: { flexDirection: 'row', - marginBottom: 12, backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground, borderRadius: 8, - padding: 8, + padding: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, poster: { width: 80, @@ -570,44 +567,39 @@ const styles = StyleSheet.create({ }, showDetails: { flex: 1, - marginLeft: 8, + marginLeft: 12, justifyContent: 'center', }, showTitle: { fontSize: 18, fontWeight: '800', color: colors.white, - marginBottom: 2, + marginBottom: 4, letterSpacing: 0.5, }, showYear: { fontSize: 13, color: colors.lightGray, - marginBottom: 6, + marginBottom: 4, }, episodeCountContainer: { flexDirection: 'row', alignItems: 'center', gap: 4, + marginTop: 4, }, episodeCount: { - fontSize: 12, + fontSize: 13, color: colors.lightGray, }, - ratingSection: { - backgroundColor: colors.darkBackground, - borderRadius: 8, - padding: 8, + ratingSourceContainer: { marginBottom: 12, }, - ratingSourceContainer: { - marginBottom: 8, - }, - ratingSourceTitle: { - fontSize: 14, + sectionTitle: { + fontSize: 15, fontWeight: '700', color: colors.white, - marginBottom: 6, + marginBottom: 8, letterSpacing: 0.5, }, ratingSourceButtons: { @@ -629,56 +621,38 @@ const styles = StyleSheet.create({ }, sourceButtonText: { color: colors.lightGray, - fontSize: 14, + fontSize: 13, fontWeight: '600', }, sourceButtonTextActive: { color: colors.white, fontWeight: '700', }, - tmdbDisclaimer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.black + '40', - padding: 6, - borderRadius: 6, - marginTop: 8, - gap: 6, - }, - tmdbDisclaimerText: { - color: colors.lightGray, - fontSize: 12, - flex: 1, - lineHeight: 16, - }, legend: { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground, borderRadius: 8, - padding: 8, - marginBottom: 12, - }, - legendTitle: { - fontSize: 14, - fontWeight: '700', - color: colors.white, - marginBottom: 8, - letterSpacing: 0.5, + padding: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, legendItems: { flexDirection: 'row', flexWrap: 'wrap', - gap: 8, + justifyContent: 'space-between', marginBottom: 12, }, legendItem: { flexDirection: 'row', alignItems: 'center', - minWidth: '45%', - marginBottom: 2, + width: '48%', + marginBottom: 8, }, legendColor: { - width: 14, - height: 14, + width: 12, + height: 12, borderRadius: 3, marginRight: 6, }, @@ -688,7 +662,7 @@ const styles = StyleSheet.create({ }, warningLegends: { marginTop: 8, - gap: 6, + gap: 4, borderTopWidth: 1, borderTopColor: colors.black + '40', paddingTop: 8, @@ -700,13 +674,18 @@ const styles = StyleSheet.create({ }, warningText: { color: colors.lightGray, - fontSize: 11, + fontSize: 12, flex: 1, }, ratingsGrid: { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground, borderRadius: 8, - padding: 8, + padding: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, }, gridContainer: { flexDirection: 'row', @@ -715,6 +694,7 @@ const styles = StyleSheet.create({ width: 40, borderRightWidth: 1, borderRightColor: colors.black + '40', + paddingRight: 6, }, seasonsScrollView: { flex: 1, @@ -733,12 +713,12 @@ const styles = StyleSheet.create({ paddingLeft: 6, }, episodeCell: { - height: 28, + height: 26, justifyContent: 'center', paddingRight: 6, }, episodeColumn: { - height: 28, + height: 26, justifyContent: 'center', marginBottom: 8, paddingRight: 6, @@ -750,18 +730,18 @@ const styles = StyleSheet.create({ headerText: { color: colors.white, fontWeight: '700', - fontSize: 12, + fontSize: 13, letterSpacing: 0.5, }, episodeText: { color: colors.lightGray, - fontSize: 12, + fontSize: 13, fontWeight: '500', }, ratingCell: { width: 32, - height: 24, - borderRadius: 3, + height: 26, + borderRadius: 4, justifyContent: 'center', alignItems: 'center', }, @@ -773,7 +753,7 @@ const styles = StyleSheet.create({ ratingCellContainer: { position: 'relative', width: 32, - height: 24, + height: 26, }, warningIcon: { position: 'absolute', From 26c8e333aaa2ea3c22f72553da7c422052a4985c Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 03:14:00 +0530 Subject: [PATCH 36/38] Refactor ShowRatingsScreen to integrate ThemeContext for dynamic theming This update enhances the ShowRatingsScreen and its related components by incorporating the ThemeContext, allowing for dynamic theming throughout the interface. Styles have been adjusted to reflect the current theme colors, improving visual consistency and user experience. Key changes include updates to text colors, background settings, and the addition of theme props in memoized components, ensuring a cohesive interface that adapts to different themes. Overall, these modifications enhance the maintainability and aesthetics of the code. --- src/screens/ShowRatingsScreen.tsx | 138 +++++++++++++++--------------- 1 file changed, 68 insertions(+), 70 deletions(-) diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 4f4560d..390ed0f 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -12,7 +12,7 @@ import { } from 'react-native'; import { Image } from 'expo-image'; import { BlurView } from 'expo-blur'; -import { colors } from '../styles'; +import { useTheme } from '../contexts/ThemeContext'; import { TMDBService, TMDBShow as Show, TMDBSeason, TMDBEpisode } from '../services/tmdbService'; import { RouteProp } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; @@ -63,11 +63,12 @@ const getRatingColor = (rating: number): string => { }; // Memoized components -const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeason }: { +const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeason, theme }: { episode: TMDBEpisode; ratingSource: RatingSource; getTVMazeRating: (seasonNumber: number, episodeNumber: number) => number | null; isCurrentSeason: (episode: TMDBEpisode) => boolean; + theme: any; }) => { const getRatingForSource = useCallback((episode: TMDBEpisode): number | null => { switch (ratingSource) { @@ -101,14 +102,14 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeas if (!rating) { if (!episode.air_date || new Date(episode.air_date) > new Date()) { return ( - - + + ); } return ( - - + + ); } @@ -128,7 +129,7 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeas )} @@ -136,33 +137,43 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeas ); }); -const RatingSourceToggle = memo(({ ratingSource, setRatingSource }: { +const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: { ratingSource: RatingSource; setRatingSource: (source: RatingSource) => void; + theme: any; }) => ( - Rating Source: + Rating Source: - {['imdb', 'tmdb', 'tvmaze'].map((source) => ( - setRatingSource(source as RatingSource)} - > - {source.toUpperCase()} - - ))} + {['imdb', 'tmdb', 'tvmaze'].map((source) => { + const isActive = ratingSource === source; + return ( + setRatingSource(source as RatingSource)} + > + + {source.toUpperCase()} + + + ); + })} )); -const ShowInfo = memo(({ show }: { show: Show | null }) => ( +const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => ( ( transition={200} /> - {show?.name} - + {show?.name} + {show?.first_air_date ? `${new Date(show.first_air_date).getFullYear()} - ${show.last_air_date ? new Date(show.last_air_date).getFullYear() : 'Present'}` : ''} - - + + {show?.number_of_seasons} Seasons • {show?.number_of_episodes} Episodes @@ -186,6 +197,8 @@ const ShowInfo = memo(({ show }: { show: Show | null }) => ( )); const ShowRatingsScreen = ({ route }: Props) => { + const { currentTheme } = useTheme(); + const { colors } = currentTheme; const { showId } = route.params; const [show, setShow] = useState(null); const [seasons, setSeasons] = useState([]); @@ -345,7 +358,7 @@ const ShowRatingsScreen = ({ route }: Props) => { - Loading show data... + Loading show data... @@ -370,7 +383,7 @@ const ShowRatingsScreen = ({ route }: Props) => { - Loading content... + Loading content... }> { entering={FadeIn.duration(300)} style={styles.showInfoContainer} > - + - + { style={styles.section} > {/* Legend */} - - Rating Scale + + Rating Scale {[ { color: '#186A3B', text: 'Awesome (9.0+)' }, @@ -412,18 +429,18 @@ const ShowRatingsScreen = ({ route }: Props) => { ].map((item, index) => ( - {item.text} + {item.text} ))} - + - Rating differs significantly from IMDb + Rating differs significantly from IMDb - Current season (ratings may change) + Current season (ratings may change) @@ -434,17 +451,17 @@ const ShowRatingsScreen = ({ route }: Props) => { style={styles.section} > {/* Ratings Grid */} - Episode Ratings - + Episode Ratings + {/* Fixed Episode Column */} - + - Episode + Episode {Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => ( - E{episodeIndex + 1} + E{episodeIndex + 1} ))} @@ -459,14 +476,14 @@ const ShowRatingsScreen = ({ route }: Props) => { > {/* Seasons Header */} - + {seasons.map((season) => ( - S{season.season_number} + S{season.season_number} ))} {loadingSeasons && ( @@ -474,7 +491,7 @@ const ShowRatingsScreen = ({ route }: Props) => { {loadingProgress > 0 && ( - + {Math.round(loadingProgress)}% )} @@ -498,6 +515,7 @@ const ShowRatingsScreen = ({ route }: Props) => { ratingSource={ratingSource} getTVMazeRating={getTVMazeRating} isCurrentSeason={isCurrentSeason} + theme={currentTheme} /> } @@ -539,7 +557,6 @@ const styles = StyleSheet.create({ gap: 12, }, loadingText: { - color: colors.lightGray, fontSize: 14, fontWeight: '500', }, @@ -551,7 +568,6 @@ const styles = StyleSheet.create({ }, showInfo: { flexDirection: 'row', - backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground, borderRadius: 8, padding: 12, shadowColor: '#000', @@ -573,13 +589,11 @@ const styles = StyleSheet.create({ showTitle: { fontSize: 18, fontWeight: '800', - color: colors.white, marginBottom: 4, letterSpacing: 0.5, }, showYear: { fontSize: 13, - color: colors.lightGray, marginBottom: 4, }, episodeCountContainer: { @@ -590,7 +604,6 @@ const styles = StyleSheet.create({ }, episodeCount: { fontSize: 13, - color: colors.lightGray, }, ratingSourceContainer: { marginBottom: 12, @@ -598,7 +611,6 @@ const styles = StyleSheet.create({ sectionTitle: { fontSize: 15, fontWeight: '700', - color: colors.white, marginBottom: 8, letterSpacing: 0.5, }, @@ -611,25 +623,20 @@ const styles = StyleSheet.create({ paddingVertical: 6, borderRadius: 6, borderWidth: 1, - borderColor: colors.lightGray, flex: 1, alignItems: 'center', }, sourceButtonActive: { - backgroundColor: colors.primary, - borderColor: colors.primary, + fontWeight: '700', }, sourceButtonText: { - color: colors.lightGray, fontSize: 13, fontWeight: '600', }, sourceButtonTextActive: { - color: colors.white, fontWeight: '700', }, legend: { - backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground, borderRadius: 8, padding: 12, shadowColor: '#000', @@ -657,14 +664,12 @@ const styles = StyleSheet.create({ marginRight: 6, }, legendText: { - color: colors.lightGray, fontSize: 12, }, warningLegends: { marginTop: 8, gap: 4, borderTopWidth: 1, - borderTopColor: colors.black + '40', paddingTop: 8, }, warningLegend: { @@ -673,12 +678,10 @@ const styles = StyleSheet.create({ gap: 6, }, warningText: { - color: colors.lightGray, fontSize: 12, flex: 1, }, ratingsGrid: { - backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground, borderRadius: 8, padding: 12, shadowColor: '#000', @@ -693,7 +696,6 @@ const styles = StyleSheet.create({ fixedColumn: { width: 40, borderRightWidth: 1, - borderRightColor: colors.black + '40', paddingRight: 6, }, seasonsScrollView: { @@ -703,7 +705,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', marginBottom: 8, borderBottomWidth: 1, - borderBottomColor: colors.black + '40', paddingBottom: 6, paddingLeft: 6, }, @@ -728,13 +729,11 @@ const styles = StyleSheet.create({ alignItems: 'center', }, headerText: { - color: colors.white, fontWeight: '700', fontSize: 13, letterSpacing: 0.5, }, episodeText: { - color: colors.lightGray, fontSize: 13, fontWeight: '500', }, @@ -746,7 +745,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, ratingText: { - color: colors.white, + color: 'white', fontSize: 12, fontWeight: '700', }, @@ -759,7 +758,7 @@ const styles = StyleSheet.create({ position: 'absolute', top: -4, right: -4, - backgroundColor: colors.black, + backgroundColor: 'black', borderRadius: 8, padding: 1, }, @@ -774,7 +773,6 @@ const styles = StyleSheet.create({ gap: 4, }, loadingProgressText: { - color: colors.primary, fontSize: 10, fontWeight: '600', }, From e47548d1ac36873f967263e9bebc0fcc7344102f Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 03:18:25 +0530 Subject: [PATCH 37/38] Enhance HomeScreen status bar configuration and layout adjustments This update improves the HomeScreen component by refining the status bar settings for better visual consistency across platforms. Key changes include ensuring the status bar is fully transparent, adjusting padding for iOS, and modifying the layout from SafeAreaView to View for better compatibility. Additionally, cleanup logic for the status bar has been optimized for Android, enhancing the overall user experience and maintainability of the code. --- src/screens/HomeScreen.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 1916758..1b82a1e 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -425,25 +425,32 @@ const HomeScreen = () => { useFocusEffect( useCallback(() => { const statusBarConfig = () => { + // Ensure status bar is fully transparent and doesn't take up space StatusBar.setBarStyle("light-content"); StatusBar.setTranslucent(true); StatusBar.setBackgroundColor('transparent'); + + // For iOS specifically + if (Platform.OS === 'ios') { + StatusBar.setHidden(false); + } }; statusBarConfig(); return () => { - // Don't change StatusBar settings when unfocusing to prevent layout shifts - // Only set these when component unmounts completely + // Keep translucent when unfocusing to prevent layout shifts }; }, []) ); useEffect(() => { - // Only run cleanup when component unmounts completely, not on unfocus + // Only run cleanup when component unmounts completely return () => { - StatusBar.setTranslucent(false); - StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(false); + StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); + } }; }, [currentTheme.colors.darkBackground]); @@ -553,7 +560,7 @@ const HomeScreen = () => { } return ( - + { } contentContainerStyle={[ styles.scrollContent, - { paddingTop: Platform.OS === 'ios' ? 39 : 90 } + { paddingTop: Platform.OS === 'ios' ? 100 : 90 } ]} showsVerticalScrollIndicator={false} > @@ -617,7 +624,7 @@ const HomeScreen = () => { ) )} - + ); }; From 86d149257370500955970a2cf1774179a18e3a15 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 03:22:56 +0530 Subject: [PATCH 38/38] Refactor StreamsScreen to integrate ThemeContext for dynamic theming This update enhances the StreamsScreen component by incorporating the ThemeContext, allowing for dynamic theming throughout the interface. Styles have been adjusted to reflect the current theme colors, improving visual consistency and user experience. Key changes include updates to text colors, background settings, and the addition of theme props in memoized components, ensuring a cohesive interface that adapts to different themes. Overall, these modifications enhance the maintainability and aesthetics of the code. --- src/screens/StreamsScreen.tsx | 70 ++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 3f0f27b..c31daea 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -23,7 +23,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator'; import { useMetadata } from '../hooks/useMetadata'; -import { colors } from '../styles/colors'; +import { useTheme } from '../contexts/ThemeContext'; import { Stream } from '../types/metadata'; import { tmdbService } from '../services/tmdbService'; import { stremioService } from '../services/stremioService'; @@ -53,13 +53,16 @@ const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_V const { width, height } = Dimensions.get('window'); // Extracted Components -const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: { +const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: { stream: Stream; onPress: () => void; index: number; isLoading?: boolean; statusMessage?: string; + theme: any; }) => { + const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); + const quality = stream.title?.match(/(\d+)p/)?.[1] || null; const isHDR = stream.title?.toLowerCase().includes('hdr'); const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); @@ -82,11 +85,11 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: { - + {displayTitle} {displayAddonName && displayAddonName !== displayTitle && ( - + {displayAddonName} )} @@ -95,8 +98,8 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: { {/* Show loading indicator if stream is loading */} {isLoading && ( - - + + {statusMessage || "Loading..."} @@ -113,14 +116,14 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: { )} {size && ( - - {size} + + {size} )} {isDebrid && ( - - DEBRID + + DEBRID )} @@ -130,28 +133,36 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: { ); }; -const QualityTag = React.memo(({ text, color }: { text: string; color: string }) => ( - - {text} - -)); +const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => { + const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); + + return ( + + {text} + + ); +}); const ProviderFilter = memo(({ selectedProvider, providers, - onSelect + onSelect, + theme }: { selectedProvider: string; providers: Array<{ id: string; name: string; }>; onSelect: (id: string) => void; + theme: any; }) => { + const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); + const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => ( - ), [selectedProvider, onSelect]); + ), [selectedProvider, onSelect, styles]); return ( { const navigation = useNavigation(); const { id, type, episodeId } = route.params; const { settings } = useSettings(); + const { currentTheme } = useTheme(); + const { colors } = currentTheme; // Add timing logs const [loadStartTime, setLoadStartTime] = useState(0); @@ -217,6 +230,9 @@ export const StreamsScreen = () => { groupedEpisodes, } = useMetadata({ id, type }); + // Create styles using current theme colors + const styles = React.useMemo(() => createStyles(colors), [colors]); + const [selectedProvider, setSelectedProvider] = React.useState('all'); const [availableProviders, setAvailableProviders] = React.useState>(new Set()); @@ -629,9 +645,10 @@ export const StreamsScreen = () => { index={index} isLoading={isLoading} statusMessage={providerStatus[section.addonId]?.message} + theme={currentTheme} /> ); - }, [handleStreamPress, loadingProviders, providerStatus]); + }, [handleStreamPress, loadingProviders, providerStatus, currentTheme]); const renderSectionHeader = useCallback(({ section }: { section: { title: string } }) => ( { onPress={handleBack} activeOpacity={0.7} > - + {type === 'series' ? 'Back to Episodes' : 'Back to Info'} @@ -722,12 +739,11 @@ export const StreamsScreen = () => { colors={[ 'rgba(0,0,0,0)', 'rgba(0,0,0,0.4)', - 'rgba(0,0,0,0.7)', - 'rgba(0,0,0,0.85)', - 'rgba(0,0,0,0.95)', + 'rgba(0,0,0,0.6)', + 'rgba(0,0,0,0.8)', colors.darkBackground ]} - locations={[0, 0.3, 0.5, 0.7, 0.85, 1]} + locations={[0, 0.3, 0.5, 0.7, 1]} style={styles.streamsHeroGradient} > @@ -789,6 +805,7 @@ export const StreamsScreen = () => { selectedProvider={selectedProvider} providers={filterItems} onSelect={handleProviderChange} + theme={currentTheme} /> )} @@ -842,7 +859,8 @@ export const StreamsScreen = () => { ); }; -const styles = StyleSheet.create({ +// Create a function to generate styles with the current theme colors +const createStyles = (colors: any) => StyleSheet.create({ container: { flex: 1, backgroundColor: colors.darkBackground, @@ -1133,7 +1151,7 @@ const styles = StyleSheet.create({ height: 14, }, streamsHeroRatingText: { - color: '#01b4e4', + color: colors.accent, fontSize: 13, fontWeight: '700', marginLeft: 4,