import React, { useCallback, useMemo, useRef, useState } from "react"; import { ScrollView, StyleSheet, Text, View } from "react-native"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; 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, { 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 { 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 Lyrics = ({ navigation }) => { const scrollRef = useRef(null); const [containerLayout, setContainerLayout] = useState(null); const { setIsLoading } = useMinuit(); 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 [sensitiveContentModal, setSensitiveContentModal] = useState({ visible: false, title: "", message: "", }); const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] = useState(false); const sensitiveContentResolverRef = useRef(null); 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 () => { try { await setIsLoading(true); const titleTrimmed = (titleValue || "").trim(); if (!titleTrimmed) { 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) { 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 = firebase .functions() .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; 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") { 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"); const proceed = await confirmSensitiveContent( "Contenu potentiellement sensible", msg ); if (!proceed) return; } } catch (moderationError) { console.log( "Moderation call failed", moderationError?.message || moderationError ); 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); await setIsLoading(false); if (hasExistingMusicDraft) { navigate(Routes.ComposeSong, { isRegeneration: true }); return; } const handleNavigateToStudio = () => { const targetRoute = beatmakerStage?.route || Routes.Compose; navigate(targetRoute, beatmakerStage?.params); }; alert( "Céline", "Bravo ! Vous venez de terminer de créer les paroles de votre musique ! Maintenant vous pouvez passer à la prochaine étape : le Studio pour donner vie à votre chanson !", [ { 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); alertMessage("Erreur", "Échec de l'enregistrement dans le projet."); } finally { await setIsLoading(false); } }, [ titleValue, sections, projectConfig, projectSelections, alertMessage, confirmSensitiveContent, sanitize, setIsLoading, selectedProjectId, projectHasLyrics, projectLyrics, selectedProject, hasExistingMusicDraft, ]); const typeOccurrences = {}; return ( navigate(Routes.Home)} progress={95} logo={icons.musicLandWriting} /> setContainerLayout(e.nativeEvent.layout)} > {strings.writing.lyrics.title} {strings.writing.lyrics.instructions} {strings.writing.lyrics.personalizationBanner} { e.persist(); setItemsContainerLayout((prev) => [ ...prev, 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={() => { setIsFocus(idx); scrollRef?.current?.scrollTo({ y: itemsContainerLayout[idx + 1]?.y, }); }} onLayout={(e) => { e.persist(); setItemsContainerLayout((prev) => [ ...prev, e?.nativeEvent?.layout, ]); }} /> ); })} {!projectHasLyrics && ( )} { 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, }, personalizationBanner: { backgroundColor: Palette.ultraLightWhite, borderRadius: 12, paddingVertical: 10, paddingHorizontal: 12, marginTop: 4, }, personalizationText: { color: Palette.white, fontFamily: FONT_FAMILY.InterMedium, fontSize: 14, lineHeight: 20, }, 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, }, });