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>
|
<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>
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user