Merge pull request #5 from tapframe/ios

Ios
This commit is contained in:
tapframe 2025-05-04 03:27:32 +05:30 committed by GitHub
commit 19ed2aa1a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 8884 additions and 4780 deletions

62
App.tsx
View file

@ -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 (
<PaperProvider theme={customDarkTheme}>
<NavigationContainer
theme={customNavigationTheme}
// Disable automatic linking which can cause layout issues
linking={undefined}
>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
style="light"
/>
<AppNavigator />
</View>
</NavigationContainer>
</PaperProvider>
);
}
function App(): React.JSX.Element {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GenreProvider>
<CatalogProvider>
<TraktProvider>
<PaperProvider theme={CustomDarkTheme}>
<NavigationContainer
theme={CustomNavigationDarkTheme}
// Disable automatic linking which can cause layout issues
linking={undefined}
>
<View style={[styles.container, { backgroundColor: '#000000' }]}>
<StatusBar
style="light"
/>
<AppNavigator />
</View>
</NavigationContainer>
</PaperProvider>
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>

17
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -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<RootStackParamList>;
export const NuvioHeader = () => {
const navigation = useNavigation<NavigationProp>();
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 = () => {
<MaterialCommunityIcons
name="magnify"
size={24}
color={colors.white}
color={currentTheme.colors.white}
/>
</View>
</TouchableOpacity>

View file

@ -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 (
<TouchableOpacity
style={[
styles.dayItem,
styles.dayButton,
today && styles.todayItem,
isSelected && styles.selectedItem,
hasEvents && styles.dayWithEvents
@ -66,25 +58,26 @@ const DayItem = ({
>
<Text style={[
styles.dayText,
!isCurrentMonth && styles.otherMonthDay,
!isCurrentMonth && { color: currentTheme.colors.lightGray + '80' },
today && styles.todayText,
isSelected && styles.selectedDayText
]}>
{date.getDate()}
</Text>
{hasEvents && (
<View style={styles.eventIndicator} />
<View style={[styles.eventIndicator, { backgroundColor: currentTheme.colors.primary }]} />
)}
</TouchableOpacity>
);
};
export const CalendarSection: React.FC<CalendarSectionProps> = ({
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<Date | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
// Map of dates with episodes
@ -97,7 +90,7 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
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<CalendarSectionProps> = ({
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) => (
<View key={weekIndex} style={styles.weekRow}>
{week.map((day, dayIndex) => {
if (!day) {
return <View key={`empty-${dayIndex}`} style={styles.emptyDay} />;
}
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 (
<TouchableOpacity
key={day.toISOString()}
style={[
styles.dayButton,
isCurrentDay && [styles.todayItem, { backgroundColor: currentTheme.colors.primary + '30', borderColor: currentTheme.colors.primary }],
isSelected && [styles.selectedItem, { backgroundColor: currentTheme.colors.primary + '60', borderColor: currentTheme.colors.primary }],
hasEvents && styles.dayWithEvents
]}
onPress={() => handleDateSelect(day)}
>
<Text
style={[
styles.dayText,
{ color: currentTheme.colors.text },
!isCurrentMonth && { color: currentTheme.colors.lightGray + '80' },
isCurrentDay && [styles.todayText, { color: currentTheme.colors.primary }],
isSelected && [styles.selectedDayText, { color: currentTheme.colors.text }]
]}
>
{format(day, 'd')}
</Text>
{hasEvents && (
<View style={[styles.eventDot, { backgroundColor: currentTheme.colors.primary }]} />
)}
</TouchableOpacity>
);
})}
</View>
));
};
// 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 (
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={goToPreviousMonth} style={styles.headerButton}>
<MaterialIcons name="chevron-left" size={24} color={colors.text} />
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
onPress={goToPreviousMonth}
style={styles.headerButton}
>
<MaterialIcons name="chevron-left" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={styles.monthTitle}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
{format(currentDate, 'MMMM yyyy')}
</Text>
<TouchableOpacity onPress={goToNextMonth} style={styles.headerButton}>
<MaterialIcons name="chevron-right" size={24} color={colors.text} />
<TouchableOpacity
onPress={goToNextMonth}
style={styles.headerButton}
>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
</View>
<View style={styles.weekHeader}>
<View style={styles.weekDaysContainer}>
{weekDays.map((day, index) => (
<View key={index} style={styles.weekHeaderItem}>
<Text style={styles.weekDayText}>{day}</Text>
</View>
<Text
key={index}
style={[styles.weekDayText, { color: currentTheme.colors.lightGray }]}
>
{day}
</Text>
))}
</View>
<View style={styles.calendarGrid}>
{rows.map((row, rowIndex) => (
<View key={rowIndex} style={styles.row}>
{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 (
<DayItem
key={cellIndex}
date={date}
isCurrentMonth={isCurrentMonthDay}
isToday={isSelectedToday}
isSelected={isDateSelected}
hasEvents={hasEvents}
onPress={handleDayPress}
/>
);
})}
</View>
))}
<View style={styles.daysContainer}>
{renderDays()}
</View>
</Animated.View>
</View>
);
};
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,
},
});

View file

@ -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<NavigationProp<RootStackParamList>>();
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 }) => (
<ContentItem
item={item}
onPress={() => handleContentPress(item)}
width={itemWidth}
/>
), [handleContentPress, itemWidth]);
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
const ItemSeparator = useCallback(() => (
<View style={{ width: 16 }} />
), []);
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.white }]}>
{catalog.genre}
</Text>
<View style={[styles.titleBar, { backgroundColor: currentTheme.colors.primary }]} />
</View>
<TouchableOpacity
onPress={handleSeeMorePress}
style={styles.seeAllButton}
activeOpacity={0.6}
>
<Text style={[styles.seeAllText, { color: currentTheme.colors.primary }]}>See All</Text>
<MaterialIcons name="arrow-forward-ios" color={currentTheme.colors.primary} size={14} />
</TouchableOpacity>
</View>
<FlatList
data={displayItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
snapToInterval={itemWidth + 16}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={ItemSeparator}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={3}
removeClippedSubviews={true}
/>
</View>
);
};
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);

View file

@ -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 }) => (
<CatalogSection
catalog={item}
selectedCategory={selectedCategory}
/>
), [selectedCategory]);
// Memoize list key extractor
const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []);
return (
<FlatList
data={catalogs}
renderItem={renderCatalogItem}
keyExtractor={catalogKeyExtractor}
contentContainerStyle={styles.container}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
);
};
const styles = StyleSheet.create({
container: {
paddingVertical: 8,
},
});
export default React.memo(CatalogsList);

View file

@ -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 (
<TouchableOpacity
key={category.id}
style={[
styles.categoryButton,
isSelected && { backgroundColor: currentTheme.colors.primary }
]}
onPress={() => onSelectCategory(category)}
activeOpacity={0.7}
>
<MaterialIcons
name={category.icon}
size={24}
color={isSelected ? currentTheme.colors.white : currentTheme.colors.mediumGray}
/>
<Text
style={[
styles.categoryText,
isSelected && { color: currentTheme.colors.white, fontWeight: '700' }
]}
>
{category.name}
</Text>
</TouchableOpacity>
);
}, [selectedCategory, onSelectCategory, currentTheme]);
return (
<View style={styles.container}>
<View style={styles.content}>
{categories.map(renderCategoryButton)}
</View>
</View>
);
};
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);

View file

@ -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 (
<TouchableOpacity
style={[styles.container, { width: itemWidth }]}
onPress={onPress}
activeOpacity={0.6}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory-disk"
transition={300}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.gradient}
>
<Text style={[styles.title, { color: currentTheme.colors.white }]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={styles.year}>{item.year}</Text>
)}
</LinearGradient>
</View>
</TouchableOpacity>
);
};
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);

View file

@ -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 (
<TouchableOpacity
key={genre}
style={[
styles.genreButton,
isSelected && { backgroundColor: currentTheme.colors.primary }
]}
onPress={() => onSelectGenre(genre)}
activeOpacity={0.7}
>
<Text
style={[
styles.genreText,
isSelected && { color: currentTheme.colors.white, fontWeight: '600' }
]}
>
{genre}
</Text>
</TouchableOpacity>
);
}, [selectedGenre, onSelectGenre, currentTheme]);
return (
<View style={styles.container}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollViewContent}
decelerationRate="fast"
snapToInterval={10}
>
{genres.map(renderGenreButton)}
</ScrollView>
</View>
);
};
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);

View file

@ -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<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const handleContentPress = (id: string, type: string) => {
navigation.navigate('Metadata', { id, type });
@ -43,9 +44,9 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
>
<View style={styles.catalogHeader}>
<View style={styles.titleContainer}>
<Text style={styles.catalogTitle}>{catalog.name}</Text>
<Text style={[styles.catalogTitle, { color: currentTheme.colors.highEmphasis }]}>{catalog.name}</Text>
<LinearGradient
colors={[colors.primary, colors.secondary]}
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
@ -61,8 +62,8 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
}
style={styles.seeAllButton}
>
<Text style={styles.seeAllText}>See More</Text>
<MaterialIcons name="arrow-forward" color={colors.primary} size={16} />
<Text style={[styles.seeAllText, { color: currentTheme.colors.primary }]}>See More</Text>
<MaterialIcons name="arrow-forward" color={currentTheme.colors.primary} size={16} />
</TouchableOpacity>
</View>
@ -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,
},
});

View file

@ -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) && (
<View style={[styles.loadingOverlay, { backgroundColor: colors.elevation2 }]}>
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation2 }]}>
{!imageError ? (
<ActivityIndicator color={colors.primary} size="small" />
<ActivityIndicator color={currentTheme.colors.primary} size="small" />
) : (
<MaterialIcons name="broken-image" size={24} color={colors.lightGray} />
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.lightGray} />
)}
</View>
)}
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={colors.success} />
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{localItem.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={colors.white} />
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
@ -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,
},

View file

@ -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<ContinueWatchingRef>((props, ref) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState);
@ -213,9 +214,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={styles.title}>Continue Watching</Text>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text>
<LinearGradient
colors={[colors.primary, colors.secondary]}
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
@ -227,7 +228,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
data={continueWatchingItems}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.contentItem}
style={[styles.contentItem, {
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.7}
onPress={() => handleContentPress(item.id, item.type)}
>
@ -240,12 +244,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
cachePolicy="memory-disk"
/>
{item.type === 'series' && item.season && item.episode && (
<View style={styles.episodeInfoContainer}>
<Text style={styles.episodeInfo}>
<View style={[styles.episodeInfoContainer, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}>
<Text style={[styles.episodeInfo, { color: currentTheme.colors.white }]}>
S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')}
</Text>
{item.episodeTitle && (
<Text style={styles.episodeTitle} numberOfLines={1}>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.white, opacity: 0.9 }]} numberOfLines={1}>
{item.episodeTitle}
</Text>
)}
@ -256,7 +260,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<View
style={[
styles.progressBar,
{ width: `${item.progress}%` }
{ width: `${item.progress}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
@ -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,
},
});

View file

@ -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<NavigationProp<RootStackParamList>>();
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const { currentTheme } = useTheme();
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(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<string | null>(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<boolean>(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 <SkeletonFeatured />;
@ -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
<Animated.View
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
>
{featuredContent.logo ? (
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<ExpoImage
source={{ uri: logoUrl || featuredContent.logo }}
source={{ uri: logoUrl }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
cachePolicy="memory-disk"
transition={400}
onError={() => {
console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`);
setLogoLoadError(true);
}}
/>
</Animated.View>
) : (
<Text style={styles.featuredTitleText as TextStyle}>{featuredContent.name}</Text>
<Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}>
{featuredContent.name}
</Text>
)}
<View style={styles.genreContainer as ViewStyle}>
{featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
<React.Fragment key={index}>
<Text style={styles.genreText as TextStyle}>{genre}</Text>
<Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
{index < array.length - 1 && (
<Text style={styles.genreDot as TextStyle}></Text>
<Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}></Text>
)}
</React.Fragment>
))}
@ -198,15 +395,15 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
<MaterialIcons
name={isSaved ? "bookmark" : "bookmark-border"}
size={24}
color={colors.white}
color={currentTheme.colors.white}
/>
<Text style={styles.myListButtonText as TextStyle}>
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
{isSaved ? "Saved" : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.playButton as ViewStyle}
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
onPress={() => {
if (featuredContent) {
navigation.navigate('Streams', {
@ -216,8 +413,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}
}}
>
<MaterialIcons name="play-arrow" size={24} color={colors.black} />
<Text style={styles.playButtonText as TextStyle}>Play</Text>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play
</Text>
</TouchableOpacity>
<TouchableOpacity
@ -231,8 +430,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}
}}
>
<MaterialIcons name="info-outline" size={24} color={colors.white} />
<Text style={styles.infoButtonText as TextStyle}>Info</Text>
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
Info
</Text>
</TouchableOpacity>
</Animated.View>
</LinearGradient>
@ -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',
},

View file

@ -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 = () => (
<View style={styles.catalogContainer}>
<View style={styles.loadingPlaceholder}>
<ActivityIndicator size="small" color={colors.primary} />
export const SkeletonCatalog = () => {
const { currentTheme } = useTheme();
return (
<View style={styles.catalogContainer}>
<View style={[styles.loadingPlaceholder, { backgroundColor: currentTheme.colors.elevation1 }]}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
</View>
</View>
);
);
};
export const SkeletonFeatured = () => (
<View style={styles.featuredLoadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading featured content...</Text>
</View>
);
export const SkeletonFeatured = () => {
const { currentTheme } = useTheme();
return (
<View style={[styles.featuredLoadingContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading featured content...</Text>
</View>
);
};
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,
},

View file

@ -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<ThisWeekEpisode[]>([]);
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 (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
);
}
@ -217,26 +218,27 @@ export const ThisWeekSection = () => {
<View style={styles.badgeContainer}>
<View style={[
styles.badge,
isReleased ? styles.releasedBadge : styles.upcomingBadge
isReleased ? styles.releasedBadge : styles.upcomingBadge,
{ backgroundColor: isReleased ? currentTheme.colors.success + 'CC' : currentTheme.colors.primary + 'CC' }
]}>
<MaterialIcons
name={isReleased ? "check-circle" : "event"}
size={12}
color={isReleased ? "#ffffff" : "#ffffff"}
color={currentTheme.colors.white}
/>
<Text style={styles.badgeText}>
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{isReleased ? 'Released' : 'Coming Soon'}
</Text>
</View>
{item.vote_average > 0 && (
<View style={styles.ratingBadge}>
<View style={[styles.ratingBadge, { backgroundColor: 'rgba(0,0,0,0.8)' }]}>
<MaterialIcons
name="star"
size={12}
color={colors.primary}
color={currentTheme.colors.primary}
/>
<Text style={styles.ratingText}>
<Text style={[styles.ratingText, { color: currentTheme.colors.primary }]}>
{item.vote_average.toFixed(1)}
</Text>
</View>
@ -244,18 +246,18 @@ export const ThisWeekSection = () => {
</View>
<View style={styles.content}>
<Text style={styles.seriesName} numberOfLines={1}>
<Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}>
{item.seriesName}
</Text>
<Text style={styles.episodeTitle} numberOfLines={2}>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
S{item.season}:E{item.episode} - {item.title}
</Text>
{item.overview ? (
<Text style={styles.overview} numberOfLines={2}>
<Text style={[styles.overview, { color: currentTheme.colors.lightGray, opacity: 0.8 }]} numberOfLines={2}>
{item.overview}
</Text>
) : null}
<Text style={styles.releaseDate}>
<Text style={[styles.releaseDate, { color: currentTheme.colors.primary }]}>
{formattedDate}
</Text>
</View>
@ -268,10 +270,10 @@ export const ThisWeekSection = () => {
return (
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>This Week</Text>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
<TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}>
<Text style={styles.viewAllText}>View All</Text>
<MaterialIcons name="chevron-right" size={18} color={colors.lightGray} />
<Text style={[styles.viewAllText, { color: currentTheme.colors.lightGray }]}>View All</Text>
<MaterialIcons name="chevron-right" size={18} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
@ -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',
},

View file

@ -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<CastSectionProps> = ({
@ -24,123 +25,137 @@ export const CastSection: React.FC<CastSectionProps> = ({
loadingCast,
onSelectCastMember,
}) => {
const { currentTheme } = useTheme();
if (loadingCast) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
);
}
if (!cast.length) {
if (!cast || cast.length === 0) {
return null;
}
return (
<View style={styles.castSection}>
<Text style={styles.sectionTitle}>Cast</Text>
<ScrollView
horizontal
<Animated.View
style={styles.castSection}
entering={FadeIn.duration(500).delay(300)}
layout={Layout}
>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>Cast</Text>
</View>
<FlatList
horizontal
data={cast}
showsHorizontalScrollIndicator={false}
style={styles.castScrollContainer}
contentContainerStyle={styles.castContainer}
snapToAlignment="start"
>
{cast.map((member) => (
<TouchableOpacity
key={member.id}
style={styles.castMember}
onPress={() => onSelectCastMember(member)}
contentContainerStyle={styles.castList}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<Animated.View
entering={FadeIn.duration(500).delay(100 + index * 50)}
layout={Layout}
>
<View style={styles.castImageContainer}>
{member.profile_path ? (
<Image
source={{
uri: `https://image.tmdb.org/t/p/w185${member.profile_path}`
}}
style={styles.castImage}
contentFit="cover"
/>
) : (
<MaterialIcons
name="person"
size={32}
color={colors.textMuted}
/>
<TouchableOpacity
style={styles.castCard}
onPress={() => onSelectCastMember(item)}
activeOpacity={0.7}
>
<View style={styles.castImageContainer}>
{item.profile_path ? (
<Image
source={{
uri: `https://image.tmdb.org/t/p/w185${item.profile_path}`,
}}
style={styles.castImage}
contentFit="cover"
transition={200}
/>
) : (
<View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.cardBackground }]}>
<Text style={[styles.placeholderText, { color: currentTheme.colors.textMuted }]}>
{item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
</Text>
</View>
)}
</View>
<Text style={[styles.castName, { color: currentTheme.colors.text }]} numberOfLines={1}>{item.name}</Text>
{item.character && (
<Text style={[styles.characterName, { color: currentTheme.colors.textMuted }]} numberOfLines={1}>{item.character}</Text>
)}
</View>
<View style={styles.castTextContainer}>
<Text style={styles.castName} numberOfLines={1}>{member.name}</Text>
<Text style={styles.castCharacter} numberOfLines={1}>{member.character}</Text>
</View>
</TouchableOpacity>
))}
</ScrollView>
</View>
</TouchableOpacity>
</Animated.View>
)}
/>
</Animated.View>
);
};
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,
},
});

View file

@ -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<number>;
headerElementsY: Animated.SharedValue<number>;
headerElementsOpacity: Animated.SharedValue<number>;
safeAreaTop: number;
setLogoLoadError: (error: boolean) => void;
}
const FloatingHeader: React.FC<FloatingHeaderProps> = ({
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 (
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
{Platform.OS === 'ios' ? (
<ExpoBlurView
intensity={50}
tint="dark"
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
onError={() => {
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={[styles.floatingHeaderTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>{metadata.name}</Text>
)}
</View>
<TouchableOpacity
style={styles.headerActionButton}
onPress={handleToggleLibrary}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={22}
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</ExpoBlurView>
) : (
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
<CommunityBlurView
style={styles.absoluteFill}
blurType="dark"
blurAmount={15}
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
/>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
onError={() => {
logger.warn(`[FloatingHeader] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={[styles.floatingHeaderTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>{metadata.name}</Text>
)}
</View>
<TouchableOpacity
style={styles.headerActionButton}
onPress={handleToggleLibrary}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={22}
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</View>
)}
{Platform.OS === 'ios' && <View style={[styles.headerBottomBorder, { backgroundColor: 'rgba(255,255,255,0.15)' }]} />}
</Animated.View>
);
};
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);

View file

@ -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<number>;
dampedScrollY: Animated.SharedValue<number>;
heroHeight: Animated.SharedValue<number>;
heroOpacity: Animated.SharedValue<number>;
heroScale: Animated.SharedValue<number>;
logoOpacity: Animated.SharedValue<number>;
logoScale: Animated.SharedValue<number>;
genresOpacity: Animated.SharedValue<number>;
genresTranslateY: Animated.SharedValue<number>;
buttonsOpacity: Animated.SharedValue<number>;
buttonsTranslateY: Animated.SharedValue<number>;
watchProgressOpacity: Animated.SharedValue<number>;
watchProgressScaleY: Animated.SharedValue<number>;
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 (
<Animated.View style={[styles.actionButtons, animatedStyle]}>
<TouchableOpacity
style={[styles.actionButton, styles.playButton]}
onPress={handleShowStreams}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>
{playButtonText}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.infoButton]}
onPress={toggleLibrary}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24}
color={currentTheme.colors.white}
/>
<Text style={styles.infoButtonText}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{type === 'series' && (
<TouchableOpacity
style={[styles.iconButton]}
onPress={async () => {
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
}
}}
>
<MaterialIcons
name="assessment"
size={24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
)}
</Animated.View>
);
});
// 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 (
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}>
<View style={styles.watchProgressBar}>
<View
style={[
styles.watchProgressFill,
{
width: `${progressPercent}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.watchProgressText, { color: currentTheme.colors.textMuted }]}>
{progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} Last watched on {formattedTime}
</Text>
</Animated.View>
);
});
const HeroSection: React.FC<HeroSectionProps> = ({
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) => (
<React.Fragment key={index}>
<Text style={[styles.genreText, { color: currentTheme.colors.text }]}>
{genreName}
</Text>
{index < array.length - 1 && (
<Text style={[styles.genreDot, { color: currentTheme.colors.text, opacity: 0.6 }]}>
</Text>
)}
</React.Fragment>
));
};
return (
<Animated.View style={heroAnimatedStyle}>
<View style={styles.heroSection}>
{loadingBanner ? (
<View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} />
) : (
<Animated.Image
source={{ uri: bannerImage || metadata.banner || metadata.poster }}
style={[styles.absoluteFill, parallaxImageStyle]}
resizeMode="cover"
onError={() => {
logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`);
if (bannerImage !== metadata.banner) {
setBannerImage(metadata.banner || metadata.poster);
}
}}
/>
)}
<LinearGradient
colors={[
`${currentTheme.colors.darkBackground}00`,
`${currentTheme.colors.darkBackground}20`,
`${currentTheme.colors.darkBackground}50`,
`${currentTheme.colors.darkBackground}C0`,
`${currentTheme.colors.darkBackground}F8`,
currentTheme.colors.darkBackground
]}
locations={[0, 0.4, 0.65, 0.8, 0.9, 1]}
style={styles.heroGradient}
>
<View style={styles.heroContent}>
{/* Title/Logo */}
<View style={styles.logoContainer}>
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
{metadata.logo && !logoLoadError ? (
<Image
source={{ uri: metadata.logo }}
style={styles.titleLogo}
contentFit="contain"
transition={300}
onError={() => {
logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`);
setLogoLoadError(true);
}}
/>
) : (
<Text style={[styles.heroTitle, { color: currentTheme.colors.highEmphasis }]}>{metadata.name}</Text>
)}
</Animated.View>
</View>
{/* Watch Progress */}
<WatchProgressDisplay
watchProgress={watchProgress}
type={type}
getEpisodeDetails={getEpisodeDetails}
animatedStyle={watchProgressAnimatedStyle}
/>
{/* Genre Tags */}
<Animated.View style={genresAnimatedStyle}>
<View style={styles.genreContainer}>
{renderGenres()}
</View>
</Animated.View>
{/* Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type}
id={id}
navigation={navigation}
playButtonText={getPlayButtonText()}
animatedStyle={buttonsAnimatedStyle}
/>
</View>
</LinearGradient>
</View>
</Animated.View>
);
};
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);

View file

@ -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<MetadataDetailsProps> = ({
metadata,
imdbId,
type,
renderRatings,
}) => {
const { currentTheme } = useTheme();
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
return (
<>
{/* Meta Info */}
<View style={styles.metaInfo}>
{metadata.year && (
<Text style={[styles.metaText, { color: currentTheme.colors.text }]}>{metadata.year}</Text>
)}
{metadata.runtime && (
<Text style={[styles.metaText, { color: currentTheme.colors.text }]}>{metadata.runtime}</Text>
)}
{metadata.certification && (
<Text style={[styles.metaText, { color: currentTheme.colors.text }]}>{metadata.certification}</Text>
)}
{metadata.imdbRating && (
<View style={styles.ratingContainer}>
<Image
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
style={styles.imdbLogo}
contentFit="contain"
/>
<Text style={[styles.ratingText, { color: currentTheme.colors.text }]}>{metadata.imdbRating}</Text>
</View>
)}
</View>
{/* Ratings Section */}
{renderRatings && renderRatings()}
{/* Creator/Director Info */}
<Animated.View
entering={FadeIn.duration(500).delay(200)}
style={styles.creatorContainer}
>
{metadata.directors && metadata.directors.length > 0 && (
<View style={styles.creatorSection}>
<Text style={[styles.creatorLabel, { color: currentTheme.colors.white }]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
<Text style={[styles.creatorText, { color: currentTheme.colors.mediumEmphasis }]}>{metadata.directors.join(', ')}</Text>
</View>
)}
{metadata.creators && metadata.creators.length > 0 && (
<View style={styles.creatorSection}>
<Text style={[styles.creatorLabel, { color: currentTheme.colors.white }]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
<Text style={[styles.creatorText, { color: currentTheme.colors.mediumEmphasis }]}>{metadata.creators.join(', ')}</Text>
</View>
)}
</Animated.View>
{/* Description */}
{metadata.description && (
<Animated.View
style={styles.descriptionContainer}
layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))}
>
<TouchableOpacity
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
activeOpacity={0.7}
>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={isFullDescriptionOpen ? undefined : 3}>
{metadata.description}
</Text>
<View style={styles.showMoreButton}>
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}>
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
</Text>
<MaterialIcons
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={18}
color={currentTheme.colors.textMuted}
/>
</View>
</TouchableOpacity>
</Animated.View>
)}
</>
);
};
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);

View file

@ -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<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const handleItemPress = async (item: StreamingContent) => {
@ -69,11 +70,11 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
>
<Image
source={{ uri: item.poster }}
style={styles.poster}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1 }]}
contentFit="cover"
transition={200}
/>
<Text style={styles.title} numberOfLines={2}>
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}>
{item.name}
</Text>
</TouchableOpacity>
@ -82,7 +83,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
if (loadingRecommendations) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
);
}
@ -93,7 +94,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>More Like This</Text>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>More Like This</Text>
<FlatList
data={recommendations}
renderItem={renderItem}
@ -115,7 +116,6 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 20,
fontWeight: '800',
color: colors.highEmphasis,
marginBottom: 12,
marginTop: 8,
paddingHorizontal: 16,
@ -132,12 +132,10 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
height: POSTER_HEIGHT,
borderRadius: 8,
backgroundColor: colors.elevation1,
marginBottom: 8,
},
title: {
fontSize: 13,
color: colors.mediumEmphasis,
fontWeight: '500',
lineHeight: 18,
},

View file

