header refactor

This commit is contained in:
tapframe 2025-12-11 15:55:52 +05:30
parent 52065a1462
commit 9e7543df02
4 changed files with 592 additions and 477 deletions

View file

@ -0,0 +1,240 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
Platform,
} from 'react-native';
import { useTheme } from '../../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Feather, MaterialIcons } from '@expo/vector-icons';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface ScreenHeaderProps {
/**
* The main title displayed in the header
*/
title: string;
/**
* Optional right action button (icon name from Feather icons)
*/
rightActionIcon?: string;
/**
* Optional callback for right action button press
*/
onRightActionPress?: () => void;
/**
* Optional custom right action component (overrides rightActionIcon)
*/
rightActionComponent?: React.ReactNode;
/**
* Optional back button (shows arrow back icon)
*/
showBackButton?: boolean;
/**
* Optional callback for back button press
*/
onBackPress?: () => void;
/**
* Whether this screen is displayed on a tablet layout
*/
isTablet?: boolean;
/**
* Optional extra top padding for tablet navigation offset
*/
tabletNavOffset?: number;
/**
* Optional custom title component (overrides title text)
*/
titleComponent?: React.ReactNode;
/**
* Optional children to render below the title row (e.g., filters, search bar)
*/
children?: React.ReactNode;
/**
* Whether to hide the header title row (useful when showing only children)
*/
hideTitleRow?: boolean;
/**
* Use MaterialIcons instead of Feather for icons
*/
useMaterialIcons?: boolean;
/**
* Optional custom style for title
*/
titleStyle?: object;
}
const ScreenHeader: React.FC<ScreenHeaderProps> = ({
title,
rightActionIcon,
onRightActionPress,
rightActionComponent,
showBackButton = false,
onBackPress,
isTablet = false,
tabletNavOffset = 64,
titleComponent,
children,
hideTitleRow = false,
useMaterialIcons = false,
titleStyle,
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
// Calculate header spacing
const topSpacing =
(Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : insets.top) +
(isTablet ? tabletNavOffset : 0);
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const titleRowHeight = headerBaseHeight + topSpacing;
const IconComponent = useMaterialIcons ? MaterialIcons : Feather;
const backIconName = useMaterialIcons ? 'arrow-back' : 'arrow-left';
return (
<>
{/* Fixed position header background to prevent shifts */}
<View
style={[
styles.headerBackground,
{
backgroundColor: currentTheme.colors.darkBackground,
},
]}
/>
{/* Header Section */}
<View
style={[
styles.header,
{
paddingTop: topSpacing,
backgroundColor: 'transparent',
},
]}
>
{/* Title Row */}
{!hideTitleRow && (
<View
style={[
styles.titleRow,
{
height: headerBaseHeight,
},
]}
>
<View style={styles.headerContent}>
{showBackButton ? (
<TouchableOpacity
style={styles.backButton}
onPress={onBackPress}
activeOpacity={0.7}
>
<IconComponent
name={backIconName as any}
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
) : null}
{titleComponent ? (
titleComponent
) : (
<Text
style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
showBackButton && styles.headerTitleWithBack,
titleStyle,
]}
>
{title}
</Text>
)}
{/* Right Action */}
{rightActionComponent ? (
<View style={styles.rightActionContainer}>{rightActionComponent}</View>
) : rightActionIcon && onRightActionPress ? (
<TouchableOpacity
style={styles.rightActionButton}
onPress={onRightActionPress}
activeOpacity={0.7}
>
<IconComponent
name={rightActionIcon as any}
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
) : (
<View style={styles.rightActionPlaceholder} />
)}
</View>
</View>
)}
{/* Children (filters, search bar, etc.) */}
{children}
</View>
</>
);
};
const styles = StyleSheet.create({
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
header: {
paddingHorizontal: 20,
zIndex: 11,
},
titleRow: {
justifyContent: 'flex-end',
paddingBottom: 8,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
backButton: {
padding: 8,
marginLeft: -8,
marginRight: 8,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
flex: 1,
},
headerTitleWithBack: {
fontSize: 24,
flex: 0,
},
rightActionContainer: {
minWidth: 40,
alignItems: 'flex-end',
},
rightActionButton: {
padding: 8,
marginRight: -8,
},
rightActionPlaceholder: {
width: 40,
},
});
export default ScreenHeader;

View file

@ -34,6 +34,7 @@ import { VideoPlayerService } from '../services/videoPlayerService';
import type { DownloadItem } from '../contexts/DownloadsContext'; import type { DownloadItem } from '../contexts/DownloadsContext';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import ScreenHeader from '../components/common/ScreenHeader';
const { height, width } = Dimensions.get('window'); const { height, width } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
@ -346,7 +347,6 @@ const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const { top: safeAreaTop } = useSafeAreaInsets();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const { showSuccess, showInfo } = useToast(); const { showSuccess, showInfo } = useToast();
@ -356,9 +356,6 @@ const DownloadsScreen: React.FC = () => {
const [showRemoveAlert, setShowRemoveAlert] = useState(false); const [showRemoveAlert, setShowRemoveAlert] = useState(false);
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null); const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
// Animation values
const headerOpacity = useSharedValue(1);
// Filter downloads based on selected filter // Filter downloads based on selected filter
const filteredDownloads = useMemo(() => { const filteredDownloads = useMemo(() => {
if (selectedFilter === 'all') return downloads; if (selectedFilter === 'all') return downloads;
@ -571,11 +568,6 @@ const DownloadsScreen: React.FC = () => {
}, []) }, [])
); );
// Animated styles
const headerStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
}));
const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => ( const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => (
<TouchableOpacity <TouchableOpacity
key={filter} key={filter}
@ -632,22 +624,10 @@ const DownloadsScreen: React.FC = () => {
backgroundColor="transparent" backgroundColor="transparent"
/> />
{/* Header */} {/* ScreenHeader Component */}
<Animated.View style={[ <ScreenHeader
styles.header, title="Downloads"
{ rightActionComponent={
backgroundColor: currentTheme.colors.darkBackground,
paddingTop: (Platform.OS === 'android'
? (StatusBar.currentHeight || 0) + 26
: safeAreaTop + 15) + (isTablet ? 64 : 0),
borderBottomColor: currentTheme.colors.border,
},
headerStyle,
]}>
<View style={styles.headerTitleRow}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Downloads
</Text>
<TouchableOpacity <TouchableOpacity
style={styles.helpButton} style={styles.helpButton}
onPress={showDownloadHelp} onPress={showDownloadHelp}
@ -659,8 +639,9 @@ const DownloadsScreen: React.FC = () => {
color={currentTheme.colors.mediumEmphasis} color={currentTheme.colors.mediumEmphasis}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> }
isTablet={isTablet}
>
{downloads.length > 0 && ( {downloads.length > 0 && (
<View style={styles.filterContainer}> <View style={styles.filterContainer}>
{renderFilterButton('all', 'All', stats.total)} {renderFilterButton('all', 'All', stats.total)}
@ -669,7 +650,7 @@ const DownloadsScreen: React.FC = () => {
{renderFilterButton('paused', 'Paused', stats.paused)} {renderFilterButton('paused', 'Paused', stats.paused)}
</View> </View>
)} )}
</Animated.View> </ScreenHeader>
{/* Content */} {/* Content */}
{downloads.length === 0 ? ( {downloads.length === 0 ? (
@ -742,23 +723,6 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
header: {
paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05),
paddingBottom: isTablet ? 20 : 16,
borderBottomWidth: StyleSheet.hairlineWidth,
},
headerTitleRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
marginBottom: isTablet ? 20 : 16,
paddingBottom: 8,
},
headerTitle: {
fontSize: isTablet ? 36 : Math.min(32, width * 0.08),
fontWeight: '800',
letterSpacing: 0.3,
},
helpButton: { helpButton: {
padding: 8, padding: 8,
marginLeft: 8, marginLeft: 8,

View file

@ -4,6 +4,7 @@ import { Share } from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import DropUpMenu from '../components/home/DropUpMenu'; import DropUpMenu from '../components/home/DropUpMenu';
import ScreenHeader from '../components/common/ScreenHeader';
import { import {
View, View,
Text, Text,
@ -107,7 +108,7 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item:
navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); navigation.navigate('Metadata', { id: item.imdbId, type: item.type });
} }
}, [navigation, item.imdbId, item.type]); }, [navigation, item.imdbId, item.type]);
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.itemContainer, { width }]} style={[styles.itemContainer, { width }]}
@ -168,17 +169,17 @@ const SkeletonLoader = () => {
const renderSkeletonItem = () => ( const renderSkeletonItem = () => (
<View style={[styles.itemContainer, { width: itemWidth }]}> <View style={[styles.itemContainer, { width: itemWidth }]}>
<RNAnimated.View <RNAnimated.View
style={[ style={[
styles.posterContainer, styles.posterContainer,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} ]}
/> />
<RNAnimated.View <RNAnimated.View
style={[ style={[
styles.skeletonTitle, styles.skeletonTitle,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} ]}
/> />
</View> </View>
); );
@ -212,7 +213,7 @@ const LibraryScreen = () => {
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null); const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Trakt integration // Trakt integration
const { const {
isAuthenticated: traktAuthenticated, isAuthenticated: traktAuthenticated,
@ -272,14 +273,14 @@ const LibraryScreen = () => {
setLoading(true); setLoading(true);
try { try {
const items = await catalogService.getLibraryItems(); const items = await catalogService.getLibraryItems();
// Sort by date added (most recent first) // Sort by date added (most recent first)
const sortedItems = items.sort((a, b) => { const sortedItems = items.sort((a, b) => {
const timeA = (a as any).addedToLibraryAt || 0; const timeA = (a as any).addedToLibraryAt || 0;
const timeB = (b as any).addedToLibraryAt || 0; const timeB = (b as any).addedToLibraryAt || 0;
return timeB - timeA; // Descending order (newest first) return timeB - timeA; // Descending order (newest first)
}); });
// Load watched status for each item from AsyncStorage // Load watched status for each item from AsyncStorage
const updatedItems = await Promise.all(sortedItems.map(async (item) => { const updatedItems = await Promise.all(sortedItems.map(async (item) => {
// Map StreamingContent to LibraryItem shape // Map StreamingContent to LibraryItem shape
@ -313,7 +314,7 @@ const LibraryScreen = () => {
const timeB = (b as any).addedToLibraryAt || 0; const timeB = (b as any).addedToLibraryAt || 0;
return timeB - timeA; // Descending order (newest first) return timeB - timeA; // Descending order (newest first)
}); });
// Sync watched status on update // Sync watched status on update
const updatedItems = await Promise.all(sortedItems.map(async (item) => { const updatedItems = await Promise.all(sortedItems.map(async (item) => {
// Map StreamingContent to LibraryItem shape // Map StreamingContent to LibraryItem shape
@ -403,8 +404,8 @@ const LibraryScreen = () => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<View> <View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> <View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<FastImage <FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster} style={styles.poster}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
@ -425,7 +426,7 @@ const LibraryScreen = () => {
</View> </View>
)} )}
</View> </View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
{item.name} {item.name}
</Text> </Text>
</View> </View>
@ -444,11 +445,11 @@ const LibraryScreen = () => {
> >
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black, backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black, backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.folderGradient}> <View style={styles.folderGradient}>
<MaterialIcons <MaterialIcons
name={folder.icon} name={folder.icon}
size={48} size={48}
color={currentTheme.colors.white} color={currentTheme.colors.white}
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
/> />
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}> <Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
{folder.name} {folder.name}
@ -724,8 +725,8 @@ const LibraryScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Your Trakt collections will appear here once you start using Trakt Your Trakt collections will appear here once you start using Trakt
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.exploreButton, { style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black shadowColor: currentTheme.colors.black
}]} }]}
@ -742,22 +743,22 @@ const LibraryScreen = () => {
// Show collection folders // Show collection folders
return ( return (
<FlashList <FlashList
data={traktFolders} data={traktFolders}
renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })} renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })}
keyExtractor={item => item.id} keyExtractor={item => item.id}
numColumns={numColumns} numColumns={numColumns}
contentContainerStyle={styles.listContainer} contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => { }}
/> />
); );
} }
// Show content for specific folder // Show content for specific folder
const folderItems = getTraktFolderItems(selectedTraktFolder); const folderItems = getTraktFolderItems(selectedTraktFolder);
if (folderItems.length === 0) { if (folderItems.length === 0) {
const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'; const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection';
return ( return (
@ -767,8 +768,8 @@ const LibraryScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
This collection is empty This collection is empty
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.exploreButton, { style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black shadowColor: currentTheme.colors.black
}]} }]}
@ -793,14 +794,14 @@ const LibraryScreen = () => {
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => { }}
/> />
); );
}; };
const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => { const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
const isActive = filter === filterType; const isActive = filter === filterType;
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@ -858,9 +859,9 @@ const LibraryScreen = () => {
const emptySubtitle = 'Add some content to your library to see it here'; const emptySubtitle = 'Add some content to your library to see it here';
return ( return (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons <MaterialIcons
name="video-library" name="video-library"
size={64} size={64}
color={currentTheme.colors.lightGray} color={currentTheme.colors.lightGray}
/> />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}> <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
@ -869,8 +870,8 @@ const LibraryScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
{emptySubtitle} {emptySubtitle}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.exploreButton, { style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black shadowColor: currentTheme.colors.black
}]} }]}
@ -892,92 +893,53 @@ const LibraryScreen = () => {
contentContainerStyle={styles.listContainer} contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => { }}
/> />
); );
}; };
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Tablet detection aligned with navigation tablet logic // Tablet detection aligned with navigation tablet logic
const isTablet = useMemo(() => { const isTablet = useMemo(() => {
const smallestDimension = Math.min(width, height); const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768); return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
}, [width, height]); }, [width, height]);
// Keep header below floating top navigator on tablets
const tabletNavOffset = isTablet ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
{/* Fixed position header background to prevent shifts */} {/* ScreenHeader Component */}
<View style={[styles.headerBackground, { height: headerHeight, backgroundColor: currentTheme.colors.darkBackground }]} /> <ScreenHeader
title={showTraktContent
<View style={{ flex: 1 }}> ? (selectedTraktFolder
{/* Header Section with proper top spacing */} ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}> : 'Trakt Collection')
<View style={[styles.headerContent, showTraktContent && { justifyContent: 'flex-start' }]}> : 'Library'
{showTraktContent ? ( }
<> showBackButton={showTraktContent}
<TouchableOpacity onBackPress={showTraktContent ? () => {
style={styles.backButton} if (selectedTraktFolder) {
onPress={() => { setSelectedTraktFolder(null);
if (selectedTraktFolder) { } else {
setSelectedTraktFolder(null); setShowTraktContent(false);
} else { }
setShowTraktContent(false); } : undefined}
} useMaterialIcons={showTraktContent}
}} rightActionIcon={!showTraktContent ? 'calendar' : undefined}
activeOpacity={0.7} onRightActionPress={!showTraktContent ? () => navigation.navigate('Calendar') : undefined}
> isTablet={isTablet}
<MaterialIcons />
name="arrow-back"
size={28}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white, fontSize: 24, marginLeft: 16 }]}>
{selectedTraktFolder
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
: 'Trakt Collection'
}
</Text>
</>
) : (
<>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
<TouchableOpacity
style={styles.calendarButton}
onPress={() => navigation.navigate('Calendar')}
activeOpacity={0.7}
>
<Feather
name="calendar"
size={24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
</>
)}
</View>
</View>
{/* Content Container */} {/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && ( {!showTraktContent && (
// Replaced ScrollView with View and used the modified style <View style={styles.filtersContainer}>
<View {renderFilter('trakt', 'Trakt', 'pan-tool')}
style={styles.filtersContainer} {renderFilter('movies', 'Movies', 'movie')}
> {renderFilter('series', 'TV Shows', 'live-tv')}
{renderFilter('trakt', 'Trakt', 'pan-tool')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</View>
)}
{showTraktContent ? renderTraktContent() : renderContent()}
</View> </View>
</View> )}
{showTraktContent ? renderTraktContent() : renderContent()}
</View>
{/* DropUpMenu integration */} {/* DropUpMenu integration */}
{selectedItem && ( {selectedItem && (
@ -991,45 +953,45 @@ const LibraryScreen = () => {
if (!selectedItem) return; if (!selectedItem) return;
switch (option) { switch (option) {
case 'library': { case 'library': {
try { try {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
showInfo('Removed from Library', 'Item removed from your library'); showInfo('Removed from Library', 'Item removed from your library');
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
setMenuVisible(false); setMenuVisible(false);
} catch (error) { } catch (error) {
showError('Failed to update Library', 'Unable to remove item from library'); showError('Failed to update Library', 'Unable to remove item from library');
} }
break; break;
} }
case 'watched': { case 'watched': {
try { try {
// Use AsyncStorage to store watched status by key // Use AsyncStorage to store watched status by key
const key = `watched:${selectedItem.type}:${selectedItem.id}`; const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !selectedItem.watched; const newWatched = !selectedItem.watched;
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched');
// Instantly update local state // Instantly update local state
setLibraryItems(prev => prev.map(item => setLibraryItems(prev => prev.map(item =>
item.id === selectedItem.id && item.type === selectedItem.type item.id === selectedItem.id && item.type === selectedItem.type
? { ...item, watched: newWatched } ? { ...item, watched: newWatched }
: item : item
)); ));
} catch (error) { } catch (error) {
showError('Failed to update watched status', 'Unable to update watched status'); showError('Failed to update watched status', 'Unable to update watched status');
} }
break; break;
} }
case 'share': { case 'share': {
let url = ''; let url = '';
if (selectedItem.id) { if (selectedItem.id) {
url = `https://www.imdb.com/title/${selectedItem.id}/`; url = `https://www.imdb.com/title/${selectedItem.id}/`;
} }
const message = `${selectedItem.name}\n${url}`; const message = `${selectedItem.name}\n${url}`;
Share.share({ message, url, title: selectedItem.name }); Share.share({ message, url, title: selectedItem.name });
break; break;
} }
default: default:
break; break;
} }
}} }}
/> />
@ -1042,13 +1004,6 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
watchedIndicator: { watchedIndicator: {
position: 'absolute', position: 'absolute',
top: 8, top: 8,
@ -1060,23 +1015,6 @@ const styles = StyleSheet.create({
contentContainer: { contentContainer: {
flex: 1, flex: 1,
}, },
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',
letterSpacing: 0.5,
},
filtersContainer: { filtersContainer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
@ -1130,7 +1068,7 @@ const styles = StyleSheet.create({
borderRadius: 12, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)', backgroundColor: 'rgba(255,255,255,0.03)',
aspectRatio: 2/3, aspectRatio: 2 / 3,
elevation: 5, elevation: 5,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2, shadowOpacity: 0.2,
@ -1253,7 +1191,7 @@ const styles = StyleSheet.create({
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)', backgroundColor: 'rgba(255,255,255,0.03)',
aspectRatio: 2/3, aspectRatio: 2 / 3,
elevation: 5, elevation: 5,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2, shadowOpacity: 0.2,

View file

@ -27,11 +27,11 @@ import debounce from 'lodash/debounce';
import { DropUpMenu } from '../components/home/DropUpMenu'; import { DropUpMenu } from '../components/home/DropUpMenu';
import { DeviceEventEmitter, Share } from 'react-native'; import { DeviceEventEmitter, Share } from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
interpolate, interpolate,
withSpring, withSpring,
@ -43,6 +43,7 @@ import { BlurView } from 'expo-blur';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import LoadingSpinner from '../components/common/LoadingSpinner'; import LoadingSpinner from '../components/common/LoadingSpinner';
import ScreenHeader from '../components/common/ScreenHeader';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
@ -110,21 +111,21 @@ const SkeletonLoader = () => {
const renderSkeletonItem = () => ( const renderSkeletonItem = () => (
<View style={styles.skeletonVerticalItem}> <View style={styles.skeletonVerticalItem}>
<RNAnimated.View style={[ <RNAnimated.View style={[
styles.skeletonPoster, styles.skeletonPoster,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} /> ]} />
<View style={styles.skeletonItemDetails}> <View style={styles.skeletonItemDetails}>
<RNAnimated.View style={[ <RNAnimated.View style={[
styles.skeletonTitle, styles.skeletonTitle,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} /> ]} />
<View style={styles.skeletonMetaRow}> <View style={styles.skeletonMetaRow}>
<RNAnimated.View style={[ <RNAnimated.View style={[
styles.skeletonMeta, styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} /> ]} />
<RNAnimated.View style={[ <RNAnimated.View style={[
styles.skeletonMeta, styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} /> ]} />
</View> </View>
@ -138,7 +139,7 @@ const SkeletonLoader = () => {
<View key={index}> <View key={index}>
{index === 0 && ( {index === 0 && (
<RNAnimated.View style={[ <RNAnimated.View style={[
styles.skeletonSectionHeader, styles.skeletonSectionHeader,
{ opacity, backgroundColor: currentTheme.colors.darkBackground } { opacity, backgroundColor: currentTheme.colors.darkBackground }
]} /> ]} />
)} )}
@ -157,7 +158,7 @@ const SimpleSearchAnimation = () => {
const spinAnim = React.useRef(new RNAnimated.Value(0)).current; const spinAnim = React.useRef(new RNAnimated.Value(0)).current;
const fadeAnim = React.useRef(new RNAnimated.Value(0)).current; const fadeAnim = React.useRef(new RNAnimated.Value(0)).current;
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
React.useEffect(() => { React.useEffect(() => {
// Rotation animation // Rotation animation
const spin = RNAnimated.loop( const spin = RNAnimated.loop(
@ -168,32 +169,32 @@ const SimpleSearchAnimation = () => {
useNativeDriver: true, useNativeDriver: true,
}) })
); );
// Fade animation // Fade animation
const fade = RNAnimated.timing(fadeAnim, { const fade = RNAnimated.timing(fadeAnim, {
toValue: 1, toValue: 1,
duration: 300, duration: 300,
useNativeDriver: true, useNativeDriver: true,
}); });
// Start animations // Start animations
spin.start(); spin.start();
fade.start(); fade.start();
// Clean up // Clean up
return () => { return () => {
spin.stop(); spin.stop();
}; };
}, [spinAnim, fadeAnim]); }, [spinAnim, fadeAnim]);
// Simple rotation interpolation // Simple rotation interpolation
const spin = spinAnim.interpolate({ const spin = spinAnim.interpolate({
inputRange: [0, 1], inputRange: [0, 1],
outputRange: ['0deg', '360deg'], outputRange: ['0deg', '360deg'],
}); });
return ( return (
<RNAnimated.View <RNAnimated.View
style={[ style={[
styles.simpleAnimationContainer, styles.simpleAnimationContainer,
{ opacity: fadeAnim } { opacity: fadeAnim }
@ -204,10 +205,10 @@ const SimpleSearchAnimation = () => {
styles.spinnerContainer, styles.spinnerContainer,
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary } { transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
]}> ]}>
<MaterialIcons <MaterialIcons
name="search" name="search"
size={32} size={32}
color={currentTheme.colors.white} color={currentTheme.colors.white}
/> />
</RNAnimated.View> </RNAnimated.View>
<Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text> <Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text>
@ -268,9 +269,9 @@ const SearchScreen = () => {
StatusBar.setBackgroundColor('transparent'); StatusBar.setBackgroundColor('transparent');
} }
}; };
applyStatusBarConfig(); applyStatusBarConfig();
// Re-apply on focus // Re-apply on focus
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
return unsubscribe; return unsubscribe;
@ -284,7 +285,7 @@ const SearchScreen = () => {
useEffect(() => { useEffect(() => {
loadRecentSearches(); loadRecentSearches();
// Cleanup function to cancel pending searches on unmount // Cleanup function to cancel pending searches on unmount
return () => { return () => {
debouncedSearch.cancel(); debouncedSearch.cancel();
@ -302,12 +303,12 @@ const SearchScreen = () => {
return { return {
opacity: backButtonOpacity.value, opacity: backButtonOpacity.value,
transform: [ transform: [
{ {
translateX: interpolate( translateX: interpolate(
backButtonOpacity.value, backButtonOpacity.value,
[0, 1], [0, 1],
[-20, 0] [-20, 0]
) )
} }
] ]
}; };
@ -361,14 +362,14 @@ const SearchScreen = () => {
const saveRecentSearch = async (searchQuery: string) => { const saveRecentSearch = async (searchQuery: string) => {
try { try {
setRecentSearches(prevSearches => { setRecentSearches(prevSearches => {
const newRecentSearches = [ const newRecentSearches = [
searchQuery, searchQuery,
...prevSearches.filter(s => s !== searchQuery) ...prevSearches.filter(s => s !== searchQuery)
].slice(0, MAX_RECENT_SEARCHES); ].slice(0, MAX_RECENT_SEARCHES);
// Save to AsyncStorage // Save to AsyncStorage
mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
return newRecentSearches; return newRecentSearches;
}); });
} catch (error) { } catch (error) {
@ -400,7 +401,7 @@ const SearchScreen = () => {
const rank: Record<string, number> = {}; const rank: Record<string, number> = {};
addons.forEach((a, idx) => { rank[a.id] = idx; }); addons.forEach((a, idx) => { rank[a.id] = idx; });
addonOrderRankRef.current = rank; addonOrderRankRef.current = rank;
} catch {} } catch { }
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => { const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
// Append/update this addon section immediately with minimal changes // Append/update this addon section immediately with minimal changes
@ -444,7 +445,7 @@ const SearchScreen = () => {
// Save to recents after first result batch // Save to recents after first result batch
try { try {
await saveRecentSearch(searchQuery); await saveRecentSearch(searchQuery);
} catch {} } catch { }
}); });
liveSearchHandle.current = handle; liveSearchHandle.current = handle;
}, 800); }, 800);
@ -502,7 +503,7 @@ const SearchScreen = () => {
if (!showRecent || recentSearches.length === 0) return null; if (!showRecent || recentSearches.length === 0) return null;
return ( return (
<Animated.View <Animated.View
style={styles.recentSearchesContainer} style={styles.recentSearchesContainer}
entering={FadeIn.duration(300)} entering={FadeIn.duration(300)}
> >
@ -586,10 +587,10 @@ const SearchScreen = () => {
entering={FadeIn.duration(300).delay(index * 50)} entering={FadeIn.duration(300).delay(index * 50)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={[styles.horizontalItemPosterContainer, { <View style={[styles.horizontalItemPosterContainer, {
backgroundColor: currentTheme.colors.darkBackground, backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)' borderColor: 'rgba(255,255,255,0.05)'
}]}> }]}>
<FastImage <FastImage
source={{ uri: item.poster || PLACEHOLDER_POSTER }} source={{ uri: item.poster || PLACEHOLDER_POSTER }}
style={styles.horizontalItemPoster} style={styles.horizontalItemPoster}
@ -597,28 +598,28 @@ const SearchScreen = () => {
/> />
{/* Bookmark and watched icons top right, bookmark to the left of watched */} {/* Bookmark and watched icons top right, bookmark to the left of watched */}
{inLibrary && ( {inLibrary && (
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }] }> <View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
<Feather name="bookmark" size={16} color={currentTheme.colors.white} /> <Feather name="bookmark" size={16} color={currentTheme.colors.white} />
</View> </View>
)} )}
{watched && ( {watched && (
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }] }> <View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} /> <MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
</View> </View>
)} )}
{item.imdbRating && ( {item.imdbRating && (
<View style={styles.ratingContainer}> <View style={styles.ratingContainer}>
<MaterialIcons name="star" size={12} color="#FFC107" /> <MaterialIcons name="star" size={12} color="#FFC107" />
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}> <Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
{item.imdbRating} {item.imdbRating}
</Text> </Text>
</View> </View>
)} )}
</View> </View>
<Text <Text
style={[ style={[
styles.horizontalItemTitle, styles.horizontalItemTitle,
{ {
color: currentTheme.colors.white, color: currentTheme.colors.white,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18, lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
@ -629,36 +630,36 @@ const SearchScreen = () => {
{item.name} {item.name}
</Text> </Text>
{item.year && ( {item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}> <Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
{item.year} {item.year}
</Text> </Text>
)} )}
</AnimatedTouchable> </AnimatedTouchable>
); );
}; };
const hasResultsToShow = useMemo(() => { const hasResultsToShow = useMemo(() => {
return results.byAddon.length > 0; return results.byAddon.length > 0;
}, [results]); }, [results]);
// Memoized addon section to prevent re-rendering unchanged sections // Memoized addon section to prevent re-rendering unchanged sections
const AddonSection = React.memo(({ const AddonSection = React.memo(({
addonGroup, addonGroup,
addonIndex addonIndex
}: { }: {
addonGroup: AddonSearchResults; addonGroup: AddonSearchResults;
addonIndex: number; addonIndex: number;
}) => { }) => {
const movieResults = useMemo(() => const movieResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'movie'), addonGroup.results.filter(item => item.type === 'movie'),
[addonGroup.results] [addonGroup.results]
); );
const seriesResults = useMemo(() => const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'), addonGroup.results.filter(item => item.type === 'series'),
[addonGroup.results] [addonGroup.results]
); );
const otherResults = useMemo(() => const otherResults = useMemo(() =>
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
[addonGroup.results] [addonGroup.results]
); );
@ -679,15 +680,15 @@ const SearchScreen = () => {
{/* Movies */} {/* Movies */}
{movieResults.length > 0 && ( {movieResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}> <Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[ <Text style={[
styles.carouselSubtitle, styles.carouselSubtitle,
{ {
color: currentTheme.colors.lightGray, color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14, fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8, marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16 paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
} }
]}> ]}>
Movies ({movieResults.length}) Movies ({movieResults.length})
</Text> </Text>
<FlatList <FlatList
@ -713,15 +714,15 @@ const SearchScreen = () => {
{/* TV Shows */} {/* TV Shows */}
{seriesResults.length > 0 && ( {seriesResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}> <Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[ <Text style={[
styles.carouselSubtitle, styles.carouselSubtitle,
{ {
color: currentTheme.colors.lightGray, color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14, fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8, marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16 paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
} }
]}> ]}>
TV Shows ({seriesResults.length}) TV Shows ({seriesResults.length})
</Text> </Text>
<FlatList <FlatList
@ -747,15 +748,15 @@ const SearchScreen = () => {
{/* Other types */} {/* Other types */}
{otherResults.length > 0 && ( {otherResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}> <Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[ <Text style={[
styles.carouselSubtitle, styles.carouselSubtitle,
{ {
color: currentTheme.colors.lightGray, color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14, fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8, marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16 paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
} }
]}> ]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text> </Text>
<FlatList <FlatList
@ -784,12 +785,6 @@ const SearchScreen = () => {
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
}); });
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Keep header below floating top navigator on tablets by adding extra offset
const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing + 60;
// Set up listeners for watched status and library updates // Set up listeners for watched status and library updates
// These will trigger re-renders in individual SearchResultItem components // These will trigger re-renders in individual SearchResultItem components
useEffect(() => { useEffect(() => {
@ -809,11 +804,11 @@ const SearchScreen = () => {
}, []); }, []);
return ( return (
<Animated.View <Animated.View
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
entering={Platform.OS === 'android' ? undefined : FadeIn.duration(350)} entering={Platform.OS === 'android' ? undefined : FadeIn.duration(350)}
exiting={Platform.OS === 'android' ? exiting={Platform.OS === 'android' ?
FadeOut.duration(200).withInitialValues({ opacity: 1 }) : FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
FadeOut.duration(250) FadeOut.duration(250)
} }
> >
@ -822,172 +817,170 @@ const SearchScreen = () => {
backgroundColor="transparent" backgroundColor="transparent"
translucent translucent
/> />
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { {/* ScreenHeader Component */}
height: headerHeight, <ScreenHeader
backgroundColor: currentTheme.colors.darkBackground title="Search"
}]} /> isTablet={isTV || isLargeTablet || isTablet}
<View style={{ flex: 1 }}> >
{/* Header Section with proper top spacing */} {/* Search Bar */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}> <View style={styles.searchBarContainer}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Search</Text> <View style={[
<View style={styles.searchBarContainer}> styles.searchBarWrapper,
{ width: '100%' }
]}>
<View style={[ <View style={[
styles.searchBarWrapper, styles.searchBar,
{ width: '100%' } {
backgroundColor: currentTheme.colors.elevation2,
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
}
]}> ]}>
<View style={[ <MaterialIcons
styles.searchBar, name="search"
{ size={24}
backgroundColor: currentTheme.colors.elevation2, color={currentTheme.colors.lightGray}
borderColor: 'rgba(255,255,255,0.1)', style={styles.searchIcon}
borderWidth: 1, />
} <TextInput
]}> style={[
<MaterialIcons styles.searchInput,
name="search" { color: currentTheme.colors.white }
size={24} ]}
color={currentTheme.colors.lightGray} placeholder="Search movies, shows..."
style={styles.searchIcon} placeholderTextColor={currentTheme.colors.lightGray}
/> value={query}
<TextInput onChangeText={setQuery}
style={[ returnKeyType="search"
styles.searchInput, keyboardAppearance="dark"
{ color: currentTheme.colors.white } ref={inputRef}
]} />
placeholder="Search movies, shows..." {query.length > 0 && (
placeholderTextColor={currentTheme.colors.lightGray} <TouchableOpacity
value={query} onPress={handleClearSearch}
onChangeText={setQuery} style={styles.clearButton}
returnKeyType="search" hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
keyboardAppearance="dark" >
ref={inputRef} <MaterialIcons
/> name="close"
{query.length > 0 && ( size={20}
<TouchableOpacity color={currentTheme.colors.lightGray}
onPress={handleClearSearch} />
style={styles.clearButton} </TouchableOpacity>
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} )}
>
<MaterialIcons
name="close"
size={20}
color={currentTheme.colors.lightGray}
/>
</TouchableOpacity>
)}
</View>
</View> </View>
</View> </View>
</View> </View>
{/* Content Container */} </ScreenHeader>
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? ( {/* Content Container */}
<View style={styles.loadingOverlay} pointerEvents="none"> <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
<LoadingSpinner {searching ? (
size="large" <View style={styles.loadingOverlay} pointerEvents="none">
offsetY={-60} <LoadingSpinner
size="large"
offsetY={-60}
/>
</View>
) : query.trim().length === 1 ? (
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search"
size={64}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
Keep typing...
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Type at least 2 characters to search
</Text>
</Animated.View>
) : searched && !hasResultsToShow ? (
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search-off"
size={64}
color={currentTheme.colors.lightGray}
/>
<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()}
{/* Render results grouped by addon using memoized component */}
{results.byAddon.map((addonGroup, addonIndex) => (
<AddonSection
key={addonGroup.addonId}
addonGroup={addonGroup}
addonIndex={addonIndex}
/> />
</View> ))}
) : query.trim().length === 1 ? ( </Animated.ScrollView>
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search"
size={64}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
Keep typing...
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Type at least 2 characters to search
</Text>
</Animated.View>
) : searched && !hasResultsToShow ? (
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search-off"
size={64}
color={currentTheme.colors.lightGray}
/>
<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()}
{/* Render results grouped by addon using memoized component */}
{results.byAddon.map((addonGroup, addonIndex) => (
<AddonSection
key={addonGroup.addonId}
addonGroup={addonGroup}
addonIndex={addonIndex}
/>
))}
</Animated.ScrollView>
)}
</View>
{/* DropUpMenu integration for search results */}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
onClose={() => setMenuVisible(false)}
item={selectedItem}
isSaved={isSaved}
isWatched={isWatched}
onOptionSelect={async (option: string) => {
if (!selectedItem) return;
switch (option) {
case 'share': {
let url = '';
if (selectedItem.id) {
url = `https://www.imdb.com/title/${selectedItem.id}/`;
}
const message = `${selectedItem.name}\n${url}`;
Share.share({ message, url, title: selectedItem.name });
break;
}
case 'library': {
if (isSaved) {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
setIsSaved(false);
} else {
await catalogService.addToLibrary(selectedItem);
setIsSaved(true);
}
break;
}
case 'watched': {
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !isWatched;
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
setIsWatched(newWatched);
break;
}
default:
break;
}
}}
/>
)} )}
</View> </View>
{/* DropUpMenu integration for search results */}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
onClose={() => setMenuVisible(false)}
item={selectedItem}
isSaved={isSaved}
isWatched={isWatched}
onOptionSelect={async (option: string) => {
if (!selectedItem) return;
switch (option) {
case 'share': {
let url = '';
if (selectedItem.id) {
url = `https://www.imdb.com/title/${selectedItem.id}/`;
}
const message = `${selectedItem.name}\n${url}`;
Share.share({ message, url, title: selectedItem.name });
break;
}
case 'library': {
if (isSaved) {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
setIsSaved(false);
} else {
await catalogService.addToLibrary(selectedItem);
setIsSaved(true);
}
break;
}
case 'watched': {
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !isWatched;
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
setIsWatched(newWatched);
break;
}
default:
break;
}
}}
/>
)}
</Animated.View> </Animated.View>
); );
}; };
@ -996,30 +989,10 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
contentContainer: { contentContainer: {
flex: 1, flex: 1,
paddingTop: 0, paddingTop: 0,
}, },
header: {
paddingHorizontal: 15,
justifyContent: 'flex-end',
paddingBottom: 0,
backgroundColor: 'transparent',
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 12,
},
searchBarContainer: { searchBarContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',