import { BlurView } from "expo-blur"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Animated, Image, Platform, StyleSheet, View, useWindowDimensions, } from "react-native"; import { ai } from "../../assets"; import { getCreationStageStates } from "../../utils/projectStages"; import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; import PersonaCard from "../cards/PersonaCard/PersonaCard"; import { LinearGradient } from "../LinearGradient/LinearGradient"; const STAGE_CARD_CONTENT = [ { key: "songwriter", title: "Nathalie", description: "Let’s write lyrics together !", image: ai.leftIcon, }, { key: "beatmaker", title: "Theo", description: "Come back, when you'll have lyrics!", image: ai.rightIcon, }, { key: "designer", title: "Bena", description: "Come back when you want to generate cover!", image: ai.leftIcon, }, { key: "director", title: "John", description: "Come back when your audio is ready!", image: ai.rightIcon, }, ]; const WEB_SCROLL_INACTIVE_DELTA = 0.05; const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image const AnimatedImage = Animated.createAnimatedComponent(Image); const FeatureCarousel = ({ style, selectedProject, stageStates: stageStatesProp, activeIndex, onActiveIndexChange, backgroundImage, }) => { const stageStates = useMemo(() => { if (stageStatesProp) { return stageStatesProp; } return getCreationStageStates(selectedProject); }, [stageStatesProp, selectedProject]); const stageStatesByKey = useMemo(() => { if (!Array.isArray(stageStates)) { return {}; } return stageStates.reduce((acc, stage) => { if (stage?.key) { acc[stage.key] = stage; } return acc; }, {}); }, [stageStates]); const carouselItems = useMemo( () => STAGE_CARD_CONTENT.map((item) => { const state = stageStatesByKey[item.key]; return { ...item, isLocked: state?.isLocked ?? true, description: state?.description ?? item.description, }; }), [stageStatesByKey] ); const { height: windowHeight } = useWindowDimensions(); const isWeb = Platform.OS === "web"; const [viewportHeight, setViewportHeight] = useState(() => Math.max(windowHeight, 1) ); const updateSnapHeight = useCallback((height) => { if (!height || Number.isNaN(height)) { return; } setViewportHeight((prev) => { if (prev == null || Math.abs(prev - height) > 0.5) { return height; } return prev; }); }, []); useEffect(() => { updateSnapHeight(Math.max(windowHeight, 1)); }, [updateSnapHeight, windowHeight]); const itemHeight = Math.max(viewportHeight, 1); const scrollViewRef = useRef(null); const alignTimeoutRef = useRef(null); const activeIndexRef = useRef( typeof activeIndex === "number" ? activeIndex : 0 ); const onActiveIndexChangeRef = useRef(onActiveIndexChange); const scrollY = useRef(new Animated.Value(0)).current; const parallaxTranslateY = useMemo(() => { if (!itemHeight) { return null; } return scrollY.interpolate({ inputRange: [0, itemHeight], outputRange: [0, -itemHeight * 0.25], extrapolate: "clamp", }); }, [itemHeight, scrollY]); useEffect(() => { onActiveIndexChangeRef.current = onActiveIndexChange; }, [onActiveIndexChange]); useEffect(() => { if (typeof activeIndex === "number") { activeIndexRef.current = activeIndex; } }, [activeIndex]); const clampIndex = useCallback( (index) => { if (!carouselItems.length) { return 0; } if (index < 0) { return 0; } if (index >= carouselItems.length) { return carouselItems.length - 1; } return index; }, [carouselItems.length] ); const clearPendingAlignment = useCallback(() => { if (alignTimeoutRef.current != null) { globalThis.clearTimeout(alignTimeoutRef.current); alignTimeoutRef.current = null; } }, []); useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]); const getScrollNode = useCallback(() => { const node = scrollViewRef.current; if (!node) { return null; } if (typeof node.scrollTo === "function") { return node; } if (typeof node.getNode === "function") { return node.getNode(); } return null; }, []); const scrollToIndex = useCallback( (index, animated = true, heightOverride) => { const target = getScrollNode(); if (!target) { return; } const clamped = clampIndex(index); const height = heightOverride && heightOverride > 0 ? heightOverride : itemHeight; if (!height) { return; } updateSnapHeight(height); const offset = clamped * height; try { if (typeof target.scrollTo === "function") { target.scrollTo({ y: offset, animated }); } else if (typeof target.scrollToOffset === "function") { target.scrollToOffset({ offset, animated }); } } catch (_error) { // ScrollView not ready yet, ignore. } activeIndexRef.current = clamped; }, [clampIndex, getScrollNode, itemHeight, updateSnapHeight] ); useEffect(() => { if ( scrollViewRef.current == null || typeof activeIndex !== "number" || activeIndex < 0 || activeIndex >= carouselItems.length || (isWeb && !itemHeight) ) { return; } scrollToIndex(activeIndex); }, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]); useEffect(() => { if (scrollViewRef.current == null || (isWeb && !itemHeight)) { return; } scrollToIndex(activeIndexRef.current, false); }, [carouselItems.length, isWeb, itemHeight, scrollToIndex]); const alignToOffset = useCallback( (offset, layoutHeight, options = {}) => { const { forceSnap = false } = options || {}; const height = layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight; if (!height) { return; } updateSnapHeight(height); const currentIndex = activeIndexRef.current; const rawIndex = height ? offset / height : currentIndex; let nextIndex = currentIndex; if (isWeb) { const delta = rawIndex - currentIndex; if (Math.abs(delta) > WEB_SCROLL_INACTIVE_DELTA) { if (Math.abs(delta) <= 1) { nextIndex = clampIndex(currentIndex + (delta > 0 ? 1 : -1)); } else { nextIndex = clampIndex(currentIndex + Math.round(delta)); } } } else { nextIndex = clampIndex(Math.round(rawIndex)); } const hasChanged = nextIndex !== activeIndexRef.current; if (hasChanged) { activeIndexRef.current = nextIndex; const callback = onActiveIndexChangeRef.current; if (callback) { callback(nextIndex); } } if (forceSnap || hasChanged) { scrollToIndex(nextIndex, true, height); } }, [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight] ); const handleScrollEnd = useCallback( (event) => { clearPendingAlignment(); const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; alignToOffset(offsetY, layoutHeight, { forceSnap: true }); }, [alignToOffset, clearPendingAlignment] ); const handleScroll = useCallback( (event) => { if (!isWeb) { return; } const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0; const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; clearPendingAlignment(); alignTimeoutRef.current = globalThis.setTimeout(() => { alignToOffset(offsetY, layoutHeight, { forceSnap: false }); alignTimeoutRef.current = null; }, 80); }, [alignToOffset, clearPendingAlignment, isWeb] ); const animatedScrollHandler = useMemo( () => Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: false, listener: isWeb ? handleScroll : undefined, }), [handleScroll, isWeb, scrollY] ); const handleLayout = useCallback( (event) => { const layoutHeight = event?.nativeEvent?.layout?.height ?? 0; if (layoutHeight > 0) { updateSnapHeight(layoutHeight); } }, [updateSnapHeight] ); const renderContent = useMemo( () => carouselItems.map((item, index) => { const card = ( ); const containerStyle = [styles.backgroundItem, { height: itemHeight }]; const isFirstItem = index === 0 && backgroundImage; if (isFirstItem) { const parallaxStyles = [ styles.parallaxImage, itemHeight ? { height: itemHeight * 1.2 } : null, itemHeight ? { top: -itemHeight * 0.1 } : null, { width: "100%" }, ].filter(Boolean); if (parallaxTranslateY) { parallaxStyles.push({ transform: [{ translateY: parallaxTranslateY }], }); } return ( {card} ); } return ( {card} ); }), [backgroundImage, carouselItems, itemHeight, parallaxTranslateY] ); const snapOffsets = useMemo(() => { if (!itemHeight || !isWeb) { return undefined; } return carouselItems.map((_, index) => index * itemHeight); }, [carouselItems, isWeb, itemHeight]); const blurIntensity = isWeb ? 80 : 30; const dotsWrapperStyle = useMemo( () => [ styles.dotsWrapperBase, isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative, ], [isWeb] ); return ( {renderContent} ); }; export default FeatureCarousel; const styles = StyleSheet.create({ container: { flex: 1, width: "100%", flexDirection: "row", backgroundColor: HOME_BACKGROUND_COLOR, }, containerWeb: { // paddingRight: 56, }, list: { flex: 1, }, scrollContent: { flexGrow: 1, }, backgroundItem: { width: "100%", justifyContent: "flex-start", }, parallaxContainer: { overflow: "hidden", position: "relative", backgroundColor: HOME_BACKGROUND_COLOR, }, parallaxImage: { position: "absolute", left: 0, right: 0, }, backgroundGradient: { ...StyleSheet.absoluteFillObject, }, plainBackground: { backgroundColor: HOME_BACKGROUND_COLOR, }, cardWrapper: { flex: 1, width: "60%", justifyContent: "center", alignSelf: "center", zIndex: 1, }, dotsWrapperBase: { justifyContent: "center", alignItems: "center", borderRadius: 16, paddingVertical: 12, paddingHorizontal: 8, overflow: "hidden", backgroundColor: "rgba(18, 18, 18, 0.2)", }, dotsWrapperWeb: { position: "absolute", right: 12, top: 0, bottom: 0, maxHeight: "75%", alignSelf: "center", }, dotsWrapperNative: { marginLeft: 12, alignSelf: "center", }, dotsContainer: { flexDirection: "column", }, dot: { width: 8, height: 8, marginHorizontal: 0, marginVertical: 6, borderRadius: 999, backgroundColor: "rgba(255,255,255,0.4)", }, });