home carousel

This commit is contained in:
2025-10-10 15:34:53 +02:00
parent 48773659fd
commit 865644914c
5 changed files with 542 additions and 2 deletions
@@ -14,7 +14,9 @@ import {
View, View,
useWindowDimensions, useWindowDimensions,
} from "react-native"; } from "react-native";
import { responsiveHeight } from "../../actions/responsiveSizes";
import { ai } from "../../assets"; import { ai } from "../../assets";
import { gutters } from "../../styles";
import { getCreationStageStates } from "../../utils/projectStages"; import { getCreationStageStates } from "../../utils/projectStages";
import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot";
import PersonaCard from "../cards/PersonaCard/PersonaCard"; import PersonaCard from "../cards/PersonaCard/PersonaCard";
@@ -422,6 +424,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
width: "100%", width: "100%",
flexDirection: "row", flexDirection: "row",
paddingBottom: responsiveHeight(5),
paddingHorizontal: gutters,
}, },
containerWeb: { containerWeb: {
paddingRight: 56, paddingRight: 56,
@@ -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: "Lets 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 = (
<View style={styles.cardWrapper}>
<PersonaCard
item={item}
index={index}
isLock={item.isLocked}
height={itemHeight}
/>
</View>
);
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 (
<View
key={item.key}
style={[...containerStyle, styles.parallaxContainer]}
>
<AnimatedImage
source={backgroundImage}
style={parallaxStyles}
resizeMode="cover"
/>
<LinearGradient
colors={[
"rgba(66, 91, 135, 0)",
"rgba(66, 91, 135, 0.4)",
HOME_BACKGROUND_COLOR,
]}
locations={[0, 0.75, 1]}
style={styles.backgroundGradient}
pointerEvents="none"
/>
{card}
</View>
);
}
return (
<View key={item.key} style={[containerStyle, styles.plainBackground]}>
{card}
</View>
);
}),
[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 (
<View
style={[styles.container, isWeb && styles.containerWeb, style]}
onLayout={handleLayout}
>
<Animated.ScrollView
ref={scrollViewRef}
showsVerticalScrollIndicator={false}
pagingEnabled={!isWeb}
bounces={false}
overScrollMode="never"
scrollEventThrottle={16}
snapToAlignment="start"
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
snapToOffsets={snapOffsets}
decelerationRate={!isWeb ? "fast" : "normal"}
style={styles.list}
contentContainerStyle={styles.scrollContent}
onScroll={animatedScrollHandler}
onMomentumScrollEnd={handleScrollEnd}
onScrollEndDrag={isWeb ? handleScrollEnd : undefined}
>
{renderContent}
</Animated.ScrollView>
<BlurView
intensity={blurIntensity}
tint={Platform.OS === "web" ? undefined : "dark"}
style={dotsWrapperStyle}
pointerEvents="none"
>
<AnimatedPaginationDot
data={carouselItems}
scrollValue={scrollY}
itemDimension={itemHeight}
orientation={AnimatedPaginationDot.orientation.VERTICAL}
expandingDotSize={24}
inactiveDotOpacity={0.3}
containerStyle={styles.dotsContainer}
dotStyle={styles.dot}
baseDotSize={8}
/>
</BlurView>
</View>
);
};
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)",
},
});
@@ -18,6 +18,7 @@ export default function PersonaCard({ item, index, isLock = false, height }) {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
height: cardHeight, height: cardHeight,
width: "100%",
}} }}
> >
<View <View
+1 -1
View File
@@ -75,7 +75,7 @@ export default ({
style={{ style={{
height: "100%", height: "100%",
paddingTop: isWeb ? gutters / 2 : 0, paddingTop: isWeb ? gutters / 2 : 0,
padding: gutters, // padding: gutters,
paddingBottom: 0, paddingBottom: 0,
...(isWeb ? { maxHeight: "100svh" } : {}), ...(isWeb ? { maxHeight: "100svh" } : {}),
width: computedWidth, width: computedWidth,
+7 -1
View File
@@ -249,7 +249,11 @@ const Home = () => {
const continueDisabled = !hasActiveProject || stageLocked || !stageRoute; const continueDisabled = !hasActiveProject || stageLocked || !stageRoute;
const startDisabled = !songwriterAction?.route; const startDisabled = !songwriterAction?.route;
return ( return (
<Page shareBtn backgroundImg={background.homeBGWeb} headerType="NONE"> <Page
shareBtn
headerType="NONE"
containerStyle={{ width: "100%", maxWidth: "100%" }}
>
<View style={styles.root}> <View style={styles.root}>
<View <View
style={{ style={{
@@ -291,6 +295,7 @@ const Home = () => {
stageStates={stageStates} stageStates={stageStates}
activeIndex={activeStageIndex} activeIndex={activeStageIndex}
onActiveIndexChange={setActiveStageIndex} onActiveIndexChange={setActiveStageIndex}
backgroundImage={background.homeBGWeb}
/> />
</View> </View>
<View <View
@@ -342,6 +347,7 @@ export default Home;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { root: {
flex: 1, flex: 1,
backgroundColor: "#425B87",
}, },
heroImage: { heroImage: {
position: "absolute", position: "absolute",