parallax added

This commit is contained in:
tapframe 2025-11-08 14:14:33 +05:30
parent aa0c338c05
commit 426e936740
3 changed files with 92 additions and 15 deletions

View file

@ -27,6 +27,8 @@ import Animated, {
runOnJS, runOnJS,
interpolate, interpolate,
Extrapolation, Extrapolation,
useAnimatedScrollHandler,
SharedValue,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { StreamingContent } from '../../services/catalogService'; import { StreamingContent } from '../../services/catalogService';
@ -43,6 +45,7 @@ interface AppleTVHeroProps {
allFeaturedContent?: StreamingContent[]; allFeaturedContent?: StreamingContent[];
loading?: boolean; loading?: boolean;
onRetry?: () => void; onRetry?: () => void;
scrollY?: SharedValue<number>; // Optional scroll position for parallax
} }
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
@ -50,8 +53,8 @@ const { width, height } = Dimensions.get('window');
// Get status bar height // Get status bar height
const STATUS_BAR_HEIGHT = StatusBar.currentHeight || 0; const STATUS_BAR_HEIGHT = StatusBar.currentHeight || 0;
// Calculate hero height - 65% of screen height // Calculate hero height - 85% of screen height
const HERO_HEIGHT = height * 0.75; const HERO_HEIGHT = height * 0.85;
// Animated Pagination Dot Component // Animated Pagination Dot Component
const PaginationDot: React.FC<{ isActive: boolean; onPress: () => void }> = React.memo( const PaginationDot: React.FC<{ isActive: boolean; onPress: () => void }> = React.memo(
@ -86,6 +89,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
allFeaturedContent, allFeaturedContent,
loading, loading,
onRetry, onRetry,
scrollY: externalScrollY,
}) => { }) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isFocused = useIsFocused(); const isFocused = useIsFocused();
@ -94,6 +98,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer(); const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
// Create internal scrollY if not provided externally
const internalScrollY = useSharedValue(0);
const scrollY = externalScrollY || internalScrollY;
// Determine items to display // Determine items to display
const items = useMemo(() => { const items = useMemo(() => {
if (allFeaturedContent && allFeaturedContent.length > 0) { if (allFeaturedContent && allFeaturedContent.length > 0) {
@ -160,6 +168,62 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}; };
}); });
// Parallax style for background images
const backgroundParallaxStyle = useAnimatedStyle(() => {
'worklet';
const scrollYValue = scrollY.value;
// Pre-calculated constants - start at 1.0 for normal size
const DEFAULT_ZOOM = 1.0;
const SCROLL_UP_MULTIPLIER = 0.002;
const SCROLL_DOWN_MULTIPLIER = 0.0001;
const MAX_SCALE = 1.3;
const PARALLAX_FACTOR = 0.3;
// Optimized scale calculation with minimal branching
const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER;
const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER;
const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE);
// Single parallax calculation
const parallaxOffset = scrollYValue * PARALLAX_FACTOR;
return {
transform: [
{ scale },
{ translateY: parallaxOffset }
],
};
});
// Parallax style for trailer
const trailerParallaxStyle = useAnimatedStyle(() => {
'worklet';
const scrollYValue = scrollY.value;
// Pre-calculated constants - start at 1.0 for normal size
const DEFAULT_ZOOM = 1.0;
const SCROLL_UP_MULTIPLIER = 0.0015;
const SCROLL_DOWN_MULTIPLIER = 0.0001;
const MAX_SCALE = 1.2;
const PARALLAX_FACTOR = 0.2; // Slower than background for depth
// Optimized scale calculation with minimal branching
const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER;
const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER;
const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE);
// Single parallax calculation
const parallaxOffset = scrollYValue * PARALLAX_FACTOR;
return {
transform: [
{ scale },
{ translateY: parallaxOffset }
],
};
});
// Reset loaded states when items change // Reset loaded states when items change
useEffect(() => { useEffect(() => {
setBannerLoaded({}); setBannerLoaded({});
@ -585,7 +649,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
{/* Background Images with Crossfade */} {/* Background Images with Crossfade */}
<View style={styles.backgroundContainer}> <View style={styles.backgroundContainer}>
{/* Current Image - Always visible as base */} {/* Current Image - Always visible as base */}
<View style={styles.imageWrapper}> <Animated.View style={[styles.imageWrapper, backgroundParallaxStyle]}>
<FastImage <FastImage
source={{ source={{
uri: bannerUrl, uri: bannerUrl,
@ -596,11 +660,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
onLoad={() => setBannerLoaded((prev) => ({ ...prev, [currentIndex]: true }))} onLoad={() => setBannerLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
/> />
</View> </Animated.View>
{/* Next/Preview Image - Animated overlay during drag */} {/* Next/Preview Image - Animated overlay during drag */}
{nextIndex !== currentIndex && ( {nextIndex !== currentIndex && (
<Animated.View style={[styles.imageWrapperAbsolute, nextImageStyle]}> <Animated.View style={[styles.imageWrapperAbsolute, nextImageStyle, backgroundParallaxStyle]}>
<FastImage <FastImage
source={{ source={{
uri: nextBannerUrl, uri: nextBannerUrl,
@ -634,7 +698,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
{/* Visible trailer player - 60% height with 5% zoom and smooth fade */} {/* Visible trailer player - 60% height with 5% zoom and smooth fade */}
{settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && ( {settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && (
<Animated.View style={trailerContainerStyle}> <Animated.View style={[trailerContainerStyle, trailerParallaxStyle]}>
<Animated.View style={trailerVideoStyle}> <Animated.View style={trailerVideoStyle}>
<TrailerPlayer <TrailerPlayer
key={`visible-${trailerUrl}`} key={`visible-${trailerUrl}`}
@ -692,7 +756,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
{settings?.showTrailers && trailerReady && trailerUrl && ( {settings?.showTrailers && trailerReady && trailerUrl && (
<Animated.View style={{ <Animated.View style={{
position: 'absolute', position: 'absolute',
top: Platform.OS === 'android' ? 60 : 70, top: (Platform.OS === 'android' ? 60 : 70) + insets.top,
right: 24, right: 24,
zIndex: 1000, zIndex: 1000,
opacity: trailerOpacity, opacity: trailerOpacity,

View file

@ -100,7 +100,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
preferredPlayer: 'internal', preferredPlayer: 'internal',
showHeroSection: true, showHeroSection: true,
featuredContentSource: 'catalogs', featuredContentSource: 'catalogs',
heroStyle: 'carousel', heroStyle: 'appletv',
selectedHeroCatalogs: [], // Empty array means all catalogs are selected selectedHeroCatalogs: [], // Empty array means all catalogs are selected
logoSourcePreference: 'tmdb', // Default to TMDB as first source logoSourcePreference: 'tmdb', // Default to TMDB as first source
tmdbLanguagePreference: 'en', // Default to English tmdbLanguagePreference: 'en', // Default to English

View file

@ -30,7 +30,7 @@ import { Stream } from '../types/metadata';
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 FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import Animated, { FadeIn, Layout } from 'react-native-reanimated'; import Animated, { FadeIn, Layout, useSharedValue, useAnimatedScrollHandler } from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler'; import { PanGestureHandler } from 'react-native-gesture-handler';
import { import {
Gesture, Gesture,
@ -126,6 +126,9 @@ const HomeScreen = () => {
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false); const [hasContinueWatching, setHasContinueWatching] = useState(false);
// Shared value for scroll position (for parallax effects)
const scrollY = useSharedValue(0);
const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]); const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]);
const [catalogsLoading, setCatalogsLoading] = useState(true); const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
@ -642,6 +645,7 @@ const HomeScreen = () => {
featuredContent={featuredContent || null} featuredContent={featuredContent || null}
allFeaturedContent={allFeaturedContent || []} allFeaturedContent={allFeaturedContent || []}
loading={featuredLoading} loading={featuredLoading}
scrollY={scrollY}
/> />
); );
} else if (heroStyleToUse === 'carousel') { } else if (heroStyleToUse === 'carousel') {
@ -786,11 +790,14 @@ const HomeScreen = () => {
} }
// Capture scroll values immediately before async operation // Capture scroll values immediately before async operation
const scrollY = event.nativeEvent.contentOffset.y; const scrollYValue = event.nativeEvent.contentOffset.y;
// Update shared value for parallax (on UI thread)
scrollY.value = scrollYValue;
// Use requestAnimationFrame to throttle scroll handling // Use requestAnimationFrame to throttle scroll handling
scrollAnimationFrameRef.current = requestAnimationFrame(() => { scrollAnimationFrameRef.current = requestAnimationFrame(() => {
const y = scrollY; const y = scrollYValue;
const dy = y - lastScrollYRef.current; const dy = y - lastScrollYRef.current;
lastScrollYRef.current = y; lastScrollYRef.current = y;
@ -812,10 +819,16 @@ const HomeScreen = () => {
}, [toggleHeader]); }, [toggleHeader]);
// Memoize content container style - use stable insets to prevent iOS shifting // Memoize content container style - use stable insets to prevent iOS shifting
const contentContainerStyle = useMemo(() => // Don't add paddingTop when using AppleTVHero as it handles its own top spacing
StyleSheet.flatten([styles.scrollContent, { paddingTop: stableInsetsTop }]), const contentContainerStyle = useMemo(() => {
[stableInsetsTop] const heroStyleToUse = settings.heroStyle;
); const isUsingAppleTVHero = heroStyleToUse === 'appletv' && !isTablet && showHeroSection;
return StyleSheet.flatten([
styles.scrollContent,
{ paddingTop: isUsingAppleTVHero ? 0 : stableInsetsTop }
]);
}, [stableInsetsTop, settings.heroStyle, isTablet, showHeroSection]);
// Memoize the main content section // Memoize the main content section
const renderMainContent = useMemo(() => { const renderMainContent = useMemo(() => {