@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors } from '../../styles/colors';
import { useTheme } from '../../contexts/ThemeContext';
import { StreamingContent } from '../../types/metadata';
interface MovieContentProps {
@ -8,6 +8,7 @@ interface MovieContentProps {
}
export const MovieContent: React.FC<MovieContentProps> = ({ 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<MovieContentProps> = ({ metadata }) => {
<View style={styles.additionalInfo}>
{metadata.director && (
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Director:</Text>
<Text style={styles.metadataValue}>{metadata.director}</Text>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Director:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.director}</Text>
</View>
)}
{metadata.writer && (
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Writer:</Text>
<Text style={styles.metadataValue}>{metadata.writer}</Text>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Writer:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.writer}</Text>
</View>
)}
{hasCast && (
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Cast:</Text>
<Text style={styles.metadataValue}>{castDisplay}</Text>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Cast:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{castDisplay}</Text>
</View>
)}
</View>
@ -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,

View file

@ -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<RatingsSectionProps> = ({ imdbId, type })
const [enabledProviders, setEnabledProviders] = useState<Record<string, boolean>>({});
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<RatingsSectionProps> = ({ 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<RatingsSectionProps> = ({ 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<RatingsSectionProps> = ({ 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 (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
);
}
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 <View style={styles.loadingContainer}><ActivityIndicator size="small" color={currentTheme.colors.primary} /></View>;
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<RatingsSectionProps> = ({ 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<RatingsSectionProps> = ({ 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 (
<View key={source} style={styles.ratingItem}>
{config.isImage ? (
<Image
source={config.icon}
style={styles.ratingIcon}
resizeMode="contain"
/>
) : (
<config.icon
width={16}
height={16}
style={styles.ratingIcon}
/>
)}
<Text style={[styles.ratingValue, {color: config.color}]}>
{displayValue}{config.suffix}
</Text>
</View>
);
})}
<View style={styles.compactRatingsContainer}>
{displayRatings.map(([source, value]) => {
const config = ratingConfig[source as keyof typeof ratingConfig];
const displayValue = config.transform(parseFloat(value as string));
return (
<View key={source} style={styles.compactRatingItem}>
{config.isImage ? (
<Image
source={config.icon as any}
style={styles.compactRatingIcon}
resizeMode="contain"
/>
) : (
<View style={styles.compactSvgContainer}>
{React.createElement(config.icon as any, {
width: 16,
height: 16,
})}
</View>
)}
<Text style={[styles.compactRatingValue, { color: config.color }]}>
{displayValue}
</Text>
</View>
);
})}
</View>
</Animated.View>
);
};
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',
},
});

View file

@ -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<SeriesContentProps> = ({
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<SeriesContentProps> = ({
if (loadingSeasons) {
return (
<View style={styles.centeredContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.centeredText}>Loading episodes...</Text>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>Loading episodes...</Text>
</View>
);
}
@ -104,8 +105,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
if (episodes.length === 0) {
return (
<View style={styles.centeredContainer}>
<MaterialIcons name="error-outline" size={48} color="#666" />
<Text style={styles.centeredText}>No episodes available</Text>
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>No episodes available</Text>
</View>
);
}
@ -119,7 +120,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
return (
<View style={styles.seasonSelectorWrapper}>
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
<Text style={[styles.seasonSelectorTitle, { color: currentTheme.colors.highEmphasis }]}>Seasons</Text>
<ScrollView
ref={seasonScrollViewRef}
horizontal
@ -142,7 +143,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
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<SeriesContentProps> = ({
contentFit="cover"
/>
{selectedSeason === season && (
<View style={styles.selectedSeasonIndicator} />
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} />
)}
</View>
<Text
style={[
styles.seasonButtonText,
selectedSeason === season && styles.selectedSeasonButtonText
{ color: currentTheme.colors.mediumEmphasis },
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }]
]}
>
Season {season}
@ -215,7 +216,11 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
return (
<TouchableOpacity
key={episode.id}
style={[styles.episodeCard, isTablet && styles.episodeCardTablet]}
style={[
styles.episodeCard,
isTablet && styles.episodeCardTablet,
{ backgroundColor: currentTheme.colors.elevation2 }
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.7}
>
@ -233,21 +238,21 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<View
style={[
styles.progressBar,
{ width: `${progressPercent}%` }
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
{progressPercent >= 95 && (
<View style={styles.completedBadge}>
<MaterialIcons name="check" size={12} color={colors.white} />
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
</View>
)}
</View>
<View style={styles.episodeInfo}>
<View style={styles.episodeHeader}>
<Text style={styles.episodeTitle} numberOfLines={2}>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.text }]} numberOfLines={2}>
{episode.name}
</Text>
<View style={styles.episodeMetadata}>
@ -258,27 +263,27 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
style={styles.tmdbLogo}
contentFit="contain"
/>
<Text style={styles.ratingText}>
<Text style={[styles.ratingText, { color: currentTheme.colors.textMuted }]}>
{episode.vote_average.toFixed(1)}
</Text>
</View>
)}
{episode.runtime && (
<View style={styles.runtimeContainer}>
<MaterialIcons name="schedule" size={14} color={colors.textMuted} />
<Text style={styles.runtimeText}>
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.textMuted} />
<Text style={[styles.runtimeText, { color: currentTheme.colors.textMuted }]}>
{formatRuntime(episode.runtime)}
</Text>
</View>
)}
{episode.air_date && (
<Text style={styles.airDateText}>
<Text style={[styles.airDateText, { color: currentTheme.colors.textMuted }]}>
{formatDate(episode.air_date)}
</Text>
)}
</View>
</View>
<Text style={styles.episodeOverview} numberOfLines={2}>
<Text style={[styles.episodeOverview, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}>
{episode.overview || 'No description available'}
</Text>
</View>
@ -286,6 +291,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
);
};
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
return (
<View style={styles.container}>
<Animated.View
@ -297,7 +304,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<Animated.View
entering={FadeIn.duration(500).delay(200)}
>
<Text style={styles.sectionTitle}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
@ -310,7 +317,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
>
{isTablet ? (
<View style={styles.episodeGrid}>
{episodes.map((episode, index) => (
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
@ -320,7 +327,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
))}
</View>
) : (
episodes.map((episode, index) => (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
@ -349,14 +356,12 @@ const styles = StyleSheet.create({
centeredText: {
marginTop: 12,
fontSize: 16,
color: colors.textMuted,
textAlign: 'center',
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
color: colors.text,
},
episodeList: {
flex: 1,
@ -374,7 +379,6 @@ const styles = StyleSheet.create({
},
episodeCard: {
flexDirection: 'row',
backgroundColor: colors.darkBackground,
borderRadius: 16,
marginBottom: 16,
overflow: 'hidden',
@ -396,7 +400,6 @@ const styles = StyleSheet.create({
position: 'relative',
width: 120,
height: 120,
backgroundColor: colors.darkBackground,
},
episodeImage: {
width: '100%',
@ -432,7 +435,6 @@ const styles = StyleSheet.create({
episodeTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
letterSpacing: 0.3,
marginBottom: 2,
},
@ -461,13 +463,11 @@ const styles = StyleSheet.create({
},
airDateText: {
fontSize: 12,
color: colors.textMuted,
opacity: 0.8,
},
episodeOverview: {
fontSize: 13,
lineHeight: 18,
color: colors.textMuted,
},
seasonSelectorWrapper: {
marginBottom: 20,
@ -476,7 +476,6 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
color: colors.text,
},
seasonSelectorContainer: {
flexGrow: 0,
@ -510,15 +509,12 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
height: 4,
backgroundColor: colors.primary,
},
seasonButtonText: {
fontSize: 14,
fontWeight: '500',
color: colors.textMuted,
},
selectedSeasonButtonText: {
color: colors.text,
fontWeight: '700',
},
progressBarContainer: {
@ -531,7 +527,6 @@ const styles = StyleSheet.create({
},
progressBar: {
height: '100%',
backgroundColor: colors.primary,
},
progressTextContainer: {
flexDirection: 'row',
@ -543,7 +538,6 @@ const styles = StyleSheet.create({
marginRight: 8,
},
progressText: {
color: colors.primary,
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
@ -552,7 +546,6 @@ const styles = StyleSheet.create({
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: colors.success,
width: 20,
height: 20,
borderRadius: 10,
@ -570,7 +563,6 @@ const styles = StyleSheet.create({
borderRadius: 4,
},
runtimeText: {
color: colors.textMuted,
fontSize: 13,
fontWeight: '600',
marginLeft: 4,

42
src/constants/discover.ts Normal file
View file

@ -0,0 +1,42 @@
import { MaterialIcons } from '@expo/vector-icons';
import { StreamingContent } from '../services/catalogService';
export interface Category {
id: string;
name: string;
type: 'movie' | 'series' | 'channel' | 'tv';
icon: keyof typeof MaterialIcons.glyphMap;
}
export interface GenreCatalog {
genre: string;
items: StreamingContent[];
}
export const CATEGORIES: Category[] = [
{ id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' },
{ id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' }
];
// Common genres for movies and TV shows
export const COMMON_GENRES = [
'All',
'Action',
'Adventure',
'Animation',
'Comedy',
'Crime',
'Documentary',
'Drama',
'Family',
'Fantasy',
'History',
'Horror',
'Music',
'Mystery',
'Romance',
'Science Fiction',
'Thriller',
'War',
'Western'
];

View file

@ -0,0 +1,321 @@
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors as defaultColors } from '../styles/colors';
// Define the Theme interface
export interface Theme {
id: string;
name: string;
colors: typeof defaultColors;
isEditable: boolean;
}
// Default built-in themes
export const DEFAULT_THEMES: Theme[] = [
{
id: 'default',
name: 'Default Dark',
colors: defaultColors,
isEditable: false,
},
{
id: 'ocean',
name: 'Ocean Blue',
colors: {
...defaultColors,
primary: '#3498db',
secondary: '#2ecc71',
darkBackground: '#0a192f',
},
isEditable: false,
},
{
id: 'sunset',
name: 'Sunset',
colors: {
...defaultColors,
primary: '#ff7e5f',
secondary: '#feb47b',
darkBackground: '#1a0f0b',
},
isEditable: false,
},
{
id: 'moonlight',
name: 'Moonlight',
colors: {
...defaultColors,
primary: '#a786df',
secondary: '#5e72e4',
darkBackground: '#0f0f1a',
},
isEditable: false,
},
{
id: 'emerald',
name: 'Emerald',
colors: {
...defaultColors,
primary: '#2ecc71',
secondary: '#3498db',
darkBackground: '#0e1e13',
},
isEditable: false,
},
{
id: 'ruby',
name: 'Ruby',
colors: {
...defaultColors,
primary: '#e74c3c',
secondary: '#9b59b6',
darkBackground: '#1a0a0a',
},
isEditable: false,
},
{
id: 'amethyst',
name: 'Amethyst',
colors: {
...defaultColors,
primary: '#9b59b6',
secondary: '#3498db',
darkBackground: '#140a1c',
},
isEditable: false,
},
{
id: 'amber',
name: 'Amber',
colors: {
...defaultColors,
primary: '#f39c12',
secondary: '#d35400',
darkBackground: '#1a140a',
},
isEditable: false,
},
{
id: 'mint',
name: 'Mint',
colors: {
...defaultColors,
primary: '#1abc9c',
secondary: '#16a085',
darkBackground: '#0a1a17',
},
isEditable: false,
},
{
id: 'slate',
name: 'Slate',
colors: {
...defaultColors,
primary: '#7f8c8d',
secondary: '#95a5a6',
darkBackground: '#10191a',
},
isEditable: false,
},
{
id: 'neon',
name: 'Neon',
colors: {
...defaultColors,
primary: '#00ff00',
secondary: '#ff00ff',
darkBackground: '#0a0a0a',
},
isEditable: false,
},
{
id: 'retro',
name: 'Retro Wave',
colors: {
...defaultColors,
primary: '#ff00ff',
secondary: '#00ffff',
darkBackground: '#150036',
},
isEditable: false,
},
];
// Theme context props
interface ThemeContextProps {
currentTheme: Theme;
availableThemes: Theme[];
setCurrentTheme: (themeId: string) => void;
addCustomTheme: (theme: Omit<Theme, 'id' | 'isEditable'>) => void;
updateCustomTheme: (theme: Theme) => void;
deleteCustomTheme: (themeId: string) => void;
}
// Create the context
const ThemeContext = createContext<ThemeContextProps | undefined>(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<Theme>(DEFAULT_THEMES[0]);
const [availableThemes, setAvailableThemes] = useState<Theme[]>(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<Theme, 'id' | 'isEditable'>) => {
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 (
<ThemeContext.Provider
value={{
currentTheme,
availableThemes,
setCurrentTheme,
addCustomTheme,
updateCustomTheme,
deleteCustomTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
// 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;
}

View file

@ -9,6 +9,7 @@ interface TraktContextProps {
watchedMovies: TraktWatchedItem[];
watchedShows: TraktWatchedItem[];
checkAuthStatus: () => Promise<void>;
refreshAuthStatus: () => Promise<void>;
loadWatchedItems: () => Promise<void>;
isMovieWatched: (imdbId: string) => Promise<boolean>;
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;

View file

@ -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,
};
};

View file

@ -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<string | null>(null);
const [loadingBanner, setLoadingBanner] = useState<boolean>(false);
const forcedBannerRefreshDone = useRef<boolean>(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<boolean>(false);
const logoRefreshCounter = useRef<number>(0);
const MAX_LOGO_REFRESHES = 2;
const forcedLogoRefreshDone = useRef<boolean>(false);
// For TMDB ID tracking
const [foundTmdbId, setFoundTmdbId] = useState<string | null>(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
};
};

View file

@ -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';

View file

@ -8,6 +8,7 @@ export function useTraktIntegration() {
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
const [lastAuthCheck, setLastAuthCheck] = useState<number>(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
};
}

View file

@ -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<WatchProgressData | null>(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
};
};

View file

@ -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<RootStackParamList>;
@ -384,6 +391,7 @@ const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ 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 = () => {
>
<TabIcon
focused={isFocused}
color={isFocused ? colors.primary : '#FFFFFF'}
color={isFocused ? currentTheme.colors.primary : currentTheme.colors.white}
iconName={iconName}
/>
<Text
@ -498,7 +506,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 (
<View style={{ flex: 1, backgroundColor: colors.darkBackground }}>
<View style={{ flex: 1, backgroundColor: currentTheme.colors.darkBackground }}>
{/* Common StatusBar for all tabs */}
<StatusBar
translucent
@ -545,8 +553,8 @@ const MainTabs = () => {
return <TabIcon focused={focused} color={color} iconName={iconName} />;
},
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 (
<SafeAreaProvider>
@ -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,
},
}}
/>
<Stack.Screen
name="LogoSourceSettings"
component={LogoSourceSettings}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ThemeSettings"
component={ThemeScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ProfilesSettings"
component={ProfilesScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>

File diff suppressed because it is too large Load diff

View file

@ -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<NavigationProp<RootStackParamList>>();
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<CalendarSection[]>([]);
const [loading, setLoading] = useState(true);
@ -270,7 +270,7 @@ const CalendarScreen = () => {
return (
<Animated.View entering={FadeIn.duration(300).delay(100)}>
<TouchableOpacity
style={styles.episodeItem}
style={[styles.episodeItem, { borderBottomColor: currentTheme.colors.border + '20' }]}
onPress={() => handleEpisodePress(item)}
activeOpacity={0.7}
>
@ -287,18 +287,18 @@ const CalendarScreen = () => {
</TouchableOpacity>
<View style={styles.episodeDetails}>
<Text style={styles.seriesName} numberOfLines={1}>
<Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}>
{item.seriesName}
</Text>
{hasReleaseDate ? (
<>
<Text style={styles.episodeTitle} numberOfLines={2}>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
S{item.season}:E{item.episode} - {item.title}
</Text>
{item.overview ? (
<Text style={styles.overview} numberOfLines={2}>
<Text style={[styles.overview, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
{item.overview}
</Text>
) : null}
@ -308,9 +308,9 @@ const CalendarScreen = () => {
<MaterialIcons
name={isFuture ? "event" : "event-available"}
size={16}
color={colors.lightGray}
color={currentTheme.colors.lightGray}
/>
<Text style={styles.date}>{formattedDate}</Text>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{formattedDate}</Text>
</View>
{item.vote_average > 0 && (
@ -318,9 +318,9 @@ const CalendarScreen = () => {
<MaterialIcons
name="star"
size={16}
color={colors.primary}
color={currentTheme.colors.primary}
/>
<Text style={styles.rating}>
<Text style={[styles.rating, { color: currentTheme.colors.primary }]}>
{item.vote_average.toFixed(1)}
</Text>
</View>
@ -329,16 +329,16 @@ const CalendarScreen = () => {
</>
) : (
<>
<Text style={styles.noEpisodesText}>
<Text style={[styles.noEpisodesText, { color: currentTheme.colors.text }]}>
No scheduled episodes
</Text>
<View style={styles.dateContainer}>
<MaterialIcons
name="event-busy"
size={16}
color={colors.lightGray}
color={currentTheme.colors.lightGray}
/>
<Text style={styles.date}>Check back later</Text>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>Check back later</Text>
</View>
</>
)}
@ -349,8 +349,13 @@ const CalendarScreen = () => {
};
const renderSectionHeader = ({ section }: { section: CalendarSection }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={[styles.sectionHeader, {
backgroundColor: currentTheme.colors.darkBackground,
borderBottomColor: currentTheme.colors.border
}]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{section.title}
</Text>
</View>
);
@ -386,22 +391,22 @@ const CalendarScreen = () => {
if (libraryItems.length === 0 && !libraryLoading) {
return (
<SafeAreaView style={styles.container}>
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Calendar</Text>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text>
<View style={{ width: 40 }} />
</View>
<View style={styles.emptyLibraryContainer}>
<MaterialIcons name="video-library" size={64} color={colors.lightGray} />
<MaterialIcons name="video-library" size={64} color={currentTheme.colors.lightGray} />
<Text style={styles.emptyText}>
Your library is empty
</Text>
@ -423,10 +428,10 @@ const CalendarScreen = () => {
if (loading && !refreshing) {
return (
<SafeAreaView style={styles.container}>
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={styles.loadingText}>Loading calendar...</Text>
</View>
</SafeAreaView>
@ -434,27 +439,27 @@ const CalendarScreen = () => {
}
return (
<SafeAreaView style={styles.container}>
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Calendar</Text>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text>
<View style={{ width: 40 }} />
</View>
{selectedDate && filteredEpisodes.length > 0 && (
<View style={styles.filterInfoContainer}>
<Text style={styles.filterInfoText}>
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
Showing episodes for {format(selectedDate, 'MMMM d, yyyy')}
</Text>
<TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}>
<MaterialIcons name="close" size={18} color={colors.text} />
<MaterialIcons name="close" size={18} color={currentTheme.colors.text} />
</TouchableOpacity>
</View>
)}
@ -474,22 +479,22 @@ const CalendarScreen = () => {
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
/>
) : selectedDate && filteredEpisodes.length === 0 ? (
<View style={styles.emptyFilterContainer}>
<MaterialIcons name="event-busy" size={48} color={colors.lightGray} />
<Text style={styles.emptyFilterText}>
<MaterialIcons name="event-busy" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyFilterText, { color: currentTheme.colors.text }]}>
No episodes for {format(selectedDate, 'MMMM d, yyyy')}
</Text>
<TouchableOpacity
style={styles.clearFilterButtonLarge}
style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]}
onPress={clearDateFilter}
>
<Text style={styles.clearFilterButtonText}>
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.text }]}>
Show All Episodes
</Text>
</TouchableOpacity>
@ -505,18 +510,18 @@ const CalendarScreen = () => {
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
/>
) : (
<View style={styles.emptyContainer}>
<MaterialIcons name="calendar-today" size={64} color={colors.lightGray} />
<Text style={styles.emptyText}>
<MaterialIcons name="calendar-today" size={64} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.text }]}>
No upcoming episodes found
</Text>
<Text style={styles.emptySubtext}>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Add series to your library to see their upcoming episodes here
</Text>
</View>
@ -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,
},

View file

@ -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<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name: originalName, genreFilter } = route.params;
const [items, setItems] = useState<Meta[]>([]);
@ -54,6 +168,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dataSource, setDataSource] = useState<DataSource>(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<CatalogScreenProps> = ({ route, navigation }) => {
</View>
</TouchableOpacity>
);
}, [navigation]);
}, [navigation, styles]);
const renderEmptyState = () => (
<View style={styles.centered}>
@ -451,117 +568,4 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ 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;

View file

@ -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<CatalogSetting[]>([]);
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
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;

View file

@ -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 (
<TouchableOpacity
style={[
styles.categoryButton,
isSelected && styles.selectedCategoryButton
]}
onPress={onPress}
activeOpacity={0.7}
>
<MaterialIcons
name={category.icon}
size={24}
color={isSelected ? colors.white : colors.mediumGray}
/>
<Text
style={[
styles.categoryText,
isSelected && styles.selectedCategoryText
]}
>
{category.name}
</Text>
</TouchableOpacity>
);
});
const GenreButton = React.memo(({
genre,
isSelected,
onPress
}: {
genre: string;
isSelected: boolean;
onPress: () => void;
}) => {
const styles = useStyles();
return (
<TouchableOpacity
style={[
styles.genreButton,
isSelected && styles.selectedGenreButton
]}
onPress={onPress}
activeOpacity={0.7}
>
<Text
style={[
styles.genreText,
isSelected && styles.selectedGenreText
]}
>
{genre}
</Text>
</TouchableOpacity>
);
});
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 (
<TouchableOpacity
style={[styles.contentItem, { width: itemWidth }]}
onPress={onPress}
activeOpacity={0.6}
>
<View style={styles.posterContainer}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory-disk"
transition={300}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text style={styles.contentTitle} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={styles.contentYear}>{item.year}</Text>
)}
</LinearGradient>
</View>
</TouchableOpacity>
);
});
const CatalogSection = React.memo(({
catalog,
selectedCategory,
navigation
}: {
catalog: GenreCatalog;
selectedCategory: Category;
navigation: NavigationProp<RootStackParamList>;
}) => {
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 }) => (
<ContentItem
item={item}
onPress={() => 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(() => <View style={{ width: 16 }} />, []);
return (
<View style={styles.catalogContainer}>
<View style={styles.catalogHeader}>
<View style={styles.catalogTitleContainer}>
<Text style={styles.catalogTitle}>{catalog.genre}</Text>
<View style={styles.catalogTitleBar} />
</View>
<TouchableOpacity
onPress={handleSeeMorePress}
style={styles.seeAllButton}
activeOpacity={0.6}
>
<Text style={styles.seeAllText}>See All</Text>
<MaterialIcons name="arrow-forward-ios" color={colors.primary} size={14} />
</TouchableOpacity>
</View>
<FlatList
data={displayItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16 }}
snapToInterval={itemWidth + 16}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={ItemSeparator}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={3}
removeClippedSubviews={true}
/>
</View>
);
});
// 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<NavigationProp<RootStackParamList>>();
const [selectedCategory, setSelectedCategory] = useState<Category>(CATEGORIES[0]);
const [selectedGenre, setSelectedGenre] = useState<string>('All');
const [catalogs, setCatalogs] = useState<GenreCatalog[]>([]);
const [allContent, setAllContent] = useState<StreamingContent[]>([]);
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 }) => (
<CatalogSection
catalog={item}
selectedCategory={selectedCategory}
navigation={navigation}
/>
), [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 = () => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
</Text>
</View>
);
return (
<View style={styles.container}>
{/* Fixed position header background to prevent shifts */}
{/* Fixed position header background */}
<View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
{/* Header Section */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Discover</Text>
@ -635,72 +163,39 @@ const DiscoverScreen = () => {
<MaterialIcons
name="search"
size={24}
color={colors.white}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
</View>
</View>
{/* Rest of the content */}
{/* Content Container */}
<View style={styles.contentContainer}>
{/* Categories Section */}
<View style={styles.categoryContainer}>
<View style={styles.categoriesContent}>
{CATEGORIES.map((category) => (
<CategoryButton
key={category.id}
category={category}
isSelected={selectedCategory.id === category.id}
onPress={() => handleCategoryPress(category)}
/>
))}
</View>
</View>
<CategorySelector
categories={CATEGORIES}
selectedCategory={selectedCategory}
onSelectCategory={handleCategoryPress}
/>
{/* Genres Section */}
<View style={styles.genreContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.genresScrollView}
decelerationRate="fast"
snapToInterval={10}
>
{COMMON_GENRES.map(genre => (
<GenreButton
key={genre}
genre={genre}
isSelected={selectedGenre === genre}
onPress={() => handleGenrePress(genre)}
/>
))}
</ScrollView>
</View>
<GenreSelector
genres={COMMON_GENRES}
selectedGenre={selectedGenre}
onSelectGenre={handleGenrePress}
/>
{/* Content Section */}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
) : catalogs.length > 0 ? (
<FlatList
data={catalogs}
renderItem={renderCatalogItem}
keyExtractor={catalogKeyExtractor}
contentContainerStyle={styles.catalogsContainer}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
<CatalogsList
catalogs={catalogs}
selectedCategory={selectedCategory}
/>
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
</Text>
</View>
)}
) : renderEmptyState()}
</View>
</View>
</View>

View file

@ -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 (
<Modal
visible={visible}
@ -170,20 +172,20 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
<Animated.View style={[styles.modalOverlay, overlayStyle]}>
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.menuContainer, menuStyle, { backgroundColor }]}>
<View style={styles.dragHandle} />
<View style={styles.menuHeader}>
<Animated.View style={[styles.menuContainer, menuStyle]}>
<View style={[styles.dragHandle, { backgroundColor: currentTheme.colors.transparentLight }]} />
<View style={[styles.menuHeader, { borderBottomColor: currentTheme.colors.border }]}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.menuPoster}
contentFit="cover"
/>
<View style={styles.menuTitleContainer}>
<Text style={[styles.menuTitle, { color: isDarkMode ? '#FFFFFF' : '#000000' }]}>
<Text style={[styles.menuTitle, { color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }]}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.menuYear, { color: isDarkMode ? '#999999' : '#666666' }]}>
<Text style={[styles.menuYear, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{item.year}
</Text>
)}
@ -206,11 +208,11 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24}
color={colors.primary}
color={currentTheme.colors.primary}
/>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? '#FFFFFF' : '#000000' }
{ color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }
]}>
{option.label}
</Text>
@ -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) && (
<View style={[styles.loadingOverlay, { backgroundColor: colors.elevation2 }]}>
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation2 }]}>
{!imageError ? (
<ActivityIndicator color={colors.primary} size="small" />
<ActivityIndicator color={currentTheme.colors.primary} size="small" />
) : (
<MaterialIcons name="broken-image" size={24} color={colors.lightGray} />
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.lightGray} />
)}
</View>
)}
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={colors.success} />
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{localItem.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={colors.white} />
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
@ -344,17 +347,21 @@ const SAMPLE_CATEGORIES: Category[] = [
{ id: 'channel', name: 'Channels' },
];
const SkeletonCatalog = () => (
<View style={styles.catalogContainer}>
<View style={styles.loadingPlaceholder}>
<ActivityIndicator size="small" color={colors.primary} />
const SkeletonCatalog = () => {
const { currentTheme } = useTheme();
return (
<View style={styles.catalogContainer}>
<View style={styles.loadingPlaceholder}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
</View>
</View>
);
);
};
const HomeScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const { currentTheme } = useTheme();
const continueWatchingRef = useRef<ContinueWatchingRef>(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 (
<View style={homeStyles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<View style={homeStyles.loadingMainContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={homeStyles.loadingText}>Loading your content...</Text>
<View style={styles.loadingMainContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading your content...</Text>
</View>
</View>
);
}
return (
<SafeAreaView style={homeStyles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
@ -557,13 +571,13 @@ const HomeScreen = () => {
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary, colors.secondary]}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
/>
}
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 && (
<View style={homeStyles.emptyCatalog}>
<MaterialIcons name="movie-filter" size={40} color={colors.textDark} />
<Text style={{ color: colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={homeStyles.addCatalogButton}
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={colors.white} />
<Text style={homeStyles.addCatalogButtonText}>Add Catalogs</Text>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)
)}
</ScrollView>
</SafeAreaView>
</View>
);
};
@ -620,11 +634,44 @@ const POSTER_WIDTH = (width - 50) / 3;
const styles = StyleSheet.create<any>({
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<any>({
alignSelf: 'center',
},
featuredTitle: {
color: colors.white,
fontSize: 32,
fontWeight: '900',
marginBottom: 0,
@ -679,13 +725,11 @@ const styles = StyleSheet.create<any>({
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<any>({
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<any>({
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<any>({
catalogTitle: {
fontSize: 18,
fontWeight: '800',
color: colors.highEmphasis,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
@ -786,13 +827,11 @@ const styles = StyleSheet.create<any>({
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<any>({
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<any>({
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: colors.transparentDark,
},
modalOverlayPressable: {
flex: 1,
@ -896,7 +924,6 @@ const styles = StyleSheet.create<any>({
dragHandle: {
width: 40,
height: 4,
backgroundColor: colors.transparentLight,
borderRadius: 2,
alignSelf: 'center',
marginTop: 12,
@ -908,7 +935,7 @@ const styles = StyleSheet.create<any>({
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<any>({
flexDirection: 'row',
padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
menuPoster: {
width: 60,
@ -962,7 +988,7 @@ const styles = StyleSheet.create<any>({
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<any>({
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<any>({
paddingBottom: 20,
},
featuredTitleText: {
color: colors.highEmphasis,
fontSize: 28,
fontWeight: '900',
marginBottom: 8,
@ -1006,42 +1031,10 @@ const styles = StyleSheet.create<any>({
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<any>({
height: height * 0.4,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.elevation1,
},
});

View file

@ -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<SettingsCardProps> = ({ children, isDarkMode }) => (
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode, colors }) => (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
@ -46,6 +47,7 @@ interface SettingItemProps {
isLast?: boolean;
onPress?: () => void;
isDarkMode: boolean;
colors: any;
}
const SettingItem: React.FC<SettingItemProps> = ({
@ -55,7 +57,8 @@ const SettingItem: React.FC<SettingItemProps> = ({
renderControl,
isLast = false,
onPress,
isDarkMode
isDarkMode,
colors
}) => {
return (
<TouchableOpacity
@ -89,7 +92,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
);
};
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => (
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any }> = ({ title, isDarkMode, colors }) => (
<View style={styles.sectionHeader}>
<Text style={[
styles.sectionHeaderText,
@ -103,6 +106,8 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ 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<NavigationProp<RootStackParamList>>();
const [showSavedIndicator, setShowSavedIndicator] = useState(false);
@ -161,7 +166,7 @@ const HomeScreenSettings: React.FC = () => {
styles.radio,
{ borderColor: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{selected && <View style={styles.radioInner} />}
{selected && <View style={[styles.radioInner, { backgroundColor: colors.primary }]} />}
</View>
<Text style={[
styles.radioLabel,
@ -229,13 +234,14 @@ const HomeScreenSettings: React.FC = () => {
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SectionHeader title="DISPLAY OPTIONS" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SectionHeader title="DISPLAY OPTIONS" isDarkMode={isDarkMode} colors={colors} />
<SettingsCard isDarkMode={isDarkMode} colors={colors}>
<SettingItem
title="Show Hero Section"
description="Featured content at the top"
icon="movie-filter"
isDarkMode={isDarkMode}
colors={colors}
renderControl={() => (
<CustomSwitch
value={settings.showHeroSection}
@ -248,6 +254,7 @@ const HomeScreenSettings: React.FC = () => {
description={settings.featuredContentSource === 'tmdb' ? 'TMDB Trending' : 'From Catalogs'}
icon="settings-input-component"
isDarkMode={isDarkMode}
colors={colors}
renderControl={() => <View />}
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 = () => {
</>
)}
<SectionHeader title="ABOUT THESE SETTINGS" isDarkMode={isDarkMode} />
<SectionHeader title="ABOUT THESE SETTINGS" isDarkMode={isDarkMode} colors={colors} />
<View style={[styles.infoCard, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.03)' }]}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
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,

View file

@ -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 = () => {
<RNAnimated.View
style={[
styles.posterContainer,
{ opacity, backgroundColor: colors.darkBackground }
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]}
/>
<RNAnimated.View
style={[
styles.skeletonTitle,
{ opacity, backgroundColor: colors.darkBackground }
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]}
/>
</View>
@ -99,6 +100,7 @@ const LibraryScreen = () => {
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
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}
>
<View style={styles.posterContainer}>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
@ -169,7 +171,7 @@ const LibraryScreen = () => {
style={styles.posterGradient}
>
<Text
style={styles.itemTitle}
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
@ -186,7 +188,7 @@ const LibraryScreen = () => {
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%` }
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
@ -196,10 +198,10 @@ const LibraryScreen = () => {
<MaterialIcons
name="live-tv"
size={14}
color={colors.white}
color={currentTheme.colors.white}
style={{ marginRight: 4 }}
/>
<Text style={styles.badgeText}>Series</Text>
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text>
</View>
)}
</View>
@ -212,7 +214,8 @@ const LibraryScreen = () => {
<TouchableOpacity
style={[
styles.filterButton,
isActive && styles.filterButtonActive,
isActive && { backgroundColor: currentTheme.colors.primary },
{ shadowColor: currentTheme.colors.black }
]}
onPress={() => setFilter(filterType)}
activeOpacity={0.7}
@ -220,13 +223,14 @@ const LibraryScreen = () => {
<MaterialIcons
name={iconName}
size={22}
color={isActive ? colors.white : colors.mediumGray}
color={isActive ? currentTheme.colors.white : currentTheme.colors.mediumGray}
style={styles.filterIcon}
/>
<Text
style={[
styles.filterText,
isActive && styles.filterTextActive
{ color: currentTheme.colors.mediumGray },
isActive && { color: currentTheme.colors.white, fontWeight: '600' }
]}
>
{label}
@ -240,20 +244,20 @@ const LibraryScreen = () => {
const headerHeight = headerBaseHeight + topSpacing;
return (
<View style={styles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={[styles.headerBackground, { height: headerHeight, backgroundColor: currentTheme.colors.darkBackground }]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Library</Text>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
</View>
</View>
{/* Content Container */}
<View style={styles.contentContainer}>
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={styles.filtersContainer}>
{renderFilter('all', 'All', 'apps')}
{renderFilter('movies', 'Movies', 'movie')}
@ -267,19 +271,22 @@ const LibraryScreen = () => {
<MaterialIcons
name="video-library"
size={80}
color={colors.mediumGray}
color={currentTheme.colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={styles.exploreButton}
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={styles.exploreButtonText}>Explore Content</Text>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text>
</TouchableOpacity>
</View>
) : (
@ -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',
}

View file

@ -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<NavigationProp<any>>();
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<string>(
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<string | null>(null);
const [metahubLogo, setMetahubLogo] = useState<string | null>(null);
const [tmdbBanner, setTmdbBanner] = useState<string | null>(null);
const [metahubBanner, setMetahubBanner] = useState<string | null>(null);
const [loadingLogos, setLoadingLogos] = useState(true);
// State for TMDB language selection
// Store unique language codes as strings
const [uniqueTmdbLanguages, setUniqueTmdbLanguages] = useState<string[]>([]);
const [tmdbLogosData, setTmdbLogosData] = useState<Array<{ iso_639_1: string; file_path: string }> | 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<string>(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 (
<View style={[styles.exampleImage, styles.loadingContainer]}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
);
}
return (
<View style={styles.bannerContainer}>
<Image
source={{ uri: banner || undefined }}
style={styles.bannerImage}
resizeMode="cover"
/>
<View style={styles.bannerOverlay} />
{logo && (
<Image
source={{ uri: logo }}
style={styles.logoOverBanner}
resizeMode="contain"
/>
)}
{!logo && (
<View style={styles.noLogoContainer}>
<Text style={styles.noLogoText}>No logo available</Text>
</View>
)}
</View>
);
};
return (
<SafeAreaView style={[styles.container]}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons name="arrow-back" size={24} color={colors.white} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Logo Source</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={true}
scrollEventThrottle={32}
decelerationRate="normal"
>
{/* Description */}
<View style={styles.descriptionContainer}>
<Text style={styles.description}>
Choose the primary source for content logos and backgrounds. The selected source will be used exclusively.
</Text>
</View>
{/* Show selector */}
<View style={styles.showSelectorContainer}>
<Text style={styles.selectorLabel}>Select a show/movie to preview:</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.showsScrollContent}
scrollEventThrottle={32}
decelerationRate="normal"
>
{EXAMPLE_SHOWS.map((show) => (
<TouchableOpacity
key={show.imdbId}
style={[
styles.showItem,
selectedShow.imdbId === show.imdbId && styles.selectedShowItem
]}
onPress={() => handleShowSelect(show)}
activeOpacity={0.7}
delayPressIn={100}
>
<Text
style={[
styles.showItemText,
selectedShow.imdbId === show.imdbId && styles.selectedShowItemText
]}
>
{show.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Options */}
<View style={styles.optionsContainer}>
<TouchableOpacity
style={[
styles.optionCard,
logoSource === 'metahub' && styles.selectedCard
]}
onPress={() => applyLogoSourceSetting('metahub')}
activeOpacity={0.7}
delayPressIn={100}
>
<View style={styles.optionHeader}>
<Text style={styles.optionTitle}>Metahub</Text>
{logoSource === 'metahub' && (
<MaterialIcons name="check-circle" size={24} color={colors.primary} />
)}
</View>
<Text style={styles.optionDescription}>
High-quality logos from Metahub. Best for popular titles.
</Text>
<View style={styles.exampleContainer}>
<Text style={styles.exampleLabel}>Example:</Text>
{renderLogoExample(metahubLogo, metahubBanner, loadingLogos)}
<Text style={styles.logoSourceLabel}>{selectedShow.name} logo from Metahub</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.optionCard,
logoSource === 'tmdb' && styles.selectedCard
]}
onPress={() => applyLogoSourceSetting('tmdb')}
activeOpacity={0.7}
delayPressIn={100}
>
<View style={styles.optionHeader}>
<Text style={styles.optionTitle}>TMDB</Text>
{logoSource === 'tmdb' && (
<MaterialIcons name="check-circle" size={24} color={colors.primary} />
)}
</View>
<Text style={styles.optionDescription}>
Logos from TMDB. Offers localized options and better coverage for recent content.
</Text>
<View style={styles.exampleContainer}>
<Text style={styles.exampleLabel}>Example:</Text>
{renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)}
<Text style={styles.logoSourceLabel}>{selectedShow.name} logo from TMDB</Text>
</View>
{/* TMDB Language Selector */}
{uniqueTmdbLanguages.length > 1 && (
<View style={styles.languageSelectorContainer}>
<Text style={styles.languageSelectorTitle}>Logo Language</Text>
<Text style={styles.languageSelectorDescription}>
Select your preferred language for TMDB logos.
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.languageScrollContent}
scrollEventThrottle={32}
decelerationRate="normal"
>
{/* Iterate over unique language codes */}
{uniqueTmdbLanguages.map((langCode) => (
<TouchableOpacity
key={langCode} // Use the unique code as key
style={[
styles.languageItem,
selectedTmdbLanguage === langCode && styles.selectedLanguageItem
]}
onPress={() => handleTmdbLanguageSelect(langCode)}
activeOpacity={0.7}
delayPressIn={150}
>
<Text
style={[
styles.languageItemText,
selectedTmdbLanguage === langCode && styles.selectedLanguageItemText
]}
>
{(langCode || '').toUpperCase() || '??'}
</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={styles.noteText}>
If unavailable in preferred language, English will be used as fallback.
</Text>
</View>
)}
</TouchableOpacity>
</View>
{/* Additional Info */}
<View style={styles.infoBox}>
<Text style={styles.infoText}>
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.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default LogoSourceSettings;

View file

@ -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<string | null> => {
}
};
// 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;

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ import {
StatusBar,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors';
import { useTheme } from '../contexts/ThemeContext';
import { notificationService, NotificationSettings } from '../services/notificationService';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
@ -19,6 +19,7 @@ import { logger } from '../utils/logger';
const NotificationSettingsScreen = () => {
const navigation = useNavigation();
const { currentTheme } = useTheme();
const [settings, setSettings] = useState<NotificationSettings>({
enabled: true,
newEpisodeNotifications: true,
@ -155,36 +156,36 @@ const NotificationSettingsScreen = () => {
if (loading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Notification Settings</Text>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Notification Settings</Text>
<View style={{ width: 40 }} />
</View>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading settings...</Text>
<Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading settings...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Notification Settings</Text>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Notification Settings</Text>
<View style={{ width: 40 }} />
</View>
@ -193,72 +194,72 @@ const NotificationSettingsScreen = () => {
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
>
<View style={styles.section}>
<Text style={styles.sectionTitle}>General</Text>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>General</Text>
<View style={styles.settingItem}>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="notifications" size={24} color={colors.text} />
<Text style={styles.settingText}>Enable Notifications</Text>
<MaterialIcons name="notifications" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Enable Notifications</Text>
</View>
<Switch
value={settings.enabled}
onValueChange={(value) => 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}
/>
</View>
</View>
{settings.enabled && (
<>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Notification Types</Text>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Types</Text>
<View style={styles.settingItem}>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="new-releases" size={24} color={colors.text} />
<Text style={styles.settingText}>New Episodes</Text>
<MaterialIcons name="new-releases" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>New Episodes</Text>
</View>
<Switch
value={settings.newEpisodeNotifications}
onValueChange={(value) => 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}
/>
</View>
<View style={styles.settingItem}>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="event" size={24} color={colors.text} />
<Text style={styles.settingText}>Upcoming Shows</Text>
<MaterialIcons name="event" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Upcoming Shows</Text>
</View>
<Switch
value={settings.upcomingShowsNotifications}
onValueChange={(value) => 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}
/>
</View>
<View style={styles.settingItem}>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="alarm" size={24} color={colors.text} />
<Text style={styles.settingText}>Reminders</Text>
<MaterialIcons name="alarm" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Reminders</Text>
</View>
<Switch
value={settings.reminderNotifications}
onValueChange={(value) => 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}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Notification Timing</Text>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Timing</Text>
<Text style={styles.settingDescription}>
<Text style={[styles.settingDescription, { color: currentTheme.colors.lightGray }]}>
When should you be notified before an episode airs?
</Text>
@ -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)}
>
<Text style={[
styles.timingText,
settings.timeBeforeAiring === hours && styles.selectedTimingText
{ color: currentTheme.colors.text },
settings.timeBeforeAiring === hours && {
color: currentTheme.colors.primary,
fontWeight: 'bold',
}
]}>
{hours === 1 ? '1 hour' : `${hours} hours`}
</Text>
@ -283,27 +295,37 @@ const NotificationSettingsScreen = () => {
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Advanced</Text>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Advanced</Text>
<TouchableOpacity
style={styles.resetButton}
style={[
styles.resetButton,
{
backgroundColor: currentTheme.colors.error + '20',
borderColor: currentTheme.colors.error + '50'
}
]}
onPress={resetAllNotifications}
>
<MaterialIcons name="refresh" size={24} color={colors.error} />
<Text style={styles.resetButtonText}>Reset All Notifications</Text>
<MaterialIcons name="refresh" size={24} color={currentTheme.colors.error} />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.error }]}>Reset All Notifications</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.resetButton,
{ marginTop: 12, backgroundColor: colors.primary + '20', borderColor: colors.primary + '50' }
{
marginTop: 12,
backgroundColor: currentTheme.colors.primary + '20',
borderColor: currentTheme.colors.primary + '50'
}
]}
onPress={handleTestNotification}
disabled={countdown !== null}
>
<MaterialIcons name="bug-report" size={24} color={colors.primary} />
<Text style={[styles.resetButtonText, { color: colors.primary }]}>
<MaterialIcons name="bug-report" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}>
{countdown !== null
? `Notification in ${countdown}s...`
: 'Test Notification (1min)'}
@ -315,16 +337,16 @@ const NotificationSettingsScreen = () => {
<MaterialIcons
name="timer"
size={16}
color={colors.primary}
color={currentTheme.colors.primary}
style={styles.countdownIcon}
/>
<Text style={styles.countdownText}>
<Text style={[styles.countdownText, { color: currentTheme.colors.primary }]}>
Notification will appear in {countdown} seconds
</Text>
</View>
)}
<Text style={styles.resetDescription}>
<Text style={[styles.resetDescription, { color: currentTheme.colors.lightGray }]}>
This will cancel all scheduled notifications. You'll need to re-enable them manually.
</Text>
</View>
@ -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,
},
});

View file

@ -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<SettingItemProps> = ({
title,
description,
icon,
isDarkMode,
isSelected,
onPress,
isLast,
}) => (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' },
]}
>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
]}>
<MaterialIcons
name={icon}
size={20}
color={colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
]}
>
{title}
</Text>
{description && (
}) => {
const { currentTheme } = useTheme();
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: 'rgba(255,255,255,0.08)' },
]}
>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name={icon}
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
{description}
{title}
</Text>
{description && (
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
{description}
</Text>
)}
</View>
{isSelected && (
<MaterialIcons
name="check"
size={24}
color={currentTheme.colors.primary}
style={styles.checkIcon}
/>
)}
</View>
{isSelected && (
<MaterialIcons
name="check"
size={24}
color={colors.primary}
style={styles.checkIcon}
/>
)}
</View>
</TouchableOpacity>
);
</TouchableOpacity>
);
};
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 = () => {
<SafeAreaView
style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' },
{ backgroundColor: currentTheme.colors.darkBackground },
]}
>
<StatusBar
translucent
backgroundColor="transparent"
barStyle={isDarkMode ? "light-content" : "dark-content"}
barStyle="light-content"
/>
<View style={styles.header}>
@ -162,13 +162,13 @@ const PlayerSettingsScreen: React.FC = () => {
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
<Text
style={[
styles.headerTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
{ color: currentTheme.colors.text },
]}
>
Video Player
@ -183,7 +183,7 @@ const PlayerSettingsScreen: React.FC = () => {
<Text
style={[
styles.sectionTitle,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
{ color: currentTheme.colors.textMuted },
]}
>
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

View file

@ -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<Profile[]>([]);
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 }) => (
<View style={styles.profileItem}>
<TouchableOpacity
style={[
styles.profileContent,
item.isActive && {
backgroundColor: `${currentTheme.colors.primary}30`,
borderColor: currentTheme.colors.primary
}
]}
onPress={() => handleSelectProfile(item.id)}
>
<View style={styles.avatarContainer}>
<MaterialIcons
name="account-circle"
size={40}
color={item.isActive ? currentTheme.colors.primary : currentTheme.colors.text}
/>
</View>
<View style={styles.profileInfo}>
<Text style={[styles.profileName, { color: currentTheme.colors.text }]}>
{item.name}
</Text>
{item.isActive && (
<Text style={[styles.activeLabel, { color: currentTheme.colors.primary }]}>
Active
</Text>
)}
</View>
{!item.isActive && (
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDeleteProfile(item.id)}
>
<MaterialIcons name="delete" size={24} color={currentTheme.colors.error} />
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
<Text
style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
]}
>
Profiles
</Text>
</View>
<View style={styles.content}>
<FlatList
data={profiles}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
<Text style={[styles.sectionTitle, { color: currentTheme.colors.textMuted }]}>
MANAGE PROFILES
</Text>
}
ListFooterComponent={
<TouchableOpacity
style={[
styles.addButton,
{ backgroundColor: currentTheme.colors.elevation2 }
]}
onPress={() => setShowAddModal(true)}
>
<MaterialIcons name="add" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.addButtonText, { color: currentTheme.colors.text }]}>
Add New Profile
</Text>
</TouchableOpacity>
}
/>
</View>
{/* Modal for adding a new profile */}
<Modal
visible={showAddModal}
transparent
animationType="fade"
onRequestClose={() => setShowAddModal(false)}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.modalTitle, { color: currentTheme.colors.text }]}>
Create New Profile
</Text>
<TextInput
style={[
styles.input,
{
backgroundColor: `${currentTheme.colors.textMuted}20`,
color: currentTheme.colors.text,
borderColor: currentTheme.colors.border
}
]}
placeholder="Profile Name"
placeholderTextColor={currentTheme.colors.textMuted}
value={newProfileName}
onChangeText={setNewProfileName}
autoFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => {
setNewProfileName('');
setShowAddModal(false);
}}
>
<Text style={{ color: currentTheme.colors.textMuted }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.modalButton,
styles.createButton,
{ backgroundColor: currentTheme.colors.primary }
]}
onPress={handleAddProfile}
>
<Text style={{ color: '#fff' }}>Create</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
};
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;

View file

@ -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 = () => (
<View style={styles.skeletonVerticalItem}>
<RNAnimated.View style={[styles.skeletonPoster, { opacity }]} />
<RNAnimated.View style={[
styles.skeletonPoster,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonItemDetails}>
<RNAnimated.View style={[styles.skeletonTitle, { opacity }]} />
<RNAnimated.View style={[
styles.skeletonTitle,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonMetaRow}>
<RNAnimated.View style={[styles.skeletonMeta, { opacity }]} />
<RNAnimated.View style={[styles.skeletonMeta, { opacity }]} />
<RNAnimated.View style={[
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<RNAnimated.View style={[
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
</View>
</View>
</View>
@ -82,7 +113,10 @@ const SkeletonLoader = () => {
{[...Array(5)].map((_, index) => (
<View key={index}>
{index === 0 && (
<RNAnimated.View style={[styles.skeletonSectionHeader, { opacity }]} />
<RNAnimated.View style={[
styles.skeletonSectionHeader,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
)}
{renderSkeletonItem()}
</View>
@ -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 (
<RNAnimated.View
style={[
styles.simpleAnimationContainer,
{ opacity: fadeAnim }
]}
>
<View style={styles.simpleAnimationContent}>
<RNAnimated.View style={[
styles.spinnerContainer,
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
]}>
<MaterialIcons
name="search"
size={32}
color={currentTheme.colors.white}
/>
</RNAnimated.View>
<Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text>
</View>
</RNAnimated.View>
);
};
const SearchScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = true;
@ -100,6 +201,31 @@ const SearchScreen = () => {
const [searched, setSearched] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [showRecent, setShowRecent] = useState(true);
const inputRef = useRef<TextInput>(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 (
<View style={styles.recentSearchesContainer}>
<Text style={[styles.carouselTitle, { color: isDarkMode ? colors.white : colors.black }]}>
<Animated.View
style={styles.recentSearchesContainer}
entering={FadeIn.duration(300)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
Recent Searches
</Text>
{recentSearches.map((search, index) => (
<TouchableOpacity
<AnimatedTouchable
key={index}
style={styles.recentSearchItem}
onPress={() => {
setQuery(search);
Keyboard.dismiss();
}}
entering={FadeIn.duration(300).delay(index * 50)}
>
<MaterialIcons
name="history"
size={20}
color={isDarkMode ? colors.lightGray : colors.mediumGray}
color={currentTheme.colors.lightGray}
style={styles.recentSearchIcon}
/>
<Text style={[
styles.recentSearchText,
{ color: isDarkMode ? colors.white : colors.black }
]}>
<Text style={[styles.recentSearchText, { color: currentTheme.colors.white }]}>
{search}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
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}
>
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</AnimatedTouchable>
))}
</View>
</Animated.View>
);
};
const renderHorizontalItem = ({ item }: { item: StreamingContent }) => {
const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => {
return (
<TouchableOpacity
<AnimatedTouchable
style={styles.horizontalItem}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
entering={FadeIn.duration(500).delay(index * 100)}
activeOpacity={0.7}
>
<View style={styles.horizontalItemPosterContainer}>
<View style={[styles.horizontalItemPosterContainer, {
backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)'
}]}>
<Image
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
style={styles.horizontalItemPoster}
contentFit="cover"
transition={300}
/>
<View style={styles.itemTypeContainer}>
<Text style={[styles.itemTypeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'MOVIE' : 'SERIES'}
</Text>
</View>
{item.imdbRating && (
<View style={styles.ratingContainer}>
<MaterialIcons name="star" size={12} color="#FFC107" />
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
{item.imdbRating}
</Text>
</View>
)}
</View>
<Text
style={[styles.horizontalItemTitle, { color: isDarkMode ? colors.white : colors.black }]}
style={[styles.horizontalItemTitle, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
</Text>
</TouchableOpacity>
{item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray }]}>
{item.year}
</Text>
)}
</AnimatedTouchable>
);
};
@ -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 (
<SafeAreaView style={[
styles.container,
{ backgroundColor: colors.black }
]}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor={colors.black}
backgroundColor="transparent"
translucent
/>
<View style={styles.header}>
<Text style={styles.headerTitle}>Search</Text>
<View style={[
styles.searchBar,
{
backgroundColor: colors.darkGray,
borderColor: 'transparent',
}
]}>
<MaterialIcons
name="search"
size={24}
color={colors.lightGray}
style={styles.searchIcon}
/>
<TextInput
style={[
styles.searchInput,
{ color: colors.white }
]}
placeholder="Search movies, shows..."
placeholderTextColor={colors.lightGray}
value={query}
onChangeText={setQuery}
returnKeyType="search"
keyboardAppearance="dark"
autoFocus
/>
{query.length > 0 && (
<TouchableOpacity
onPress={handleClearSearch}
style={styles.clearButton}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, {
height: headerHeight,
backgroundColor: currentTheme.colors.darkBackground
}]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Search</Text>
<View style={styles.searchBarContainer}>
<View style={[
styles.searchBarWrapper,
{ width: '100%' }
]}>
<View style={[
styles.searchBar,
{
backgroundColor: currentTheme.colors.elevation2,
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
}
]}>
<MaterialIcons
name="search"
size={24}
color={currentTheme.colors.lightGray}
style={styles.searchIcon}
/>
<TextInput
style={[
styles.searchInput,
{ color: currentTheme.colors.white }
]}
placeholder="Search movies, shows..."
placeholderTextColor={currentTheme.colors.lightGray}
value={query}
onChangeText={setQuery}
returnKeyType="search"
keyboardAppearance="dark"
autoFocus
ref={inputRef}
/>
{query.length > 0 && (
<TouchableOpacity
onPress={handleClearSearch}
style={styles.clearButton}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
>
<MaterialIcons
name="close"
size={20}
color={currentTheme.colors.lightGray}
/>
</TouchableOpacity>
)}
</View>
</View>
</View>
</View>
{/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? (
<SimpleSearchAnimation />
) : searched && !hasResultsToShow ? (
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="close"
size={20}
color={colors.lightGray}
name="search-off"
size={64}
color={currentTheme.colors.lightGray}
/>
</TouchableOpacity>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
No results found
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Try different keywords or check your spelling
</Text>
</Animated.View>
) : (
<Animated.ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"
onScrollBeginDrag={Keyboard.dismiss}
entering={FadeIn.duration(300)}
showsVerticalScrollIndicator={false}
>
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
Movies ({movieResults.length})
</Text>
<FlatList
data={movieResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
)}
{seriesResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300).delay(100)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
TV Shows ({seriesResults.length})
</Text>
<FlatList
data={seriesResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
)}
</Animated.ScrollView>
)}
</View>
</View>
{searching ? (
<SkeletonLoader />
) : searched && !hasResultsToShow ? (
<View style={styles.emptyContainer}>
<MaterialIcons
name="search-off"
size={64}
color={isDarkMode ? colors.lightGray : colors.mediumGray}
/>
<Text style={[
styles.emptyText,
{ color: isDarkMode ? colors.white : colors.black }
]}>
No results found
</Text>
<Text style={[
styles.emptySubtext,
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
]}>
Try different keywords or check your spelling
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"
onScrollBeginDrag={Keyboard.dismiss}
>
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
<View style={styles.carouselContainer}>
<Text style={styles.carouselTitle}>Movies ({movieResults.length})</Text>
<FlatList
data={movieResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{seriesResults.length > 0 && (
<View style={styles.carouselContainer}>
<Text style={styles.carouselTitle}>TV Shows ({seriesResults.length})</Text>
<FlatList
data={seriesResults}
renderItem={renderHorizontalItem}
keyExtractor={item => `series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
</ScrollView>
)}
</SafeAreaView>
</View>
);
};
@ -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;

View file

@ -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<SettingsCardProps> = ({ children, isDarkMode, title }) => (
<View style={[styles.cardContainer]}>
{title && (
<Text style={[
styles.cardTitle,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
const { currentTheme } = useTheme();
return (
<View style={[styles.cardContainer]}>
{title && (
<Text style={[
styles.cardTitle,
{ color: currentTheme.colors.mediumEmphasis }
]}>
{title.toUpperCase()}
</Text>
)}
<View style={[
styles.card,
{ backgroundColor: currentTheme.colors.elevation2 }
]}>
{title.toUpperCase()}
</Text>
)}
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
{children}
</View>
</View>
</View>
);
);
};
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<SettingItemProps> = ({
renderControl,
isLast = false,
onPress,
isDarkMode,
badge
}) => {
const { currentTheme } = useTheme();
return (
<TouchableOpacity
activeOpacity={0.7}
@ -86,28 +88,28 @@ const SettingItem: React.FC<SettingItemProps> = ({
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)' }
]}
>
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons name={icon} size={20} color={colors.primary} />
<MaterialIcons name={icon} size={20} color={currentTheme.colors.primary} />
</View>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]}>
{title}
</Text>
{description && (
<Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{description}
</Text>
)}
</View>
{badge && (
<View style={[styles.badge, { backgroundColor: colors.primary }]}>
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
@ -121,36 +123,35 @@ const SettingItem: React.FC<SettingItemProps> = ({
const SettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
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<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [discoverDataSource, setDiscoverDataSource] = useState<DataSource>(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 = () => {
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? (value ? colors.white : colors.white) : ''}
ios_backgroundColor={isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
);
@ -239,7 +240,7 @@ const SettingsScreen: React.FC = () => {
<MaterialIcons
name="chevron-right"
size={22}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
color={'rgba(255,255,255,0.3)'}
/>
);
@ -257,52 +258,126 @@ const SettingsScreen: React.FC = () => {
return (
<View style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
{/* Fixed position header background to prevent shifts */}
<View style={[
styles.headerBackground,
{ height: headerHeight, backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]} />
<StatusBar barStyle={'light-content'} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Settings
</Text>
<TouchableOpacity onPress={handleResetSettings} style={styles.resetButton}>
<Text style={[styles.resetButtonText, {color: colors.primary}]}>Reset</Text>
</TouchableOpacity>
</View>
{/* Content Container */}
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard isDarkMode={isDarkMode} title="User & Account">
<SettingsCard title="User & Account">
<SettingItem
title="Trakt"
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
icon="person"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
isLast={false}
/>
</SettingsCard>
<SettingsCard title="Profiles">
{isAuthenticated ? (
<SettingItem
title="Manage Profiles"
description="Create and switch between profiles"
icon="people"
renderControl={ChevronRight}
onPress={() => navigation.navigate('ProfilesSettings')}
isLast={true}
/>
) : (
<TouchableOpacity
style={[
styles.profileLockContainer,
{
backgroundColor: `${currentTheme.colors.primary}10`,
borderWidth: 1,
borderColor: `${currentTheme.colors.primary}30`
}
]}
activeOpacity={1}
>
<View style={styles.profileLockContent}>
<MaterialIcons name="lock-outline" size={24} color={currentTheme.colors.primary} />
<View style={styles.profileLockTextContainer}>
<Text style={[styles.profileLockTitle, { color: currentTheme.colors.text }]}>
Sign in to use Profiles
</Text>
<Text style={[styles.profileLockDescription, { color: currentTheme.colors.textMuted }]}>
Create multiple profiles for different users and preferences
</Text>
</View>
</View>
<View style={styles.profileBenefits}>
<View style={styles.benefitCol}>
<View style={styles.benefitItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}>
Separate watchlists
</Text>
</View>
<View style={styles.benefitItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}>
Content preferences
</Text>
</View>
</View>
<View style={styles.benefitCol}>
<View style={styles.benefitItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}>
Personalized recommendations
</Text>
</View>
<View style={styles.benefitItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}>
Individual viewing history
</Text>
</View>
</View>
</View>
<TouchableOpacity
style={[styles.loginButton, { backgroundColor: currentTheme.colors.primary }]}
activeOpacity={0.7}
onPress={() => navigation.navigate('TraktSettings')}
>
<Text style={styles.loginButtonText}>Connect with Trakt</Text>
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" style={styles.loginButtonIcon} />
</TouchableOpacity>
</TouchableOpacity>
)}
</SettingsCard>
<SettingsCard title="Appearance">
<SettingItem
title="Theme"
description={currentTheme.name}
icon="palette"
renderControl={ChevronRight}
onPress={() => navigation.navigate('ThemeSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Features">
<SettingsCard title="Features">
<SettingItem
title="Calendar"
description="Manage your show calendar settings"
icon="calendar-today"
renderControl={ChevronRight}
onPress={() => navigation.navigate('Calendar')}
isDarkMode={isDarkMode}
/>
<SettingItem
title="Notifications"
@ -310,17 +385,15 @@ const SettingsScreen: React.FC = () => {
icon="notifications"
renderControl={ChevronRight}
onPress={() => navigation.navigate('NotificationSettings')}
isDarkMode={isDarkMode}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Content">
<SettingsCard title="Content">
<SettingItem
title="Addons"
description="Manage your installed addons"
icon="extension"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => 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')}
/>
<SettingItem
title="Ratings Source"
description={mdblistKeySet ? "MDBList API Configured" : "Configure MDBList API"}
title="MDBList Integration"
description={mdblistKeySet ? "Ratings and reviews provided by MDBList" : "Connect MDBList for ratings and reviews"}
icon="info-outline"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('MDBListSettings')}
/>
<SettingItem
title="Image Sources"
description="Choose primary source for title logos and backgrounds"
icon="image"
renderControl={ChevronRight}
onPress={() => navigation.navigate('LogoSourceSettings')}
/>
<SettingItem
title="TMDB"
description="API & Metadata Settings"
icon="movie-filter"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Playback">
<SettingsCard title="Playback">
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
@ -373,43 +449,53 @@ const SettingsScreen: React.FC = () => {
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
}
icon="play-arrow"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Discover">
<SettingsCard title="Discover">
<SettingItem
title="Content Source"
description="Choose where to get content for the Discover screen"
icon="explore"
isDarkMode={isDarkMode}
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
discoverDataSource === DataSource.STREMIO_ADDONS && styles.selectorButtonActive
discoverDataSource === DataSource.STREMIO_ADDONS && {
backgroundColor: currentTheme.colors.primary
}
]}
onPress={() => handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)}
>
<Text style={[
styles.selectorText,
discoverDataSource === DataSource.STREMIO_ADDONS && styles.selectorTextActive
{ color: currentTheme.colors.mediumEmphasis },
discoverDataSource === DataSource.STREMIO_ADDONS && {
color: currentTheme.colors.white,
fontWeight: '600'
}
]}>Addons</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.selectorButton,
discoverDataSource === DataSource.TMDB && styles.selectorButtonActive
discoverDataSource === DataSource.TMDB && {
backgroundColor: currentTheme.colors.primary
}
]}
onPress={() => handleDiscoverDataSourceChange(DataSource.TMDB)}
>
<Text style={[
styles.selectorText,
discoverDataSource === DataSource.TMDB && styles.selectorTextActive
{ color: currentTheme.colors.mediumEmphasis },
discoverDataSource === DataSource.TMDB && {
color: currentTheme.colors.white,
fontWeight: '600'
}
]}>TMDB</Text>
</TouchableOpacity>
</View>
@ -418,7 +504,7 @@ const SettingsScreen: React.FC = () => {
</SettingsCard>
<View style={styles.versionContainer}>
<Text style={[styles.versionText, {color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}]}>
<Text style={[styles.versionText, {color: currentTheme.colors.mediumEmphasis}]}>
Version 1.0.0
</Text>
</View>
@ -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,
},
});

View file

@ -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 (
<View style={[styles.ratingCell, { backgroundColor: colors.darkGray }]}>
<MaterialIcons name="schedule" size={16} color={colors.lightGray} />
<View style={[styles.ratingCell, { backgroundColor: theme.colors.darkGray }]}>
<MaterialIcons name="schedule" size={16} color={theme.colors.lightGray} />
</View>
);
}
return (
<View style={[styles.ratingCell, { backgroundColor: colors.darkGray }]}>
<Text style={[styles.ratingText, { color: colors.lightGray }]}></Text>
<View style={[styles.ratingCell, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.ratingText, { color: theme.colors.lightGray }]}></Text>
</View>
);
}
return (
<View style={styles.ratingCellContainer}>
<View style={[
<Animated.View style={styles.ratingCellContainer}>
<Animated.View style={[
styles.ratingCell,
{
backgroundColor: getRatingColor(rating),
opacity: isCurrent ? 0.7 : 1
opacity: isCurrent ? 0.7 : 1,
}
]}>
<Text style={styles.ratingText}>{rating.toFixed(1)}</Text>
</View>
</Animated.View>
{(isInaccurate || isCurrent) && (
<MaterialIcons
name={isCurrent ? "schedule" : "warning"}
size={12}
color={isCurrent ? colors.primary : colors.warning}
color={isCurrent ? theme.colors.primary : theme.colors.warning}
style={styles.warningIcon}
/>
)}
</View>
</Animated.View>
);
});
const RatingSourceToggle = memo(({ ratingSource, setRatingSource }: {
const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: {
ratingSource: RatingSource;
setRatingSource: (source: RatingSource) => void;
theme: any;
}) => (
<View style={styles.ratingSourceContainer}>
<Text style={styles.ratingSourceTitle}>Rating Source:</Text>
<Text style={[styles.sectionTitle, { color: theme.colors.white }]}>Rating Source:</Text>
<View style={styles.ratingSourceButtons}>
<TouchableOpacity
style={[
styles.sourceButton,
ratingSource === 'imdb' && styles.sourceButtonActive
]}
onPress={() => setRatingSource('imdb')}
>
<Text style={[
styles.sourceButtonText,
ratingSource === 'imdb' && styles.sourceButtonTextActive
]}>IMDb</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sourceButton,
ratingSource === 'tmdb' && styles.sourceButtonActive
]}
onPress={() => setRatingSource('tmdb')}
>
<Text style={[
styles.sourceButtonText,
ratingSource === 'tmdb' && styles.sourceButtonTextActive
]}>TMDB</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sourceButton,
ratingSource === 'tvmaze' && styles.sourceButtonActive
]}
onPress={() => setRatingSource('tvmaze')}
>
<Text style={[
styles.sourceButtonText,
ratingSource === 'tvmaze' && styles.sourceButtonTextActive
]}>TVMaze</Text>
</TouchableOpacity>
{['imdb', 'tmdb', 'tvmaze'].map((source) => {
const isActive = ratingSource === source;
return (
<TouchableOpacity
key={source}
style={[
styles.sourceButton,
{ borderColor: theme.colors.lightGray },
isActive && { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary }
]}
onPress={() => setRatingSource(source as RatingSource)}
>
<Text
style={{
fontSize: 13,
fontWeight: isActive ? '700' : '600',
color: isActive ? theme.colors.white : theme.colors.lightGray
}}
>
{source.toUpperCase()}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
));
const ShowInfo = memo(({ show }: { show: Show | null }) => (
const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => (
<View style={styles.showInfo}>
<Image
source={{ uri: `https://image.tmdb.org/t/p/w500${show?.poster_path}` }}
style={styles.poster}
contentFit="cover"
transition={200}
/>
<View style={styles.showDetails}>
<Text style={styles.showTitle}>{show?.name}</Text>
<Text style={styles.showYear}>
<Text style={[styles.showTitle, { color: theme.colors.white }]}>{show?.name}</Text>
<Text style={[styles.showYear, { color: theme.colors.lightGray }]}>
{show?.first_air_date ? `${new Date(show.first_air_date).getFullYear()} - ${show.last_air_date ? new Date(show.last_air_date).getFullYear() : 'Present'}` : ''}
</Text>
<View style={styles.episodeCountContainer}>
<MaterialIcons name="tv" size={16} color={colors.primary} />
<Text style={styles.episodeCount}>
<MaterialIcons name="tv" size={16} color={theme.colors.primary} />
<Text style={[styles.episodeCount, { color: theme.colors.lightGray }]}>
{show?.number_of_seasons} Seasons {show?.number_of_episodes} Episodes
</Text>
</View>
@ -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<Show | null>(null);
const [seasons, setSeasons] = useState<TMDBSeason[]>([]);
@ -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) => {
<SafeAreaView style={{ flex: 1 }}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.lightGray }]}>Loading show data...</Text>
</View>
</SafeAreaView>
</View>
@ -385,89 +383,85 @@ const ShowRatingsScreen = ({ route }: Props) => {
<Suspense fallback={
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.lightGray }]}>Loading content...</Text>
</View>
}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
contentContainerStyle={styles.scrollViewContent}
>
<View style={styles.content}>
<Animated.View
entering={FadeIn.duration(150)}
entering={FadeIn.duration(300)}
style={styles.showInfoContainer}
>
<ShowInfo show={show} />
<ShowInfo show={show} theme={currentTheme} />
</Animated.View>
<Animated.View
entering={FadeIn.delay(50).duration(150)}
style={styles.ratingSourceContainer}
entering={FadeIn.delay(100).duration(300)}
style={styles.section}
>
<RatingSourceToggle ratingSource={ratingSource} setRatingSource={setRatingSource} />
<RatingSourceToggle
ratingSource={ratingSource}
setRatingSource={setRatingSource}
theme={currentTheme}
/>
</Animated.View>
<Animated.View
entering={FadeIn.delay(100).duration(150)}
style={styles.legend}
entering={FadeIn.delay(200).duration(300)}
style={styles.section}
>
{/* Legend */}
<View style={styles.legend}>
<Text style={styles.legendTitle}>Rating Scale</Text>
<View style={[styles.legend, { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground }]}>
<Text style={[styles.sectionTitle, { color: colors.white }]}>Rating Scale</Text>
<View style={styles.legendItems}>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#186A3B' }]} />
<Text style={styles.legendText}>Awesome (9.0+)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#28B463' }]} />
<Text style={styles.legendText}>Great (8.0-8.9)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#F4D03F' }]} />
<Text style={styles.legendText}>Good (7.5-7.9)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#F39C12' }]} />
<Text style={styles.legendText}>Regular (7.0-7.4)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#E74C3C' }]} />
<Text style={styles.legendText}>Bad (6.0-6.9)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: '#633974' }]} />
<Text style={styles.legendText}>Garbage ({'<'}6.0)</Text>
</View>
{[
{ 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) => (
<View key={index} style={styles.legendItem}>
<View style={[styles.legendColor, { backgroundColor: item.color }]} />
<Text style={[styles.legendText, { color: colors.lightGray }]}>{item.text}</Text>
</View>
))}
</View>
<View style={styles.warningLegends}>
<View style={[styles.warningLegends, { borderTopColor: colors.black + '40' }]}>
<View style={styles.warningLegend}>
<MaterialIcons name="warning" size={16} color={colors.warning} />
<Text style={styles.warningText}>Rating differs significantly from IMDb</Text>
<MaterialIcons name="warning" size={14} color={colors.warning} />
<Text style={[styles.warningText, { color: colors.lightGray }]}>Rating differs significantly from IMDb</Text>
</View>
<View style={styles.warningLegend}>
<MaterialIcons name="schedule" size={16} color={colors.primary} />
<Text style={styles.warningText}>Current season (ratings may change)</Text>
<MaterialIcons name="schedule" size={14} color={colors.primary} />
<Text style={[styles.warningText, { color: colors.lightGray }]}>Current season (ratings may change)</Text>
</View>
</View>
</View>
</Animated.View>
<Animated.View
entering={FadeIn.delay(150).duration(150)}
style={styles.ratingsGrid}
entering={FadeIn.delay(300).duration(300)}
style={styles.section}
>
{/* Ratings Grid */}
<View style={styles.ratingsGrid}>
<Text style={[styles.sectionTitle, { color: colors.white }]}>Episode Ratings</Text>
<View style={[styles.ratingsGrid, { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground }]}>
<View style={styles.gridContainer}>
{/* Fixed Episode Column */}
<View style={styles.fixedColumn}>
<View style={[styles.fixedColumn, { borderRightColor: colors.black + '40' }]}>
<View style={styles.episodeColumn}>
<Text style={styles.headerText}>Episode</Text>
<Text style={[styles.headerText, { color: colors.white }]}>Episode</Text>
</View>
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
<View key={`e${episodeIndex + 1}`} style={styles.episodeCell}>
<Text style={styles.episodeText}>E{episodeIndex + 1}</Text>
<Text style={[styles.episodeText, { color: colors.lightGray }]}>E{episodeIndex + 1}</Text>
</View>
))}
</View>
@ -482,18 +476,22 @@ const ShowRatingsScreen = ({ route }: Props) => {
>
<View>
{/* Seasons Header */}
<View style={styles.gridHeader}>
<View style={[styles.gridHeader, { borderBottomColor: colors.black + '40' }]}>
{seasons.map((season) => (
<View key={`s${season.season_number}`} style={styles.ratingColumn}>
<Text style={styles.headerText}>S{season.season_number}</Text>
</View>
<Animated.View
key={`s${season.season_number}`}
style={styles.ratingColumn}
entering={SlideInRight.delay(season.season_number * 50).duration(200)}
>
<Text style={[styles.headerText, { color: colors.white }]}>S{season.season_number}</Text>
</Animated.View>
))}
{loadingSeasons && (
<View style={[styles.ratingColumn, styles.loadingColumn]}>
<View style={styles.loadingProgressContainer}>
<ActivityIndicator size="small" color={colors.primary} />
{loadingProgress > 0 && (
<Text style={styles.loadingProgressText}>
<Text style={[styles.loadingProgressText, { color: colors.primary }]}>
{Math.round(loadingProgress)}%
</Text>
)}
@ -506,16 +504,21 @@ const ShowRatingsScreen = ({ route }: Props) => {
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
<View key={`e${episodeIndex + 1}`} style={styles.gridRow}>
{seasons.map((season) => (
<View key={`s${season.season_number}e${episodeIndex + 1}`} style={styles.ratingColumn}>
<Animated.View
key={`s${season.season_number}e${episodeIndex + 1}`}
style={styles.ratingColumn}
entering={SlideInRight.delay((season.season_number + episodeIndex) * 10).duration(200)}
>
{season.episodes[episodeIndex] &&
<RatingCell
episode={season.episodes[episodeIndex]}
ratingSource={ratingSource}
getTVMazeRating={getTVMazeRating}
isCurrentSeason={isCurrentSeason}
theme={currentTheme}
/>
}
</View>
</Animated.View>
))}
{loadingSeasons && <View style={[styles.ratingColumn, styles.loadingColumn]} />}
</View>
@ -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',
},

View file

@ -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 }: {
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={styles.streamName}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{displayTitle}
</Text>
{displayAddonName && displayAddonName !== displayTitle && (
<Text style={styles.streamAddonName}>
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{displayAddonName}
</Text>
)}
@ -95,8 +98,8 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: {
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.loadingText}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
@ -113,14 +116,14 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: {
)}
{size && (
<View style={[styles.chip, { backgroundColor: colors.darkGray }]}>
<Text style={styles.chipText}>{size}</Text>
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text>
</View>
)}
{isDebrid && (
<View style={[styles.chip, { backgroundColor: colors.success }]}>
<Text style={styles.chipText}>DEBRID</Text>
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
</View>
)}
</View>
@ -130,28 +133,36 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: {
<MaterialIcons
name="play-arrow"
size={24}
color={colors.primary}
color={theme.colors.primary}
/>
</View>
</TouchableOpacity>
);
};
const QualityTag = React.memo(({ text, color }: { text: string; color: string }) => (
<View style={[styles.chip, { backgroundColor: color }]}>
<Text style={styles.chipText}>{text}</Text>
</View>
));
const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
return (
<View style={[styles.chip, { backgroundColor: color }]}>
<Text style={styles.chipText}>{text}</Text>
</View>
);
});
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 } }) => (
<TouchableOpacity
key={item.id}
@ -168,7 +179,7 @@ const ProviderFilter = memo(({
{item.name}
</Text>
</TouchableOpacity>
), [selectedProvider, onSelect]);
), [selectedProvider, onSelect, styles]);
return (
<FlatList
@ -198,6 +209,8 @@ export const StreamsScreen = () => {
const navigation = useNavigation<RootStackNavigationProp>();
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<Set<string>>(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 } }) => (
<Animated.View
@ -658,7 +675,7 @@ export const StreamsScreen = () => {
onPress={handleBack}
activeOpacity={0.7}
>
<MaterialIcons name="arrow-back" size={24} color="#fff" />
<MaterialIcons name="arrow-back" size={24} color={colors.white} />
<Text style={styles.backButtonText}>
{type === 'series' ? 'Back to Episodes' : 'Back to Info'}
</Text>
@ -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}
>
<View style={styles.streamsHeroContent}>
@ -789,6 +805,7 @@ export const StreamsScreen = () => {
selectedProvider={selectedProvider}
providers={filterItems}
onSelect={handleProviderChange}
theme={currentTheme}
/>
)}
</Animated.View>
@ -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,

View file

@ -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<TextInput>(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 (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={styles.loadingText}>Loading Settings...</Text>
</View>
</SafeAreaView>
@ -237,42 +462,43 @@ const TMDBSettingsScreen = () => {
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>TMDb Settings</Text>
<Text style={styles.title}>TMDb Settings</Text>
<ScrollView
style={styles.content}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.switchCard}>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Use Custom TMDb API Key</Text>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: colors.lightGray, true: colors.accentLight }}
thumbColor={Platform.OS === 'android' ? colors.primary : ''}
ios_backgroundColor={colors.lightGray}
/>
<View style={styles.switchTextContainer}>
<Text style={styles.switchTitle}>Use Custom TMDb API Key</Text>
</View>
<Text style={styles.switchDescription}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }}
thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''}
ios_backgroundColor={currentTheme.colors.lightGray}
/>
</View>
<Text style={styles.switchDescription}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
{useCustomKey && (
<>
<View style={styles.statusCard}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
color={isKeySet ? colors.success : colors.warning}
style={styles.statusIcon}
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
style={styles.statusIconContainer}
/>
<View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}>
@ -287,8 +513,8 @@ const TMDBSettingsScreen = () => {
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>API Key</Text>
<View style={styles.inputWrapper}>
<Text style={styles.cardTitle}>API Key</Text>
<View style={styles.inputContainer}>
<TextInput
ref={apiKeyInputRef}
style={[styles.input, isInputFocused && styles.inputFocused]}
@ -298,7 +524,7 @@ const TMDBSettingsScreen = () => {
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}
>
<MaterialIcons name="content-paste" size={20} color={colors.primary} />
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
</TouchableOpacity>
</View>
@ -339,7 +565,7 @@ const TMDBSettingsScreen = () => {
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={18}
color={testResult.success ? colors.success : colors.error}
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
style={styles.resultIcon}
/>
<Text style={[
@ -355,7 +581,7 @@ const TMDBSettingsScreen = () => {
style={styles.helpLink}
onPress={openTMDBWebsite}
>
<MaterialIcons name="help" size={16} color={colors.primary} style={styles.helpIcon} />
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={styles.helpText}>
How to get a TMDb API key?
</Text>
@ -363,7 +589,7 @@ const TMDBSettingsScreen = () => {
</View>
<View style={styles.infoCard}>
<MaterialIcons name="info-outline" size={22} color={colors.primary} style={styles.infoIcon} />
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}>
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 && (
<View style={styles.infoCard}>
<MaterialIcons name="info-outline" size={22} color={colors.primary} style={styles.infoIcon} />
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}>
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;

897
src/screens/ThemeScreen.tsx Normal file
View file

@ -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<ThemeCardProps> = ({
theme,
isSelected,
onSelect,
onEdit,
onDelete
}) => {
return (
<TouchableOpacity
style={[
styles.themeCard,
isSelected && styles.selectedThemeCard,
{
borderColor: isSelected ? theme.colors.primary : 'transparent',
backgroundColor: Platform.OS === 'ios'
? `${theme.colors.darkBackground}60`
: 'rgba(255, 255, 255, 0.07)'
}
]}
onPress={onSelect}
activeOpacity={0.7}
>
<View style={styles.themeCardHeader}>
<Text style={[styles.themeCardTitle, { color: theme.colors.text }]}>
{theme.name}
</Text>
{isSelected && (
<MaterialIcons name="check-circle" size={18} color={theme.colors.primary} />
)}
</View>
<View style={styles.colorPreviewContainer}>
<View style={[styles.colorPreview, { backgroundColor: theme.colors.primary }, styles.colorPreviewShadow]} />
<View style={[styles.colorPreview, { backgroundColor: theme.colors.secondary }, styles.colorPreviewShadow]} />
<View style={[styles.colorPreview, { backgroundColor: theme.colors.darkBackground }, styles.colorPreviewShadow]} />
</View>
{theme.isEditable && (
<View style={styles.themeCardActions}>
{onEdit && (
<TouchableOpacity
style={[styles.themeCardAction, styles.buttonShadow]}
onPress={onEdit}
>
<MaterialIcons name="edit" size={16} color={theme.colors.primary} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity
style={[styles.themeCardAction, styles.buttonShadow]}
onPress={onDelete}
>
<MaterialIcons name="delete" size={16} color={theme.colors.error} />
</TouchableOpacity>
)}
</View>
)}
</TouchableOpacity>
);
};
// Filter tab component
interface FilterTabProps {
category: { id: string; name: string };
isActive: boolean;
onPress: () => void;
primaryColor: string;
}
const FilterTab: React.FC<FilterTabProps> = ({
category,
isActive,
onPress,
primaryColor
}) => (
<TouchableOpacity
style={[
styles.filterTab,
isActive && { backgroundColor: primaryColor },
styles.buttonShadow
]}
onPress={onPress}
>
<Text
style={[
styles.filterTabText,
isActive && { color: '#FFFFFF' }
]}
>
{category.name}
</Text>
</TouchableOpacity>
);
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<ThemeColorEditorProps> = ({
initialColors,
onSave,
onCancel
}) => {
const [themeName, setThemeName] = useState('Custom Theme');
const [selectedColorKey, setSelectedColorKey] = useState<ColorKey>('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 = () => (
<View style={[styles.previewContainer, { backgroundColor: themeColors.darkBackground }]}>
<View style={styles.previewContent}>
{/* App header */}
<View style={styles.previewHeader}>
<View style={styles.previewHeaderTitle} />
<View style={styles.previewIconGroup}>
<View style={styles.previewIcon} />
<View style={styles.previewIcon} />
</View>
</View>
{/* Content area */}
<View style={styles.previewBody}>
{/* Featured content poster */}
<View style={styles.previewFeatured}>
<View style={styles.previewPosterGradient} />
<View style={styles.previewTitle} />
<View style={styles.previewButtonRow}>
<View style={[styles.previewPlayButton, { backgroundColor: themeColors.primary }]} />
<View style={styles.previewActionButton} />
</View>
</View>
{/* Content row */}
<View style={styles.previewSectionHeader}>
<View style={styles.previewSectionTitle} />
</View>
<View style={styles.previewPosterRow}>
<View style={styles.previewPoster} />
<View style={styles.previewPoster} />
<View style={styles.previewPoster} />
</View>
</View>
</View>
</View>
);
return (
<View style={styles.editorContainer}>
<View style={styles.editorHeader}>
<TouchableOpacity
style={styles.editorBackButton}
onPress={onCancel}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" />
</TouchableOpacity>
<TextInput
style={styles.editorTitleInput}
value={themeName}
onChangeText={setThemeName}
placeholder="Theme name"
placeholderTextColor="rgba(255,255,255,0.5)"
/>
<TouchableOpacity
style={styles.editorSaveButton}
onPress={handleSave}
>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</View>
<View style={styles.editorBody}>
<View style={styles.colorSectionRow}>
<ThemePreview />
<View style={styles.colorButtonsColumn}>
<TouchableOpacity
style={[
styles.colorSelectorButton,
selectedColorKey === 'primary' && styles.selectedColorButton,
{ backgroundColor: themeColors.primary }
]}
onPress={() => setSelectedColorKey('primary')}
>
<Text style={styles.colorButtonText}>Primary</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.colorSelectorButton,
selectedColorKey === 'secondary' && styles.selectedColorButton,
{ backgroundColor: themeColors.secondary }
]}
onPress={() => setSelectedColorKey('secondary')}
>
<Text style={styles.colorButtonText}>Secondary</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.colorSelectorButton,
selectedColorKey === 'darkBackground' && styles.selectedColorButton,
{ backgroundColor: themeColors.darkBackground }
]}
onPress={() => setSelectedColorKey('darkBackground')}
>
<Text style={styles.colorButtonText}>Background</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.colorPickerContainer}>
<ColorPicker
color={themeColors[selectedColorKey]}
onColorChange={handleColorChange}
thumbSize={22}
sliderSize={22}
noSnap={true}
row={false}
/>
</View>
</View>
</View>
);
};
const ThemeScreen: React.FC = () => {
const {
currentTheme,
availableThemes,
setCurrentTheme,
addCustomTheme,
updateCustomTheme,
deleteCustomTheme
} = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const insets = useSafeAreaInsets();
const [isEditMode, setIsEditMode] = useState(false);
const [editingTheme, setEditingTheme] = useState<Theme | null>(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 (
<View style={[
styles.container,
{
backgroundColor: currentTheme.colors.darkBackground,
paddingTop: insets.top,
paddingBottom: insets.bottom,
}
]}>
<ThemeColorEditor
initialColors={initialColors}
onSave={handleSaveTheme}
onCancel={handleCancelEdit}
/>
</View>
);
}
return (
<View style={[
styles.container,
{
backgroundColor: currentTheme.colors.darkBackground,
paddingTop: insets.top,
paddingBottom: insets.bottom,
}
]}>
<View style={styles.header}>
<TouchableOpacity
style={[styles.backButton, styles.buttonShadow]}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>App Themes</Text>
</View>
{/* Category filter */}
<View style={styles.filterContainer}>
<FlatList
data={THEME_CATEGORIES}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<FilterTab
category={item}
isActive={activeFilter === item.id}
onPress={() => setActiveFilter(item.id)}
primaryColor={currentTheme.colors.primary}
/>
)}
contentContainerStyle={styles.filterList}
/>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.textMuted }]}>
SELECT THEME
</Text>
<View style={styles.themeGrid}>
{filteredThemes.map(theme => (
<ThemeCard
key={theme.id}
theme={theme}
isSelected={currentTheme.id === theme.id}
onSelect={() => handleThemeSelect(theme.id)}
onEdit={theme.isEditable ? () => handleEditTheme(theme) : undefined}
onDelete={theme.isEditable ? () => handleDeleteTheme(theme) : undefined}
/>
))}
</View>
<TouchableOpacity
style={[
styles.createButton,
{ backgroundColor: currentTheme.colors.primary },
styles.buttonShadow
]}
onPress={handleCreateTheme}
>
<MaterialIcons name="add" size={20} color="#FFFFFF" />
<Text style={styles.createButtonText}>Create Custom Theme</Text>
</TouchableOpacity>
</ScrollView>
</View>
);
};
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;

View file

@ -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<TraktUser | null>(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 (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
{ backgroundColor: isDarkMode ? currentTheme.colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
@ -162,12 +175,12 @@ const TraktSettingsScreen: React.FC = () => {
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
/>
</TouchableOpacity>
<Text style={[
styles.headerTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Trakt Settings
</Text>
@ -179,11 +192,11 @@ const TraktSettingsScreen: React.FC = () => {
>
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
{ backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white }
]}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
) : isAuthenticated && userProfile ? (
<View style={styles.profileContainer}>
@ -194,7 +207,7 @@ const TraktSettingsScreen: React.FC = () => {
style={styles.avatar}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: colors.primary }]}>
<View style={[styles.avatarPlaceholder, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.avatarText}>
{userProfile.name?.charAt(0) || userProfile.username.charAt(0)}
</Text>
@ -203,13 +216,13 @@ const TraktSettingsScreen: React.FC = () => {
<View style={styles.profileInfo}>
<Text style={[
styles.profileName,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
{userProfile.name || userProfile.username}
</Text>
<Text style={[
styles.profileUsername,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
@{userProfile.username}
</Text>
@ -224,7 +237,7 @@ const TraktSettingsScreen: React.FC = () => {
<View style={styles.statsContainer}>
<Text style={[
styles.joinedDate,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Joined {new Date(userProfile.joined_at).toLocaleDateString()}
</Text>
@ -252,20 +265,20 @@ const TraktSettingsScreen: React.FC = () => {
/>
<Text style={[
styles.signInTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Connect with Trakt
</Text>
<Text style={[
styles.signInDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Sync your watch history, watchlist, and collection with Trakt.tv
</Text>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? colors.primary : colors.primary }
{ backgroundColor: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
]}
onPress={handleSignIn}
disabled={!request || isExchangingCode} // Disable while waiting for response or exchanging code
@ -285,25 +298,25 @@ const TraktSettingsScreen: React.FC = () => {
{isAuthenticated && (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
{ backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white }
]}>
<View style={styles.settingsSection}>
<Text style={[
styles.sectionTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Sync Settings
</Text>
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Auto-sync playback progress
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Coming soon
</Text>
@ -311,13 +324,13 @@ const TraktSettingsScreen: React.FC = () => {
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Import watched history
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Coming soon
</Text>
@ -331,7 +344,7 @@ const TraktSettingsScreen: React.FC = () => {
>
<Text style={[
styles.buttonText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Sync Now (Coming Soon)
</Text>

View file

@ -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<any[]> {
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<string | null> {
async getMovieImages(movieId: number | string, preferredLanguage: string = 'en'): Promise<string | null> {
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<string | null> {
async getTvShowImages(showId: number | string, preferredLanguage: string = 'en'): Promise<string | null> {
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<string | null> {
async getContentLogo(type: 'movie' | 'tv', id: number | string, preferredLanguage: string = 'en'): Promise<string | null> {
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 [];
}
}

View file

@ -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;
export default {
POSTER_WIDTH,
POSTER_HEIGHT,
HORIZONTAL_PADDING,
};

View file

@ -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;

170
src/utils/logoUtils.ts Normal file
View file

@ -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<boolean> => {
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<string | null> => {
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;
};