391 lines
13 KiB
JavaScript
391 lines
13 KiB
JavaScript
import { useIsFocused } from "@react-navigation/native";
|
|
import { BlurView } from "expo-blur";
|
|
import moment from "moment";
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Image, Platform, Text, View } from "react-native";
|
|
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
|
import { ai, background } from "../../assets";
|
|
import AppAlert from "../../components/Alert";
|
|
import GradientButton from "../../components/GradientButton";
|
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
|
import ProgressBar from "../../components/ProgressBar";
|
|
import firebase, { projectsRef } from "../../config/firebase";
|
|
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 } from "../../styles";
|
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
|
|
|
const GeneratingSong = () => {
|
|
const { selectedProjectId, selectedProject } = useUser();
|
|
const [progress, setProgress] = useState(0);
|
|
const { setIsLoading } = useMinuit();
|
|
const progressTimerRef = useRef(null);
|
|
const navigatedRef = useRef(false);
|
|
const isFocused = useIsFocused();
|
|
const askedRef = useRef(false);
|
|
const callingRef = useRef(false);
|
|
const localStartRef = useRef(null);
|
|
const errorAlertShownRef = useRef(false);
|
|
|
|
const isFailed = selectedProject?.musicStatus === "FAILED";
|
|
const musicErrorMessage =
|
|
selectedProject?.musicError?.message ||
|
|
selectedProject?.musicError?.status ||
|
|
"Une erreur est survenue lors de la génération.";
|
|
const hasRefund =
|
|
selectedProject?.musicCreditsRefunded === true ||
|
|
typeof selectedProject?.musicCreditsRefundOrderId === "string";
|
|
|
|
// project loaded from provider
|
|
|
|
// Progress based on an 8 minute cap or until status becomes GENERATED
|
|
useEffect(() => {
|
|
const totalMs = 8 * 60 * 1000;
|
|
const maxGeneratingProgress = 90;
|
|
const clearTimer = () => {
|
|
if (progressTimerRef.current) {
|
|
global.clearInterval(progressTimerRef.current);
|
|
progressTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const status = selectedProject?.musicStatus;
|
|
const readyUrls = Array.isArray(selectedProject?.musicUrls)
|
|
? selectedProject.musicUrls.filter(Boolean)
|
|
: [];
|
|
const hasReadySong = readyUrls.length > 0 || !!selectedProject?.songUrl;
|
|
|
|
if (status !== "GENERATING") {
|
|
// Reset local start when leaving generating state
|
|
localStartRef.current = null;
|
|
}
|
|
|
|
if (status === "GENERATED") {
|
|
clearTimer();
|
|
setProgress(hasReadySong ? 100 : maxGeneratingProgress);
|
|
return () => clearTimer();
|
|
}
|
|
|
|
if (status !== "GENERATING") {
|
|
clearTimer();
|
|
setProgress(0);
|
|
return () => clearTimer();
|
|
}
|
|
|
|
const resolveStartDate = () => {
|
|
const gs = selectedProject?.generationStartAt;
|
|
if (gs?.toDate) return gs.toDate();
|
|
if (gs) return new Date(gs);
|
|
if (status === "GENERATING") {
|
|
if (!localStartRef.current) localStartRef.current = new Date();
|
|
return localStartRef.current;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const update = () => {
|
|
const startDate = resolveStartDate();
|
|
if (!startDate) {
|
|
setProgress(0);
|
|
return;
|
|
}
|
|
const elapsed = moment().diff(moment(startDate));
|
|
const raw = Math.floor((elapsed / totalMs) * 100);
|
|
// While status is GENERATING, block visual progress at 90%
|
|
const pct = Math.max(0, Math.min(maxGeneratingProgress, raw));
|
|
setProgress(pct);
|
|
};
|
|
|
|
update();
|
|
clearTimer();
|
|
progressTimerRef.current = global.setInterval(update, 1000);
|
|
return () => clearTimer();
|
|
}, [
|
|
selectedProject?.musicStatus,
|
|
selectedProject?.generationStartAt,
|
|
selectedProject?.musicUrls,
|
|
selectedProject?.songUrl,
|
|
]);
|
|
|
|
// Auto navigate to SongReady when generation completed
|
|
useEffect(() => {
|
|
if (!selectedProjectId) return;
|
|
if (selectedProject?.musicStatus === "GENERATED" && !navigatedRef.current) {
|
|
navigatedRef.current = true;
|
|
navigate(Routes.SongReady);
|
|
}
|
|
}, [selectedProject?.musicStatus, selectedProjectId]);
|
|
|
|
const effectiveConfig = useMemo(() => {
|
|
// Use selectedProject.musicConfig only
|
|
const cfg = selectedProject?.musicConfig || {};
|
|
return {
|
|
title: cfg?.title || "",
|
|
lyrics: Array.isArray(cfg?.lyrics) ? cfg.lyrics : [],
|
|
genres: Array.isArray(cfg?.genres) ? cfg.genres : [],
|
|
voice: Array.isArray(cfg?.voice) ? cfg.voice : [],
|
|
instruments: Array.isArray(cfg?.instruments) ? cfg.instruments : [],
|
|
tempo: cfg?.tempo || "",
|
|
};
|
|
}, [selectedProject?.musicConfig]);
|
|
|
|
async function startMusicGeneration() {
|
|
try {
|
|
console.log("startMusicGeneration");
|
|
await setIsLoading(true);
|
|
const callable = firebase
|
|
.functions()
|
|
.httpsCallable("music-generateMusic");
|
|
const cfg = effectiveConfig || {};
|
|
const { data } = await callable({
|
|
title: cfg?.title,
|
|
lyrics: cfg?.lyrics,
|
|
genres: cfg?.genres,
|
|
voice: cfg?.voice,
|
|
instruments: cfg?.instruments,
|
|
tempo: cfg?.tempo,
|
|
projectId: selectedProjectId || selectedProject?.id || null,
|
|
});
|
|
const taskId =
|
|
data?.response?.data?.taskId || data?.response?.data?.task_id;
|
|
if (selectedProjectId && taskId) {
|
|
const baseUpdate = {
|
|
sunoTaskId: taskId,
|
|
musicStatus: "GENERATING",
|
|
musicError: firebase.firestore.FieldValue.delete(),
|
|
generationStartAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
};
|
|
const updatePayload = selectedProject?.musicConfig
|
|
? baseUpdate
|
|
: {
|
|
...baseUpdate,
|
|
musicConfig: {
|
|
title: effectiveConfig?.title || "",
|
|
lyrics: effectiveConfig?.lyrics || [],
|
|
genres: effectiveConfig?.genres || [],
|
|
voice: effectiveConfig?.voice || "",
|
|
instruments: effectiveConfig?.instruments || [],
|
|
tempo: effectiveConfig?.tempo || "",
|
|
},
|
|
};
|
|
await projectsRef
|
|
.doc(selectedProjectId)
|
|
.set(updatePayload, { merge: true });
|
|
}
|
|
} catch (e) {
|
|
console.log("GeneratingSong error", e?.message);
|
|
const rawMessage = e?.message || "";
|
|
const sanitized =
|
|
typeof rawMessage === "string"
|
|
? rawMessage.replace(/^INTERNAL:/i, "").trim()
|
|
: "";
|
|
const fallbackMessage =
|
|
e?.details?.message ||
|
|
e?.details?.status ||
|
|
"Le service Suno est indisponible. Veuillez réessayer plus tard.";
|
|
AppAlert(
|
|
"Impossible de générer la musique",
|
|
sanitized || fallbackMessage,
|
|
);
|
|
if (selectedProjectId) {
|
|
try {
|
|
await projectsRef.doc(selectedProjectId).set(
|
|
{
|
|
musicStatus: "FAILED",
|
|
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
|
generationStartAt: firebase.firestore.FieldValue.delete(),
|
|
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
musicError: {
|
|
source: "SUNO_API",
|
|
message: sanitized || fallbackMessage,
|
|
},
|
|
},
|
|
{ merge: true },
|
|
);
|
|
} catch (firestoreError) {
|
|
console.log(
|
|
"GeneratingSong Firestore update error",
|
|
firestoreError?.message,
|
|
);
|
|
}
|
|
}
|
|
askedRef.current = false;
|
|
} finally {
|
|
await setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
// Ensure we never call generation more than once concurrently
|
|
const startMusicGenerationOnce = React.useCallback(() => {
|
|
if (callingRef.current) return;
|
|
callingRef.current = true;
|
|
startMusicGeneration()
|
|
.catch(() => { })
|
|
.finally(() => {
|
|
// Keep locked while status transitions to GENERATING; will be prevented by guards
|
|
global.setTimeout(() => {
|
|
callingRef.current = false;
|
|
}, 500);
|
|
});
|
|
}, [startMusicGeneration]);
|
|
|
|
// Trigger generation only when focused; ask once while idle
|
|
useEffect(() => {
|
|
if (!isFocused) return;
|
|
const status = selectedProject?.musicStatus;
|
|
const titleOk = !!selectedProject?.title;
|
|
// Idle if not actively generating or generated (ignore stale taskId)
|
|
const isIdle =
|
|
status == null ||
|
|
status === "" ||
|
|
status === "PENDING" ||
|
|
status === "READY" ||
|
|
status === "FAILED";
|
|
|
|
if (titleOk && isIdle && !askedRef.current) {
|
|
askedRef.current = true;
|
|
startMusicGenerationOnce();
|
|
}
|
|
}, [
|
|
isFocused,
|
|
selectedProject?.title,
|
|
selectedProject?.musicStatus,
|
|
startMusicGenerationOnce,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (isFailed) {
|
|
if (!errorAlertShownRef.current) {
|
|
errorAlertShownRef.current = true;
|
|
const refundNotice = hasRefund
|
|
? "\n\nTes crédits ont été automatiquement remboursés."
|
|
: "";
|
|
AppAlert("Génération échouée", `${musicErrorMessage}${refundNotice}`);
|
|
}
|
|
} else if (selectedProject?.musicStatus === "GENERATING") {
|
|
errorAlertShownRef.current = false;
|
|
}
|
|
}, [
|
|
hasRefund,
|
|
isFailed,
|
|
musicErrorMessage,
|
|
selectedProject?.musicStatus,
|
|
]);
|
|
|
|
const progressStatus = isFailed ? "error" : "default";
|
|
const progressLabel = isFailed ? "Erreur" : `${progress}%`;
|
|
|
|
return (
|
|
<Page headerType="NONE" backgroundImg={background.studioBG2}>
|
|
<MusicLandHeader
|
|
onPressBack={() => navigate(Routes.Home)}
|
|
progress={63}
|
|
// logo={icons.musicLandStudio}
|
|
/>
|
|
<View style={{ flex: 1, marginTop: 16 }}>
|
|
<CreateLyricsHeader
|
|
title="Ta musique est en cours de création !"
|
|
subTitle="Encore un peu de patience"
|
|
containerStyle={{ marginBottom: responsiveHeight(2) }}
|
|
/>
|
|
<View
|
|
style={{
|
|
marginTop: responsiveHeight(10),
|
|
gap: 16,
|
|
flex: 1, // Added flex: 1 to ensure full height usage
|
|
}}
|
|
>
|
|
<BlurView
|
|
intensity={Platform.OS !== "ios" ? 10 : 40}
|
|
tint="dark"
|
|
style={{
|
|
flex: 1,
|
|
borderRadius: 16,
|
|
overflow: "hidden",
|
|
padding: 12,
|
|
backgroundColor: "#FFFFFF0A",
|
|
}}
|
|
// experimentalBlurMethod={
|
|
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
|
// }
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<BlurView
|
|
intensity={Platform.OS !== "ios" ? 10 : 40}
|
|
tint="dark"
|
|
style={{ flex: 1, padding: 10, paddingBottom: 20 }}
|
|
// experimentalBlurMethod={
|
|
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
|
// }
|
|
>
|
|
<View style={{ flex: 1, justifyContent: "flex-end", gap: 20 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
Ta musique est{"\n"}en cours de création
|
|
</Text>
|
|
<View style={{ alignItems: "center", gap: 16 }}>
|
|
<ProgressBar
|
|
gradient
|
|
progress={progress}
|
|
status={progressStatus}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: isFailed ? Palette.red : Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
}}
|
|
>
|
|
{progressLabel}
|
|
</Text>
|
|
</View>
|
|
{isFailed ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: Palette.red,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: "center",
|
|
lineHeight: 20,
|
|
}}
|
|
>
|
|
{musicErrorMessage}
|
|
{hasRefund
|
|
? "\nTes crédits ont été remboursés automatiquement."
|
|
: "\nTu peux revenir en arrière pour relancer la génération."}
|
|
</Text>
|
|
) : null}
|
|
<GradientButton
|
|
title={
|
|
selectedProject?.musicStatus === "GENERATED"
|
|
? "Découvrir ma musique"
|
|
: "Création en cours..."
|
|
}
|
|
disabled={selectedProject?.musicStatus !== "GENERATED"}
|
|
containerStyle={{ width: "80%", alignSelf: "center" }}
|
|
onPress={() => navigate(Routes.SongReady)}
|
|
/>
|
|
</View>
|
|
</BlurView>
|
|
</View>
|
|
</BlurView>
|
|
</View>
|
|
</View>
|
|
</Page>
|
|
);
|
|
};
|
|
|
|
export default GeneratingSong;
|