diff --git a/functions/helpers/email.js b/functions/helpers/email.js index c602d5e..8617609 100644 --- a/functions/helpers/email.js +++ b/functions/helpers/email.js @@ -43,7 +43,7 @@ function basicTemplate({ title = "", content = "", button = null }) {
${content}
${btn}
-

minuit.app

+

musicland

diff --git a/functions/index.js b/functions/index.js index 1a927b3..f81e5df 100644 --- a/functions/index.js +++ b/functions/index.js @@ -33,6 +33,7 @@ exports.ALERT_TYPE = { MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED", COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS", COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED", + CREDITS_UPDATED: "CREDITS_UPDATED", PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE", }; diff --git a/functions/src/notifications.js b/functions/src/notifications.js index a01483a..012c6eb 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -2,7 +2,6 @@ const { onDocumentCreated, onDocumentWritten, } = require("firebase-functions/v2/firestore"); -const admin = require("firebase-admin"); const { FieldValue } = require("firebase-admin/firestore"); const { refList, ALERT_TYPE } = require("../index"); const { Expo } = require("expo-server-sdk"); @@ -10,11 +9,12 @@ const { Resend } = require("resend"); const { basicTemplate } = require("../helpers/email"); const { RESEND_API_KEY } = require("../config/keys"); -const resendInstance = new Resend(RESEND_API_KEY); +const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null; // Initialisation de Expo SDK let expo = new Expo(); const EMAIL_FROM = "MusicLand "; +const DEFAULT_EMAIL_TITLE = "MusicLand"; function getCollectionRef(collectionName = "") { const ref = refList?.[collectionName]; @@ -24,6 +24,49 @@ function getCollectionRef(collectionName = "") { return ref; } +function cleanString(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function buildNotificationEmailPayload({ + title = "", + message = "", + template = {}, +} = {}) { + const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE; + const fallbackContent = cleanString(message) || ""; + const overrides = template && typeof template === "object" ? template : {}; + + const subject = cleanString(overrides.subject) || fallbackTitle; + const emailTitle = cleanString(overrides.title) || fallbackTitle; + const content = cleanString(overrides.content) || fallbackContent; + + let button = null; + if (overrides.button && typeof overrides.button === "object") { + const buttonUrl = + cleanString(overrides.button.url) || cleanString(overrides.button.href); + if (buttonUrl) { + button = { + url: buttonUrl, + label: + cleanString(overrides.button.label) || + cleanString(overrides.button.text) || + undefined, + }; + } + } + + const templatePayload = { title: emailTitle, content }; + if (button) { + templatePayload.button = button; + } + + return { + subject, + html: basicTemplate(templatePayload), + }; +} + exports.sendNotificationWhenDocIsCreated = onDocumentCreated( { region: "europe-west1", document: "notifications/{notificationId}" }, async (event) => { @@ -49,52 +92,63 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( const { pushToken = null, pushTokens = [], - email = "", + email: receiverEmail = "", emailNotifications = false, } = receiverData; if (!mailOnly) { - const tokensSet = new Set( - [] - .concat(Array.isArray(pushTokens) ? pushTokens : []) - .concat(pushToken ? [pushToken] : []) - .filter(Boolean), - ); - const tokens = Array.from(tokensSet); - - if (!tokens?.length) { - console.warn("User push token not found"); - return null; - } - - await sendExpoNotification({ - tokens, - receiverId: receiver, - receiverCollection, - title: title || "MusicLand", - message: message, - data: notifData || {}, - }); - } - if (emailNotifications && !!email) { try { - if (!email) { - throw new Error("userMail is required"); + const tokensSet = new Set( + [] + .concat(Array.isArray(pushTokens) ? pushTokens : []) + .concat(pushToken ? [pushToken] : []) + .filter(Boolean), + ); + const tokens = Array.from(tokensSet); + + if (!tokens?.length) { + await sendExpoNotification({ + tokens, + receiverId: receiver, + receiverCollection, + title: title || "MusicLand", + message: message, + data: notifData || {}, + }); + } else { + console.log("User push token not found"); } - if (!email?.subject || !email?.html) { - throw new Error("Email subject and html are required"); - } - await resendInstance.emails.send({ - from: EMAIL_FROM, - to: [email], - subject: title, - html: basicTemplate({ title, content: message }), - }); } catch (e) { - console.log("Error sending email:", e); + console.log("Error sending notif:", e); + } + } + + if ((emailNotifications || mailOnly) && !!receiverEmail) { + if (!resendInstance) { + console.warn( + "Resend client not configured; unable to send notification email.", + ); + } else { + try { + const { subject, html } = buildNotificationEmailPayload({ + title, + message, + template: notifData?.email, + }); + await resendInstance.emails.send({ + from: EMAIL_FROM, + to: [receiverEmail], + subject, + html, + }); + } catch (e) { + console.log("Error sending email:", e); + } } } else { - console.log(`Email disabled for user ${receiver} and type ${type}`); + console.log( + `[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? "user preference" : "missing email"}) for user ${receiver} and type ${notifData?.type || "UNKNOWN"}`, + ); } } catch (e) { console.log(e); diff --git a/functions/src/orders.js b/functions/src/orders.js index 138bd9d..bfd8722 100644 --- a/functions/src/orders.js +++ b/functions/src/orders.js @@ -3,7 +3,8 @@ const { FieldValue } = require("firebase-admin/firestore"); const { onDocumentCreated } = require("firebase-functions/firestore"); const { HttpsError, onCall } = require("firebase-functions/https"); -const { REGION } = require("../index"); +const { REGION, ALERT_TYPE } = require("../index"); +const { sendNotification } = require("./notifications"); const { ORDER_TYPES, ORDER_STATUS, @@ -14,6 +15,121 @@ const { const USERS_COLLECTION = "users"; +const formatCoinsText = (value) => { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const absoluteValue = Math.abs(value); + if (!Number.isFinite(absoluteValue)) { + return null; + } + + const formatted = Number.isInteger(absoluteValue) + ? `${absoluteValue}` + : absoluteValue.toFixed(2); + const suffix = absoluteValue === 1 ? "crédit" : "crédits"; + + return `${formatted} ${suffix}`; +}; + +const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => { + if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) { + return null; + } + + const coinsText = formatCoinsText(amount); + if (!coinsText) { + return null; + } + + const balanceText = formatCoinsText(balanceAfter); + const balanceSentence = balanceText + ? ` Ton solde est maintenant de ${balanceText}.` + : ""; + + if (amount > 0) { + if (orderType === ORDER_TYPES.COINS) { + return { + title: "Crédits achetés", + message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`, + action: "PURCHASED", + }; + } + + if (orderType === ORDER_TYPES.GIFT) { + return { + title: "Crédits reçus", + message: `Tu as reçu ${coinsText}.${balanceSentence}`, + action: "EARNED", + }; + } + + return { + title: "Crédits ajoutés", + message: `Ton solde augmente de ${coinsText}.${balanceSentence}`, + action: "CREDITED", + }; + } + + const reason = + orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : ""; + + return { + title: "Crédits dépensés", + message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`, + action: "SPENT", + }; +}; + +const notifyOrderApplied = async ({ + userId, + orderId, + amount, + orderType, + balanceBefore, + balanceAfter, + metadata = {}, +}) => { + const content = buildOrderNotificationContent({ + amount, + orderType, + balanceAfter, + }); + + if (!content || !userId) { + return; + } + + try { + await sendNotification({ + sender: "SYSTEM", + receiver: userId, + receiverCollection: USERS_COLLECTION, + title: content.title, + message: content.message, + data: { + type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED", + orderId, + orderType: orderType || null, + amount, + balanceBefore, + balanceAfter, + action: content.action, + source: + typeof metadata?.source === "string" ? metadata.source : null, + metadata: metadata || {}, + }, + }); + } catch (error) { + console.error( + "[orders-onOrderCreated] Failed to send notification", + orderId, + error, + ); + } +}; + const onOrderCreated = onDocumentCreated( `${ORDERS_COLLECTION}/{orderId}`, async (event) => { @@ -58,6 +174,8 @@ const onOrderCreated = onDocumentCreated( const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId); + let notificationContext = null; + try { await admin.firestore().runTransaction(async (transaction) => { const userSnapshot = await transaction.get(userRef); @@ -114,6 +232,11 @@ const onOrderCreated = onDocumentCreated( }, { merge: true }, ); + + notificationContext = { + balanceBefore: currentBalance, + balanceAfter: nextBalance, + }; }); } catch (error) { console.error( @@ -131,6 +254,20 @@ const onOrderCreated = onDocumentCreated( }, { merge: true }, ); + return; + } + + if (notificationContext) { + await notifyOrderApplied({ + userId, + orderId: orderRef.id, + amount, + orderType: + typeof orderData?.type === "string" ? orderData.type : null, + balanceBefore: notificationContext.balanceBefore, + balanceAfter: notificationContext.balanceAfter, + metadata: orderData?.metadata || {}, + }); } }, ); diff --git a/src/assets/UI/productionBG2.png b/src/assets/UI/productionBG2.png index f0081f0..065698a 100644 Binary files a/src/assets/UI/productionBG2.png and b/src/assets/UI/productionBG2.png differ diff --git a/src/components/modal/DeleteAccountModal.js b/src/components/modal/DeleteAccountModal.js index f0f1f08..297daad 100644 --- a/src/components/modal/DeleteAccountModal.js +++ b/src/components/modal/DeleteAccountModal.js @@ -19,7 +19,8 @@ const DeleteAccountModal = () => { await SheetManager.hide("DeleteAccount"); }; - const confirmDelete = () => { + const confirmDelete = async () => { + await onClose().catch(() => {}); alert( "Êtes-vous sûr ?", "Cette action supprimera votre profil.", diff --git a/src/hooks/useSearch.js b/src/hooks/useSearch.js index beb7b7c..d7063f7 100644 --- a/src/hooks/useSearch.js +++ b/src/hooks/useSearch.js @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import useAlgoliaSearch from "react-native-minuit/src/hooks/useAlgoliaSearch"; import { AlgoliaUserConfig, AlgoliaProjectConfig } from "../data/keys"; +import { projectsRef } from "../config/firebase"; import { useUserData } from "../providers/UserDataProvider"; const batchSizes = { @@ -8,9 +9,119 @@ const batchSizes = { projects: 6, }; +const isValidUri = (value) => + typeof value === "string" && value.trim().length > 0; + +const hasCoverAsset = (project) => { + if (!project) return false; + if (isValidUri(project?.coverUrl)) { + return true; + } + const cover = project?.cover; + if (!cover || typeof cover !== "object") { + return false; + } + const coverCandidates = [ + cover?.result, + cover?.finalUrl, + cover?.generatedBackground, + ]; + if (Array.isArray(cover?.options)) { + coverCandidates.push( + ...cover.options.flatMap((option) => [ + option?.finalUrl, + option?.generatedUrl, + ]), + ); + } + return coverCandidates.some(isValidUri); +}; + +const hasThumbnailAsset = (project) => + isValidUri(project?.thumbnailUrl) || isValidUri(project?.songThumbnailUrl); + +const mergeProjectAssets = ( + project, + firestoreData, + { mergeCover = false, mergeThumbnails = false } = {}, +) => { + if (!firestoreData || typeof firestoreData !== "object") { + return project; + } + const mergedProject = { ...project }; + + if (mergeCover) { + if (!isValidUri(mergedProject.coverUrl) && isValidUri(firestoreData.coverUrl)) { + mergedProject.coverUrl = firestoreData.coverUrl; + } + const firestoreCover = + firestoreData.cover && typeof firestoreData.cover === "object" + ? firestoreData.cover + : null; + if (firestoreCover) { + mergedProject.cover = { + ...(typeof mergedProject.cover === "object" ? mergedProject.cover : {}), + ...firestoreCover, + }; + } + } + + if (mergeThumbnails) { + if (!isValidUri(mergedProject.thumbnailUrl) && isValidUri(firestoreData.thumbnailUrl)) { + mergedProject.thumbnailUrl = firestoreData.thumbnailUrl; + } + if ( + !isValidUri(mergedProject.songThumbnailUrl) && + isValidUri(firestoreData.songThumbnailUrl) + ) { + mergedProject.songThumbnailUrl = firestoreData.songThumbnailUrl; + } + } + + return mergedProject; +}; + +const hydrateProjects = async ( + projects = [], + { ensureCover = false, ensureThumbnails = false } = {}, +) => { + if (!Array.isArray(projects) || projects.length === 0) { + return []; + } + + return Promise.all( + projects.map(async (project) => { + const needsCover = ensureCover && !hasCoverAsset(project); + const needsThumbnail = ensureThumbnails && !hasThumbnailAsset(project); + + if ((!needsCover && !needsThumbnail) || !project?.id) { + return project; + } + + try { + const snapshot = await projectsRef.doc(project.id).get(); + if (!snapshot.exists) { + return project; + } + const firestoreData = snapshot.data() || {}; + return mergeProjectAssets(project, firestoreData, { + mergeCover: needsCover, + mergeThumbnails: needsThumbnail, + }); + } catch (error) { + console.log("useSearch.hydrateProjects error", error?.message || error); + return project; + } + }), + ); +}; + const useSearch = () => { const [selected, setSelected] = useState(null); const [search, setSearch] = useState(""); + const [hydratedMusics, setHydratedMusics] = useState(null); + const [hydratedPlaybacks, setHydratedPlaybacks] = useState(null); + const { currentUID } = useUserData(); const { hits: users, loading: userLoading } = useAlgoliaSearch({ @@ -38,14 +149,70 @@ const useSearch = () => { }, }); + useEffect(() => { + let isCancelled = false; + const nextMusics = Array.isArray(musics) ? musics : []; + const requiresHydration = + nextMusics.length > 0 && + nextMusics.some((project) => !hasCoverAsset(project)); + + if (!requiresHydration) { + setHydratedMusics(null); + return () => { + isCancelled = true; + }; + } + + setHydratedMusics(null); + (async () => { + const hydrated = await hydrateProjects(nextMusics, { ensureCover: true }); + if (!isCancelled) { + setHydratedMusics(hydrated); + } + })(); + + return () => { + isCancelled = true; + }; + }, [musics]); + + useEffect(() => { + let isCancelled = false; + const nextPlaybacks = Array.isArray(playbacks) ? playbacks : []; + const requiresHydration = + nextPlaybacks.length > 0 && + nextPlaybacks.some((project) => !hasThumbnailAsset(project)); + + if (!requiresHydration) { + setHydratedPlaybacks(null); + return () => { + isCancelled = true; + }; + } + + setHydratedPlaybacks(null); + (async () => { + const hydrated = await hydrateProjects(nextPlaybacks, { + ensureThumbnails: true, + }); + if (!isCancelled) { + setHydratedPlaybacks(hydrated); + } + })(); + + return () => { + isCancelled = true; + }; + }, [playbacks]); + return { search, setSearch, selected, setSelected, users, - musics, - playbacks, + musics: hydratedMusics ?? musics, + playbacks: hydratedPlaybacks ?? playbacks, loading: userLoading || musicLoading || playbackLoading, }; }; diff --git a/src/hooks/useSocialAuth.js b/src/hooks/useSocialAuth.js index dc50331..881afc6 100644 --- a/src/hooks/useSocialAuth.js +++ b/src/hooks/useSocialAuth.js @@ -92,6 +92,9 @@ const ensureUserDocument = async (user, overrides = {}) => { if (!snapshot.exists) { payload.createdAt = firebase.firestore.FieldValue.serverTimestamp(); if (user.email) payload.email = user.email; + payload.emailNotifications = true; + } else if (typeof existingData?.emailNotifications === "undefined") { + payload.emailNotifications = true; } const overrideFirstName = diff --git a/src/layouts/Page.js b/src/layouts/Page.js index c4c66aa..fb161fc 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -252,7 +252,7 @@ export default ({ > ) : null} diff --git a/src/screens/CreatePassword.js b/src/screens/CreatePassword.js index e366176..6193c23 100644 --- a/src/screens/CreatePassword.js +++ b/src/screens/CreatePassword.js @@ -72,6 +72,7 @@ const CreatePassword = () => { await usersRef.doc(uid).set( { email: email.trim(), + emailNotifications: true, ...(trimmedFirstName ? { firstName: trimmedFirstName } : {}), ...(trimmedLastName ? { lastName: trimmedLastName } : {}), ...(trimmedCity ? { city: trimmedCity } : {}), diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index a319221..1e07e73 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -89,6 +89,12 @@ const STAGE_CARD_CONTENT = [ ]; const CLUB_CARD_IMAGE = icons.clubIcon; +const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ + "trialing", + "active", + "past_due", + "unpaid", +]); const Home = ({ navigation, route }) => { const { @@ -102,6 +108,43 @@ const Home = ({ navigation, route }) => { } = useUser(); const { setTooltip } = useMinuit(); + const hasActiveSubscription = useMemo(() => { + if (!currentUserData) { + return false; + } + + const pickStatus = (value) => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : null; + }; + + const statusCandidates = [ + pickStatus(currentUserData?.stripeSubscriptionStatus), + pickStatus(currentUserData?.stripeSubscription?.status), + pickStatus( + currentUserData?.stripeSubscription?.stripeSubscriptionStatus, + ), + pickStatus(currentUserData?.stripeSubscription?.metadata?.status), + ].filter(Boolean); + + if ( + statusCandidates.some((status) => + ACTIVE_SUBSCRIPTION_STATUSES.has(status), + ) + ) { + return true; + } + + const hasPremiumLevel = + typeof currentUserData?.premiumLevel === "string" && + currentUserData.premiumLevel.trim().length > 0; + + return hasPremiumLevel; + }, [currentUserData]); + if (!currentUID) { return ; } @@ -386,9 +429,17 @@ const Home = ({ navigation, route }) => { const handleStagePress = useCallback( (stageKey, isLocked) => { - if (!hasActiveProject || isLocked) { + if (isLocked) { return; } + + if (!hasActiveProject || !currentProject) { + if (stageKey === "songwriter") { + handleStartNew(); + } + return; + } + ensureProjectSelected(); const action = getStageAction(stageKey, currentProject); if (!action?.route) { @@ -396,7 +447,12 @@ const Home = ({ navigation, route }) => { } navigate(action.route, action.params); }, - [currentProject, ensureProjectSelected, hasActiveProject], + [ + currentProject, + ensureProjectSelected, + hasActiveProject, + handleStartNew, + ], ); const handleClubPress = useCallback(() => { @@ -464,7 +520,11 @@ const Home = ({ navigation, route }) => { ))} - + diff --git a/src/screens/Home/HomeSave.js b/src/screens/Home/HomeSave.js deleted file mode 100644 index ed4f78a..0000000 --- a/src/screens/Home/HomeSave.js +++ /dev/null @@ -1,165 +0,0 @@ -import { BlurView } from "expo-blur"; -import React, { useMemo, useState } from "react"; -import { FlatList, Image, Platform, Text, View } from "react-native"; -import { responsiveHeight } from "react-native-responsive-dimensions"; -import { useGlobal } from "reactn"; -import { background, img } from "../../assets"; -import alert from "../../components/Alert"; -import GradientButton from "../../components/GradientButton"; -import MoreMenu from "../../components/MoreMenu"; -import { projectsRef } from "../../config/firebase"; -import Page from "../../layouts/Page"; -import { Routes } from "../../navigation"; -import { navigate } from "../../navigation/NavigationService"; -import { useUser } from "../../providers/UserDataProvider"; -import { getArtistDisplayName } from "../../utils/artistName"; -import { getProjectLikes, LIKE_TARGET } from "../../utils/likes"; -import { Palette } from "../../styles"; -import { FONT_FAMILY } from "../../styles/Fonts"; -import MusicCard from "../Library/components/MusicCard"; - -const HomeSave = () => { - const { - userProjects = [], - resetSelectedProject, - selectProject, - currentUserData, - } = useUser(); - const projects = useMemo( - () => (Array.isArray(userProjects) ? userProjects : []), - [userProjects] - ); - const ownerDisplayName = useMemo( - () => getArtistDisplayName(currentUserData, "MusicLand"), - [currentUserData] - ); - const [, setTooltip] = useGlobal("_tooltip"); - const [menuPosition, setMenuPosition] = useState(null); - const [showMenu, setShowMenu] = useState(false); - const [menuProjectId, setMenuProjectId] = useState(null); - - return ( - - - - {projects.length > 0 && ( - - - - Musiques en cours - - item.id} - contentContainerStyle={{ gap: 10, paddingBottom: 10 }} - renderItem={({ item }) => ( - { - selectProject(item.id); - navigate(Routes.Home); - }} - onPressMore={(posTop) => { - setMenuProjectId(item.id); - setMenuPosition(posTop); - setShowMenu( - (prev) => - !prev || posTop?.top !== (menuPosition?.top ?? null) - ); - }} - /> - )} - /> - setShowMenu(false)} - inPlaylist={false} - projectId={menuProjectId} - extraItems={[ - { - label: "Supprimer", - onPress: () => - alert( - "Confirmer la suppression", - "Cette action supprimera définitivement ce projet.", - [ - { text: "Annuler", style: "cancel" }, - { - text: "Supprimer", - style: "destructive", - onPress: async () => { - try { - if (!menuProjectId) return; - await projectsRef.doc(menuProjectId).delete(); - setTooltip({ - type: "success", - text: "Projet supprimé", - }); - } catch (e) { - setTooltip({ - type: "error", - text: e?.message || "Suppression impossible", - }); - } - }, - }, - ], - { cancelable: true } - ), - }, - ]} - /> - - - )} - - { - resetSelectedProject(); - navigate(Routes.Home); - }} - /> - - - - ); -}; - -export default Home; diff --git a/src/screens/Home/components/ClubCard.js b/src/screens/Home/components/ClubCard.js index 3f25ad2..e0486f0 100644 --- a/src/screens/Home/components/ClubCard.js +++ b/src/screens/Home/components/ClubCard.js @@ -5,22 +5,28 @@ import { FONT_FAMILY } from "../../../styles/Fonts"; import { Palette } from "../../../styles"; import { icons } from "../../../assets"; -const ClubCard = ({ image, onPress }) => ( - [styles.card, pressed && styles.cardPressed]} - > - - - - Rejoins le club ! - - -); +const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => { + const subtitle = hasActiveSubscription + ? "Tu fais déjà partie du club !" + : "Rejoins le club !"; + + return ( + [styles.card, pressed && styles.cardPressed]} + > + + + + {subtitle} + + + ); +}; export default memo(ClubCard); diff --git a/src/screens/Library/Library.web.js b/src/screens/Library/Library.web.js index bf2ecdd..be59be1 100644 --- a/src/screens/Library/Library.web.js +++ b/src/screens/Library/Library.web.js @@ -6,7 +6,7 @@ import React, { useRef, useState, } from "react"; -import { Image, Platform, ScrollView, Text, View } from "react-native"; +import { Image, Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, icons } from "../../assets"; import SearchBar from "../../components/SearchBar"; @@ -91,6 +91,7 @@ const Library = () => { const [dropdownVisible, setDropdownVisible] = useState(false); const searchWrapperRef = useRef(null); + const hasSearchQuery = search.trim().length > 0; const closeDropdown = useCallback(() => { setDropdownVisible(false); @@ -103,6 +104,7 @@ const Library = () => { }; const shouldShowResults = dropdownVisible; + const shouldBlurContent = dropdownVisible && hasSearchQuery; const handleChangeText = (value) => { setSearch(value); @@ -203,7 +205,21 @@ const Library = () => { )} - + + {shouldBlurContent && ( + + )} { paddingTop: Platform.OS !== "android" ? 20 : 0, }} showsVerticalScrollIndicator={false} + style={{ flex: 1, position: "relative", zIndex: 5 }} > { } }, [project?.musicTimestamps, project?.songIndex]); + const estimatedDurationMs = useMemo(() => { + const toMs = (value) => { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return 0; + return num > 1000 ? Math.round(num) : Math.round(num * 1000); + }; + const idx = Number(project?.songIndex); + const normalizedIdx = Number.isFinite(idx) && idx >= 0 ? idx : 0; + const tsEntry = project?.musicTimestamps?.[normalizedIdx]; + if (tsEntry && typeof tsEntry === "object") { + const fromMetadata = + toMs(tsEntry.durationMs) || + toMs(tsEntry.duration) || + toMs(tsEntry.durationS) || + toMs(tsEntry.durationSeconds) || + toMs(tsEntry.audioDuration) || + toMs(tsEntry.audioLength); + if (fromMetadata > 0) return fromMetadata; + } + let maxEndS = 0; + for (let i = 0; i < alignedWords.length; i++) { + const word = alignedWords[i]; + const end = Number(word?.endS ?? word?.startS ?? 0); + if (Number.isFinite(end) && end > maxEndS) { + maxEndS = end; + } + } + return maxEndS > 0 ? Math.round(maxEndS * 1000) : 0; + }, [alignedWords, project?.musicTimestamps, project?.songIndex]); + + const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs; + // Reset counters when the track changes useEffect(() => { listenedMsRef.current = 0; @@ -320,7 +352,7 @@ const MusicDetails = ({ route }) => { const handleSliderSeek = useCallback( async (ratio) => { - const dur = durationMs || 0; + const dur = sliderDurationMs || 0; if (!trackDescriptor || dur <= 0) return; const targetMs = Math.max(0, Math.floor(dur * ratio)); try { @@ -333,7 +365,7 @@ const MusicDetails = ({ route }) => { console.log("MusicDetails seek error", e?.message); } }, - [durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo] + [sliderDurationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo] ); const handleToggleLoop = useCallback(async () => { @@ -839,10 +871,10 @@ const MusicDetails = ({ route }) => { { const timerRef = React.useRef(null); const hasAutoPlayedRef = React.useRef(false); const { isLooping, setLooping } = usePlayer() || {}; + const isWeb = Platform.OS === "web"; + const handleBackPress = useCallback(() => { + goBack(); + }, []); + const renderWebBackButton = useCallback(() => { + if (!isWeb) return null; + return ( + + + + Retour + + + ); + }, [handleBackPress, isWeb]); const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, @@ -297,6 +322,38 @@ const MusicDetails = ({ route }) => { } }, [project?.musicTimestamps, project?.songIndex]); + const estimatedDurationMs = useMemo(() => { + const toMs = (value) => { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return 0; + return num > 1000 ? Math.round(num) : Math.round(num * 1000); + }; + const idx = Number(project?.songIndex); + const normalizedIdx = Number.isFinite(idx) && idx >= 0 ? idx : 0; + const tsEntry = project?.musicTimestamps?.[normalizedIdx]; + if (tsEntry && typeof tsEntry === "object") { + const fromMetadata = + toMs(tsEntry.durationMs) || + toMs(tsEntry.duration) || + toMs(tsEntry.durationS) || + toMs(tsEntry.durationSeconds) || + toMs(tsEntry.audioDuration) || + toMs(tsEntry.audioLength); + if (fromMetadata > 0) return fromMetadata; + } + let maxEndS = 0; + for (let i = 0; i < alignedWords.length; i++) { + const word = alignedWords[i]; + const end = Number(word?.endS ?? word?.startS ?? 0); + if (Number.isFinite(end) && end > maxEndS) { + maxEndS = end; + } + } + return maxEndS > 0 ? Math.round(maxEndS * 1000) : 0; + }, [alignedWords, project?.musicTimestamps, project?.songIndex]); + + const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs; + // Reset counters when the track changes useEffect(() => { listenedMsRef.current = 0; @@ -411,7 +468,7 @@ const MusicDetails = ({ route }) => { const handleSliderSeek = useCallback( async (ratio) => { - const dur = durationMs || 0; + const dur = sliderDurationMs || 0; if (!trackDescriptor || dur <= 0) return; const targetMs = Math.max(0, Math.floor(dur * ratio)); lastSeekTargetMs.current = targetMs; @@ -431,7 +488,7 @@ const MusicDetails = ({ route }) => { console.log("MusicDetails seek error", e?.message); } }, - [trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo] + [trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo] ); const handleToggleLoop = useCallback(async () => { @@ -905,6 +962,8 @@ const MusicDetails = ({ route }) => { return ( { { export default MusicDetails; const styles = StyleSheet.create({ + webBackContainer: { + alignSelf: "flex-start", + marginBottom: 12, + }, + webBackButton: { + flexDirection: "row", + alignItems: "center", + gap: 8, + alignSelf: "flex-start", + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 999, + borderWidth: 1, + borderColor: Palette.ultraLightWhite, + backgroundColor: Palette.ultraLightWhite, + }, + webBackIcon: { + width: 16, + height: 16, + transform: [{ rotate: "90deg" }], + }, + webBackText: { + fontSize: 14, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + }, img: { width: 200, height: 200, diff --git a/src/screens/Library/components/SearchResultsList.js b/src/screens/Library/components/SearchResultsList.js index b040cb5..d118304 100644 --- a/src/screens/Library/components/SearchResultsList.js +++ b/src/screens/Library/components/SearchResultsList.js @@ -84,11 +84,6 @@ const SearchResultsList = ({ ); } - const fallbackThumbnail = thumbnailCandidates.find(isValid); - if (fallbackThumbnail) { - coverCandidates.push(fallbackThumbnail); - } - return coverCandidates.find(isValid) || null; }, [] diff --git a/src/screens/Payments.js b/src/screens/Payments.js index 74599c9..a69b554 100644 --- a/src/screens/Payments.js +++ b/src/screens/Payments.js @@ -277,6 +277,7 @@ const PLAN_SEGMENTS = [ const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520; const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360; +const PAGE_BACKGROUND_COLOR = "#303438"; export default function Payments() { const route = useRoute(); @@ -498,6 +499,7 @@ export default function Payments() { width={isWeb ? 960 : undefined} containerStyle={styles.page} contentContainerStyle={styles.pageContent} + backgroundColor={PAGE_BACKGROUND_COLOR} > { }} > + setShowIntro(true)} /> {/* */} - { log("Route params received", { hasProject: !!project }); const projectId = project?.id; const songIndex = project?.songIndex; + const { setLooping, isLooping } = usePlayer() || {}; // Permissions const [cameraPermission, requestCameraPermission] = useCameraPermissions(); @@ -48,6 +50,8 @@ const RecordPlayback = ({ route }) => { const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 }); + const originalLoopingValueRef = useRef({ hasValue: false, value: false }); + const latestLoopingValueRef = useRef(isLooping ?? false); // Compteurs vues const listenedMsRef = useRef(0); @@ -80,6 +84,10 @@ const RecordPlayback = ({ route }) => { log("Screen params", { projectId, songUrl, songIndex }); }, [projectId, songUrl, songIndex]); + useEffect(() => { + latestLoopingValueRef.current = isLooping ?? false; + }, [isLooping]); + const musicIndex = useMemo(() => { const i = Number(songIndex); return Number.isFinite(i) && i >= 0 ? i : 0; @@ -280,12 +288,42 @@ const RecordPlayback = ({ route }) => { } catch (_) {} }, [player]); + const resetSessionRef = useRef(resetSession); + useEffect(() => { + resetSessionRef.current = resetSession; + }, [resetSession]); + useFocusEffect( useCallback(() => { log("Screen focused, resetting session"); - void resetSession(); - return () => {}; - }, [resetSession]) + void resetSessionRef.current?.(); + return () => { + log("Screen blurred, stopping playback"); + void resetSessionRef.current?.(); + }; + }, []) + ); + + useFocusEffect( + useCallback(() => { + if (typeof setLooping === "function") { + originalLoopingValueRef.current = { + hasValue: true, + value: latestLoopingValueRef.current, + }; + if (latestLoopingValueRef.current) { + setLooping(false); + } + } + return () => { + if ( + typeof setLooping === "function" && + originalLoopingValueRef.current?.hasValue + ) { + setLooping(!!originalLoopingValueRef.current.value); + } + }; + }, [setLooping]) ); // Lancer le compte à rebours (le tick décrémente uniquement) diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index c052003..2b2735b 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -16,6 +16,7 @@ import KaraokeLyrics from "../../components/KaraokeLyrics"; import MusicLandHeader from "../../components/MusicLandHeader"; import { increment, projectsRef } from "../../config/firebase"; import { isWeb } from "../../hooks/useLayoutType"; +import usePlayer from "../../hooks/usePlayer"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; @@ -59,6 +60,7 @@ const RecordPlayback = ({ route }) => { const { project } = route.params || {}; const songIndex = Number(project?.songIndex ?? 0) || 0; const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + const { setLooping, isLooping } = usePlayer() || {}; // Player const songUrl = project?.songUrl || null; @@ -80,6 +82,8 @@ const RecordPlayback = ({ route }) => { const stopRecordingPromiseRef = useRef(null); const stopRecordingResolveRef = useRef(null); const recordedUrlRef = useRef(null); + const originalLoopingValueRef = useRef({ hasValue: false, value: false }); + const latestLoopingValueRef = useRef(isLooping ?? false); const [mediaReady, setMediaReady] = useState(false); const [mediaError, setMediaError] = useState(null); @@ -100,6 +104,10 @@ const RecordPlayback = ({ route }) => { const correctedInitialJumpRef = useRef(false); const progressDebugCounterRef = useRef(0); + useEffect(() => { + latestLoopingValueRef.current = isLooping ?? false; + }, [isLooping]); + // Lyrics const alignedWords = useMemo(() => { const ts = project?.musicTimestamps?.[songIndex]; @@ -328,11 +336,41 @@ const RecordPlayback = ({ route }) => { console.log(LOG_PREFIX, "resetSession:end"); }, [player, releaseRecordingUrl, stopRecorderAndGetUrl]); + const resetSessionRef = useRef(resetSession); + useEffect(() => { + resetSessionRef.current = resetSession; + }, [resetSession]); + useFocusEffect( useCallback(() => { - void resetSession(); - return () => {}; - }, [resetSession]) + if (typeof setLooping === "function") { + originalLoopingValueRef.current = { + hasValue: true, + value: latestLoopingValueRef.current, + }; + if (latestLoopingValueRef.current) { + setLooping(false); + } + } + return () => { + if ( + typeof setLooping === "function" && + originalLoopingValueRef.current?.hasValue + ) { + setLooping(!!originalLoopingValueRef.current.value); + } + }; + }, [setLooping]) + ); + + useFocusEffect( + useCallback(() => { + void resetSessionRef.current?.(); + return () => { + console.log(LOG_PREFIX, "focusEffect:cleanup"); + void resetSessionRef.current?.(); + }; + }, []) ); useEffect(() => { diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index be1de2b..664fc9d 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -1,9 +1,9 @@ import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import * as FileSystem from "expo-file-system"; import { VideoView, useVideoPlayer } from "expo-video"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { View } from "react-native"; -import { background } from "../../assets"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Image, Pressable, View } from "react-native"; +import { background, icons } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; @@ -30,8 +30,13 @@ const RecordedPlayback = ({ route }) => { p.timeUpdateEventInterval = 0.2; }); - const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); + const [progressInfo, setProgressInfo] = useState({ + pos: 0, + dur: 0, + isPlaying: false, + }); const wasPlayingBeforeSeek = useRef(false); + const playbackEndedRef = useRef(false); // Format mm:ss const fmt = (ms) => { @@ -44,7 +49,17 @@ const RecordedPlayback = ({ route }) => { }; // Start both players on mount + const stopPlayback = useCallback(async () => { + try { + if (audioPlayer?.playing) await audioPlayer.pause?.(); + } catch (e) {} + try { + if (videoPlayer?.playing) videoPlayer.pause(); + } catch (e) {} + }, [audioPlayer, videoPlayer]); + useEffect(() => { + playbackEndedRef.current = false; const start = async () => { try { if (audioPlayer && songUrl) await audioPlayer.play?.(); @@ -53,12 +68,10 @@ const RecordedPlayback = ({ route }) => { }; start(); return () => { - try { - if (audioPlayer?.playing) audioPlayer.pause?.(); - if (videoPlayer?.playing) videoPlayer.pause(); - } catch (e) {} + playbackEndedRef.current = false; + void stopPlayback(); }; - }, [audioPlayer, videoPlayer, songUrl]); + }, [audioPlayer, songUrl, stopPlayback, videoPlayer]); // Poll from audio player for progress display; keep video in sync if drifting useEffect(() => { @@ -66,7 +79,8 @@ const RecordedPlayback = ({ route }) => { try { const dur = (audioPlayer?.duration || 0) * 1000; const pos = (audioPlayer?.currentTime || 0) * 1000; - setProgressInfo({ pos, dur }); + const playing = !!audioPlayer?.playing || !!videoPlayer?.playing; + setProgressInfo({ pos, dur, isPlaying: playing }); // basic drift correction: if desync > 300ms, align video if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) { @@ -94,6 +108,7 @@ const RecordedPlayback = ({ route }) => { const onSeekStart = async () => { try { wasPlayingBeforeSeek.current = !!audioPlayer?.playing; + playbackEndedRef.current = false; if (audioPlayer?.playing) await audioPlayer.pause?.(); if (videoPlayer?.playing) videoPlayer.pause(); } catch (e) {} @@ -113,6 +128,47 @@ const RecordedPlayback = ({ route }) => { : 0; }, [progressInfo]); + useEffect(() => { + const duration = progressInfo?.dur || 0; + if (!duration) return; + const position = progressInfo?.pos || 0; + if (playbackEndedRef.current && duration - position > 1000) { + playbackEndedRef.current = false; + return; + } + const remaining = Math.max(0, duration - position); + if (remaining <= 400 && !playbackEndedRef.current) { + playbackEndedRef.current = true; + void stopPlayback(); + } + }, [progressInfo, stopPlayback]); + + const handleTogglePlayback = async () => { + try { + const duration = progressInfo?.dur || 0; + const position = progressInfo?.pos || 0; + const isAtEnd = duration > 0 && duration - position < 350; + const isCurrentlyPlaying = + !!audioPlayer?.playing || !!videoPlayer?.playing; + + if (isCurrentlyPlaying) { + playbackEndedRef.current = false; + if (audioPlayer?.playing) await audioPlayer.pause?.(); + if (videoPlayer?.playing) videoPlayer.pause(); + return; + } + + if (isAtEnd) { + if (audioPlayer) await audioPlayer.seekTo?.(0); + if (videoPlayer) videoPlayer.currentTime = 0; + } + + playbackEndedRef.current = false; + if (songUrl && audioPlayer) await audioPlayer.play?.(); + if (videoPlayer) videoPlayer.play(); + } catch (e) {} + }; + return ( @@ -144,6 +200,26 @@ const RecordedPlayback = ({ route }) => { onSeekStart={onSeekStart} onSeekEnd={onSeekEnd} /> + + + { title="Recommencer" onPress={async () => { try { - if (audioPlayer?.playing) await audioPlayer.pause?.(); - if (videoPlayer?.playing) videoPlayer.pause(); + await stopPlayback(); } catch (e) {} try { if (videoUri) { diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js index 00c3f55..ecb212b 100644 --- a/src/screens/Playback/RecordedPlayback.web.js +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Text, View } from "react-native"; import { background } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; @@ -43,6 +43,7 @@ const RecordedPlayback = ({ route }) => { // Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source const videoElRef = useRef(null); + const playbackEndedRef = useRef(false); const [progress, setProgress] = useState({ posS: 0, // secondes @@ -50,9 +51,20 @@ const RecordedPlayback = ({ route }) => { playing: false, }); + const stopPlayback = useCallback(async () => { + try { + if (audioPlayer?.playing) await audioPlayer.pause?.(); + } catch {} + try { + if (videoElRef.current && !videoElRef.current.paused) { + videoElRef.current.pause(); + } + } catch {} + }, [audioPlayer]); + // Démarrage / arrêt useEffect(() => { - let mounted = true; + playbackEndedRef.current = false; const start = async () => { try { if (audioPlayer && songUrl) { @@ -68,17 +80,10 @@ const RecordedPlayback = ({ route }) => { start(); return () => { - mounted = false; - try { - if (audioPlayer?.playing) audioPlayer.pause?.(); - } catch {} - try { - if (videoElRef.current) { - videoElRef.current.pause(); - } - } catch {} + playbackEndedRef.current = false; + void stopPlayback(); }; - }, [audioPlayer, songUrl, videoUri]); + }, [audioPlayer, songUrl, stopPlayback, videoUri]); // Boucle de progression + éventuelle sync de la vidéo si fournie useEffect(() => { @@ -115,6 +120,21 @@ const RecordedPlayback = ({ route }) => { return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0; }, [progress]); + useEffect(() => { + const duration = progress?.durS || 0; + if (!duration) return; + const position = progress?.posS || 0; + if (playbackEndedRef.current && duration - position > 1) { + playbackEndedRef.current = false; + return; + } + const remaining = Math.max(0, duration - position); + if (remaining <= 0.35 && !playbackEndedRef.current) { + playbackEndedRef.current = true; + void stopPlayback(); + } + }, [progress, stopPlayback]); + const onSeek = async (ratio) => { try { const durS = Number(progress.durS || 0); @@ -132,6 +152,7 @@ const RecordedPlayback = ({ route }) => { const onSeekStart = async () => { try { wasPlayingRef.current = !!audioPlayer?.playing; + playbackEndedRef.current = false; if (audioPlayer?.playing) await audioPlayer.pause?.(); if (videoElRef.current && !videoElRef.current.paused) { videoElRef.current.pause(); @@ -244,13 +265,9 @@ const RecordedPlayback = ({ route }) => { /> { + onPress={async () => { try { - if (audioPlayer?.playing) audioPlayer.pause?.(); - } catch {} - try { - if (videoElRef.current && !videoElRef.current.paused) - videoElRef.current.pause(); + await stopPlayback(); } catch {} navigate(Routes.RecordPlayback, { project }); }} diff --git a/src/screens/Profile/ManageSubscription.js b/src/screens/Profile/ManageSubscription.js index 0015a72..870d2ba 100644 --- a/src/screens/Profile/ManageSubscription.js +++ b/src/screens/Profile/ManageSubscription.js @@ -3,6 +3,7 @@ import React, { useCallback, useMemo, useState } from "react"; import { Alert, Platform, StyleSheet, Text, View } from "react-native"; import BorderGradientButton from "../../components/BorderGradientButton"; +import { background } from "../../assets"; import { getFunctionsClient } from "../../config/firebase"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation/Routes"; @@ -41,6 +42,8 @@ const PERIOD_LABELS = { annual: "Annuel", }; +const PAGE_BACKGROUND_COLOR = "#303438"; + const formatCoinsAmount = (value) => { if (typeof value !== "number" || !Number.isFinite(value)) { return null; @@ -560,8 +563,16 @@ const ManageSubscription = ({ navigation }) => { ? Palette.grayMid : Palette.red; + const backgroundImage = background.bgTrans; + return ( - + {subscriptionInfo.hasAnySubscription ? ( <> diff --git a/src/screens/Studio/ChooseRhythm.js b/src/screens/Studio/ChooseRhythm.js index 835ad1b..9c86f94 100644 --- a/src/screens/Studio/ChooseRhythm.js +++ b/src/screens/Studio/ChooseRhythm.js @@ -7,13 +7,22 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import { RHYTHM } from "../../data/data"; import ListSelection from "../../components/ListSelection/ListSelection"; -const ChooseRhythm = ({ selected, setSelected }) => { +const ITEM_HEIGHT = 54; +const CONTENT_GAP = 10; +const CONTENT_PADDING = 8; +const EXTRA_HEADROOM = 80; // extra space keeps the list fully visible without scrolling +const RHYTHM_CONTAINER_HEIGHT = + RHYTHM.length * ITEM_HEIGHT + + Math.max(RHYTHM.length - 1, 0) * CONTENT_GAP + + CONTENT_PADDING * 2 + + EXTRA_HEADROOM; +const ChooseRhythm = ({ selected, setSelected }) => { return ( - + { /> - - - { 1: { pos: 0, dur: 0 }, }); - console.log("progress info", JSON.stringify(progressInfo, null, 2)); - const player0 = useSharedAudioPlayer( musicUrls[0] ? { uri: musicUrls[0] } : undefined, { @@ -51,7 +49,7 @@ const SongReady = () => { artwork: selectedProject?.coverUrl || null, coverUrl: selectedProject?.coverUrl || null, metadata: { index: 0, projectId }, - } + }, ); const player1 = useSharedAudioPlayer( musicUrls[1] ? { uri: musicUrls[1] } : undefined, @@ -66,7 +64,7 @@ const SongReady = () => { artwork: selectedProject?.coverUrl || null, coverUrl: selectedProject?.coverUrl || null, metadata: { index: 1, projectId }, - } + }, ); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false }); const prefetchedRef = useRef({ @@ -288,7 +286,7 @@ const SongReady = () => { player1?.pause?.(); } catch {} }; - }, [player0, player1]) + }, [player0, player1]), ); useEffect(() => { @@ -318,7 +316,7 @@ const SongReady = () => { }, }, ], - { cancelable: false } + { cancelable: false }, ); return; } @@ -425,7 +423,7 @@ const SongReady = () => { } catch (e) { console.log( "SongReady pause on seek start", - e?.message + e?.message, ); } }} @@ -441,7 +439,7 @@ const SongReady = () => { } catch (e) { console.log( "SongReady resume after seek", - e?.message + e?.message, ); } }} diff --git a/src/screens/Studio/Studio.js b/src/screens/Studio/Studio.js index ab1a9ec..020668b 100644 --- a/src/screens/Studio/Studio.js +++ b/src/screens/Studio/Studio.js @@ -9,6 +9,7 @@ import { Routes } from "../../navigation"; import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; +import { isWeb } from "../../hooks/useLayoutType"; const Studio = () => { const [selectedProjectId, setSelectedProjectId] = useState(null); @@ -23,7 +24,7 @@ const Studio = () => { return ( { if (selectedProject?.musicStatus === "GENERATING") { alert( "Attention", - "La chanson est en cours de génération. Veuillez patienter." + "La chanson est en cours de génération. Veuillez patienter.", ); } else { if (!selectedProject?.id) return; diff --git a/src/screens/cover/ChooseCoverType.js b/src/screens/cover/ChooseCoverType.js index 260a897..5fbd43e 100644 --- a/src/screens/cover/ChooseCoverType.js +++ b/src/screens/cover/ChooseCoverType.js @@ -16,6 +16,7 @@ import { useUserData } from "../../providers/UserDataProvider"; import { gutters, Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { buildFullName } from "../../utils/artistName"; +import { background } from "../../assets"; export const ChooseCoverType = () => { const [showIntro, setShowIntro] = useState(true); @@ -26,14 +27,9 @@ export const ChooseCoverType = () => { const [savingChoice, setSavingChoice] = useState(false); const [savingPseudo, setSavingPseudo] = useState(false); - const { - selectedProject, - selectedProjectId, - updateProjectData, - currentUserData, - currentUID, - } = useUserData(); - const { setIsLoading, setTooltip } = useMinuit(); + const { selectedProject, selectedProjectId, currentUserData, currentUID } = + useUserData(); + const { setTooltip } = useMinuit(); const projectId = selectedProject?.id || selectedProjectId || null; const hasFinalCover = !!selectedProject?.coverUrl; @@ -53,7 +49,7 @@ export const ChooseCoverType = () => { currentUserData?.firstName, currentUserData?.lastName, currentUserData?.displayName, - ] + ], ); useEffect(() => { @@ -73,7 +69,7 @@ export const ChooseCoverType = () => { setPendingAction(() => nextAction); setChoiceVisible(true); }, - [hasArtistPreference] + [hasArtistPreference], ); const runPendingAction = useCallback(async () => { @@ -117,7 +113,7 @@ export const ChooseCoverType = () => { console.log("update current project userName error", error?.message); } }, - [currentUID, projectId] + [currentUID, projectId], ); // const pickUserImage = useCallback(async () => { @@ -252,7 +248,7 @@ export const ChooseCoverType = () => { artistNamePreference: "CUSTOM", updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }, - { merge: true } + { merge: true }, ); await applyDisplayNameToProjects(value); setTooltip({ type: "success", text: "Pseudo enregistré" }); @@ -277,7 +273,7 @@ export const ChooseCoverType = () => { return ( @@ -373,6 +369,9 @@ export const ChooseCoverType = () => { Choisis le nom qui sera visible sur tes musiques. + + Attention : tu ne pourras plus le modifier ensuite. + { const { selectedProjectId, selectedProject, updateProjectData } = useUser(); - const { setIsLoading } = useMinuit(); + const { setLoading } = useGlobalLoading(); const isGenerating = selectedProject?.coverStatus === "GENERATING"; const coverOptions = useMemo(() => { if (!Array.isArray(selectedProject?.cover?.options)) { @@ -53,21 +53,37 @@ const PouchReady = () => { return null; } const found = coverOptions.find( - (option) => option?.id === selectedOptionId + (option) => option?.id === selectedOptionId, ); return found || coverOptions[0] || null; }, [coverOptions, selectedOptionId]); const coverBackgroundMessage = isWeb ? loaderMessages.pouchReadyGenerationWeb : ""; + const generationLoadingMessage = useMemo(() => { + if (typeof coverBackgroundMessage === "string") { + const trimmedMessage = coverBackgroundMessage.trim(); + if (trimmedMessage.length) { + return trimmedMessage; + } + } + return "Nous lançons la génération de ta pochette..."; + }, [coverBackgroundMessage]); const [styleMode, setStyleMode] = useState("preset"); const [selectedPresetStyle, setSelectedPresetStyle] = useState( - COVER_STYLE_PRESETS[0] + COVER_STYLE_PRESETS[0], ); const [customStyle, setCustomStyle] = useState(""); const [isPresetDropdownOpen, setIsPresetDropdownOpen] = useState(false); const [isSelecting, setIsSelecting] = useState(false); + const [isAwaitingGenerationStart, setIsAwaitingGenerationStart] = + useState(false); + + const showGenerationLoading = useCallback(async () => { + setIsAwaitingGenerationStart(true); + await setLoading(true, { message: generationLoadingMessage }); + }, [generationLoadingMessage, setLoading]); useEffect(() => { const projectStyle = (selectedProject?.coverStyle || "").trim(); @@ -106,12 +122,12 @@ const PouchReady = () => { return; } try { - await setIsLoading(true); + await showGenerationLoading(); await updateProjectData( { coverStyle: trimmedStyle, }, - { merge: true } + { merge: true }, ); await tasksRef.add({ type: "cover", @@ -121,11 +137,17 @@ const PouchReady = () => { }); } catch (e) { console.log("Cover task error", e?.message); - } finally { - setIsLoading(false); + setIsAwaitingGenerationStart(false); + await setLoading(false); } }, - [hasGeneratedOptions, selectedProjectId, setIsLoading, updateProjectData] + [ + hasGeneratedOptions, + selectedProjectId, + setLoading, + showGenerationLoading, + updateProjectData, + ], ); const requestCoverGeneration = useCallback(() => { @@ -196,7 +218,7 @@ const PouchReady = () => { selectedOptionId, selectedProject?.cover, updateProjectData, - ] + ], ); const onValidatePicture = useCallback(async () => { @@ -204,7 +226,9 @@ const PouchReady = () => { return; } try { - await setIsLoading(true); + await setLoading(true, { + message: "Validation de la pochette en cours...", + }); const existingCover = selectedProject?.cover || {}; const finalUrl = selectedOption.finalUrl || selectedOption.generatedUrl || null; @@ -226,7 +250,7 @@ const PouchReady = () => { coverUrl: finalUrl, }; const playbackStage = getStageAction("director", projectForStage); - await setIsLoading(false); + await setLoading(false); alert( "Malik", "Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.", @@ -246,19 +270,19 @@ const PouchReady = () => { navigate(targetRoute, params); }, }, - ] + ], ); } catch (e) { console.log("PouchReady: unable to validate cover", e?.message); } finally { - await setIsLoading(false); + await setLoading(false); } }, [ coverOptions, navigate, selectedOption, selectedProject, - setIsLoading, + setLoading, updateProjectData, ]); @@ -272,8 +296,31 @@ const PouchReady = () => { isGenerating || (hasGeneratedOptions ? !selectedOption || isSelecting : true); + useEffect(() => { + if (!isAwaitingGenerationStart) { + return; + } + if (isGenerating) { + setIsAwaitingGenerationStart(false); + setLoading(false); + } + }, [isAwaitingGenerationStart, isGenerating, setLoading]); + + useEffect( + () => () => { + if (isAwaitingGenerationStart) { + setIsAwaitingGenerationStart(false); + setLoading(false); + } + }, + [isAwaitingGenerationStart, setLoading], + ); + return ( - +