Merge branch 'main' of gitlab.com:agenceminuit/musicland

This commit is contained in:
2025-09-03 15:50:36 +02:00
29 changed files with 841 additions and 344 deletions
+10 -18
View File
@@ -5,21 +5,13 @@ const { generateAI } = require("../index");
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
const {
objective = "Célébrer l'agence Minuit et mettre en avant son " +
"expertise digitale, son esprit d'équipe et sa créativité.",
context = "L'agence Minuit accompagne les startups et entreprises " +
"innovantes dans la création de produits digitaux, du prototype " +
"à la version de financement, jusqu'à l'optimisation et la mise " +
"à l'échelle. Spécialisée dans le développement sur-mesure " +
"d'applications mobiles, elle valorise l'humain, le design et " +
"l'accompagnement personnalisé. Esprit nocturne, équipe passionnée.",
emotion = "La Joie : expose un bonheur profond, l'émerveillement, " +
"la gratitude, satisfaction intense, énergie positive.",
style = "Upbeat : Pour une ambiance joyeuse et rythmée.",
audience = "L'équipe Minuit et ses clients fidèles, startups " +
"ambitieuses et partenaires visionnaires.",
objective = "",
context = "",
style = "",
audience = "",
emotion = "",
structure = ["couplet", "refrain", "couplet", "refrain"],
rhymes = "Avec rimes",
rhymes = "",
} = data;
console.log("Function generate lyrics start with data", data);
@@ -69,19 +61,19 @@ Pour chaque ` +
.string()
.describe(
'Type de section : "couplet" ou "refrain" ' +
"selon la structure"
"selon la structure",
),
lyrics: z
.string()
.describe(
"Paroles de la section, chaque ligne séparée " +
"par un retour à la ligne"
"par un retour à la ligne",
),
})
}),
)
.describe(
"Paroles de la chanson sous forme de tableau de sections " +
"structurées."
"structurées.",
),
success: z.boolean().describe("Indique si la génération a réussi"),
});
+4
View File
@@ -230,6 +230,10 @@ export const ai = {
john,
};
export const videos = {
test: require("./video/testVideo.mp4"),
};
export const img = {
placeholder,
placeholder2,
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
import React, { useEffect } from "react";
import { View, Pressable, Text } from "react-native";
import { VideoView, useVideoPlayer } from "expo-video";
import { videos } from "../assets";
import { Portal } from "@gorhom/portal";
// Fullscreen vertical video overlay without controls
// Props:
// - url?: string | number (require), source of the video. Defaults to videos.test
// - visible?: boolean, when false returns null
// - onClose: () => void, called when user skips or when video ends
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
if (!visible) return null;
const source = url
? typeof url === "string"
? { uri: url }
: url
: videos.test;
const player = useVideoPlayer(source, (p) => {
p.loop = false;
p.timeUpdateEventInterval = 0.25;
});
useEffect(() => {
try {
player?.play?.();
} catch (e) {}
}, [player]);
useEffect(() => {
if (!player) return;
const sub = player.addListener?.("playToEnd", () => {
try {
onClose?.();
} catch (e) {}
});
return () => {
try {
sub?.remove?.();
} catch (e) {}
};
}, [player, onClose]);
return (
<Portal>
<View
pointerEvents="box-none"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
zIndex: 9999,
}}
>
<VideoView
player={player}
nativeControls={false}
contentFit="cover"
style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}
/>
<Pressable
onPress={() => onClose?.()}
style={{
position: "absolute",
top: 50,
right: 20,
backgroundColor: "#00000080",
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 20,
borderWidth: 1,
borderColor: "#FFFFFF55",
}}
>
<Text style={{ color: "#FFF", fontSize: 14 }}>Passer la vidéo</Text>
</Pressable>
</View>
</Portal>
);
};
export default FullscreenIntroVideo;
+1
View File
@@ -48,6 +48,7 @@ try {
export const usersRef = firestore.collection("users");
export const projectsRef = firestore.collection("projects");
export const tasksRef = firestore.collection("tasks");
export const playlistsRef = firestore.collection("playlists");
export const chatsRef = firestore.collection("chats");
+9 -4
View File
@@ -26,10 +26,10 @@ import Compose from "../screens/Studio/Compose";
import CustomizeVoice from "../screens/Studio/CustomizeVoice";
import SongReady from "../screens/Studio/SongReady";
import Regenerate from "../screens/Studio/Regenerate";
import PouchReady from "../screens/Studio/PouchReady";
import PhotoCover from "../screens/Studio/PhotoCover";
import AddPhotoCover from "../screens/Studio/AddPhotoCover";
import FinishCompose from "../screens/Studio/FinishCompose";
import PouchReady from "../screens/cover/PouchReady";
import PhotoCover from "../screens/cover/PhotoCover";
import AddPhotoCover from "../screens/cover/AddPhotoCover";
import FinishCompose from "../screens/cover/FinishCompose";
import Production from "../screens/Production/Production";
import ProductionOnboarding from "../screens/Production/ProductionOnboarding";
import DownloadSongs from "../screens/Production/DownloadSongs";
@@ -64,6 +64,7 @@ import NewMusicOptions from "../screens/NewMusicOptions";
import Register from "../screens/Register";
import CreatePassword from "../screens/CreatePassword";
import CreatePseudo from "../screens/CreatePseudo";
import ChooseCoverType from "../screens/cover/ChooseCoverType";
const screenOptions = {
headerShown: false,
@@ -302,6 +303,10 @@ const screens = [
name: Routes.CreatePseudo,
component: CreatePseudo,
},
{
name: Routes.ChooseCoverType,
component: ChooseCoverType,
},
];
export default function Main() {
+1
View File
@@ -82,4 +82,5 @@ export const Routes = {
Reels: "Reels",
Follows: "Follows",
FlowSelection: "FlowSelection",
ChooseCoverType: "ChooseCoverType",
};
+65 -2
View File
@@ -90,6 +90,62 @@ export default ({ children }) => {
...newData,
}));
};
// Selected project state and helpers
const [selectedProjectId, setSelectedProjectId] = useState(null);
const { data: selectedProject = null, setData: setSelectedProject } =
useDataFromRef({
ref: selectedProjectId ? projectsRef.doc(selectedProjectId) : null,
simpleRef: true,
listener: true,
condition: !!selectedProjectId,
refreshArray: [selectedProjectId],
});
const resetSelectedProject = () => {
setSelectedProjectId(null);
setSelectedProject(null);
};
const selectProject = (projectId) => setSelectedProjectId(projectId || null);
// Ancienne variante de création de projet supprimée pour éviter les doublons.
const updateProjectData = async (partial = {}, options = { merge: true }) => {
if (!selectedProjectId) return;
try {
await projectsRef.doc(selectedProjectId).set(
{
...partial,
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
options,
);
} catch (e) {
console.log("updateProjectData error", e?.message);
throw e;
}
};
const createNewProject = async ({ hasLyrics = false } = {}) => {
try {
await setIsLoading(true);
const user = firebase.auth().currentUser;
const payload = {
userId: user ? user.uid : currentUID || null,
hasLyrics: !!hasLyrics,
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
};
const { id } = await projectsRef.add(payload);
setSelectedProjectId(id);
return id;
} catch (e) {
console.log("createNewProject error", e?.message);
throw e;
} finally {
await setIsLoading(false);
}
};
const followUser = async (userId) => {
try {
if (currentUID && userId) {
@@ -98,7 +154,7 @@ export default ({ children }) => {
followedBy: arrayUnion(currentUID),
lastFollowersUpdateAt: new Date(),
},
{ merge: true }
{ merge: true },
);
setTooltip({ type: "success", text: "Abonnement mis à jour" });
}
@@ -116,7 +172,7 @@ export default ({ children }) => {
followedBy: arrayRemove(currentUID),
lastFollowersUpdateAt: new Date(),
},
{ merge: true }
{ merge: true },
);
setTooltip({
type: "success",
@@ -248,6 +304,8 @@ export default ({ children }) => {
userPlaybacks,
userLikedProjects,
userPlaylists,
selectedProjectId,
selectedProject,
followingCount: userFollowing?.length || 0,
setCurrentUserData,
@@ -260,6 +318,11 @@ export default ({ children }) => {
updateUserData,
followUser,
unfollowUser,
resetSelectedProject,
selectProject,
setSelectedProjectId,
updateProjectData,
createNewProject,
}}
>
{children}
+2 -1
View File
@@ -68,7 +68,8 @@ const HitParade = () => {
justifyContent: "space-between",
}}
>
{["Chansons", "Playbacks", "Clips"].map((item, index) => (
{/*{["Chansons", "Playbacks", "Clips"].map((item, index) => (*/}
{["Chansons", "Playbacks"].map((item, index) => (
<BorderGradientButton
key={index}
title={item}
+14 -10
View File
@@ -17,7 +17,7 @@ import { projectsRef } from "../config/firebase";
import { useGlobal } from "reactn";
const Home = () => {
const { userProjects = [] } = useUser();
const { userProjects = [], resetSelectedProject, selectProject } = useUser();
const projects = useMemo(
() => (Array.isArray(userProjects) ? userProjects : []),
[userProjects],
@@ -25,7 +25,7 @@ const Home = () => {
const [, setTooltip] = useGlobal("_tooltip");
const [menuTop, setMenuTop] = useState(0);
const [showMenu, setShowMenu] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState(null);
const [menuProjectId, setMenuProjectId] = useState(null);
return (
<Page backgroundImg={background.homeBG} headerType="NONE">
@@ -77,11 +77,12 @@ const Home = () => {
imageUri={item?.coverUrl || null}
projectId={item?.id}
likedBy={item?.likedBy || []}
onPress={() =>
navigate(Routes.FlowSelection, { projectId: item.id })
}
onPress={() => {
selectProject(item.id);
navigate(Routes.FlowSelection);
}}
onPressMore={(posTop) => {
setSelectedProjectId(item.id);
setMenuProjectId(item.id);
setMenuTop(posTop);
setShowMenu((prev) => !prev || posTop !== menuTop);
}}
@@ -93,7 +94,7 @@ const Home = () => {
top={menuTop}
onClose={() => setShowMenu(false)}
inPlaylist={false}
projectId={selectedProjectId}
projectId={menuProjectId}
extraItems={[
{
label: "Supprimer",
@@ -108,9 +109,9 @@ const Home = () => {
style: "destructive",
onPress: async () => {
try {
if (!selectedProjectId) return;
if (!menuProjectId) return;
await projectsRef
.doc(selectedProjectId)
.doc(menuProjectId)
.delete();
setTooltip({
type: "success",
@@ -137,7 +138,10 @@ const Home = () => {
<GradientButton
title="Créer une nouvelle musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
onPress={() => navigate(Routes.FlowSelection)}
onPress={() => {
resetSelectedProject();
navigate(Routes.FlowSelection);
}}
/>
</View>
</View>
+38 -45
View File
@@ -1,6 +1,6 @@
import FontAwesome from "@expo/vector-icons/FontAwesome";
import { BlurView } from "expo-blur";
import React, { useMemo } from "react";
import React from "react";
import { Image, Platform, Pressable, Text, View } from "react-native";
import { ai, background } from "../assets";
import Page from "../layouts/Page";
@@ -42,69 +42,62 @@ const CREATE_DATA = [
},
];
const NewMusicOptions = ({ route }) => {
const { userProjects } = useUserData();
const { projectId = null } = route?.params || {};
const currentProjet = useMemo(() => {
if (!projectId) {
return null;
}
return userProjects?.find((p) => p.id === projectId) || null;
}, [projectId, userProjects]);
const hasProject = !!currentProjet;
const hasLyrics = Array.isArray(currentProjet?.lyrics)
? currentProjet.lyrics.length > 0
: !!currentProjet?.lyrics;
const hasCover = !!currentProjet?.coverUrl;
console.log("current projet cover", currentProjet?.coverUrl);
const NewMusicOptions = () => {
const { selectedProject } = useUserData();
// Lock rules per option index:
// 0 (Songwriter): allowed if no project OR no songUrl
// 1 (Beatmaker): allowed only if lyrics exist and no songUrl
// 2 (Designer): allowed only if songUrl exists and no coverUrl
// 3 (Director): allowed only if coverUrl exists
const isLocked = (index) => {
// 0: Songwriter, 1: Beatmaker, 2: Producer, 3: Director
// Rules:
// - If no project: only Songwriter (0) is available.
// - If project exists: Songwriter (0) is always available.
// - If lyrics exist: Beatmaker (1) becomes available.
// - If cover exists: Producer (2) and Director (3) become available.
if (!hasProject) return index !== 0;
const songUrl = selectedProject?.songUrl || null;
const coverUrl = selectedProject?.coverUrl || null;
const playbackUrl = selectedProject?.songUrl || null;
const lyricsLen = Array.isArray(selectedProject?.lyrics)
? selectedProject.lyrics.length
: 0;
const allowed = new Set([0]); // songwriter always allowed when project exists
if (hasLyrics) {
allowed.add(1);
switch (index) {
case 0: // Songwriter
return !!songUrl; // locked if a song already exists
case 1: // Beatmaker
return !(lyricsLen > 0 && !songUrl);
case 2: // Designer
return !(!!songUrl && !!playbackUrl);
case 3: // Director
return !!!coverUrl;
default:
return true;
}
if (hasCover) {
allowed.add(2);
allowed.add(3);
}
return !allowed.has(index);
};
const onPressOption = (index, item) => {
if (isLocked(index)) return;
switch (index) {
case 0:
navigate(Routes.WritingLyrics, {
projectId: currentProjet?.id || null,
});
if (selectedProject?.lyrics?.length) {
navigate(Routes.Lyrics);
} else {
navigate(Routes.WritingLyrics);
}
break;
case 1:
if (currentProjet?.musicStatus === "GENERATING") {
navigate(Routes.GeneratingSong, { projectId: currentProjet.id });
} else if (currentProjet?.musicStatus === "GENERATED") {
navigate(Routes.SongReady, { projectId: currentProjet.id });
if (selectedProject?.musicStatus === "GENERATING") {
navigate(Routes.GeneratingSong);
} else if (selectedProject?.musicStatus === "GENERATED") {
navigate(Routes.SongReady);
} else {
navigate(Routes.Compose, { projectId: currentProjet.id });
navigate(Routes.Compose);
}
break;
case 2:
navigate(Routes.PouchReady, { projectId: currentProjet.id });
navigate(Routes.ChooseCoverType);
break;
case 3:
console.log("test");
navigate(Routes.Playback, {
project: currentProjet,
project: selectedProject,
});
default:
break;
@@ -115,7 +108,7 @@ const NewMusicOptions = ({ route }) => {
<Page
backgroundImg={background.homeBG}
headerType="NAVIGATION"
title={!!currentProjet ? "Continuer la création" : "Nouvelle musique"}
title={!!selectedProject ? "Continuer la création" : "Nouvelle musique"}
scrollEnabled={true}
>
<View style={{ gap: 10 }}>
+13 -4
View File
@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React, { useCallback, useState } from "react";
import { Alert, Image, StyleSheet, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { ai, background } from "../../assets";
@@ -10,6 +10,7 @@ import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { gutters } from "../../styles";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
const Playback = ({ route }) => {
const { project } = route.params;
@@ -17,6 +18,7 @@ const Playback = ({ route }) => {
const songIndex = project?.songIndex;
const sunoTaskId = project?.sunoTaskId;
const { setIsLoading } = useMinuit();
const [showIntro, setShowIntro] = useState(false);
const onPressRecord = useCallback(async () => {
try {
@@ -43,7 +45,7 @@ const Playback = ({ route }) => {
console.log("getSunoTimestamps failed", data?.error || data);
Alert.alert(
"Timestamps",
"Impossible de récupérer les timestamps pour ce playback pour le moment. Tu peux quand même continuer."
"Impossible de récupérer les timestamps pour ce playback pour le moment. Tu peux quand même continuer.",
);
}
@@ -53,7 +55,7 @@ const Playback = ({ route }) => {
console.log("getSunoTimestamps error", e?.message || e);
Alert.alert(
"Timestamps",
"Une erreur est survenue lors de la génération des timestamps. Tu peux quand même continuer."
"Une erreur est survenue lors de la génération des timestamps. Tu peux quand même continuer.",
);
await setIsLoading(false);
}
@@ -71,7 +73,10 @@ const Playback = ({ route }) => {
}}
>
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
<BorderGradientButton title="Guide du Playbacker" />
<BorderGradientButton
title="Guide du Playbacker"
onPress={() => setShowIntro(true)}
/>
{/* <BorderGradientButton title="Importer une vidéo" /> */}
<GradientButton
title="Enregistrer mon Playback"
@@ -79,6 +84,10 @@ const Playback = ({ route }) => {
/>
</View>
</View>
<FullscreenIntroVideo
visible={showIntro}
onClose={() => setShowIntro(false)}
/>
</Page>
);
};
+3 -2
View File
@@ -65,7 +65,7 @@ const Profile = () => {
setFollowers(
Array.isArray(currentUserData?.followedBy)
? currentUserData.followedBy.length
: 0
: 0,
);
return;
}
@@ -367,7 +367,8 @@ const Profile = () => {
gap: 6,
}}
>
{["Chansons", "Playbacks", "Clips"].map((item, index) => (
{/*{["Chansons", "Playbacks", "Clips"].map((item, index) => (*/}
{["Chansons", "Playbacks"].map((item, index) => (
<Pressable
key={index}
onPress={() => onPressMenu(item)}
+10 -7
View File
@@ -1,21 +1,20 @@
import { View, Text, StyleSheet, Image } from "react-native";
import React from "react";
import { View, StyleSheet, Image } from "react-native";
import React, { useState } from "react";
import { ai, background } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useRoute } from "@react-navigation/native";
import MusicLandHeader from "../../components/MusicLandHeader";
import { Routes } from "../../navigation";
import GradientButton from "../../components/GradientButton";
import { gutters } from "../../styles";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
const Compose = () => {
const route = useRoute();
const projectId = route?.params?.projectId;
const [showIntro, setShowIntro] = useState(true);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<Image source={ai.theo} style={styles.img} resizeMode="contain" />
<MusicLandHeader showSkip onPressBack={goBack} progress={9} />
<MusicLandHeader onPressBack={goBack} progress={9} />
<View
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
>
@@ -28,10 +27,14 @@ const Compose = () => {
>
<GradientButton
title="Composer ma chanson"
onPress={() => navigate(Routes.ComposeSong, { projectId })}
onPress={() => navigate(Routes.ComposeSong)}
/>
</View>
</View>
<FullscreenIntroVideo
visible={showIntro}
onClose={() => setShowIntro(false)}
/>
</Page>
);
};
+34 -21
View File
@@ -11,9 +11,8 @@ import ChooseGenre from "./ChooseGenre";
import CustomizeVoice from "./CustomizeVoice";
import ChooseInstruments from "./ChooseInstruments";
import ChooseRhythm from "./ChooseRhythm";
import { useRoute } from "@react-navigation/native";
import { projectsRef } from "../../config/firebase";
import useDataFromRef from "../../hooks/useDataFromRef";
import { useUser } from "../../providers/UserDataProvider";
import { Routes } from "../../navigation";
const { width } = Dimensions.get("window");
@@ -23,8 +22,7 @@ const ComposeSong = () => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [progress, setProgress] = useState(18);
const [containerLayout, setContainerLayout] = useState(null);
const route = useRoute();
const projectId = route?.params?.projectId;
const { selectedProjectId, selectedProject, updateProjectData } = useUser();
// Selections state
const [genres, setGenres] = useState([]);
@@ -32,14 +30,6 @@ const ComposeSong = () => {
const [instruments, setInstruments] = useState([]);
const [rhythm, setRhythm] = useState(null);
// Fetch selected project to get title + lyrics
const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
});
const isStepValid = useMemo(() => {
switch (selectedIndex) {
case 0:
@@ -57,31 +47,54 @@ const ComposeSong = () => {
const musicConfig = useMemo(() => {
let lyricsArr = [];
if (Array.isArray(project?.lyrics)) {
lyricsArr = project.lyrics.map((s) => ({
if (Array.isArray(selectedProject?.lyrics)) {
lyricsArr = selectedProject.lyrics.map((s) => ({
type: (s?.type || "").toLowerCase(),
lyrics: s?.lyrics || "",
}));
} else {
const c = project?.lyrics?.couplet;
const r = project?.lyrics?.refrain;
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 });
}
return {
title: project?.title || "",
title: selectedProject?.title || "",
lyrics: lyricsArr,
genres: Array.isArray(genres) ? genres : [],
voice: voice || undefined,
instruments: Array.isArray(instruments) ? instruments : [],
tempo: rhythm || undefined,
projectId: projectId || undefined,
projectId: selectedProjectId || undefined,
};
}, [project, genres, voice, instruments, rhythm, projectId]);
}, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]);
const onPressNext = () => {
const onPressNext = async () => {
if (selectedIndex === 3) {
navigate(Routes.GeneratingSong, { config: musicConfig, projectId });
try {
// Persist config on the selected selectedProject so GeneratingSong can pick it up
if (selectedProjectId) {
await updateProjectData({
musicConfig: {
title: musicConfig?.title || "",
lyrics: Array.isArray(musicConfig?.lyrics)
? musicConfig.lyrics
: [],
genres: Array.isArray(musicConfig?.genres)
? musicConfig.genres
: [],
voice: musicConfig?.voice || "",
instruments: Array.isArray(musicConfig?.instruments)
? musicConfig.instruments
: [],
tempo: musicConfig?.tempo || "",
},
musicStatus: null,
});
}
} catch (e) {}
// Also pass the config to the screen to avoid any race condition
navigate(Routes.GeneratingSong, { config: musicConfig });
return;
}
setSelectedIndex(selectedIndex + 1);
+3 -3
View File
@@ -7,6 +7,7 @@ import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import ProgressBar from "../../components/ProgressBar";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { Routes } from "../../navigation";
import GradientButton from "../../components/GradientButton";
import firebase, { projectsRef } from "../../config/firebase";
@@ -21,6 +22,7 @@ const CreatingSong = ({ active, config }) => {
const [musicStatus, setMusicStatus] = useState(null);
const [generationStartAt, setGenerationStartAt] = useState(null);
const progressTimerRef = React.useRef(null);
const { selectedProjectId } = useUser();
// Abonnement au document projet pour suivre le statut et la date de début
useEffect(() => {
@@ -236,9 +238,7 @@ const CreatingSong = ({ active, config }) => {
width: "80%",
alignSelf: "center",
}}
onPress={() =>
navigate(Routes.SongReady, { projectId: config?.projectId })
}
onPress={() => navigate(Routes.SongReady)}
/>
</View>
</BlurView>
+89 -50
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Image, Platform, Text, View } from "react-native";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Image, Platform, Text, View } from "react-native";
import Page from "../../layouts/Page";
import { ai, background } from "../../assets";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -15,24 +15,22 @@ import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import moment from "moment";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { FONT_FAMILY } from "../../styles/Fonts";
import useDataFromRef from "../../hooks/useDataFromRef";
import { useUser } from "../../providers/UserDataProvider";
import { useIsFocused } from "@react-navigation/native";
const GeneratingSong = ({ route }) => {
const { config, projectId } = route.params;
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 lastConfigKeyRef = useRef(null);
const askedRef = useRef(false);
const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
refreshArray: [projectId],
});
// project loaded from provider
// Progress based on 8 minutes cap or until status changes
// Progress based on 8 minutes cap or until status becomes GENERATED
useEffect(() => {
const totalMs = 8 * 60 * 1000;
const clearTimer = () => {
@@ -41,15 +39,15 @@ const GeneratingSong = ({ route }) => {
progressTimerRef.current = null;
}
};
if (project?.musicStatus !== "GENERATING") {
if (selectedProject?.musicStatus === "GENERATED") {
setProgress(100);
clearTimer();
return () => clearTimer();
}
const update = () => {
const startDate = project?.generationStartAt?.toDate
? project.generationStartAt.toDate()
: new Date(project?.generationStartAt || Date.now());
const startDate = selectedProject?.generationStartAt?.toDate
? selectedProject.generationStartAt.toDate()
: new Date(selectedProject?.generationStartAt || Date.now());
const elapsed = moment().diff(moment(startDate));
const raw = Math.floor((elapsed / totalMs) * 100);
// While status is GENERATING, block visual progress at 99%
@@ -60,16 +58,29 @@ const GeneratingSong = ({ route }) => {
clearTimer();
progressTimerRef.current = setInterval(update, 1000);
return () => clearTimer();
}, [project?.musicStatus]);
}, [selectedProject?.musicStatus]);
// Auto navigate to SongReady when generation completed
useEffect(() => {
if (!config?.projectId) return;
if (project?.musicStatus !== "GENERATING" && !navigatedRef.current) {
if (!selectedProjectId) return;
if (selectedProject?.musicStatus === "GENERATED" && !navigatedRef.current) {
navigatedRef.current = true;
navigate(Routes.SongReady, { projectId: config.projectId });
navigate(Routes.SongReady);
}
}, [project?.musicStatus, config?.projectId]);
}, [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: cfg?.voice || "",
instruments: Array.isArray(cfg?.instruments) ? cfg.instruments : [],
tempo: cfg?.tempo || "",
};
}, [selectedProject?.musicConfig]);
async function startMusicGeneration() {
try {
@@ -78,39 +89,39 @@ const GeneratingSong = ({ route }) => {
const callable = firebase
.functions()
.httpsCallable("music-generateMusic");
const cfg = effectiveConfig || {};
const { data } = await callable({
title: config?.title,
lyrics: config?.lyrics,
genres: config?.genres,
voice: config?.voice,
instruments: config?.instruments,
tempo: config?.tempo,
projectId: config?.projectId,
title: cfg?.title,
lyrics: cfg?.lyrics,
genres: cfg?.genres,
voice: cfg?.voice,
instruments: cfg?.instruments,
tempo: cfg?.tempo,
});
const taskId =
data?.response?.data?.taskId || data?.response?.data?.task_id;
if (config?.projectId && taskId) {
if (selectedProjectId && taskId) {
const baseUpdate = {
sunoTaskId: taskId,
musicStatus: "GENERATING",
generationStartAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
};
const updatePayload = project?.musicConfig
const updatePayload = selectedProject?.musicConfig
? baseUpdate
: {
...baseUpdate,
musicConfig: {
title: config?.title || "",
lyrics: config?.lyrics || [],
genres: config?.genres || [],
voice: config?.voice || "",
instruments: config?.instruments || [],
tempo: config?.tempo || "",
title: effectiveConfig?.title || "",
lyrics: effectiveConfig?.lyrics || [],
genres: effectiveConfig?.genres || [],
voice: effectiveConfig?.voice || "",
instruments: effectiveConfig?.instruments || [],
tempo: effectiveConfig?.tempo || "",
},
};
await projectsRef
.doc(config.projectId)
.doc(selectedProjectId)
.set(updatePayload, { merge: true });
}
} catch (e) {
@@ -120,16 +131,44 @@ const GeneratingSong = ({ route }) => {
}
}
// Trigger generation only if not already GENERATING
// Trigger generation only when focused; ask once per config
useEffect(() => {
if (!!project?.title && project?.musicStatus !== "GENERATING") {
startMusicGeneration();
if (!isFocused) return;
const key = JSON.stringify(effectiveConfig || {});
if (lastConfigKeyRef.current !== key) {
lastConfigKeyRef.current = key;
askedRef.current = false;
}
}, [project]);
const canAsk =
!!selectedProject?.title && selectedProject?.musicStatus !== "GENERATING";
if (canAsk && !askedRef.current) {
askedRef.current = true;
Alert.alert(
"Attention",
"Une génération va être lancée. Continuer ?",
[
{
text: "Non",
style: "cancel",
},
{
text: "Oui",
onPress: () => startMusicGeneration(),
},
],
);
}
}, [isFocused, selectedProject?.title, selectedProject?.musicStatus, effectiveConfig]);
return (
<Page headerType="NONE" backgroundImg={background.studioBG2}>
<MusicLandHeader onPressBack={goBack} progress={63} />
<MusicLandHeader
onPressBack={() => navigate(Routes.FlowSelection)}
progress={63}
/>
<View style={{ flex: 1, marginTop: 16 }}>
<CreateLyricsHeader
title="Ta musique est en cours de création !"
@@ -206,14 +245,14 @@ const GeneratingSong = ({ route }) => {
</Text>
</View>
<GradientButton
title={"Création en cours..."}
disabled={project?.musicStatus === "GENERATING"}
containerStyle={{ width: "80%", alignSelf: "center" }}
onPress={() =>
navigate(Routes.SongReady, {
projectId: config?.projectId,
})
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>
+62 -43
View File
@@ -1,6 +1,6 @@
import { useAudioPlayer } from "expo-audio";
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { Image, Platform, Pressable, Text, View } from "react-native";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { background, icons } from "../../assets";
@@ -9,16 +9,22 @@ import GradientButton from "../../components/GradientButton";
import ValidateModal from "../../components/modal/ValidateModal";
import MusicLandHeader from "../../components/MusicLandHeader";
import Slider from "../../components/Slider";
import firebase, { projectsRef } from "../../config/firebase";
import firebase from "../../config/firebase";
import { useUser } from "../../providers/UserDataProvider";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { navigate } from "../../navigation/NavigationService";
import { Style } from "../../styles";
import { gutters, size } from "../../styles/Style";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { useFocusEffect } from "@react-navigation/native";
const SongReady = ({ route }) => {
const { projectId = null } = route?.params || {};
const SongReady = () => {
const {
selectedProjectId: projectId,
selectedProject,
updateProjectData,
} = useUser();
const [showValidateModal, setShowValidateModal] = useState(false);
const [musicUrls, setMusicUrls] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -28,29 +34,20 @@ const SongReady = ({ route }) => {
1: { pos: 0, dur: 0 },
});
const player0 = useAudioPlayer(
musicUrls[0] ? { uri: musicUrls[0] } : undefined
musicUrls[0] ? { uri: musicUrls[0] } : undefined,
);
const player1 = useAudioPlayer(
musicUrls[1] ? { uri: musicUrls[1] } : undefined
musicUrls[1] ? { uri: musicUrls[1] } : undefined,
);
const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });
// Charger les URLs depuis le document projet
// Sync URLs from provider's selectedProject
useEffect(() => {
if (!projectId) return;
const unsub = firebase
.firestore()
.collection("projects")
.doc(projectId)
.onSnapshot((doc) => {
const data = doc.data() || {};
const urls = Array.isArray(data?.musicUrls)
? data.musicUrls.slice(0, 2)
: [];
setMusicUrls(urls);
});
return () => unsub?.();
}, [projectId]);
const urls = Array.isArray(selectedProject?.musicUrls)
? selectedProject.musicUrls.slice(0, 2)
: [];
setMusicUrls(urls);
}, [selectedProject?.musicUrls]);
// Sync progression depuis les players
useEffect(() => {
@@ -117,34 +114,50 @@ const SongReady = ({ route }) => {
try {
const url = musicUrls[selectedIndex];
if (!projectId || !url) return;
await projectsRef.doc(projectId).set(
{
songIndex: selectedIndex,
songUrl: url,
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
);
navigate(Routes.FlowSelection, { projectId });
await updateProjectData({
songIndex: selectedIndex,
songUrl: url,
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
});
await player0?.pause?.();
await player1?.pause?.();
navigate(Routes.FlowSelection);
} catch (e) {
console.log("Validate error", e?.message);
}
};
const onPressRegenerate = async () => {
try {
navigate(Routes.GeneratingSong, { projectId });
} catch (e) {
console.log("Regenerate error", e?.message);
} finally {
goBack();
}
};
// Pause audio when screen loses focus (navigate/reset) and on unmount
useFocusEffect(
useCallback(() => {
return () => {
try {
player0?.pause?.();
player1?.pause?.();
} catch {}
};
}, [player0, player1]),
);
useEffect(() => {
return () => {
try {
player0?.pause?.();
player1?.pause?.();
} catch {}
};
}, [player0, player1]);
return (
<Page headerType="NONE" backgroundImg={background.studioBG2}>
<MusicLandHeader
onPressBack={() => navigate(Routes.FlowSelection, { projectId })}
onPressBack={async () => {
try {
await player0?.pause?.();
await player1?.pause?.();
} catch {}
navigate(Routes.FlowSelection);
}}
progress={63}
/>
<View style={{ flex: 1, marginTop: 16 }}>
@@ -207,7 +220,7 @@ const SongReady = ({ route }) => {
} catch (e) {
console.log(
"SongReady pause on seek start",
e?.message
e?.message,
);
}
}}
@@ -269,7 +282,13 @@ const SongReady = ({ route }) => {
<BorderGradientButton
title="Regénérer"
icon={icons.stars}
onPress={onPressRegenerate}
onPress={async () => {
try {
await player0?.pause?.();
await player1?.pause?.();
} catch {}
navigate(Routes.ComposeSong);
}}
/>
<GradientButton
title="Choisir ce morceau"
+11 -8
View File
@@ -12,7 +12,7 @@ import { responsiveHeight } from "react-native-responsive-dimensions";
const Studio = () => {
const [selected, setSelected] = useState(null);
const { userProjects: projects } = useUser();
const { userProjects: projects, selectProject } = useUser();
return (
<Page
@@ -107,7 +107,8 @@ const Studio = () => {
"La chanson est en cours de génération. Veuillez patienter.",
);
} else {
navigate(Routes.Compose, { projectId: selected.id });
selectProject(selected.id);
navigate(Routes.Compose);
}
}}
/>
@@ -118,18 +119,20 @@ const Studio = () => {
containerStyle={{
marginTop: responsiveHeight(2),
}}
onPress={() =>
navigate(Routes.SongReady, { projectId: selected.id })
}
onPress={() => {
selectProject(selected.id);
navigate(Routes.SongReady);
}}
/>
<GradientButton
title="Générer une pochette"
containerStyle={{
marginTop: responsiveHeight(2),
}}
onPress={() =>
navigate(Routes.PouchReady, { projectId: selected.id })
}
onPress={() => {
selectProject(selected.id);
navigate(Routes.PouchReady);
}}
/>
</>
)}
+81 -15
View File
@@ -17,13 +17,14 @@ import CustomizeSongStructure from "./CustomizeSongStructure";
import Rhymes from "./Rhymes";
import CreatingLyrics from "./CreatingLyrics";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { useUser } from "../../providers/UserDataProvider";
const { width } = Dimensions.get("window");
const CreateLyricsWithAi = ({ route }) => {
const { hasLyrics = false, regenerateKey } = route?.params || {};
const CreateLyricsWithAi = () => {
const { selectedProject, updateProjectData } = useUser();
const hasLyrics = selectedProject?.hasLyrics === true;
const scrollRef = useRef(null);
// If user already has lyrics, start at SongStructure (index 5)
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0);
const [progress, setProgress] = useState(16);
const [parentLayout, setparentLayout] = useState(null);
@@ -40,6 +41,58 @@ const CreateLyricsWithAi = ({ route }) => {
const [rhymes, setRhymes] = useState(null);
const [customStructure, setCustomStructure] = useState(null); // array like ['couplet','refrain']
// Pré-remplir les états depuis le projet sélectionné si disponibles
React.useEffect(() => {
if (!selectedProject) return;
const sel = selectedProject?.selections || {};
const cfg = selectedProject?.config || {};
// Text inputs / choix simples
if (objective == null && typeof sel.objective === "string" && sel.objective)
setObjective(sel.objective);
if (!otherObjective && typeof sel.otherObjective === "string")
setOtherObjective(sel.otherObjective);
if (!context && typeof sel.context === "string") setContext(sel.context);
// Emotion: accepter objet {title, description} ou string "Titre : description"
if (emotion == null && sel.emotion) {
if (typeof sel.emotion === "object" && sel.emotion.title) {
setEmotion({
title: sel.emotion.title,
description: sel.emotion.description || "",
});
} else if (typeof sel.emotion === "string") {
const [t, d] = sel.emotion.split(":");
const title = (t || "").trim();
const description = (d || "").trim();
if (title) setEmotion({ title, description });
}
}
if (style == null && typeof sel.style === "string" && sel.style)
setStyle(sel.style);
if (!otherStyle && typeof sel.otherStyle === "string")
setOtherStyle(sel.otherStyle);
if (!audience && typeof sel.audience === "string") setAudience(sel.audience);
// Structure choisie (string) si déjà enregistrée dans selections
if (structure == null && typeof sel.structure === "string" && sel.structure)
setStructure(sel.structure);
// Rimes
if (rhymes == null && typeof sel.rhymes === "string" && sel.rhymes)
setRhymes(sel.rhymes);
// Structure personnalisée: prioriser selections.customStructure puis config.structure
const savedCustom = Array.isArray(sel.customStructure)
? sel.customStructure
: null;
const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null;
if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) {
setCustomStructure(savedCustom || cfgStructure);
}
}, [selectedProject]);
const parsedStructure = useMemo(() => {
// Parses strings like "1 couplet, 1 refrain, 1 couplet, 1 refrain"
try {
@@ -65,6 +118,13 @@ const CreateLyricsWithAi = ({ route }) => {
}
}, [structure]);
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
React.useEffect(() => {
if (hasLyrics && selectedIndex < 5) {
setSelectedIndex(5);
}
}, [hasLyrics]);
const lyricsConfig = useMemo(() => {
return {
objective: otherObjective?.trim()
@@ -98,9 +158,9 @@ const CreateLyricsWithAi = ({ route }) => {
customStructure,
]);
const onPressNext = () => {
// If user already has lyrics and just finished CustomizeSongStructure (index 6),
// skip AI generation and go straight to Lyrics editor
const onPressNext = async () => {
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
if (hasLyrics && selectedIndex === 6) {
const chosenStructure =
(customStructure &&
@@ -108,7 +168,7 @@ const CreateLyricsWithAi = ({ route }) => {
customStructure.length === parsedStructure.length
? customStructure
: parsedStructure) || [];
navigate(Routes.Lyrics, {
await updateProjectData({
config: { structure: chosenStructure },
selections: {
objective,
@@ -125,6 +185,7 @@ const CreateLyricsWithAi = ({ route }) => {
},
hasLyrics: true,
});
navigate(Routes.Lyrics);
return;
}
setSelectedIndex((idx) => idx + 1);
@@ -156,11 +217,7 @@ const CreateLyricsWithAi = ({ route }) => {
return (
<Page headerType="NONE">
<MusicLandHeader
onPressBack={onPressBack}
progress={progress}
showSkip={selectedIndex === 4}
/>
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
<View
style={{
flex: 1,
@@ -250,7 +307,14 @@ const CreateLyricsWithAi = ({ route }) => {
}}
>
<CustomizeSongStructure
baseStructure={parsedStructure || []}
baseStructure={
parsedStructure ||
(Array.isArray(customStructure)
? customStructure
: Array.isArray(selectedProject?.config?.structure)
? selectedProject.config.structure
: [])
}
onChange={setCustomStructure}
/>
</View>
@@ -273,7 +337,6 @@ const CreateLyricsWithAi = ({ route }) => {
<CreatingLyrics
active={selectedIndex === 8}
config={lyricsConfig}
regenerateKey={regenerateKey}
selections={{
objective,
otherObjective,
@@ -292,7 +355,10 @@ const CreateLyricsWithAi = ({ route }) => {
</SwiperFlatList>
</View>
{selectedIndex !== 8 && (
<GradientButton title="Suivant" onPress={onPressNext} />
<GradientButton
title={selectedIndex === 7 ? "Générer" : "Suivant"}
onPress={onPressNext}
/>
)}
</View>
</Page>
+74 -32
View File
@@ -11,21 +11,25 @@ import { goBack, navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation";
import firebase from "../../config/firebase";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { useUser } from "../../providers/UserDataProvider";
const CreatingLyrics = ({ active, config, regenerateKey, selections }) => {
const CreatingLyrics = ({ active, config, selections }) => {
const [progress, setProgress] = useState(0);
const [called, setCalled] = useState(false);
const [result, setResult] = useState(null);
const [saved, setSaved] = useState(false);
const { setIsLoading } = useMinuit();
const { updateProjectData } = useUser();
// When asked to regenerate, reset flags so effect runs again
// Reset when becomes active
useEffect(() => {
if (active) {
setCalled(false);
setResult(null);
setProgress(0);
setSaved(false);
}
}, [regenerateKey, active]);
}, [active]);
useEffect(() => {
if (active) {
@@ -74,7 +78,42 @@ const CreatingLyrics = ({ active, config, regenerateKey, selections }) => {
}
}, [active, called, config, setIsLoading]);
console.log(result);
// Lorsque la génération est terminée, enregistrer et naviguer automatiquement vers Lyrics
useEffect(() => {
const autoSaveAndGo = async () => {
try {
if (saved) return;
setSaved(true);
await setIsLoading(true);
await updateProjectData({
title: result?.title || "",
titleLower: (result?.title || "").toLowerCase(),
lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [],
config: config || null,
selections: selections || null,
hasLyrics: false,
});
navigate(Routes.Lyrics);
} catch (e) {
console.log("Auto save generated lyrics error", e?.message);
} finally {
await setIsLoading(false);
}
};
if (active && result && progress >= 100 && !saved) {
autoSaveAndGo();
}
}, [
active,
result,
progress,
saved,
setIsLoading,
updateProjectData,
config,
selections,
]);
return (
<View
@@ -121,19 +160,6 @@ const CreatingLyrics = ({ active, config, regenerateKey, selections }) => {
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
}
>
<Pressable
style={{
...size({ size: 24 }),
...Style.containerCenter,
position: "absolute",
top: 10,
left: 10,
zIndex: 3,
}}
onPress={goBack}
>
<Image source={icons.close} style={size({ size: 11 })} />
</Pressable>
<View style={{ flex: 1, justifyContent: "flex-end", gap: 20 }}>
<Text
style={{
@@ -157,21 +183,37 @@ const CreatingLyrics = ({ active, config, regenerateKey, selections }) => {
{progress}%
</Text>
</View>
<GradientButton
title="Découvrir mon texte"
containerStyle={{
width: "80%",
alignSelf: "center",
}}
onPress={() =>
navigate(Routes.Lyrics, {
lyricsData: result,
config,
selections,
hasLyrics: false,
})
}
/>
{!saved && (
<GradientButton
title="Découvrir mon texte"
containerStyle={{
width: "80%",
alignSelf: "center",
}}
onPress={async () => {
try {
if (saved) return;
setSaved(true);
await setIsLoading(true);
await updateProjectData({
title: result?.title || "",
titleLower: (result?.title || "").toLowerCase(),
lyrics: Array.isArray(result?.lyrics)
? result.lyrics
: [],
config: config || null,
selections: selections || null,
hasLyrics: false,
});
navigate(Routes.Lyrics);
} catch (e) {
console.log("Save generated lyrics error", e?.message);
} finally {
await setIsLoading(false);
}
}}
/>
)}
</View>
</BlurView>
</View>
+111 -39
View File
@@ -1,41 +1,44 @@
import { View, ScrollView, Alert } from "react-native";
import React, { useMemo, useState, useCallback } from "react";
import React, { useMemo, useState, useCallback, useEffect, useRef } from "react";
import Page from "../../layouts/Page";
import MusicLandHeader from "../../components/MusicLandHeader";
import { goBack, navigate } from "../../navigation/NavigationService";
import { navigate } from "../../navigation/NavigationService";
import { gutters } from "../../styles";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import { Routes } from "../../navigation";
import CustomInput from "./components/CustomInput";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import { useRoute } from "@react-navigation/native";
import firebase, { projectsRef } from "../../config/firebase";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { useUser } from "../../providers/UserDataProvider";
const Lyrics = ({ navigation }) => {
const [containerLayout, setContainerLayout] = useState(null);
const route = useRoute();
const lyricsData = route?.params?.lyricsData;
const config = route?.params?.config;
const selections = route?.params?.selections;
const hasLyrics = !!route?.params?.hasLyrics;
const { setIsLoading } = useMinuit();
const { selectedProjectId, selectedProject } = useUser();
// 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 = lyricsData?.title || "";
const aiSections = Array.isArray(lyricsData?.lyrics)
? lyricsData.lyrics
: [];
const title = projectTitle || "";
const aiSections = Array.isArray(projectLyrics) ? projectLyrics : [];
// Respecter l'ordre de la structure choisie si disponible
const targetStructure = Array.isArray(config?.structure)
? config.structure.map((t) => (t || "").toLowerCase())
const targetStructure = Array.isArray(projectConfig?.structure)
? projectConfig.structure.map((t) => (t || "").toLowerCase())
: null;
if (
aiSections.length &&
targetStructure &&
aiSections.length === targetStructure.length
) {
// 1) Si des paroles existent déjà, les utiliser en priorité
if (aiSections.length) {
// Optionnel: si une structure cible de même longueur existe, garder l'ordre courant
// et harmoniser les types en minuscule.
return {
title,
sections: aiSections.map((s) => ({
@@ -44,18 +47,51 @@ const Lyrics = ({ navigation }) => {
})),
};
}
// Sinon, créer à partir de la structure
// 2) Sinon, créer à partir de la structure si fournie
if (targetStructure && targetStructure.length) {
return {
title,
sections: targetStructure.map((t) => ({ type: t, lyrics: "" })),
};
}
// Fallback vide
// 3) Fallback vide
return { title, sections: [] };
}, [lyricsData, config]);
}, [projectTitle, projectLyrics, projectConfig]);
const [titleValue, setTitleValue] = useState(initial.title || "");
const titleSaveTimer = useRef(null);
// Auto-save du titre lorsqu'il est modifié (si non vide)
useEffect(() => {
const newTitle = (titleValue || "").trim();
// Annuler tout timer précédent
if (titleSaveTimer.current) clearTimeout(titleSaveTimer.current);
// Ne rien faire si inchangé vs projet courant
const currentTitle = (selectedProject?.title || "").trim();
if (!newTitle || newTitle === currentTitle) return;
titleSaveTimer.current = setTimeout(async () => {
try {
await projectsRef.doc(selectedProjectId).set(
{
title: newTitle,
titleLower: newTitle.toLowerCase(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
} catch (e) {
// silencieux: l'utilisateur pourra tjs valider plus tard
console.log("Auto-save titre échoué", e?.message);
}
}, 500);
return () => {
if (titleSaveTimer.current) clearTimeout(titleSaveTimer.current);
};
}, [titleValue, selectedProjectId, selectedProject]);
const [sections, setSections] = useState(initial.sections || []);
const setSectionAt = (index, value) => {
setSections((prev) => {
@@ -67,7 +103,7 @@ const Lyrics = ({ navigation }) => {
// Plus d'appel direct à la cloud function ici: on retourne à l'étape précédente
const regenerate = useCallback(() => {
navigate(Routes.CreateLyricsWithAi, { regenerateKey: Date.now() });
navigate(Routes.CreateLyricsWithAi);
}, []);
const sanitize = (obj) => {
@@ -102,34 +138,68 @@ const Lyrics = ({ navigation }) => {
);
return;
}
const user = firebase.auth().currentUser;
const payload = {
const normalizedNewLyrics = (sections || []).map((s) => ({
type: (s?.type || "").toLowerCase(),
lyrics: (s?.lyrics || "").trim(),
}));
const normalizedOldLyrics = Array.isArray(projectLyrics)
? projectLyrics.map((s) => ({
type: (s?.type || "").toLowerCase(),
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,
);
const baseData = {
title: titleTrimmed,
titleLower: titleTrimmed.toLowerCase(),
lyrics: (sections || []).map((s) => ({
type: (s?.type || "").toLowerCase(),
lyrics: s?.lyrics || "",
})),
config: sanitize(config),
selections: sanitize(selections),
userId: user ? user.uid : null,
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
lyrics: normalizedNewLyrics,
config: sanitize(projectConfig),
selections: sanitize(projectSelections),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
hasLyrics,
hasLyrics: projectHasLyrics,
};
const { id: projectId } = await projectsRef.add(payload);
navigate(Routes.FlowSelection, { projectId });
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 });
}
navigate(Routes.FlowSelection);
} catch (e) {
console.log(e);
Alert.alert("Erreur", "Échec de l'enregistrement dans le projet.");
} finally {
await setIsLoading(false);
}
}, [titleValue, sections, config, selections, setIsLoading]);
}, [
titleValue,
sections,
projectConfig,
projectSelections,
setIsLoading,
selectedProjectId,
projectHasLyrics,
projectLyrics,
]);
return (
<Page headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={95} />
<MusicLandHeader
onPressBack={() => navigate(Routes.FlowSelection)}
progress={95}
/>
<View
style={{
flex: 1,
@@ -153,6 +223,8 @@ const Lyrics = ({ navigation }) => {
height={45}
value={titleValue}
setValue={setTitleValue}
multiline={false}
maxLength={60}
/>
{sections.map((s, idx) => {
// Calculer l'index humain par type
@@ -184,7 +256,7 @@ const Lyrics = ({ navigation }) => {
gap: 12,
}}
>
{!hasLyrics && (
{!projectHasLyrics && (
<BorderGradientButton
title="Générer d'autres paroles"
onPress={regenerate}
+38 -7
View File
@@ -1,5 +1,5 @@
import { View, Image, StyleSheet } from "react-native";
import React from "react";
import React, { useEffect, useState } from "react";
import Page from "../../layouts/Page";
import { ai } from "../../assets";
import GradientButton from "../../components/GradientButton";
@@ -8,8 +8,39 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import { goBack, navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation";
import { useUserData } from "../../providers/UserDataProvider";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
const WritingLyrics = () => {
const { createNewProject, selectedProject, updateProjectData } =
useUserData();
const { setIsLoading } = useMinuit();
const [showIntro, setShowIntro] = useState(false);
useEffect(() => {
if (selectedProject === null) {
setShowIntro(true);
}
}, [selectedProject]);
async function createAndNavigate({ hasLyrics = false }) {
try {
await setIsLoading(true);
if (selectedProject) {
updateProjectData({ hasLyrics });
} else {
await createNewProject({ hasLyrics });
}
setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500);
} catch (e) {
console.log(e);
} finally {
await setIsLoading(false);
}
}
return (
<Page headerType="NONE">
<Image source={ai.nathalie} style={styles.img} resizeMode="contain" />
@@ -25,18 +56,18 @@ const WritingLyrics = () => {
}}
>
<BorderGradientButton
onPress={() => {
navigate(Routes.CreateLyricsWithAi, {
hasLyrics: true,
});
}}
onPress={() => createAndNavigate({ hasLyrics: true })}
/>
<GradientButton
title="Écrire des paroles avec une IA"
onPress={() => navigate(Routes.CreateLyricsWithAi)}
onPress={() => createAndNavigate({ hasLyrics: false })}
/>
</View>
</View>
<FullscreenIntroVideo
visible={showIntro}
onClose={() => setShowIntro(false)}
/>
</Page>
);
};
@@ -9,6 +9,8 @@ const CustomInput = ({
value,
setValue,
height = 65,
multiline = true,
maxLength,
}) => {
return (
<View style={{ gap: 8 }}>
@@ -42,8 +44,10 @@ const CustomInput = ({
color: Palette.black,
fontFamily: FONT_FAMILY.InterRegularItalic,
}}
multiline
textAlignVertical="top"
multiline={multiline}
numberOfLines={multiline ? undefined : 1}
maxLength={maxLength}
textAlignVertical={multiline ? "top" : "center"}
/>
</View>
</View>
@@ -113,7 +113,7 @@ const AddPhotoCover = () => {
/>
<GradientButton
title="Valider"
onPress={() => navigate(Routes.FinishCompose)}
onPress={() => navigate(Routes.FlowSelection)}
/>
</View>
</Page>
+54
View File
@@ -0,0 +1,54 @@
import { View, StyleSheet, Image } from "react-native";
import React, { useState } from "react";
import { ai, background } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import MusicLandHeader from "../../components/MusicLandHeader";
import { Routes } from "../../navigation";
import GradientButton from "../../components/GradientButton";
import { gutters } from "../../styles";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import BorderGradientButton from "../../components/BorderGradientButton";
const ChooseCoverType = () => {
const [showIntro, setShowIntro] = useState(true);
return (
<Page backgroundImg={background.productionBG2} headerType="NONE">
<Image source={ai.bena} style={styles.img} resizeMode="contain" />
<MusicLandHeader onPressBack={goBack} progress={9} />
<View
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
>
<View
style={{
paddingBottom: gutters * 2,
paddingHorizontal: gutters,
gap: 12,
}}
>
<BorderGradientButton title="Choisir une image" />
<GradientButton
title="Générer une pochette"
onPress={() => navigate(Routes.PouchReady)}
/>
</View>
</View>
<FullscreenIntroVideo
visible={showIntro}
onClose={() => setShowIntro(false)}
/>
</Page>
);
};
export default ChooseCoverType;
const styles = StyleSheet.create({
img: {
width: "100%",
height: "70%",
position: "absolute",
bottom: -40,
right: -30,
},
});
@@ -50,3 +50,4 @@ const styles = StyleSheet.create({
right: -30,
},
});
@@ -11,12 +11,11 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import { Routes } from "../../navigation";
import { FONT_FAMILY } from "../../styles/Fonts";
import { useRoute } from "@react-navigation/native";
import firebase from "../../config/firebase";
import firebase, { tasksRef } from "../../config/firebase";
import { useUser } from "../../providers/UserDataProvider";
const PouchReady = () => {
const route = useRoute();
const projectId = route?.params?.projectId;
const { selectedProjectId, selectedProject, updateProjectData } = useUser();
const [coverUrl, setCoverUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
@@ -24,35 +23,25 @@ const PouchReady = () => {
const isGenerating = coverStatus === "GENERATING";
useEffect(() => {
if (!projectId) return;
const unsub = firebase
.firestore()
.collection("projects")
.doc(projectId)
.onSnapshot((doc) => {
const d = doc.data() || {};
setTitle(d?.title || "");
setCoverUrl(d?.coverUrl || null);
setCoverStatus(d?.coverStatus || null);
});
return () => unsub?.();
}, [projectId]);
const d = selectedProject || {};
setTitle(d?.title || "");
setCoverUrl(d?.coverUrl || null);
setCoverStatus(d?.coverStatus || null);
}, [
selectedProject?.title,
selectedProject?.coverUrl,
selectedProject?.coverStatus,
]);
const generateCover = async () => {
if (!projectId) return;
if (!selectedProjectId) return;
try {
setLoading(true);
// Marquer le projet en génération
await firebase
.firestore()
.collection("projects")
.doc(projectId)
.set({ coverStatus: "GENERATING" }, { merge: true });
await updateProjectData({ coverStatus: "GENERATING" });
// Créer une tâche pour déclencher la Cloud Function onCreate
await firebase.firestore().collection("tasks").add({
await tasksRef.add({
type: "cover",
projectId,
projectId: selectedProjectId,
status: "PENDING",
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
});
@@ -64,12 +53,11 @@ const PouchReady = () => {
};
useEffect(() => {
if (!projectId) return;
// Si pas de cover et pas déjà en génération, lancer une tâche
if (!selectedProjectId) return;
if (!coverUrl && !isGenerating && !loading) {
generateCover();
}
}, [projectId, coverUrl, isGenerating]);
}, [selectedProjectId, coverUrl, isGenerating]);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">