clear lot of tickets
This commit is contained in:
@@ -43,7 +43,7 @@ function basicTemplate({ title = "", content = "", button = null }) {
|
||||
<div style="color:rgba(255,255,255,0.9)!important">${content}</div>
|
||||
${btn}
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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 <musicland@minuit.app>";
|
||||
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);
|
||||
|
||||
+138
-1
@@ -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 || {},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 508 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -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.",
|
||||
|
||||
+170
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ export default ({
|
||||
>
|
||||
<Image
|
||||
source={subscriptionBadgeSource}
|
||||
style={{ height: 40, width: 40, resizeMode: "contain" }}
|
||||
style={{ height: 50, width: 50, resizeMode: "contain" }}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 <LandingPage />;
|
||||
}
|
||||
@@ -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 }) => {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ClubCard image={CLUB_CARD_IMAGE} onPress={handleClubPress} />
|
||||
<ClubCard
|
||||
image={CLUB_CARD_IMAGE}
|
||||
onPress={handleClubPress}
|
||||
hasActiveSubscription={hasActiveSubscription}
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</View>
|
||||
|
||||
@@ -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;
|
||||
@@ -5,22 +5,28 @@ import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||
import { Palette } from "../../../styles";
|
||||
import { icons } from "../../../assets";
|
||||
|
||||
const ClubCard = ({ image, onPress }) => (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<ExpoImage
|
||||
source={icons.club}
|
||||
contentFit="contain"
|
||||
style={styles.clubLogo}
|
||||
/>
|
||||
<ExpoImage source={image} contentFit="contain" style={styles.image} />
|
||||
<Text style={styles.subtitle}>Rejoins le club !</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
|
||||
const subtitle = hasActiveSubscription
|
||||
? "Tu fais déjà partie du club !"
|
||||
: "Rejoins le club !";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<ExpoImage
|
||||
source={icons.club}
|
||||
contentFit="contain"
|
||||
style={styles.clubLogo}
|
||||
/>
|
||||
<ExpoImage source={image} contentFit="contain" style={styles.image} />
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ClubCard);
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
)}
|
||||
</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
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
@@ -212,6 +228,7 @@ const Library = () => {
|
||||
paddingTop: Platform.OS !== "android" ? 20 : 0,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{ flex: 1, position: "relative", zIndex: 5 }}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
|
||||
@@ -221,6 +221,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;
|
||||
@@ -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 }) => {
|
||||
<View style={{ paddingTop: 22 }}>
|
||||
<Slider
|
||||
value={fmt(positionMs)}
|
||||
maxValue={fmt(durationMs)}
|
||||
maxValue={fmt(sliderDurationMs)}
|
||||
progress={
|
||||
durationMs
|
||||
? Math.min(1, Math.max(0, (positionMs || 0) / durationMs))
|
||||
sliderDurationMs
|
||||
? Math.min(1, Math.max(0, (positionMs || 0) / sliderDurationMs))
|
||||
: 0
|
||||
}
|
||||
seekEnabled={!!songUrl}
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
import {
|
||||
Pressable,
|
||||
Image as RNImage,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
createMusicSharePayload,
|
||||
openShareSheet,
|
||||
} from "../../utils/shareSheet";
|
||||
import { goBack } from "../../navigation/NavigationService";
|
||||
|
||||
// 20 secondes
|
||||
const timeBeforeIncrement = 20000;
|
||||
@@ -71,6 +73,29 @@ 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 (
|
||||
<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({
|
||||
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 (
|
||||
<Page
|
||||
headerType="NAVIGATION"
|
||||
hideBackButton={isWeb}
|
||||
topStickyContent={renderWebBackButton}
|
||||
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
|
||||
backgroundImg={
|
||||
action === "userProfile" ? background.profileBG : background.libraryBG2
|
||||
@@ -1078,8 +1137,10 @@ const MusicDetails = ({ route }) => {
|
||||
</View>
|
||||
<Slider
|
||||
value={fmt(positionMs)}
|
||||
maxValue={fmt(durationMs)}
|
||||
progress={durationMs ? (positionMs || 0) / durationMs : 0}
|
||||
maxValue={fmt(sliderDurationMs)}
|
||||
progress={
|
||||
sliderDurationMs ? (positionMs || 0) / sliderDurationMs : 0
|
||||
}
|
||||
seekEnabled={!!songUrl}
|
||||
onSeekStart={handleSliderSeekStart}
|
||||
onSeek={handleSliderSeek}
|
||||
@@ -1191,6 +1252,32 @@ const MusicDetails = ({ route }) => {
|
||||
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,
|
||||
|
||||
@@ -84,11 +84,6 @@ const SearchResultsList = ({
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackThumbnail = thumbnailCandidates.find(isValid);
|
||||
if (fallbackThumbnail) {
|
||||
coverCandidates.push(fallbackThumbnail);
|
||||
}
|
||||
|
||||
return coverCandidates.find(isValid) || null;
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<ExpoImage
|
||||
@@ -597,7 +599,7 @@ export default function Payments() {
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#303438",
|
||||
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||
},
|
||||
page: {
|
||||
backgroundColor: "transparent",
|
||||
|
||||
@@ -30,15 +30,15 @@ const Playback = ({ route }) => {
|
||||
}}
|
||||
>
|
||||
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
|
||||
<GradientButton
|
||||
title="Enregistrer mon Playback"
|
||||
onPress={onPressRecord}
|
||||
/>
|
||||
<BorderGradientButton
|
||||
title="Guide du Playbacker"
|
||||
onPress={() => setShowIntro(true)}
|
||||
/>
|
||||
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
||||
<GradientButton
|
||||
title="Enregistrer mon Playback"
|
||||
onPress={onPressRecord}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<FullscreenIntroVideo
|
||||
|
||||
@@ -15,6 +15,7 @@ import GradientButton from "../../components/GradientButton";
|
||||
import KaraokeLyrics from "../../components/KaraokeLyrics";
|
||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import { increment, projectsRef } from "../../config/firebase";
|
||||
import usePlayer from "../../hooks/usePlayer";
|
||||
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
@@ -35,6 +36,7 @@ const RecordPlayback = ({ route }) => {
|
||||
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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
<Page backgroundImg={background.playbackBG2} headerType="NONE">
|
||||
<MusicLandHeader progress={19} onPressBack={goBack} />
|
||||
@@ -144,6 +200,26 @@ const RecordedPlayback = ({ route }) => {
|
||||
onSeekStart={onSeekStart}
|
||||
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
|
||||
style={{ width: "80%", alignSelf: "center", marginTop: 4, gap: 12 }}
|
||||
@@ -162,8 +238,7 @@ const RecordedPlayback = ({ route }) => {
|
||||
title="Recommencer"
|
||||
onPress={async () => {
|
||||
try {
|
||||
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
||||
if (videoPlayer?.playing) videoPlayer.pause();
|
||||
await stopPlayback();
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (videoUri) {
|
||||
|
||||
@@ -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 }) => {
|
||||
/>
|
||||
<BorderGradientButton
|
||||
title="Recommencer"
|
||||
onPress={() => {
|
||||
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 });
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
<Page headerType="NAVIGATE" title="Mon abonnement" scrollEnabled>
|
||||
<Page
|
||||
headerType="NAVIGATE"
|
||||
title="Mon abonnement"
|
||||
scrollEnabled
|
||||
backgroundColor={PAGE_BACKGROUND_COLOR}
|
||||
backgroundImg={backgroundImage}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{subscriptionInfo.hasAnySubscription ? (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ flex: 1, gap: 10, paddingTop: 16 }}>
|
||||
<CreateLyricsHeader title="Choisis un rythme" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<ItemContainer height={340}>
|
||||
<ItemContainer height={RHYTHM_CONTAINER_HEIGHT}>
|
||||
<ListSelection
|
||||
options={RHYTHM}
|
||||
variant="simple"
|
||||
@@ -34,12 +43,12 @@ export default ChooseRhythm;
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
padding: 8,
|
||||
gap: 10,
|
||||
padding: CONTENT_PADDING,
|
||||
gap: CONTENT_GAP,
|
||||
backgroundColor: "#FFFFFF00",
|
||||
},
|
||||
itemContainer: {
|
||||
height: 54,
|
||||
height: ITEM_HEIGHT,
|
||||
backgroundColor: Palette.glass,
|
||||
borderRadius: 14,
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -264,32 +264,10 @@ const GeneratingSong = () => {
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: responsiveHeight(50),
|
||||
marginTop: responsiveHeight(10),
|
||||
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
|
||||
intensity={Platform.OS !== "ios" ? 10 : 40}
|
||||
tint="dark"
|
||||
|
||||
@@ -36,8 +36,6 @@ const SongReady = () => {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
<Page
|
||||
headerType="NONE"
|
||||
backgroundImg={background.studioBG}
|
||||
backgroundImg={isWeb ? background.productionBG2 : background.studioBG}
|
||||
containerStyle={{ paddingHorizontal: 0 }}
|
||||
contentContainerStyle={{
|
||||
padding: gutters * 2,
|
||||
@@ -117,7 +118,7 @@ const Studio = () => {
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<Page
|
||||
// backgroundImg={background.studioBG2}
|
||||
backgroundImg={background.studioBG2}
|
||||
backgroundColor={"#2A2E33"}
|
||||
headerType="NONE"
|
||||
>
|
||||
@@ -373,6 +369,9 @@ export const ChooseCoverType = () => {
|
||||
<Text style={styles.modalDescription}>
|
||||
Choisis le nom qui sera visible sur tes musiques.
|
||||
</Text>
|
||||
<Text style={styles.modalWarning}>
|
||||
Attention : tu ne pourras plus le modifier ensuite.
|
||||
</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Input
|
||||
placeholder="Nom d'artiste"
|
||||
@@ -437,6 +436,12 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
},
|
||||
modalWarning: {
|
||||
fontSize: 13,
|
||||
color: Palette.orange,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
textAlign: "center",
|
||||
},
|
||||
modalButtons: {
|
||||
width: "80%",
|
||||
alignSelf: "center",
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
||||
import { icons } from "../../assets";
|
||||
import { background, icons } from "../../assets";
|
||||
import alert from "../../components/Alert";
|
||||
import AppCheckbox from "../../components/AppCheckbox";
|
||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||
@@ -20,6 +19,7 @@ import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import firebase, { tasksRef } from "../../config/firebase";
|
||||
import loaderMessages from "../../config/loaderMessages";
|
||||
import { isWeb } from "../../hooks/useLayoutType";
|
||||
import useGlobalLoading from "../../hooks/useGlobalLoading";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
@@ -38,7 +38,7 @@ const COVER_STYLE_PRESETS = [
|
||||
|
||||
const PouchReady = () => {
|
||||
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 (
|
||||
<Page backgroundColor={"#2A2E33"} headerType="NONE">
|
||||
<Page
|
||||
backgroundImg={isWeb ? background.productionBG2 : background.studioBG}
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader onPressBack={goBack} progress={72} />
|
||||
<View style={{ flex: 1, marginTop: 0 }}>
|
||||
<CreateLyricsHeader
|
||||
|
||||
Reference in New Issue
Block a user