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 (
-
+