Files
musicland/src/components/FeatureCarousel/FeatureCarousel.web.js
T
2025-10-13 13:48:32 +02:00

531 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "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)",
},
});