Files
musicland/src/components/FeatureCarousel/FeatureCarousel.web.js
T
Thomas Demirdjian 5457030d4a more tickets
2025-11-13 18:31:38 +01:00

529 lines
13 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,
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: "Lets 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) => (
<View
key={item.key}
style={[
styles.slide,
hasBackgroundImage ? styles.slideTransparent : styles.slideColored,
itemHeight ? { height: itemHeight } : { minHeight: 1 },
]}
>
<View style={styles.cardWrapper}>
<PersonaCard
item={item}
index={index}
isLock={item.isLocked}
height={itemHeight}
/>
</View>
</View>
)),
[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 (
<ContainerComponent
{...containerProps}
style={[
styles.container,
isWeb && styles.containerWeb,
hasBackgroundImage ? styles.containerTransparent : styles.containerColored,
style,
]}
onLayout={handleLayout}
>
{hasBackgroundImage && (
<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"
/>
)}
<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>
</ContainerComponent>
);
};
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)",
},
});