493 lines
13 KiB
JavaScript
493 lines
13 KiB
JavaScript
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: 'Let’s 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)',
|
||
},
|
||
})
|