386 lines
12 KiB
JavaScript
386 lines
12 KiB
JavaScript
import React, { useMemo, useRef, useState } from "react";
|
|
import { Dimensions, Modal, Text, View } from "react-native";
|
|
import SwiperFlatList from "react-native-swiper-flatlist";
|
|
import { background, icons } from "../../assets";
|
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
|
import GradientButton from "../../components/GradientButton";
|
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
|
import AppAlert from "../../components/Alert";
|
|
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 } = Dimensions.get("window");
|
|
const MUSIC_GENERATION_COIN_COST = 8;
|
|
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
|
|
|
const ComposeSong = () => {
|
|
const scrollRef = useRef(null);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [progress, setProgress] = useState(18);
|
|
const [containerLayout, setContainerLayout] = useState(null);
|
|
const {
|
|
selectedProjectId,
|
|
selectedProject,
|
|
updateProjectData,
|
|
currentUserData,
|
|
currentUID,
|
|
} = useUser();
|
|
|
|
// Selections state
|
|
const [genres, setGenres] = useState([]);
|
|
// voice: object keyed by category (e.g., { base: string|null, Sensibilité: string|null, Technique: string|null })
|
|
const [voice, setVoice] = useState({});
|
|
const [instruments, setInstruments] = useState([]);
|
|
const [rhythm, setRhythm] = useState(null);
|
|
|
|
const [isConfirmVisible, setIsConfirmVisible] = useState(false);
|
|
const [isProcessingConfirmation, setIsProcessingConfirmation] =
|
|
useState(false);
|
|
|
|
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 formattedCoinBalance = useMemo(() => {
|
|
try {
|
|
return new Intl.NumberFormat("fr-FR", {
|
|
maximumFractionDigits: 0,
|
|
}).format(coinBalance);
|
|
} catch (_error) {
|
|
return `${coinBalance}`;
|
|
}
|
|
}, [coinBalance]);
|
|
|
|
const isStepValid = useMemo(() => {
|
|
switch (selectedIndex) {
|
|
case 0:
|
|
return Array.isArray(genres) && genres.length > 0;
|
|
case 1:
|
|
// Must select at least one in base category
|
|
return !!(voice && typeof voice === "object" && voice.BASE);
|
|
case 2:
|
|
return Array.isArray(instruments) && instruments.length > 0;
|
|
case 3:
|
|
return !!rhythm;
|
|
default:
|
|
return true;
|
|
}
|
|
}, [selectedIndex, genres, voice, instruments, rhythm]);
|
|
|
|
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]);
|
|
|
|
const persistMusicConfig = async () => {
|
|
try {
|
|
if (!selectedProjectId) return;
|
|
await updateProjectData({
|
|
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(),
|
|
musicUrls: firebase.firestore.FieldValue.delete(),
|
|
});
|
|
} catch (e) {}
|
|
};
|
|
|
|
const spendCoinsForGeneration = async () => {
|
|
if (!currentUID) {
|
|
throw new Error("Utilisateur introuvable. Merci de réessayer.");
|
|
}
|
|
|
|
try {
|
|
const functionsClient = getFunctionsClient();
|
|
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.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleConfirmGeneration = 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);
|
|
}
|
|
};
|
|
|
|
const onPressNext = () => {
|
|
if (selectedIndex === 3) {
|
|
setIsConfirmVisible(true);
|
|
return;
|
|
}
|
|
setSelectedIndex(selectedIndex + 1);
|
|
setProgress(progress + 9);
|
|
scrollRef.current.scrollToIndex({
|
|
index: selectedIndex + 1,
|
|
animated: true,
|
|
});
|
|
};
|
|
|
|
const onPressBack = () => {
|
|
if (selectedIndex > 0) {
|
|
setSelectedIndex(selectedIndex - 1);
|
|
setProgress(progress - 9);
|
|
scrollRef.current.scrollToIndex({
|
|
index: selectedIndex - 1,
|
|
animated: true,
|
|
});
|
|
} 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) => setContainerLayout(event.nativeEvent.layout)}
|
|
>
|
|
<SwiperFlatList
|
|
style={{ width, left: -gutters }}
|
|
ref={scrollRef}
|
|
disableGesture
|
|
>
|
|
<View
|
|
style={{
|
|
width: width,
|
|
height: containerLayout?.height,
|
|
paddingHorizontal: gutters,
|
|
}}
|
|
>
|
|
<ChooseGenre selected={genres} setSelected={setGenres} />
|
|
</View>
|
|
<View
|
|
style={{
|
|
width: width,
|
|
height: containerLayout?.height,
|
|
paddingHorizontal: gutters,
|
|
}}
|
|
>
|
|
<CustomizeVoice selected={voice} setSelected={setVoice} />
|
|
</View>
|
|
<View
|
|
style={{
|
|
width: width,
|
|
height: containerLayout?.height,
|
|
paddingHorizontal: gutters,
|
|
}}
|
|
>
|
|
<ChooseInstruments
|
|
selected={instruments}
|
|
setSelected={setInstruments}
|
|
/>
|
|
</View>
|
|
<View
|
|
style={{
|
|
width: width,
|
|
height: containerLayout?.height,
|
|
paddingHorizontal: gutters,
|
|
}}
|
|
>
|
|
<ChooseRhythm selected={rhythm} setSelected={setRhythm} />
|
|
</View>
|
|
</SwiperFlatList>
|
|
</View>
|
|
{selectedIndex !== 4 && (
|
|
<GradientButton
|
|
title={selectedIndex === 3 ? "Générer" : "Suivant"}
|
|
onPress={onPressNext}
|
|
disabled={!isStepValid}
|
|
/>
|
|
)}
|
|
</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>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
Cette action coûte {MUSIC_GENERATION_COIN_COST} pièces.
|
|
{"\n"}Souhaites-tu les utiliser pour lancer la génération ?
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
Solde disponible : {formattedCoinBalance} pièces
|
|
</Text>
|
|
</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;
|