diff --git a/functions/helpers/prompts.js b/functions/helpers/prompts.js index 62ae5e1..f4d518e 100644 --- a/functions/helpers/prompts.js +++ b/functions/helpers/prompts.js @@ -9,7 +9,10 @@ exports.generatePicturePrompt = (project = {}) => { const sanitizeInline = (value = "") => { if (typeof value !== "string") return ""; - return value.replace(/[\r\n]+/g, " ").replace(/[<>]/g, "").trim(); + return value + .replace(/[\r\n]+/g, " ") + .replace(/[<>]/g, "") + .trim(); }; const titleForPrompt = sanitizeInline(title) || "Sans titre"; @@ -74,7 +77,7 @@ exports.generatePicturePrompt = (project = {}) => { : ""; const prompt = ` - Créer une pochette d'album en adéquation avec les paroles de la musique + Créer une pochette d'album en adéquation avec les paroles de la musique et qui respecte le style ${coverStyle} ${titleForPrompt} ${artistLine} ${tagsLine} ${lyricsLine} diff --git a/functions/src/music.js b/functions/src/music.js index 056276f..7a5df54 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -7,9 +7,12 @@ const axios = require("axios"); const admin = require("firebase-admin"); const { FieldValue } = require("firebase-admin/firestore"); const { logger } = require("firebase-functions/logger"); +const { pipeline } = require("stream/promises"); +const { randomUUID } = require("crypto"); const { ALERT_TYPE, refList } = require("../index"); const { sendNotification } = require("./notifications"); const { SUNO_API_KEY } = require("../config/keys"); +const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders"); const { SUNO_MODEL, SUNO_CALLBACK_URL, @@ -18,6 +21,51 @@ const { SUNO_STATUS_PATH, } = require("../config/suno"); +const MUSIC_GENERATION_CREDIT_COST = 8; +const MUSIC_REFUND_SOURCE = "music_generation_refund"; + +const refundMusicCredits = async ({ + projectId, + userId, + reason = "music_generation_failed", + context = {}, +}) => { + if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null; + + try { + const metadata = { + source: MUSIC_REFUND_SOURCE, + reason, + projectId, + ...context, + }; + + const { orderId } = await createOrderDocument({ + userId, + type: ORDER_TYPES.SONG, + amount: MUSIC_GENERATION_CREDIT_COST, + songId: projectId, + createdBy: "system", + metadata, + }); + + logger.log("💸 [Music] Crédits remboursés", { + projectId, + userId, + orderId, + }); + + return { orderId }; + } catch (error) { + logger.error("❌ [Music] Échec remboursement crédits", { + projectId, + userId, + error: error?.message, + }); + return null; + } +}; + /** * Marque un projet comme échoué suite à une erreur Suno * @param {string} projectId - Identifiant du projet @@ -42,21 +90,46 @@ async function markProjectMusicFailure(projectId, error) { if (status) errorPayload.status = status; if (error?.code) errorPayload.code = error.code; - await docRef.set( - { - musicStatus: "FAILED", - sunoTaskId: FieldValue.delete(), - generationStartAt: FieldValue.delete(), - musicError: errorPayload, - updatedAt: FieldValue.serverTimestamp(), - }, - { merge: true }, - ); - const receiverId = sanitizeField(projectData?.userId); + const alreadyRefunded = + projectData?.musicCreditsRefunded === true || + typeof projectData?.musicCreditsRefundOrderId === "string"; + + let refundResult = null; + if (receiverId && !alreadyRefunded) { + refundResult = await refundMusicCredits({ + projectId, + userId: receiverId, + reason: sunoMessage, + context: { + status: status || null, + code: error?.code || null, + }, + }); + } + + const updatePayload = { + musicStatus: "FAILED", + sunoTaskId: FieldValue.delete(), + generationStartAt: FieldValue.delete(), + musicError: errorPayload, + updatedAt: FieldValue.serverTimestamp(), + }; + + if (refundResult?.orderId) { + updatePayload.musicCreditsRefunded = true; + updatePayload.musicCreditsRefundOrderId = refundResult.orderId; + updatePayload.musicCreditsRefundedAt = FieldValue.serverTimestamp(); + } + + await docRef.set(updatePayload, { merge: true }); + if (receiverId) { const projectTitle = sanitizeField(projectData?.title, "ton projet"); - const message = `La génération de musique pour "${projectTitle}" a échoué.`; + const baseMessage = `La génération de musique pour "${projectTitle}" a échoué.`; + const message = refundResult?.orderId + ? `${baseMessage} Tes crédits ont été remboursés.` + : baseMessage; try { await sendNotification({ sender: "SYSTEM", @@ -267,12 +340,11 @@ const downloadTrackToStorage = async ( 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 resp = await axios.get(url, { responseType: "stream" }); const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`; - const token = require("crypto").randomUUID(); + const token = randomUUID(); const file = bucket.file(path); - await file.save(buffer, { + const writeStream = file.createWriteStream({ resumable: false, metadata: { contentType: "audio/mpeg", @@ -280,6 +352,7 @@ const downloadTrackToStorage = async ( metadata: { firebaseStorageDownloadTokens: token }, }, }); + await pipeline(resp.data, writeStream); const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( path, )}?alt=media&token=${token}`; @@ -681,104 +754,120 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => { * Cette fonction est appelée par l'API Suno lorsque la génération * de musique est terminée */ -exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { - 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" }); - } +exports.sunoCallback = onRequest( + { methods: ["POST"], memory: "1GiB" }, + async (req, res) => { + 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)); + console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body)); - const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body); - console.log("🎯 [SunoCallback] Détails:", { - code, - status, - taskId, - count: tracks.length, - }); - - if (code !== 200 || status !== "complete") { - console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", { + const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body); + console.log("🎯 [SunoCallback] Détails:", { code, status, + taskId, + count: tracks.length, }); - 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 }); - } - - 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, - }, - ); + if (code !== 200 || status !== "complete") { + console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", { + code, + status, + }); + return res.status(200).json({ success: true, ignored: true }); } - const storedUrls = await saveTracksToStorage(audioUrls, { - userId, - projectId, - taskId, - }); + if (!taskId) { + console.warn("⚠️ [SunoCallback] taskId manquant dans le callback"); + return res.status(200).json({ success: true, ignored: true }); + } - const musicUrls = await mergeMusicUrls(projectRef, storedUrls); + let projectIdForFailure = null; - console.log("🏷️ [SunoCallback] Projet marqué GENERATED", { - projectId, - musicUrlsCount: musicUrls.length, - }); + try { + const { projectId, projectData, projectRef } = + await fetchProjectByTaskId(taskId); + projectIdForFailure = projectId; + const { userId, projectTitle } = formatProjectMeta(projectData); - 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, + const audioUrls = extractAudioUrlsFromTracks(tracks); + if (audioUrls.length < 2) { + console.warn( + "⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback", + { + found: audioUrls.length, }, - }); - } catch (notifError) { - console.error( - "[sunoCallback] Failed to send success notification:", - notifError, ); } - } - return res.status(200).json({ - success: true, - projectId, - 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); - return res.status(statusCode).json({ - success: false, - error: error?.message || "Erreur interne du serveur", - }); - } -}); + 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 { + 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, + ); + } + } + + return res.status(200).json({ + success: true, + projectId, + savedCount: storedUrls.length, + musicUrlsCount: musicUrls.length, + }); + } catch (error) { + const statusCode = + error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500; + if (statusCode !== 404 && projectIdForFailure) { + try { + await markProjectMusicFailure(projectIdForFailure, error); + } catch (markError) { + console.error( + "⚠️ [SunoCallback] Impossible de marquer le projet en échec:", + markError, + ); + } + } + logger.error("❌ [SunoCallback] Erreur interne:", error); + return res.status(statusCode).json({ + success: false, + error: error?.message || "Erreur interne du serveur", + }); + } + }, +); diff --git a/src/assets/UI/libraryBgWeb.png b/src/assets/UI/libraryBgWeb.png index 0a226bd..0d38e16 100644 Binary files a/src/assets/UI/libraryBgWeb.png and b/src/assets/UI/libraryBgWeb.png differ diff --git a/src/components/BorderGradientButton.js b/src/components/BorderGradientButton.js index b57ade4..c3d5c70 100644 --- a/src/components/BorderGradientButton.js +++ b/src/components/BorderGradientButton.js @@ -3,7 +3,7 @@ import React from "react"; import { Image, Pressable, Text, View } from "react-native"; import { Palette, Style } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; -import { size } from "../styles/Style"; +import { size as sizeStyle } from "../styles/Style"; import BorderGradient from "./BorderGradient/BorderGradient"; const HEIGHT_BY_SIZE = { @@ -84,7 +84,9 @@ const BorderGradientButton = ({ gap: 11, }} > - {icon && } + {icon && ( + + )} { + const numericProgress = Number(progress); + const normalizedProgress = Math.max( + 0, + Math.min(100, Number.isFinite(numericProgress) ? numericProgress : 0), + ); + const isError = status === "error"; + const containerBackground = isError ? "#3C101B" : "#0F0C19"; + const fillColor = isError ? "#FF6B6B" : Palette.white; + const shouldUseGradient = gradient && !isError; + return ( - {gradient ? ( + {shouldUseGradient ? ( diff --git a/src/config/firebase.js b/src/config/firebase.js index 331eab3..eea044f 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -12,7 +12,7 @@ import { Platform } from "react-native"; const functionsInstances = {}; const emulatorConfigured = {}; -const emulatorHost = Platform.OS === "web" ? "localhost" : "192.168.1.62"; +const emulatorHost = Platform.OS === "web" ? "localhost" : "192.168.1.103"; const USE_FUNCTIONS_EMULATOR = false; // Toggle to route functions traffic to the local emulator. const configureFunctionsEmulator = (instance, regionKey = "us-central1") => { diff --git a/src/data/keys.js b/src/data/keys.js index f7fec16..60b4bbc 100644 --- a/src/data/keys.js +++ b/src/data/keys.js @@ -15,7 +15,7 @@ export const GOOGLE_WEB_CLIENT_ID = // iOS client ID (may need updating to match the iOS bundle for this project) export const GOOGLE_IOS_CLIENT_ID = - "943006074419-h43c7p48nhn5f4td4oimv4dq7b1p3s29.apps.googleusercontent.com"; + "305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com"; // Android client ID (client_type 1 from google-services.json) export const GOOGLE_ANDROID_CLIENT_ID = diff --git a/src/layouts/Page.js b/src/layouts/Page.js index fd484da..6acc3b4 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -1,5 +1,5 @@ /* eslint-disable react/display-name */ -import { Image, Pressable, Text, View } from "react-native"; +import { Image, Pressable, Text, View, StyleSheet } from "react-native"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; import { SafeAreaView } from "react-native-safe-area-context"; import React from "reactn"; @@ -168,6 +168,19 @@ export default ({ return ; })(); + const resolvedContainerStyle = React.useMemo( + () => StyleSheet.flatten(containerStyle) || {}, + [containerStyle], + ); + const resolvedHeaderStyle = React.useMemo( + () => StyleSheet.flatten(headerStyle) || {}, + [headerStyle], + ); + const resolvedContentContainerStyle = React.useMemo( + () => StyleSheet.flatten(contentContainerStyle) || {}, + [contentContainerStyle], + ); + return ( {headerType !== "NONE" ? ( headerType === "BASE" ? ( - + ) : ( { if (isWeb) { if (typeof window !== "undefined") { - window.location.assign(checkoutUrl); + const openedTab = window.open( + checkoutUrl, + "_blank", + "noopener,noreferrer", + ); + // Fallback to same-tab navigation if the popup is blocked. + if (!openedTab) { + window.location.assign(checkoutUrl); + } return; } throw new Error("Navigation Stripe impossible dans cet environnement."); diff --git a/src/screens/Payments.js b/src/screens/Payments.js index 9182f87..a363d2c 100644 --- a/src/screens/Payments.js +++ b/src/screens/Payments.js @@ -75,7 +75,8 @@ function SubscriptionCard({ plan, selected, onSelect }) { const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency); const intervalLabel = getIntervalLabel(plan?.recurring); const coinsPerMonth = - typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth) + typeof plan?.coinsPerMonth === "number" && + Number.isFinite(plan.coinsPerMonth) ? Math.round(plan.coinsPerMonth) : null; const planBadgeKey = getPlanKeyForBadge(plan); @@ -278,7 +279,7 @@ const PLAN_SEGMENTS = [ { key: "annual", label: "Annuel" }, ]; -const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520; +const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700; const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360; const PAGE_BACKGROUND_COLOR = "#303438"; const SUBSCRIPTION_DISCLAIMER = @@ -399,11 +400,7 @@ export default function Payments() { } else if (shouldApplyPack && !isCatalogLoading) { initialPackHandledRef.current = true; } - }, [ - normalizedPlans, - initialSubscriptionPack, - isCatalogLoading, - ]); + }, [normalizedPlans, initialSubscriptionPack, isCatalogLoading]); const currentPlans = normalizedPlans[billingPeriod] || []; const selectedPriceId = selectedPriceIds[billingPeriod]; @@ -460,8 +457,7 @@ export default function Payments() { }, [billingPeriod]); const isProcessing = Boolean(processingPriceId); - const isActionDisabled = - !selectedPriceId || isProcessing || isLoadingPlans; + const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans; const actionButtonTitle = isProcessing ? "Redirection..." : "Choisir cet abonnement"; @@ -621,7 +617,7 @@ export default function Payments() { > @@ -685,9 +681,7 @@ export default function Payments() { /> - - {SUBSCRIPTION_DISCLAIMER} - + {SUBSCRIPTION_DISCLAIMER} )} diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index 8edecf9..b76130e 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -12,7 +12,7 @@ import { goBack, navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; -const Playback = ({ route }) => { +const Playback = ({ route, navigation }) => { const { project } = route.params || {}; const { videos } = useUser(); const [showIntro, setShowIntro] = useState(false); @@ -50,7 +50,10 @@ const Playback = ({ route }) => { return ( - + navigation.navigate(Routes.Home)} + progress={25} + /> { setRemoteError(null); try { - const callable = getFunctionsClient( - FUNCTIONS_REGION, - ).httpsCallable("subscription-getActiveSubscription"); + const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( + "subscription-getActiveSubscription", + ); const { data } = await callable(); if (!isMounted) { return; @@ -232,7 +234,9 @@ const ManageSubscription = ({ navigation }) => { pickString(currentUserData?.stripeSubscriptionStatus) || pickString(remoteSubscription?.status) || pickString(currentUserData?.stripeSubscription?.status) || - pickString(currentUserData?.stripeSubscription?.stripeSubscriptionStatus) || + pickString( + currentUserData?.stripeSubscription?.stripeSubscriptionStatus, + ) || pickString(rawSubscription?.status) || null; @@ -349,8 +353,7 @@ const ManageSubscription = ({ navigation }) => { ? computeFirstAnnualGrantFromCreation(createdAtDate) : null; const futureFallbackGrant = - fallbackInitialGrant && - fallbackInitialGrant.getTime() >= now.getTime() + fallbackInitialGrant && fallbackInitialGrant.getTime() >= now.getTime() ? fallbackInitialGrant : null; @@ -418,9 +421,9 @@ const ManageSubscription = ({ navigation }) => { setSuccessMessage(null); try { - const callable = getFunctionsClient( - FUNCTIONS_REGION, - ).httpsCallable("subscription-cancelActiveSubscription"); + const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( + "subscription-cancelActiveSubscription", + ); const payload = subscriptionInfo.subscriptionId ? { subscriptionId: subscriptionInfo.subscriptionId } : {}; @@ -446,9 +449,7 @@ const ManageSubscription = ({ navigation }) => { error?.message || error, ); const message = - error?.message || - error?.codeMessage || - "Impossible d'annuler l'abonnement pour le moment."; + error?.message || "Impossible d'annuler l'abonnement pour le moment."; setErrorMessage(message); } finally { setIsCancelling(false); @@ -491,14 +492,17 @@ const ManageSubscription = ({ navigation }) => { const cancelButtonTitle = subscriptionInfo.cancelAtPeriodEnd ? "Annulation programmée" : isCancelling - ? "Annulation..." - : "Annuler l'abonnement"; + ? "Annulation..." + : "Annuler l'abonnement"; const cancelTitleColor = subscriptionInfo.cancelAtPeriodEnd ? Palette.grayMid : Palette.red; - const backgroundImage = background.bgTrans; + const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520; + const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360; + const HOME_BACKGROUND_STYLE_WIDTH = HOME_BACKGROUND_WIDTH + 120; + const HOME_BACKGROUND_STYLE_HEIGHT = HOME_BACKGROUND_HEIGHT + 80; return ( { title="Mon abonnement" scrollEnabled backgroundColor={PAGE_BACKGROUND_COLOR} - backgroundImg={backgroundImage} + // backgroundImg={backgroundImage} > + {subscriptionInfo.hasAnySubscription ? ( <> @@ -645,7 +668,6 @@ const ManageSubscription = ({ navigation }) => { containerStyle={styles.cancelButton} titleStyle={{ color: cancelTitleColor }} /> - ) : ( diff --git a/src/screens/Register.js b/src/screens/Register.js index 0dd01c9..26f1da8 100644 --- a/src/screens/Register.js +++ b/src/screens/Register.js @@ -464,16 +464,18 @@ const styles = StyleSheet.create({ bottomLoginPrompt: { marginTop: 16, paddingHorizontal: 24, + marginBottom: 30, }, bottomFooterText: { - fontSize: 14, + fontSize: 15, color: Palette.white, fontFamily: FONT_FAMILY.HelveticaNeueRegular, textAlign: "center", }, bottomFooterLink: { - fontFamily: FONT_FAMILY.InterSemiBold, + fontFamily: FONT_FAMILY.InterBold, color: Palette.white, + fontSize: 16, }, socialWrapper: { width: "100%", diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index fcec23d..f8f2291 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -27,6 +27,12 @@ const { width } = Dimensions.get("window"); const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; const FUNCTIONS_REGION = "europe-west1"; +const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"]; +const FIRST_VOICE_STEP_INDEX = 1; +const TOTAL_STEPS = 1 + VOICE_SECTION_TITLES.length + 2; // genre + voices + instruments + rhythm +const LAST_STEP_INDEX = TOTAL_STEPS - 1; +const INSTRUMENT_STEP_INDEX = FIRST_VOICE_STEP_INDEX + VOICE_SECTION_TITLES.length; +const RHYTHM_STEP_INDEX = LAST_STEP_INDEX; const ComposeSong = () => { const scrollRef = useRef(null); @@ -69,19 +75,19 @@ const ComposeSong = () => { }, [currentUserData?.coins]); const isStepValid = useMemo(() => { - switch (selectedIndex) { - case 0: - return Array.isArray(genres) && genres.length > 0; - case 1: - // Must select at least one in base category - return !!(voice && typeof voice === "object" && voice.BASE); - case 2: - return Array.isArray(instruments) && instruments.length > 0; - case 3: - return !!rhythm; - default: - return true; + if (selectedIndex === 0) { + return Array.isArray(genres) && genres.length > 0; } + if (selectedIndex === FIRST_VOICE_STEP_INDEX) { + return !!(voice && typeof voice === "object" && voice.BASE); + } + if (selectedIndex === INSTRUMENT_STEP_INDEX) { + return Array.isArray(instruments) && instruments.length > 0; + } + if (selectedIndex === RHYTHM_STEP_INDEX) { + return !!rhythm; + } + return true; }, [selectedIndex, genres, voice, instruments, rhythm]); const musicConfig = useMemo(() => { @@ -199,24 +205,24 @@ const ComposeSong = () => { }; const onPressNext = () => { - if (selectedIndex === 3) { + if (selectedIndex === LAST_STEP_INDEX) { setIsConfirmVisible(true); return; } - setSelectedIndex(selectedIndex + 1); - setProgress(progress + 9); + setSelectedIndex((prev) => Math.min(prev + 1, LAST_STEP_INDEX)); + setProgress((prev) => prev + 9); scrollRef.current.scrollToIndex({ - index: selectedIndex + 1, + index: Math.min(selectedIndex + 1, LAST_STEP_INDEX), animated: true, }); }; const onPressBack = () => { if (selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); - setProgress(progress - 9); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + setProgress((prev) => Math.max(18, prev - 9)); scrollRef.current.scrollToIndex({ - index: selectedIndex - 1, + index: Math.max(selectedIndex - 1, 0), animated: true, }); } else { @@ -229,6 +235,58 @@ const ComposeSong = () => { navigate(Routes.SongReady); }; + const swiperSlides = React.Children.toArray([ + + + , + ...VOICE_SECTION_TITLES.map((section) => ( + + + + )), + + + , + + + , + ]); + return ( { ref={scrollRef} disableGesture > - - - - - - - - - - - - + {swiperSlides} - {selectedIndex !== 4 && ( + {selectedIndex <= LAST_STEP_INDEX && ( - {selectedIndex === 3 && isRegenerationFlow && ( + {selectedIndex === LAST_STEP_INDEX && isRegenerationFlow && ( )} diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index 14f2ca9..33b8336 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -32,6 +32,7 @@ const { width: windowWidth } = Dimensions.get("window"); const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; const FUNCTIONS_REGION = "europe-west1"; +const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"]; const ComposeSong = () => { const scrollRef = useRef(null); @@ -71,20 +72,68 @@ const ComposeSong = () => { return 0; }, [currentUserData?.coins]); + const steps = useMemo( + () => [ + { + key: "genres", + render: () => , + }, + ...VOICE_SECTION_TITLES.map((section) => ({ + key: `voice-${section}`, + render: () => ( + + ), + })), + { + key: "instruments", + render: () => ( + + ), + }, + { + key: "rhythm", + render: () => ( + + ), + }, + ], + [genres, voice, instruments, rhythm] + ); + + const totalSteps = steps.length; + const lastStepIndex = totalSteps - 1; + const instrumentStepIndex = Math.max(lastStepIndex - 1, 0); + const isStepValid = useMemo(() => { - switch (selectedIndex) { - case 0: - return Array.isArray(genres) && genres.length > 0; - case 1: - return !!(voice && typeof voice === "object" && voice.BASE); - case 2: - return Array.isArray(instruments) && instruments.length > 0; - case 3: - return !!rhythm; - default: - return true; + if (selectedIndex === 0) { + return Array.isArray(genres) && genres.length > 0; } - }, [selectedIndex, genres, voice, instruments, rhythm]); + if (selectedIndex === 1) { + return !!(voice && typeof voice === "object" && voice.BASE); + } + if (selectedIndex === instrumentStepIndex) { + return Array.isArray(instruments) && instruments.length > 0; + } + if (selectedIndex === lastStepIndex) { + return !!rhythm; + } + return true; + }, [ + selectedIndex, + genres, + voice, + instruments, + rhythm, + instrumentStepIndex, + lastStepIndex, + ]); const musicConfig = useMemo(() => { let lyricsArr = []; @@ -114,36 +163,6 @@ const ComposeSong = () => { }; }, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]); - const steps = useMemo( - () => [ - { - key: "genres", - render: () => , - }, - { - key: "voice", - render: () => ( - - ), - }, - { - key: "instruments", - render: () => ( - - ), - }, - { - key: "rhythm", - render: () => ( - - ), - }, - ], - [genres, voice, instruments, rhythm] - ); useEffect(() => { const nextProgress = 18 + selectedIndex * 9; diff --git a/src/screens/Studio/CustomizeVoice.js b/src/screens/Studio/CustomizeVoice.js index ecc9e4d..2196dc2 100644 --- a/src/screens/Studio/CustomizeVoice.js +++ b/src/screens/Studio/CustomizeVoice.js @@ -1,30 +1,112 @@ import { View, StyleSheet } from "react-native"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import ItemContainer from "../../components/ItemContainer/ItemContainer"; import { Palette } from "../../styles"; import { VOICE } from "../../data/data"; import ListSelection from "../../components/ListSelection/ListSelection"; -const CustomizeVoice = ({ selected = {}, setSelected }) => { - const [containerLayout, setContainerLayout] = useState(null); +const SECTION_INSTRUCTIONS = { + BASE: "Choisis ta base", + SENSIBILITE: "Choisis ta sensibilité", + TECHNIQUE: "Choisis ta technique", +}; - // Toggle select: only 1 per category (section.title) - const onPressSelect = (category, item) => { - if (!setSelected) return; - const current = selected && typeof selected === "object" ? selected : {}; - // If tapping the same item, deselect; otherwise, set new one for the category - const next = { ...current }; - if (current[category] === item) next[category] = null; - else next[category] = item; - setSelected(next); +const normalizeCategory = (value) => { + if (typeof value !== "string") return ""; + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toUpperCase(); +}; + +const selectionToObject = (value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value; + } + if (Array.isArray(value)) { + return value.reduce((acc, entry) => { + if (entry && typeof entry === "object") { + const cat = entry.category || entry.title || entry.type; + if (!cat) return acc; + const val = + entry.value ?? + entry.label ?? + entry.name ?? + (typeof entry.description === "string" + ? entry.description + : undefined); + if (typeof val === "string") { + acc[cat] = val; + } + return acc; + } + return acc; + }, {}); + } + return {}; +}; + +const objectToSelectionShape = (value, preferArray = false) => { + if (!preferArray) return value; + return Object.entries(value || {}).map(([category, val]) => ({ + category, + value: val, + })); +}; + +const CustomizeVoice = ({ + selected = {}, + setSelected = () => {}, + category = "BASE", +}) => { + const [containerLayout, setContainerLayout] = useState(null); + const normalizedCategory = normalizeCategory(category); + + const sectionData = useMemo(() => { + const fallback = Array.isArray(VOICE) && VOICE.length > 0 ? VOICE[0] : null; + if (!Array.isArray(VOICE)) return fallback; + const match = + VOICE.find( + (section) => normalizeCategory(section?.title) === normalizedCategory + ) || fallback; + return match; + }, [normalizedCategory]); + + const subtitle = + SECTION_INSTRUCTIONS[normalizedCategory] || + "Choisis la voix pour ta chanson"; + + const voiceObject = useMemo( + () => selectionToObject(selected), + [selected] + ); + const sectionKey = sectionData?.title || "BASE"; + const currentValue = + typeof voiceObject?.[sectionKey] === "string" + ? voiceObject[sectionKey] + : null; + + const handleSelectionChange = (value) => { + if (typeof setSelected !== "function") return; + setSelected((previous) => { + const wasArray = Array.isArray(previous); + const baseObject = selectionToObject(previous); + const nextObject = { ...baseObject }; + if (typeof value === "string" && value.length > 0) { + nextObject[sectionKey] = value; + } else { + delete nextObject[sectionKey]; + } + return objectToSelectionShape(nextObject, wasArray); + }); }; return ( { diff --git a/src/screens/Studio/GeneratingSong.js b/src/screens/Studio/GeneratingSong.js index 8432366..28418d3 100644 --- a/src/screens/Studio/GeneratingSong.js +++ b/src/screens/Studio/GeneratingSong.js @@ -30,6 +30,16 @@ const GeneratingSong = () => { const askedRef = useRef(false); const callingRef = useRef(false); const localStartRef = useRef(null); + const errorAlertShownRef = useRef(false); + + const isFailed = selectedProject?.musicStatus === "FAILED"; + const musicErrorMessage = + selectedProject?.musicError?.message || + selectedProject?.musicError?.status || + "Une erreur est survenue lors de la génération."; + const hasRefund = + selectedProject?.musicCreditsRefunded === true || + typeof selectedProject?.musicCreditsRefundOrderId === "string"; // project loaded from provider @@ -249,6 +259,28 @@ const GeneratingSong = () => { startMusicGenerationOnce, ]); + useEffect(() => { + if (isFailed) { + if (!errorAlertShownRef.current) { + errorAlertShownRef.current = true; + const refundNotice = hasRefund + ? "\n\nTes crédits ont été automatiquement remboursés." + : ""; + AppAlert("Génération échouée", `${musicErrorMessage}${refundNotice}`); + } + } else if (selectedProject?.musicStatus === "GENERATING") { + errorAlertShownRef.current = false; + } + }, [ + hasRefund, + isFailed, + musicErrorMessage, + selectedProject?.musicStatus, + ]); + + const progressStatus = isFailed ? "error" : "default"; + const progressLabel = isFailed ? "Erreur" : `${progress}%`; + return ( { Ta musique est{"\n"}en cours de création - + - {progress}% + {progressLabel} + {isFailed ? ( + + {musicErrorMessage} + {hasRefund + ? "\nTes crédits ont été remboursés automatiquement." + : "\nTu peux revenir en arrière pour relancer la génération."} + + ) : null} { marginBottom: responsiveHeight(2), }} /> - - {musicUrls.map((url, idx) => ( + `${item || "song"}-${idx}`} + renderItem={({ item, index }) => ( - ))} - + )} + contentContainerStyle={{ gap: 16, paddingBottom: gutters }} + showsVerticalScrollIndicator={false} + extraData={selectedIndex} + /> { + const { width, height } = useWindowDimensions(); + const isCompactWidth = width < 420; + const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12); + const verticalPadding = Math.max(isCompactWidth ? gutters : gutters * 1.5, 12); + const availableWidth = Math.max(width - horizontalPadding * 2, 0); + const contentWidth = + availableWidth > 0 + ? Math.min(REGENERATE_MODAL_MAX_WIDTH, availableWidth) + : undefined; + const contentGap = isCompactWidth ? 18 : 24; + const buttonGap = isCompactWidth ? 12 : 15; + return ( { - - - - - Re-générer le morceau ? - - - {`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`} - + + + + Re-générer le morceau ? + { textAlign: "center", }} > - Cette action coûte + {`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`} - + + Cette action coûte + + + + + > + Les crédits seront utilisés lors de l'étape de génération. + - - Les crédits seront utilisés lors de l'étape de génération. - + + + - - - - - - + + ); diff --git a/src/screens/cover/PouchReady.js b/src/screens/cover/PouchReady.js index ba28012..13ce98e 100644 --- a/src/screens/cover/PouchReady.js +++ b/src/screens/cover/PouchReady.js @@ -30,10 +30,12 @@ import { getStageAction } from "../../utils/projectStages"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const COVER_STYLE_PRESETS = [ - "Néo-Néon / Cyberpunk", - "Photographie Minimaliste & Éditoriale", - "Illustration & Collage Surréaliste", - "Anti-Design / Maximalisme (Tendance Actuelle)", + "Cyberpunk", + "Dessins animé", + "Dessins animé rétro", + "Portrait théâtralisé", + "Livre de coloriage", + "Shooting", ]; const PouchReady = () => { @@ -301,10 +303,7 @@ const PouchReady = () => { ); return ( - + @@ -67,9 +67,9 @@ export default function RegisterScreen() { type="password" containerStyle={styles.input} textInputProps={{ - autoComplete: 'new-password', - textContentType: 'newPassword', - name: 'new-password', + autoComplete: "new-password", + textContentType: "newPassword", + name: "new-password", }} /> @@ -81,9 +81,9 @@ export default function RegisterScreen() { type="password" containerStyle={styles.input} textInputProps={{ - autoComplete: 'new-password', - textContentType: 'newPassword', - name: 'confirm-password', + autoComplete: "new-password", + textContentType: "newPassword", + name: "confirm-password", }} /> @@ -129,28 +129,28 @@ export default function RegisterScreen() { const styles = StyleSheet.create({ body: { flex: 1, - justifyContent: 'center', + justifyContent: "center", gap: 24, - alignItems: 'center', + alignItems: "center", }, heading: { fontSize: 28, - fontWeight: '700', + fontWeight: "700", color: Palette.darkPurple, - textAlign: 'center', + textAlign: "center", }, subheading: { ...Fonts.bodySmall, - color: 'rgba(15, 12, 20, 0.6)', - textAlign: 'center', + color: "rgba(15, 12, 20, 0.6)", + textAlign: "center", }, form: { - width: '100%', + width: "100%", maxWidth: 420, gap: 16, }, input: { - width: '100%', + width: "100%", }, primaryButton: { marginTop: 8, @@ -160,28 +160,28 @@ const styles = StyleSheet.create({ gap: 12, }, socialDivider: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: 8, }, dividerLine: { flex: 1, height: StyleSheet.hairlineWidth, - backgroundColor: 'rgba(15, 12, 20, 0.15)', + backgroundColor: "rgba(15, 12, 20, 0.15)", }, dividerText: { ...Fonts.tinyBold, - textTransform: 'uppercase', - color: 'rgba(15, 12, 20, 0.45)', + textTransform: "uppercase", + color: "rgba(15, 12, 20, 0.45)", }, socialButton: { - width: '100%', + width: "100%", }, hint: { marginTop: 12, ...Fonts.bodySmall, - textAlign: 'center', - color: 'rgba(15, 12, 20, 0.6)', + textAlign: "center", + color: "rgba(15, 12, 20, 0.6)", }, secondaryButton: { marginTop: 12,