Files
musicland/src/screens/Studio/ComposeSong.web.js
T
2025-11-20 11:26:24 +01:00

513 lines
16 KiB
JavaScript

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRoute } from "@react-navigation/native";
import { Dimensions, FlatList, Modal, Text, View } from "react-native";
import { background } from "../../assets";
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 { width: windowWidth } = Dimensions.get("window");
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 ComposeSong = () => {
const scrollRef = useRef(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [progress, setProgress] = useState(18);
const [parentLayout, setParentLayout] = useState(null);
const containerWidth = parentLayout?.width || windowWidth || 1;
const route = useRoute();
const {
selectedProjectId,
selectedProject,
updateProjectData,
currentUserData,
currentUID,
} = 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 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(),
};
if (!isRegenerationFlow) {
payload.musicUrls = 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 {
goBack();
}
};
return (
<Page backgroundImg={background.studioBG2} 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;