523 lines
17 KiB
JavaScript
523 lines
17 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useIsFocused, useRoute } from '@react-navigation/native'
|
|
import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
|
|
import { background } from '../../assets'
|
|
import BackgroundVideo from '../../components/BackgroundVideo'
|
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
|
import GradientButton from '../../components/GradientButton'
|
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
|
import AppAlert from '../../components/Alert'
|
|
import CreditAmount from '../../components/CreditAmount'
|
|
import firebase, { getFunctionsClient } from '../../config/firebase'
|
|
import Page from '../../layouts/Page'
|
|
import { Routes } from '../../navigation'
|
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
|
import { useUser } from '../../providers/UserDataProvider'
|
|
import { gutters, Palette } from '../../styles'
|
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
|
import { normalizeStructureType } from '../../utils/songStructure'
|
|
import { openCoinPackModal } from '../../utils/coinPackModal'
|
|
import ChooseGenre from './ChooseGenre'
|
|
import ChooseInstruments from './ChooseInstruments'
|
|
import ChooseRhythm from './ChooseRhythm'
|
|
import CustomizeVoice from './CustomizeVoice'
|
|
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|
|
|
const MUSIC_GENERATION_COIN_COST = 8
|
|
const CONFIRM_MODAL_MAX_WIDTH = 540
|
|
const FUNCTIONS_REGION = 'europe-west1'
|
|
const VOICE_SECTION_TITLES = ['BASE', 'SENSIBILITÉ', 'TECHNIQUE']
|
|
const FIRST_VOICE_STEP_INDEX = 1
|
|
const OPTIONAL_VOICE_CATEGORIES = new Set(['SENSIBILITÉ', 'TECHNIQUE'])
|
|
const PAGE_WIDTH_RATIO_WEB = 0.6 // keep in sync with Page default width on web
|
|
const PAGE_MAX_WIDTH = 1200
|
|
const PAGE_HORIZONTAL_PADDING = gutters * 2
|
|
|
|
const ComposeSong = () => {
|
|
const isFocused = useIsFocused()
|
|
const scrollRef = useRef(null)
|
|
const { width: windowWidth } = useWindowDimensions()
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const [progress, setProgress] = useState(18)
|
|
const [parentLayout, setParentLayout] = useState(null)
|
|
const estimatedContainerWidth = useMemo(() => {
|
|
const safeWindowWidth =
|
|
typeof windowWidth === 'number' && Number.isFinite(windowWidth) ? windowWidth : 0
|
|
const estimatedPageWidth = Math.min(safeWindowWidth * PAGE_WIDTH_RATIO_WEB, PAGE_MAX_WIDTH)
|
|
const paddedWidth = estimatedPageWidth - PAGE_HORIZONTAL_PADDING
|
|
return Math.max(1, paddedWidth)
|
|
}, [windowWidth])
|
|
const containerWidth = parentLayout?.width || estimatedContainerWidth
|
|
const route = useRoute()
|
|
const {
|
|
selectedProjectId,
|
|
selectedProject,
|
|
updateProjectData,
|
|
currentUserData,
|
|
currentUID,
|
|
videos,
|
|
} = useUser()
|
|
|
|
const [genres, setGenres] = useState([])
|
|
const [voice, setVoice] = useState({})
|
|
const [instruments, setInstruments] = useState([])
|
|
const [rhythm, setRhythm] = useState(null)
|
|
const [isConfirmVisible, setIsConfirmVisible] = useState(false)
|
|
const [isProcessingConfirmation, setIsProcessingConfirmation] = useState(false)
|
|
const isRegenerationFlow = route?.params?.isRegeneration === true
|
|
const fromLyrics = route?.params?.fromLyrics === true
|
|
|
|
const coinBalance = useMemo(() => {
|
|
const value = currentUserData?.coins
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value)
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed
|
|
}
|
|
}
|
|
return 0
|
|
}, [currentUserData?.coins])
|
|
|
|
const steps = useMemo(
|
|
() => [
|
|
{
|
|
key: 'genres',
|
|
render: () => <ChooseGenre selected={genres} setSelected={setGenres} />,
|
|
},
|
|
...VOICE_SECTION_TITLES.map((section) => ({
|
|
key: `voice-${section}`,
|
|
render: () => <CustomizeVoice category={section} selected={voice} setSelected={setVoice} />,
|
|
})),
|
|
{
|
|
key: 'instruments',
|
|
render: () => <ChooseInstruments selected={instruments} setSelected={setInstruments} />,
|
|
},
|
|
{
|
|
key: 'rhythm',
|
|
render: () => <ChooseRhythm selected={rhythm} setSelected={setRhythm} />,
|
|
},
|
|
],
|
|
[genres, voice, instruments, rhythm]
|
|
)
|
|
|
|
const totalSteps = steps.length
|
|
const lastStepIndex = totalSteps - 1
|
|
const instrumentStepIndex = Math.max(lastStepIndex - 1, 0)
|
|
|
|
const isStepValid = useMemo(() => {
|
|
if (selectedIndex === 0) {
|
|
return Array.isArray(genres) && genres.length > 0
|
|
}
|
|
|
|
const voiceStepIndex = selectedIndex - FIRST_VOICE_STEP_INDEX
|
|
const isVoiceStep = voiceStepIndex >= 0 && voiceStepIndex < VOICE_SECTION_TITLES.length
|
|
if (isVoiceStep) {
|
|
const category = VOICE_SECTION_TITLES[voiceStepIndex]
|
|
if (OPTIONAL_VOICE_CATEGORIES.has(category)) {
|
|
return true
|
|
}
|
|
return !!(voice && typeof voice === 'object' && voice[category])
|
|
}
|
|
|
|
if (selectedIndex === instrumentStepIndex) {
|
|
return Array.isArray(instruments) && instruments.length > 0
|
|
}
|
|
if (selectedIndex === lastStepIndex) {
|
|
return !!rhythm
|
|
}
|
|
return true
|
|
}, [selectedIndex, genres, voice, instruments, rhythm, instrumentStepIndex, lastStepIndex])
|
|
|
|
const musicConfig = useMemo(() => {
|
|
let lyricsArr = []
|
|
if (Array.isArray(selectedProject?.lyrics)) {
|
|
lyricsArr = selectedProject.lyrics.map((s) => ({
|
|
type: normalizeStructureType(s?.type),
|
|
lyrics: s?.lyrics || '',
|
|
}))
|
|
} else {
|
|
const c = selectedProject?.lyrics?.couplet
|
|
const r = selectedProject?.lyrics?.refrain
|
|
if (c) lyricsArr.push({ type: 'couplet', lyrics: c })
|
|
if (r) lyricsArr.push({ type: 'refrain', lyrics: r })
|
|
}
|
|
const voiceArray = Object.entries(voice || {})
|
|
.filter(([, v]) => typeof v === 'string' && v.trim())
|
|
.map(([category, value]) => ({ category, value }))
|
|
|
|
return {
|
|
title: selectedProject?.title || '',
|
|
lyrics: lyricsArr,
|
|
genres: Array.isArray(genres) ? genres : [],
|
|
voice: voiceArray,
|
|
instruments: Array.isArray(instruments) ? instruments : [],
|
|
tempo: rhythm || undefined,
|
|
projectId: selectedProjectId || undefined,
|
|
}
|
|
}, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId])
|
|
|
|
useEffect(() => {
|
|
const nextProgress = 18 + selectedIndex * 9
|
|
if (nextProgress !== progress) {
|
|
setProgress(nextProgress)
|
|
}
|
|
try {
|
|
scrollRef.current?.scrollToIndex?.({
|
|
index: selectedIndex,
|
|
animated: true,
|
|
})
|
|
} catch (_) {}
|
|
}, [selectedIndex, progress, containerWidth])
|
|
|
|
const getItemLayout = useCallback(
|
|
(_data, index) => ({
|
|
length: containerWidth,
|
|
offset: containerWidth * index,
|
|
index,
|
|
}),
|
|
[containerWidth]
|
|
)
|
|
|
|
const handleCancelGeneration = () => {
|
|
setIsConfirmVisible(false)
|
|
navigate(Routes.SongReady)
|
|
}
|
|
|
|
const persistMusicConfig = useCallback(async () => {
|
|
try {
|
|
if (!selectedProjectId) return
|
|
const payload = {
|
|
musicConfig: {
|
|
title: musicConfig?.title || '',
|
|
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
|
|
genres: Array.isArray(musicConfig?.genres) ? musicConfig.genres : [],
|
|
voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [],
|
|
instruments: Array.isArray(musicConfig?.instruments) ? musicConfig.instruments : [],
|
|
tempo: musicConfig?.tempo || '',
|
|
},
|
|
musicStatus: null,
|
|
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
|
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
|
|
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
|
|
}
|
|
|
|
if (!isRegenerationFlow) {
|
|
payload.musicUrls = firebase.firestore.FieldValue.delete()
|
|
payload.musicAudioIds = firebase.firestore.FieldValue.delete()
|
|
payload.musicDurations = firebase.firestore.FieldValue.delete()
|
|
payload.musicTaskIds = firebase.firestore.FieldValue.delete()
|
|
payload.musicTimestamps = firebase.firestore.FieldValue.delete()
|
|
payload.lyricsSyncStatus = firebase.firestore.FieldValue.delete()
|
|
payload.lyricsSyncError = firebase.firestore.FieldValue.delete()
|
|
payload.lyricsSyncDiagnostics = firebase.firestore.FieldValue.delete()
|
|
}
|
|
|
|
await updateProjectData(payload)
|
|
} catch (_error) {}
|
|
}, [isRegenerationFlow, musicConfig, selectedProjectId, updateProjectData])
|
|
|
|
const spendCoinsForGeneration = useCallback(async () => {
|
|
if (!currentUID) {
|
|
throw new Error('Utilisateur introuvable. Merci de réessayer.')
|
|
}
|
|
|
|
try {
|
|
const functionsClient = getFunctionsClient(FUNCTIONS_REGION)
|
|
const createSongOrder = functionsClient.httpsCallable('orders-createSongOrder')
|
|
|
|
await createSongOrder({
|
|
amount: -MUSIC_GENERATION_COIN_COST,
|
|
songId: selectedProjectId || null,
|
|
source: 'music_generation',
|
|
})
|
|
} catch (error) {
|
|
const message =
|
|
typeof error?.message === 'string'
|
|
? error.message.replace(/^functions\.https\.HttpsError:\s*/iu, '')
|
|
: null
|
|
throw new Error(
|
|
message || 'Une erreur est survenue lors de la création de la commande de crédits.'
|
|
)
|
|
}
|
|
}, [currentUID, selectedProjectId])
|
|
|
|
const handleConfirmGeneration = useCallback(async () => {
|
|
if (isProcessingConfirmation) return
|
|
setIsProcessingConfirmation(true)
|
|
try {
|
|
const availableCoins = Number.isFinite(coinBalance) ? coinBalance : 0
|
|
if (availableCoins < MUSIC_GENERATION_COIN_COST) {
|
|
setIsConfirmVisible(false)
|
|
AppAlert(
|
|
'Crédits insuffisants',
|
|
"Tu n'as pas assez de pièces pour générer une musique. Recharge ton compte pour continuer."
|
|
)
|
|
openCoinPackModal()
|
|
return
|
|
}
|
|
|
|
await spendCoinsForGeneration()
|
|
await persistMusicConfig()
|
|
setIsConfirmVisible(false)
|
|
navigate(Routes.GeneratingSong, { config: musicConfig })
|
|
} catch (error) {
|
|
setIsConfirmVisible(false)
|
|
const message =
|
|
error?.message || 'Une erreur est survenue lors du lancement de la génération.'
|
|
AppAlert('Impossible de lancer la génération', message)
|
|
} finally {
|
|
setIsProcessingConfirmation(false)
|
|
}
|
|
}, [
|
|
coinBalance,
|
|
isProcessingConfirmation,
|
|
musicConfig,
|
|
persistMusicConfig,
|
|
spendCoinsForGeneration,
|
|
])
|
|
|
|
const onPressNext = () => {
|
|
if (selectedIndex === steps.length - 1) {
|
|
setIsConfirmVisible(true)
|
|
return
|
|
}
|
|
setSelectedIndex((prev) => Math.min(prev + 1, steps.length - 1))
|
|
}
|
|
|
|
const onPressBack = () => {
|
|
if (selectedIndex > 0) {
|
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
|
} else {
|
|
if (fromLyrics) {
|
|
navigate(Routes.BottomTab, {
|
|
screen: Routes.HomeStack,
|
|
params: {
|
|
screen: Routes.Home,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
goBack()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Page
|
|
backgroundImg={background.studioBG2}
|
|
backgroundContent={
|
|
isFocused ? (
|
|
<BackgroundVideo
|
|
source={videos?.malikWeb}
|
|
soundButtonStyle={styles.rightSideControl}
|
|
/>
|
|
) : null
|
|
}
|
|
coinBadgeContainerStyle={styles.rightSideCredits}
|
|
headerType="NONE"
|
|
>
|
|
<MusicLandHeader
|
|
onPressBack={onPressBack}
|
|
progress={progress}
|
|
// logo={icons.musicLandStudio}
|
|
/>
|
|
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
|
|
<View style={{ flex: 1 }} onLayout={(event) => setParentLayout(event.nativeEvent.layout)}>
|
|
<FlatList
|
|
ref={scrollRef}
|
|
data={steps}
|
|
keyExtractor={(item) => item.key}
|
|
horizontal
|
|
pagingEnabled
|
|
scrollEnabled={false}
|
|
showsHorizontalScrollIndicator={false}
|
|
initialScrollIndex={selectedIndex}
|
|
getItemLayout={getItemLayout}
|
|
style={{ width: containerWidth }}
|
|
renderItem={({ item }) => (
|
|
<View
|
|
style={{
|
|
width: containerWidth,
|
|
height: parentLayout?.height,
|
|
paddingHorizontal: gutters,
|
|
}}
|
|
>
|
|
{item.render()}
|
|
</View>
|
|
)}
|
|
/>
|
|
</View>
|
|
{selectedIndex !== steps.length && (
|
|
<View style={{ gap: 12 }}>
|
|
{selectedIndex === steps.length - 1 && isRegenerationFlow && (
|
|
<BorderGradientButton
|
|
title="Annuler la nouvelle génération"
|
|
onPress={handleCancelGeneration}
|
|
/>
|
|
)}
|
|
<GradientButton
|
|
title={selectedIndex === steps.length - 1 ? 'Générer' : 'Suivant'}
|
|
onPress={onPressNext}
|
|
disabled={!isStepValid}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Modal
|
|
animationType="slide"
|
|
transparent
|
|
visible={isConfirmVisible}
|
|
onRequestClose={() => {
|
|
if (isProcessingConfirmation) return
|
|
setIsConfirmVisible(false)
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingHorizontal: gutters,
|
|
paddingVertical: gutters * 1.5,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
}}
|
|
>
|
|
<CreateLyricsHeader
|
|
containerStyle={{
|
|
width: '100%',
|
|
maxWidth: CONFIRM_MODAL_MAX_WIDTH,
|
|
alignSelf: 'center',
|
|
paddingVertical: 24,
|
|
paddingHorizontal: 24,
|
|
gap: 24,
|
|
}}
|
|
>
|
|
<View style={{ gap: 30, alignItems: 'center' }}>
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 15,
|
|
gap: 12,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Générer la musique ?
|
|
</Text>
|
|
<View style={{ alignItems: 'center', gap: 8 }}>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Cette action coûte
|
|
</Text>
|
|
<CreditAmount
|
|
value={MUSIC_GENERATION_COIN_COST}
|
|
textStyle={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
textAlign: 'center',
|
|
}}
|
|
iconSize={18}
|
|
/>
|
|
</View>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Souhaites-tu les utiliser pour lancer la génération ?
|
|
</Text>
|
|
</View>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Solde disponible :
|
|
</Text>
|
|
<CreditAmount
|
|
value={coinBalance}
|
|
textStyle={{
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
textAlign: 'center',
|
|
}}
|
|
iconSize={16}
|
|
/>
|
|
</View>
|
|
</View>
|
|
<View style={{ width: '85%', alignSelf: 'center', gap: 15 }}>
|
|
<GradientButton
|
|
title="Confirmer"
|
|
onPress={handleConfirmGeneration}
|
|
disabled={isProcessingConfirmation}
|
|
/>
|
|
<BorderGradientButton
|
|
title="Annuler"
|
|
onPress={() => {
|
|
if (isProcessingConfirmation) return
|
|
setIsConfirmVisible(false)
|
|
}}
|
|
disabled={isProcessingConfirmation}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</CreateLyricsHeader>
|
|
</View>
|
|
</Modal>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
export default ComposeSong
|
|
|
|
const styles = StyleSheet.create({
|
|
rightSideControl: {
|
|
right: 24,
|
|
left: 'auto',
|
|
},
|
|
rightSideCredits: {
|
|
right: 24,
|
|
left: 'auto',
|
|
alignItems: 'flex-end',
|
|
},
|
|
})
|