Files
musicland/src/screens/Writing/Lyrics.js
T
Thomas Demirdjian 016f7c27ac end of tickets
2025-11-17 15:54:54 +01:00

622 lines
20 KiB
JavaScript

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 (
<Page
backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG}
headerType="NONE"
>
<MusicLandHeader
onPressBack={() => navigate(Routes.Home)}
progress={95}
logo={icons.musicLandWriting}
/>
<View
style={{
flex: 1,
marginTop: 16,
gap: 48,
}}
onLayout={(e) => setContainerLayout(e.nativeEvent.layout)}
>
<ItemContainer
height={containerLayout?.height}
disableKeyboardHeight={isFocus !== sections.length - 1 || !isFocus}
>
<ScrollView
ref={scrollRef}
contentContainerStyle={{
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
flexGrow: 1,
}}
>
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}>
{strings.writing.lyrics.title}
</Text>
<Text style={styles.instructions}>
{strings.writing.lyrics.instructions}
</Text>
<View style={styles.personalizationBanner}>
<Text style={styles.personalizationText}>
{strings.writing.lyrics.personalizationBanner}
</Text>
</View>
</View>
<CustomInput
label="Titre"
placeholder="Titre"
height={45}
value={titleValue}
setValue={setTitleValue}
multiline={false}
maxLength={60}
onLayout={(e) => {
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 (
<View key={idx} style={styles.instrumentalBlock}>
<Text style={styles.instrumentalTitle}>{label}</Text>
<Text style={styles.instrumentalText}>
{getPromptLabelForStructure(key)} section instrumentale
sans paroles.
</Text>
</View>
);
}
return (
<CustomInput
key={idx}
label={label}
placeholder={placeholder}
height={inputHeight}
value={s?.lyrics || ""}
setValue={(val) => setSectionAt(idx, val)}
onFocus={() => {
setIsFocus(idx);
scrollRef?.current?.scrollTo({
y: itemsContainerLayout[idx + 1]?.y,
});
}}
onLayout={(e) => {
e.persist();
setItemsContainerLayout((prev) => [
...prev,
e?.nativeEvent?.layout,
]);
}}
/>
);
})}
</ScrollView>
</ItemContainer>
</View>
<View
style={{
paddingTop: gutters,
paddingBottom: gutters * 2,
paddingHorizontal: gutters,
gap: 12,
}}
>
{!projectHasLyrics && (
<BorderGradientButton
title="Générer d'autres paroles"
onPress={regenerate}
/>
)}
<GradientButton title="Valider" onPress={onValidate} />
</View>
<Overlay
isVisible={sensitiveContentModal.visible}
setIsVisible={(visible) => {
if (visible === false) {
handleSensitiveCancel();
}
}}
contentContainerStyle={{
alignItems: "center",
justifyContent: "center",
}}
>
<View style={styles.sensitiveModal}>
<Text style={styles.sensitiveModalTitle}>
{sensitiveContentModal.title}
</Text>
<Text style={styles.sensitiveModalMessage}>
{sensitiveContentModal.message}
</Text>
<View style={styles.sensitiveCheckboxWrapper}>
<AppCheckbox
selected={isSensitiveContentAcknowledged}
onPress={() => setIsSensitiveContentAcknowledged((prev) => !prev)}
label={RESPONSIBILITY_CHECKBOX_LABEL}
/>
</View>
<View style={styles.sensitiveModalActions}>
<BorderGradientButton
title="Annuler"
onPress={handleSensitiveCancel}
containerStyle={{ flex: 1 }}
/>
<GradientButton
title="Continuer"
onPress={handleSensitiveConfirm}
disabled={!isSensitiveContentAcknowledged}
containerStyle={{ flex: 1 }}
/>
</View>
</View>
</Overlay>
</Page>
);
};
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,
},
});