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 } 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, }, ] 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)', }, })