432 lines
14 KiB
JavaScript
432 lines
14 KiB
JavaScript
import React, { useMemo, useState } from 'react'
|
|
import { Platform, View } from 'react-native'
|
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
|
import { background } from '../../assets'
|
|
import GradientButton from '../../components/GradientButton'
|
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
|
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data'
|
|
import Page from '../../layouts/Page'
|
|
import { goBack } from '../../navigation/NavigationService'
|
|
import { useUser } from '../../providers/UserDataProvider'
|
|
import { gutters } from '../../styles'
|
|
import { sanitizeStructureList } from '../../utils/songStructure'
|
|
import CreatingLyrics from './CreatingLyrics'
|
|
import CustomizeSongStructure from './CustomizeSongStructure'
|
|
import EmotionConvey from './EmotionConvey'
|
|
import Goals from './Goals'
|
|
import Rhymes from './Rhymes'
|
|
import SongStructure from './SongStructure'
|
|
import SongStyle from './SongStyle'
|
|
import SongTo from './SongTo'
|
|
import SpecificityContext from './SpecificityContext'
|
|
|
|
const MAX_STEP_INDEX = 8
|
|
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
|
|
|
const CreateLyricsWithAi = () => {
|
|
const { selectedProject } = useUser()
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const handleLyricsError = React.useCallback(() => {
|
|
setSelectedIndex(7)
|
|
}, [setSelectedIndex])
|
|
|
|
// Collected state across steps
|
|
const [objective, setObjective] = useState(null) // from Goals list
|
|
const [otherObjective, setOtherObjective] = useState('')
|
|
const [context, setContext] = useState('')
|
|
const [emotion, setEmotion] = useState(null)
|
|
const [style, setStyle] = useState(null) // from list
|
|
const [otherStyle, setOtherStyle] = useState('')
|
|
const [audience, setAudience] = useState('')
|
|
const [structure, setStructure] = useState(null) // selected structure string
|
|
const [rhymes, setRhymes] = useState(null)
|
|
const [customStructure, setCustomStructure] = useState(null) // array like ['couplet','refrain']
|
|
|
|
const trimmedOtherObjective = typeof otherObjective === 'string' ? otherObjective.trim() : ''
|
|
const shouldUseOtherObjective = objective === OTHER_OBJECTIVE_OPTION
|
|
const persistedOtherObjective = shouldUseOtherObjective ? trimmedOtherObjective : ''
|
|
|
|
const trimmedOtherStyle = typeof otherStyle === 'string' ? otherStyle.trim() : ''
|
|
const shouldUseOtherStyle = style === OTHER_STYLE_OPTION
|
|
const persistedOtherStyle = shouldUseOtherStyle ? trimmedOtherStyle : ''
|
|
|
|
// Pré-remplir les états depuis le projet sélectionné si disponibles
|
|
React.useEffect(() => {
|
|
if (!selectedProject) return
|
|
const sel = selectedProject?.selections || {}
|
|
const cfg = selectedProject?.config || {}
|
|
|
|
const rawOtherObjective = typeof sel.otherObjective === 'string' ? sel.otherObjective : ''
|
|
const trimmedSavedOtherObjective = rawOtherObjective.trim()
|
|
const hasSavedOtherObjective = trimmedSavedOtherObjective.length > 0
|
|
const savedObjective =
|
|
typeof sel.objective === 'string' && sel.objective.trim().length > 0
|
|
? sel.objective.trim()
|
|
: null
|
|
|
|
if (!otherObjective && hasSavedOtherObjective) {
|
|
setOtherObjective(rawOtherObjective)
|
|
}
|
|
|
|
const shouldSelectOtherObjective =
|
|
hasSavedOtherObjective && (!savedObjective || savedObjective === OTHER_OBJECTIVE_OPTION)
|
|
const resolvedSavedObjective = shouldSelectOtherObjective
|
|
? OTHER_OBJECTIVE_OPTION
|
|
: savedObjective
|
|
|
|
if (resolvedSavedObjective && objective !== resolvedSavedObjective) {
|
|
setObjective(resolvedSavedObjective)
|
|
} else if (!resolvedSavedObjective && objective !== null) {
|
|
setObjective(null)
|
|
}
|
|
|
|
if (!context && typeof sel.context === 'string') setContext(sel.context)
|
|
|
|
// Emotion: accepter objet {title, description} ou string "Titre : description"
|
|
if (emotion == null && sel.emotion) {
|
|
if (typeof sel.emotion === 'object' && sel.emotion.title) {
|
|
setEmotion({
|
|
title: sel.emotion.title,
|
|
description: sel.emotion.description || '',
|
|
})
|
|
} else if (typeof sel.emotion === 'string') {
|
|
const [t, d] = sel.emotion.split(':')
|
|
const title = (t || '').trim()
|
|
const description = (d || '').trim()
|
|
if (title) setEmotion({ title, description })
|
|
}
|
|
}
|
|
|
|
const rawOtherStyle = typeof sel.otherStyle === 'string' ? sel.otherStyle : ''
|
|
const trimmedSavedOtherStyle = rawOtherStyle.trim()
|
|
const hasSavedOtherStyle = trimmedSavedOtherStyle.length > 0
|
|
const savedStyle =
|
|
typeof sel.style === 'string' && sel.style.trim().length > 0 ? sel.style.trim() : null
|
|
if (!otherStyle && hasSavedOtherStyle) {
|
|
setOtherStyle(rawOtherStyle)
|
|
}
|
|
const shouldSelectOtherStyle =
|
|
hasSavedOtherStyle && (!savedStyle || savedStyle === OTHER_STYLE_OPTION)
|
|
const resolvedSavedStyle = shouldSelectOtherStyle ? OTHER_STYLE_OPTION : savedStyle
|
|
if (resolvedSavedStyle && style !== resolvedSavedStyle) {
|
|
setStyle(resolvedSavedStyle)
|
|
} else if (!resolvedSavedStyle && style !== null) {
|
|
setStyle(null)
|
|
}
|
|
if (!audience && typeof sel.audience === 'string') setAudience(sel.audience)
|
|
|
|
// Structure choisie (string) si déjà enregistrée dans selections
|
|
if (structure == null && typeof sel.structure === 'string' && sel.structure)
|
|
setStructure(sel.structure)
|
|
|
|
// Rimes
|
|
if (rhymes == null && typeof sel.rhymes === 'string' && sel.rhymes) setRhymes(sel.rhymes)
|
|
|
|
// Structure personnalisée: prioriser selections.customStructure puis config.structure
|
|
const savedCustom = Array.isArray(sel.customStructure)
|
|
? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS)
|
|
: null
|
|
const cfgStructure = Array.isArray(cfg.structure)
|
|
? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS)
|
|
: null
|
|
const fallbackStructure = savedCustom && savedCustom.length ? savedCustom : cfgStructure
|
|
if (!Array.isArray(customStructure) && fallbackStructure?.length) {
|
|
setCustomStructure(fallbackStructure)
|
|
} else if (
|
|
Array.isArray(customStructure) &&
|
|
fallbackStructure?.length &&
|
|
(customStructure.length !== fallbackStructure.length ||
|
|
customStructure.some((value, idx) => value !== fallbackStructure[idx]))
|
|
) {
|
|
setCustomStructure(fallbackStructure)
|
|
}
|
|
}, [selectedProject])
|
|
|
|
React.useEffect(() => {
|
|
console.debug('[CreateLyricsWithAi.web] structure state', {
|
|
structure,
|
|
customStructure,
|
|
})
|
|
}, [structure, customStructure])
|
|
|
|
const parsedStructure = useMemo(() => {
|
|
// Parses strings like "1 couplet, 1 refrain, 1 couplet, 1 refrain"
|
|
try {
|
|
if (!structure || typeof structure !== 'string') return null
|
|
const parts = structure.split(',')
|
|
const result = []
|
|
parts.forEach((seg) => {
|
|
const s = seg.trim().toLowerCase()
|
|
const coupletMatch = s.match(/(\d+)\s+couplet/)
|
|
const refrainMatch = s.match(/(\d+)\s+refrain/)
|
|
if (coupletMatch) {
|
|
const count = parseInt(coupletMatch[1], 10)
|
|
for (let i = 0; i < count; i++) result.push('couplet')
|
|
}
|
|
if (refrainMatch) {
|
|
const count = parseInt(refrainMatch[1], 10)
|
|
for (let i = 0; i < count; i++) result.push('refrain')
|
|
}
|
|
})
|
|
const sanitizedResult = sanitizeStructureList(result)
|
|
return sanitizedResult.length ? sanitizedResult : null
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}, [structure])
|
|
|
|
const sanitizedCustomStructure = useMemo(() => {
|
|
if (!Array.isArray(customStructure) || customStructure.length === 0) {
|
|
return []
|
|
}
|
|
return sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS)
|
|
}, [customStructure])
|
|
|
|
const sanitizedParsedStructure = useMemo(() => {
|
|
if (!Array.isArray(parsedStructure) || parsedStructure.length === 0) {
|
|
return []
|
|
}
|
|
return sanitizeStructureList(parsedStructure, CUSTOM_SANITIZE_OPTIONS)
|
|
}, [parsedStructure])
|
|
|
|
const fallbackProjectStructure = useMemo(() => {
|
|
const raw = selectedProject?.config?.structure
|
|
if (!Array.isArray(raw) || raw.length === 0) return []
|
|
return sanitizeStructureList(raw, CUSTOM_SANITIZE_OPTIONS)
|
|
}, [selectedProject])
|
|
|
|
const baseStructureForCustomizer = useMemo(() => {
|
|
if (sanitizedCustomStructure.length > 0) return sanitizedCustomStructure
|
|
if (sanitizedParsedStructure.length > 0) return sanitizedParsedStructure
|
|
return fallbackProjectStructure
|
|
}, [sanitizedCustomStructure, sanitizedParsedStructure, fallbackProjectStructure])
|
|
|
|
const progress = useMemo(() => 16 + selectedIndex * 9, [selectedIndex])
|
|
|
|
const lyricsConfig = useMemo(() => {
|
|
const resolvedObjective = shouldUseOtherObjective
|
|
? persistedOtherObjective || undefined
|
|
: objective || undefined
|
|
const resolvedStyle = shouldUseOtherStyle
|
|
? persistedOtherStyle || undefined
|
|
: style || undefined
|
|
const sanitizedCustom = sanitizedCustomStructure
|
|
const sanitizedParsed = sanitizedParsedStructure
|
|
|
|
return {
|
|
objective: resolvedObjective,
|
|
context: context?.trim() ? context.trim() : undefined,
|
|
emotion:
|
|
emotion?.title && emotion?.description
|
|
? `${emotion.title} : ${emotion.description}`
|
|
: undefined,
|
|
style: resolvedStyle,
|
|
audience: audience?.trim() ? audience.trim() : undefined,
|
|
structure:
|
|
sanitizedCustom.length > 0
|
|
? sanitizedCustom
|
|
: sanitizedParsed.length > 0
|
|
? sanitizedParsed
|
|
: undefined,
|
|
rhymes: rhymes || undefined,
|
|
}
|
|
}, [
|
|
shouldUseOtherObjective,
|
|
persistedOtherObjective,
|
|
objective,
|
|
shouldUseOtherStyle,
|
|
persistedOtherStyle,
|
|
context,
|
|
emotion,
|
|
style,
|
|
audience,
|
|
sanitizedParsedStructure,
|
|
rhymes,
|
|
sanitizedCustomStructure,
|
|
])
|
|
|
|
const isNextDisabled = React.useMemo(() => {
|
|
switch (selectedIndex) {
|
|
case 0: {
|
|
const hasObjective = typeof objective === 'string' && objective.length > 0
|
|
const hasOther = trimmedOtherObjective.length > 0
|
|
return !(hasObjective || hasOther)
|
|
}
|
|
case 1: {
|
|
const hasContext = typeof context === 'string' && context.trim().length > 0
|
|
return !hasContext
|
|
}
|
|
case 2: {
|
|
const hasEmotion =
|
|
(emotion && typeof emotion === 'object' && !!emotion.title) ||
|
|
(typeof emotion === 'string' && emotion.trim().length > 0)
|
|
return !hasEmotion
|
|
}
|
|
case 3: {
|
|
const hasStyle = typeof style === 'string' && style.length > 0
|
|
const hasOther = trimmedOtherStyle.length > 0
|
|
return !(hasStyle || hasOther)
|
|
}
|
|
case 4: {
|
|
const hasAudience = typeof audience === 'string' && audience.trim().length > 0
|
|
return !hasAudience
|
|
}
|
|
case 5: {
|
|
const hasStructure = typeof structure === 'string' && structure.length > 0
|
|
return !hasStructure
|
|
}
|
|
case 7: {
|
|
const hasRhymes = typeof rhymes === 'string' && rhymes.length > 0
|
|
return !hasRhymes
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
}, [
|
|
selectedIndex,
|
|
objective,
|
|
trimmedOtherObjective,
|
|
context,
|
|
emotion,
|
|
style,
|
|
trimmedOtherStyle,
|
|
audience,
|
|
structure,
|
|
rhymes,
|
|
])
|
|
|
|
const onPressNext = async () => {
|
|
setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX))
|
|
}
|
|
|
|
const onPressBack = () => {
|
|
if (selectedIndex > 0) {
|
|
setSelectedIndex((idx) => Math.max(0, idx - 1))
|
|
} else {
|
|
goBack()
|
|
}
|
|
}
|
|
|
|
const steps = [
|
|
{
|
|
key: 'goals',
|
|
render: () => (
|
|
<Goals
|
|
selected={objective}
|
|
setSelected={setObjective}
|
|
otherObjective={otherObjective}
|
|
setOtherObjective={setOtherObjective}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'context',
|
|
render: () => <SpecificityContext context={context} setContext={setContext} />,
|
|
},
|
|
{
|
|
key: 'emotion',
|
|
render: () => <EmotionConvey selected={emotion} setSelected={setEmotion} />,
|
|
},
|
|
{
|
|
key: 'style',
|
|
render: () => (
|
|
<SongStyle
|
|
selected={style}
|
|
setSelected={setStyle}
|
|
otherStyle={otherStyle}
|
|
setOtherStyle={setOtherStyle}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'audience',
|
|
render: () => <SongTo audience={audience} setAudience={setAudience} />,
|
|
},
|
|
{
|
|
key: 'structure',
|
|
render: () => <SongStructure selected={structure} setSelected={setStructure} />,
|
|
},
|
|
{
|
|
key: 'customStructure',
|
|
render: () => (
|
|
<CustomizeSongStructure
|
|
baseStructure={baseStructureForCustomizer}
|
|
onChange={setCustomStructure}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'rhymes',
|
|
render: () => <Rhymes selected={rhymes} setSelected={setRhymes} />,
|
|
},
|
|
{
|
|
key: 'creating',
|
|
render: () => (
|
|
<CreatingLyrics
|
|
active={selectedIndex === 8}
|
|
config={lyricsConfig}
|
|
selections={{
|
|
objective,
|
|
otherObjective: persistedOtherObjective,
|
|
context,
|
|
emotion,
|
|
style,
|
|
otherStyle: persistedOtherStyle,
|
|
audience,
|
|
structure,
|
|
parsedStructure,
|
|
customStructure,
|
|
rhymes,
|
|
}}
|
|
onErrorRedirect={handleLyricsError}
|
|
/>
|
|
),
|
|
},
|
|
]
|
|
|
|
const currentStep = steps[selectedIndex]
|
|
|
|
return (
|
|
<Page
|
|
headerType="NONE"
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 100}
|
|
backgroundImg={background.libraryBgWeb}
|
|
>
|
|
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
paddingBottom: gutters,
|
|
gap: responsiveHeight(5),
|
|
}}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
{currentStep ? (
|
|
<View
|
|
key={currentStep.key}
|
|
style={{
|
|
flex: 1,
|
|
paddingHorizontal: gutters,
|
|
}}
|
|
>
|
|
{currentStep.render()}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
{selectedIndex !== 8 && (
|
|
<GradientButton
|
|
maxWidth={500}
|
|
title={selectedIndex === 7 ? 'Générer' : 'Suivant'}
|
|
disabled={isNextDisabled}
|
|
onPress={onPressNext}
|
|
containerStyle={{ alignSelf: 'center', width: '100%' }}
|
|
/>
|
|
)}
|
|
</View>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
export default CreateLyricsWithAi
|