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 f3f5b58..183de35 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", @@ -60,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": { @@ -10839,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", @@ -11196,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/calendar/CalendarSection.tsx b/src/components/calendar/CalendarSection.tsx index 7bafa91..2d443e1 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,10 +44,12 @@ const DayItem = ({ isSelected, hasEvents, onPress -}: DayItemProps) => ( +}: DayItemProps) => { + const { currentTheme } = useTheme(); + return ( {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/components/discover/CatalogSection.tsx b/src/components/discover/CatalogSection.tsx new file mode 100644 index 0000000..68b0251 --- /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 { useTheme } from '../../contexts/ThemeContext'; +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 { currentTheme } = useTheme(); + 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, + marginTop: 6, + borderRadius: 2, + }, + title: { + fontSize: 20, + fontWeight: '700', + }, + seeAllButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 6, + paddingHorizontal: 4, + }, + seeAllText: { + 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..c090e8a --- /dev/null +++ b/src/components/discover/CategorySelector.tsx @@ -0,0 +1,95 @@ +import React, { useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Category } from '../../constants/discover'; + +interface CategorySelectorProps { + categories: Category[]; + selectedCategory: Category; + onSelectCategory: (category: Category) => void; +} + +const CategorySelector = ({ + categories, + selectedCategory, + onSelectCategory +}: CategorySelectorProps) => { + const { currentTheme } = useTheme(); + + const renderCategoryButton = useCallback((category: Category) => { + const isSelected = selectedCategory.id === category.id; + + return ( + onSelectCategory(category)} + activeOpacity={0.7} + > + + + {category.name} + + + ); + }, [selectedCategory, onSelectCategory, currentTheme]); + + 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: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 4, + }, + categoryText: { + color: '#9e9e9e', // Default medium gray + fontWeight: '600', + fontSize: 16, + }, +}); + +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..015db8c --- /dev/null +++ b/src/components/discover/ContentItem.tsx @@ -0,0 +1,93 @@ +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 { useTheme } from '../../contexts/ThemeContext'; +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 { currentTheme } = useTheme(); + 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, + 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', + 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..7cc4df0 --- /dev/null +++ b/src/components/discover/GenreSelector.tsx @@ -0,0 +1,88 @@ +import React, { useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface GenreSelectorProps { + genres: string[]; + selectedGenre: string; + onSelectGenre: (genre: string) => void; +} + +const GenreSelector = ({ + genres, + selectedGenre, + onSelectGenre +}: GenreSelectorProps) => { + const { currentTheme } = useTheme(); + + const renderGenreButton = useCallback((genre: string) => { + const isSelected = selectedGenre === genre; + + return ( + onSelectGenre(genre)} + activeOpacity={0.7} + > + + {genre} + + + ); + }, [selectedGenre, onSelectGenre, currentTheme]); + + 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: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + overflow: 'hidden', + }, + genreText: { + color: '#9e9e9e', // Default medium gray + fontWeight: '500', + fontSize: 14, + }, +}); + +export default React.memo(GenreSelector); \ No newline at end of file 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/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/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 405208d..eb4467f 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, @@ -28,6 +27,11 @@ import Animated, { } from 'react-native-reanimated'; import { StreamingContent } from '../../services/catalogService'; import { SkeletonFeatured } from './SkeletonLoaders'; +import { isValidMetahubLogo, hasValidLogoFormat, isMetahubUrl, isTmdbUrl } from '../../utils/logoUtils'; +import { useSettings } from '../../hooks/useSettings'; +import { TMDBService } from '../../services/tmdbService'; +import { logger } from '../../utils/logger'; +import { useTheme } from '../../contexts/ThemeContext'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -42,16 +46,25 @@ const { width, height } = Dimensions.get('window'); const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const navigation = useNavigation>(); - const [logoUrl, setLogoUrl] = useState(null); + const { currentTheme } = useTheme(); 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); + // Add a ref to track logo fetch in progress + 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, })); @@ -60,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, })); @@ -74,21 +90,191 @@ 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) { + return false; + } + } catch (validationError) { + // If validation fails, still try to load the image + } + } + + // Always attempt to prefetch the image regardless of format validation await ExpoImage.prefetch(url); imageCache[url] = true; return true; } catch (error) { - console.error('Error preloading image:', error); return false; } }; + // Reset logo error state when content changes + 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 + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; // Get preferred language + + // 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; + } + } + + // 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) { + setLogoUrl(metahubUrl); + logoFetchInProgress.current = false; + return; // Exit if Metahub logo was found + } + } catch (error) { + // Removed logger.warn + } + + // 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, preferredLanguage); + + if (logoUrl) { + setLogoUrl(logoUrl); + } else if (currentLogo) { + // If TMDB fails too, use existing logo if any + setLogoUrl(currentLogo); + } + } catch (error) { + // Removed logger.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, preferredLanguage); + + if (logoUrl) { + setLogoUrl(logoUrl); + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found + } + } catch (error) { + // Removed logger.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(), preferredLanguage); + + if (logoUrl) { + setLogoUrl(logoUrl); + logoFetchInProgress.current = false; + return; // Exit if TMDB logo was found + } + } + } catch (error) { + // Removed logger.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) { + setLogoUrl(metahubUrl); + } else if (currentLogo) { + // If Metahub fails too, use existing logo if any + setLogoUrl(currentLogo); + } + } catch (error) { + // Removed logger.warn + if (currentLogo) setLogoUrl(currentLogo); + } + } else if (currentLogo) { + // Use existing logo if we don't have IMDB ID + setLogoUrl(currentLogo); + } + } + } catch (error) { + // Removed logger.error + // Optionally set a fallback logo or handle the error state + setLogoUrl(featuredContent.logo ?? null); // Fallback to initial logo or null + } finally { + logoFetchInProgress.current = false; + } + }; + + fetchLogo(); + }, [featuredContent?.id, settings.logoSourcePreference, settings.tmdbLanguagePreference]); + // 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 @@ -99,9 +285,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 () => { @@ -117,19 +302,23 @@ 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, 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: ${logoUrl}`); } } }; loadImages(); - }, [featuredContent?.id]); + }, [featuredContent?.id, logoUrl]); if (!featuredContent) { return ; @@ -157,7 +346,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} @@ -165,25 +354,33 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - {featuredContent.logo ? ( + {logoUrl && !logoLoadError ? ( { + console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`); + setLogoLoadError(true); + }} /> ) : ( - {featuredContent.name} + + {featuredContent.name} + )} {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( - {genre} + + {genre} + {index < array.length - 1 && ( - + )} ))} @@ -198,15 +395,15 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - + {isSaved ? "Saved" : "Save"} { if (featuredContent) { navigation.navigate('Streams', { @@ -216,8 +413,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } }} > - - Play + + + Play + - - Info + + + Info + @@ -249,7 +450,6 @@ const styles = StyleSheet.create({ marginTop: 0, marginBottom: 8, position: 'relative', - backgroundColor: colors.elevation1, }, imageContainer: { width: '100%', @@ -271,7 +471,6 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: colors.elevation1, justifyContent: 'center', alignItems: 'center', zIndex: 1, @@ -294,7 +493,6 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, featuredTitleText: { - color: colors.highEmphasis, fontSize: 28, fontWeight: '900', marginBottom: 8, @@ -313,13 +511,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, @@ -341,7 +537,6 @@ const styles = StyleSheet.create({ paddingVertical: 14, paddingHorizontal: 32, borderRadius: 30, - backgroundColor: colors.white, elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, @@ -371,18 +566,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/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', }, 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 new file mode 100644 index 0000000..30bdfbb --- /dev/null +++ b/src/components/metadata/FloatingHeader.tsx @@ -0,0 +1,243 @@ +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 { useTheme } from '../../contexts/ThemeContext'; +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, +}) => { + const { currentTheme } = useTheme(); + + // 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, + }, + 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: { + 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..cef9ec4 --- /dev/null +++ b/src/components/metadata/HeroSection.tsx @@ -0,0 +1,569 @@ +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 { useTheme } from '../../contexts/ThemeContext'; +import { logger } from '../../utils/logger'; +import { TMDBService } from '../../services/tmdbService'; + +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; +}) => { + const { currentTheme } = useTheme(); + return ( + + + + + {playButtonText} + + + + + + + {inLibrary ? 'Saved' : 'Save'} + + + + {type === 'series' && ( + { + 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 + } + }} + > + + + )} + + ); +}); + +// 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; +}) => { + const { currentTheme } = useTheme(); + 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, +}) => { + const { currentTheme } = useTheme(); + // Animated styles + const heroAnimatedStyle = useAnimatedStyle(() => ({ + width: '100%', + height: heroHeight.value, + backgroundColor: currentTheme.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: '#000', + 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: { + 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: { + fontSize: 12, + fontWeight: '500', + }, + genreDot: { + fontSize: 12, + fontWeight: '500', + 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: '#fff', + }, + 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%', + borderRadius: 1.5, + }, + watchProgressText: { + 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..1011359 --- /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 { useTheme } from '../../contexts/ThemeContext'; + +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); + + return ( + <> + {/* Meta Info */} + + {metadata.year && ( + {metadata.year} + )} + {metadata.runtime && ( + {metadata.runtime} + )} + {metadata.certification && ( + {metadata.certification} + )} + {metadata.imdbRating && ( + + + {metadata.imdbRating} + + )} + + + {/* Ratings Section */} + {renderRatings && renderRatings()} + + {/* 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: { + 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: { + fontWeight: '700', + fontSize: 15, + letterSpacing: 0.3, + }, + creatorContainer: { + marginBottom: 2, + paddingHorizontal: 16, + }, + creatorSection: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + height: 20 + }, + creatorLabel: { + fontSize: 14, + fontWeight: '600', + marginRight: 8, + lineHeight: 20 + }, + creatorText: { + fontSize: 14, + flex: 1, + lineHeight: 20 + }, + descriptionContainer: { + marginBottom: 16, + paddingHorizontal: 16, + }, + description: { + fontSize: 15, + lineHeight: 24, + }, + showMoreButton: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + paddingVertical: 4, + }, + showMoreText: { + fontSize: 14, + marginRight: 4, + }, +}); + +export default React.memo(MetadataDetails); \ No newline at end of file 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 208cb8b..f8aaab9 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -1,10 +1,7 @@ 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 { 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'; @@ -57,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(); @@ -67,9 +65,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,29 +84,9 @@ 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]); - useEffect(() => { if (ratings && Object.keys(ratings).length > 0) { // Start fade-in animation when ratings are loaded @@ -123,26 +99,9 @@ export const RatingsSection: React.FC = ({ imdbId, type }) }, [ratings, fadeAnim]); // 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 ( - - - - ); - } - - 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); + 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 = { @@ -150,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() } }; @@ -229,86 +174,69 @@ 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} - - - ); - })} + + {displayRatings.map(([source, value]) => { + const config = ratingConfig[source as keyof typeof ratingConfig]; + const displayValue = config.transform(parseFloat(value as string)); + + return ( + + {config.isImage ? ( + + ) : ( + + {React.createElement(config.icon as any, { + width: 16, + height: 16, + })} + + )} + + {displayValue} + + + ); + })} + ); }; const styles = StyleSheet.create({ container: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginTop: 8, - marginBottom: 16, - paddingHorizontal: 12, - gap: 4, + marginTop: 2, + marginBottom: 8, + paddingHorizontal: 16, }, loadingContainer: { - alignItems: 'center', - justifyContent: 'center', height: 40, - marginVertical: 16, + justifyContent: 'center', + alignItems: 'center', }, - ratingItem: { + compactRatingsContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.4)', - paddingVertical: 3, - paddingHorizontal: 4, - borderRadius: 4, + flexWrap: 'nowrap', }, - ratingIcon: { + compactRatingItem: { + flexDirection: 'row', + alignItems: 'center', + marginRight: 12, + }, + compactRatingIcon: { width: 16, height: 16, - marginRight: 3, - alignSelf: 'center', + marginRight: 4, }, - ratingValue: { - fontSize: 13, - fontWeight: 'bold', + compactSvgContainer: { + marginRight: 4, }, - ratingLabel: { - fontSize: 11, - opacity: 0.9, + compactRatingValue: { + fontSize: 14, + fontWeight: '600', }, }); \ No newline at end of file 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) => ( 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/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/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..0b44bf0 --- /dev/null +++ b/src/hooks/useMetadataAssets.ts @@ -0,0 +1,444 @@ +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); + + // 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); + const logoRefreshCounter = useRef(0); + const MAX_LOGO_REFRESHES = 2; + const forcedLogoRefreshDone = useRef(false); + + // For TMDB ID tracking + const [foundTmdbId, setFoundTmdbId] = useState(null); + + // 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'; + + // Always clear logo on preference change to force proper refresh + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); + + logger.log(`[useMetadataAssets] Preference changed to ${settings.logoSourcePreference}, forcing refresh of all assets`); + } + }, [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(() => { + 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 (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'; // Already defined above + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + + 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 + 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:Logo] Metahub logo HEAD request failed with status ${response.status} for ${imdbId}`); + } + } catch (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(); + const foundId = await tmdbService.findTMDBIdByIMDB(imdbId); + 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: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) { + 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: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:Logo] Error in outer fetchLogo try block:`, error); + } finally { + logger.log(`[useMetadataAssets:Logo] Finished logo fetch attempt.`); + logoFetchInProgress.current = false; + } + }; + + // Execute fetch without awaiting + fetchLogo(); + } + // 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) { + 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'; + const contentType = type === 'series' ? 'tv' : 'movie'; + + // Get TMDB ID once + let tmdbId = null; + if (id.startsWith('tmdb:')) { + tmdbId = id.split(':')[1]; + } else if (foundTmdbId) { + 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 + + if (currentPreference === 'tmdb' && tmdbId) { + // TMDB direct path + 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:Banner] 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); + 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 (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)); + 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:Banner] Using TMDB backdrop from details: ${finalBanner}`); + } + // If no backdrop, try poster as fallback + else if (details && details.poster_path) { + logger.warn(`[useMetadataAssets:Banner] No TMDB backdrop available, using poster as fallback.`); + finalBanner = tmdbService.getImageUrl(details.poster_path); + bannerSourceType = 'tmdb'; + } + else { + 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: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 { + 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:Banner] Metahub banner found: ${finalBanner}`); + } else { + 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: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.`); + } + } + } + + // 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'); + 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, settings.tmdbLanguagePreference, setMetadata, foundTmdbId, bannerSource]); // Added bannerSource dependency to re-evaluate if it changes unexpectedly + + return { + bannerImage, + loadingBanner, + logoLoadError, + foundTmdbId, + setLogoLoadError, + setBannerImage, + bannerSource, // Export banner source for debugging + }; +}; \ No newline at end of file diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 1899269..3f55f4e 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -32,6 +32,8 @@ 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 + tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) } export const DEFAULT_SETTINGS: AppSettings = { @@ -46,6 +48,8 @@ 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 + tmdbLanguagePreference: 'en', // Default to English }; const SETTINGS_STORAGE_KEY = 'app_settings'; 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/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/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 896d15c..6720cda 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -13,6 +13,7 @@ import { colors } from '../styles/colors'; import { NuvioHeader } from '../components/NuvioHeader'; import { Stream } from '../types/streams'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { useTheme } from '../contexts/ThemeContext'; // Import screens with their proper types import HomeScreen from '../screens/HomeScreen'; @@ -35,6 +36,9 @@ 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'; +import ThemeScreen from '../screens/ThemeScreen'; +import ProfilesScreen from '../screens/ProfilesScreen'; // Stack navigator types export type RootStackParamList = { @@ -90,6 +94,9 @@ export type RootStackParamList = { HeroCatalogs: undefined; TraktSettings: undefined; PlayerSettings: undefined; + LogoSourceSettings: undefined; + ThemeSettings: undefined; + ProfilesSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -384,6 +391,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 ( @@ -404,9 +412,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, @@ -490,7 +498,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, }} > @@ -514,7 +522,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', @@ -578,9 +586,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, @@ -607,7 +615,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 @@ -651,8 +659,7 @@ const MainTabs = () => { // Stack Navigator const AppNavigator = () => { - // Always use dark mode - const isDarkMode = true; + const { currentTheme } = useTheme(); return ( @@ -669,7 +676,7 @@ const AppNavigator = () => { animation: 'none', // Ensure content is not popping in and out contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, } }} > @@ -721,7 +728,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -736,7 +743,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -774,7 +781,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -789,7 +796,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -804,7 +811,7 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, }, }} /> @@ -819,7 +826,52 @@ const AppNavigator = () => { gestureDirection: 'horizontal', headerShown: false, contentStyle: { - backgroundColor: colors.darkBackground, + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + + + 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 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/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 diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx index 008d01a..3df7368 100644 --- a/src/screens/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -1,508 +1,43 @@ -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'; +import { useTheme } from '../contexts/ThemeContext'; -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(); + const { currentTheme } = useTheme(); // Force consistent status bar settings useEffect(() => { @@ -539,8 +74,6 @@ const DiscoverScreen = () => { content.push(...catalog.items); }); - setAllContent(content); - if (genre === 'All') { // Group by genres when "All" is selected const genreCatalogs: GenreCatalog[] = []; @@ -578,7 +111,6 @@ const DiscoverScreen = () => { } catch (error) { logger.error('Failed to load content:', error); setCatalogs([]); - setAllContent([]); } finally { setLoading(false); } @@ -601,29 +133,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 @@ -635,72 +163,39 @@ 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 ? ( - + ) : catalogs.length > 0 ? ( - - ) : ( - - - No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'} - - - )} + ) : renderEmptyState()} diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 977df42..1b82a1e 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -26,7 +26,6 @@ import { Stream } from '../types/metadata'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; -import { colors } from '../styles/colors'; import Animated, { FadeIn, FadeOut, @@ -58,7 +57,9 @@ import { useSettings, settingsEmitter } from '../hooks/useSettings'; import FeaturedContent from '../components/home/FeaturedContent'; import CatalogSection from '../components/home/CatalogSection'; import { SkeletonFeatured } from '../components/home/SkeletonLoaders'; -import homeStyles from '../styles/homeStyles'; +import homeStyles, { sharedStyles } from '../styles/homeStyles'; +import { useTheme } from '../contexts/ThemeContext'; +import type { Theme } from '../contexts/ThemeContext'; // Define interfaces for our data interface Category { @@ -86,6 +87,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) const translateY = useSharedValue(300); const opacity = useSharedValue(0); const isDarkMode = useColorScheme() === 'dark'; + const { currentTheme } = useTheme(); const SNAP_THRESHOLD = 100; useEffect(() => { @@ -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); @@ -418,27 +425,34 @@ 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(colors.darkBackground); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(false); + StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); + } }; - }, []); + }, [currentTheme.colors.darkBackground]); useEffect(() => { navigation.addListener('beforeRemove', () => {}); @@ -531,22 +545,22 @@ const HomeScreen = () => { if (isLoading && !isRefreshing) { return ( - + - - - Loading your content... + + + Loading your content... ); } return ( - + { } contentContainerStyle={[ - homeStyles.scrollContent, - { paddingTop: Platform.OS === 'ios' ? 39 : 90 } + styles.scrollContent, + { paddingTop: Platform.OS === 'ios' ? 100 : 90 } ]} showsVerticalScrollIndicator={false} > @@ -594,23 +608,23 @@ const HomeScreen = () => { )) ) : ( !catalogsLoading && ( - - - + + + No content available navigation.navigate('Settings')} > - - Add Catalogs + + Add Catalogs ) )} - + ); }; @@ -620,11 +634,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 +708,6 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, featuredTitle: { - color: colors.white, fontSize: 32, fontWeight: '900', marginBottom: 0, @@ -679,13 +725,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 +751,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 +781,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 +812,6 @@ const styles = StyleSheet.create({ catalogTitle: { fontSize: 18, fontWeight: '800', - color: colors.highEmphasis, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, @@ -786,13 +827,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 +880,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 +917,6 @@ const styles = StyleSheet.create({ modalOverlay: { flex: 1, justifyContent: 'flex-end', - backgroundColor: colors.transparentDark, }, modalOverlayPressable: { flex: 1, @@ -896,7 +924,6 @@ const styles = StyleSheet.create({ dragHandle: { width: 40, height: 4, - backgroundColor: colors.transparentLight, borderRadius: 2, alignSelf: 'center', marginTop: 12, @@ -908,7 +935,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 +949,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.border, }, menuPoster: { width: 60, @@ -962,7 +988,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 +996,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 +1022,6 @@ const styles = StyleSheet.create({ paddingBottom: 20, }, featuredTitleText: { - color: colors.highEmphasis, fontSize: 28, fontWeight: '900', marginBottom: 8, @@ -1006,42 +1031,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 +1042,6 @@ const styles = StyleSheet.create({ height: height * 0.4, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.elevation1, }, }); 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/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/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx new file mode 100644 index 0000000..a5e09b0 --- /dev/null +++ b/src/screens/LogoSourceSettings.tsx @@ -0,0 +1,914 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + Switch, + SafeAreaView, + Image, + Alert, + StatusBar, + Platform, + ActivityIndicator, +} 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 { 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'; + +// 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 + } +]; + +// 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'>( + 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]); + + // 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]); + + // 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); + + // State for TMDB language selection + // Store unique language codes as strings + const [uniqueTmdbLanguages, setUniqueTmdbLanguages] = useState([]); + const [tmdbLogosData, setTmdbLogosData] = useState | null>(null); + + // Load example logos for selected show + useEffect(() => { + 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); + // Reset unique languages and logos data + setUniqueTmdbLanguages([]); + setTmdbLogosData(null); + + 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 { + 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(); + + // Store all TMDB logos data and extract unique languages + if (imagesData.logos && imagesData.logos.length > 0) { + setTmdbLogosData(imagesData.logos); + + // 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 selectedTmdbLanguage, then 'en') + let initialLogoPath: string | null = null; + let initialLanguage = selectedTmdbLanguage; + + // 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 (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) { + 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 + } + + // 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 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); + + // 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) { + logger.error(`[LogoSourceSettings] Error in applyLogoSourceSetting:`, 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 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 = async (languageCode: string) => { + logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`); + + try { + // 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 + 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' }] + ); + } + }; + + // 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 and background + const renderLogoExample = (logo: string | null, banner: string | null, isLoading: boolean) => { + if (isLoading) { + return ( + + + + ); + } + + return ( + + + + {logo && ( + + )} + {!logo && ( + + No logo available + + )} + + ); + }; + + return ( + + + + {/* Header */} + + + + + Logo Source + + + + {/* Description */} + + + Choose the primary source for content logos and backgrounds. The selected source will be used exclusively. + + + + {/* Show selector */} + + Select a show/movie to preview: + + {EXAMPLE_SHOWS.map((show) => ( + handleShowSelect(show)} + activeOpacity={0.7} + delayPressIn={100} + > + + {show.name} + + + ))} + + + + {/* Options */} + + applyLogoSourceSetting('metahub')} + activeOpacity={0.7} + delayPressIn={100} + > + + Metahub + {logoSource === 'metahub' && ( + + )} + + + + High-quality logos from Metahub. Best for popular titles. + + + + Example: + {renderLogoExample(metahubLogo, metahubBanner, loadingLogos)} + {selectedShow.name} logo from Metahub + + + + applyLogoSourceSetting('tmdb')} + activeOpacity={0.7} + delayPressIn={100} + > + + TMDB + {logoSource === 'tmdb' && ( + + )} + + + + Logos from TMDB. Offers localized options and better coverage for recent content. + + + + Example: + {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} + {selectedShow.name} logo from TMDB + + + {/* TMDB Language Selector */} + {uniqueTmdbLanguages.length > 1 && ( + + Logo Language + + Select your preferred language for TMDB logos. + + + {/* Iterate over unique language codes */} + {uniqueTmdbLanguages.map((langCode) => ( + handleTmdbLanguageSelect(langCode)} + activeOpacity={0.7} + delayPressIn={150} + > + + {(langCode || '').toUpperCase() || '??'} + + + ))} + + + If unavailable in preferred language, English will be used as fallback. + + + )} + + + + {/* Additional Info */} + + + 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. + + + + + ); + }; + + export default LogoSourceSettings; \ No newline at end of file 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 diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 75fa51a..2cf547e 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,215 +1,58 @@ -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 { useTheme } from '../contexts/ThemeContext'; 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 { 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>>(); const navigation = useNavigation>(); const { id, type, episodeId } = route.params; + + // Add settings hook + const { settings } = useSettings(); + + // Get theme context + const { currentTheme } = useTheme(); + + // Get safe area insets + const { top: safeAreaTop } = useSafeAreaInsets(); const { metadata, @@ -231,38 +74,22 @@ const MetadataScreen = () => { imdbId, } = useMetadata({ id, type }); - // Get genres from context - const { genreMap, loadingGenres } = useGenres(); + // Use our new hooks + const { + watchProgress, + getEpisodeDetails, + getPlayButtonText, + } = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); - // Update the ref type to be compatible with Animated.ScrollView - const contentRef = useRef(null); - const [lastScrollTop, setLastScrollTop] = useState(0); - const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); + const { + bannerImage, + loadingBanner, + logoLoadError, + setLogoLoadError, + setBannerImage, + } = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); - // 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); + const animations = useMetadataAnimations(safeAreaTop, watchProgress); // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { @@ -290,364 +117,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 - useEffect(() => { - if (metadata && !metadata.logo) { - const fetchLogo = async () => { - try { - // First try to get logo from Metahub - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - - logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); - - // Test if Metahub logo exists with a HEAD request - 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 - } - } 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 fallback logo from TMDB: - - Content Type: ${tmdbType} - - TMDB ID: ${tmdbId} - - Logo URL: ${logoUrl} - `); - - // Update metadata with TMDB logo - setMetadata(prevMetadata => ({ - ...prevMetadata!, - logo: logoUrl - })); - } else { - logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id})`); - } - } - } catch (error) { - logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { - error, - contentId: id, - contentType: type - }); - } - }; - - fetchLogo(); - } else if (metadata?.logo) { - logger.log(`[MetadataScreen] Using existing logo from metadata: - - Content ID: ${id} - - Content Type: ${type} - - Logo URL: ${metadata.logo} - `); - } - }, [id, type, metadata, setMetadata, imdbId]); - - // 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') { @@ -680,13 +149,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, @@ -695,290 +161,33 @@ 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 ( { barStyle="light-content" /> - - + + Loading content... @@ -999,7 +210,9 @@ const MetadataScreen = () => { if (metadataError || !metadata) { return ( { - + {metadataError || 'Content not found'} Try Again @@ -1034,11 +249,11 @@ const MetadataScreen = () => { - + Go Back @@ -1049,7 +264,9 @@ const MetadataScreen = () => { return ( { /> {/* Floating Header */} - - {Platform.OS === 'ios' ? ( - - - - - - - - {metadata.logo ? ( - - ) : ( - {metadata.name} - )} - - - - - - - - ) : ( - - - - - - - - - {metadata.logo ? ( - - ) : ( - {metadata.name} - )} - - - - - - - - )} - {Platform.OS === 'ios' && } - + {/* Hero Section */} - - - {/* Use Animated.Image directly instead of ImageBackground with imageStyle */} - - - - {/* Title */} - - - {metadata.logo ? ( - - ) : ( - {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} - - )} - - - {/* Add RatingsSection right under the main metadata */} - {imdbId && ( - - )} - - {/* 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'} - - - - - - )} + {/* Metadata Details */} + imdbId ? ( + + ) : null} + /> {/* Cast Section */} { 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, }, }); 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 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/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 63459f8..0e2d471 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,18 +14,34 @@ import { Dimensions, ScrollView, Animated as RNAnimated, + Pressable, + Platform, + Easing, } 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 } 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'; +import { useTheme } from '../contexts/ThemeContext'; const { width } = Dimensions.get('window'); const HORIZONTAL_ITEM_WIDTH = width * 0.3; @@ -37,8 +53,11 @@ 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; + const { currentTheme } = useTheme(); React.useEffect(() => { const pulse = RNAnimated.loop( @@ -66,12 +85,24 @@ const SkeletonLoader = () => { const renderSkeletonItem = () => ( - + - + - - + + @@ -82,7 +113,10 @@ const SkeletonLoader = () => { {[...Array(5)].map((_, index) => ( {index === 0 && ( - + )} {renderSkeletonItem()} @@ -91,6 +125,73 @@ 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; + const { currentTheme } = useTheme(); + + 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 +201,31 @@ const SearchScreen = () => { const [searched, setSearched] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const [showRecent, setShowRecent] = useState(true); + const inputRef = useRef(null); + const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); + + // 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 +237,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 +322,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,66 +355,103 @@ 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} > - + + + + {item.type === 'movie' ? 'MOVIE' : 'SERIES'} + + + {item.imdbRating && ( + + + + {item.imdbRating} + + + )} {item.name} - + {item.year && ( + + {item.year} + + )} + ); }; @@ -253,122 +467,154 @@ 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} - /> - - )} - - - )} - + ); }; @@ -376,25 +622,55 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + }, + contentContainer: { + flex: 1, + 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: 'space-between', + marginBottom: 8, + height: 48, + }, + searchBarWrapper: { + flex: 1, + height: 48, }, searchBar: { flexDirection: 'row', alignItems: 'center', - borderRadius: 24, + borderRadius: 12, paddingHorizontal: 16, - height: 48, + height: '100%', + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, }, searchIcon: { marginRight: 12, @@ -412,6 +688,7 @@ const styles = StyleSheet.create({ }, scrollViewContent: { paddingBottom: 20, + paddingHorizontal: 0, }, carouselContainer: { marginBottom: 24, @@ -419,12 +696,11 @@ const styles = StyleSheet.create({ carouselTitle: { fontSize: 18, fontWeight: '700', - color: colors.white, marginBottom: 12, paddingHorizontal: 16, }, horizontalListContent: { - paddingHorizontal: 16, + paddingHorizontal: 12, paddingRight: 8, }, horizontalItem: { @@ -434,10 +710,10 @@ 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, }, horizontalItemPoster: { width: '100%', @@ -445,19 +721,28 @@ const styles = StyleSheet.create({ }, horizontalItemTitle: { fontSize: 14, - fontWeight: '500', + fontWeight: '600', lineHeight: 18, textAlign: 'left', }, + yearText: { + fontSize: 12, + 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, @@ -466,6 +751,9 @@ const styles = StyleSheet.create({ fontSize: 16, flex: 1, }, + recentSearchDeleteButton: { + padding: 4, + }, loadingContainer: { flex: 1, justifyContent: 'center', @@ -493,7 +781,11 @@ const styles = StyleSheet.create({ lineHeight: 20, }, skeletonContainer: { - padding: 16, + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 12, + paddingTop: 16, + justifyContent: 'space-between', }, skeletonVerticalItem: { flexDirection: 'row', @@ -503,7 +795,6 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, height: POSTER_HEIGHT, borderRadius: 8, - backgroundColor: colors.darkBackground, }, skeletonItemDetails: { flex: 1, @@ -519,22 +810,76 @@ 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, }, + itemTypeContainer: { + position: 'absolute', + top: 8, + left: 8, + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + itemTypeText: { + 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: { + fontSize: 10, + fontWeight: '700', + marginLeft: 2, + }, + simpleAnimationContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + simpleAnimationContent: { + alignItems: 'center', + }, + spinnerContainer: { + width: 64, + height: 64, + borderRadius: 32, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 3, + }, + simpleAnimationText: { + fontSize: 16, + fontWeight: '600', + }, }); export default SearchScreen; \ No newline at end of file diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 47a5757..a0cd3b7 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -6,7 +6,6 @@ import { TouchableOpacity, Switch, ScrollView, - useColorScheme, SafeAreaView, StatusBar, Alert, @@ -19,12 +18,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'; @@ -35,28 +34,31 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Card component with modern style interface SettingsCardProps { children: React.ReactNode; - isDarkMode: boolean; title?: string; } -const SettingsCard: React.FC = ({ children, isDarkMode, title }) => ( - - {title && ( - = ({ children, title }) => { + const { currentTheme } = useTheme(); + + return ( + + {title && ( + + {title.toUpperCase()} + + )} + - {title.toUpperCase()} - - )} - - {children} + {children} + - -); + ); +}; interface SettingItemProps { title: string; @@ -65,7 +67,6 @@ interface SettingItemProps { renderControl: () => React.ReactNode; isLast?: boolean; onPress?: () => void; - isDarkMode: boolean; badge?: string | number; } @@ -76,9 +77,10 @@ const SettingItem: React.FC = ({ renderControl, isLast = false, onPress, - isDarkMode, badge }) => { + const { currentTheme } = useTheme(); + return ( = ({ 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} )} {badge && ( - + {badge} )} @@ -121,36 +123,35 @@ 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(); + 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); 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 @@ -229,9 +230,9 @@ const SettingsScreen: React.FC = () => { ); @@ -239,7 +240,7 @@ const SettingsScreen: React.FC = () => { ); @@ -257,52 +258,126 @@ 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} + /> + + + + {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 + + + + )} + + + + 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} @@ -329,7 +402,6 @@ const SettingsScreen: React.FC = () => { title="Catalogs" description="Configure content sources" icon="view-list" - isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('CatalogSettings')} badge={catalogCount} @@ -338,30 +410,34 @@ 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')} + /> 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} /> - + ( handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)} > Addons handleDiscoverDataSourceChange(DataSource.TMDB)} > TMDB @@ -418,7 +504,7 @@ const SettingsScreen: React.FC = () => { - + Version 1.0.0 @@ -433,18 +519,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', @@ -459,13 +533,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, @@ -526,11 +597,6 @@ const styles = StyleSheet.create({ settingTextContainer: { flex: 1, }, - settingTitleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, settingTitle: { fontSize: 16, fontWeight: '500', @@ -589,17 +655,65 @@ 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, + profileLockContainer: { + padding: 16, + borderRadius: 8, + overflow: 'hidden', + marginVertical: 8, + }, + profileLockContent: { + flexDirection: 'row', + alignItems: 'center', + }, + profileLockTextContainer: { + flex: 1, + marginHorizontal: 12, + }, + profileLockTitle: { + fontSize: 16, fontWeight: '600', + marginBottom: 4, + }, + profileLockDescription: { + fontSize: 14, + opacity: 0.8, + }, + profileBenefits: { + flexDirection: 'row', + marginTop: 16, + justifyContent: 'space-between', + }, + benefitCol: { + flex: 1, + }, + benefitItem: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + benefitText: { + fontSize: 14, + marginLeft: 8, + }, + loginButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 12, + marginTop: 16, + }, + loginButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + loginButtonIcon: { + marginLeft: 8, }, }); diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 04e8e1b..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,103 +102,93 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeas if (!rating) { if (!episode.air_date || new Date(episode.air_date) > new Date()) { return ( - - + + ); } return ( - - + + ); } return ( - - + {rating.toFixed(1)} - + {(isInaccurate || isCurrent) && ( )} - + ); }); -const RatingSourceToggle = memo(({ ratingSource, setRatingSource }: { +const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: { ratingSource: RatingSource; setRatingSource: (source: RatingSource) => void; + theme: any; }) => ( - Rating Source: + Rating Source: - setRatingSource('imdb')} - > - IMDb - - setRatingSource('tmdb')} - > - TMDB - - setRatingSource('tvmaze')} - > - TVMaze - + {['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 }) => ( - {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 @@ -206,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([]); @@ -301,6 +294,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); @@ -361,6 +358,7 @@ const ShowRatingsScreen = ({ route }: Props) => { + Loading show data... @@ -385,89 +383,85 @@ 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 + + Rating differs significantly from IMDb - - Current season (ratings may change) + + Current season (ratings may change) {/* Ratings Grid */} - + 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} ))} @@ -482,18 +476,22 @@ const ShowRatingsScreen = ({ route }: Props) => { > {/* Seasons Header */} - + {seasons.map((season) => ( - - S{season.season_number} - + + S{season.season_number} + ))} {loadingSeasons && ( {loadingProgress > 0 && ( - + {Math.round(loadingProgress)}% )} @@ -506,16 +504,21 @@ const ShowRatingsScreen = ({ route }: Props) => { {Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => ( {seasons.map((season) => ( - + {season.episodes[episodeIndex] && } - + ))} {loadingSeasons && } @@ -540,24 +543,38 @@ 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: { + 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, @@ -566,44 +583,35 @@ 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, - color: colors.lightGray, - }, - ratingSection: { - backgroundColor: colors.darkBackground, - borderRadius: 8, - padding: 8, - marginBottom: 12, + fontSize: 13, }, ratingSourceContainer: { - marginBottom: 8, + marginBottom: 12, }, - ratingSourceTitle: { - fontSize: 14, + sectionTitle: { + fontSize: 15, fontWeight: '700', - color: colors.white, - marginBottom: 6, + marginBottom: 8, letterSpacing: 0.5, }, ratingSourceButtons: { @@ -615,78 +623,53 @@ 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: 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, }, legendText: { - color: colors.lightGray, fontSize: 12, }, warningLegends: { marginTop: 8, - gap: 6, + gap: 4, borderTopWidth: 1, - borderTopColor: colors.black + '40', paddingTop: 8, }, warningLegend: { @@ -695,14 +678,17 @@ const styles = StyleSheet.create({ gap: 6, }, 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', @@ -710,7 +696,7 @@ const styles = StyleSheet.create({ fixedColumn: { width: 40, borderRightWidth: 1, - borderRightColor: colors.black + '40', + paddingRight: 6, }, seasonsScrollView: { flex: 1, @@ -719,7 +705,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', marginBottom: 8, borderBottomWidth: 1, - borderBottomColor: colors.black + '40', paddingBottom: 6, paddingLeft: 6, }, @@ -729,12 +714,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, @@ -744,38 +729,36 @@ const styles = StyleSheet.create({ alignItems: 'center', }, 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', }, ratingText: { - color: colors.white, + color: 'white', fontSize: 12, fontWeight: '700', }, ratingCellContainer: { position: 'relative', width: 32, - height: 24, + height: 26, }, warningIcon: { position: 'absolute', top: -4, right: -4, - backgroundColor: colors.black, + backgroundColor: 'black', borderRadius: 8, padding: 1, }, @@ -790,7 +773,6 @@ const styles = StyleSheet.create({ gap: 4, }, loadingProgressText: { - color: colors.primary, fontSize: 10, fontWeight: '600', }, 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, 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..0f1e909 --- /dev/null +++ b/src/screens/ThemeScreen.tsx @@ -0,0 +1,897 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Switch, + ScrollView, + Alert, + Platform, + TextInput, + Dimensions, + StatusBar, + FlatList, +} 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'); + +// 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; + onSelect: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +const ThemeCard: React.FC = ({ + theme, + isSelected, + onSelect, + onEdit, + onDelete +}) => { + return ( + + + + {theme.name} + + {isSelected && ( + + )} + + + + + + + + + {theme.isEditable && ( + + {onEdit && ( + + + + )} + {onDelete && ( + + + + )} + + )} + + ); +}; + +// 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 { + 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 + }); + }; + + // Compact preview component + const ThemePreview = () => ( + + + {/* App header */} + + + + + + + + + {/* Content area */} + + {/* Featured content poster */} + + + + + + + + + + {/* Content row */} + + + + + + + + + + + + ); + + return ( + + + + + + + + Save + + + + + + + + + setSelectedColorKey('primary')} + > + Primary + + + setSelectedColorKey('secondary')} + > + Secondary + + + setSelectedColorKey('darkBackground')} + > + Background + + + + + + + + + + ); +}; + +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); + const [activeFilter, setActiveFilter] = useState('all'); + + // 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]); + + // 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]); + + 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 + + + {/* Category filter */} + + item.id} + renderItem={({ item }) => ( + setActiveFilter(item.id)} + primaryColor={currentTheme.colors.primary} + /> + )} + contentContainerStyle={styles.filterList} + /> + + + + + SELECT THEME + + + + {filteredThemes.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', + paddingHorizontal: 12, + paddingVertical: 8, + }, + backButton: { + padding: 6, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + marginLeft: 12, + }, + content: { + flex: 1, + }, + contentContainer: { + 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: 10, + letterSpacing: 0.5, + textTransform: 'uppercase', + }, + themeGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + themeCard: { + width: (width - 36) / 2, + marginBottom: 12, + borderRadius: 12, + padding: 10, + borderWidth: 2, + borderColor: 'transparent', + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + selectedThemeCard: { + borderWidth: 2, + }, + themeCardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + themeCardTitle: { + fontSize: 14, + fontWeight: 'bold', + }, + colorPreviewContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 8, + }, + colorPreview: { + width: 24, + height: 24, + borderRadius: 12, + }, + colorPreviewShadow: { + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 1.5, + elevation: 2, + }, + themeCardActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + themeCardAction: { + padding: 6, + marginLeft: 8, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 16, + }, + buttonShadow: { + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 1.41, + elevation: 2, + }, + createButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + borderRadius: 10, + marginTop: 12, + }, + createButtonText: { + color: '#FFFFFF', + fontWeight: 'bold', + fontSize: 14, + marginLeft: 8, + }, + + // Editor styles + editorContainer: { + flex: 1, + }, + 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', + fontSize: 14, + fontWeight: 'bold', + marginHorizontal: 10, + padding: 0, + height: 28, + }, + 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, + overflow: 'hidden', + padding: 4, + }, + previewContent: { + flex: 1, + borderRadius: 4, + overflow: 'hidden', + }, + previewHeader: { + height: 16, + flexDirection: 'row', + justifyContent: 'space-between', + 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: { + height: 36, + paddingVertical: 5, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 6, + }, + selectedColorButton: { + borderWidth: 2, + borderColor: '#FFFFFF', + }, + colorButtonText: { + color: '#FFFFFF', + fontSize: 10, + fontWeight: 'bold', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + colorPickerContainer: { + flex: 1, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 8, + padding: 8, + marginBottom: 10, + }, + + // 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 - 36) / 2, + padding: 12, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + cancelButtonText: { + color: '#FFFFFF', + fontWeight: 'bold', + fontSize: 14, + }, + saveButton: { + width: (width - 36) / 2, + padding: 12, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary, + }, + saveButtonText: { + color: '#FFFFFF', + fontWeight: 'bold', + fontSize: 14, + }, +}); + +export default ThemeScreen; \ No newline at end of file diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 8c87af7..ad214ee 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); @@ -93,7 +94,19 @@ const TraktSettingsScreen: React.FC = () => { .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.'); @@ -115,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 @@ -151,7 +164,7 @@ const TraktSettingsScreen: React.FC = () => { return ( @@ -162,12 +175,12 @@ const TraktSettingsScreen: React.FC = () => { Trakt Settings @@ -179,11 +192,11 @@ const TraktSettingsScreen: React.FC = () => { > {isLoading ? ( - + ) : isAuthenticated && userProfile ? ( @@ -194,7 +207,7 @@ const TraktSettingsScreen: React.FC = () => { style={styles.avatar} /> ) : ( - + {userProfile.name?.charAt(0) || userProfile.username.charAt(0)} @@ -203,13 +216,13 @@ const TraktSettingsScreen: React.FC = () => { {userProfile.name || userProfile.username} @{userProfile.username} @@ -224,7 +237,7 @@ const TraktSettingsScreen: React.FC = () => { Joined {new Date(userProfile.joined_at).toLocaleDateString()} @@ -252,20 +265,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 +324,13 @@ const TraktSettingsScreen: React.FC = () => { Import watched history Coming soon @@ -331,7 +344,7 @@ const TraktSettingsScreen: React.FC = () => { > Sync Now (Coming Soon) diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index a216196..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; } } @@ -375,8 +363,14 @@ 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) { + return null; + } + + const baseImageUrl = 'https://image.tmdb.org/t/p/'; + const fullUrl = `${baseImageUrl}${size}${path}`; + + return fullUrl; } /** @@ -403,7 +397,6 @@ export class TMDBService { await Promise.all(seasonPromises); return allEpisodes; } catch (error) { - logger.error('Failed to get all episodes:', error); return {}; } } @@ -464,7 +457,6 @@ export class TMDBService { crew: response.data.crew || [] }; } catch (error) { - logger.error('Failed to fetch credits:', error); return { cast: [], crew: [] }; } } @@ -479,7 +471,6 @@ export class TMDBService { }); return response.data; } catch (error) { - logger.error('Failed to fetch person details:', error); return null; } } @@ -498,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 { @@ -515,7 +504,6 @@ export class TMDBService { }); return response.data.results || []; } catch (error) { - logger.error(`Error fetching TMDB ${type} recommendations for ID ${tmdbId}:`, error); return []; } } @@ -533,7 +521,6 @@ export class TMDBService { }); return response.data.results; } catch (error) { - logger.error('Failed to search multi:', error); return []; } } @@ -552,7 +539,6 @@ export class TMDBService { }); return response.data; } catch (error) { - logger.error('Failed to get movie details:', error); return null; } } @@ -560,18 +546,49 @@ 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 { 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` }), }); const images = response.data; + 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) { + 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) { + 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) { + 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') && @@ -621,8 +638,6 @@ export class TMDBService { 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); return null; } } @@ -630,17 +645,48 @@ 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 { 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` }), }); const images = response.data; + 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) { + 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) { + 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) { + return this.getImageUrl(preferredLogo.file_path); + } + } + // First prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => logo.file_path && @@ -691,8 +737,6 @@ export class TMDBService { 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); return null; } } @@ -700,13 +744,18 @@ 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 { - return type === 'movie' - ? await this.getMovieImages(id) - : await this.getTvShowImages(id); + const result = type === 'movie' + ? await this.getMovieImages(id, preferredLanguage) + : await this.getTvShowImages(id, preferredLanguage); + + if (result) { + } else { + } + + return result; } catch (error) { - logger.error(`Failed to get content logo for ${type} ID ${id}:`, error); return null; } } @@ -741,7 +790,6 @@ export class TMDBService { } return null; } catch (error) { - logger.error('Error fetching certification:', error); return null; } } @@ -777,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; } }) @@ -785,7 +832,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to get trending ${type} content:`, error); return []; } } @@ -822,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; } }) @@ -830,7 +875,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to get popular ${type} content:`, error); return []; } } @@ -870,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; } }) @@ -878,7 +921,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to get upcoming ${type} content:`, error); return []; } } @@ -896,7 +938,6 @@ export class TMDBService { }); return response.data.genres || []; } catch (error) { - logger.error('Failed to fetch movie genres:', error); return []; } } @@ -914,7 +955,6 @@ export class TMDBService { }); return response.data.genres || []; } catch (error) { - logger.error('Failed to fetch TV genres:', error); return []; } } @@ -935,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 []; } @@ -969,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; } }) @@ -977,7 +1015,6 @@ export class TMDBService { return resultsWithExternalIds; } catch (error) { - logger.error(`Failed to discover ${type} by genre ${genreName}:`, error); return []; } } 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 new file mode 100644 index 0000000..18d1bee --- /dev/null +++ b/src/styles/screens/discoverStyles.ts @@ -0,0 +1,68 @@ +import { StyleSheet, Dimensions } from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; + +const useDiscoverStyles = () => { + const { width } = Dimensions.get('window'); + const { currentTheme } = useTheme(); + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: currentTheme.colors.darkBackground, + }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: currentTheme.colors.darkBackground, + zIndex: 1, + }, + contentContainer: { + flex: 1, + backgroundColor: currentTheme.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: currentTheme.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: currentTheme.colors.mediumGray, + fontSize: 16, + textAlign: 'center', + paddingHorizontal: 32, + }, + }); +}; + +export default useDiscoverStyles; \ No newline at end of file diff --git a/src/utils/logoUtils.ts b/src/utils/logoUtils.ts new file mode 100644 index 0000000..fcbdc05 --- /dev/null +++ b/src/utils/logoUtils.ts @@ -0,0 +1,170 @@ +import { logger } from './logger'; +import { TMDBService } from '../services/tmdbService'; + +/** + * 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'); +}; + +/** + * 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