commit
19ed2aa1a5
58 changed files with 8884 additions and 4780 deletions
62
App.tsx
62
App.tsx
|
|
@ -23,33 +23,61 @@ import 'react-native-reanimated';
|
|||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
import { TraktProvider } from './src/contexts/TraktContext';
|
||||
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
|
||||
|
||||
// This fixes many navigation layout issues by using native screen containers
|
||||
enableScreens(true);
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
// Always use dark mode
|
||||
const isDarkMode = true;
|
||||
// Inner app component that uses the theme context
|
||||
const ThemedApp = () => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Create custom themes based on current theme
|
||||
const customDarkTheme = {
|
||||
...CustomDarkTheme,
|
||||
colors: {
|
||||
...CustomDarkTheme.colors,
|
||||
primary: currentTheme.colors.primary,
|
||||
}
|
||||
};
|
||||
|
||||
const customNavigationTheme = {
|
||||
...CustomNavigationDarkTheme,
|
||||
colors: {
|
||||
...CustomNavigationDarkTheme.colors,
|
||||
primary: currentTheme.colors.primary,
|
||||
card: currentTheme.colors.darkBackground,
|
||||
background: currentTheme.colors.darkBackground,
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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
17
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
132
src/components/discover/CatalogSection.tsx
Normal file
132
src/components/discover/CatalogSection.tsx
Normal 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);
|
||||
43
src/components/discover/CatalogsList.tsx
Normal file
43
src/components/discover/CatalogsList.tsx
Normal 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);
|
||||
95
src/components/discover/CategorySelector.tsx
Normal file
95
src/components/discover/CategorySelector.tsx
Normal 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);
|
||||
93
src/components/discover/ContentItem.tsx
Normal file
93
src/components/discover/ContentItem.tsx
Normal 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);
|
||||
88
src/components/discover/GenreSelector.tsx
Normal file
88
src/components/discover/GenreSelector.tsx
Normal 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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
243
src/components/metadata/FloatingHeader.tsx
Normal file
243
src/components/metadata/FloatingHeader.tsx
Normal 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);
|
||||
569
src/components/metadata/HeroSection.tsx
Normal file
569
src/components/metadata/HeroSection.tsx
Normal 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);
|
||||
180
src/components/metadata/MetadataDetails.tsx
Normal file
180
src/components/metadata/MetadataDetails.tsx
Normal 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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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
42
src/constants/discover.ts
Normal 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'
|
||||
];
|
||||
321
src/contexts/ThemeContext.tsx
Normal file
321
src/contexts/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
247
src/hooks/useMetadataAnimations.ts
Normal file
247
src/hooks/useMetadataAnimations.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
444
src/hooks/useMetadataAssets.ts
Normal file
444
src/hooks/useMetadataAssets.ts
Normal 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
|
||||
};
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
216
src/hooks/useWatchProgress.ts
Normal file
216
src/hooks/useWatchProgress.ts
Normal 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
|
||||
};
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
914
src/screens/LogoSourceSettings.tsx
Normal file
914
src/screens/LogoSourceSettings.tsx
Normal 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;
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
441
src/screens/ProfilesScreen.tsx
Normal file
441
src/screens/ProfilesScreen.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
897
src/screens/ThemeScreen.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
68
src/styles/screens/discoverStyles.ts
Normal file
68
src/styles/screens/discoverStyles.ts
Normal 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
170
src/utils/logoUtils.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in a new issue