ui changes

This commit is contained in:
tapframe 2025-09-15 20:38:37 +05:30
parent 3138f33fee
commit c18f984eac
10 changed files with 504 additions and 238 deletions

View file

@ -93,8 +93,8 @@ android {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 11
versionName "0.6.0-beta.11"
versionCode 1
versionName "1.0.0"
}
splits {

View file

@ -7,6 +7,7 @@ import {
TouchableOpacity,
Dimensions
} from 'react-native';
import { InteractionManager } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
import Animated, { FadeIn } from 'react-native-reanimated';
@ -79,26 +80,35 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
const [uiReady, setUiReady] = useState(false);
// Map of dates with episodes
const [datesWithEpisodes, setDatesWithEpisodes] = useState<{ [key: string]: boolean }>({});
// Process episodes to identify dates with content
// Defer initial heavy work until after interactions
useEffect(() => {
if (__DEV__) console.log(`[CalendarSection] Processing ${episodes.length} episodes for calendar dots`);
const task = InteractionManager.runAfterInteractions(() => setUiReady(true));
return () => task.cancel();
}, []);
// Process episodes to identify dates with content (bounded and deferred)
useEffect(() => {
if (!uiReady) return;
const MAX_TO_PROCESS = 3000; // cap to prevent massive loops
const dateMap: { [key: string]: boolean } = {};
episodes.forEach(episode => {
if (episode.releaseDate) {
const len = Math.min(episodes.length, MAX_TO_PROCESS);
for (let i = 0; i < len; i++) {
const episode = episodes[i];
if (episode && episode.releaseDate) {
const releaseDate = new Date(episode.releaseDate);
const dateKey = format(releaseDate, 'yyyy-MM-dd');
dateMap[dateKey] = true;
if (!isNaN(releaseDate.getTime())) {
const dateKey = format(releaseDate, 'yyyy-MM-dd');
dateMap[dateKey] = true;
}
}
});
if (__DEV__) console.log(`[CalendarSection] Found ${Object.keys(dateMap).length} unique dates with episodes`);
}
setDatesWithEpisodes(dateMap);
}, [episodes]);
}, [episodes, uiReady]);
const goToPreviousMonth = useCallback(() => {
setCurrentDate(prev => subMonths(prev, 1));
@ -213,9 +223,13 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
))}
</View>
<View style={styles.daysContainer}>
{renderDays()}
</View>
{uiReady ? (
<View style={styles.daysContainer}>
{renderDays()}
</View>
) : (
<View style={styles.daysContainer} />
)}
</View>
);
};

View file

@ -10,6 +10,12 @@ import { MaterialIcons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import Animated, {
FadeIn,
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
interpolate,
Extrapolate,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
@ -36,6 +42,11 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
const { currentTheme } = useTheme();
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
const [isMDBEnabled, setIsMDBEnabled] = useState(false);
const [isTextTruncated, setIsTextTruncated] = useState(false);
// Animation values for smooth height transition
const animatedHeight = useSharedValue(0);
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
useEffect(() => {
const checkMDBListEnabled = async () => {
@ -50,6 +61,42 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
checkMDBListEnabled();
}, []);
const handleTextLayout = (event: any) => {
const { lines } = event.nativeEvent;
// If we have 3 or more lines, it means the text was truncated
setIsTextTruncated(lines.length >= 3);
};
const handleCollapsedTextLayout = (event: any) => {
const { height } = event.nativeEvent.layout;
setMeasuredHeights(prev => ({ ...prev, collapsed: height }));
};
const handleExpandedTextLayout = (event: any) => {
const { height } = event.nativeEvent.layout;
setMeasuredHeights(prev => ({ ...prev, expanded: height }));
};
// Animate height changes
const toggleDescription = () => {
const targetHeight = isFullDescriptionOpen ? measuredHeights.collapsed : measuredHeights.expanded;
animatedHeight.value = withTiming(targetHeight, { duration: 300 });
setIsFullDescriptionOpen(!isFullDescriptionOpen);
};
// Initialize height when component mounts or text changes
useEffect(() => {
if (measuredHeights.collapsed > 0) {
animatedHeight.value = measuredHeights.collapsed;
}
}, [measuredHeights.collapsed]);
// Animated style for smooth height transition
const animatedDescriptionStyle = useAnimatedStyle(() => ({
height: animatedHeight.value,
overflow: 'hidden',
}));
return (
<>
{/* Metadata Source Selector removed */}
@ -115,23 +162,47 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
style={[styles.descriptionContainer, loadingMetadata && styles.dimmed]}
entering={FadeIn.duration(300)}
>
<TouchableOpacity
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
activeOpacity={0.7}
{/* Hidden text elements to measure heights */}
<Text
style={[styles.description, { color: currentTheme.colors.mediumEmphasis, position: 'absolute', opacity: 0 }]}
numberOfLines={3}
onLayout={handleCollapsedTextLayout}
>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={isFullDescriptionOpen ? undefined : 3}>
{metadata.description}
</Text>
<View style={styles.showMoreButton}>
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}>
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
{metadata.description}
</Text>
<Text
style={[styles.description, { color: currentTheme.colors.mediumEmphasis, position: 'absolute', opacity: 0 }]}
onLayout={handleExpandedTextLayout}
>
{metadata.description}
</Text>
<TouchableOpacity
onPress={toggleDescription}
activeOpacity={0.7}
disabled={!isTextTruncated && !isFullDescriptionOpen}
>
<Animated.View style={animatedDescriptionStyle}>
<Text
style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={isFullDescriptionOpen ? undefined : 3}
onTextLayout={handleTextLayout}
>
{metadata.description}
</Text>
<MaterialIcons
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={18}
color={currentTheme.colors.textMuted}
/>
</View>
</Animated.View>
{(isTextTruncated || isFullDescriptionOpen) && (
<View style={styles.showMoreButton}>
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}>
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
</Text>
<MaterialIcons
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={18}
color={currentTheme.colors.textMuted}
/>
</View>
)}
</TouchableOpacity>
</Animated.View>
)}

