From 705fda0ce3ca216a1b8e1d0b177e4fde27570cf6 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Fri, 14 Nov 2025 18:25:28 +0100 Subject: [PATCH] =?UTF-8?q?more=20fixes=20and=20changes,=20crash,=20design?= =?UTF-8?q?=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + functions/src/music.js | 428 +++++------ src/components/ShareBtn/ShareBtn.js | 31 +- src/navigation/Routes.js | 2 +- src/providers/StripeProvider.js | 23 +- src/screens/Home/Home.js | 237 +++++-- src/screens/Payments.js | 389 +++++++--- src/screens/Playback/Playback.js | 42 +- src/screens/Playback/RecordPlayback.web.js | 51 +- src/screens/Studio/ComposeSong.js | 11 +- src/screens/Studio/ComposeSong.web.js | 18 +- src/screens/Studio/SongReady.js | 666 ++++++++---------- src/screens/Studio/Studio.js | 2 +- src/screens/Writing/CreateLyricsWithAi.js | 10 +- src/screens/Writing/CreateLyricsWithAi.web.js | 6 +- src/screens/Writing/WritingLyrics.js | 2 +- src/screens/cover/ChooseCoverType.js | 2 +- 17 files changed, 1136 insertions(+), 785 deletions(-) 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} > - + { /> */}