import { BlurView } from "expo-blur"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Animated, ImageBackground, Platform, StyleSheet, View, useWindowDimensions, } from "react-native"; import { ai, cardsImg } 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: "Céline", 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: "director", title: "Theo", description: "Theo t'accompagne pour créer ton playback.", image: ai.rightIcon, }, { key: "publisher", title: "Publication", description: "Ta vidéo est prête ? Direction YouTube !", image: cardsImg.production, }, ]; const WEB_SCROLL_INACTIVE_DELTA = 0.05; const HOME_BACKGROUND_COLOR = "#425B87"; // Derived from the bottom color of the home hero image const FeatureCarousel = ({ style, selectedProject, stageStates: stageStatesProp, activeIndex, onActiveIndexChange, backgroundImage, isFocused, }) => { 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; 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]); useEffect(() => { if (!isWeb || !isFocused) { return; } if (!itemHeight) { return; } clearPendingAlignment(); scrollToIndex(activeIndexRef.current, false); }, [ clearPendingAlignment, isFocused, 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 hasBackgroundImage = !!backgroundImage; const renderContent = useMemo( () => carouselItems.map((item, index) => ( )), [carouselItems, hasBackgroundImage, itemHeight] ); 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] ); const containerProps = hasBackgroundImage ? { source: backgroundImage, resizeMode: "cover", imageStyle: styles.backgroundImage, } : {}; const ContainerComponent = hasBackgroundImage ? ImageBackground : View; return ( {hasBackgroundImage && ( )} {renderContent} ); }; export default FeatureCarousel; const styles = StyleSheet.create({ container: { flex: 1, width: "100%", flexDirection: "row", }, containerWeb: { // paddingRight: 56, }, containerColored: { backgroundColor: HOME_BACKGROUND_COLOR, }, containerTransparent: { backgroundColor: "transparent", }, list: { flex: 1, }, scrollContent: { flexGrow: 1, }, backgroundImage: { ...StyleSheet.absoluteFillObject, }, backgroundGradient: { ...StyleSheet.absoluteFillObject, }, slide: { width: "100%", justifyContent: "flex-start", }, slideColored: { backgroundColor: HOME_BACKGROUND_COLOR, }, slideTransparent: { backgroundColor: "transparent", }, 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)", }, });