View file

@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal } from 'react-native';
import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal, AppState } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import RNImmersiveMode from 'react-native-immersive-mode';
@ -588,6 +588,8 @@ const AndroidVideoPlayer: React.FC = () => {
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
// Re-apply immersive mode on layout changes to keep system bars hidden
enableImmersiveMode();
});
const initializePlayer = async () => {
StatusBar.setHidden(true, 'none');
@ -620,6 +622,27 @@ const AndroidVideoPlayer: React.FC = () => {
};
}, []);
// Re-apply immersive mode when screen gains focus
useFocusEffect(
useCallback(() => {
enableImmersiveMode();
return () => {};
}, [])
);
// Re-apply immersive mode when app returns to foreground
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
enableImmersiveMode();
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => {
sub.remove();
};
}, []);
const startOpeningAnimation = () => {
// Logo entrance animation - optimized for faster appearance
Animated.parallel([
@ -1206,6 +1229,8 @@ const AndroidVideoPlayer: React.FC = () => {
if (newShowControls) {
controlsTimeout.current = setTimeout(hideControls, 5000);
}
// Reinforce immersive mode after any UI toggle
enableImmersiveMode();
return newShowControls;
});
};

View file

@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal } from 'react-native';
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet, Modal, AppState } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { VLCPlayer } from 'react-native-vlc-media-player';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import RNImmersiveMode from 'react-native-immersive-mode';
@ -567,6 +567,8 @@ const VideoPlayer: React.FC = () => {
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
setScreenDimensions(screen);
// Re-apply immersive mode on layout changes (Android)
enableImmersiveMode();
});
const initializePlayer = async () => {
StatusBar.setHidden(true, 'none');
@ -599,6 +601,27 @@ const VideoPlayer: React.FC = () => {
};
}, []);
// Re-apply immersive mode when screen gains focus (Android)
useFocusEffect(
useCallback(() => {
enableImmersiveMode();
return () => {};
}, [])
);
// Re-apply immersive mode when app returns to foreground (Android)
useEffect(() => {
const onAppStateChange = (state: string) => {
if (state === 'active') {
enableImmersiveMode();
}
};
const sub = AppState.addEventListener('change', onAppStateChange);
return () => {
sub.remove();
};
}, []);
const startOpeningAnimation = () => {
// Logo entrance animation - optimized for faster appearance
Animated.parallel([
@ -1166,6 +1189,8 @@ const VideoPlayer: React.FC = () => {
if (newShowControls) {
controlsTimeout.current = setTimeout(hideControls, 5000);
}
// Reinforce immersive mode after any UI toggle (Android)
enableImmersiveMode();
return newShowControls;
});
};

View file

@ -333,7 +333,8 @@ export const CustomNavigationDarkTheme: Theme = {
type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' |
'play-box-multiple' | 'play-box-multiple-outline' |
'puzzle' | 'puzzle-outline' |
'cog' | 'cog-outline' | 'feature-search' | 'feature-search-outline';
'cog' | 'cog-outline' | 'feature-search' | 'feature-search-outline' |
'magnify' | 'heart' | 'heart-outline';
// Add TabIcon component
const TabIcon = React.memo(({ focused, color, iconName }: {
@ -352,7 +353,11 @@ const TabIcon = React.memo(({ focused, color, iconName }: {
}).start();
}, [focused]);
const finalIconName = focused ? iconName : `${iconName}-outline` as IconNameType;
// Use outline variant when available, but keep single-form icons (like 'magnify') the same
const finalIconName = (() => {
if (iconName === 'magnify') return 'magnify';
return focused ? iconName : `${iconName}-outline` as IconNameType;
})();
return (
<Animated.View style={{
@ -637,10 +642,10 @@ const MainTabs = () => {
iconName = 'home';
break;
case 'Library':
iconName = 'play-box-multiple';
iconName = 'heart';
break;
case 'Search':
iconName = 'feature-search';
iconName = 'magnify';
break;
case 'Settings':
iconName = 'cog';
@ -755,7 +760,7 @@ const MainTabs = () => {
options={{
tabBarLabel: 'Library',
tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'play-box-multiple' : 'play-box-multiple-outline'} size={size} color={color} />
<MaterialCommunityIcons name={focused ? 'heart' : 'heart-outline'} size={size} color={color} />
),
}}
/>
@ -764,8 +769,8 @@ const MainTabs = () => {
component={SearchScreen}
options={{
tabBarLabel: 'Search',
tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'feature-search' : 'feature-search-outline'} size={size} color={color} />
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name={'magnify'} size={size} color={color} />
),
}}
/>

View file

@ -13,6 +13,7 @@ import {
SectionList,
Platform
} from 'react-native';
import { InteractionManager } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { Image } from 'expo-image';
@ -69,6 +70,7 @@ const CalendarScreen = () => {
logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`);
const [refreshing, setRefreshing] = useState(false);
const [uiReady, setUiReady] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
@ -79,6 +81,14 @@ const CalendarScreen = () => {
refresh(true);
setRefreshing(false);
}, [refresh]);
// Defer heavy UI work until after interactions to reduce jank/crashes
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
setUiReady(true);
});
return () => task.cancel();
}, []);
const handleSeriesPress = useCallback((seriesId: string, episode?: CalendarEpisode) => {
navigation.navigate('Metadata', {
@ -211,12 +221,15 @@ const CalendarScreen = () => {
// Process all episodes once data is loaded - using memory-efficient approach
const allEpisodes = React.useMemo(() => {
const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) =>
[...acc, ...section.data], [] as CalendarEpisode[]);
// Limit episodes to prevent memory issues in large libraries
return memoryManager.limitArraySize(episodes, 1000);
}, [calendarData]);
if (!uiReady) return [] as CalendarEpisode[];
const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => {
// Pre-trim section arrays defensively
const trimmed = memoryManager.limitArraySize(section.data, 500);
return acc.length > 1500 ? acc : [...acc, ...trimmed];
}, [] as CalendarEpisode[]);
// Global cap to keep memory bounded
return memoryManager.limitArraySize(episodes, 1500);
}, [calendarData, uiReady]);
// Log when rendering with relevant state info
logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`);
@ -244,7 +257,7 @@ const CalendarScreen = () => {
setFilteredEpisodes([]);
}, []);
if (loading && !refreshing) {
if ((loading || !uiReady) && !refreshing) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
@ -293,6 +306,11 @@ const CalendarScreen = () => {
keyExtractor={(item) => item.id}
renderItem={renderEpisodeItem}
contentContainerStyle={styles.listContent}
initialNumToRender={8}
maxToRenderPerBatch={8}
updateCellsBatchingPeriod={50}
windowSize={7}
removeClippedSubviews
refreshControl={
<RefreshControl
refreshing={refreshing}
@ -324,7 +342,11 @@ const CalendarScreen = () => {
renderItem={renderEpisodeItem}
renderSectionHeader={renderSectionHeader}
contentContainerStyle={styles.listContent}
removeClippedSubviews={false}
removeClippedSubviews
initialNumToRender={8}
maxToRenderPerBatch={8}
updateCellsBatchingPeriod={50}
windowSize={7}
refreshControl={
<RefreshControl
refreshing={refreshing}

View file

@ -12,6 +12,7 @@ import {
ActivityIndicator,
Platform,
ScrollView,
BackHandler,
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';
@ -109,49 +110,33 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item:
onPress={handlePress}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={styles.poster}
contentFit="cover"
cachePolicy="disk"
transition={0}
allowDownscaling
/>
) : (
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}>
<ActivityIndicator color={currentTheme.colors.primary} />
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={styles.poster}
contentFit="cover"
cachePolicy="disk"
transition={0}
allowDownscaling
/>
) : (
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}>
<ActivityIndicator color={currentTheme.colors.primary} />
</View>
)}
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(45,55,72,0.9)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
</View>
)}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={styles.lastWatched}>
Last watched: {item.lastWatched}
</Text>
)}
{item.plays && item.plays > 1 && (
<Text style={styles.playsCount}>
{item.plays} plays
</Text>
)}
</LinearGradient>
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
{item.name}
</Text>
</View>
</TouchableOpacity>
);
@ -255,14 +240,35 @@ const LibraryScreen = () => {
StatusBar.setBackgroundColor('transparent');
}
};
applyStatusBarConfig();
// Re-apply on focus
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
return unsubscribe;
}, [navigation]);
// Handle hardware back button and gesture navigation
useEffect(() => {
const backAction = () => {
if (showTraktContent) {
if (selectedTraktFolder) {
// If in a specific folder, go back to folder list
setSelectedTraktFolder(null);
} else {
// If in Trakt collections view, go back to main library
setShowTraktContent(false);
}
return true; // Prevent default back behavior
}
return false; // Allow default back behavior (navigate back)
};
const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction);
return () => backHandler.remove();
}, [showTraktContent, selectedTraktFolder]);
useEffect(() => {
const loadLibrary = async () => {
setLoading(true);
@ -306,7 +312,7 @@ const LibraryScreen = () => {
icon: 'visibility',
description: 'Your watched content',
itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0),
gradient: ['#4CAF50', '#2E7D32']
gradient: ['#2C3E50', '#34495E']
},
{
id: 'continue-watching',
@ -314,7 +320,7 @@ const LibraryScreen = () => {
icon: 'play-circle-outline',
description: 'Resume your progress',
itemCount: continueWatching?.length || 0,
gradient: ['#FF9800', '#F57C00']
gradient: ['#2980B9', '#3498DB']
},
{
id: 'watchlist',
@ -322,7 +328,7 @@ const LibraryScreen = () => {
icon: 'bookmark',
description: 'Want to watch',
itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0),
gradient: ['#2196F3', '#1976D2']
gradient: ['#6C3483', '#9B59B6']
},
{
id: 'collection',
@ -330,7 +336,7 @@ const LibraryScreen = () => {
icon: 'library-add',
description: 'Your collection',
itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0),
gradient: ['#9C27B0', '#7B1FA2']
gradient: ['#1B2631', '#283747']
},
{
id: 'ratings',
@ -338,7 +344,7 @@ const LibraryScreen = () => {
icon: 'star',
description: 'Your ratings',
itemCount: ratedContent?.length || 0,
gradient: ['#FF5722', '#D84315']
gradient: ['#5D6D7E', '#85929E']
}
];
@ -352,51 +358,40 @@ const LibraryScreen = () => {
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<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, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={styles.lastWatched}>
{item.lastWatched}
</Text>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
transition={300}
/>
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
</LinearGradient>
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
{item.type === 'series' && (
<View style={styles.badgeContainer}>
<MaterialIcons
name="live-tv"
size={14}
color={currentTheme.colors.white}
style={{ marginRight: 4 }}
/>
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text>
</View>
)}
{item.type === 'series' && (
<View style={styles.badgeContainer}>
<MaterialIcons
name="live-tv"
size={14}
color={currentTheme.colors.white}
style={{ marginRight: 4 }}
/>
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text>
</View>
)}
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
{item.name}
</Text>
</View>
</TouchableOpacity>
);
@ -450,34 +445,31 @@ const LibraryScreen = () => {
}}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
<LinearGradient
colors={['#E8254B', '#C41E3A']}
style={styles.folderGradient}
>
<TraktIcon width={60} height={60} style={{ marginBottom: 12 }} />
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
Trakt Collection
</Text>
{traktAuthenticated && traktFolders.length > 0 && (
<Text style={styles.folderCount}>
{traktFolders.length} items
<View>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
<LinearGradient
colors={['#666666', '#444444']}
style={styles.folderGradient}
>
<TraktIcon width={60} height={60} style={{ marginBottom: 12 }} />
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
Trakt
</Text>
)}
{!traktAuthenticated && (
<Text style={styles.folderSubtitle}>
Tap to connect
</Text>
)}
</LinearGradient>
{/* Trakt badge */}
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(255,255,255,0.2)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
Trakt
</Text>
{traktAuthenticated && traktFolders.length > 0 && (
<Text style={styles.folderCount}>
{traktFolders.length} items
</Text>
)}
{!traktAuthenticated && (
<Text style={styles.folderSubtitle}>
Tap to connect
</Text>
)}
</LinearGradient>
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
Trakt collections
</Text>
</View>
</TouchableOpacity>
);
@ -921,7 +913,7 @@ const LibraryScreen = () => {
<>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
<TouchableOpacity
style={[styles.calendarButton, { backgroundColor: currentTheme.colors.primary }]}
style={styles.calendarButton}
onPress={() => navigation.navigate('Calendar')}
activeOpacity={0.7}
>
@ -1096,6 +1088,13 @@ const styles = StyleSheet.create({
textShadowRadius: 2,
letterSpacing: 0.3,
},
cardTitle: {
fontSize: 14,
fontWeight: '600',
marginTop: 8,
textAlign: 'center',
paddingHorizontal: 4,
},
lastWatched: {
fontSize: 12,
color: 'rgba(255,255,255,0.7)',
@ -1246,13 +1245,8 @@ const styles = StyleSheet.create({
calendarButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
elevation: 3,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
},
});

View file

@ -10,7 +10,6 @@ import {
StatusBar,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
@ -20,6 +19,10 @@ import Animated, {
withTiming,
FadeInDown,
FadeInUp,
useAnimatedScrollHandler,
runOnJS,
interpolateColor,
interpolate,
} from 'react-native-reanimated';
import { useTheme } from '../contexts/ThemeContext';
import { NavigationProp, useNavigation } from '@react-navigation/native';
@ -71,6 +74,14 @@ const onboardingData: OnboardingSlide[] = [
icon: 'library-books',
gradient: ['#43e97b', '#38f9d7'],
},
{
id: '5',
title: 'Plugins',
subtitle: 'Stream Sources Only',
description: 'Plugins add streaming sources to Nuvio.',
icon: 'widgets',
gradient: ['#ff9a9e', '#fad0c4'],
},
];
const OnboardingScreen = () => {
@ -80,6 +91,30 @@ const OnboardingScreen = () => {
const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef<FlatList>(null);
const progressValue = useSharedValue(0);
const scrollX = useSharedValue(0);
const currentSlide = onboardingData[currentIndex];
const onScroll = useAnimatedScrollHandler({
onScroll: (event) => {
scrollX.value = event.contentOffset.x;
},
});
const getAnimatedBackgroundStyle = (slideIndex: number) => {
return useAnimatedStyle(() => {
const inputRange = [(slideIndex - 1) * width, slideIndex * width, (slideIndex + 1) * width];
const opacity = interpolate(
scrollX.value,
inputRange,
[0, 1, 0],
'clamp'
);
return {
opacity,
};
});
};
const animatedProgressStyle = useAnimatedStyle(() => ({
width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`),
@ -149,13 +184,13 @@ const OnboardingScreen = () => {
entering={FadeInUp.delay(500).duration(800)}
style={styles.textContainer}
>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
<Text style={[styles.title, { color: 'white' }]}>
{item.title}
</Text>
<Text style={[styles.subtitle, { color: currentTheme.colors.primary }]}>
<Text style={[styles.subtitle, { color: 'rgba(255,255,255,0.9)' }]}>
{item.subtitle}
</Text>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
<Text style={[styles.description, { color: 'rgba(255,255,255,0.85)' }]}>
{item.description}
</Text>
</Animated.View>
@ -183,71 +218,106 @@ const OnboardingScreen = () => {
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor={currentTheme.colors.darkBackground} />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
<Text style={[styles.skipText, { color: currentTheme.colors.mediumEmphasis }]}>
Skip
</Text>
</TouchableOpacity>
{/* Progress Bar */}
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Animated.View
style={[
styles.progressBar,
{ backgroundColor: currentTheme.colors.primary },
animatedProgressStyle
]}
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
{/* Layered animated gradient backgrounds */}
{onboardingData.map((slide, index) => (
<Animated.View key={`bg-${index}`} style={[styles.backgroundPanel, getAnimatedBackgroundStyle(index)]}>
<LinearGradient
colors={[slide.gradient[0], slide.gradient[1]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
</View>
</View>
{/* Content */}
<FlatList
ref={flatListRef}
data={onboardingData}
renderItem={renderSlide}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item.id}
onMomentumScrollEnd={(event) => {
const slideIndex = Math.round(event.nativeEvent.contentOffset.x / width);
setCurrentIndex(slideIndex);
}}
style={{ flex: 1 }}
</Animated.View>
))}
<LinearGradient
colors={["rgba(0,0,0,0.2)", "rgba(0,0,0,0.45)"]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.overlayPanel}
/>
{/* Decorative gradient blobs that change with current slide */}
<LinearGradient
colors={[currentSlide.gradient[1], 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.blobTopRight}
/>
<LinearGradient
colors={[currentSlide.gradient[0], 'transparent']}
start={{ x: 1, y: 1 }}
end={{ x: 0, y: 0 }}
style={styles.blobBottomLeft}
/>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Footer */}
<View style={styles.footer}>
{renderPagination()}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.button,
styles.nextButton,
{ backgroundColor: currentTheme.colors.primary }
]}
onPress={handleNext}
>
<Text style={[styles.buttonText, { color: 'white' }]}>
{currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Next'}
{/* Content container with status bar padding */}
<View style={styles.fullScreenContainer}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
<Text style={[styles.skipText, { color: currentTheme.colors.mediumEmphasis }]}>
Skip
</Text>
<MaterialIcons
name={currentIndex === onboardingData.length - 1 ? 'check' : 'arrow-forward'}
size={20}
color="white"
style={styles.buttonIcon}
/>
</TouchableOpacity>
{/* Progress Bar */}
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Animated.View
style={[
styles.progressBar,
{ backgroundColor: currentTheme.colors.primary },
animatedProgressStyle
]}
/>
</View>
</View>
{/* Content */}
<Animated.FlatList
ref={flatListRef}
data={onboardingData}
renderItem={renderSlide}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item.id}
onScroll={onScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={(event) => {
const slideIndex = Math.round(event.nativeEvent.contentOffset.x / width);
setCurrentIndex(slideIndex);
}}
style={{ flex: 1 }}
/>
{/* Footer */}
<View style={styles.footer}>
{renderPagination()}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.button,
styles.nextButton,
{ backgroundColor: currentTheme.colors.primary }
]}
onPress={handleNext}
>
<Text style={[styles.buttonText, { color: 'white' }]}>
{currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Next'}
</Text>
<MaterialIcons
name={currentIndex === onboardingData.length - 1 ? 'check' : 'arrow-forward'}
size={20}
color="white"
style={styles.buttonIcon}
/>
</TouchableOpacity>
</View>
</View>
</View>
</SafeAreaView>
</View>
);
};
@ -260,7 +330,7 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: Platform.OS === 'ios' ? 10 : 20,
paddingTop: 10,
paddingBottom: 20,
},
skipButton: {
@ -288,6 +358,46 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingHorizontal: 40,
},
backgroundPanel: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
borderRadius: 0,
},
overlayPanel: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
borderRadius: 0,
},
fullScreenContainer: {
flex: 1,
paddingTop: Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 24,
},
blobTopRight: {
position: 'absolute',
top: -60,
right: -60,
width: 220,
height: 220,
borderRadius: 110,
opacity: 0.35,
transform: [{ rotate: '15deg' }],
},
blobBottomLeft: {
position: 'absolute',
bottom: -70,
left: -70,
width: 260,
height: 260,
borderRadius: 130,
opacity: 0.28,
transform: [{ rotate: '-20deg' }],
},
iconContainer: {
width: 160,
height: 160,

View file

@ -606,7 +606,7 @@ const SettingsScreen: React.FC = () => {
/>
<SettingItem
title="Version"
description="0.6.0-beta.11"
description="1.0.0"
icon="info-outline"
isLast={true}
isTablet={isTablet}