separate structure and option

This commit is contained in:
2026-02-23 12:14:51 +01:00
parent 76d466d6dc
commit 070808737c
2 changed files with 242 additions and 92 deletions
+84 -13
View File
@@ -10,7 +10,6 @@ import {
arraysAreSame, arraysAreSame,
formatStructureLabel, formatStructureLabel,
getSegmentMeta, getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList, sanitizeStructureList,
} from '../../utils/songStructure' } from '../../utils/songStructure'
import ItemContainer from '../../components/ItemContainer/ItemContainer' import ItemContainer from '../../components/ItemContainer/ItemContainer'
@@ -28,6 +27,32 @@ const PRE_REFRAIN_ERROR_MESSAGE =
'Impossible de positionner un pré-refrain ici. Place-le juste avant un refrain.' '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 INTRO_ERROR_MESSAGE = "L'introduction musicale doit rester en première position."
const SANITIZE_OPTIONS = { autoInjectPreChorus: false } const SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const AUTO_STRUCTURE_SEGMENTS = [
'short_intro',
'long_intro',
PRE_REFRAIN_TYPE,
'final_apogee',
'arret_net',
'fade_out',
'transition_douce',
]
const DRAG_STRUCTURE_SEGMENTS = [
'solo_de_guitare',
'solo_de_guitare_electrique',
'solo_de_batterie',
'solo_de_saxophone',
'solo_de_violon',
'break',
'pont',
]
const isAutoSegmentType = (type) => {
if (!type) return false
if (INTRO_TYPES.has(type)) return true
if (type === PRE_REFRAIN_TYPE) return true
const meta = getSegmentMeta(type)
return meta?.exclusiveGroup === 'outro'
}
const buildSortableItems = (values = []) => const buildSortableItems = (values = []) =>
sanitizeStructureList(values, SANITIZE_OPTIONS).map((type, index) => sanitizeStructureList(values, SANITIZE_OPTIONS).map((type, index) =>
@@ -106,14 +131,24 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
type: baseType, type: baseType,
value: baseType, value: baseType,
label: formatStructureLabel(baseType, nextCount), label: formatStructureLabel(baseType, nextCount),
sortable: true, mode: 'structure',
draggable: !isAutoSegmentType(baseType),
} }
}) })
}, [sortableItems]) }, [sortableItems])
const optionalSegments = useMemo( const autoSegments = useMemo(
() => () =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { AUTO_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type)
return { type, label: meta?.label || formatStructureLabel(type, 1) }
}),
[]
)
const dragSegments = useMemo(
() =>
DRAG_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type) const meta = getSegmentMeta(type)
return { type, label: meta?.label || formatStructureLabel(type, 1) } return { type, label: meta?.label || formatStructureLabel(type, 1) }
}), }),
@@ -191,7 +226,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const Row = ({ item: rowData, onPress }) => { const Row = ({ item: rowData, onPress }) => {
const baseType = rowData?.type || rowData?.value const baseType = rowData?.type || rowData?.value
const meta = getSegmentMeta(baseType) const meta = getSegmentMeta(baseType)
const canRemove = rowData?.sortable && meta?.optional === true const isOptionRow = rowData?.mode === 'option'
const showActions = !isOptionRow
const canRemove = showActions && meta?.optional === true
const canDrag = showActions && rowData?.draggable !== false
const selected = rowData?.selected const selected = rowData?.selected
const count = rowData?.count ?? 0 const count = rowData?.count ?? 0
const containerStyle = StyleSheet.flatten([styles.rowContainer]) const containerStyle = StyleSheet.flatten([styles.rowContainer])
@@ -212,11 +250,13 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
> >
<View style={styles.rowContent}> <View style={styles.rowContent}>
<Text style={styles.rowText}>{rowData?.label || ''}</Text> <Text style={styles.rowText}>{rowData?.label || ''}</Text>
{rowData?.sortable ? ( {showActions ? (
<View style={styles.rowActions}> <View style={styles.rowActions}>
<Sortable.Handle> {canDrag && (
<Image source={icons.dragDots} style={styles.handleIcon} /> <Sortable.Handle>
</Sortable.Handle> <Image source={icons.dragDots} style={styles.handleIcon} />
</Sortable.Handle>
)}
{canRemove && ( {canRemove && (
<TouchableOpacity <TouchableOpacity
onPress={() => handleRemoveItem(rowData.id)} onPress={() => handleRemoveItem(rowData.id)}
@@ -241,7 +281,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<View style={{ flex: 1, gap: 10, marginTop: 16 }}> <View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader <CreateLyricsHeader
title="Personnalise la structure de ta chanson" title="Personnalise la structure de ta chanson"
subTitle="Réorganise par glisser-déposer" subTitle="Active les options automatiques et réorganise par glisser-déposer"
/> />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Animated.ScrollView <Animated.ScrollView
@@ -274,11 +314,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
/> />
</ItemContainer> </ItemContainer>
<View style={[styles.sectionTag, { marginTop: 24 }]}> <View style={[styles.sectionTag, { marginTop: 24 }]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text> <Text style={styles.sectionTagText}>Options automatiques</Text>
</View> </View>
<ItemContainer height="auto" disableKeyboardHeight style={styles.blurSection}> <ItemContainer height="auto" disableKeyboardHeight style={styles.blurSection}>
<FlatList <FlatList
data={optionalSegments} data={autoSegments}
gap={12} gap={12}
renderItem={({ item, index }) => { renderItem={({ item, index }) => {
const count = segmentCounts[item.type] || 0 const count = segmentCounts[item.type] || 0
@@ -289,7 +329,38 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Row <Row
item={{ item={{
...item, ...item,
sortable: false, mode: 'option',
selected,
count,
}}
onPress={() => handleOptionalSegmentPress(item.type)}
/>
</View>
)
}}
keyExtractor={(item) => item.type}
contentContainerStyle={{ paddingBottom: 24 }}
scrollEnabled={false}
nestedScrollEnabled={false}
/>
</ItemContainer>
<View style={[styles.sectionTag, { marginTop: 24 }]}>
<Text style={styles.sectionTagText}>Options glisser-déposer</Text>
</View>
<ItemContainer height="auto" disableKeyboardHeight style={styles.blurSection}>
<FlatList
data={dragSegments}
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,
mode: 'option',
selected, selected,
count, count,
}} }}
+158 -79
View File
@@ -17,7 +17,6 @@ import {
arraysAreSame, arraysAreSame,
formatStructureLabel, formatStructureLabel,
getSegmentMeta, getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
sanitizeStructureList, sanitizeStructureList,
} from '../../utils/songStructure' } from '../../utils/songStructure'
import CreateLyricsHeader from './components/CreateLyricsHeader' import CreateLyricsHeader from './components/CreateLyricsHeader'
@@ -29,6 +28,24 @@ const PRE_REFRAIN_ERROR_MESSAGE =
'Impossible de positionner un pré-refrain ici. Place-le juste avant un refrain.' '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 INTRO_ERROR_MESSAGE = "L'introduction musicale doit rester en première position."
const SANITIZE_OPTIONS = { autoInjectPreChorus: false } const SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const AUTO_STRUCTURE_SEGMENTS = [
'short_intro',
'long_intro',
PRE_REFRAIN_TYPE,
'final_apogee',
'arret_net',
'fade_out',
'transition_douce',
]
const DRAG_STRUCTURE_SEGMENTS = [
'solo_de_guitare',
'solo_de_guitare_electrique',
'solo_de_batterie',
'solo_de_saxophone',
'solo_de_violon',
'break',
'pont',
]
const PALETTE_SEGMENT_OVERRIDES = [ const PALETTE_SEGMENT_OVERRIDES = [
{ {
@@ -36,6 +53,18 @@ const PALETTE_SEGMENT_OVERRIDES = [
description: description:
'Transition contrastée qui relance l’énergie avant dentrer dans la dernière partie.', 'Transition contrastée qui relance l’énergie avant dentrer dans la dernière partie.',
}, },
{
type: 'short_intro',
description: 'Introduction courte placée en ouverture du morceau.',
},
{
type: 'long_intro',
description: 'Introduction longue pour installer lambiance dès le début.',
},
{
type: 'pre_refrain_instrumental',
description: 'Se place automatiquement juste avant un refrain.',
},
{ {
type: 'interlude_melodique', type: 'interlude_melodique',
description: 'Respiration instrumentale qui met la mélodie à lhonneur sans paroles.', description: 'Respiration instrumentale qui met la mélodie à lhonneur sans paroles.',
@@ -44,10 +73,18 @@ const PALETTE_SEGMENT_OVERRIDES = [
type: 'final_apogee', type: 'final_apogee',
description: 'Clôture apothéose qui amplifie l’émotion pour un final impactant.', description: 'Clôture apothéose qui amplifie l’émotion pour un final impactant.',
}, },
{
type: 'arret_net',
description: 'Final abrupt qui coupe net la musique.',
},
{ {
type: 'fade_out', type: 'fade_out',
description: 'Sortie progressive où le morceau disparaît doucement dans le mix.', description: 'Sortie progressive où le morceau disparaît doucement dans le mix.',
}, },
{
type: 'transition_douce',
description: 'Fin en douceur qui glisse vers le silence.',
},
] ]
const getPaletteSegmentInfo = (type) => { const getPaletteSegmentInfo = (type) => {
@@ -59,6 +96,14 @@ const getPaletteSegmentInfo = (type) => {
} }
} }
const isAutoSegmentType = (type) => {
if (!type) return false
if (INTRO_TYPES.has(type)) return true
if (type === PRE_REFRAIN_TYPE) return true
const meta = getSegmentMeta(type)
return meta?.exclusiveGroup === 'outro'
}
const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0) const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0)
const randomSuffix = () => Math.random().toString(36).slice(2, 8) const randomSuffix = () => Math.random().toString(36).slice(2, 8)
@@ -241,14 +286,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[sortableItems] [sortableItems]
) )
const paletteSegments = useMemo( const autoPaletteSegments = useMemo(
() => () =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { AUTO_STRUCTURE_SEGMENTS.map((type) => {
const info = getPaletteSegmentInfo(type) const info = getPaletteSegmentInfo(type)
return { return {
type, type,
label: info.label, label: info.label,
description: info.description, description: info.description,
placement: 'auto',
}
}),
[]
)
const dragPaletteSegments = useMemo(
() =>
DRAG_STRUCTURE_SEGMENTS.map((type) => {
const info = getPaletteSegmentInfo(type)
return {
type,
label: info.label,
description: info.description,
placement: 'drag',
} }
}), }),
[] []
@@ -756,11 +816,79 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[attachGlobalListeners] [attachGlobalListeners]
) )
const renderPaletteSegment = (segment) => {
const count = segmentCounts[segment.type] || 0
const isActive = activeItemId === `palette-${segment.type}`
const isDraggable = segment.placement === 'drag'
const description =
segment.description || (segment.placement === 'auto' ? 'Placement automatique.' : '')
const blurStyle = StyleSheet.flatten([
styles.itemBlur,
styles.paletteItemBlur,
isActive ? styles.activeBlur : null,
])
const badgeStyle = StyleSheet.flatten([
styles.countBadge,
count > 0 ? styles.countBadgeActive : null,
])
return (
<View
key={segment.type}
style={StyleSheet.flatten([
styles.paletteCardWrapper,
!isDraggable ? styles.paletteCardWrapperAuto : null,
])}
onPointerDown={isDraggable ? (event) => startPaletteDrag(segment.type, event) : undefined}
>
<CreateLyricsHeader
tint="dark"
intensity={40}
showBorder={false}
containerStyle={styles.rowContainer}
blurViewStyle={blurStyle}
>
<View style={styles.paletteRow}>
<View style={styles.paletteTextWrapper}>
<Text style={styles.rowText}>{segment.label}</Text>
{description ? (
<Text style={styles.paletteDescription}>{description}</Text>
) : null}
</View>
<View style={styles.paletteActions}>
<Pressable
onPointerDown={(ev) => ev?.stopPropagation?.()}
onPressIn={(ev) => ev?.stopPropagation?.()}
onPress={(ev) => {
ev?.stopPropagation?.()
const result = addOptionalSegment(segment.type)
if (result?.success) {
setInsertPreviewIndex(null)
setActiveItemId(null)
}
}}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
style={({ pressed }) =>
StyleSheet.flatten([styles.addButton, pressed ? styles.addButtonPressed : null])
}
>
<Image source={icons.add} style={styles.addIcon} />
</Pressable>
<View style={badgeStyle}>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
</View>
</View>
</CreateLyricsHeader>
</View>
)
}
const StructureRow = ({ item }) => { const StructureRow = ({ item }) => {
const baseType = item?.type || item?.value const baseType = item?.type || item?.value
const meta = getSegmentMeta(baseType) const meta = getSegmentMeta(baseType)
const canRemove = meta?.optional === true const canRemove = meta?.optional === true
const isIntroduction = INTRO_TYPES.has(baseType) const isAutoSegment = isAutoSegmentType(baseType)
const rowRef = useRef(null) const rowRef = useRef(null)
const isActive = activeItemId === item.id const isActive = activeItemId === item.id
const containerStyle = StyleSheet.flatten([ const containerStyle = StyleSheet.flatten([
@@ -775,7 +903,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
]) ])
const rowStyle = StyleSheet.flatten([ const rowStyle = StyleSheet.flatten([
styles.row, styles.row,
isIntroduction ? styles.rowDisabled : styles.rowDraggable, isAutoSegment ? styles.rowDisabled : styles.rowDraggable,
isActive ? styles.rowHidden : null, isActive ? styles.rowHidden : null,
]) ])
@@ -791,7 +919,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
ref={rowRef} ref={rowRef}
style={rowStyle} style={rowStyle}
onPointerDown={(event) => { onPointerDown={(event) => {
if (isIntroduction) return if (isAutoSegment) return
startItemDrag(item.id, event, rowRef.current) startItemDrag(item.id, event, rowRef.current)
}} }}
> >
@@ -811,7 +939,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Image source={icons.close} style={styles.removeIcon} /> <Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{!isIntroduction && ( {!isAutoSegment && (
<View <View
style={styles.handleWrapper} style={styles.handleWrapper}
onPointerDown={(event) => { onPointerDown={(event) => {
@@ -832,7 +960,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<View style={styles.container}> <View style={styles.container}>
<CreateLyricsHeader <CreateLyricsHeader
title="Personnalise la structure de ta chanson" title="Personnalise la structure de ta chanson"
subTitle="Réorganise par glisser-déposer" subTitle="Active les options automatiques et réorganise par glisser-déposer"
/> />
<View style={styles.content}> <View style={styles.content}>
<View style={styles.columns}> <View style={styles.columns}>
@@ -865,9 +993,6 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
</ItemContainer> </ItemContainer>
</View> </View>
<View style={[styles.column, styles.paletteColumn]}> <View style={[styles.column, styles.paletteColumn]}>
<View style={[styles.sectionTag, styles.addSectionTag]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<ItemContainer <ItemContainer
height="auto" height="auto"
disableKeyboardHeight disableKeyboardHeight
@@ -878,71 +1003,21 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator showsVerticalScrollIndicator
> >
<View style={styles.paletteList}> <View style={styles.paletteSection}>
{paletteSegments.map((segment) => { <View style={styles.sectionTag}>
const count = segmentCounts[segment.type] || 0 <Text style={styles.sectionTagText}>Options automatiques</Text>
const isActive = activeItemId === `palette-${segment.type}` </View>
const blurStyle = StyleSheet.flatten([ <View style={styles.paletteList}>
styles.itemBlur, {autoPaletteSegments.map((segment) => renderPaletteSegment(segment))}
styles.paletteItemBlur, </View>
isActive ? styles.activeBlur : null, </View>
]) <View style={styles.paletteSection}>
const badgeStyle = StyleSheet.flatten([ <View style={styles.sectionTag}>
styles.countBadge, <Text style={styles.sectionTagText}>Options glisser-déposer</Text>
count > 0 ? styles.countBadgeActive : null, </View>
]) <View style={styles.paletteList}>
{dragPaletteSegments.map((segment) => renderPaletteSegment(segment))}
return ( </View>
<View
key={segment.type}
style={styles.paletteCardWrapper}
onPointerDown={(event) => startPaletteDrag(segment.type, event)}
>
<CreateLyricsHeader
tint="dark"
intensity={40}
showBorder={false}
containerStyle={styles.rowContainer}
blurViewStyle={blurStyle}
>
<View style={styles.paletteRow}>
<View style={styles.paletteTextWrapper}>
<Text style={styles.rowText}>{segment.label}</Text>
{segment.description ? (
<Text style={styles.paletteDescription}>{segment.description}</Text>
) : null}
</View>
<View style={styles.paletteActions}>
<Pressable
onPointerDown={(ev) => ev?.stopPropagation?.()}
onPressIn={(ev) => ev?.stopPropagation?.()}
onPress={(ev) => {
ev?.stopPropagation?.()
const result = addOptionalSegment(segment.type)
if (result?.success) {
setInsertPreviewIndex(null)
setActiveItemId(null)
}
}}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
style={({ pressed }) =>
StyleSheet.flatten([
styles.addButton,
pressed ? styles.addButtonPressed : null,
])
}
>
<Image source={icons.add} style={styles.addIcon} />
</Pressable>
<View style={badgeStyle}>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
</View>
</View>
</CreateLyricsHeader>
</View>
)
})}
</View> </View>
</ScrollView> </ScrollView>
</ItemContainer> </ItemContainer>
@@ -1033,9 +1108,6 @@ const styles = StyleSheet.create({
borderRadius: 999, borderRadius: 999,
backgroundColor: 'rgba(255,255,255,0.12)', backgroundColor: 'rgba(255,255,255,0.12)',
}, },
addSectionTag: {
marginTop: 0,
},
sectionTagText: { sectionTagText: {
fontSize: 12, fontSize: 12,
color: Palette.white, color: Palette.white,
@@ -1145,9 +1217,16 @@ const styles = StyleSheet.create({
paletteList: { paletteList: {
gap: 12, gap: 12,
}, },
paletteSection: {
gap: 16,
marginBottom: 20,
},
paletteCardWrapper: { paletteCardWrapper: {
cursor: 'grab', cursor: 'grab',
}, },
paletteCardWrapperAuto: {
cursor: 'default',
},
paletteRow: { paletteRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',