NuvioStreaming/src/screens/LibraryScreen.tsx

508 lines
No EOL
13 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
useColorScheme,
useWindowDimensions,
SafeAreaView,
StatusBar,
Animated as RNAnimated,
ActivityIndicator,
Platform,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles';
import { Image } from 'expo-image';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { catalogService } from '../services/catalogService';
import type { StreamingContent } from '../services/catalogService';
import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// Types
interface LibraryItem extends StreamingContent {
progress?: number;
lastWatched?: string;
}
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const SkeletonLoader = () => {
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
const { width } = useWindowDimensions();
const itemWidth = (width - 48) / 2;
React.useEffect(() => {
const pulse = RNAnimated.loop(
RNAnimated.sequence([
RNAnimated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
RNAnimated.timing(pulseAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
])
);
pulse.start();
return () => pulse.stop();
}, [pulseAnim]);
const opacity = pulseAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.7],
});
const renderSkeletonItem = () => (
<View style={[styles.itemContainer, { width: itemWidth }]}>
<RNAnimated.View
style={[
styles.posterContainer,
{ opacity, backgroundColor: colors.darkBackground }
]}
/>
<RNAnimated.View
style={[
styles.skeletonTitle,
{ opacity, backgroundColor: colors.darkBackground }
]}
/>
</View>
);
return (
<View style={styles.skeletonContainer}>
{[...Array(6)].map((_, index) => (
<View key={index} style={{ width: itemWidth, margin: 8 }}>
{renderSkeletonItem()}
</View>
))}
</View>
);
};
const LibraryScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const { width } = useWindowDimensions();
const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
const insets = useSafeAreaInsets();
// Force consistent status bar settings
useEffect(() => {
const applyStatusBarConfig = () => {
StatusBar.setBarStyle('light-content');
if (Platform.OS === 'android') {
StatusBar.setTranslucent(true);
StatusBar.setBackgroundColor('transparent');
}
};
applyStatusBarConfig();
// Re-apply on focus
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
return unsubscribe;
}, [navigation]);
useEffect(() => {
const loadLibrary = async () => {
setLoading(true);
try {
const items = await catalogService.getLibraryItems();
setLibraryItems(items);
} catch (error) {
logger.error('Failed to load library:', error);
} finally {
setLoading(false);
}
};
loadLibrary();
// Subscribe to library updates
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
setLibraryItems(items);
});
return () => {
unsubscribe();
};
}, []);
const filteredItems = libraryItems.filter(item => {
if (filter === 'all') return true;
if (filter === 'movies') return item.type === 'movie';
if (filter === 'series') return item.type === 'series';
return true;
});
const itemWidth = (width - 48) / 2; // 2 items per row with padding
const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7}
>
<View style={styles.posterContainer}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
transition={300}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text
style={styles.itemTitle}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={styles.lastWatched}>
{item.lastWatched}
</Text>
)}
</LinearGradient>
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%` }
]}
/>
</View>
)}
{item.type === 'series' && (
<View style={styles.badgeContainer}>
<MaterialIcons
name="live-tv"
size={14}
color={colors.white}
style={{ marginRight: 4 }}
/>
<Text style={styles.badgeText}>Series</Text>
</View>
)}
</View>
</TouchableOpacity>
);
const renderFilter = (filterType: 'all' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
const isActive = filter === filterType;
return (
<TouchableOpacity
style={[
styles.filterButton,
isActive && styles.filterButtonActive,
]}
onPress={() => setFilter(filterType)}
activeOpacity={0.7}
>
<MaterialIcons
name={iconName}
size={22}
color={isActive ? colors.white : colors.mediumGray}
style={styles.filterIcon}
/>
<Text
style={[
styles.filterText,
isActive && styles.filterTextActive
]}
>
{label}
</Text>
</TouchableOpacity>
);
};
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing;
return (
<View style={styles.container}>
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Library</Text>
</View>
</View>
{/* Content Container */}
<View style={styles.contentContainer}>
<View style={styles.filtersContainer}>
{renderFilter('all', 'All', 'apps')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</View>
{loading ? (
<SkeletonLoader />
) : filteredItems.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={styles.exploreButton}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={styles.exploreButtonText}>Explore Content</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={filteredItems}
renderItem={renderItem}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
)}
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: colors.darkBackground,
zIndex: 1,
},
contentContainer: {
flex: 1,
backgroundColor: colors.darkBackground,
},
header: {
paddingHorizontal: 20,
justifyContent: 'flex-end',
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
color: colors.white,
letterSpacing: 0.3,
},
filtersContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
zIndex: 10,
},
filterButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 16,
marginHorizontal: 4,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.05)',
shadowColor: colors.black,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
filterButtonActive: {
backgroundColor: colors.primary,
},
filterIcon: {
marginRight: 8,
},
filterText: {
fontSize: 15,
fontWeight: '500',
color: colors.mediumGray,
},
filterTextActive: {
fontWeight: '600',
color: colors.white,
},
listContainer: {
paddingHorizontal: 12,
paddingVertical: 16,
},
columnWrapper: {
justifyContent: 'space-between',
marginBottom: 16,
},
skeletonContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16,
justifyContent: 'space-between',
},
itemContainer: {
marginBottom: 16,
},
posterContainer: {
borderRadius: 16,
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)',
aspectRatio: 2/3,
elevation: 5,
shadowColor: colors.black,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
},
poster: {
width: '100%',
height: '100%',
},
posterGradient: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 16,
justifyContent: 'flex-end',
height: '45%',
},
progressBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 4,
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressBar: {
height: '100%',
backgroundColor: colors.primary,
},
badgeContainer: {
position: 'absolute',
top: 10,
right: 10,
backgroundColor: 'rgba(0,0,0,0.7)',
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
},
badgeText: {
color: colors.white,
fontSize: 10,
fontWeight: '600',
},
itemTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
marginBottom: 4,
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
letterSpacing: 0.3,
},
lastWatched: {
fontSize: 12,
color: 'rgba(255,255,255,0.7)',
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
skeletonTitle: {
height: 14,
marginTop: 8,
borderRadius: 4,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
emptyText: {
fontSize: 20,
fontWeight: '700',
color: colors.white,
marginTop: 16,
marginBottom: 8,
},
emptySubtext: {
fontSize: 15,
color: colors.mediumGray,
textAlign: 'center',
marginBottom: 24,
},
exploreButton: {
backgroundColor: colors.primary,
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 24,
elevation: 3,
shadowColor: colors.black,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
},
exploreButtonText: {
color: colors.white,
fontSize: 16,
fontWeight: '600',
}
});
export default LibraryScreen;