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 { useToast } from '../contexts/ToastContext';
import CustomAlert from '../components/CustomAlert';
import ScreenHeader from '../components/common/ScreenHeader';
const { height, width } = Dimensions.get('window');
const isTablet = width >= 768;
@ -346,7 +347,6 @@ const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { top: safeAreaTop } = useSafeAreaInsets();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const { showSuccess, showInfo } = useToast();
@ -356,9 +356,6 @@ const DownloadsScreen: React.FC = () => {
const [showRemoveAlert, setShowRemoveAlert] = useState(false);
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
// Animation values
const headerOpacity = useSharedValue(1);
// Filter downloads based on selected filter
const filteredDownloads = useMemo(() => {
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) => (
<TouchableOpacity
key={filter}
@ -632,22 +624,10 @@ const DownloadsScreen: React.FC = () => {
backgroundColor="transparent"
/>
{/* Header */}
<Animated.View style={[
styles.header,
{
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>
{/* ScreenHeader Component */}
<ScreenHeader
title="Downloads"
rightActionComponent={
<TouchableOpacity
style={styles.helpButton}
onPress={showDownloadHelp}
@ -659,8 +639,9 @@ const DownloadsScreen: React.FC = () => {
color={currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</View>
}
isTablet={isTablet}
>
{downloads.length > 0 && (
<View style={styles.filterContainer}>
{renderFilterButton('all', 'All', stats.total)}
@ -669,7 +650,7 @@ const DownloadsScreen: React.FC = () => {
{renderFilterButton('paused', 'Paused', stats.paused)}
</View>
)}
</Animated.View>
</ScreenHeader>
{/* Content */}
{downloads.length === 0 ? (
@ -742,23 +723,6 @@ const styles = StyleSheet.create({
container: {
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: {
padding: 8,
marginLeft: 8,

View file

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

View file

@ -27,11 +27,11 @@ import debounce from 'lodash/debounce';
import { DropUpMenu } from '../components/home/DropUpMenu';
import { DeviceEventEmitter, Share } from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
interpolate,
withSpring,
@ -43,6 +43,7 @@ import { BlurView } from 'expo-blur';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext';
import LoadingSpinner from '../components/common/LoadingSpinner';
import ScreenHeader from '../components/common/ScreenHeader';
const { width, height } = Dimensions.get('window');
@ -110,21 +111,21 @@ const SkeletonLoader = () => {
const renderSkeletonItem = () => (
<View style={styles.skeletonVerticalItem}>
<RNAnimated.View style={[
styles.skeletonPoster,
styles.skeletonPoster,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonItemDetails}>
<RNAnimated.View style={[
styles.skeletonTitle,
styles.skeletonTitle,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<View style={styles.skeletonMetaRow}>
<RNAnimated.View style={[
styles.skeletonMeta,
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
<RNAnimated.View style={[
styles.skeletonMeta,
styles.skeletonMeta,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
</View>
@ -138,7 +139,7 @@ const SkeletonLoader = () => {
<View key={index}>
{index === 0 && (
<RNAnimated.View style={[
styles.skeletonSectionHeader,
styles.skeletonSectionHeader,
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
]} />
)}
@ -157,7 +158,7 @@ const SimpleSearchAnimation = () => {
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(
@ -168,32 +169,32 @@ const SimpleSearchAnimation = () => {
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
<RNAnimated.View
style={[
styles.simpleAnimationContainer,
{ opacity: fadeAnim }
@ -204,10 +205,10 @@ const SimpleSearchAnimation = () => {
styles.spinnerContainer,
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
]}>
<MaterialIcons
name="search"
size={32}
color={currentTheme.colors.white}
<MaterialIcons
name="search"
size={32}
color={currentTheme.colors.white}
/>
</RNAnimated.View>
<Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text>
@ -268,9 +269,9 @@ const SearchScreen = () => {
StatusBar.setBackgroundColor('transparent');
}
};
applyStatusBarConfig();
// Re-apply on focus
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
return unsubscribe;
@ -284,7 +285,7 @@ const SearchScreen = () => {
useEffect(() => {
loadRecentSearches();
// Cleanup function to cancel pending searches on unmount
return () => {
debouncedSearch.cancel();
@ -302,12 +303,12 @@ const SearchScreen = () => {
return {
opacity: backButtonOpacity.value,
transform: [
{
{
translateX: interpolate(
backButtonOpacity.value,
[0, 1],
[-20, 0]
)
)
}
]
};
@ -361,14 +362,14 @@ const SearchScreen = () => {
const saveRecentSearch = async (searchQuery: string) => {
try {
setRecentSearches(prevSearches => {
const newRecentSearches = [
searchQuery,
const newRecentSearches = [
searchQuery,
...prevSearches.filter(s => s !== searchQuery)
].slice(0, MAX_RECENT_SEARCHES);
].slice(0, MAX_RECENT_SEARCHES);
// Save to AsyncStorage
mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
return newRecentSearches;
});
} catch (error) {
@ -400,7 +401,7 @@ const SearchScreen = () => {
const rank: Record<string, number> = {};
addons.forEach((a, idx) => { rank[a.id] = idx; });
addonOrderRankRef.current = rank;
} catch {}
} catch { }
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
// Append/update this addon section immediately with minimal changes
@ -444,7 +445,7 @@ const SearchScreen = () => {
// Save to recents after first result batch
try {
await saveRecentSearch(searchQuery);
} catch {}
} catch { }
});
liveSearchHandle.current = handle;
}, 800);
@ -502,7 +503,7 @@ const SearchScreen = () => {
if (!showRecent || recentSearches.length === 0) return null;
return (
<Animated.View
<Animated.View
style={styles.recentSearchesContainer}
entering={FadeIn.duration(300)}
>
@ -586,10 +587,10 @@ const SearchScreen = () => {
entering={FadeIn.duration(300).delay(index * 50)}
activeOpacity={0.7}
>
<View style={[styles.horizontalItemPosterContainer, {
<View style={[styles.horizontalItemPosterContainer, {
backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)'
}]}>
}]}>
<FastImage
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
style={styles.horizontalItemPoster}
@ -597,28 +598,28 @@ const SearchScreen = () => {
/>
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
{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} />
</View>
)}
{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'} />
</View>
)}
{item.imdbRating && (
<View style={styles.ratingContainer}>
<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}
</Text>
</View>
)}
</View>
<Text
<Text
style={[
styles.horizontalItemTitle,
{
styles.horizontalItemTitle,
{
color: currentTheme.colors.white,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
@ -629,36 +630,36 @@ const SearchScreen = () => {
{item.name}
</Text>
{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}
</Text>
)}
</AnimatedTouchable>
);
};
const hasResultsToShow = useMemo(() => {
return results.byAddon.length > 0;
}, [results]);
// Memoized addon section to prevent re-rendering unchanged sections
const AddonSection = React.memo(({
addonGroup,
addonIndex
}: {
addonGroup: AddonSearchResults;
const AddonSection = React.memo(({
addonGroup,
addonIndex
}: {
addonGroup: AddonSearchResults;
addonIndex: number;
}) => {
const movieResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'movie'),
const movieResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'movie'),
[addonGroup.results]
);
const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'),
const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'),
[addonGroup.results]
);
const otherResults = useMemo(() =>
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
const otherResults = useMemo(() =>
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
[addonGroup.results]
);
@ -679,15 +680,15 @@ const SearchScreen = () => {
{/* Movies */}
{movieResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
Movies ({movieResults.length})
</Text>
<FlatList
@ -713,15 +714,15 @@ const SearchScreen = () => {
{/* TV Shows */}
{seriesResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
TV Shows ({seriesResults.length})
</Text>
<FlatList
@ -747,15 +748,15 @@ const SearchScreen = () => {
{/* Other types */}
{otherResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text>
<FlatList
@ -784,12 +785,6 @@ const SearchScreen = () => {
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
// These will trigger re-renders in individual SearchResultItem components
useEffect(() => {
@ -809,11 +804,11 @@ const SearchScreen = () => {
}, []);
return (
<Animated.View
<Animated.View
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
entering={Platform.OS === 'android' ? undefined : FadeIn.duration(350)}
exiting={Platform.OS === 'android' ?
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
exiting={Platform.OS === 'android' ?
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
FadeOut.duration(250)
}
>
@ -822,172 +817,170 @@ const SearchScreen = () => {
backgroundColor="transparent"
translucent
/>
{/* 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}>
{/* ScreenHeader Component */}
<ScreenHeader
title="Search"
isTablet={isTV || isLargeTablet || isTablet}
>
{/* Search Bar */}
<View style={styles.searchBarContainer}>
<View style={[
styles.searchBarWrapper,
{ width: '100%' }
]}>
<View style={[
styles.searchBarWrapper,
{ width: '100%' }
styles.searchBar,
{
backgroundColor: currentTheme.colors.elevation2,
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
}
]}>
<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"
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>
<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"
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>
{/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? (
<View style={styles.loadingOverlay} pointerEvents="none">
<LoadingSpinner
size="large"
offsetY={-60}
</ScreenHeader>
{/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? (
<View style={styles.loadingOverlay} pointerEvents="none">
<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.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;
}
}}
/>
))}
</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;
}
}}
/>
)}
</Animated.View>
);
};
@ -996,30 +989,10 @@ 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: 15,
justifyContent: 'flex-end',
paddingBottom: 0,
backgroundColor: 'transparent',
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 12,
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',