diff --git a/AGENTS.md b/AGENTS.md
index 25e2955..386b19c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,6 +26,7 @@
- Hooks: prefix with `use` (e.g., `src/hooks/useFirestorePagination.js`).
- Utilities: lowerCamelCase modules in `src/utils/` or `helpers/`; prefer named exports.
- Avoid duplicating inline styles; share via `src/styles/`.
+- Lors du parsing de payloads (API, callbacks, fichiers), utilise uniquement les champs attendus : ne multiplie pas les fallbacks "au cas où". Si la structure exacte t'échappe, demande-la avant d'ajouter du code.
## Testing Guidelines
diff --git a/functions/src/music.js b/functions/src/music.js
index 01085af..056276f 100644
--- a/functions/src/music.js
+++ b/functions/src/music.js
@@ -18,8 +18,6 @@ const {
SUNO_STATUS_PATH,
} = require("../config/suno");
-const MAX_STORED_MUSIC_TRACKS = 4;
-
/**
* Marque un projet comme échoué suite à une erreur Suno
* @param {string} projectId - Identifiant du projet
@@ -55,15 +53,9 @@ async function markProjectMusicFailure(projectId, error) {
{ merge: true },
);
- const receiverId =
- typeof projectData?.userId === "string" && projectData.userId.trim()
- ? projectData.userId.trim()
- : null;
+ const receiverId = sanitizeField(projectData?.userId);
if (receiverId) {
- const projectTitle =
- typeof projectData?.title === "string" && projectData.title.trim()
- ? projectData.title.trim()
- : "ton projet";
+ const projectTitle = sanitizeField(projectData?.title, "ton projet");
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
@@ -205,6 +197,161 @@ function detectVocalGender(voiceInput = "") {
return undefined;
}
+const sanitizeField = (value, fallback = null) => {
+ const cleaned = typeof value === "string" ? value.trim() : value;
+ if (typeof cleaned !== "string" || !cleaned) return fallback;
+ return cleaned;
+};
+
+const sanitizeMusicUrls = (urls = []) =>
+ (Array.isArray(urls) ? urls : [])
+ .filter((url) => typeof url === "string" && url.trim())
+ .map((url) => url.trim());
+
+const formatProjectMeta = (projectData = {}) => {
+ const userId = sanitizeField(projectData?.userId);
+ const projectTitle = sanitizeField(projectData?.title, "ton projet");
+ return { userId, projectTitle };
+};
+
+const parseSunoCallbackPayload = (rawBody = {}) => {
+ const body = rawBody || {};
+ const code = body.code ?? body.statusCode ?? null;
+ const callbackType = (body?.data?.callbackType || "")
+ .toString()
+ .toLowerCase();
+ const status = (body.status || body.state || callbackType || "")
+ .toString()
+ .toLowerCase();
+ const taskId = sanitizeField(body?.data?.task_id);
+ const tracks = Array.isArray(body?.data?.data)
+ ? body.data.data
+ : Array.isArray(body.data)
+ ? body.data
+ : [];
+
+ return { code, status, taskId, tracks };
+};
+
+const extractAudioUrlsFromTracks = (tracks = []) =>
+ tracks
+ .map(
+ (t) =>
+ t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
+ )
+ .filter(Boolean)
+ .slice(0, 2);
+
+const fetchProjectByTaskId = async (taskId) => {
+ const snapshot = await refList.projects
+ .where("sunoTaskId", "==", taskId)
+ .limit(1)
+ .get();
+
+ if (snapshot.empty) {
+ throw new Error("PROJECT_NOT_FOUND_FOR_TASK");
+ }
+
+ const doc = snapshot.docs[0];
+ return {
+ projectId: doc.id,
+ projectData: doc.data() || {},
+ projectRef: doc.ref,
+ };
+};
+
+const downloadTrackToStorage = async (
+ url,
+ { userId, projectId, taskId, bucket, index },
+) => {
+ if (!url) return null;
+ try {
+ console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
+ const resp = await axios.get(url, { responseType: "arraybuffer" });
+ const buffer = Buffer.from(resp.data);
+ const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
+ const token = require("crypto").randomUUID();
+ const file = bucket.file(path);
+ await file.save(buffer, {
+ resumable: false,
+ metadata: {
+ contentType: "audio/mpeg",
+ cacheControl: "public, max-age=31536000",
+ metadata: { firebaseStorageDownloadTokens: token },
+ },
+ });
+ const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
+ path,
+ )}?alt=media&token=${token}`;
+ console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
+ return { path, url: downloadUrl };
+ } catch (error) {
+ console.error(
+ `❌ [SunoCallback] Échec save piste ${index + 1}:`,
+ error.message,
+ );
+ return null;
+ }
+};
+
+const saveTracksToStorage = async (audioUrls, meta) => {
+ if (!audioUrls?.length) return [];
+ const bucket = admin.storage().bucket();
+ console.log("🪣 [SunoCallback] Bucket:", bucket.name);
+ const context = { ...meta, bucket };
+ const results = await Promise.all(
+ audioUrls.map((url, index) =>
+ downloadTrackToStorage(url, { ...context, index }),
+ ),
+ );
+ return results.filter(Boolean).map((entry) => entry.url);
+};
+
+const mergeMusicUrls = async (projectRef, newUrls) => {
+ const sanitizedNewUrls = sanitizeMusicUrls(newUrls);
+ console.log("➕ [SunoCallback] Nouveaux morceaux à ajouter:", {
+ count: sanitizedNewUrls.length,
+ urls: sanitizedNewUrls,
+ });
+
+ let existingUrls = [];
+ try {
+ const snapshot = await projectRef.get();
+ existingUrls = sanitizeMusicUrls(snapshot?.data()?.musicUrls);
+ console.log("📦 [SunoCallback] Morceaux déjà stockés:", {
+ count: existingUrls.length,
+ urls: existingUrls,
+ });
+ } catch (error) {
+ console.error(
+ "⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:",
+ error,
+ );
+ }
+
+ const allUrls = [...existingUrls, ...sanitizedNewUrls];
+ const dedupedUrls = allUrls.filter(
+ (url, index) => allUrls.indexOf(url) === index,
+ );
+
+ console.log("🎶 [SunoCallback] Morceaux conservés après fusion:", {
+ count: dedupedUrls.length,
+ urls: dedupedUrls,
+ });
+
+ await projectRef.set(
+ {
+ musicStatus: "GENERATED",
+ musicUrls: dedupedUrls,
+ musicError: FieldValue.delete(),
+ updatedAt: FieldValue.serverTimestamp(),
+ },
+ { merge: true },
+ );
+
+ return dedupedUrls;
+};
+
/**
* Construit le style musical à partir des paramètres
* @param {Object} params - Les paramètres de style
@@ -535,232 +682,103 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
* de musique est terminée
*/
exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
- try {
- console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
+ if (req.method !== "POST") {
+ console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
+ return res.status(405).json({ error: "Méthode non autorisée" });
+ }
- if (req.method !== "POST") {
- console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
- return res.status(405).json({ error: "Méthode non autorisée" });
- }
+ console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
- const body = req.body || {};
- const code = body.code ?? body.statusCode ?? null;
- const callbackType = (body?.data?.callbackType || "")
- .toString()
- .toLowerCase();
- const status = (body.status || body.state || callbackType)
- .toString()
- .toLowerCase();
- const taskId =
- body.taskId ||
- body.task_id ||
- body?.data?.taskId ||
- body?.data?.task_id ||
- null;
- const tracks = Array.isArray(body?.data?.data)
- ? body.data.data
- : Array.isArray(body.data)
- ? body.data
- : [];
+ const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
+ console.log("🎯 [SunoCallback] Détails:", {
+ code,
+ status,
+ taskId,
+ count: tracks.length,
+ });
- console.log("🎯 [SunoCallback] Détails:", {
+ if (code !== 200 || status !== "complete") {
+ console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
code,
status,
- callbackType,
- taskId,
- count: tracks.length,
});
+ return res.status(200).json({ success: true, ignored: true });
+ }
- // On n'agit que si code === 200 et status === "complete"
- if (code !== 200 || status !== "complete") {
- console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
- code,
- status,
- });
- return res.status(200).json({ success: true, ignored: true });
- }
+ if (!taskId) {
+ console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
+ return res.status(200).json({ success: true, ignored: true });
+ }
- if (!taskId) {
- console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
- return res.status(200).json({ success: true, ignored: true });
- }
-
- // 1) Récupérer le projectId associé au taskId
- let projectId = null;
- const projSnap = await refList.projects
- .where("sunoTaskId", "==", taskId)
- .limit(1)
- .get();
- if (projSnap.empty) {
- throw new Error("Aucun projet trouvé pour ce taskId");
- }
- const projectDoc = projSnap.docs[0];
- const projectData = projectDoc?.data() || {};
- projectId = projectDoc.id;
- const userId =
- typeof projectData.userId === "string" && projectData.userId.trim()
- ? projectData.userId.trim()
- : null;
- const projectTitle =
- typeof projectData.title === "string" && projectData.title.trim()
- ? projectData.title.trim()
- : "ton projet";
-
- if (!projectId) {
- console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
- return res.status(200).json({ success: true, ignored: true });
- }
-
- // 2) Extraire jusqu'à 2 URLs audio
- const audioUrls = tracks
- .map(
- (t) =>
- t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
- )
- .filter(Boolean)
- .slice(0, 2);
+ try {
+ const { projectId, projectData, projectRef } =
+ await fetchProjectByTaskId(taskId);
+ const { userId, projectTitle } = formatProjectMeta(projectData);
+ const audioUrls = extractAudioUrlsFromTracks(tracks);
if (audioUrls.length < 2) {
console.warn(
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
{
found: audioUrls.length,
- tracksSample: tracks.map((t) => ({
- id: t.id,
- has_audio_url: !!t.audio_url,
- has_stream_audio_url: !!t.stream_audio_url,
- })),
},
);
}
- // 3) Télécharger et sauvegarder dans Cloud Storage + récupérer download URLs
- const bucket = admin.storage().bucket();
- console.log("🪣 [SunoCallback] Bucket:", bucket.name);
- const saveOne = async (url, index) => {
- if (!url) return null;
+ const storedUrls = await saveTracksToStorage(audioUrls, {
+ userId,
+ projectId,
+ taskId,
+ });
+
+ const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
+
+ console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
+ projectId,
+ musicUrlsCount: musicUrls.length,
+ });
+
+ if (userId) {
+ const successMessage =
+ musicUrls.length > 0
+ ? `Ta musique pour "${projectTitle}" est prête.`
+ : `La génération de musique pour "${projectTitle}" est terminée.`;
try {
- console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
- const resp = await axios.get(url, { responseType: "arraybuffer" });
- const buffer = Buffer.from(resp.data);
- const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
- const token = require("crypto").randomUUID();
- const file = bucket.file(path);
- await file.save(buffer, {
- resumable: false,
- metadata: {
- contentType: "audio/mpeg",
- cacheControl: "public, max-age=31536000",
- metadata: { firebaseStorageDownloadTokens: token },
+ await sendNotification({
+ sender: "SYSTEM",
+ receiver: userId,
+ receiverCollection: "users",
+ title: "Musique prête",
+ message: successMessage,
+ data: {
+ type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
+ projectId,
+ projectTitle,
+ musicUrls,
+ taskId,
},
});
- const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
- path,
- )}?alt=media&token=${token}`;
- console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
- return { path, url: downloadUrl };
- } catch (e) {
+ } catch (notifError) {
console.error(
- `❌ [SunoCallback] Échec save piste ${index + 1}:`,
- e.message,
- );
- return null;
- }
- };
-
- const [p1, p2] = await Promise.all([
- saveOne(audioUrls[0], 0),
- saveOne(audioUrls[1], 1),
- ]);
-
- // 4) Mettre à jour le statut du projet
- try {
- const newMusicUrls = [p1?.url, p2?.url]
- .filter((url) => typeof url === "string" && url.trim())
- .map((url) => url.trim());
-
- const projectRef = refList.projects.doc(projectId);
- let existingMusicUrls = [];
- try {
- const projectSnap = await projectRef.get();
- const projectData = projectSnap?.data() || {};
- if (Array.isArray(projectData.musicUrls)) {
- existingMusicUrls = projectData.musicUrls
- .filter((url) => typeof url === "string" && url.trim())
- .map((url) => url.trim());
- }
- } catch (readError) {
- console.error(
- "⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:",
- readError,
+ "[sunoCallback] Failed to send success notification:",
+ notifError,
);
}
-
- const mergedMusicUrls = [...existingMusicUrls, ...newMusicUrls];
- const uniqueMusicUrls = mergedMusicUrls.filter(
- (url, index) => mergedMusicUrls.indexOf(url) === index,
- );
- const musicUrls =
- uniqueMusicUrls.length > MAX_STORED_MUSIC_TRACKS
- ? uniqueMusicUrls.slice(
- uniqueMusicUrls.length - MAX_STORED_MUSIC_TRACKS,
- )
- : uniqueMusicUrls;
-
- await projectRef.set(
- {
- musicStatus: "GENERATED",
- musicUrls,
- musicError: FieldValue.delete(),
- updatedAt: FieldValue.serverTimestamp(),
- },
- { merge: true },
- );
- console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
- projectId,
- musicUrlsCount: musicUrls.length,
- });
-
- if (userId) {
- const successMessage =
- musicUrls.length > 0
- ? `Ta musique pour "${projectTitle}" est prête.`
- : `La génération de musique pour "${projectTitle}" est terminée.`;
- try {
- await sendNotification({
- sender: "SYSTEM",
- receiver: userId,
- receiverCollection: "users",
- title: "Musique prête",
- message: successMessage,
- data: {
- type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
- projectId,
- projectTitle,
- musicUrls,
- taskId,
- },
- });
- } catch (notifError) {
- console.error(
- "[sunoCallback] Failed to send success notification:",
- notifError,
- );
- }
- }
- } catch (e) {
- console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
}
return res.status(200).json({
success: true,
projectId,
- saved: [p1, p2].filter(Boolean),
+ savedCount: storedUrls.length,
+ musicUrlsCount: musicUrls.length,
});
} catch (error) {
+ const statusCode =
+ error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
logger.error("❌ [SunoCallback] Erreur interne:", error);
- res
- .status(500)
- .json({ error: "Erreur interne du serveur", message: error.message });
+ return res.status(statusCode).json({
+ success: false,
+ error: error?.message || "Erreur interne du serveur",
+ });
}
});
diff --git a/src/components/ShareBtn/ShareBtn.js b/src/components/ShareBtn/ShareBtn.js
index 3d15295..1d9ae0d 100644
--- a/src/components/ShareBtn/ShareBtn.js
+++ b/src/components/ShareBtn/ShareBtn.js
@@ -1,10 +1,16 @@
import React, { useCallback } from "react";
import { BlurView } from "expo-blur";
-import { Pressable } from "react-native";
+import { Pressable, Text } from "react-native";
import { Entypo } from "@expo/vector-icons";
+import { FONT_FAMILY } from "../../styles/Fonts";
import { openShareSheet } from "../../utils/shareSheet";
-export default function ShareBtn({ style, onPress = null }) {
+export default function ShareBtn({
+ style,
+ onPress = null,
+ label = null,
+ iconOnly = false,
+}) {
const handlePress = useCallback(() => {
if (typeof onPress === "function") {
onPress();
@@ -25,14 +31,33 @@ export default function ShareBtn({ style, onPress = null }) {
intensity={20}
tint="dark"
style={{
- padding: 12,
+ padding: iconOnly ? 10 : label ? 10 : 12,
+ paddingHorizontal: iconOnly ? 10 : label ? 16 : 12,
borderRadius: 15,
overflow: "hidden",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
+ borderWidth: iconOnly ? 1 : 0,
+ borderColor: iconOnly ? "rgba(255, 255, 255, 0.1)" : "transparent",
+ backgroundColor: iconOnly
+ ? "rgba(12, 14, 18, 0.72)"
+ : "transparent",
}}
>
+ {label && !iconOnly ? (
+
+ {label}
+
+ ) : null}
diff --git a/src/navigation/Routes.js b/src/navigation/Routes.js
index 4e3db8f..4d059ec 100644
--- a/src/navigation/Routes.js
+++ b/src/navigation/Routes.js
@@ -64,7 +64,7 @@ export const Routes = {
Create: "Create",
HitParade: "HitParade",
- Playbacks: "Playback",
+ Playbacks: "Playbacks",
Library: "Library",
AllMyPlaylist: "AllMyPlaylist",
diff --git a/src/providers/StripeProvider.js b/src/providers/StripeProvider.js
index bbc5778..beb4b41 100644
--- a/src/providers/StripeProvider.js
+++ b/src/providers/StripeProvider.js
@@ -1,10 +1,11 @@
import React from "react";
-import { Linking, StyleSheet, View } from "react-native";
+import { Linking, Platform, StyleSheet, View } from "react-native";
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
+import * as WebBrowser from "expo-web-browser";
import Overlay from "../components/Overlay";
import Button from "../components/Button";
@@ -80,6 +81,26 @@ const StripeProvider = ({ children }) => {
throw new Error("Navigation Stripe impossible dans cet environnement.");
}
+ const isMobileApp =
+ Platform.OS === "ios" || Platform.OS === "android";
+
+ if (isMobileApp) {
+ try {
+ await WebBrowser.openBrowserAsync(checkoutUrl, {
+ enableDefaultShareMenu: false,
+ dismissButtonStyle: "close",
+ presentationStyle:
+ WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET,
+ });
+ return;
+ } catch (webBrowserError) {
+ console.warn(
+ "[StripeProvider] WebBrowser checkout fallback",
+ webBrowserError,
+ );
+ }
+ }
+
const canOpen = await Linking.canOpenURL(checkoutUrl);
if (!canOpen) {
throw new Error("Impossible d'ouvrir l'URL de paiement.");
diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js
index bca5625..b7ddfd2 100644
--- a/src/screens/Home/Home.js
+++ b/src/screens/Home/Home.js
@@ -5,8 +5,8 @@ import React, {
useRef,
useState,
} from "react";
-import { ScrollView, StyleSheet, Text, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
+import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, cardsImg, icons } from "../../assets";
import Alert from "../../components/Alert";
@@ -16,6 +16,7 @@ import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MoreMenu from "../../components/MoreMenu";
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
+import CoinPackModal from "../../components/modal/CoinPackModal";
import { projectsRef, usersRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType.js";
import Page from "../../layouts/Page";
@@ -23,12 +24,15 @@ import LandingPage from "../LandingPage";
import { navigate } from "../../navigation/NavigationService";
import { Routes } from "../../navigation/Routes";
import { useUser } from "../../providers/UserDataProvider";
+import CreditAmount from "../../components/CreditAmount";
+import ShareBtn from "../../components/ShareBtn/ShareBtn";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
getCreationStageStates,
getStageAction,
} from "../../utils/projectStages";
+import { openCoinPackModal } from "../../utils/coinPackModal";
import StageCard from "./components/StageCard";
import ClubCard from "./components/ClubCard";
@@ -424,6 +428,8 @@ const Home = ({ navigation, route }) => {
[stageStatesByKey],
);
+ const [isCoinModalVisible, setCoinModalVisible] = useState(false);
+
const handleStagePress = useCallback(
(stageKey, isLocked) => {
if (isLocked) {
@@ -451,6 +457,18 @@ const Home = ({ navigation, route }) => {
navigate(Routes.Payments);
}, []);
+ const handleOpenCoinModal = useCallback(() => {
+ if (isWeb) {
+ openCoinPackModal();
+ return;
+ }
+ setCoinModalVisible(true);
+ }, []);
+
+ const handleCloseCoinModal = useCallback(() => {
+ setCoinModalVisible(false);
+ }, []);
+
const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack;
const stageCardItemStyle = isWeb
? styles.webStageCard
@@ -476,8 +494,45 @@ const Home = ({ navigation, route }) => {
);
+ 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 mobileInfoRow = !isWeb ? (
+
+
+
+
+
+
+ ) : null;
+
const topBar = (
+ {mobileInfoRow}
{
>
);
- return adventureStarted ? (
-
-
-
-
- {isWeb ? (
-
- {topBar}
- {journeyContent}
-
- ) : (
-
- {topBar}
-
- {journeyContent}
-
-
- )}
-
-
-
- ) : (
+ return (
<>
-
-
-
+ {adventureStarted ? (
+
+
+
+
+ {isWeb ? (
+
+ {topBar}
+ {journeyContent}
+
+ ) : (
+
+ {topBar}
+
+ {journeyContent}
+
+
+ )}
+
+
-
-
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {!isWeb ? (
+
+ ) : null}
>
);
};
@@ -659,10 +724,10 @@ const styles = StyleSheet.create({
},
topBar: {
width: "100%",
- flexDirection: "row",
- flexWrap: "wrap",
- alignItems: "center",
- justifyContent: "center",
+ flexDirection: isWeb ? "row" : "column",
+ flexWrap: isWeb ? "wrap" : "nowrap",
+ alignItems: isWeb ? "center" : "stretch",
+ justifyContent: isWeb ? "center" : "flex-start",
gap: 12,
marginTop: isWeb ? 24 : 0,
marginBottom: isWeb ? 8 : 16,
@@ -674,6 +739,38 @@ const styles = StyleSheet.create({
maxWidth: 420,
flexGrow: 1,
},
+ mobileInfoRow: {
+ width: "100%",
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ },
+ mobileCoinButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ backgroundColor: "rgba(12, 14, 18, 0.72)",
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ flexShrink: 1,
+ },
+ mobileCoinAmount: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ },
+ mobileCoinText: {
+ fontSize: 18,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterSemiBold,
+ },
+ mobileShareButton: {
+ flexShrink: 0,
+ },
mobileContent: {
flex: 1,
width: "100%",
@@ -684,7 +781,7 @@ const styles = StyleSheet.create({
},
mobileScrollContent: {
paddingHorizontal: gutters,
- paddingTop: 24,
+ paddingTop: 0,
paddingBottom: gutters * 6,
},
subtitleWrapper: {
diff --git a/src/screens/Payments.js b/src/screens/Payments.js
index 53c14f3..9182f87 100644
--- a/src/screens/Payments.js
+++ b/src/screens/Payments.js
@@ -1,6 +1,7 @@
import React from "react";
import {
ActivityIndicator,
+ FlatList,
Pressable,
StyleSheet,
Text,
@@ -9,6 +10,7 @@ import {
import { useRoute } from "@react-navigation/native";
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import GradientButton from "../components/GradientButton";
import CreditAmount from "../components/CreditAmount";
import Page from "../layouts/Page";
@@ -279,9 +281,14 @@ const PLAN_SEGMENTS = [
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520;
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
const PAGE_BACKGROUND_COLOR = "#303438";
+const SUBSCRIPTION_DISCLAIMER =
+ "Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation).";
+const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
export default function Payments() {
const route = useRoute();
+ const insets = useSafeAreaInsets();
+ const isMobile = !isWeb;
const initialPack = React.useMemo(() => {
const rawPack = route?.params?.pack;
return typeof rawPack === "string" ? rawPack.toLowerCase() : null;
@@ -290,7 +297,7 @@ export default function Payments() {
const initialSubscriptionPack =
initialPack && initialPack !== "packs" ? initialPack : null;
- const backgroundImage = background.bgTrans;
+ const backgroundImage = isMobile ? background.homeBG : background.bgTrans;
const {
subscriptions,
isCatalogLoading,
@@ -458,6 +465,144 @@ export default function Payments() {
const actionButtonTitle = isProcessing
? "Redirection..."
: "Choisir cet abonnement";
+ const currentPlanCount = currentPlans.length;
+ const mobileActionSafePadding = React.useMemo(
+ () => Math.max(insets.bottom, gutters),
+ [insets.bottom],
+ );
+
+ const mobileListContentInset = React.useMemo(
+ () => ({
+ paddingBottom: mobileActionSafePadding + gutters * 3,
+ }),
+ [mobileActionSafePadding],
+ );
+
+ const renderHeaderSection = React.useCallback(() => {
+ return (
+ <>
+
+
+ Choisissez l’abonnement qui vous correspond
+
+
+ {combinedErrorMessage ? (
+ {combinedErrorMessage}
+ ) : null}
+ >
+ );
+ }, [combinedErrorMessage]);
+
+ const renderSegmentedControl = React.useCallback(() => {
+ return (
+
+ {PLAN_SEGMENTS.map(({ key, label }) => {
+ const isActive = billingPeriod === key;
+ return (
+ setBillingPeriod(key)}
+ style={[
+ styles.segmentButton,
+ isActive && styles.segmentButtonActive,
+ ]}
+ accessibilityRole="button"
+ accessibilityState={{ selected: isActive }}
+ >
+
+ {label}
+
+
+ );
+ })}
+
+ );
+ }, [billingPeriod, isMobile]);
+
+ const renderMobilePlanItem = React.useCallback(
+ ({ item }) => (
+
+
+
+ ),
+ [handleSelect, selectedPriceId],
+ );
+
+ const renderMobileEmptyComponent = React.useCallback(() => {
+ return (
+
+ {isLoadingPlans ? (
+
+ ) : (
+
+ Aucun abonnement Stripe disponible pour le moment.
+
+ )}
+
+ );
+ }, [isLoadingPlans]);
+
+ const renderMobileFooterComponent = React.useCallback(() => {
+ return (
+
+ {isLoadingPlans && currentPlanCount > 0 ? (
+
+
+
+ ) : null}
+ {SUBSCRIPTION_DISCLAIMER}
+
+ );
+ }, [currentPlanCount, isLoadingPlans]);
+
+ const renderMobileTopSticky = React.useCallback(() => {
+ return (
+
+ {renderHeaderSection()}
+ {renderSegmentedControl()}
+
+ );
+ }, [renderHeaderSection, renderSegmentedControl]);
+
+ const renderMobileBottomActions = React.useCallback(() => {
+ return (
+
+
+
+ );
+ }, [
+ actionButtonTitle,
+ handleCheckout,
+ isActionDisabled,
+ mobileActionSafePadding,
+ ]);
return (
@@ -467,100 +612,88 @@ export default function Payments() {
scrollEnabled={false}
width={isWeb ? 960 : undefined}
containerStyle={styles.page}
- contentContainerStyle={styles.pageContent}
+ contentContainerStyle={[
+ styles.pageContent,
+ isMobile && styles.pageContentMobile,
+ ]}
backgroundColor={PAGE_BACKGROUND_COLOR}
+ topStickyContent={isMobile ? renderMobileTopSticky : undefined}
>
-
+
-
-
-
- Choisissez l’abonnement qui vous correspond
-
-
-
- {combinedErrorMessage ? (
- {combinedErrorMessage}
- ) : null}
-
- {isLoadingPlans && !currentPlans.length ? (
-
-
-
- ) : null}
-
- {!isLoadingPlans && currentPlans.length === 0 ? (
-
- Aucun abonnement Stripe disponible pour le moment.
-
- ) : null}
-
-
- {PLAN_SEGMENTS.map(({ key, label }) => {
- const isActive = billingPeriod === key;
- return (
- setBillingPeriod(key)}
- style={[
- styles.segmentButton,
- isActive && styles.segmentButtonActive,
- ]}
- accessibilityRole="button"
- accessibilityState={{ selected: isActive }}
- >
-
- {label}
-
-
- );
- })}
-
-
-
- {currentPlans.map((plan) => (
-
- ))}
-
-
- {isLoadingPlans && currentPlans.length > 0 ? (
-
-
-
- ) : null}
-
-
-
+ {isMobile ? (
+ plan.priceId}
+ renderItem={renderMobilePlanItem}
+ style={styles.mobileList}
+ contentContainerStyle={[
+ styles.mobileListContent,
+ mobileListContentInset,
+ ]}
+ ListEmptyComponent={renderMobileEmptyComponent}
+ ListFooterComponent={renderMobileFooterComponent}
+ showsVerticalScrollIndicator={false}
/>
-
+ ) : (
+ <>
+ {renderHeaderSection()}
-
- Résiliable en un clic à tout moment. Les prix sont indiqués TTC.
- Contacte-nous pour des besoins spécifiques (facturation annuelle,
- volume, offres éducation).
-
+ {isLoadingPlans && currentPlanCount === 0 ? (
+
+
+
+ ) : null}
+
+ {!isLoadingPlans && currentPlanCount === 0 ? (
+
+ Aucun abonnement Stripe disponible pour le moment.
+
+ ) : null}
+
+ {renderSegmentedControl()}
+
+
+ {currentPlans.map((plan) => (
+
+ ))}
+
+
+ {isLoadingPlans && currentPlanCount > 0 ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+ {SUBSCRIPTION_DISCLAIMER}
+
+ >
+ )}
+ {isMobile ? renderMobileBottomActions() : null}
);
}
@@ -578,6 +711,10 @@ const styles = StyleSheet.create({
paddingTop: 48,
paddingBottom: 48,
},
+ pageContentMobile: {
+ paddingTop: gutters,
+ paddingBottom: gutters * 2,
+ },
inner: {
flex: 1,
width: "100%",
@@ -588,6 +725,10 @@ const styles = StyleSheet.create({
paddingHorizontal: gutters,
position: "relative",
},
+ mobileInner: {
+ alignItems: "stretch",
+ justifyContent: "flex-start",
+ },
content: {
width: "100%",
gap: 32,
@@ -596,6 +737,12 @@ const styles = StyleSheet.create({
position: "relative",
zIndex: 1,
},
+ mobileContent: {
+ flex: 1,
+ gap: 0,
+ alignItems: "stretch",
+ justifyContent: "flex-start",
+ },
centerImage: {
width: HERO_IMAGE_WIDTH,
height: HERO_IMAGE_HEIGHT,
@@ -616,7 +763,8 @@ const styles = StyleSheet.create({
},
title: {
fontFamily: FONT_FAMILY.InterBold,
- fontSize: 32,
+ fontSize: isWeb ? 32 : 24,
+ lineHeight: isWeb ? 40 : 28,
color: Palette.white,
textAlign: "center",
},
@@ -650,11 +798,12 @@ const styles = StyleSheet.create({
segmentedControl: {
flexDirection: "row",
alignSelf: "center",
+ justifyContent: "center",
padding: 4,
borderRadius: 999,
backgroundColor: "rgba(255, 255, 255, 0.08)",
- marginTop: 12,
- marginBottom: 8,
+ marginTop: isWeb ? 12 : 8,
+ marginBottom: isWeb ? 8 : 4,
},
segmentButton: {
paddingVertical: 8,
@@ -683,7 +832,7 @@ const styles = StyleSheet.create({
flex: 1,
width: "100%",
minWidth: 0,
- minHeight: 320,
+ minHeight: CARD_MIN_HEIGHT,
borderRadius: 24,
overflow: "hidden",
borderWidth: 1,
@@ -706,15 +855,15 @@ const styles = StyleSheet.create({
},
cardBlur: {
flex: 1,
- paddingHorizontal: gutters * 1.2,
- paddingVertical: gutters,
- gap: 20,
+ paddingHorizontal: gutters,
+ paddingVertical: isWeb ? gutters : Math.max(gutters * 0.2, 10),
+ gap: isWeb ? 18 : 10,
justifyContent: "center",
backgroundColor: "rgba(48, 52, 56, 0.55)",
borderRadius: 24,
},
cardContent: {
- gap: 16,
+ gap: isWeb ? 16 : 4,
},
cardHeader: {
gap: 6,
@@ -728,7 +877,7 @@ const styles = StyleSheet.create({
},
planName: {
fontFamily: FONT_FAMILY.InterBold,
- fontSize: 24,
+ fontSize: isWeb ? 24 : 20,
color: Palette.white,
flexShrink: 1,
},
@@ -744,7 +893,7 @@ const styles = StyleSheet.create({
color: "rgba(255, 255, 255, 0.75)",
},
priceBlock: {
- gap: 4,
+ gap: isWeb ? 4 : 1,
},
priceRow: {
flexDirection: "row",
@@ -768,7 +917,7 @@ const styles = StyleSheet.create({
},
priceValue: {
fontFamily: FONT_FAMILY.InterBold,
- fontSize: 22,
+ fontSize: isWeb ? 22 : 20,
color: Palette.white,
},
period: {
@@ -805,4 +954,58 @@ const styles = StyleSheet.create({
lineHeight: 18,
textAlign: "center",
},
+ mobileStickyHeader: {
+ width: "100%",
+ gap: 6,
+ paddingBottom: gutters * 0.2,
+ backgroundColor: PAGE_BACKGROUND_COLOR,
+ alignItems: "center",
+ },
+ segmentedControlMobile: {
+ alignSelf: "center",
+ marginBottom: 0,
+ },
+ mobileList: {
+ flex: 1,
+ width: "100%",
+ },
+ mobileListContent: {
+ flexGrow: 1,
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ mobileCard: {
+ marginBottom: gutters * 0.6,
+ },
+ mobileEmptyWrapper: {
+ paddingVertical: gutters * 2,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ mobileFooter: {
+ width: "100%",
+ paddingTop: gutters,
+ gap: gutters,
+ alignItems: "center",
+ },
+ mobileActions: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: 10,
+ paddingHorizontal: gutters,
+ paddingTop: gutters * 0.4,
+ backgroundColor: PAGE_BACKGROUND_COLOR,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: "rgba(255, 255, 255, 0.12)",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: -4 },
+ shadowOpacity: 0.25,
+ shadowRadius: 12,
+ elevation: 12,
+ },
+ mobileActionButtonGradient: {
+ width: "100%",
+ },
});
diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js
index 68edb8e..8edecf9 100644
--- a/src/screens/Playback/Playback.js
+++ b/src/screens/Playback/Playback.js
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Image, StyleSheet, View } from "react-native";
import { ai, background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
@@ -6,13 +6,42 @@ import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import Page from "../../layouts/Page";
+import { isWeb } from "../../hooks/useLayoutType";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
+import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
const Playback = ({ route }) => {
- const { project } = route.params;
- const [showIntro, setShowIntro] = useState(true);
+ const { project } = route.params || {};
+ const { videos } = useUser();
+ const [showIntro, setShowIntro] = useState(false);
+
+ const introVideoUrl = useMemo(() => {
+ if (!videos) {
+ return null;
+ }
+ return isWeb ? videos?.benhaiWeb || null : videos?.benhai || null;
+ }, [videos]);
+
+ useEffect(() => {
+ if (!introVideoUrl) {
+ setShowIntro(false);
+ return;
+ }
+ setShowIntro(true);
+ }, [introVideoUrl]);
+
+ const handleCloseIntro = useCallback(() => {
+ setShowIntro(false);
+ }, []);
+
+ const handleShowGuide = useCallback(() => {
+ if (!introVideoUrl) {
+ return;
+ }
+ setShowIntro(true);
+ }, [introVideoUrl]);
const onPressRecord = useCallback(() => {
navigate(Routes.RecordPlayback, { project });
@@ -36,14 +65,15 @@ const Playback = ({ route }) => {
/>
setShowIntro(true)}
+ onPress={handleShowGuide}
/>
{/* */}
setShowIntro(false)}
+ url={introVideoUrl}
+ visible={showIntro && !!introVideoUrl}
+ onClose={handleCloseIntro}
/>
);
diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js
index 2b2735b..4e2af91 100644
--- a/src/screens/Playback/RecordPlayback.web.js
+++ b/src/screens/Playback/RecordPlayback.web.js
@@ -24,6 +24,8 @@ import { goBack, navigate } from "../../navigation/NavigationService";
import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
+import { background } from "../../assets";
+import playbackBG2 from "../../assets/UI/playbackBG2.png";
const TIME_BEFORE_INCREMENT_MS = 20000;
const LOG_PREFIX = "[RecordPlayback.web]";
@@ -169,7 +171,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:stopPrevious:error",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
@@ -187,7 +189,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:createError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
return false;
@@ -209,7 +211,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:error",
- String(err?.message || err || "")
+ String(err?.message || err || ""),
);
setMediaError(err instanceof Error ? err : new Error(String(err || "")));
};
@@ -230,7 +232,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:onstop:createBlobError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
releaseRecordingUrl();
}
@@ -253,7 +255,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:startError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(null);
@@ -283,7 +285,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:stopError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
@@ -298,7 +300,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"mediaRecorder:waitStopError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
} finally {
stopRecordingPromiseRef.current = null;
@@ -320,7 +322,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"resetSession:error",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
try {
@@ -330,7 +332,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"resetSession:recorderError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
console.log(LOG_PREFIX, "resetSession:end");
@@ -360,7 +362,7 @@ const RecordPlayback = ({ route }) => {
setLooping(!!originalLoopingValueRef.current.value);
}
};
- }, [setLooping])
+ }, [setLooping]),
);
useFocusEffect(
@@ -370,7 +372,7 @@ const RecordPlayback = ({ route }) => {
console.log(LOG_PREFIX, "focusEffect:cleanup");
void resetSessionRef.current?.();
};
- }, [])
+ }, []),
);
useEffect(() => {
@@ -385,7 +387,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"requestingCameraPermission:error",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
})();
@@ -410,7 +412,7 @@ const RecordPlayback = ({ route }) => {
!navigator.mediaDevices?.getUserMedia
) {
setMediaError(
- new Error("La capture vidéo n'est pas supportée sur ce navigateur")
+ new Error("La capture vidéo n'est pas supportée sur ce navigateur"),
);
setMediaReady(false);
return;
@@ -460,7 +462,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"previewVideo:attachError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
} else {
@@ -502,7 +504,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"stopAndNavigate:pauseError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
@@ -516,7 +518,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"stopAndNavigate:recorderError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
@@ -562,7 +564,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"progressLoop:correctInitialJumpError",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
}
@@ -644,7 +646,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"listenLoop:incrementViews:error",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
}
@@ -654,7 +656,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"listenLoop:error",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
}
}, 500);
@@ -683,7 +685,7 @@ const RecordPlayback = ({ route }) => {
console.log(
LOG_PREFIX,
"startPlayback:error",
- String(e?.message || e || "")
+ String(e?.message || e || ""),
);
setIsRecording(false);
setIsPreparing(false);
@@ -702,7 +704,7 @@ const RecordPlayback = ({ route }) => {
},
},
],
- { cancelable: false }
+ { cancelable: false },
);
}
}
@@ -743,6 +745,7 @@ const RecordPlayback = ({ route }) => {
maxWidth={800}
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
headerType="NONE"
+ backgroundImg={background.playbackBG2}
>
@@ -767,7 +770,7 @@ const RecordPlayback = ({ route }) => {
height: "100%",
objectFit: "cover",
transform: "scaleX(-1)",
- backgroundColor: "#000000",
+ backgroundColor: "transparent",
}}
/>
@@ -793,7 +796,7 @@ const RecordPlayback = ({ route }) => {
left: 0,
right: 0,
bottom: 0,
- backgroundColor: "#00000099",
+ backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: gutters,
@@ -820,7 +823,7 @@ const RecordPlayback = ({ route }) => {
left: 0,
right: 0,
bottom: 0,
- backgroundColor: "#00000066",
+ backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
}}
diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js
index ec380ad..fcec23d 100644
--- a/src/screens/Studio/ComposeSong.js
+++ b/src/screens/Studio/ComposeSong.js
@@ -115,7 +115,7 @@ const ComposeSong = () => {
const persistMusicConfig = async () => {
try {
if (!selectedProjectId) return;
- await updateProjectData({
+ const payload = {
musicConfig: {
title: musicConfig?.title || "",
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
@@ -128,8 +128,13 @@ const ComposeSong = () => {
},
musicStatus: null,
sunoTaskId: firebase.firestore.FieldValue.delete(),
- musicUrls: firebase.firestore.FieldValue.delete(),
- });
+ };
+
+ if (!isRegenerationFlow) {
+ payload.musicUrls = firebase.firestore.FieldValue.delete();
+ }
+
+ await updateProjectData(payload);
} catch (e) {}
};
diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js
index 99b394f..14f2ca9 100644
--- a/src/screens/Studio/ComposeSong.web.js
+++ b/src/screens/Studio/ComposeSong.web.js
@@ -175,7 +175,7 @@ const ComposeSong = () => {
const persistMusicConfig = useCallback(async () => {
try {
if (!selectedProjectId) return;
- await updateProjectData({
+ const payload = {
musicConfig: {
title: musicConfig?.title || "",
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
@@ -188,10 +188,20 @@ const ComposeSong = () => {
},
musicStatus: null,
sunoTaskId: firebase.firestore.FieldValue.delete(),
- musicUrls: firebase.firestore.FieldValue.delete(),
- });
+ };
+
+ if (!isRegenerationFlow) {
+ payload.musicUrls = firebase.firestore.FieldValue.delete();
+ }
+
+ await updateProjectData(payload);
} catch (_error) {}
- }, [musicConfig, selectedProjectId, updateProjectData]);
+ }, [
+ isRegenerationFlow,
+ musicConfig,
+ selectedProjectId,
+ updateProjectData,
+ ]);
const spendCoinsForGeneration = useCallback(async () => {
if (!currentUID) {
diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js
index a9033c5..7853a34 100644
--- a/src/screens/Studio/SongReady.js
+++ b/src/screens/Studio/SongReady.js
@@ -11,9 +11,7 @@ import GradientButton from "../../components/GradientButton";
import ValidateModal from "../../components/modal/ValidateModal";
import MusicLandHeader from "../../components/MusicLandHeader";
import Slider from "../../components/Slider";
-import CreditAmount from "../../components/CreditAmount";
import { isWeb } from "../../hooks/useLayoutType";
-import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { navigate } from "../../navigation/NavigationService";
@@ -21,11 +19,20 @@ import { useUser } from "../../providers/UserDataProvider";
import { Palette, Style } from "../../styles";
import { gutters, size } from "../../styles/Style";
import { FONT_FAMILY } from "../../styles/Fonts";
+import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const MUSIC_GENERATION_COIN_COST = 8;
const SONG_OPTIONS_PER_GENERATION = 2;
const REGENERATE_MODAL_MAX_WIDTH = 540;
+const formatTime = (ms) => {
+ const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000));
+ const minutes = Math.floor(totalSeconds / 60)
+ .toString()
+ .padStart(1, "0");
+ const seconds = (totalSeconds % 60).toString().padStart(2, "0");
+ return `${minutes}:${seconds}`;
+};
const SongReady = () => {
const {
@@ -38,219 +45,72 @@ const SongReady = () => {
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [musicUrls, setMusicUrls] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
- const [isPlaying, setIsPlaying] = useState({ 0: false, 1: false });
- const [progressInfo, setProgressInfo] = useState({
- 0: { pos: 0, dur: 0 },
- 1: { pos: 0, dur: 0 },
- });
+ const playerRefs = useRef({});
- const player0 = useSharedAudioPlayer(
- musicUrls[0] ? { uri: musicUrls[0] } : undefined,
- {
- id: musicUrls[0] ? `songready-${musicUrls[0]}` : undefined,
- title:
- (Array.isArray(selectedProject?.musicTitles)
- ? selectedProject?.musicTitles?.[0]
- : null) ||
- selectedProject?.title ||
- "Option 1",
- artwork: selectedProject?.coverUrl || null,
- coverUrl: selectedProject?.coverUrl || null,
- metadata: { index: 0, projectId },
+ const registerPlayer = useCallback((index, player) => {
+ if (player) {
+ playerRefs.current[index] = player;
+ } else {
+ delete playerRefs.current[index];
+ }
+ }, []);
+
+ const pauseAllExcept = useCallback(async (keepIndex = null) => {
+ const tasks = Object.entries(playerRefs.current).map(
+ async ([key, player]) => {
+ const idx = Number(key);
+ if (!player || idx === keepIndex) return;
+ try {
+ await player.pause?.();
+ } catch (error) {
+ console.log("SongReady pause error", error?.message);
+ }
+ },
+ );
+ await Promise.all(tasks);
+ }, []);
+
+ const pauseAllPlayers = useCallback(async () => {
+ await pauseAllExcept(null);
+ }, [pauseAllExcept]);
+
+ const handleTogglePlayback = useCallback(
+ async (index, isPlayingNow) => {
+ const player = playerRefs.current[index];
+ if (!player) return;
+ if (isPlayingNow) {
+ try {
+ await player.pause?.();
+ } catch (error) {
+ console.log("SongReady pause toggle", error?.message);
+ }
+ return;
+ }
+ await pauseAllExcept(index);
+ try {
+ await player.play?.();
+ } catch (error) {
+ console.log("SongReady play error", error?.message);
+ }
},
+ [pauseAllExcept],
);
- const player1 = useSharedAudioPlayer(
- musicUrls[1] ? { uri: musicUrls[1] } : undefined,
- {
- id: musicUrls[1] ? `songready-${musicUrls[1]}` : undefined,
- title:
- (Array.isArray(selectedProject?.musicTitles)
- ? selectedProject?.musicTitles?.[1]
- : null) ||
- selectedProject?.title ||
- "Option 2",
- artwork: selectedProject?.coverUrl || null,
- coverUrl: selectedProject?.coverUrl || null,
- metadata: { index: 1, projectId },
- },
- );
- const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });
- const prefetchedRef = useRef({
- 0: { url: null, done: false },
- 1: { url: null, done: false },
- });
// Sync URLs from provider's selectedProject
useEffect(() => {
const urls = Array.isArray(selectedProject?.musicUrls)
- ? selectedProject.musicUrls.slice(0, 2)
+ ? selectedProject.musicUrls
+ .filter((url) => typeof url === "string" && url.trim())
+ .map((url) => url.trim())
: [];
setMusicUrls(urls);
+ setSelectedIndex((prev) => {
+ if (!urls.length) return 0;
+ return Math.min(prev, urls.length - 1);
+ });
}, [selectedProject?.musicUrls]);
- const firstUrl = musicUrls[0] ?? null;
- const secondUrl = musicUrls[1] ?? null;
- useEffect(() => {
- let isCancelled = false;
-
- const waitForDuration = async (index) => {
- const getDurationSeconds = () => {
- const targetPlayer = index === 0 ? player0 : player1;
- if (!targetPlayer) return 0;
- const raw = Number(targetPlayer.duration || 0);
- return Number.isFinite(raw) ? raw : 0;
- };
-
- const start = Date.now();
- while (!isCancelled) {
- const durationSeconds = getDurationSeconds();
- if (durationSeconds > 0) {
- const durationMs = Math.round(durationSeconds * 1000);
- setProgressInfo((prev) => {
- const current = prev?.[index] || { pos: 0, dur: 0 };
- if (Math.abs(current.dur - durationMs) < 5) return prev;
- return {
- ...prev,
- [index]: {
- ...current,
- dur: durationMs,
- },
- };
- });
- return true;
- }
- if (Date.now() - start > 6000) {
- break;
- }
- await new Promise((resolve) => setTimeout(resolve, 150));
- }
- return false;
- };
-
- const prefetch = async () => {
- const entries = [
- [0, firstUrl, player0],
- [1, secondUrl, player1],
- ];
-
- for (const [idx, url, player] of entries) {
- const cacheEntry = prefetchedRef.current[idx];
-
- if (!url || !player?.load) {
- if (cacheEntry) {
- cacheEntry.url = url ?? null;
- cacheEntry.done = false;
- }
- continue;
- }
-
- if (cacheEntry && cacheEntry.url === url && cacheEntry.done) {
- continue;
- }
-
- if (cacheEntry) {
- cacheEntry.url = url;
- cacheEntry.done = false;
- }
-
- try {
- await player.load({ startPositionMs: 0 });
- const resolved = await waitForDuration(idx);
- if (cacheEntry && !isCancelled) {
- cacheEntry.done = resolved;
- }
- } catch (err) {
- if (cacheEntry) {
- cacheEntry.done = false;
- }
- if (!isCancelled) {
- console.log("SongReady preload error", err?.message);
- }
- }
-
- if (isCancelled) {
- break;
- }
- }
- };
-
- prefetch();
-
- return () => {
- isCancelled = true;
- };
- }, [firstUrl, secondUrl, player0, player1]);
-
- // Sync progression depuis les players
- useEffect(() => {
- // Ensure we consistently work with milliseconds whatever the platform
- const toMs = (t) => {
- const n = Number(t || 0);
- if (!isFinite(n) || n <= 0) return 0;
- return n >= 1000 ? n : n * 1000;
- };
-
- const id = setInterval(() => {
- const d0 = toMs(player0?.duration);
- const p0 = toMs(player0?.currentTime);
- const d1 = toMs(player1?.duration);
- const p1 = toMs(player1?.currentTime);
- setProgressInfo({ 0: { pos: p0, dur: d0 }, 1: { pos: p1, dur: d1 } });
- setIsPlaying({ 0: !!player0?.playing, 1: !!player1?.playing });
- }, 300);
- return () => clearInterval(id);
- }, [player0, player1]);
-
- const togglePlay = async (idx) => {
- setSelectedIndex(idx);
- const url = musicUrls[idx];
- if (!url) return;
- try {
- // Pause l'autre piste si elle joue
- const other = idx === 0 ? 1 : 0;
- if (isPlaying[other]) {
- if (other === 0) await player0?.pause?.();
- else await player1?.pause?.();
- setIsPlaying((p) => ({ ...p, [other]: false }));
- }
- const player = idx === 0 ? player0 : player1;
- if (!player) return;
- if (player.playing) {
- await player.pause?.();
- setIsPlaying((p) => ({ ...p, [idx]: false }));
- } else {
- await player.play?.();
- setIsPlaying((p) => ({ ...p, [idx]: true }));
- }
- } catch (e) {
- console.log("Audio error", e?.message);
- }
- };
-
- const fmt = (ms) => {
- const total = Math.max(0, Math.floor((ms || 0) / 1000));
- const m = Math.floor(total / 60)
- .toString()
- .padStart(1, "0");
- const s = (total % 60).toString().padStart(2, "0");
- return `${m}:${s}`;
- };
-
- const onSeek = async (idx, ratio) => {
- try {
- const info = progressInfo[idx] || {};
- const dur = info.dur || 0;
- const pos = Math.floor(dur * ratio);
- const player = idx === 0 ? player0 : player1;
- if (player && dur > 0) {
- // seekTo expects seconds
- await player.seekTo?.(Math.floor((pos || 0) / 1000));
- }
- } catch (e) {
- console.log("Seek error", e?.message);
- }
- };
const validateSelection = async () => {
try {
@@ -277,8 +137,7 @@ const SongReady = () => {
shouldSetTooltip: false,
});
- await player0?.pause?.();
- await player1?.pause?.();
+ await pauseAllPlayers();
navigate(Routes.ChooseCoverType);
} catch (e) {
console.log("Validate error", e?.message);
@@ -289,22 +148,16 @@ const SongReady = () => {
useFocusEffect(
useCallback(() => {
return () => {
- try {
- player0?.pause?.();
- player1?.pause?.();
- } catch {}
+ pauseAllPlayers();
};
- }, [player0, player1]),
+ }, [pauseAllPlayers]),
);
useEffect(() => {
return () => {
- try {
- player0?.pause?.();
- player1?.pause?.();
- } catch {}
+ pauseAllPlayers();
};
- }, [player0, player1]);
+ }, [pauseAllPlayers]);
const handleChooseTrack = () => {
if (isWeb) {
@@ -334,8 +187,7 @@ const SongReady = () => {
const handleConfirmRegenerate = async () => {
setShowRegenerateModal(false);
try {
- await player0?.pause?.();
- await player1?.pause?.();
+ await pauseAllPlayers();
} catch {}
navigate(Routes.ComposeSong, { isRegeneration: true });
};
@@ -368,8 +220,7 @@ const SongReady = () => {
{
try {
- await player0?.pause?.();
- await player1?.pause?.();
+ await pauseAllPlayers();
} catch {}
navigate(Routes.Home);
}}
@@ -384,146 +235,19 @@ const SongReady = () => {
}}
/>
- {[0, 1].map((idx) => {
- const isSelected = selectedIndex === idx;
- return (
- setSelectedIndex(idx)}
- style={({ pressed }) => [
- {
- borderRadius: 20,
- borderWidth: isSelected ? 2 : 1,
- borderColor: isSelected
- ? Palette.primary
- : Palette.ultraLightWhite,
- overflow: "hidden",
- },
- pressed && { opacity: 0.96 },
- ]}
- >
-
-
- togglePlay(idx)}
- style={{
- ...Style.containerCenter,
- ...size({ size: 48 }),
- }}
- >
-
-
-
- {`Morceau ${idx + 1}`}
- {
- setSelectedIndex(idx);
- try {
- const player = idx === 0 ? player0 : player1;
- wasPlayingBeforeSeek.current[idx] =
- !!player?.playing;
- if (player?.playing) {
- await player.pause?.();
- setIsPlaying((p) => ({ ...p, [idx]: false }));
- }
- } catch (e) {
- console.log(
- "SongReady pause on seek start",
- e?.message,
- );
- }
- }}
- onSeek={(ratio) => onSeek(idx, ratio)}
- onSeekEnd={async () => {
- try {
- const player = idx === 0 ? player0 : player1;
- if (player && wasPlayingBeforeSeek.current[idx]) {
- if (player.resume) {
- await player.resume?.();
- } else {
- await player.play?.();
- }
- setIsPlaying((p) => ({ ...p, [idx]: true }));
- }
- wasPlayingBeforeSeek.current[idx] = false;
- } catch (e) {
- console.log(
- "SongReady resume after seek",
- e?.message,
- );
- }
- }}
- />
-
- setSelectedIndex(idx)}
- style={{
- ...Style.containerCenter,
- ...size({ size: 24 }),
- }}
- >
-
- {isSelected && (
-
- )}
-
-
-
-
-
- );
- })}
+ {musicUrls.map((url, idx) => (
+
+ ))}
{
);
};
+const SongOptionCard = ({
+ index,
+ url,
+ isSelected,
+ onSelect,
+ registerPlayer,
+ onTogglePlayback,
+ projectId,
+ selectedProject,
+}) => {
+ const trackTitle =
+ (Array.isArray(selectedProject?.musicTitles)
+ ? selectedProject.musicTitles?.[index]
+ : null) ||
+ selectedProject?.title ||
+ `Morceau ${index + 1}`;
+
+ const player = useSharedAudioPlayer(url ? { uri: url } : undefined, {
+ id: url ? `songready-${url}` : undefined,
+ title: trackTitle,
+ artwork: selectedProject?.coverUrl || null,
+ coverUrl: selectedProject?.coverUrl || null,
+ metadata: { index, projectId },
+ });
+
+ const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
+ const [isPlaying, setIsPlaying] = useState(false);
+ const wasPlayingBeforeSeek = useRef(false);
+
+ useEffect(() => {
+ registerPlayer(index, player);
+ return () => registerPlayer(index, null);
+ }, [index, player, registerPlayer]);
+
+ useEffect(() => {
+ if (!player || !url) return;
+ const preload = async () => {
+ try {
+ await player.load?.({ startPositionMs: 0 });
+ } catch (error) {
+ console.log("SongReady preload error", error?.message);
+ }
+ };
+ preload();
+ }, [player, url]);
+
+ useEffect(() => {
+ if (!player) {
+ setIsPlaying(false);
+ setProgressInfo({ pos: 0, dur: 0 });
+ return undefined;
+ }
+ const id = setInterval(() => {
+ const durationMs = Math.max(
+ 0,
+ Math.round((Number(player.duration) || 0) * 1000),
+ );
+ const positionMs = Math.max(
+ 0,
+ Math.round((Number(player.currentTime) || 0) * 1000),
+ );
+ setProgressInfo((prev) => {
+ if (
+ Math.abs((prev?.dur || 0) - durationMs) < 5 &&
+ Math.abs((prev?.pos || 0) - positionMs) < 5
+ ) {
+ return prev;
+ }
+ return { pos: positionMs, dur: durationMs };
+ });
+ setIsPlaying((prev) => {
+ const next = !!player.playing;
+ if (prev !== next) {
+ return next;
+ }
+ return prev;
+ });
+ }, 300);
+ return () => clearInterval(id);
+ }, [player]);
+
+ const handleSeek = async (ratio) => {
+ if (!player || !progressInfo?.dur) return;
+ const dur = progressInfo.dur || 0;
+ const pos = Math.max(0, Math.floor(dur * ratio));
+ try {
+ await player.seekTo?.(Math.floor(pos / 1000));
+ setProgressInfo((prev) => ({ ...prev, pos }));
+ } catch (error) {
+ console.log("SongReady seek error", error?.message);
+ }
+ };
+
+ const handleSeekStart = async () => {
+ onSelect(index);
+ if (!player) return;
+ try {
+ wasPlayingBeforeSeek.current = !!player.playing;
+ if (player.playing) {
+ await player.pause?.();
+ }
+ } catch (error) {
+ console.log("SongReady pause on seek start", error?.message);
+ }
+ };
+
+ const handleSeekEnd = async () => {
+ if (!player) return;
+ try {
+ if (wasPlayingBeforeSeek.current) {
+ if (player.resume) {
+ await player.resume?.();
+ } else {
+ await player.play?.();
+ }
+ }
+ } catch (error) {
+ console.log("SongReady resume after seek", error?.message);
+ } finally {
+ wasPlayingBeforeSeek.current = false;
+ }
+ };
+
+ const handleToggle = () => {
+ onSelect(index);
+ onTogglePlayback?.(index, isPlaying);
+ };
+
+ return (
+ onSelect(index)}
+ style={({ pressed }) => [
+ {
+ borderRadius: 20,
+ borderWidth: isSelected ? 2 : 1,
+ borderColor: isSelected ? Palette.primary : Palette.ultraLightWhite,
+ overflow: "hidden",
+ },
+ pressed && { opacity: 0.96 },
+ ]}
+ >
+
+
+
+
+
+
+ {`Morceau ${index + 1}`}
+
+
+ onSelect(index)}
+ style={{
+ ...Style.containerCenter,
+ ...size({ size: 24 }),
+ }}
+ >
+
+ {isSelected && (
+
+ )}
+
+
+
+
+
+ );
+};
+
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
return (
{
/>
{!selectedProject?.coverUrl && (
{
Array.isArray(customStructure) &&
fallbackStructure?.length &&
(customStructure.length !== fallbackStructure.length ||
- customStructure.some(
- (value, idx) => value !== fallbackStructure[idx]
- ))
+ customStructure.some((value, idx) => value !== fallbackStructure[idx]))
) {
setCustomStructure(fallbackStructure);
}
@@ -371,11 +369,7 @@ const CreateLyricsWithAi = () => {
keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100}
backgroundImg={background.writingBG}
>
-
+
{
keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100}
backgroundImg={background.libraryBgWeb}
>
-
+
{
/> */}