mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-29 05:48:45 +00:00
508 lines
No EOL
13 KiB
TypeScript
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;
|