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: () => , }, ...VOICE_SECTION_TITLES.map((section) => ({ key: `voice-${section}`, render: () => , })), { key: 'instruments', render: () => , }, { key: 'rhythm', render: () => , }, ], [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 ( ) : null } coinBadgeContainerStyle={styles.rightSideCredits} headerType="NONE" > setParentLayout(event.nativeEvent.layout)}> item.key} horizontal pagingEnabled scrollEnabled={false} showsHorizontalScrollIndicator={false} initialScrollIndex={selectedIndex} getItemLayout={getItemLayout} style={{ width: containerWidth }} renderItem={({ item }) => ( {item.render()} )} /> {selectedIndex !== steps.length && ( {selectedIndex === steps.length - 1 && isRegenerationFlow && ( )} )} { if (isProcessingConfirmation) return setIsConfirmVisible(false) }} > Générer la musique ? Cette action coûte Souhaites-tu les utiliser pour lancer la génération ? Solde disponible : { if (isProcessingConfirmation) return setIsConfirmVisible(false) }} disabled={isProcessingConfirmation} /> ) } export default ComposeSong const styles = StyleSheet.create({ rightSideControl: { right: 24, left: 'auto', }, rightSideCredits: { right: 24, left: 'auto', alignItems: 'flex-end', }, })