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