clear lot of tickets

This commit is contained in:
Thomas Demirdjian
2025-11-07 17:33:06 +01:00
parent 4365f1e46d
commit d7d9211fbe
30 changed files with 970 additions and 355 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ function basicTemplate({ title = "", content = "", button = null }) {
<div style="color:rgba(255,255,255,0.9)!important">${content}</div> <div style="color:rgba(255,255,255,0.9)!important">${content}</div>
${btn} ${btn}
<hr style="margin:24px 0;border:none;border-top:1px solid rgba(255,255,255,0.1)" /> <hr style="margin:24px 0;border:none;border-top:1px solid rgba(255,255,255,0.1)" />
<p style="color:rgba(255,255,255,0.45);font-size:12px;margin:0">minuit.app</p> <p style="color:rgba(255,255,255,0.45);font-size:12px;margin:0">musicland</p>
</td> </td>
</tr> </tr>
</table> </table>
+1
View File
@@ -33,6 +33,7 @@ exports.ALERT_TYPE = {
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED", MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED",
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS", COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS",
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED", COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED",
CREDITS_UPDATED: "CREDITS_UPDATED",
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE", PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
}; };
+72 -18
View File
@@ -2,7 +2,6 @@ const {
onDocumentCreated, onDocumentCreated,
onDocumentWritten, onDocumentWritten,
} = require("firebase-functions/v2/firestore"); } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore"); const { FieldValue } = require("firebase-admin/firestore");
const { refList, ALERT_TYPE } = require("../index"); const { refList, ALERT_TYPE } = require("../index");
const { Expo } = require("expo-server-sdk"); const { Expo } = require("expo-server-sdk");
@@ -10,11 +9,12 @@ const { Resend } = require("resend");
const { basicTemplate } = require("../helpers/email"); const { basicTemplate } = require("../helpers/email");
const { RESEND_API_KEY } = require("../config/keys"); 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 // Initialisation de Expo SDK
let expo = new Expo(); let expo = new Expo();
const EMAIL_FROM = "MusicLand <musicland@minuit.app>"; const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
const DEFAULT_EMAIL_TITLE = "MusicLand";
function getCollectionRef(collectionName = "") { function getCollectionRef(collectionName = "") {
const ref = refList?.[collectionName]; const ref = refList?.[collectionName];
@@ -24,6 +24,49 @@ function getCollectionRef(collectionName = "") {
return ref; 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( exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
{ region: "europe-west1", document: "notifications/{notificationId}" }, { region: "europe-west1", document: "notifications/{notificationId}" },
async (event) => { async (event) => {
@@ -49,11 +92,12 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
const { const {
pushToken = null, pushToken = null,
pushTokens = [], pushTokens = [],
email = "", email: receiverEmail = "",
emailNotifications = false, emailNotifications = false,
} = receiverData; } = receiverData;
if (!mailOnly) { if (!mailOnly) {
try {
const tokensSet = new Set( const tokensSet = new Set(
[] []
.concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(Array.isArray(pushTokens) ? pushTokens : [])
@@ -63,10 +107,6 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
const tokens = Array.from(tokensSet); const tokens = Array.from(tokensSet);
if (!tokens?.length) { if (!tokens?.length) {
console.warn("User push token not found");
return null;
}
await sendExpoNotification({ await sendExpoNotification({
tokens, tokens,
receiverId: receiver, receiverId: receiver,
@@ -75,26 +115,40 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
message: message, message: message,
data: notifData || {}, data: notifData || {},
}); });
} else {
console.log("User push token not found");
} }
if (emailNotifications && !!email) { } catch (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 { try {
if (!email) { const { subject, html } = buildNotificationEmailPayload({
throw new Error("userMail is required"); title,
} message,
if (!email?.subject || !email?.html) { template: notifData?.email,
throw new Error("Email subject and html are required"); });
}
await resendInstance.emails.send({ await resendInstance.emails.send({
from: EMAIL_FROM, from: EMAIL_FROM,
to: [email], to: [receiverEmail],
subject: title, subject,
html: basicTemplate({ title, content: message }), html,
}); });
} catch (e) { } catch (e) {
console.log("Error sending email:", e); console.log("Error sending email:", e);
} }
}
} else { } 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) { } catch (e) {
console.log(e); console.log(e);
+138 -1
View File
@@ -3,7 +3,8 @@ const { FieldValue } = require("firebase-admin/firestore");
const { onDocumentCreated } = require("firebase-functions/firestore"); const { onDocumentCreated } = require("firebase-functions/firestore");
const { HttpsError, onCall } = require("firebase-functions/https"); const { HttpsError, onCall } = require("firebase-functions/https");
const { REGION } = require("../index"); const { REGION, ALERT_TYPE } = require("../index");
const { sendNotification } = require("./notifications");
const { const {
ORDER_TYPES, ORDER_TYPES,
ORDER_STATUS, ORDER_STATUS,
@@ -14,6 +15,121 @@ const {
const USERS_COLLECTION = "users"; 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( const onOrderCreated = onDocumentCreated(
`${ORDERS_COLLECTION}/{orderId}`, `${ORDERS_COLLECTION}/{orderId}`,
async (event) => { async (event) => {
@@ -58,6 +174,8 @@ const onOrderCreated = onDocumentCreated(
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId); const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
let notificationContext = null;
try { try {
await admin.firestore().runTransaction(async (transaction) => { await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef); const userSnapshot = await transaction.get(userRef);
@@ -114,6 +232,11 @@ const onOrderCreated = onDocumentCreated(
}, },
{ merge: true }, { merge: true },
); );
notificationContext = {
balanceBefore: currentBalance,
balanceAfter: nextBalance,
};
}); });
} catch (error) { } catch (error) {
console.error( console.error(
@@ -131,6 +254,20 @@ const onOrderCreated = onDocumentCreated(
}, },
{ merge: true }, { 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 || {},
});
} }
}, },
); );
Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+2 -1
View File
@@ -19,7 +19,8 @@ const DeleteAccountModal = () => {
await SheetManager.hide("DeleteAccount"); await SheetManager.hide("DeleteAccount");
}; };
const confirmDelete = () => { const confirmDelete = async () => {
await onClose().catch(() => {});
alert( alert(
"Êtes-vous sûr ?", "Êtes-vous sûr ?",
"Cette action supprimera votre profil.", "Cette action supprimera votre profil.",
+170 -3
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import useAlgoliaSearch from "react-native-minuit/src/hooks/useAlgoliaSearch"; import useAlgoliaSearch from "react-native-minuit/src/hooks/useAlgoliaSearch";
import { AlgoliaUserConfig, AlgoliaProjectConfig } from "../data/keys"; import { AlgoliaUserConfig, AlgoliaProjectConfig } from "../data/keys";
import { projectsRef } from "../config/firebase";
import { useUserData } from "../providers/UserDataProvider"; import { useUserData } from "../providers/UserDataProvider";
const batchSizes = { const batchSizes = {
@@ -8,9 +9,119 @@ const batchSizes = {
projects: 6, 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 useSearch = () => {
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [hydratedMusics, setHydratedMusics] = useState(null);
const [hydratedPlaybacks, setHydratedPlaybacks] = useState(null);
const { currentUID } = useUserData(); const { currentUID } = useUserData();
const { hits: users, loading: userLoading } = useAlgoliaSearch({ 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 { return {
search, search,
setSearch, setSearch,
selected, selected,
setSelected, setSelected,
users, users,
musics, musics: hydratedMusics ?? musics,
playbacks, playbacks: hydratedPlaybacks ?? playbacks,
loading: userLoading || musicLoading || playbackLoading, loading: userLoading || musicLoading || playbackLoading,
}; };
}; };
+3
View File
@@ -92,6 +92,9 @@ const ensureUserDocument = async (user, overrides = {}) => {
if (!snapshot.exists) { if (!snapshot.exists) {
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp(); payload.createdAt = firebase.firestore.FieldValue.serverTimestamp();
if (user.email) payload.email = user.email; if (user.email) payload.email = user.email;
payload.emailNotifications = true;
} else if (typeof existingData?.emailNotifications === "undefined") {
payload.emailNotifications = true;
} }
const overrideFirstName = const overrideFirstName =
+1 -1
View File
@@ -252,7 +252,7 @@ export default ({
> >
<Image <Image
source={subscriptionBadgeSource} source={subscriptionBadgeSource}
style={{ height: 40, width: 40, resizeMode: "contain" }} style={{ height: 50, width: 50, resizeMode: "contain" }}
/> />
</Pressable> </Pressable>
) : null} ) : null}
+1
View File
@@ -72,6 +72,7 @@ const CreatePassword = () => {
await usersRef.doc(uid).set( await usersRef.doc(uid).set(
{ {
email: email.trim(), email: email.trim(),
emailNotifications: true,
...(trimmedFirstName ? { firstName: trimmedFirstName } : {}), ...(trimmedFirstName ? { firstName: trimmedFirstName } : {}),
...(trimmedLastName ? { lastName: trimmedLastName } : {}), ...(trimmedLastName ? { lastName: trimmedLastName } : {}),
...(trimmedCity ? { city: trimmedCity } : {}), ...(trimmedCity ? { city: trimmedCity } : {}),
+63 -3
View File
@@ -89,6 +89,12 @@ const STAGE_CARD_CONTENT = [
]; ];
const CLUB_CARD_IMAGE = icons.clubIcon; const CLUB_CARD_IMAGE = icons.clubIcon;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const Home = ({ navigation, route }) => { const Home = ({ navigation, route }) => {
const { const {
@@ -102,6 +108,43 @@ const Home = ({ navigation, route }) => {
} = useUser(); } = useUser();
const { setTooltip } = useMinuit(); 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) { if (!currentUID) {
return <LandingPage />; return <LandingPage />;
} }
@@ -386,9 +429,17 @@ const Home = ({ navigation, route }) => {
const handleStagePress = useCallback( const handleStagePress = useCallback(
(stageKey, isLocked) => { (stageKey, isLocked) => {
if (!hasActiveProject || isLocked) { if (isLocked) {
return; return;
} }
if (!hasActiveProject || !currentProject) {
if (stageKey === "songwriter") {
handleStartNew();
}
return;
}
ensureProjectSelected(); ensureProjectSelected();
const action = getStageAction(stageKey, currentProject); const action = getStageAction(stageKey, currentProject);
if (!action?.route) { if (!action?.route) {
@@ -396,7 +447,12 @@ const Home = ({ navigation, route }) => {
} }
navigate(action.route, action.params); navigate(action.route, action.params);
}, },
[currentProject, ensureProjectSelected, hasActiveProject], [
currentProject,
ensureProjectSelected,
hasActiveProject,
handleStartNew,
],
); );
const handleClubPress = useCallback(() => { const handleClubPress = useCallback(() => {
@@ -464,7 +520,11 @@ const Home = ({ navigation, route }) => {
))} ))}
</View> </View>
<ClubCard image={CLUB_CARD_IMAGE} onPress={handleClubPress} /> <ClubCard
image={CLUB_CARD_IMAGE}
onPress={handleClubPress}
hasActiveSubscription={hasActiveSubscription}
/>
</View> </View>
</Page> </Page>
</View> </View>
-165
View File
@@ -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 (
<Page backgroundImg={background.homeBG} headerType="NONE">
<View style={{ flex: 1 }}>
<Image
source={img.goodVibe}
style={{ alignSelf: "center", position: "absolute" }}
/>
{projects.length > 0 && (
<View
style={{
height: responsiveHeight(70),
paddingTop: responsiveHeight(6),
}}
>
<BlurView
intensity={Platform.OS !== "ios" ? 10 : 20}
style={{
flex: 1,
borderRadius: 20,
overflow: "hidden",
backgroundColor: Palette.glass,
padding: 12,
gap: 8,
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
>
<Text
style={{
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
marginBottom: 8,
textAlign: "center",
}}
>
Musiques en cours
</Text>
<FlatList
data={projects}
keyExtractor={(item) => item.id}
contentContainerStyle={{ gap: 10, paddingBottom: 10 }}
renderItem={({ item }) => (
<MusicCard
title={item?.title || "Sans titre"}
subtitle={item?.userName || ownerDisplayName}
imageUri={item?.coverUrl || null}
projectId={item?.id}
likedBy={getProjectLikes(item, LIKE_TARGET.SONG)}
onPress={() => {
selectProject(item.id);
navigate(Routes.Home);
}}
onPressMore={(posTop) => {
setMenuProjectId(item.id);
setMenuPosition(posTop);
setShowMenu(
(prev) =>
!prev || posTop?.top !== (menuPosition?.top ?? null)
);
}}
/>
)}
/>
<MoreMenu
visible={showMenu}
top={menuPosition?.top ?? 0}
position={menuPosition}
onClose={() => 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 }
),
},
]}
/>
</BlurView>
</View>
)}
<View style={{ paddingTop: 12, marginBottom: responsiveHeight(10) }}>
<GradientButton
title="Créer une nouvelle musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
onPress={() => {
resetSelectedProject();
navigate(Routes.Home);
}}
/>
</View>
</View>
</Page>
);
};
export default Home;
+8 -2
View File
@@ -5,7 +5,12 @@ import { FONT_FAMILY } from "../../../styles/Fonts";
import { Palette } from "../../../styles"; import { Palette } from "../../../styles";
import { icons } from "../../../assets"; import { icons } from "../../../assets";
const ClubCard = ({ image, onPress }) => ( const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
const subtitle = hasActiveSubscription
? "Tu fais déjà partie du club !"
: "Rejoins le club !";
return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]} style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
@@ -17,10 +22,11 @@ const ClubCard = ({ image, onPress }) => (
style={styles.clubLogo} style={styles.clubLogo}
/> />
<ExpoImage source={image} contentFit="contain" style={styles.image} /> <ExpoImage source={image} contentFit="contain" style={styles.image} />
<Text style={styles.subtitle}>Rejoins le club !</Text> <Text style={styles.subtitle}>{subtitle}</Text>
</View> </View>
</Pressable> </Pressable>
); );
};
export default memo(ClubCard); export default memo(ClubCard);
+19 -2
View File
@@ -6,7 +6,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } 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 { responsiveHeight } from "react-native-responsive-dimensions";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
import SearchBar from "../../components/SearchBar"; import SearchBar from "../../components/SearchBar";
@@ -91,6 +91,7 @@ const Library = () => {
const [dropdownVisible, setDropdownVisible] = useState(false); const [dropdownVisible, setDropdownVisible] = useState(false);
const searchWrapperRef = useRef(null); const searchWrapperRef = useRef(null);
const hasSearchQuery = search.trim().length > 0;
const closeDropdown = useCallback(() => { const closeDropdown = useCallback(() => {
setDropdownVisible(false); setDropdownVisible(false);
@@ -103,6 +104,7 @@ const Library = () => {
}; };
const shouldShowResults = dropdownVisible; const shouldShowResults = dropdownVisible;
const shouldBlurContent = dropdownVisible && hasSearchQuery;
const handleChangeText = (value) => { const handleChangeText = (value) => {
setSearch(value); setSearch(value);
@@ -203,7 +205,21 @@ const Library = () => {
)} )}
</View> </View>
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1, position: "relative" }}>
{shouldBlurContent && (
<BlurView
intensity={35}
tint="dark"
style={[
StyleSheet.absoluteFillObject,
{
zIndex: 10,
borderRadius: 0,
backgroundColor: "rgba(0, 0, 0, 0.25)",
},
]}
/>
)}
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
flexGrow: 1, flexGrow: 1,
@@ -212,6 +228,7 @@ const Library = () => {
paddingTop: Platform.OS !== "android" ? 20 : 0, paddingTop: Platform.OS !== "android" ? 20 : 0,
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
style={{ flex: 1, position: "relative", zIndex: 5 }}
> >
<BlurView <BlurView
intensity={40} intensity={40}
+37 -5
View File
@@ -221,6 +221,38 @@ const MusicDetails = ({ route }) => {
} }
}, [project?.musicTimestamps, project?.songIndex]); }, [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 // Reset counters when the track changes
useEffect(() => { useEffect(() => {
listenedMsRef.current = 0; listenedMsRef.current = 0;
@@ -320,7 +352,7 @@ const MusicDetails = ({ route }) => {
const handleSliderSeek = useCallback( const handleSliderSeek = useCallback(
async (ratio) => { async (ratio) => {
const dur = durationMs || 0; const dur = sliderDurationMs || 0;
if (!trackDescriptor || dur <= 0) return; if (!trackDescriptor || dur <= 0) return;
const targetMs = Math.max(0, Math.floor(dur * ratio)); const targetMs = Math.max(0, Math.floor(dur * ratio));
try { try {
@@ -333,7 +365,7 @@ const MusicDetails = ({ route }) => {
console.log("MusicDetails seek error", e?.message); console.log("MusicDetails seek error", e?.message);
} }
}, },
[durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo] [sliderDurationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo]
); );
const handleToggleLoop = useCallback(async () => { const handleToggleLoop = useCallback(async () => {
@@ -839,10 +871,10 @@ const MusicDetails = ({ route }) => {
<View style={{ paddingTop: 22 }}> <View style={{ paddingTop: 22 }}>
<Slider <Slider
value={fmt(positionMs)} value={fmt(positionMs)}
maxValue={fmt(durationMs)} maxValue={fmt(sliderDurationMs)}
progress={ progress={
durationMs sliderDurationMs
? Math.min(1, Math.max(0, (positionMs || 0) / durationMs)) ? Math.min(1, Math.max(0, (positionMs || 0) / sliderDurationMs))
: 0 : 0
} }
seekEnabled={!!songUrl} seekEnabled={!!songUrl}
+91 -4
View File
@@ -11,6 +11,7 @@ import React, {
import { import {
Pressable, Pressable,
Image as RNImage, Image as RNImage,
Platform,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
@@ -52,6 +53,7 @@ import {
createMusicSharePayload, createMusicSharePayload,
openShareSheet, openShareSheet,
} from "../../utils/shareSheet"; } from "../../utils/shareSheet";
import { goBack } from "../../navigation/NavigationService";
// 20 secondes // 20 secondes
const timeBeforeIncrement = 20000; const timeBeforeIncrement = 20000;
@@ -71,6 +73,29 @@ const MusicDetails = ({ route }) => {
const timerRef = React.useRef(null); const timerRef = React.useRef(null);
const hasAutoPlayedRef = React.useRef(false); const hasAutoPlayedRef = React.useRef(false);
const { isLooping, setLooping } = usePlayer() || {}; const { isLooping, setLooping } = usePlayer() || {};
const isWeb = Platform.OS === "web";
const handleBackPress = useCallback(() => {
goBack();
}, []);
const renderWebBackButton = useCallback(() => {
if (!isWeb) return null;
return (
<View style={styles.webBackContainer}>
<Pressable
accessibilityRole="button"
onPress={handleBackPress}
style={styles.webBackButton}
>
<RNImage
source={icons.chevronDown}
style={styles.webBackIcon}
resizeMode="contain"
/>
<Text style={styles.webBackText}>Retour</Text>
</Pressable>
</View>
);
}, [handleBackPress, isWeb]);
const { data: project } = useDataFromRef({ const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null, ref: projectId ? projectsRef.doc(projectId) : null,
@@ -297,6 +322,38 @@ const MusicDetails = ({ route }) => {
} }
}, [project?.musicTimestamps, project?.songIndex]); }, [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 // Reset counters when the track changes
useEffect(() => { useEffect(() => {
listenedMsRef.current = 0; listenedMsRef.current = 0;
@@ -411,7 +468,7 @@ const MusicDetails = ({ route }) => {
const handleSliderSeek = useCallback( const handleSliderSeek = useCallback(
async (ratio) => { async (ratio) => {
const dur = durationMs || 0; const dur = sliderDurationMs || 0;
if (!trackDescriptor || dur <= 0) return; if (!trackDescriptor || dur <= 0) return;
const targetMs = Math.max(0, Math.floor(dur * ratio)); const targetMs = Math.max(0, Math.floor(dur * ratio));
lastSeekTargetMs.current = targetMs; lastSeekTargetMs.current = targetMs;
@@ -431,7 +488,7 @@ const MusicDetails = ({ route }) => {
console.log("MusicDetails seek error", e?.message); console.log("MusicDetails seek error", e?.message);
} }
}, },
[trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo] [trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
); );
const handleToggleLoop = useCallback(async () => { const handleToggleLoop = useCallback(async () => {
@@ -905,6 +962,8 @@ const MusicDetails = ({ route }) => {
return ( return (
<Page <Page
headerType="NAVIGATION" headerType="NAVIGATION"
hideBackButton={isWeb}
topStickyContent={renderWebBackButton}
title={action === "userProfile" ? "Mon profil" : "Détail musique"} title={action === "userProfile" ? "Mon profil" : "Détail musique"}
backgroundImg={ backgroundImg={
action === "userProfile" ? background.profileBG : background.libraryBG2 action === "userProfile" ? background.profileBG : background.libraryBG2
@@ -1078,8 +1137,10 @@ const MusicDetails = ({ route }) => {
</View> </View>
<Slider <Slider
value={fmt(positionMs)} value={fmt(positionMs)}
maxValue={fmt(durationMs)} maxValue={fmt(sliderDurationMs)}
progress={durationMs ? (positionMs || 0) / durationMs : 0} progress={
sliderDurationMs ? (positionMs || 0) / sliderDurationMs : 0
}
seekEnabled={!!songUrl} seekEnabled={!!songUrl}
onSeekStart={handleSliderSeekStart} onSeekStart={handleSliderSeekStart}
onSeek={handleSliderSeek} onSeek={handleSliderSeek}
@@ -1191,6 +1252,32 @@ const MusicDetails = ({ route }) => {
export default MusicDetails; export default MusicDetails;
const styles = StyleSheet.create({ 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: { img: {
width: 200, width: 200,
height: 200, height: 200,
@@ -84,11 +84,6 @@ const SearchResultsList = ({
); );
} }
const fallbackThumbnail = thumbnailCandidates.find(isValid);
if (fallbackThumbnail) {
coverCandidates.push(fallbackThumbnail);
}
return coverCandidates.find(isValid) || null; return coverCandidates.find(isValid) || null;
}, },
[] []
+3 -1
View File
@@ -277,6 +277,7 @@ const PLAN_SEGMENTS = [
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520; const HERO_IMAGE_WIDTH = isWeb ? 1280 : 520;
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360; const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
const PAGE_BACKGROUND_COLOR = "#303438";
export default function Payments() { export default function Payments() {
const route = useRoute(); const route = useRoute();
@@ -498,6 +499,7 @@ export default function Payments() {
width={isWeb ? 960 : undefined} width={isWeb ? 960 : undefined}
containerStyle={styles.page} containerStyle={styles.page}
contentContainerStyle={styles.pageContent} contentContainerStyle={styles.pageContent}
backgroundColor={PAGE_BACKGROUND_COLOR}
> >
<View style={styles.inner}> <View style={styles.inner}>
<ExpoImage <ExpoImage
@@ -597,7 +599,7 @@ export default function Payments() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { root: {
flex: 1, flex: 1,
backgroundColor: "#303438", backgroundColor: PAGE_BACKGROUND_COLOR,
}, },
page: { page: {
backgroundColor: "transparent", backgroundColor: "transparent",
+4 -4
View File
@@ -30,15 +30,15 @@ const Playback = ({ route }) => {
}} }}
> >
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}> <View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
<GradientButton
title="Enregistrer mon Playback"
onPress={onPressRecord}
/>
<BorderGradientButton <BorderGradientButton
title="Guide du Playbacker" title="Guide du Playbacker"
onPress={() => setShowIntro(true)} onPress={() => setShowIntro(true)}
/> />
{/* <BorderGradientButton title="Importer une vidéo" /> */} {/* <BorderGradientButton title="Importer une vidéo" /> */}
<GradientButton
title="Enregistrer mon Playback"
onPress={onPressRecord}
/>
</View> </View>
</View> </View>
<FullscreenIntroVideo <FullscreenIntroVideo
+41 -3
View File
@@ -15,6 +15,7 @@ import GradientButton from "../../components/GradientButton";
import KaraokeLyrics from "../../components/KaraokeLyrics"; import KaraokeLyrics from "../../components/KaraokeLyrics";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import { increment, projectsRef } from "../../config/firebase"; import { increment, projectsRef } from "../../config/firebase";
import usePlayer from "../../hooks/usePlayer";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
@@ -35,6 +36,7 @@ const RecordPlayback = ({ route }) => {
log("Route params received", { hasProject: !!project }); log("Route params received", { hasProject: !!project });
const projectId = project?.id; const projectId = project?.id;
const songIndex = project?.songIndex; const songIndex = project?.songIndex;
const { setLooping, isLooping } = usePlayer() || {};
// Permissions // Permissions
const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [cameraPermission, requestCameraPermission] = useCameraPermissions();
@@ -48,6 +50,8 @@ const RecordPlayback = ({ route }) => {
const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick
const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement
const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 }); const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 });
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
const latestLoopingValueRef = useRef(isLooping ?? false);
// Compteurs vues // Compteurs vues
const listenedMsRef = useRef(0); const listenedMsRef = useRef(0);
@@ -80,6 +84,10 @@ const RecordPlayback = ({ route }) => {
log("Screen params", { projectId, songUrl, songIndex }); log("Screen params", { projectId, songUrl, songIndex });
}, [projectId, songUrl, songIndex]); }, [projectId, songUrl, songIndex]);
useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false;
}, [isLooping]);
const musicIndex = useMemo(() => { const musicIndex = useMemo(() => {
const i = Number(songIndex); const i = Number(songIndex);
return Number.isFinite(i) && i >= 0 ? i : 0; return Number.isFinite(i) && i >= 0 ? i : 0;
@@ -280,12 +288,42 @@ const RecordPlayback = ({ route }) => {
} catch (_) {} } catch (_) {}
}, [player]); }, [player]);
const resetSessionRef = useRef(resetSession);
useEffect(() => {
resetSessionRef.current = resetSession;
}, [resetSession]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
log("Screen focused, resetting session"); log("Screen focused, resetting session");
void resetSession(); void resetSessionRef.current?.();
return () => {}; return () => {
}, [resetSession]) 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) // Lancer le compte à rebours (le tick décrémente uniquement)
+41 -3
View File
@@ -16,6 +16,7 @@ import KaraokeLyrics from "../../components/KaraokeLyrics";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
import { increment, projectsRef } from "../../config/firebase"; import { increment, projectsRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType"; import { isWeb } from "../../hooks/useLayoutType";
import usePlayer from "../../hooks/usePlayer";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
@@ -59,6 +60,7 @@ const RecordPlayback = ({ route }) => {
const { project } = route.params || {}; const { project } = route.params || {};
const songIndex = Number(project?.songIndex ?? 0) || 0; const songIndex = Number(project?.songIndex ?? 0) || 0;
const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [cameraPermission, requestCameraPermission] = useCameraPermissions();
const { setLooping, isLooping } = usePlayer() || {};
// Player // Player
const songUrl = project?.songUrl || null; const songUrl = project?.songUrl || null;
@@ -80,6 +82,8 @@ const RecordPlayback = ({ route }) => {
const stopRecordingPromiseRef = useRef(null); const stopRecordingPromiseRef = useRef(null);
const stopRecordingResolveRef = useRef(null); const stopRecordingResolveRef = useRef(null);
const recordedUrlRef = useRef(null); const recordedUrlRef = useRef(null);
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
const latestLoopingValueRef = useRef(isLooping ?? false);
const [mediaReady, setMediaReady] = useState(false); const [mediaReady, setMediaReady] = useState(false);
const [mediaError, setMediaError] = useState(null); const [mediaError, setMediaError] = useState(null);
@@ -100,6 +104,10 @@ const RecordPlayback = ({ route }) => {
const correctedInitialJumpRef = useRef(false); const correctedInitialJumpRef = useRef(false);
const progressDebugCounterRef = useRef(0); const progressDebugCounterRef = useRef(0);
useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false;
}, [isLooping]);
// Lyrics // Lyrics
const alignedWords = useMemo(() => { const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[songIndex]; const ts = project?.musicTimestamps?.[songIndex];
@@ -328,11 +336,41 @@ const RecordPlayback = ({ route }) => {
console.log(LOG_PREFIX, "resetSession:end"); console.log(LOG_PREFIX, "resetSession:end");
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]); }, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
const resetSessionRef = useRef(resetSession);
useEffect(() => {
resetSessionRef.current = resetSession;
}, [resetSession]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void resetSession(); if (typeof setLooping === "function") {
return () => {}; originalLoopingValueRef.current = {
}, [resetSession]) 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(() => { useEffect(() => {
+87 -12
View File
@@ -1,9 +1,9 @@
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { VideoView, useVideoPlayer } from "expo-video"; import { VideoView, useVideoPlayer } from "expo-video";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native"; import { Image, Pressable, View } from "react-native";
import { background } from "../../assets"; import { background, icons } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton"; import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
@@ -30,8 +30,13 @@ const RecordedPlayback = ({ route }) => {
p.timeUpdateEventInterval = 0.2; 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 wasPlayingBeforeSeek = useRef(false);
const playbackEndedRef = useRef(false);
// Format mm:ss // Format mm:ss
const fmt = (ms) => { const fmt = (ms) => {
@@ -44,7 +49,17 @@ const RecordedPlayback = ({ route }) => {
}; };
// Start both players on mount // 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(() => { useEffect(() => {
playbackEndedRef.current = false;
const start = async () => { const start = async () => {
try { try {
if (audioPlayer && songUrl) await audioPlayer.play?.(); if (audioPlayer && songUrl) await audioPlayer.play?.();
@@ -53,12 +68,10 @@ const RecordedPlayback = ({ route }) => {
}; };
start(); start();
return () => { return () => {
try { playbackEndedRef.current = false;
if (audioPlayer?.playing) audioPlayer.pause?.(); void stopPlayback();
if (videoPlayer?.playing) videoPlayer.pause();
} catch (e) {}
}; };
}, [audioPlayer, videoPlayer, songUrl]); }, [audioPlayer, songUrl, stopPlayback, videoPlayer]);
// Poll from audio player for progress display; keep video in sync if drifting // Poll from audio player for progress display; keep video in sync if drifting
useEffect(() => { useEffect(() => {
@@ -66,7 +79,8 @@ const RecordedPlayback = ({ route }) => {
try { try {
const dur = (audioPlayer?.duration || 0) * 1000; const dur = (audioPlayer?.duration || 0) * 1000;
const pos = (audioPlayer?.currentTime || 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 // basic drift correction: if desync > 300ms, align video
if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) { if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) {
@@ -94,6 +108,7 @@ const RecordedPlayback = ({ route }) => {
const onSeekStart = async () => { const onSeekStart = async () => {
try { try {
wasPlayingBeforeSeek.current = !!audioPlayer?.playing; wasPlayingBeforeSeek.current = !!audioPlayer?.playing;
playbackEndedRef.current = false;
if (audioPlayer?.playing) await audioPlayer.pause?.(); if (audioPlayer?.playing) await audioPlayer.pause?.();
if (videoPlayer?.playing) videoPlayer.pause(); if (videoPlayer?.playing) videoPlayer.pause();
} catch (e) {} } catch (e) {}
@@ -113,6 +128,47 @@ const RecordedPlayback = ({ route }) => {
: 0; : 0;
}, [progressInfo]); }, [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 ( return (
<Page backgroundImg={background.playbackBG2} headerType="NONE"> <Page backgroundImg={background.playbackBG2} headerType="NONE">
<MusicLandHeader progress={19} onPressBack={goBack} /> <MusicLandHeader progress={19} onPressBack={goBack} />
@@ -144,6 +200,26 @@ const RecordedPlayback = ({ route }) => {
onSeekStart={onSeekStart} onSeekStart={onSeekStart}
onSeekEnd={onSeekEnd} onSeekEnd={onSeekEnd}
/> />
<Pressable
onPress={handleTogglePlayback}
style={{
width: 72,
height: 72,
borderRadius: 36,
alignSelf: "center",
marginTop: 4,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(10, 5, 24, 0.65)",
borderWidth: 1,
borderColor: "#F94697",
}}
>
<Image
source={progressInfo.isPlaying ? icons.pause : icons.play}
style={{ width: 26, height: 26 }}
/>
</Pressable>
</View> </View>
<View <View
style={{ width: "80%", alignSelf: "center", marginTop: 4, gap: 12 }} style={{ width: "80%", alignSelf: "center", marginTop: 4, gap: 12 }}
@@ -162,8 +238,7 @@ const RecordedPlayback = ({ route }) => {
title="Recommencer" title="Recommencer"
onPress={async () => { onPress={async () => {
try { try {
if (audioPlayer?.playing) await audioPlayer.pause?.(); await stopPlayback();
if (videoPlayer?.playing) videoPlayer.pause();
} catch (e) {} } catch (e) {}
try { try {
if (videoUri) { if (videoUri) {
+35 -18
View File
@@ -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 { Text, View } from "react-native";
import { background } from "../../assets"; import { background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton"; 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 // Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
const videoElRef = useRef(null); const videoElRef = useRef(null);
const playbackEndedRef = useRef(false);
const [progress, setProgress] = useState({ const [progress, setProgress] = useState({
posS: 0, // secondes posS: 0, // secondes
@@ -50,9 +51,20 @@ const RecordedPlayback = ({ route }) => {
playing: false, 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 // Démarrage / arrêt
useEffect(() => { useEffect(() => {
let mounted = true; playbackEndedRef.current = false;
const start = async () => { const start = async () => {
try { try {
if (audioPlayer && songUrl) { if (audioPlayer && songUrl) {
@@ -68,17 +80,10 @@ const RecordedPlayback = ({ route }) => {
start(); start();
return () => { return () => {
mounted = false; playbackEndedRef.current = false;
try { void stopPlayback();
if (audioPlayer?.playing) audioPlayer.pause?.();
} catch {}
try {
if (videoElRef.current) {
videoElRef.current.pause();
}
} catch {}
}; };
}, [audioPlayer, songUrl, videoUri]); }, [audioPlayer, songUrl, stopPlayback, videoUri]);
// Boucle de progression + éventuelle sync de la vidéo si fournie // Boucle de progression + éventuelle sync de la vidéo si fournie
useEffect(() => { useEffect(() => {
@@ -115,6 +120,21 @@ const RecordedPlayback = ({ route }) => {
return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0; return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0;
}, [progress]); }, [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) => { const onSeek = async (ratio) => {
try { try {
const durS = Number(progress.durS || 0); const durS = Number(progress.durS || 0);
@@ -132,6 +152,7 @@ const RecordedPlayback = ({ route }) => {
const onSeekStart = async () => { const onSeekStart = async () => {
try { try {
wasPlayingRef.current = !!audioPlayer?.playing; wasPlayingRef.current = !!audioPlayer?.playing;
playbackEndedRef.current = false;
if (audioPlayer?.playing) await audioPlayer.pause?.(); if (audioPlayer?.playing) await audioPlayer.pause?.();
if (videoElRef.current && !videoElRef.current.paused) { if (videoElRef.current && !videoElRef.current.paused) {
videoElRef.current.pause(); videoElRef.current.pause();
@@ -244,13 +265,9 @@ const RecordedPlayback = ({ route }) => {
/> />
<BorderGradientButton <BorderGradientButton
title="Recommencer" title="Recommencer"
onPress={() => { onPress={async () => {
try { try {
if (audioPlayer?.playing) audioPlayer.pause?.(); await stopPlayback();
} catch {}
try {
if (videoElRef.current && !videoElRef.current.paused)
videoElRef.current.pause();
} catch {} } catch {}
navigate(Routes.RecordPlayback, { project }); navigate(Routes.RecordPlayback, { project });
}} }}
+12 -1
View File
@@ -3,6 +3,7 @@ import React, { useCallback, useMemo, useState } from "react";
import { Alert, Platform, StyleSheet, Text, View } from "react-native"; import { Alert, Platform, StyleSheet, Text, View } from "react-native";
import BorderGradientButton from "../../components/BorderGradientButton"; import BorderGradientButton from "../../components/BorderGradientButton";
import { background } from "../../assets";
import { getFunctionsClient } from "../../config/firebase"; import { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation/Routes"; import { Routes } from "../../navigation/Routes";
@@ -41,6 +42,8 @@ const PERIOD_LABELS = {
annual: "Annuel", annual: "Annuel",
}; };
const PAGE_BACKGROUND_COLOR = "#303438";
const formatCoinsAmount = (value) => { const formatCoinsAmount = (value) => {
if (typeof value !== "number" || !Number.isFinite(value)) { if (typeof value !== "number" || !Number.isFinite(value)) {
return null; return null;
@@ -560,8 +563,16 @@ const ManageSubscription = ({ navigation }) => {
? Palette.grayMid ? Palette.grayMid
: Palette.red; : Palette.red;
const backgroundImage = background.bgTrans;
return ( return (
<Page headerType="NAVIGATE" title="Mon abonnement" scrollEnabled> <Page
headerType="NAVIGATE"
title="Mon abonnement"
scrollEnabled
backgroundColor={PAGE_BACKGROUND_COLOR}
backgroundImg={backgroundImage}
>
<View style={styles.container}> <View style={styles.container}>
{subscriptionInfo.hasAnySubscription ? ( {subscriptionInfo.hasAnySubscription ? (
<> <>
+14 -5
View File
@@ -7,13 +7,22 @@ import { FONT_FAMILY } from "../../styles/Fonts";
import { RHYTHM } from "../../data/data"; import { RHYTHM } from "../../data/data";
import ListSelection from "../../components/ListSelection/ListSelection"; 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 ( return (
<View style={{ flex: 1, gap: 10, paddingTop: 16 }}> <View style={{ flex: 1, gap: 10, paddingTop: 16 }}>
<CreateLyricsHeader title="Choisis un rythme" /> <CreateLyricsHeader title="Choisis un rythme" />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<ItemContainer height={340}> <ItemContainer height={RHYTHM_CONTAINER_HEIGHT}>
<ListSelection <ListSelection
options={RHYTHM} options={RHYTHM}
variant="simple" variant="simple"
@@ -34,12 +43,12 @@ export default ChooseRhythm;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
contentContainer: { contentContainer: {
flexGrow: 1, flexGrow: 1,
padding: 8, padding: CONTENT_PADDING,
gap: 10, gap: CONTENT_GAP,
backgroundColor: "#FFFFFF00", backgroundColor: "#FFFFFF00",
}, },
itemContainer: { itemContainer: {
height: 54, height: ITEM_HEIGHT,
backgroundColor: Palette.glass, backgroundColor: Palette.glass,
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: "hidden",
-22
View File
@@ -264,32 +264,10 @@ const GeneratingSong = () => {
/> />
<View <View
style={{ style={{
height: responsiveHeight(50),
marginTop: responsiveHeight(10), marginTop: responsiveHeight(10),
gap: 16, gap: 16,
}} }}
> >
<View
style={{
zIndex: 1,
position: "absolute",
top: -150,
width: "50%",
height: "100%",
alignSelf: "center",
}}
>
<Image
source={ai.malik}
style={{
width: "100%",
height: isWeb ? 300 : "60%",
right: -10,
top: 50,
}}
resizeMode="contain"
/>
</View>
<BlurView <BlurView
intensity={Platform.OS !== "ios" ? 10 : 40} intensity={Platform.OS !== "ios" ? 10 : 40}
tint="dark" tint="dark"
+6 -8
View File
@@ -36,8 +36,6 @@ const SongReady = () => {
1: { pos: 0, dur: 0 }, 1: { pos: 0, dur: 0 },
}); });
console.log("progress info", JSON.stringify(progressInfo, null, 2));
const player0 = useSharedAudioPlayer( const player0 = useSharedAudioPlayer(
musicUrls[0] ? { uri: musicUrls[0] } : undefined, musicUrls[0] ? { uri: musicUrls[0] } : undefined,
{ {
@@ -51,7 +49,7 @@ const SongReady = () => {
artwork: selectedProject?.coverUrl || null, artwork: selectedProject?.coverUrl || null,
coverUrl: selectedProject?.coverUrl || null, coverUrl: selectedProject?.coverUrl || null,
metadata: { index: 0, projectId }, metadata: { index: 0, projectId },
} },
); );
const player1 = useSharedAudioPlayer( const player1 = useSharedAudioPlayer(
musicUrls[1] ? { uri: musicUrls[1] } : undefined, musicUrls[1] ? { uri: musicUrls[1] } : undefined,
@@ -66,7 +64,7 @@ const SongReady = () => {
artwork: selectedProject?.coverUrl || null, artwork: selectedProject?.coverUrl || null,
coverUrl: selectedProject?.coverUrl || null, coverUrl: selectedProject?.coverUrl || null,
metadata: { index: 1, projectId }, metadata: { index: 1, projectId },
} },
); );
const wasPlayingBeforeSeek = useRef({ 0: false, 1: false }); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });
const prefetchedRef = useRef({ const prefetchedRef = useRef({
@@ -288,7 +286,7 @@ const SongReady = () => {
player1?.pause?.(); player1?.pause?.();
} catch {} } catch {}
}; };
}, [player0, player1]) }, [player0, player1]),
); );
useEffect(() => { useEffect(() => {
@@ -318,7 +316,7 @@ const SongReady = () => {
}, },
}, },
], ],
{ cancelable: false } { cancelable: false },
); );
return; return;
} }
@@ -425,7 +423,7 @@ const SongReady = () => {
} catch (e) { } catch (e) {
console.log( console.log(
"SongReady pause on seek start", "SongReady pause on seek start",
e?.message e?.message,
); );
} }
}} }}
@@ -441,7 +439,7 @@ const SongReady = () => {
} catch (e) { } catch (e) {
console.log( console.log(
"SongReady resume after seek", "SongReady resume after seek",
e?.message e?.message,
); );
} }
}} }}
+3 -2
View File
@@ -9,6 +9,7 @@ import { Routes } from "../../navigation";
import { navigate } from "../../navigation/NavigationService"; import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles"; import { gutters } from "../../styles";
import { isWeb } from "../../hooks/useLayoutType";
const Studio = () => { const Studio = () => {
const [selectedProjectId, setSelectedProjectId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null);
@@ -23,7 +24,7 @@ const Studio = () => {
return ( return (
<Page <Page
headerType="NONE" headerType="NONE"
backgroundImg={background.studioBG} backgroundImg={isWeb ? background.productionBG2 : background.studioBG}
containerStyle={{ paddingHorizontal: 0 }} containerStyle={{ paddingHorizontal: 0 }}
contentContainerStyle={{ contentContainerStyle={{
padding: gutters * 2, padding: gutters * 2,
@@ -117,7 +118,7 @@ const Studio = () => {
if (selectedProject?.musicStatus === "GENERATING") { if (selectedProject?.musicStatus === "GENERATING") {
alert( alert(
"Attention", "Attention",
"La chanson est en cours de génération. Veuillez patienter." "La chanson est en cours de génération. Veuillez patienter.",
); );
} else { } else {
if (!selectedProject?.id) return; if (!selectedProject?.id) return;
+18 -13
View File
@@ -16,6 +16,7 @@ import { useUserData } from "../../providers/UserDataProvider";
import { gutters, Palette } from "../../styles"; import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import { buildFullName } from "../../utils/artistName"; import { buildFullName } from "../../utils/artistName";
import { background } from "../../assets";
export const ChooseCoverType = () => { export const ChooseCoverType = () => {
const [showIntro, setShowIntro] = useState(true); const [showIntro, setShowIntro] = useState(true);
@@ -26,14 +27,9 @@ export const ChooseCoverType = () => {
const [savingChoice, setSavingChoice] = useState(false); const [savingChoice, setSavingChoice] = useState(false);
const [savingPseudo, setSavingPseudo] = useState(false); const [savingPseudo, setSavingPseudo] = useState(false);
const { const { selectedProject, selectedProjectId, currentUserData, currentUID } =
selectedProject, useUserData();
selectedProjectId, const { setTooltip } = useMinuit();
updateProjectData,
currentUserData,
currentUID,
} = useUserData();
const { setIsLoading, setTooltip } = useMinuit();
const projectId = selectedProject?.id || selectedProjectId || null; const projectId = selectedProject?.id || selectedProjectId || null;
const hasFinalCover = !!selectedProject?.coverUrl; const hasFinalCover = !!selectedProject?.coverUrl;
@@ -53,7 +49,7 @@ export const ChooseCoverType = () => {
currentUserData?.firstName, currentUserData?.firstName,
currentUserData?.lastName, currentUserData?.lastName,
currentUserData?.displayName, currentUserData?.displayName,
] ],
); );
useEffect(() => { useEffect(() => {
@@ -73,7 +69,7 @@ export const ChooseCoverType = () => {
setPendingAction(() => nextAction); setPendingAction(() => nextAction);
setChoiceVisible(true); setChoiceVisible(true);
}, },
[hasArtistPreference] [hasArtistPreference],
); );
const runPendingAction = useCallback(async () => { const runPendingAction = useCallback(async () => {
@@ -117,7 +113,7 @@ export const ChooseCoverType = () => {
console.log("update current project userName error", error?.message); console.log("update current project userName error", error?.message);
} }
}, },
[currentUID, projectId] [currentUID, projectId],
); );
// const pickUserImage = useCallback(async () => { // const pickUserImage = useCallback(async () => {
@@ -252,7 +248,7 @@ export const ChooseCoverType = () => {
artistNamePreference: "CUSTOM", artistNamePreference: "CUSTOM",
updatedAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true },
); );
await applyDisplayNameToProjects(value); await applyDisplayNameToProjects(value);
setTooltip({ type: "success", text: "Pseudo enregistré" }); setTooltip({ type: "success", text: "Pseudo enregistré" });
@@ -277,7 +273,7 @@ export const ChooseCoverType = () => {
return ( return (
<Page <Page
// backgroundImg={background.studioBG2} backgroundImg={background.studioBG2}
backgroundColor={"#2A2E33"} backgroundColor={"#2A2E33"}
headerType="NONE" headerType="NONE"
> >
@@ -373,6 +369,9 @@ export const ChooseCoverType = () => {
<Text style={styles.modalDescription}> <Text style={styles.modalDescription}>
Choisis le nom qui sera visible sur tes musiques. Choisis le nom qui sera visible sur tes musiques.
</Text> </Text>
<Text style={styles.modalWarning}>
Attention : tu ne pourras plus le modifier ensuite.
</Text>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<Input <Input
placeholder="Nom d'artiste" placeholder="Nom d'artiste"
@@ -437,6 +436,12 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center", textAlign: "center",
}, },
modalWarning: {
fontSize: 13,
color: Palette.orange,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
},
modalButtons: { modalButtons: {
width: "80%", width: "80%",
alignSelf: "center", alignSelf: "center",
+64 -17
View File
@@ -10,8 +10,7 @@ import {
TextInput, TextInput,
View, View,
} from "react-native"; } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { background, icons } from "../../assets";
import { icons } from "../../assets";
import alert from "../../components/Alert"; import alert from "../../components/Alert";
import AppCheckbox from "../../components/AppCheckbox"; import AppCheckbox from "../../components/AppCheckbox";
import BorderGradientButton from "../../components/BorderGradientButton"; import BorderGradientButton from "../../components/BorderGradientButton";
@@ -20,6 +19,7 @@ import MusicLandHeader from "../../components/MusicLandHeader";
import firebase, { tasksRef } from "../../config/firebase"; import firebase, { tasksRef } from "../../config/firebase";
import loaderMessages from "../../config/loaderMessages"; import loaderMessages from "../../config/loaderMessages";
import { isWeb } from "../../hooks/useLayoutType"; import { isWeb } from "../../hooks/useLayoutType";
import useGlobalLoading from "../../hooks/useGlobalLoading";
import Page from "../../layouts/Page"; import Page from "../../layouts/Page";
import { Routes } from "../../navigation"; import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
@@ -38,7 +38,7 @@ const COVER_STYLE_PRESETS = [
const PouchReady = () => { const PouchReady = () => {
const { selectedProjectId, selectedProject, updateProjectData } = useUser(); const { selectedProjectId, selectedProject, updateProjectData } = useUser();
const { setIsLoading } = useMinuit(); const { setLoading } = useGlobalLoading();
const isGenerating = selectedProject?.coverStatus === "GENERATING"; const isGenerating = selectedProject?.coverStatus === "GENERATING";
const coverOptions = useMemo(() => { const coverOptions = useMemo(() => {
if (!Array.isArray(selectedProject?.cover?.options)) { if (!Array.isArray(selectedProject?.cover?.options)) {
@@ -53,21 +53,37 @@ const PouchReady = () => {
return null; return null;
} }
const found = coverOptions.find( const found = coverOptions.find(
(option) => option?.id === selectedOptionId (option) => option?.id === selectedOptionId,
); );
return found || coverOptions[0] || null; return found || coverOptions[0] || null;
}, [coverOptions, selectedOptionId]); }, [coverOptions, selectedOptionId]);
const coverBackgroundMessage = isWeb const coverBackgroundMessage = isWeb
? loaderMessages.pouchReadyGenerationWeb ? 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 [styleMode, setStyleMode] = useState("preset");
const [selectedPresetStyle, setSelectedPresetStyle] = useState( const [selectedPresetStyle, setSelectedPresetStyle] = useState(
COVER_STYLE_PRESETS[0] COVER_STYLE_PRESETS[0],
); );
const [customStyle, setCustomStyle] = useState(""); const [customStyle, setCustomStyle] = useState("");
const [isPresetDropdownOpen, setIsPresetDropdownOpen] = useState(false); const [isPresetDropdownOpen, setIsPresetDropdownOpen] = useState(false);
const [isSelecting, setIsSelecting] = 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(() => { useEffect(() => {
const projectStyle = (selectedProject?.coverStyle || "").trim(); const projectStyle = (selectedProject?.coverStyle || "").trim();
@@ -106,12 +122,12 @@ const PouchReady = () => {
return; return;
} }
try { try {
await setIsLoading(true); await showGenerationLoading();
await updateProjectData( await updateProjectData(
{ {
coverStyle: trimmedStyle, coverStyle: trimmedStyle,
}, },
{ merge: true } { merge: true },
); );
await tasksRef.add({ await tasksRef.add({
type: "cover", type: "cover",
@@ -121,11 +137,17 @@ const PouchReady = () => {
}); });
} catch (e) { } catch (e) {
console.log("Cover task error", e?.message); console.log("Cover task error", e?.message);
} finally { setIsAwaitingGenerationStart(false);
setIsLoading(false); await setLoading(false);
} }
}, },
[hasGeneratedOptions, selectedProjectId, setIsLoading, updateProjectData] [
hasGeneratedOptions,
selectedProjectId,
setLoading,
showGenerationLoading,
updateProjectData,
],
); );
const requestCoverGeneration = useCallback(() => { const requestCoverGeneration = useCallback(() => {
@@ -196,7 +218,7 @@ const PouchReady = () => {
selectedOptionId, selectedOptionId,
selectedProject?.cover, selectedProject?.cover,
updateProjectData, updateProjectData,
] ],
); );
const onValidatePicture = useCallback(async () => { const onValidatePicture = useCallback(async () => {
@@ -204,7 +226,9 @@ const PouchReady = () => {
return; return;
} }
try { try {
await setIsLoading(true); await setLoading(true, {
message: "Validation de la pochette en cours...",
});
const existingCover = selectedProject?.cover || {}; const existingCover = selectedProject?.cover || {};
const finalUrl = const finalUrl =
selectedOption.finalUrl || selectedOption.generatedUrl || null; selectedOption.finalUrl || selectedOption.generatedUrl || null;
@@ -226,7 +250,7 @@ const PouchReady = () => {
coverUrl: finalUrl, coverUrl: finalUrl,
}; };
const playbackStage = getStageAction("director", projectForStage); const playbackStage = getStageAction("director", projectForStage);
await setIsLoading(false); await setLoading(false);
alert( alert(
"Malik", "Malik",
"Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.", "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); navigate(targetRoute, params);
}, },
}, },
] ],
); );
} catch (e) { } catch (e) {
console.log("PouchReady: unable to validate cover", e?.message); console.log("PouchReady: unable to validate cover", e?.message);
} finally { } finally {
await setIsLoading(false); await setLoading(false);
} }
}, [ }, [
coverOptions, coverOptions,
navigate, navigate,
selectedOption, selectedOption,
selectedProject, selectedProject,
setIsLoading, setLoading,
updateProjectData, updateProjectData,
]); ]);
@@ -272,8 +296,31 @@ const PouchReady = () => {
isGenerating || isGenerating ||
(hasGeneratedOptions ? !selectedOption || isSelecting : true); (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 ( return (
<Page backgroundColor={"#2A2E33"} headerType="NONE"> <Page
backgroundImg={isWeb ? background.productionBG2 : background.studioBG}
headerType="NONE"
>
<MusicLandHeader onPressBack={goBack} progress={72} /> <MusicLandHeader onPressBack={goBack} progress={72} />
<View style={{ flex: 1, marginTop: 0 }}> <View style={{ flex: 1, marginTop: 0 }}>
<CreateLyricsHeader <CreateLyricsHeader