401 lines
12 KiB
JavaScript
401 lines
12 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { FlatList, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
|
import Animated, { useAnimatedRef } from 'react-native-reanimated'
|
|
import Sortable from 'react-native-sortables'
|
|
import { useGlobal } from 'reactn'
|
|
import { icons } from '../../assets'
|
|
import { Palette } from '../../styles'
|
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
|
import {
|
|
arraysAreSame,
|
|
formatStructureLabel,
|
|
getSegmentMeta,
|
|
OPTIONAL_STRUCTURE_SEGMENTS,
|
|
sanitizeStructureList,
|
|
} from '../../utils/songStructure'
|
|
import ItemContainer from '../../components/ItemContainer/ItemContainer'
|
|
import CreateLyricsHeader from './components/CreateLyricsHeader'
|
|
|
|
const randomSuffix = () => Math.random().toString(36).slice(2, 8)
|
|
const createSortableItem = (type, index = 0) => ({
|
|
id: `${type}-${index}-${randomSuffix()}`,
|
|
type,
|
|
value: type,
|
|
})
|
|
const PRE_REFRAIN_TYPE = 'pre_refrain_instrumental'
|
|
const INTRO_TYPES = new Set(['short_intro', 'long_intro', 'intro', 'introduction'])
|
|
const PRE_REFRAIN_ERROR_MESSAGE =
|
|
'Impossible de positionner un pré-refrain ici. Place-le juste avant un refrain.'
|
|
const INTRO_ERROR_MESSAGE = "L'introduction musicale doit rester en première position."
|
|
const SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
|
|
|
const buildSortableItems = (values = []) =>
|
|
sanitizeStructureList(values, SANITIZE_OPTIONS).map((type, index) =>
|
|
createSortableItem(type, index)
|
|
)
|
|
|
|
const validateStructureRules = (values = []) => {
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
const type = values[index]
|
|
if (type === PRE_REFRAIN_TYPE) {
|
|
const nextType = values[index + 1]
|
|
if (nextType !== 'refrain') {
|
|
return {
|
|
valid: false,
|
|
reason: 'pre_refrain_invalid_position',
|
|
message: PRE_REFRAIN_ERROR_MESSAGE,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const introIndex = values.findIndex((type) => INTRO_TYPES.has(type))
|
|
if (introIndex > 0) {
|
|
return {
|
|
valid: false,
|
|
reason: 'intro_must_be_first',
|
|
message: INTRO_ERROR_MESSAGE,
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
|
|
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|
const scrollRef = useAnimatedRef()
|
|
const [, setTooltip] = useGlobal('_tooltip')
|
|
const sanitizedBase = useMemo(
|
|
() => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS),
|
|
[baseStructure]
|
|
)
|
|
const extractValues = useCallback(
|
|
(items) => (items || []).map((item) => item?.type || item?.value).filter(Boolean),
|
|
[]
|
|
)
|
|
const [sortableItems, setSortableItems] = useState(() => buildSortableItems(sanitizedBase))
|
|
const showTooltip = useCallback(
|
|
(text, type = 'error') => {
|
|
if (!text) return
|
|
setTooltip({ text, type })
|
|
},
|
|
[setTooltip]
|
|
)
|
|
|
|
useEffect(() => {
|
|
setSortableItems((prev) => {
|
|
const currentValues = extractValues(prev)
|
|
if (arraysAreSame(currentValues, sanitizedBase)) {
|
|
return prev
|
|
}
|
|
return buildSortableItems(sanitizedBase)
|
|
})
|
|
}, [sanitizedBase, extractValues])
|
|
useEffect(() => {
|
|
const values = extractValues(sortableItems)
|
|
onChange?.(values)
|
|
}, [sortableItems, onChange, extractValues])
|
|
|
|
const labeledItems = useMemo(() => {
|
|
const counts = {}
|
|
return sortableItems.map((item) => {
|
|
const baseType = item.type || item.value
|
|
const nextCount = (counts[baseType] || 0) + 1
|
|
counts[baseType] = nextCount
|
|
return {
|
|
...item,
|
|
type: baseType,
|
|
value: baseType,
|
|
label: formatStructureLabel(baseType, nextCount),
|
|
sortable: true,
|
|
}
|
|
})
|
|
}, [sortableItems])
|
|
|
|
const optionalSegments = useMemo(
|
|
() =>
|
|
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
|
|
const meta = getSegmentMeta(type)
|
|
return { type, label: meta?.label || formatStructureLabel(type, 1) }
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const segmentCounts = useMemo(() => {
|
|
const counts = {}
|
|
extractValues(sortableItems).forEach((type) => {
|
|
counts[type] = (counts[type] || 0) + 1
|
|
})
|
|
return counts
|
|
}, [sortableItems, extractValues])
|
|
|
|
const handleRemoveItem = useCallback(
|
|
(itemId) => {
|
|
setSortableItems((prev) => {
|
|
const remaining = prev.filter((item) => item.id !== itemId)
|
|
const sanitized = sanitizeStructureList(extractValues(remaining), SANITIZE_OPTIONS)
|
|
return buildSortableItems(sanitized)
|
|
})
|
|
},
|
|
[extractValues]
|
|
)
|
|
|
|
const handleOptionalSegmentPress = useCallback(
|
|
(type) => {
|
|
const meta = getSegmentMeta(type)
|
|
setSortableItems((prev) => {
|
|
const currentValues = extractValues(prev)
|
|
let nextValues = [...currentValues]
|
|
|
|
if (meta?.exclusiveGroup) {
|
|
nextValues = nextValues.filter((value) => {
|
|
const itemMeta = getSegmentMeta(value)
|
|
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup
|
|
})
|
|
}
|
|
|
|
const allowsMultiple = meta?.allowMultiple !== false
|
|
if (!allowsMultiple && nextValues.includes(type)) {
|
|
const filtered = nextValues.filter((value) => value !== type)
|
|
const sanitized = sanitizeStructureList(filtered, SANITIZE_OPTIONS)
|
|
return buildSortableItems(sanitized)
|
|
}
|
|
|
|
if (type === PRE_REFRAIN_TYPE) {
|
|
const firstRefrainIndex = nextValues.findIndex((value) => value === 'refrain')
|
|
if (firstRefrainIndex === -1) {
|
|
showTooltip(PRE_REFRAIN_ERROR_MESSAGE)
|
|
return prev
|
|
}
|
|
nextValues.splice(firstRefrainIndex, 0, type)
|
|
} else if (meta?.exclusiveGroup === 'intro') {
|
|
nextValues.unshift(type)
|
|
} else if (meta?.exclusiveGroup === 'outro') {
|
|
nextValues.push(type)
|
|
} else {
|
|
nextValues.push(type)
|
|
}
|
|
|
|
const validation = validateStructureRules(nextValues)
|
|
if (!validation.valid) {
|
|
showTooltip(validation.message)
|
|
return prev
|
|
}
|
|
|
|
const sanitized = sanitizeStructureList(nextValues, SANITIZE_OPTIONS)
|
|
return buildSortableItems(sanitized)
|
|
})
|
|
},
|
|
[extractValues, showTooltip]
|
|
)
|
|
|
|
const Row = ({ item: rowData, onPress }) => {
|
|
const baseType = rowData?.type || rowData?.value
|
|
const meta = getSegmentMeta(baseType)
|
|
const canRemove = rowData?.sortable && meta?.optional === true
|
|
const selected = rowData?.selected
|
|
const count = rowData?.count ?? 0
|
|
const containerStyle = StyleSheet.flatten([styles.rowContainer])
|
|
const blurStyle = StyleSheet.flatten([styles.rowBlur, selected ? styles.selectedBlur : null])
|
|
const badgeStyle = StyleSheet.flatten([
|
|
styles.countBadge,
|
|
count > 0 ? styles.countBadgeActive : null,
|
|
])
|
|
|
|
return (
|
|
<CreateLyricsHeader
|
|
tint="dark"
|
|
intensity={40}
|
|
showBorder={false}
|
|
onPress={onPress}
|
|
containerStyle={containerStyle}
|
|
blurViewStyle={blurStyle}
|
|
>
|
|
<View style={styles.rowContent}>
|
|
<Text style={styles.rowText}>{rowData?.label || ''}</Text>
|
|
{rowData?.sortable ? (
|
|
<View style={styles.rowActions}>
|
|
<Sortable.Handle>
|
|
<Image source={icons.dragDots} style={styles.handleIcon} />
|
|
</Sortable.Handle>
|
|
{canRemove && (
|
|
<TouchableOpacity
|
|
onPress={() => handleRemoveItem(rowData.id)}
|
|
style={styles.removeButton}
|
|
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
|
>
|
|
<Image source={icons.close} style={styles.removeIcon} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<View style={badgeStyle}>
|
|
<Text style={styles.countBadgeText}>{count}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</CreateLyricsHeader>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
|
<CreateLyricsHeader
|
|
title="Personnalise la structure de ta chanson"
|
|
subTitle="Réorganise par glisser-déposer"
|
|
/>
|
|
<View style={{ flex: 1 }}>
|
|
<Animated.ScrollView
|
|
ref={scrollRef}
|
|
contentContainerStyle={{ paddingVertical: 4, paddingBottom: 24 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={styles.sectionTag}>
|
|
<Text style={styles.sectionTagText}>Structure actuelle</Text>
|
|
</View>
|
|
<ItemContainer height="auto" disableKeyboardHeight style={styles.blurSection}>
|
|
<Sortable.Grid
|
|
columns={1}
|
|
rowGap={12}
|
|
data={labeledItems}
|
|
renderItem={({ item }) => <Row item={item} />}
|
|
customHandle
|
|
scrollableRef={scrollRef}
|
|
onDragEnd={({ data: newData }) => {
|
|
const values = extractValues(newData)
|
|
const validation = validateStructureRules(values)
|
|
if (!validation.valid) {
|
|
showTooltip(validation.message)
|
|
setSortableItems((prev) => buildSortableItems(extractValues(prev)))
|
|
return
|
|
}
|
|
const sanitized = sanitizeStructureList(values, SANITIZE_OPTIONS)
|
|
setSortableItems(buildSortableItems(sanitized))
|
|
}}
|
|
/>
|
|
</ItemContainer>
|
|
<View style={[styles.sectionTag, { marginTop: 24 }]}>
|
|
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
|
|
</View>
|
|
<ItemContainer height="auto" disableKeyboardHeight style={styles.blurSection}>
|
|
<FlatList
|
|
data={optionalSegments}
|
|
gap={12}
|
|
renderItem={({ item, index }) => {
|
|
const count = segmentCounts[item.type] || 0
|
|
const selected = count > 0
|
|
const wrapperStyle = index === 0 ? null : styles.optionWrapper
|
|
return (
|
|
<View style={wrapperStyle}>
|
|
<Row
|
|
item={{
|
|
...item,
|
|
sortable: false,
|
|
selected,
|
|
count,
|
|
}}
|
|
onPress={() => handleOptionalSegmentPress(item.type)}
|
|
/>
|
|
</View>
|
|
)
|
|
}}
|
|
keyExtractor={(item) => item.type}
|
|
contentContainerStyle={{ paddingBottom: 24 }}
|
|
scrollEnabled={false}
|
|
nestedScrollEnabled={false}
|
|
/>
|
|
</ItemContainer>
|
|
</Animated.ScrollView>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export default CustomizeSongStructure
|
|
|
|
const styles = StyleSheet.create({
|
|
rowContainer: {
|
|
borderRadius: 14,
|
|
width: '100%',
|
|
},
|
|
blurSection: {
|
|
marginTop: 12,
|
|
width: '100%',
|
|
},
|
|
rowBlur: {
|
|
minHeight: 54,
|
|
paddingHorizontal: 16,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
selectedBlur: {
|
|
backgroundColor: Palette.transparentWhite,
|
|
},
|
|
rowContent: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
rowText: {
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
},
|
|
handleIcon: {
|
|
width: 22,
|
|
height: 22,
|
|
tintColor: Palette.white,
|
|
marginRight: 12,
|
|
},
|
|
countBadge: {
|
|
minWidth: 28,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
borderRadius: 999,
|
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
countBadgeActive: {
|
|
backgroundColor: Palette.transparentWhite,
|
|
},
|
|
countBadgeText: {
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
},
|
|
rowActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
removeButton: {
|
|
padding: 6,
|
|
borderRadius: 999,
|
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
},
|
|
removeIcon: {
|
|
width: 16,
|
|
height: 16,
|
|
tintColor: Palette.white,
|
|
},
|
|
optionWrapper: {
|
|
marginTop: 12,
|
|
},
|
|
sectionTag: {
|
|
alignSelf: 'flex-start',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderRadius: 999,
|
|
backgroundColor: 'rgba(255,255,255,0.12)',
|
|
marginBottom: 16,
|
|
},
|
|
sectionTagText: {
|
|
fontSize: 12,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
letterSpacing: 1,
|
|
textTransform: 'uppercase',
|
|
},
|
|
})
|