import React, { useCallback, useMemo, useRef, useState } from 'react' import { ScrollView, StyleSheet, Text, View } from 'react-native' import { background, icons } from '../../assets' import alert from '../../components/Alert' import AppCheckbox from '../../components/AppCheckbox' import BorderGradientButton from '../../components/BorderGradientButton' import GradientButton from '../../components/GradientButton' import ItemContainer from '../../components/ItemContainer/ItemContainer' import MusicLandHeader from '../../components/MusicLandHeader' import Overlay from '../../components/Overlay' import firebase, { getFunctionsClient, projectsRef } from '../../config/firebase' import { strings } from '../../constants/strings' import { isWeb } from '../../hooks/useLayoutType' import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { navigate } from '../../navigation/NavigationService' import { LoaderIndicator } from '../../providers/LoadingProvider' import { useUser } from '../../providers/UserDataProvider' import { Palette, gutters } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' import { formatStructureLabel, getPromptLabelForStructure, getSegmentMeta, normalizeStructureType, sanitizeStructureList, segmentRequiresLyrics, } from '../../utils/songStructure' import { getStageAction } from '../../utils/projectStages' import CustomInput from './components/CustomInput' const RESPONSIBILITY_CHECKBOX_LABEL = 'Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.' const FUNCTIONS_REGION = 'europe-west1' const Lyrics = ({ navigation }) => { const scrollRef = useRef(null) const [containerLayout, setContainerLayout] = useState(null) const { selectedProjectId, selectedProject } = useUser() const hasExistingMusicDraft = useMemo(() => { if (!Array.isArray(selectedProject?.musicUrls)) return false return selectedProject.musicUrls.some((url) => typeof url === 'string' && url.trim()) }, [selectedProject?.musicUrls]) const [isFocus, setIsFocus] = useState(null) const [itemsContainerLayout, setItemsContainerLayout] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const [isBlockingUI, setIsBlockingUI] = useState(false) const isSubmittingRef = useRef(false) const [sensitiveContentModal, setSensitiveContentModal] = useState({ visible: false, title: '', message: '', }) const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] = useState(false) const sensitiveContentResolverRef = useRef(null) const updateLayoutAtIndex = useCallback((index, layout) => { if (typeof index !== 'number' || !layout) return setItemsContainerLayout((prev) => { const next = [...prev] next[index] = layout return next }) }, []) const handleSectionFocus = useCallback( (idx) => { setIsFocus(idx) if (isWeb) return const targetLayout = itemsContainerLayout[idx + 1] if (typeof targetLayout?.y === 'number') { scrollRef?.current?.scrollTo({ y: targetLayout.y, animated: true, }) } }, [itemsContainerLayout, isWeb] ) const closeSensitiveContentModal = useCallback((result) => { setSensitiveContentModal((prev) => ({ ...prev, visible: false })) setIsSensitiveContentAcknowledged(false) if (sensitiveContentResolverRef.current) { sensitiveContentResolverRef.current(result) sensitiveContentResolverRef.current = null } }, []) // Effective sources from provider only const projectTitle = selectedProject?.title || '' const projectLyrics = Array.isArray(selectedProject?.lyrics) ? selectedProject.lyrics : [] const projectConfig = selectedProject?.config || null const projectSelections = selectedProject?.selections || null const projectHasLyrics = !!selectedProject?.hasLyrics const initial = useMemo(() => { const title = projectTitle || '' const normalizedSections = Array.isArray(projectLyrics) ? projectLyrics.map((s) => ({ type: normalizeStructureType(s?.type), lyrics: s?.lyrics || '', })) : [] const targetStructure = Array.isArray(projectConfig?.structure) ? sanitizeStructureList(projectConfig.structure) : null const structureToUse = targetStructure && targetStructure.length ? targetStructure : normalizedSections.map((s) => s.type) if (!structureToUse.length && normalizedSections.length) { return { title, sections: normalizedSections } } const remaining = [...normalizedSections] const takeMatching = (type) => { const index = remaining.findIndex((s) => s.type === type) if (index !== -1) { return remaining.splice(index, 1)[0] } return remaining.shift() || null } const sections = structureToUse.map((segmentType) => { const normalizedType = normalizeStructureType(segmentType) if (!segmentRequiresLyrics(normalizedType)) { return { type: normalizedType, lyrics: '' } } const matched = takeMatching(normalizedType) return { type: normalizedType, lyrics: matched?.lyrics || '', } }) const remainingTextual = remaining.filter((item) => segmentRequiresLyrics(item.type)) sections.push(...remainingTextual) return { title, sections } }, [projectTitle, projectLyrics, projectConfig]) const [titleValue, setTitleValue] = useState(initial.title || '') const [sections, setSections] = useState(initial.sections || []) const setSectionAt = (index, value) => { setSections((prev) => { const next = [...prev] if (next[index]) next[index] = { ...next[index], lyrics: value } return next }) } // Plus d'appel direct à la cloud function ici: on retourne à l'étape précédente const regenerate = useCallback(() => { navigate(Routes.CreateLyricsWithAi) }, []) const sanitize = (obj) => { if (obj === undefined) return null if (obj === null) return null if (Array.isArray(obj)) return obj.map((v) => sanitize(v)) if (typeof obj === 'object') { const out = {} Object.keys(obj).forEach((k) => { const v = obj[k] if (v === undefined) return // omit undefined out[k] = sanitize(v) }) return out } return obj } const alertMessage = useCallback((title, message) => { alert(title, message) }, []) const confirmSensitiveContent = useCallback((title, message) => { return new Promise((resolve) => { sensitiveContentResolverRef.current = resolve setSensitiveContentModal({ visible: true, title, message, }) setIsSensitiveContentAcknowledged(false) }) }, []) const handleSensitiveCancel = useCallback(() => { closeSensitiveContentModal(false) }, [closeSensitiveContentModal]) const handleSensitiveConfirm = useCallback(() => { if (!isSensitiveContentAcknowledged) return closeSensitiveContentModal(true) }, [closeSensitiveContentModal, isSensitiveContentAcknowledged]) const onValidate = useCallback(async () => { if (isSubmittingRef.current) return isSubmittingRef.current = true setIsSubmitting(true) setIsBlockingUI(true) try { const titleTrimmed = (titleValue || '').trim() if (!titleTrimmed) { setIsBlockingUI(false) alertMessage('Titre manquant', 'Veuillez renseigner un titre.') return } const invalid = (sections || []).some((s) => { const type = normalizeStructureType(s?.type) if (!segmentRequiresLyrics(type)) return false return !(s?.lyrics || '').trim() }) if (invalid) { setIsBlockingUI(false) alertMessage('Champs incomplets', 'Chaque section doit contenir du texte.') return } const normalizedNewLyrics = (sections || []).map((s) => ({ type: normalizeStructureType(s?.type), lyrics: segmentRequiresLyrics(normalizeStructureType(s?.type)) ? (s?.lyrics || '').trim() : '', })) const normalizedOldLyrics = Array.isArray(projectLyrics) ? projectLyrics.map((s) => ({ type: normalizeStructureType(s?.type), lyrics: (s?.lyrics || '').trim(), })) : [] const sameLength = normalizedOldLyrics.length === normalizedNewLyrics.length const isSame = sameLength && normalizedOldLyrics.every( (s, i) => s.type === normalizedNewLyrics[i]?.type && s.lyrics === normalizedNewLyrics[i]?.lyrics ) // 1) Appel de la Cloud Function de modération avant tout enregistrement try { const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( 'lyrics-analyseLyricsToxicity' ) const { data } = await callable({ title: titleTrimmed, lyrics: normalizedNewLyrics, }) if (!data?.success) { // Blocage dur: afficher le message et ne pas sauvegarder if (data?.errorCode === 'TOXIC_CONTENT_BLOCKED') { const quotes = Array.isArray(data?.result?.excerpts) ? data.result.excerpts .slice(0, 3) .map((e) => `• ${e.quote}`) .join('\n') : null setIsBlockingUI(false) alertMessage('Contenu interdit', [data?.message, quotes].filter(Boolean).join('\n\n')) return // stop here } // Erreur d'analyse: informer et arrêter if (data?.errorCode === 'ANALYSE_FAILED') { setIsBlockingUI(false) alertMessage( 'Analyse indisponible', 'Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.' ) return } } // Cas signalé mais non bloquant: demander confirmation if (data?.errorCode === 'TOXIC_CONTENT_FLAGGED') { const quotes = Array.isArray(data?.result?.excerpts) ? data.result.excerpts .slice(0, 3) .map((e) => `• ${e.quote}`) .join('\n') : null const msg = [data?.message, quotes].filter(Boolean).join('\n\n') setIsBlockingUI(false) const proceed = await confirmSensitiveContent('Contenu potentiellement sensible', msg) if (!proceed) return setIsBlockingUI(true) } } catch (moderationError) { console.log('Moderation call failed', moderationError?.message || moderationError) setIsBlockingUI(false) alertMessage( 'Analyse indisponible', 'Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.' ) return } const baseData = { title: titleTrimmed, lyrics: normalizedNewLyrics, config: sanitize(projectConfig), selections: sanitize(projectSelections), updatedAt: firebase.firestore.FieldValue.serverTimestamp(), hasLyrics: projectHasLyrics, } const updateData = { ...baseData } if (!isSame) { updateData.musicUrls = firebase.firestore.FieldValue.delete() updateData.musicStatus = firebase.firestore.FieldValue.delete() } await projectsRef.doc(selectedProjectId).set(updateData, { merge: true }) const projectForStage = { ...selectedProject, title: titleTrimmed, lyrics: normalizedNewLyrics, config: sanitize(projectConfig), selections: sanitize(projectSelections), hasLyrics: true, } if (!isSame) { projectForStage.musicUrls = undefined projectForStage.musicStatus = undefined } const beatmakerStage = getStageAction('beatmaker', projectForStage) setIsBlockingUI(false) if (hasExistingMusicDraft) { navigate(Routes.ComposeSong, { isRegeneration: true, fromLyrics: true }) return } const handleNavigateToStudio = () => { const targetRoute = beatmakerStage?.route || Routes.ComposeSong navigate(targetRoute, { ...(beatmakerStage?.params || {}), fromLyrics: true, }) } alert( 'Céline', "Bravo, tu as créé les paroles de ta chanson! Prochaine étape, le studio! Malik t'attends pour mettre ça en musique! A bientôt! and...Feel The Good Vibe!", [ { text: "Retour à l'accueil", style: 'cancel', onPress: () => navigate(Routes.BottomTab, { screen: Routes.HomeStack, params: { screen: Routes.Home, }, }), }, { text: 'Continuer vers le Studio', onPress: handleNavigateToStudio, }, ] ) return } catch (e) { console.log(e) setIsBlockingUI(false) alertMessage('Erreur', "Échec de l'enregistrement dans le projet.") } finally { isSubmittingRef.current = false setIsSubmitting(false) setIsBlockingUI(false) } }, [ titleValue, sections, projectConfig, projectSelections, alertMessage, confirmSensitiveContent, sanitize, selectedProjectId, projectHasLyrics, projectLyrics, selectedProject, hasExistingMusicDraft, ]) const typeOccurrences = {} return ( navigate(Routes.Home)} progress={95} /> setContainerLayout(e.nativeEvent.layout)} > {strings.writing.lyrics.title} {strings.writing.lyrics.instructions} Tu peux personnaliser le texte proposé, apporter ta touche personnelle mais respecte la structure et le nombre de syllabes pour un résultat optimal { updateLayoutAtIndex(0, e?.nativeEvent?.layout) }} /> {sections.map((s, idx) => { const normalizedType = normalizeStructureType(s?.type) const key = normalizedType || 'section' typeOccurrences[key] = (typeOccurrences[key] || 0) + 1 const occurrence = typeOccurrences[key] const label = formatStructureLabel(key, occurrence) const meta = getSegmentMeta(key) const inputHeight = typeof meta?.inputHeight === 'number' ? meta.inputHeight : key === 'refrain' ? 170 : 225 const placeholder = meta?.label || label const requiresLyrics = segmentRequiresLyrics(key) if (!requiresLyrics) { return ( {label} {getPromptLabelForStructure(key)} — section instrumentale sans paroles. ) } return ( setSectionAt(idx, val)} onFocus={() => handleSectionFocus(idx)} onLayout={(e) => { updateLayoutAtIndex(idx + 1, e?.nativeEvent?.layout) }} /> ) })} {!projectHasLyrics && ( )} {isBlockingUI ? ( ) : null} { if (visible === false) { handleSensitiveCancel() } }} contentContainerStyle={{ alignItems: 'center', justifyContent: 'center', }} > {sensitiveContentModal.title} {sensitiveContentModal.message} setIsSensitiveContentAcknowledged((prev) => !prev)} label={RESPONSIBILITY_CHECKBOX_LABEL} /> ) } export default Lyrics const styles = StyleSheet.create({ headerContainer: { gap: 8, paddingHorizontal: 2, }, headerTitle: { color: Palette.white, fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 20, }, instructions: { color: Palette.white, fontFamily: FONT_FAMILY.InterRegular, fontSize: 14, lineHeight: 20, }, personalizationText: { color: Palette.white, fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 18, lineHeight: 24, marginTop: 8, }, instrumentalBlock: { padding: 16, borderRadius: 12, backgroundColor: Palette.ultraLightWhite, gap: 6, }, instrumentalTitle: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 16, color: Palette.white, }, instrumentalText: { fontFamily: FONT_FAMILY.InterRegular, fontSize: 14, color: Palette.white, opacity: 0.8, }, sensitiveModal: { width: '90%', maxWidth: 460, backgroundColor: Palette.lightPurple, borderRadius: 18, paddingVertical: 24, paddingHorizontal: 20, gap: 16, }, sensitiveModalTitle: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 20, color: Palette.white, textAlign: 'center', }, sensitiveModalMessage: { fontFamily: FONT_FAMILY.InterRegular, fontSize: 15, color: Palette.white, lineHeight: 20, textAlign: 'left', opacity: 0.9, }, sensitiveCheckboxWrapper: { paddingVertical: 4, }, sensitiveModalActions: { flexDirection: 'row', gap: 12, }, loadingOverlay: { ...StyleSheet.absoluteFillObject, zIndex: 10000, backgroundColor: Palette.transparentBlack, justifyContent: 'center', alignItems: 'center', }, })