mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
ui changes
This commit is contained in:
parent
3138f33fee
commit
c18f984eac
10 changed files with 504 additions and 238 deletions
|
|
@ -93,8 +93,8 @@ android {
|
||||||
applicationId 'com.nuvio.app'
|
applicationId 'com.nuvio.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 11
|
versionCode 1
|
||||||
versionName "0.6.0-beta.11"
|
versionName "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
splits {
|
splits {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Dimensions
|
Dimensions
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { InteractionManager } from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
|
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
|
||||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
|
@ -79,26 +80,35 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
const [uiReady, setUiReady] = useState(false);
|
||||||
|
|
||||||
// Map of dates with episodes
|
// Map of dates with episodes
|
||||||
const [datesWithEpisodes, setDatesWithEpisodes] = useState<{ [key: string]: boolean }>({});
|
const [datesWithEpisodes, setDatesWithEpisodes] = useState<{ [key: string]: boolean }>({});
|
||||||
|
|
||||||
// Process episodes to identify dates with content
|
// Defer initial heavy work until after interactions
|
||||||
useEffect(() => {
|
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 } = {};
|
const dateMap: { [key: string]: boolean } = {};
|
||||||
|
const len = Math.min(episodes.length, MAX_TO_PROCESS);
|
||||||
episodes.forEach(episode => {
|
for (let i = 0; i < len; i++) {
|
||||||
if (episode.releaseDate) {
|
const episode = episodes[i];
|
||||||
|
if (episode && episode.releaseDate) {
|
||||||
const releaseDate = new Date(episode.releaseDate);
|
const releaseDate = new Date(episode.releaseDate);
|
||||||
const dateKey = format(releaseDate, 'yyyy-MM-dd');
|
if (!isNaN(releaseDate.getTime())) {
|
||||||
dateMap[dateKey] = true;
|
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);
|
setDatesWithEpisodes(dateMap);
|
||||||
}, [episodes]);
|
}, [episodes, uiReady]);
|
||||||
|
|
||||||
const goToPreviousMonth = useCallback(() => {
|
const goToPreviousMonth = useCallback(() => {
|
||||||
setCurrentDate(prev => subMonths(prev, 1));
|
setCurrentDate(prev => subMonths(prev, 1));
|
||||||
|
|
@ -213,9 +223,13 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.daysContainer}>
|
{uiReady ? (
|
||||||
{renderDays()}
|
<View style={styles.daysContainer}>
|
||||||
</View>
|
{renderDays()}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.daysContainer} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
runOnJS,
|
||||||
|
interpolate,
|
||||||
|
Extrapolate,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
|
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
|
||||||
|
|
@ -36,6 +42,11 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
|
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
|
||||||
const [isMDBEnabled, setIsMDBEnabled] = 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(() => {
|
useEffect(() => {
|
||||||
const checkMDBListEnabled = async () => {
|
const checkMDBListEnabled = async () => {
|
||||||
|
|
@ -50,6 +61,42 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
||||||
checkMDBListEnabled();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Metadata Source Selector removed */}
|
{/* Metadata Source Selector removed */}
|
||||||
|
|
@ -115,23 +162,47 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
||||||
style={[styles.descriptionContainer, loadingMetadata && styles.dimmed]}
|
style={[styles.descriptionContainer, loadingMetadata && styles.dimmed]}
|
||||||
entering={FadeIn.duration(300)}
|
entering={FadeIn.duration(300)}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
{/* Hidden text elements to measure heights */}
|
||||||
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
|
<Text
|
||||||
activeOpacity={0.7}
|
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}
|
||||||
{metadata.description}
|
</Text>
|
||||||
</Text>
|
<Text
|
||||||
<View style={styles.showMoreButton}>
|
style={[styles.description, { color: currentTheme.colors.mediumEmphasis, position: 'absolute', opacity: 0 }]}
|
||||||
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}>
|
onLayout={handleExpandedTextLayout}
|
||||||
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
|
>
|
||||||
|
{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>
|
</Text>
|
||||||
<MaterialIcons
|
</Animated.View>
|
||||||
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
{(isTextTruncated || isFullDescriptionOpen) && (
|
||||||
size={18}
|
<View style={styles.showMoreButton}>
|
||||||
color={currentTheme.colors.textMuted}
|
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}>
|
||||||
/>
|
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
|
||||||
</View>
|
</Text>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
||||||
|
size={18}
|
||||||
|
color={currentTheme.colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video';
|
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 { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
||||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||||
|
|
@ -588,6 +588,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
||||||
setScreenDimensions(screen);
|
setScreenDimensions(screen);
|
||||||
|
// Re-apply immersive mode on layout changes to keep system bars hidden
|
||||||
|
enableImmersiveMode();
|
||||||
});
|
});
|
||||||
const initializePlayer = async () => {
|
const initializePlayer = async () => {
|
||||||
StatusBar.setHidden(true, 'none');
|
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 = () => {
|
const startOpeningAnimation = () => {
|
||||||
// Logo entrance animation - optimized for faster appearance
|
// Logo entrance animation - optimized for faster appearance
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
|
|
@ -1206,6 +1229,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
if (newShowControls) {
|
if (newShowControls) {
|
||||||
controlsTimeout.current = setTimeout(hideControls, 5000);
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
||||||
}
|
}
|
||||||
|
// Reinforce immersive mode after any UI toggle
|
||||||
|
enableImmersiveMode();
|
||||||
return newShowControls;
|
return newShowControls;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { VLCPlayer } from 'react-native-vlc-media-player';
|
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 { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
|
||||||
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
import { PinchGestureHandler, PanGestureHandler, TapGestureHandler, State, PinchGestureHandlerGestureEvent, PanGestureHandlerGestureEvent, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler';
|
||||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||||
|
|
@ -567,6 +567,8 @@ const VideoPlayer: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
const subscription = Dimensions.addEventListener('change', ({ screen }) => {
|
||||||
setScreenDimensions(screen);
|
setScreenDimensions(screen);
|
||||||
|
// Re-apply immersive mode on layout changes (Android)
|
||||||
|
enableImmersiveMode();
|
||||||
});
|
});
|
||||||
const initializePlayer = async () => {
|
const initializePlayer = async () => {
|
||||||
StatusBar.setHidden(true, 'none');
|
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 = () => {
|
const startOpeningAnimation = () => {
|
||||||
// Logo entrance animation - optimized for faster appearance
|
// Logo entrance animation - optimized for faster appearance
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
|
|
@ -1166,6 +1189,8 @@ const VideoPlayer: React.FC = () => {
|
||||||
if (newShowControls) {
|
if (newShowControls) {
|
||||||
controlsTimeout.current = setTimeout(hideControls, 5000);
|
controlsTimeout.current = setTimeout(hideControls, 5000);
|
||||||
}
|
}
|
||||||
|
// Reinforce immersive mode after any UI toggle (Android)
|
||||||
|
enableImmersiveMode();
|
||||||
return newShowControls;
|
return newShowControls;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,8 @@ export const CustomNavigationDarkTheme: Theme = {
|
||||||
type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' |
|
type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' |
|
||||||
'play-box-multiple' | 'play-box-multiple-outline' |
|
'play-box-multiple' | 'play-box-multiple-outline' |
|
||||||
'puzzle' | 'puzzle-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
|
// Add TabIcon component
|
||||||
const TabIcon = React.memo(({ focused, color, iconName }: {
|
const TabIcon = React.memo(({ focused, color, iconName }: {
|
||||||
|
|
@ -352,7 +353,11 @@ const TabIcon = React.memo(({ focused, color, iconName }: {
|
||||||
}).start();
|
}).start();
|
||||||
}, [focused]);
|
}, [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 (
|
return (
|
||||||
<Animated.View style={{
|
<Animated.View style={{
|
||||||
|
|
@ -637,10 +642,10 @@ const MainTabs = () => {
|
||||||
iconName = 'home';
|
iconName = 'home';
|
||||||
break;
|
break;
|
||||||
case 'Library':
|
case 'Library':
|
||||||
iconName = 'play-box-multiple';
|
iconName = 'heart';
|
||||||
break;
|
break;
|
||||||
case 'Search':
|
case 'Search':
|
||||||
iconName = 'feature-search';
|
iconName = 'magnify';
|
||||||
break;
|
break;
|
||||||
case 'Settings':
|
case 'Settings':
|
||||||
iconName = 'cog';
|
iconName = 'cog';
|
||||||
|
|
@ -755,7 +760,7 @@ const MainTabs = () => {
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: 'Library',
|
tabBarLabel: 'Library',
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
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}
|
component={SearchScreen}
|
||||||
options={{
|
options={{
|
||||||
tabBarLabel: 'Search',
|
tabBarLabel: 'Search',
|
||||||
tabBarIcon: ({ color, size, focused }) => (
|
tabBarIcon: ({ color, size }) => (
|
||||||
<MaterialCommunityIcons name={focused ? 'feature-search' : 'feature-search-outline'} size={size} color={color} />
|
<MaterialCommunityIcons name={'magnify'} size={size} color={color} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
SectionList,
|
SectionList,
|
||||||
Platform
|
Platform
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { InteractionManager } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
|
@ -69,6 +70,7 @@ const CalendarScreen = () => {
|
||||||
|
|
||||||
logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`);
|
logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [uiReady, setUiReady] = useState(false);
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
|
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
|
||||||
|
|
||||||
|
|
@ -79,6 +81,14 @@ const CalendarScreen = () => {
|
||||||
refresh(true);
|
refresh(true);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}, [refresh]);
|
}, [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) => {
|
const handleSeriesPress = useCallback((seriesId: string, episode?: CalendarEpisode) => {
|
||||||
navigation.navigate('Metadata', {
|
navigation.navigate('Metadata', {
|
||||||
|
|
@ -211,12 +221,15 @@ const CalendarScreen = () => {
|
||||||
|
|
||||||
// Process all episodes once data is loaded - using memory-efficient approach
|
// Process all episodes once data is loaded - using memory-efficient approach
|
||||||
const allEpisodes = React.useMemo(() => {
|
const allEpisodes = React.useMemo(() => {
|
||||||
const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) =>
|
if (!uiReady) return [] as CalendarEpisode[];
|
||||||
[...acc, ...section.data], [] as CalendarEpisode[]);
|
const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => {
|
||||||
|
// Pre-trim section arrays defensively
|
||||||
// Limit episodes to prevent memory issues in large libraries
|
const trimmed = memoryManager.limitArraySize(section.data, 500);
|
||||||
return memoryManager.limitArraySize(episodes, 1000);
|
return acc.length > 1500 ? acc : [...acc, ...trimmed];
|
||||||
}, [calendarData]);
|
}, [] as CalendarEpisode[]);
|
||||||
|
// Global cap to keep memory bounded
|
||||||
|
return memoryManager.limitArraySize(episodes, 1500);
|
||||||
|
}, [calendarData, uiReady]);
|
||||||
|
|
||||||
// Log when rendering with relevant state info
|
// Log when rendering with relevant state info
|
||||||
logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`);
|
logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`);
|
||||||
|
|
@ -244,7 +257,7 @@ const CalendarScreen = () => {
|
||||||
setFilteredEpisodes([]);
|
setFilteredEpisodes([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading && !refreshing) {
|
if ((loading || !uiReady) && !refreshing) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
@ -293,6 +306,11 @@ const CalendarScreen = () => {
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={renderEpisodeItem}
|
renderItem={renderEpisodeItem}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
updateCellsBatchingPeriod={50}
|
||||||
|
windowSize={7}
|
||||||
|
removeClippedSubviews
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
|
|
@ -324,7 +342,11 @@ const CalendarScreen = () => {
|
||||||
renderItem={renderEpisodeItem}
|
renderItem={renderEpisodeItem}
|
||||||
renderSectionHeader={renderSectionHeader}
|
renderSectionHeader={renderSectionHeader}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
updateCellsBatchingPeriod={50}
|
||||||
|
windowSize={7}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
BackHandler,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
|
@ -109,49 +110,33 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item:
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
<View>
|
||||||
{posterUrl ? (
|
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
||||||
<Image
|
{posterUrl ? (
|
||||||
source={{ uri: posterUrl }}
|
<Image
|
||||||
style={styles.poster}
|
source={{ uri: posterUrl }}
|
||||||
contentFit="cover"
|
style={styles.poster}
|
||||||
cachePolicy="disk"
|
contentFit="cover"
|
||||||
transition={0}
|
cachePolicy="disk"
|
||||||
allowDownscaling
|
transition={0}
|
||||||
/>
|
allowDownscaling
|
||||||
) : (
|
/>
|
||||||
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}>
|
) : (
|
||||||
<ActivityIndicator color={currentTheme.colors.primary} />
|
<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>
|
</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>
|
</View>
|
||||||
|
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
@ -255,14 +240,35 @@ const LibraryScreen = () => {
|
||||||
StatusBar.setBackgroundColor('transparent');
|
StatusBar.setBackgroundColor('transparent');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
applyStatusBarConfig();
|
applyStatusBarConfig();
|
||||||
|
|
||||||
// Re-apply on focus
|
// Re-apply on focus
|
||||||
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
|
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [navigation]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const loadLibrary = async () => {
|
const loadLibrary = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -306,7 +312,7 @@ const LibraryScreen = () => {
|
||||||
icon: 'visibility',
|
icon: 'visibility',
|
||||||
description: 'Your watched content',
|
description: 'Your watched content',
|
||||||
itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0),
|
itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0),
|
||||||
gradient: ['#4CAF50', '#2E7D32']
|
gradient: ['#2C3E50', '#34495E']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'continue-watching',
|
id: 'continue-watching',
|
||||||
|
|
@ -314,7 +320,7 @@ const LibraryScreen = () => {
|
||||||
icon: 'play-circle-outline',
|
icon: 'play-circle-outline',
|
||||||
description: 'Resume your progress',
|
description: 'Resume your progress',
|
||||||
itemCount: continueWatching?.length || 0,
|
itemCount: continueWatching?.length || 0,
|
||||||
gradient: ['#FF9800', '#F57C00']
|
gradient: ['#2980B9', '#3498DB']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'watchlist',
|
id: 'watchlist',
|
||||||
|
|
@ -322,7 +328,7 @@ const LibraryScreen = () => {
|
||||||
icon: 'bookmark',
|
icon: 'bookmark',
|
||||||
description: 'Want to watch',
|
description: 'Want to watch',
|
||||||
itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0),
|
itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0),
|
||||||
gradient: ['#2196F3', '#1976D2']
|
gradient: ['#6C3483', '#9B59B6']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'collection',
|
id: 'collection',
|
||||||
|
|
@ -330,7 +336,7 @@ const LibraryScreen = () => {
|
||||||
icon: 'library-add',
|
icon: 'library-add',
|
||||||
description: 'Your collection',
|
description: 'Your collection',
|
||||||
itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0),
|
itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0),
|
||||||
gradient: ['#9C27B0', '#7B1FA2']
|
gradient: ['#1B2631', '#283747']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ratings',
|
id: 'ratings',
|
||||||
|
|
@ -338,7 +344,7 @@ const LibraryScreen = () => {
|
||||||
icon: 'star',
|
icon: 'star',
|
||||||
description: 'Your ratings',
|
description: 'Your ratings',
|
||||||
itemCount: ratedContent?.length || 0,
|
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 })}
|
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
<View>
|
||||||
<Image
|
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
||||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
<Image
|
||||||
style={styles.poster}
|
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||||
contentFit="cover"
|
style={styles.poster}
|
||||||
transition={300}
|
contentFit="cover"
|
||||||
/>
|
transition={300}
|
||||||
<LinearGradient
|
/>
|
||||||
colors={['transparent', 'rgba(0,0,0,0.85)']}
|
|
||||||
style={styles.posterGradient}
|
{item.progress !== undefined && item.progress < 1 && (
|
||||||
>
|
<View style={styles.progressBarContainer}>
|
||||||
<Text
|
<View
|
||||||
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
|
style={[
|
||||||
numberOfLines={2}
|
styles.progressBar,
|
||||||
>
|
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
|
||||||
{item.name}
|
]}
|
||||||
</Text>
|
/>
|
||||||
{item.lastWatched && (
|
</View>
|
||||||
<Text style={styles.lastWatched}>
|
|
||||||
{item.lastWatched}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</LinearGradient>
|
{item.type === 'series' && (
|
||||||
|
<View style={styles.badgeContainer}>
|
||||||
{item.progress !== undefined && item.progress < 1 && (
|
<MaterialIcons
|
||||||
<View style={styles.progressBarContainer}>
|
name="live-tv"
|
||||||
<View
|
size={14}
|
||||||
style={[
|
color={currentTheme.colors.white}
|
||||||
styles.progressBar,
|
style={{ marginRight: 4 }}
|
||||||
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
|
/>
|
||||||
]}
|
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text>
|
||||||
/>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</View>
|
||||||
{item.type === 'series' && (
|
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
|
||||||
<View style={styles.badgeContainer}>
|
{item.name}
|
||||||
<MaterialIcons
|
</Text>
|
||||||
name="live-tv"
|
|
||||||
size={14}
|
|
||||||
color={currentTheme.colors.white}
|
|
||||||
style={{ marginRight: 4 }}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
@ -450,34 +445,31 @@ const LibraryScreen = () => {
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
|
<View>
|
||||||
<LinearGradient
|
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
|
||||||
colors={['#E8254B', '#C41E3A']}
|
<LinearGradient
|
||||||
style={styles.folderGradient}
|
colors={['#666666', '#444444']}
|
||||||
>
|
style={styles.folderGradient}
|
||||||
<TraktIcon width={60} height={60} style={{ marginBottom: 12 }} />
|
>
|
||||||
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
|
<TraktIcon width={60} height={60} style={{ marginBottom: 12 }} />
|
||||||
Trakt Collection
|
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
|
||||||
</Text>
|
Trakt
|
||||||
{traktAuthenticated && traktFolders.length > 0 && (
|
|
||||||
<Text style={styles.folderCount}>
|
|
||||||
{traktFolders.length} items
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{traktAuthenticated && traktFolders.length > 0 && (
|
||||||
{!traktAuthenticated && (
|
<Text style={styles.folderCount}>
|
||||||
<Text style={styles.folderSubtitle}>
|
{traktFolders.length} items
|
||||||
Tap to connect
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
)}
|
{!traktAuthenticated && (
|
||||||
</LinearGradient>
|
<Text style={styles.folderSubtitle}>
|
||||||
|
Tap to connect
|
||||||
{/* Trakt badge */}
|
</Text>
|
||||||
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(255,255,255,0.2)' }]}>
|
)}
|
||||||
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
|
</LinearGradient>
|
||||||
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
|
|
||||||
Trakt
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
|
||||||
|
Trakt collections
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
@ -921,7 +913,7 @@ const LibraryScreen = () => {
|
||||||
<>
|
<>
|
||||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
|
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.calendarButton, { backgroundColor: currentTheme.colors.primary }]}
|
style={styles.calendarButton}
|
||||||
onPress={() => navigation.navigate('Calendar')}
|
onPress={() => navigation.navigate('Calendar')}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|
@ -1096,6 +1088,13 @@ const styles = StyleSheet.create({
|
||||||
textShadowRadius: 2,
|
textShadowRadius: 2,
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
lastWatched: {
|
lastWatched: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'rgba(255,255,255,0.7)',
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
|
@ -1246,13 +1245,8 @@ const styles = StyleSheet.create({
|
||||||
calendarButton: {
|
calendarButton: {
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
borderRadius: 22,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
elevation: 3,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.2,
|
|
||||||
shadowRadius: 4,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
|
@ -20,6 +19,10 @@ import Animated, {
|
||||||
withTiming,
|
withTiming,
|
||||||
FadeInDown,
|
FadeInDown,
|
||||||
FadeInUp,
|
FadeInUp,
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
runOnJS,
|
||||||
|
interpolateColor,
|
||||||
|
interpolate,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||||
|
|
@ -71,6 +74,14 @@ const onboardingData: OnboardingSlide[] = [
|
||||||
icon: 'library-books',
|
icon: 'library-books',
|
||||||
gradient: ['#43e97b', '#38f9d7'],
|
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 = () => {
|
const OnboardingScreen = () => {
|
||||||
|
|
@ -80,6 +91,30 @@ const OnboardingScreen = () => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const progressValue = useSharedValue(0);
|
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(() => ({
|
const animatedProgressStyle = useAnimatedStyle(() => ({
|
||||||
width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`),
|
width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`),
|
||||||
|
|
@ -149,13 +184,13 @@ const OnboardingScreen = () => {
|
||||||
entering={FadeInUp.delay(500).duration(800)}
|
entering={FadeInUp.delay(500).duration(800)}
|
||||||
style={styles.textContainer}
|
style={styles.textContainer}
|
||||||
>
|
>
|
||||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.title, { color: 'white' }]}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.subtitle, { color: currentTheme.colors.primary }]}>
|
<Text style={[styles.subtitle, { color: 'rgba(255,255,255,0.9)' }]}>
|
||||||
{item.subtitle}
|
{item.subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.description, { color: 'rgba(255,255,255,0.85)' }]}>
|
||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
@ -183,71 +218,106 @@ const OnboardingScreen = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar barStyle="light-content" backgroundColor={currentTheme.colors.darkBackground} />
|
{/* Layered animated gradient backgrounds */}
|
||||||
|
{onboardingData.map((slide, index) => (
|
||||||
{/* Header */}
|
<Animated.View key={`bg-${index}`} style={[styles.backgroundPanel, getAnimatedBackgroundStyle(index)]}>
|
||||||
<View style={styles.header}>
|
<LinearGradient
|
||||||
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
|
colors={[slide.gradient[0], slide.gradient[1]]}
|
||||||
<Text style={[styles.skipText, { color: currentTheme.colors.mediumEmphasis }]}>
|
start={{ x: 0, y: 0 }}
|
||||||
Skip
|
end={{ x: 1, y: 1 }}
|
||||||
</Text>
|
style={StyleSheet.absoluteFill}
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.progressBar,
|
|
||||||
{ backgroundColor: currentTheme.colors.primary },
|
|
||||||
animatedProgressStyle
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</Animated.View>
|
||||||
</View>
|
))}
|
||||||
|
<LinearGradient
|
||||||
{/* Content */}
|
colors={["rgba(0,0,0,0.2)", "rgba(0,0,0,0.45)"]}
|
||||||
<FlatList
|
start={{ x: 0, y: 0 }}
|
||||||
ref={flatListRef}
|
end={{ x: 0, y: 1 }}
|
||||||
data={onboardingData}
|
style={styles.overlayPanel}
|
||||||
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 }}
|
|
||||||
/>
|
/>
|
||||||
|
{/* 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 */}
|
{/* Content container with status bar padding */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.fullScreenContainer}>
|
||||||
{renderPagination()}
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
<View style={styles.buttonContainer}>
|
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
|
||||||
<TouchableOpacity
|
<Text style={[styles.skipText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
style={[
|
Skip
|
||||||
styles.button,
|
|
||||||
styles.nextButton,
|
|
||||||
{ backgroundColor: currentTheme.colors.primary }
|
|
||||||
]}
|
|
||||||
onPress={handleNext}
|
|
||||||
>
|
|
||||||
<Text style={[styles.buttonText, { color: 'white' }]}>
|
|
||||||
{currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Next'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<MaterialIcons
|
|
||||||
name={currentIndex === onboardingData.length - 1 ? 'check' : 'arrow-forward'}
|
|
||||||
size={20}
|
|
||||||
color="white"
|
|
||||||
style={styles.buttonIcon}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</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>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -260,7 +330,7 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: Platform.OS === 'ios' ? 10 : 20,
|
paddingTop: 10,
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
skipButton: {
|
skipButton: {
|
||||||
|
|
@ -288,6 +358,46 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 40,
|
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: {
|
iconContainer: {
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 160,
|
height: 160,
|
||||||
|
|
|
||||||
|
|
@ -606,7 +606,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Version"
|
title="Version"
|
||||||
description="0.6.0-beta.11"
|
description="1.0.0"
|
||||||
icon="info-outline"
|
icon="info-outline"
|
||||||
isLast={true}
|
isLast={true}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue