From 865644914caac56df31beb9f7a454943410ac73e Mon Sep 17 00:00:00 2001 From: leon-morival Date: Fri, 10 Oct 2025 15:34:53 +0200 Subject: [PATCH] home carousel --- .../FeatureCarousel/FeatureCarousel.js | 4 + .../FeatureCarousel/FeatureCarousel.web.js | 529 ++++++++++++++++++ .../cards/PersonaCard/PersonaCard.js | 1 + src/layouts/Page.js | 2 +- src/screens/Home/Home.js | 8 +- 5 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 src/components/FeatureCarousel/FeatureCarousel.web.js diff --git a/src/components/FeatureCarousel/FeatureCarousel.js b/src/components/FeatureCarousel/FeatureCarousel.js index e9579be..10c13e6 100644 --- a/src/components/FeatureCarousel/FeatureCarousel.js +++ b/src/components/FeatureCarousel/FeatureCarousel.js @@ -14,7 +14,9 @@ import { View, useWindowDimensions, } from "react-native"; +import { responsiveHeight } from "../../actions/responsiveSizes"; import { ai } from "../../assets"; +import { gutters } from "../../styles"; import { getCreationStageStates } from "../../utils/projectStages"; import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; import PersonaCard from "../cards/PersonaCard/PersonaCard"; @@ -422,6 +424,8 @@ const styles = StyleSheet.create({ flex: 1, width: "100%", flexDirection: "row", + paddingBottom: responsiveHeight(5), + paddingHorizontal: gutters, }, containerWeb: { paddingRight: 56, diff --git a/src/components/FeatureCarousel/FeatureCarousel.web.js b/src/components/FeatureCarousel/FeatureCarousel.web.js new file mode 100644 index 0000000..688eb85 --- /dev/null +++ b/src/components/FeatureCarousel/FeatureCarousel.web.js @@ -0,0 +1,529 @@ +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: "100%", + justifyContent: "flex-start", + 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)", + }, +}); diff --git a/src/components/cards/PersonaCard/PersonaCard.js b/src/components/cards/PersonaCard/PersonaCard.js index 149e955..33a9e81 100644 --- a/src/components/cards/PersonaCard/PersonaCard.js +++ b/src/components/cards/PersonaCard/PersonaCard.js @@ -18,6 +18,7 @@ export default function PersonaCard({ item, index, isLock = false, height }) { justifyContent: "center", alignItems: "center", height: cardHeight, + width: "100%", }} > { const continueDisabled = !hasActiveProject || stageLocked || !stageRoute; const startDisabled = !songwriterAction?.route; return ( - + { stageStates={stageStates} activeIndex={activeStageIndex} onActiveIndexChange={setActiveStageIndex} + backgroundImage={background.homeBGWeb} />