Files
musicland/src/components/FeatureCarousel/FeatureCarousel.web.js
T
2026-09-02 16:08:48 +02:00

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