more fixes and changes, crash, design …
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
- Hooks: prefix with `use` (e.g., `src/hooks/useFirestorePagination.js`).
|
- Hooks: prefix with `use` (e.g., `src/hooks/useFirestorePagination.js`).
|
||||||
- Utilities: lowerCamelCase modules in `src/utils/` or `helpers/`; prefer named exports.
|
- Utilities: lowerCamelCase modules in `src/utils/` or `helpers/`; prefer named exports.
|
||||||
- Avoid duplicating inline styles; share via `src/styles/`.
|
- Avoid duplicating inline styles; share via `src/styles/`.
|
||||||
|
- Lors du parsing de payloads (API, callbacks, fichiers), utilise uniquement les champs attendus : ne multiplie pas les fallbacks "au cas où". Si la structure exacte t'échappe, demande-la avant d'ajouter du code.
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
|
|||||||
+223
-205
@@ -18,8 +18,6 @@ const {
|
|||||||
SUNO_STATUS_PATH,
|
SUNO_STATUS_PATH,
|
||||||
} = require("../config/suno");
|
} = require("../config/suno");
|
||||||
|
|
||||||
const MAX_STORED_MUSIC_TRACKS = 4;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marque un projet comme échoué suite à une erreur Suno
|
* Marque un projet comme échoué suite à une erreur Suno
|
||||||
* @param {string} projectId - Identifiant du projet
|
* @param {string} projectId - Identifiant du projet
|
||||||
@@ -55,15 +53,9 @@ async function markProjectMusicFailure(projectId, error) {
|
|||||||
{ merge: true },
|
{ merge: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const receiverId =
|
const receiverId = sanitizeField(projectData?.userId);
|
||||||
typeof projectData?.userId === "string" && projectData.userId.trim()
|
|
||||||
? projectData.userId.trim()
|
|
||||||
: null;
|
|
||||||
if (receiverId) {
|
if (receiverId) {
|
||||||
const projectTitle =
|
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
||||||
typeof projectData?.title === "string" && projectData.title.trim()
|
|
||||||
? projectData.title.trim()
|
|
||||||
: "ton projet";
|
|
||||||
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
|
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
|
||||||
try {
|
try {
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
@@ -205,6 +197,161 @@ function detectVocalGender(voiceInput = "") {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeField = (value, fallback = null) => {
|
||||||
|
const cleaned = typeof value === "string" ? value.trim() : value;
|
||||||
|
if (typeof cleaned !== "string" || !cleaned) return fallback;
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeMusicUrls = (urls = []) =>
|
||||||
|
(Array.isArray(urls) ? urls : [])
|
||||||
|
.filter((url) => typeof url === "string" && url.trim())
|
||||||
|
.map((url) => url.trim());
|
||||||
|
|
||||||
|
const formatProjectMeta = (projectData = {}) => {
|
||||||
|
const userId = sanitizeField(projectData?.userId);
|
||||||
|
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
||||||
|
return { userId, projectTitle };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSunoCallbackPayload = (rawBody = {}) => {
|
||||||
|
const body = rawBody || {};
|
||||||
|
const code = body.code ?? body.statusCode ?? null;
|
||||||
|
const callbackType = (body?.data?.callbackType || "")
|
||||||
|
.toString()
|
||||||
|
.toLowerCase();
|
||||||
|
const status = (body.status || body.state || callbackType || "")
|
||||||
|
.toString()
|
||||||
|
.toLowerCase();
|
||||||
|
const taskId = sanitizeField(body?.data?.task_id);
|
||||||
|
const tracks = Array.isArray(body?.data?.data)
|
||||||
|
? body.data.data
|
||||||
|
: Array.isArray(body.data)
|
||||||
|
? body.data
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { code, status, taskId, tracks };
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractAudioUrlsFromTracks = (tracks = []) =>
|
||||||
|
tracks
|
||||||
|
.map(
|
||||||
|
(t) =>
|
||||||
|
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
const fetchProjectByTaskId = async (taskId) => {
|
||||||
|
const snapshot = await refList.projects
|
||||||
|
.where("sunoTaskId", "==", taskId)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (snapshot.empty) {
|
||||||
|
throw new Error("PROJECT_NOT_FOUND_FOR_TASK");
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = snapshot.docs[0];
|
||||||
|
return {
|
||||||
|
projectId: doc.id,
|
||||||
|
projectData: doc.data() || {},
|
||||||
|
projectRef: doc.ref,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTrackToStorage = async (
|
||||||
|
url,
|
||||||
|
{ userId, projectId, taskId, bucket, index },
|
||||||
|
) => {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
||||||
|
const resp = await axios.get(url, { responseType: "arraybuffer" });
|
||||||
|
const buffer = Buffer.from(resp.data);
|
||||||
|
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
||||||
|
const token = require("crypto").randomUUID();
|
||||||
|
const file = bucket.file(path);
|
||||||
|
await file.save(buffer, {
|
||||||
|
resumable: false,
|
||||||
|
metadata: {
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
cacheControl: "public, max-age=31536000",
|
||||||
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||||
|
path,
|
||||||
|
)}?alt=media&token=${token}`;
|
||||||
|
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
|
||||||
|
return { path, url: downloadUrl };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTracksToStorage = async (audioUrls, meta) => {
|
||||||
|
if (!audioUrls?.length) return [];
|
||||||
|
const bucket = admin.storage().bucket();
|
||||||
|
console.log("🪣 [SunoCallback] Bucket:", bucket.name);
|
||||||
|
const context = { ...meta, bucket };
|
||||||
|
const results = await Promise.all(
|
||||||
|
audioUrls.map((url, index) =>
|
||||||
|
downloadTrackToStorage(url, { ...context, index }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return results.filter(Boolean).map((entry) => entry.url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeMusicUrls = async (projectRef, newUrls) => {
|
||||||
|
const sanitizedNewUrls = sanitizeMusicUrls(newUrls);
|
||||||
|
console.log("➕ [SunoCallback] Nouveaux morceaux à ajouter:", {
|
||||||
|
count: sanitizedNewUrls.length,
|
||||||
|
urls: sanitizedNewUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
let existingUrls = [];
|
||||||
|
try {
|
||||||
|
const snapshot = await projectRef.get();
|
||||||
|
existingUrls = sanitizeMusicUrls(snapshot?.data()?.musicUrls);
|
||||||
|
console.log("📦 [SunoCallback] Morceaux déjà stockés:", {
|
||||||
|
count: existingUrls.length,
|
||||||
|
urls: existingUrls,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUrls = [...existingUrls, ...sanitizedNewUrls];
|
||||||
|
const dedupedUrls = allUrls.filter(
|
||||||
|
(url, index) => allUrls.indexOf(url) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("🎶 [SunoCallback] Morceaux conservés après fusion:", {
|
||||||
|
count: dedupedUrls.length,
|
||||||
|
urls: dedupedUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
await projectRef.set(
|
||||||
|
{
|
||||||
|
musicStatus: "GENERATED",
|
||||||
|
musicUrls: dedupedUrls,
|
||||||
|
musicError: FieldValue.delete(),
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return dedupedUrls;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit le style musical à partir des paramètres
|
* Construit le style musical à partir des paramètres
|
||||||
* @param {Object} params - Les paramètres de style
|
* @param {Object} params - Les paramètres de style
|
||||||
@@ -535,232 +682,103 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
|||||||
* de musique est terminée
|
* de musique est terminée
|
||||||
*/
|
*/
|
||||||
exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||||
try {
|
if (req.method !== "POST") {
|
||||||
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
|
||||||
|
return res.status(405).json({ error: "Méthode non autorisée" });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
||||||
console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method);
|
|
||||||
return res.status(405).json({ error: "Méthode non autorisée" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = req.body || {};
|
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
||||||
const code = body.code ?? body.statusCode ?? null;
|
console.log("🎯 [SunoCallback] Détails:", {
|
||||||
const callbackType = (body?.data?.callbackType || "")
|
code,
|
||||||
.toString()
|
status,
|
||||||
.toLowerCase();
|
taskId,
|
||||||
const status = (body.status || body.state || callbackType)
|
count: tracks.length,
|
||||||
.toString()
|
});
|
||||||
.toLowerCase();
|
|
||||||
const taskId =
|
|
||||||
body.taskId ||
|
|
||||||
body.task_id ||
|
|
||||||
body?.data?.taskId ||
|
|
||||||
body?.data?.task_id ||
|
|
||||||
null;
|
|
||||||
const tracks = Array.isArray(body?.data?.data)
|
|
||||||
? body.data.data
|
|
||||||
: Array.isArray(body.data)
|
|
||||||
? body.data
|
|
||||||
: [];
|
|
||||||
|
|
||||||
console.log("🎯 [SunoCallback] Détails:", {
|
if (code !== 200 || status !== "complete") {
|
||||||
|
console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
|
||||||
code,
|
code,
|
||||||
status,
|
status,
|
||||||
callbackType,
|
|
||||||
taskId,
|
|
||||||
count: tracks.length,
|
|
||||||
});
|
});
|
||||||
|
return res.status(200).json({ success: true, ignored: true });
|
||||||
|
}
|
||||||
|
|
||||||
// On n'agit que si code === 200 et status === "complete"
|
if (!taskId) {
|
||||||
if (code !== 200 || status !== "complete") {
|
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
|
||||||
console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", {
|
return res.status(200).json({ success: true, ignored: true });
|
||||||
code,
|
}
|
||||||
status,
|
|
||||||
});
|
|
||||||
return res.status(200).json({ success: true, ignored: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!taskId) {
|
try {
|
||||||
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
|
const { projectId, projectData, projectRef } =
|
||||||
return res.status(200).json({ success: true, ignored: true });
|
await fetchProjectByTaskId(taskId);
|
||||||
}
|
const { userId, projectTitle } = formatProjectMeta(projectData);
|
||||||
|
|
||||||
// 1) Récupérer le projectId associé au taskId
|
|
||||||
let projectId = null;
|
|
||||||
const projSnap = await refList.projects
|
|
||||||
.where("sunoTaskId", "==", taskId)
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
if (projSnap.empty) {
|
|
||||||
throw new Error("Aucun projet trouvé pour ce taskId");
|
|
||||||
}
|
|
||||||
const projectDoc = projSnap.docs[0];
|
|
||||||
const projectData = projectDoc?.data() || {};
|
|
||||||
projectId = projectDoc.id;
|
|
||||||
const userId =
|
|
||||||
typeof projectData.userId === "string" && projectData.userId.trim()
|
|
||||||
? projectData.userId.trim()
|
|
||||||
: null;
|
|
||||||
const projectTitle =
|
|
||||||
typeof projectData.title === "string" && projectData.title.trim()
|
|
||||||
? projectData.title.trim()
|
|
||||||
: "ton projet";
|
|
||||||
|
|
||||||
if (!projectId) {
|
|
||||||
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
|
|
||||||
return res.status(200).json({ success: true, ignored: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Extraire jusqu'à 2 URLs audio
|
|
||||||
const audioUrls = tracks
|
|
||||||
.map(
|
|
||||||
(t) =>
|
|
||||||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
|
|
||||||
)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2);
|
|
||||||
|
|
||||||
|
const audioUrls = extractAudioUrlsFromTracks(tracks);
|
||||||
if (audioUrls.length < 2) {
|
if (audioUrls.length < 2) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
||||||
{
|
{
|
||||||
found: audioUrls.length,
|
found: audioUrls.length,
|
||||||
tracksSample: tracks.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
has_audio_url: !!t.audio_url,
|
|
||||||
has_stream_audio_url: !!t.stream_audio_url,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Télécharger et sauvegarder dans Cloud Storage + récupérer download URLs
|
const storedUrls = await saveTracksToStorage(audioUrls, {
|
||||||
const bucket = admin.storage().bucket();
|
userId,
|
||||||
console.log("🪣 [SunoCallback] Bucket:", bucket.name);
|
projectId,
|
||||||
const saveOne = async (url, index) => {
|
taskId,
|
||||||
if (!url) return null;
|
});
|
||||||
|
|
||||||
|
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
||||||
|
|
||||||
|
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||||||
|
projectId,
|
||||||
|
musicUrlsCount: musicUrls.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const successMessage =
|
||||||
|
musicUrls.length > 0
|
||||||
|
? `Ta musique pour "${projectTitle}" est prête.`
|
||||||
|
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
||||||
try {
|
try {
|
||||||
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
await sendNotification({
|
||||||
const resp = await axios.get(url, { responseType: "arraybuffer" });
|
sender: "SYSTEM",
|
||||||
const buffer = Buffer.from(resp.data);
|
receiver: userId,
|
||||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
receiverCollection: "users",
|
||||||
const token = require("crypto").randomUUID();
|
title: "Musique prête",
|
||||||
const file = bucket.file(path);
|
message: successMessage,
|
||||||
await file.save(buffer, {
|
data: {
|
||||||
resumable: false,
|
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||||
metadata: {
|
projectId,
|
||||||
contentType: "audio/mpeg",
|
projectTitle,
|
||||||
cacheControl: "public, max-age=31536000",
|
musicUrls,
|
||||||
metadata: { firebaseStorageDownloadTokens: token },
|
taskId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
} catch (notifError) {
|
||||||
path,
|
|
||||||
)}?alt=media&token=${token}`;
|
|
||||||
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
|
|
||||||
return { path, url: downloadUrl };
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
"[sunoCallback] Failed to send success notification:",
|
||||||
e.message,
|
notifError,
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [p1, p2] = await Promise.all([
|
|
||||||
saveOne(audioUrls[0], 0),
|
|
||||||
saveOne(audioUrls[1], 1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 4) Mettre à jour le statut du projet
|
|
||||||
try {
|
|
||||||
const newMusicUrls = [p1?.url, p2?.url]
|
|
||||||
.filter((url) => typeof url === "string" && url.trim())
|
|
||||||
.map((url) => url.trim());
|
|
||||||
|
|
||||||
const projectRef = refList.projects.doc(projectId);
|
|
||||||
let existingMusicUrls = [];
|
|
||||||
try {
|
|
||||||
const projectSnap = await projectRef.get();
|
|
||||||
const projectData = projectSnap?.data() || {};
|
|
||||||
if (Array.isArray(projectData.musicUrls)) {
|
|
||||||
existingMusicUrls = projectData.musicUrls
|
|
||||||
.filter((url) => typeof url === "string" && url.trim())
|
|
||||||
.map((url) => url.trim());
|
|
||||||
}
|
|
||||||
} catch (readError) {
|
|
||||||
console.error(
|
|
||||||
"⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:",
|
|
||||||
readError,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedMusicUrls = [...existingMusicUrls, ...newMusicUrls];
|
|
||||||
const uniqueMusicUrls = mergedMusicUrls.filter(
|
|
||||||
(url, index) => mergedMusicUrls.indexOf(url) === index,
|
|
||||||
);
|
|
||||||
const musicUrls =
|
|
||||||
uniqueMusicUrls.length > MAX_STORED_MUSIC_TRACKS
|
|
||||||
? uniqueMusicUrls.slice(
|
|
||||||
uniqueMusicUrls.length - MAX_STORED_MUSIC_TRACKS,
|
|
||||||
)
|
|
||||||
: uniqueMusicUrls;
|
|
||||||
|
|
||||||
await projectRef.set(
|
|
||||||
{
|
|
||||||
musicStatus: "GENERATED",
|
|
||||||
musicUrls,
|
|
||||||
musicError: FieldValue.delete(),
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
|
||||||
},
|
|
||||||
{ merge: true },
|
|
||||||
);
|
|
||||||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
|
||||||
projectId,
|
|
||||||
musicUrlsCount: musicUrls.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
const successMessage =
|
|
||||||
musicUrls.length > 0
|
|
||||||
? `Ta musique pour "${projectTitle}" est prête.`
|
|
||||||
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
|
||||||
try {
|
|
||||||
await sendNotification({
|
|
||||||
sender: "SYSTEM",
|
|
||||||
receiver: userId,
|
|
||||||
receiverCollection: "users",
|
|
||||||
title: "Musique prête",
|
|
||||||
message: successMessage,
|
|
||||||
data: {
|
|
||||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
|
||||||
projectId,
|
|
||||||
projectTitle,
|
|
||||||
musicUrls,
|
|
||||||
taskId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (notifError) {
|
|
||||||
console.error(
|
|
||||||
"[sunoCallback] Failed to send success notification:",
|
|
||||||
notifError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
projectId,
|
projectId,
|
||||||
saved: [p1, p2].filter(Boolean),
|
savedCount: storedUrls.length,
|
||||||
|
musicUrlsCount: musicUrls.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const statusCode =
|
||||||
|
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
|
||||||
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
||||||
res
|
return res.status(statusCode).json({
|
||||||
.status(500)
|
success: false,
|
||||||
.json({ error: "Erreur interne du serveur", message: error.message });
|
error: error?.message || "Erreur interne du serveur",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Pressable } from "react-native";
|
import { Pressable, Text } from "react-native";
|
||||||
import { Entypo } from "@expo/vector-icons";
|
import { Entypo } from "@expo/vector-icons";
|
||||||
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import { openShareSheet } from "../../utils/shareSheet";
|
import { openShareSheet } from "../../utils/shareSheet";
|
||||||
|
|
||||||
export default function ShareBtn({ style, onPress = null }) {
|
export default function ShareBtn({
|
||||||
|
style,
|
||||||
|
onPress = null,
|
||||||
|
label = null,
|
||||||
|
iconOnly = false,
|
||||||
|
}) {
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (typeof onPress === "function") {
|
if (typeof onPress === "function") {
|
||||||
onPress();
|
onPress();
|
||||||
@@ -25,14 +31,33 @@ export default function ShareBtn({ style, onPress = null }) {
|
|||||||
intensity={20}
|
intensity={20}
|
||||||
tint="dark"
|
tint="dark"
|
||||||
style={{
|
style={{
|
||||||
padding: 12,
|
padding: iconOnly ? 10 : label ? 10 : 12,
|
||||||
|
paddingHorizontal: iconOnly ? 10 : label ? 16 : 12,
|
||||||
borderRadius: 15,
|
borderRadius: 15,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
borderWidth: iconOnly ? 1 : 0,
|
||||||
|
borderColor: iconOnly ? "rgba(255, 255, 255, 0.1)" : "transparent",
|
||||||
|
backgroundColor: iconOnly
|
||||||
|
? "rgba(12, 14, 18, 0.72)"
|
||||||
|
: "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{label && !iconOnly ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#ffffff",
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Entypo name="share-alternative" size={18} color="white" />
|
<Entypo name="share-alternative" size={18} color="white" />
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const Routes = {
|
|||||||
|
|
||||||
Create: "Create",
|
Create: "Create",
|
||||||
HitParade: "HitParade",
|
HitParade: "HitParade",
|
||||||
Playbacks: "Playback",
|
Playbacks: "Playbacks",
|
||||||
|
|
||||||
Library: "Library",
|
Library: "Library",
|
||||||
AllMyPlaylist: "AllMyPlaylist",
|
AllMyPlaylist: "AllMyPlaylist",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Linking, StyleSheet, View } from "react-native";
|
import { Linking, Platform, StyleSheet, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
EmbeddedCheckout,
|
EmbeddedCheckout,
|
||||||
EmbeddedCheckoutProvider,
|
EmbeddedCheckoutProvider,
|
||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
|
import * as WebBrowser from "expo-web-browser";
|
||||||
|
|
||||||
import Overlay from "../components/Overlay";
|
import Overlay from "../components/Overlay";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
@@ -80,6 +81,26 @@ const StripeProvider = ({ children }) => {
|
|||||||
throw new Error("Navigation Stripe impossible dans cet environnement.");
|
throw new Error("Navigation Stripe impossible dans cet environnement.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobileApp =
|
||||||
|
Platform.OS === "ios" || Platform.OS === "android";
|
||||||
|
|
||||||
|
if (isMobileApp) {
|
||||||
|
try {
|
||||||
|
await WebBrowser.openBrowserAsync(checkoutUrl, {
|
||||||
|
enableDefaultShareMenu: false,
|
||||||
|
dismissButtonStyle: "close",
|
||||||
|
presentationStyle:
|
||||||
|
WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (webBrowserError) {
|
||||||
|
console.warn(
|
||||||
|
"[StripeProvider] WebBrowser checkout fallback",
|
||||||
|
webBrowserError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canOpen = await Linking.canOpenURL(checkoutUrl);
|
const canOpen = await Linking.canOpenURL(checkoutUrl);
|
||||||
if (!canOpen) {
|
if (!canOpen) {
|
||||||
throw new Error("Impossible d'ouvrir l'URL de paiement.");
|
throw new Error("Impossible d'ouvrir l'URL de paiement.");
|
||||||
|
|||||||
+167
-70
@@ -5,8 +5,8 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
||||||
import { background, cardsImg, icons } from "../../assets";
|
import { background, cardsImg, icons } from "../../assets";
|
||||||
import Alert from "../../components/Alert";
|
import Alert from "../../components/Alert";
|
||||||
@@ -16,6 +16,7 @@ import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
|||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MoreMenu from "../../components/MoreMenu";
|
import MoreMenu from "../../components/MoreMenu";
|
||||||
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
|
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
|
||||||
|
import CoinPackModal from "../../components/modal/CoinPackModal";
|
||||||
import { projectsRef, usersRef } from "../../config/firebase";
|
import { projectsRef, usersRef } from "../../config/firebase";
|
||||||
import { isWeb } from "../../hooks/useLayoutType.js";
|
import { isWeb } from "../../hooks/useLayoutType.js";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
@@ -23,12 +24,15 @@ import LandingPage from "../LandingPage";
|
|||||||
import { navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
import { Routes } from "../../navigation/Routes";
|
import { Routes } from "../../navigation/Routes";
|
||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
|
import CreditAmount from "../../components/CreditAmount";
|
||||||
|
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||||
import { Palette, gutters } from "../../styles";
|
import { Palette, gutters } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import {
|
import {
|
||||||
getCreationStageStates,
|
getCreationStageStates,
|
||||||
getStageAction,
|
getStageAction,
|
||||||
} from "../../utils/projectStages";
|
} from "../../utils/projectStages";
|
||||||
|
import { openCoinPackModal } from "../../utils/coinPackModal";
|
||||||
import StageCard from "./components/StageCard";
|
import StageCard from "./components/StageCard";
|
||||||
import ClubCard from "./components/ClubCard";
|
import ClubCard from "./components/ClubCard";
|
||||||
|
|
||||||
@@ -424,6 +428,8 @@ const Home = ({ navigation, route }) => {
|
|||||||
[stageStatesByKey],
|
[stageStatesByKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isCoinModalVisible, setCoinModalVisible] = useState(false);
|
||||||
|
|
||||||
const handleStagePress = useCallback(
|
const handleStagePress = useCallback(
|
||||||
(stageKey, isLocked) => {
|
(stageKey, isLocked) => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
@@ -451,6 +457,18 @@ const Home = ({ navigation, route }) => {
|
|||||||
navigate(Routes.Payments);
|
navigate(Routes.Payments);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenCoinModal = useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
openCoinPackModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCoinModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseCoinModal = useCallback(() => {
|
||||||
|
setCoinModalVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack;
|
const stageCardContainerStyle = isWeb ? styles.cardsGrid : styles.cardsStack;
|
||||||
const stageCardItemStyle = isWeb
|
const stageCardItemStyle = isWeb
|
||||||
? styles.webStageCard
|
? styles.webStageCard
|
||||||
@@ -476,8 +494,45 @@ const Home = ({ navigation, route }) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const coinBalance = useMemo(() => {
|
||||||
|
const value = currentUserData?.coins;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [currentUserData?.coins]);
|
||||||
|
|
||||||
|
const mobileInfoRow = !isWeb ? (
|
||||||
|
<View style={styles.mobileInfoRow}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleOpenCoinModal}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={styles.mobileCoinButton}
|
||||||
|
>
|
||||||
|
<CreditAmount
|
||||||
|
value={coinBalance}
|
||||||
|
style={styles.mobileCoinAmount}
|
||||||
|
textStyle={styles.mobileCoinText}
|
||||||
|
iconSize={20}
|
||||||
|
iconPosition="left"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<ShareBtn
|
||||||
|
style={styles.mobileShareButton}
|
||||||
|
iconOnly
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const topBar = (
|
const topBar = (
|
||||||
<View style={styles.topBar}>
|
<View style={styles.topBar}>
|
||||||
|
{mobileInfoRow}
|
||||||
<ProjectDropDown
|
<ProjectDropDown
|
||||||
style={styles.projectDropDown}
|
style={styles.projectDropDown}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
@@ -542,72 +597,82 @@ const Home = ({ navigation, route }) => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return adventureStarted ? (
|
return (
|
||||||
<View style={styles.root}>
|
|
||||||
<Page
|
|
||||||
shareBtn
|
|
||||||
headerType="NONE"
|
|
||||||
scrollEnabled={false}
|
|
||||||
containerStyle={styles.page}
|
|
||||||
contentContainerStyle={styles.pageContent}
|
|
||||||
maxWidth={1600}
|
|
||||||
width="100%"
|
|
||||||
backgroundColor="#303438"
|
|
||||||
>
|
|
||||||
<View style={styles.inner}>
|
|
||||||
<ExpoImage
|
|
||||||
source={homeBackgroundImage}
|
|
||||||
contentFit="cover"
|
|
||||||
style={styles.centerImage}
|
|
||||||
/>
|
|
||||||
{isWeb ? (
|
|
||||||
<View style={styles.contentOverlay}>
|
|
||||||
{topBar}
|
|
||||||
{journeyContent}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.contentOverlay, styles.mobileContent]}>
|
|
||||||
{topBar}
|
|
||||||
<ScrollView
|
|
||||||
style={styles.mobileScrollView}
|
|
||||||
contentContainerStyle={styles.mobileScrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{journeyContent}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Page>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<Page
|
{adventureStarted ? (
|
||||||
shareBtn
|
<View style={styles.root}>
|
||||||
title="Landing Page"
|
<Page
|
||||||
backgroundImg={landingBackgroundImage}
|
shareBtn
|
||||||
contentContainerStyle={styles.pageContent}
|
headerType="NONE"
|
||||||
backgroundColor="#303438"
|
scrollEnabled={false}
|
||||||
>
|
containerStyle={styles.page}
|
||||||
<View
|
contentContainerStyle={styles.pageContent}
|
||||||
style={{
|
maxWidth={1600}
|
||||||
flex: 1,
|
width="100%"
|
||||||
justifyContent: "center",
|
backgroundColor="#303438"
|
||||||
alignItems: "center",
|
>
|
||||||
}}
|
<View style={styles.inner}>
|
||||||
>
|
<ExpoImage
|
||||||
<GradientButton
|
source={homeBackgroundImage}
|
||||||
url={videoUrl}
|
contentFit="cover"
|
||||||
title="Commencer l'aventure MusicLand"
|
style={styles.centerImage}
|
||||||
onPress={handleStartVisit}
|
/>
|
||||||
/>
|
{isWeb ? (
|
||||||
|
<View style={styles.contentOverlay}>
|
||||||
|
{topBar}
|
||||||
|
{journeyContent}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.contentOverlay, styles.mobileContent]}>
|
||||||
|
{topBar}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.mobileScrollView}
|
||||||
|
contentContainerStyle={styles.mobileScrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{journeyContent}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
) : (
|
||||||
<FullscreenIntroVideo
|
<>
|
||||||
url={videoUrl}
|
<Page
|
||||||
visible={isIntroVideoVisible}
|
shareBtn
|
||||||
onClose={handleIntroVideoClose}
|
title="Landing Page"
|
||||||
/>
|
backgroundImg={landingBackgroundImage}
|
||||||
|
contentContainerStyle={styles.pageContent}
|
||||||
|
backgroundColor="#303438"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GradientButton
|
||||||
|
url={videoUrl}
|
||||||
|
title="Commencer l'aventure MusicLand"
|
||||||
|
onPress={handleStartVisit}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
<FullscreenIntroVideo
|
||||||
|
url={videoUrl}
|
||||||
|
visible={isIntroVideoVisible}
|
||||||
|
onClose={handleIntroVideoClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isWeb ? (
|
||||||
|
<CoinPackModal
|
||||||
|
visible={isCoinModalVisible}
|
||||||
|
onClose={handleCloseCoinModal}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -659,10 +724,10 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
topBar: {
|
topBar: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
flexDirection: "row",
|
flexDirection: isWeb ? "row" : "column",
|
||||||
flexWrap: "wrap",
|
flexWrap: isWeb ? "wrap" : "nowrap",
|
||||||
alignItems: "center",
|
alignItems: isWeb ? "center" : "stretch",
|
||||||
justifyContent: "center",
|
justifyContent: isWeb ? "center" : "flex-start",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
marginTop: isWeb ? 24 : 0,
|
marginTop: isWeb ? 24 : 0,
|
||||||
marginBottom: isWeb ? 8 : 16,
|
marginBottom: isWeb ? 8 : 16,
|
||||||
@@ -674,6 +739,38 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
|
mobileInfoRow: {
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
mobileCoinButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(12, 14, 18, 0.72)",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
mobileCoinAmount: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
mobileCoinText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
mobileShareButton: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
mobileContent: {
|
mobileContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -684,7 +781,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
mobileScrollContent: {
|
mobileScrollContent: {
|
||||||
paddingHorizontal: gutters,
|
paddingHorizontal: gutters,
|
||||||
paddingTop: 24,
|
paddingTop: 0,
|
||||||
paddingBottom: gutters * 6,
|
paddingBottom: gutters * 6,
|
||||||
},
|
},
|
||||||
subtitleWrapper: {
|
subtitleWrapper: {
|
||||||
|
|||||||
+296
-93
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
import { useRoute } from "@react-navigation/native";
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import GradientButton from "../components/GradientButton";
|
import GradientButton from "../components/GradientButton";
|
||||||
import CreditAmount from "../components/CreditAmount";
|
import CreditAmount from "../components/CreditAmount";
|
||||||
import Page from "../layouts/Page";
|
import Page from "../layouts/Page";
|
||||||
@@ -279,9 +281,14 @@ 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";
|
const PAGE_BACKGROUND_COLOR = "#303438";
|
||||||
|
const SUBSCRIPTION_DISCLAIMER =
|
||||||
|
"Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation).";
|
||||||
|
const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
|
||||||
|
|
||||||
export default function Payments() {
|
export default function Payments() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const isMobile = !isWeb;
|
||||||
const initialPack = React.useMemo(() => {
|
const initialPack = React.useMemo(() => {
|
||||||
const rawPack = route?.params?.pack;
|
const rawPack = route?.params?.pack;
|
||||||
return typeof rawPack === "string" ? rawPack.toLowerCase() : null;
|
return typeof rawPack === "string" ? rawPack.toLowerCase() : null;
|
||||||
@@ -290,7 +297,7 @@ export default function Payments() {
|
|||||||
const initialSubscriptionPack =
|
const initialSubscriptionPack =
|
||||||
initialPack && initialPack !== "packs" ? initialPack : null;
|
initialPack && initialPack !== "packs" ? initialPack : null;
|
||||||
|
|
||||||
const backgroundImage = background.bgTrans;
|
const backgroundImage = isMobile ? background.homeBG : background.bgTrans;
|
||||||
const {
|
const {
|
||||||
subscriptions,
|
subscriptions,
|
||||||
isCatalogLoading,
|
isCatalogLoading,
|
||||||
@@ -458,6 +465,144 @@ export default function Payments() {
|
|||||||
const actionButtonTitle = isProcessing
|
const actionButtonTitle = isProcessing
|
||||||
? "Redirection..."
|
? "Redirection..."
|
||||||
: "Choisir cet abonnement";
|
: "Choisir cet abonnement";
|
||||||
|
const currentPlanCount = currentPlans.length;
|
||||||
|
const mobileActionSafePadding = React.useMemo(
|
||||||
|
() => Math.max(insets.bottom, gutters),
|
||||||
|
[insets.bottom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mobileListContentInset = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
paddingBottom: mobileActionSafePadding + gutters * 3,
|
||||||
|
}),
|
||||||
|
[mobileActionSafePadding],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderHeaderSection = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
Choisissez l’abonnement qui vous correspond
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{combinedErrorMessage ? (
|
||||||
|
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [combinedErrorMessage]);
|
||||||
|
|
||||||
|
const renderSegmentedControl = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.segmentedControl,
|
||||||
|
isMobile && styles.segmentedControlMobile,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{PLAN_SEGMENTS.map(({ key, label }) => {
|
||||||
|
const isActive = billingPeriod === key;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={() => setBillingPeriod(key)}
|
||||||
|
style={[
|
||||||
|
styles.segmentButton,
|
||||||
|
isActive && styles.segmentButtonActive,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: isActive }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.segmentLabel,
|
||||||
|
isActive && styles.segmentLabelActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [billingPeriod, isMobile]);
|
||||||
|
|
||||||
|
const renderMobilePlanItem = React.useCallback(
|
||||||
|
({ item }) => (
|
||||||
|
<View style={styles.mobileCard}>
|
||||||
|
<SubscriptionCard
|
||||||
|
plan={item}
|
||||||
|
selected={selectedPriceId === item.priceId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleSelect, selectedPriceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMobileEmptyComponent = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View style={styles.mobileEmptyWrapper}>
|
||||||
|
{isLoadingPlans ? (
|
||||||
|
<ActivityIndicator color={Palette.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.emptyState}>
|
||||||
|
Aucun abonnement Stripe disponible pour le moment.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [isLoadingPlans]);
|
||||||
|
|
||||||
|
const renderMobileFooterComponent = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View style={styles.mobileFooter}>
|
||||||
|
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||||
|
<View style={styles.inlineLoader}>
|
||||||
|
<ActivityIndicator color={Palette.white} size="small" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [currentPlanCount, isLoadingPlans]);
|
||||||
|
|
||||||
|
const renderMobileTopSticky = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View style={styles.mobileStickyHeader}>
|
||||||
|
{renderHeaderSection()}
|
||||||
|
{renderSegmentedControl()}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [renderHeaderSection, renderSegmentedControl]);
|
||||||
|
|
||||||
|
const renderMobileBottomActions = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.mobileActions,
|
||||||
|
{ paddingBottom: mobileActionSafePadding },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<GradientButton
|
||||||
|
title={actionButtonTitle}
|
||||||
|
onPress={handleCheckout}
|
||||||
|
disabled={isActionDisabled}
|
||||||
|
gradientStyle={[
|
||||||
|
styles.actionButtonGradient,
|
||||||
|
styles.mobileActionButtonGradient,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
actionButtonTitle,
|
||||||
|
handleCheckout,
|
||||||
|
isActionDisabled,
|
||||||
|
mobileActionSafePadding,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
@@ -467,100 +612,88 @@ export default function Payments() {
|
|||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
width={isWeb ? 960 : undefined}
|
width={isWeb ? 960 : undefined}
|
||||||
containerStyle={styles.page}
|
containerStyle={styles.page}
|
||||||
contentContainerStyle={styles.pageContent}
|
contentContainerStyle={[
|
||||||
|
styles.pageContent,
|
||||||
|
isMobile && styles.pageContentMobile,
|
||||||
|
]}
|
||||||
backgroundColor={PAGE_BACKGROUND_COLOR}
|
backgroundColor={PAGE_BACKGROUND_COLOR}
|
||||||
|
topStickyContent={isMobile ? renderMobileTopSticky : undefined}
|
||||||
>
|
>
|
||||||
<View style={styles.inner}>
|
<View style={[styles.inner, isMobile && styles.mobileInner]}>
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={backgroundImage}
|
source={backgroundImage}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={styles.centerImage}
|
style={styles.centerImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={[styles.content, isMobile && styles.mobileContent]}>
|
||||||
<View style={styles.header}>
|
{isMobile ? (
|
||||||
<Text style={styles.title}>
|
<FlatList
|
||||||
Choisissez l’abonnement qui vous correspond
|
data={currentPlans}
|
||||||
</Text>
|
keyExtractor={(plan) => plan.priceId}
|
||||||
</View>
|
renderItem={renderMobilePlanItem}
|
||||||
|
style={styles.mobileList}
|
||||||
{combinedErrorMessage ? (
|
contentContainerStyle={[
|
||||||
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
|
styles.mobileListContent,
|
||||||
) : null}
|
mobileListContentInset,
|
||||||
|
]}
|
||||||
{isLoadingPlans && !currentPlans.length ? (
|
ListEmptyComponent={renderMobileEmptyComponent}
|
||||||
<View style={styles.loaderContainer}>
|
ListFooterComponent={renderMobileFooterComponent}
|
||||||
<ActivityIndicator color={Palette.white} />
|
showsVerticalScrollIndicator={false}
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoadingPlans && currentPlans.length === 0 ? (
|
|
||||||
<Text style={styles.emptyState}>
|
|
||||||
Aucun abonnement Stripe disponible pour le moment.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.segmentedControl}>
|
|
||||||
{PLAN_SEGMENTS.map(({ key, label }) => {
|
|
||||||
const isActive = billingPeriod === key;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={key}
|
|
||||||
onPress={() => setBillingPeriod(key)}
|
|
||||||
style={[
|
|
||||||
styles.segmentButton,
|
|
||||||
isActive && styles.segmentButtonActive,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={{ selected: isActive }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.segmentLabel,
|
|
||||||
isActive && styles.segmentLabelActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.packs, isWeb && styles.packsWeb]}>
|
|
||||||
{currentPlans.map((plan) => (
|
|
||||||
<SubscriptionCard
|
|
||||||
key={plan.priceId}
|
|
||||||
plan={plan}
|
|
||||||
selected={selectedPriceId === plan.priceId}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoadingPlans && currentPlans.length > 0 ? (
|
|
||||||
<View style={styles.inlineLoader}>
|
|
||||||
<ActivityIndicator color={Palette.white} size="small" />
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<GradientButton
|
|
||||||
title={actionButtonTitle}
|
|
||||||
onPress={handleCheckout}
|
|
||||||
disabled={isActionDisabled}
|
|
||||||
gradientStyle={styles.actionButtonGradient}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
) : (
|
||||||
|
<>
|
||||||
|
{renderHeaderSection()}
|
||||||
|
|
||||||
<Text style={styles.disclaimer}>
|
{isLoadingPlans && currentPlanCount === 0 ? (
|
||||||
Résiliable en un clic à tout moment. Les prix sont indiqués TTC.
|
<View style={styles.loaderContainer}>
|
||||||
Contacte-nous pour des besoins spécifiques (facturation annuelle,
|
<ActivityIndicator color={Palette.white} />
|
||||||
volume, offres éducation).
|
</View>
|
||||||
</Text>
|
) : null}
|
||||||
|
|
||||||
|
{!isLoadingPlans && currentPlanCount === 0 ? (
|
||||||
|
<Text style={styles.emptyState}>
|
||||||
|
Aucun abonnement Stripe disponible pour le moment.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{renderSegmentedControl()}
|
||||||
|
|
||||||
|
<View style={[styles.packs, isWeb && styles.packsWeb]}>
|
||||||
|
{currentPlans.map((plan) => (
|
||||||
|
<SubscriptionCard
|
||||||
|
key={plan.priceId}
|
||||||
|
plan={plan}
|
||||||
|
selected={selectedPriceId === plan.priceId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||||
|
<View style={styles.inlineLoader}>
|
||||||
|
<ActivityIndicator color={Palette.white} size="small" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<GradientButton
|
||||||
|
title={actionButtonTitle}
|
||||||
|
onPress={handleCheckout}
|
||||||
|
disabled={isActionDisabled}
|
||||||
|
gradientStyle={styles.actionButtonGradient}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.disclaimer}>
|
||||||
|
{SUBSCRIPTION_DISCLAIMER}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
|
{isMobile ? renderMobileBottomActions() : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -578,6 +711,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: 48,
|
paddingTop: 48,
|
||||||
paddingBottom: 48,
|
paddingBottom: 48,
|
||||||
},
|
},
|
||||||
|
pageContentMobile: {
|
||||||
|
paddingTop: gutters,
|
||||||
|
paddingBottom: gutters * 2,
|
||||||
|
},
|
||||||
inner: {
|
inner: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -588,6 +725,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: gutters,
|
paddingHorizontal: gutters,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
|
mobileInner: {
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
gap: 32,
|
gap: 32,
|
||||||
@@ -596,6 +737,12 @@ const styles = StyleSheet.create({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
|
mobileContent: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 0,
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
},
|
||||||
centerImage: {
|
centerImage: {
|
||||||
width: HERO_IMAGE_WIDTH,
|
width: HERO_IMAGE_WIDTH,
|
||||||
height: HERO_IMAGE_HEIGHT,
|
height: HERO_IMAGE_HEIGHT,
|
||||||
@@ -616,7 +763,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
fontSize: 32,
|
fontSize: isWeb ? 32 : 24,
|
||||||
|
lineHeight: isWeb ? 40 : 28,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
@@ -650,11 +798,12 @@ const styles = StyleSheet.create({
|
|||||||
segmentedControl: {
|
segmentedControl: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
|
justifyContent: "center",
|
||||||
padding: 4,
|
padding: 4,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
marginTop: 12,
|
marginTop: isWeb ? 12 : 8,
|
||||||
marginBottom: 8,
|
marginBottom: isWeb ? 8 : 4,
|
||||||
},
|
},
|
||||||
segmentButton: {
|
segmentButton: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -683,7 +832,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 320,
|
minHeight: CARD_MIN_HEIGHT,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -706,15 +855,15 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
cardBlur: {
|
cardBlur: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: gutters * 1.2,
|
paddingHorizontal: gutters,
|
||||||
paddingVertical: gutters,
|
paddingVertical: isWeb ? gutters : Math.max(gutters * 0.2, 10),
|
||||||
gap: 20,
|
gap: isWeb ? 18 : 10,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "rgba(48, 52, 56, 0.55)",
|
backgroundColor: "rgba(48, 52, 56, 0.55)",
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
},
|
},
|
||||||
cardContent: {
|
cardContent: {
|
||||||
gap: 16,
|
gap: isWeb ? 16 : 4,
|
||||||
},
|
},
|
||||||
cardHeader: {
|
cardHeader: {
|
||||||
gap: 6,
|
gap: 6,
|
||||||
@@ -728,7 +877,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
planName: {
|
planName: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
fontSize: 24,
|
fontSize: isWeb ? 24 : 20,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
@@ -744,7 +893,7 @@ const styles = StyleSheet.create({
|
|||||||
color: "rgba(255, 255, 255, 0.75)",
|
color: "rgba(255, 255, 255, 0.75)",
|
||||||
},
|
},
|
||||||
priceBlock: {
|
priceBlock: {
|
||||||
gap: 4,
|
gap: isWeb ? 4 : 1,
|
||||||
},
|
},
|
||||||
priceRow: {
|
priceRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -768,7 +917,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
priceValue: {
|
priceValue: {
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
fontSize: 22,
|
fontSize: isWeb ? 22 : 20,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
},
|
},
|
||||||
period: {
|
period: {
|
||||||
@@ -805,4 +954,58 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
mobileStickyHeader: {
|
||||||
|
width: "100%",
|
||||||
|
gap: 6,
|
||||||
|
paddingBottom: gutters * 0.2,
|
||||||
|
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
segmentedControlMobile: {
|
||||||
|
alignSelf: "center",
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
mobileList: {
|
||||||
|
flex: 1,
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
mobileListContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
|
mobileCard: {
|
||||||
|
marginBottom: gutters * 0.6,
|
||||||
|
},
|
||||||
|
mobileEmptyWrapper: {
|
||||||
|
paddingVertical: gutters * 2,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
mobileFooter: {
|
||||||
|
width: "100%",
|
||||||
|
paddingTop: gutters,
|
||||||
|
gap: gutters,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
mobileActions: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
paddingHorizontal: gutters,
|
||||||
|
paddingTop: gutters * 0.4,
|
||||||
|
backgroundColor: PAGE_BACKGROUND_COLOR,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: "rgba(255, 255, 255, 0.12)",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: -4 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 12,
|
||||||
|
},
|
||||||
|
mobileActionButtonGradient: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Image, StyleSheet, View } from "react-native";
|
import { Image, StyleSheet, View } from "react-native";
|
||||||
import { ai, background } from "../../assets";
|
import { ai, background } from "../../assets";
|
||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
@@ -6,13 +6,42 @@ import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
|||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||||
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
|
|
||||||
const Playback = ({ route }) => {
|
const Playback = ({ route }) => {
|
||||||
const { project } = route.params;
|
const { project } = route.params || {};
|
||||||
const [showIntro, setShowIntro] = useState(true);
|
const { videos } = useUser();
|
||||||
|
const [showIntro, setShowIntro] = useState(false);
|
||||||
|
|
||||||
|
const introVideoUrl = useMemo(() => {
|
||||||
|
if (!videos) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return isWeb ? videos?.benhaiWeb || null : videos?.benhai || null;
|
||||||
|
}, [videos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!introVideoUrl) {
|
||||||
|
setShowIntro(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowIntro(true);
|
||||||
|
}, [introVideoUrl]);
|
||||||
|
|
||||||
|
const handleCloseIntro = useCallback(() => {
|
||||||
|
setShowIntro(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowGuide = useCallback(() => {
|
||||||
|
if (!introVideoUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowIntro(true);
|
||||||
|
}, [introVideoUrl]);
|
||||||
|
|
||||||
const onPressRecord = useCallback(() => {
|
const onPressRecord = useCallback(() => {
|
||||||
navigate(Routes.RecordPlayback, { project });
|
navigate(Routes.RecordPlayback, { project });
|
||||||
@@ -36,14 +65,15 @@ const Playback = ({ route }) => {
|
|||||||
/>
|
/>
|
||||||
<BorderGradientButton
|
<BorderGradientButton
|
||||||
title="Guide du Playbacker"
|
title="Guide du Playbacker"
|
||||||
onPress={() => setShowIntro(true)}
|
onPress={handleShowGuide}
|
||||||
/>
|
/>
|
||||||
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<FullscreenIntroVideo
|
<FullscreenIntroVideo
|
||||||
visible={showIntro}
|
url={introVideoUrl}
|
||||||
onClose={() => setShowIntro(false)}
|
visible={showIntro && !!introVideoUrl}
|
||||||
|
onClose={handleCloseIntro}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { goBack, navigate } from "../../navigation/NavigationService";
|
|||||||
import { gutters, Palette } from "../../styles";
|
import { gutters, Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
import { background } from "../../assets";
|
||||||
|
import playbackBG2 from "../../assets/UI/playbackBG2.png";
|
||||||
|
|
||||||
const TIME_BEFORE_INCREMENT_MS = 20000;
|
const TIME_BEFORE_INCREMENT_MS = 20000;
|
||||||
const LOG_PREFIX = "[RecordPlayback.web]";
|
const LOG_PREFIX = "[RecordPlayback.web]";
|
||||||
@@ -169,7 +171,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:stopPrevious:error",
|
"mediaRecorder:stopPrevious:error",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +189,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:createError",
|
"mediaRecorder:createError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
|
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
|
||||||
return false;
|
return false;
|
||||||
@@ -209,7 +211,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:error",
|
"mediaRecorder:error",
|
||||||
String(err?.message || err || "")
|
String(err?.message || err || ""),
|
||||||
);
|
);
|
||||||
setMediaError(err instanceof Error ? err : new Error(String(err || "")));
|
setMediaError(err instanceof Error ? err : new Error(String(err || "")));
|
||||||
};
|
};
|
||||||
@@ -230,7 +232,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:onstop:createBlobError",
|
"mediaRecorder:onstop:createBlobError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
releaseRecordingUrl();
|
releaseRecordingUrl();
|
||||||
}
|
}
|
||||||
@@ -253,7 +255,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:startError",
|
"mediaRecorder:startError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
if (stopRecordingResolveRef.current) {
|
if (stopRecordingResolveRef.current) {
|
||||||
stopRecordingResolveRef.current(null);
|
stopRecordingResolveRef.current(null);
|
||||||
@@ -283,7 +285,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:stopError",
|
"mediaRecorder:stopError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +300,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"mediaRecorder:waitStopError",
|
"mediaRecorder:waitStopError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
stopRecordingPromiseRef.current = null;
|
stopRecordingPromiseRef.current = null;
|
||||||
@@ -320,7 +322,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"resetSession:error",
|
"resetSession:error",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -330,7 +332,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"resetSession:recorderError",
|
"resetSession:recorderError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(LOG_PREFIX, "resetSession:end");
|
console.log(LOG_PREFIX, "resetSession:end");
|
||||||
@@ -360,7 +362,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
setLooping(!!originalLoopingValueRef.current.value);
|
setLooping(!!originalLoopingValueRef.current.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [setLooping])
|
}, [setLooping]),
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -370,7 +372,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(LOG_PREFIX, "focusEffect:cleanup");
|
console.log(LOG_PREFIX, "focusEffect:cleanup");
|
||||||
void resetSessionRef.current?.();
|
void resetSessionRef.current?.();
|
||||||
};
|
};
|
||||||
}, [])
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -385,7 +387,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"requestingCameraPermission:error",
|
"requestingCameraPermission:error",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -410,7 +412,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
!navigator.mediaDevices?.getUserMedia
|
!navigator.mediaDevices?.getUserMedia
|
||||||
) {
|
) {
|
||||||
setMediaError(
|
setMediaError(
|
||||||
new Error("La capture vidéo n'est pas supportée sur ce navigateur")
|
new Error("La capture vidéo n'est pas supportée sur ce navigateur"),
|
||||||
);
|
);
|
||||||
setMediaReady(false);
|
setMediaReady(false);
|
||||||
return;
|
return;
|
||||||
@@ -460,7 +462,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"previewVideo:attachError",
|
"previewVideo:attachError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -502,7 +504,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"stopAndNavigate:pauseError",
|
"stopAndNavigate:pauseError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +518,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"stopAndNavigate:recorderError",
|
"stopAndNavigate:recorderError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +564,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"progressLoop:correctInitialJumpError",
|
"progressLoop:correctInitialJumpError",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,7 +646,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"listenLoop:incrementViews:error",
|
"listenLoop:incrementViews:error",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,7 +656,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"listenLoop:error",
|
"listenLoop:error",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -683,7 +685,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG_PREFIX,
|
||||||
"startPlayback:error",
|
"startPlayback:error",
|
||||||
String(e?.message || e || "")
|
String(e?.message || e || ""),
|
||||||
);
|
);
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsPreparing(false);
|
setIsPreparing(false);
|
||||||
@@ -702,7 +704,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ cancelable: false }
|
{ cancelable: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,6 +745,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
maxWidth={800}
|
maxWidth={800}
|
||||||
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
|
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
|
backgroundImg={background.playbackBG2}
|
||||||
>
|
>
|
||||||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||||||
|
|
||||||
@@ -767,7 +770,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
transform: "scaleX(-1)",
|
transform: "scaleX(-1)",
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -793,7 +796,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: "#00000099",
|
backgroundColor: "transparent",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: gutters,
|
paddingHorizontal: gutters,
|
||||||
@@ -820,7 +823,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: "#00000066",
|
backgroundColor: "transparent",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const ComposeSong = () => {
|
|||||||
const persistMusicConfig = async () => {
|
const persistMusicConfig = async () => {
|
||||||
try {
|
try {
|
||||||
if (!selectedProjectId) return;
|
if (!selectedProjectId) return;
|
||||||
await updateProjectData({
|
const payload = {
|
||||||
musicConfig: {
|
musicConfig: {
|
||||||
title: musicConfig?.title || "",
|
title: musicConfig?.title || "",
|
||||||
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
|
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
|
||||||
@@ -128,8 +128,13 @@ const ComposeSong = () => {
|
|||||||
},
|
},
|
||||||
musicStatus: null,
|
musicStatus: null,
|
||||||
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
||||||
musicUrls: firebase.firestore.FieldValue.delete(),
|
};
|
||||||
});
|
|
||||||
|
if (!isRegenerationFlow) {
|
||||||
|
payload.musicUrls = firebase.firestore.FieldValue.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProjectData(payload);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ const ComposeSong = () => {
|
|||||||
const persistMusicConfig = useCallback(async () => {
|
const persistMusicConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!selectedProjectId) return;
|
if (!selectedProjectId) return;
|
||||||
await updateProjectData({
|
const payload = {
|
||||||
musicConfig: {
|
musicConfig: {
|
||||||
title: musicConfig?.title || "",
|
title: musicConfig?.title || "",
|
||||||
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
|
lyrics: Array.isArray(musicConfig?.lyrics) ? musicConfig.lyrics : [],
|
||||||
@@ -188,10 +188,20 @@ const ComposeSong = () => {
|
|||||||
},
|
},
|
||||||
musicStatus: null,
|
musicStatus: null,
|
||||||
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
sunoTaskId: firebase.firestore.FieldValue.delete(),
|
||||||
musicUrls: firebase.firestore.FieldValue.delete(),
|
};
|
||||||
});
|
|
||||||
|
if (!isRegenerationFlow) {
|
||||||
|
payload.musicUrls = firebase.firestore.FieldValue.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProjectData(payload);
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
}, [musicConfig, selectedProjectId, updateProjectData]);
|
}, [
|
||||||
|
isRegenerationFlow,
|
||||||
|
musicConfig,
|
||||||
|
selectedProjectId,
|
||||||
|
updateProjectData,
|
||||||
|
]);
|
||||||
|
|
||||||
const spendCoinsForGeneration = useCallback(async () => {
|
const spendCoinsForGeneration = useCallback(async () => {
|
||||||
if (!currentUID) {
|
if (!currentUID) {
|
||||||
|
|||||||
+307
-359
@@ -11,9 +11,7 @@ import GradientButton from "../../components/GradientButton";
|
|||||||
import ValidateModal from "../../components/modal/ValidateModal";
|
import ValidateModal from "../../components/modal/ValidateModal";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import Slider from "../../components/Slider";
|
import Slider from "../../components/Slider";
|
||||||
import CreditAmount from "../../components/CreditAmount";
|
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
@@ -21,11 +19,20 @@ import { useUser } from "../../providers/UserDataProvider";
|
|||||||
import { Palette, Style } from "../../styles";
|
import { Palette, Style } from "../../styles";
|
||||||
import { gutters, size } from "../../styles/Style";
|
import { gutters, size } from "../../styles/Style";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
|
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
|
||||||
const MUSIC_GENERATION_COIN_COST = 8;
|
const MUSIC_GENERATION_COIN_COST = 8;
|
||||||
const SONG_OPTIONS_PER_GENERATION = 2;
|
const SONG_OPTIONS_PER_GENERATION = 2;
|
||||||
const REGENERATE_MODAL_MAX_WIDTH = 540;
|
const REGENERATE_MODAL_MAX_WIDTH = 540;
|
||||||
|
const formatTime = (ms) => {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000));
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(1, "0");
|
||||||
|
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
const SongReady = () => {
|
const SongReady = () => {
|
||||||
const {
|
const {
|
||||||
@@ -38,219 +45,72 @@ const SongReady = () => {
|
|||||||
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
|
||||||
const [musicUrls, setMusicUrls] = useState([]);
|
const [musicUrls, setMusicUrls] = useState([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState({ 0: false, 1: false });
|
const playerRefs = useRef({});
|
||||||
const [progressInfo, setProgressInfo] = useState({
|
|
||||||
0: { pos: 0, dur: 0 },
|
|
||||||
1: { pos: 0, dur: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const player0 = useSharedAudioPlayer(
|
const registerPlayer = useCallback((index, player) => {
|
||||||
musicUrls[0] ? { uri: musicUrls[0] } : undefined,
|
if (player) {
|
||||||
{
|
playerRefs.current[index] = player;
|
||||||
id: musicUrls[0] ? `songready-${musicUrls[0]}` : undefined,
|
} else {
|
||||||
title:
|
delete playerRefs.current[index];
|
||||||
(Array.isArray(selectedProject?.musicTitles)
|
}
|
||||||
? selectedProject?.musicTitles?.[0]
|
}, []);
|
||||||
: null) ||
|
|
||||||
selectedProject?.title ||
|
const pauseAllExcept = useCallback(async (keepIndex = null) => {
|
||||||
"Option 1",
|
const tasks = Object.entries(playerRefs.current).map(
|
||||||
artwork: selectedProject?.coverUrl || null,
|
async ([key, player]) => {
|
||||||
coverUrl: selectedProject?.coverUrl || null,
|
const idx = Number(key);
|
||||||
metadata: { index: 0, projectId },
|
if (!player || idx === keepIndex) return;
|
||||||
|
try {
|
||||||
|
await player.pause?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady pause error", error?.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pauseAllPlayers = useCallback(async () => {
|
||||||
|
await pauseAllExcept(null);
|
||||||
|
}, [pauseAllExcept]);
|
||||||
|
|
||||||
|
const handleTogglePlayback = useCallback(
|
||||||
|
async (index, isPlayingNow) => {
|
||||||
|
const player = playerRefs.current[index];
|
||||||
|
if (!player) return;
|
||||||
|
if (isPlayingNow) {
|
||||||
|
try {
|
||||||
|
await player.pause?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady pause toggle", error?.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pauseAllExcept(index);
|
||||||
|
try {
|
||||||
|
await player.play?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady play error", error?.message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
[pauseAllExcept],
|
||||||
);
|
);
|
||||||
const player1 = useSharedAudioPlayer(
|
|
||||||
musicUrls[1] ? { uri: musicUrls[1] } : undefined,
|
|
||||||
{
|
|
||||||
id: musicUrls[1] ? `songready-${musicUrls[1]}` : undefined,
|
|
||||||
title:
|
|
||||||
(Array.isArray(selectedProject?.musicTitles)
|
|
||||||
? selectedProject?.musicTitles?.[1]
|
|
||||||
: null) ||
|
|
||||||
selectedProject?.title ||
|
|
||||||
"Option 2",
|
|
||||||
artwork: selectedProject?.coverUrl || null,
|
|
||||||
coverUrl: selectedProject?.coverUrl || null,
|
|
||||||
metadata: { index: 1, projectId },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });
|
|
||||||
const prefetchedRef = useRef({
|
|
||||||
0: { url: null, done: false },
|
|
||||||
1: { url: null, done: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync URLs from provider's selectedProject
|
// Sync URLs from provider's selectedProject
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urls = Array.isArray(selectedProject?.musicUrls)
|
const urls = Array.isArray(selectedProject?.musicUrls)
|
||||||
? selectedProject.musicUrls.slice(0, 2)
|
? selectedProject.musicUrls
|
||||||
|
.filter((url) => typeof url === "string" && url.trim())
|
||||||
|
.map((url) => url.trim())
|
||||||
: [];
|
: [];
|
||||||
setMusicUrls(urls);
|
setMusicUrls(urls);
|
||||||
|
setSelectedIndex((prev) => {
|
||||||
|
if (!urls.length) return 0;
|
||||||
|
return Math.min(prev, urls.length - 1);
|
||||||
|
});
|
||||||
}, [selectedProject?.musicUrls]);
|
}, [selectedProject?.musicUrls]);
|
||||||
|
|
||||||
const firstUrl = musicUrls[0] ?? null;
|
|
||||||
const secondUrl = musicUrls[1] ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isCancelled = false;
|
|
||||||
|
|
||||||
const waitForDuration = async (index) => {
|
|
||||||
const getDurationSeconds = () => {
|
|
||||||
const targetPlayer = index === 0 ? player0 : player1;
|
|
||||||
if (!targetPlayer) return 0;
|
|
||||||
const raw = Number(targetPlayer.duration || 0);
|
|
||||||
return Number.isFinite(raw) ? raw : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
while (!isCancelled) {
|
|
||||||
const durationSeconds = getDurationSeconds();
|
|
||||||
if (durationSeconds > 0) {
|
|
||||||
const durationMs = Math.round(durationSeconds * 1000);
|
|
||||||
setProgressInfo((prev) => {
|
|
||||||
const current = prev?.[index] || { pos: 0, dur: 0 };
|
|
||||||
if (Math.abs(current.dur - durationMs) < 5) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[index]: {
|
|
||||||
...current,
|
|
||||||
dur: durationMs,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Date.now() - start > 6000) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefetch = async () => {
|
|
||||||
const entries = [
|
|
||||||
[0, firstUrl, player0],
|
|
||||||
[1, secondUrl, player1],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [idx, url, player] of entries) {
|
|
||||||
const cacheEntry = prefetchedRef.current[idx];
|
|
||||||
|
|
||||||
if (!url || !player?.load) {
|
|
||||||
if (cacheEntry) {
|
|
||||||
cacheEntry.url = url ?? null;
|
|
||||||
cacheEntry.done = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheEntry && cacheEntry.url === url && cacheEntry.done) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheEntry) {
|
|
||||||
cacheEntry.url = url;
|
|
||||||
cacheEntry.done = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await player.load({ startPositionMs: 0 });
|
|
||||||
const resolved = await waitForDuration(idx);
|
|
||||||
if (cacheEntry && !isCancelled) {
|
|
||||||
cacheEntry.done = resolved;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (cacheEntry) {
|
|
||||||
cacheEntry.done = false;
|
|
||||||
}
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.log("SongReady preload error", err?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCancelled) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
prefetch();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, [firstUrl, secondUrl, player0, player1]);
|
|
||||||
|
|
||||||
// Sync progression depuis les players
|
|
||||||
useEffect(() => {
|
|
||||||
// Ensure we consistently work with milliseconds whatever the platform
|
|
||||||
const toMs = (t) => {
|
|
||||||
const n = Number(t || 0);
|
|
||||||
if (!isFinite(n) || n <= 0) return 0;
|
|
||||||
return n >= 1000 ? n : n * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
const id = setInterval(() => {
|
|
||||||
const d0 = toMs(player0?.duration);
|
|
||||||
const p0 = toMs(player0?.currentTime);
|
|
||||||
const d1 = toMs(player1?.duration);
|
|
||||||
const p1 = toMs(player1?.currentTime);
|
|
||||||
setProgressInfo({ 0: { pos: p0, dur: d0 }, 1: { pos: p1, dur: d1 } });
|
|
||||||
setIsPlaying({ 0: !!player0?.playing, 1: !!player1?.playing });
|
|
||||||
}, 300);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [player0, player1]);
|
|
||||||
|
|
||||||
const togglePlay = async (idx) => {
|
|
||||||
setSelectedIndex(idx);
|
|
||||||
const url = musicUrls[idx];
|
|
||||||
if (!url) return;
|
|
||||||
try {
|
|
||||||
// Pause l'autre piste si elle joue
|
|
||||||
const other = idx === 0 ? 1 : 0;
|
|
||||||
if (isPlaying[other]) {
|
|
||||||
if (other === 0) await player0?.pause?.();
|
|
||||||
else await player1?.pause?.();
|
|
||||||
setIsPlaying((p) => ({ ...p, [other]: false }));
|
|
||||||
}
|
|
||||||
const player = idx === 0 ? player0 : player1;
|
|
||||||
if (!player) return;
|
|
||||||
if (player.playing) {
|
|
||||||
await player.pause?.();
|
|
||||||
setIsPlaying((p) => ({ ...p, [idx]: false }));
|
|
||||||
} else {
|
|
||||||
await player.play?.();
|
|
||||||
setIsPlaying((p) => ({ ...p, [idx]: true }));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Audio error", e?.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fmt = (ms) => {
|
|
||||||
const total = Math.max(0, Math.floor((ms || 0) / 1000));
|
|
||||||
const m = Math.floor(total / 60)
|
|
||||||
.toString()
|
|
||||||
.padStart(1, "0");
|
|
||||||
const s = (total % 60).toString().padStart(2, "0");
|
|
||||||
return `${m}:${s}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSeek = async (idx, ratio) => {
|
|
||||||
try {
|
|
||||||
const info = progressInfo[idx] || {};
|
|
||||||
const dur = info.dur || 0;
|
|
||||||
const pos = Math.floor(dur * ratio);
|
|
||||||
const player = idx === 0 ? player0 : player1;
|
|
||||||
if (player && dur > 0) {
|
|
||||||
// seekTo expects seconds
|
|
||||||
await player.seekTo?.(Math.floor((pos || 0) / 1000));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Seek error", e?.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateSelection = async () => {
|
const validateSelection = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -277,8 +137,7 @@ const SongReady = () => {
|
|||||||
shouldSetTooltip: false,
|
shouldSetTooltip: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await player0?.pause?.();
|
await pauseAllPlayers();
|
||||||
await player1?.pause?.();
|
|
||||||
navigate(Routes.ChooseCoverType);
|
navigate(Routes.ChooseCoverType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Validate error", e?.message);
|
console.log("Validate error", e?.message);
|
||||||
@@ -289,22 +148,16 @@ const SongReady = () => {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
pauseAllPlayers();
|
||||||
player0?.pause?.();
|
|
||||||
player1?.pause?.();
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
}, [player0, player1]),
|
}, [pauseAllPlayers]),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
pauseAllPlayers();
|
||||||
player0?.pause?.();
|
|
||||||
player1?.pause?.();
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
}, [player0, player1]);
|
}, [pauseAllPlayers]);
|
||||||
|
|
||||||
const handleChooseTrack = () => {
|
const handleChooseTrack = () => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
@@ -334,8 +187,7 @@ const SongReady = () => {
|
|||||||
const handleConfirmRegenerate = async () => {
|
const handleConfirmRegenerate = async () => {
|
||||||
setShowRegenerateModal(false);
|
setShowRegenerateModal(false);
|
||||||
try {
|
try {
|
||||||
await player0?.pause?.();
|
await pauseAllPlayers();
|
||||||
await player1?.pause?.();
|
|
||||||
} catch {}
|
} catch {}
|
||||||
navigate(Routes.ComposeSong, { isRegeneration: true });
|
navigate(Routes.ComposeSong, { isRegeneration: true });
|
||||||
};
|
};
|
||||||
@@ -368,8 +220,7 @@ const SongReady = () => {
|
|||||||
<MusicLandHeader
|
<MusicLandHeader
|
||||||
onPressBack={async () => {
|
onPressBack={async () => {
|
||||||
try {
|
try {
|
||||||
await player0?.pause?.();
|
await pauseAllPlayers();
|
||||||
await player1?.pause?.();
|
|
||||||
} catch {}
|
} catch {}
|
||||||
navigate(Routes.Home);
|
navigate(Routes.Home);
|
||||||
}}
|
}}
|
||||||
@@ -384,146 +235,19 @@ const SongReady = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={{ gap: 16 }}>
|
<View style={{ gap: 16 }}>
|
||||||
{[0, 1].map((idx) => {
|
{musicUrls.map((url, idx) => (
|
||||||
const isSelected = selectedIndex === idx;
|
<SongOptionCard
|
||||||
return (
|
key={`${url || "song"}-${idx}`}
|
||||||
<Pressable
|
index={idx}
|
||||||
key={idx}
|
url={url}
|
||||||
onPress={() => setSelectedIndex(idx)}
|
isSelected={selectedIndex === idx}
|
||||||
style={({ pressed }) => [
|
onSelect={setSelectedIndex}
|
||||||
{
|
registerPlayer={registerPlayer}
|
||||||
borderRadius: 20,
|
onTogglePlayback={handleTogglePlayback}
|
||||||
borderWidth: isSelected ? 2 : 1,
|
projectId={projectId}
|
||||||
borderColor: isSelected
|
selectedProject={selectedProject}
|
||||||
? Palette.primary
|
/>
|
||||||
: Palette.ultraLightWhite,
|
))}
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
pressed && { opacity: 0.96 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<BlurView
|
|
||||||
intensity={40}
|
|
||||||
tint="dark"
|
|
||||||
style={{
|
|
||||||
borderRadius: 18,
|
|
||||||
overflow: "hidden",
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: "#FFFFFF0A",
|
|
||||||
}}
|
|
||||||
// experimentalBlurMethod={
|
|
||||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
|
||||||
// }
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => togglePlay(idx)}
|
|
||||||
style={{
|
|
||||||
...Style.containerCenter,
|
|
||||||
...size({ size: 48 }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={isPlaying[idx] ? icons.pause : icons.play}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
marginBottom: responsiveHeight(1),
|
|
||||||
}}
|
|
||||||
>{`Morceau ${idx + 1}`}</Text>
|
|
||||||
<Slider
|
|
||||||
value={fmt(progressInfo[idx]?.pos)}
|
|
||||||
maxValue={fmt(progressInfo[idx]?.dur)}
|
|
||||||
progress={
|
|
||||||
progressInfo[idx]?.dur
|
|
||||||
? (progressInfo[idx].pos || 0) /
|
|
||||||
progressInfo[idx].dur
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
seekEnabled={!!musicUrls[idx]}
|
|
||||||
onSeekStart={async () => {
|
|
||||||
setSelectedIndex(idx);
|
|
||||||
try {
|
|
||||||
const player = idx === 0 ? player0 : player1;
|
|
||||||
wasPlayingBeforeSeek.current[idx] =
|
|
||||||
!!player?.playing;
|
|
||||||
if (player?.playing) {
|
|
||||||
await player.pause?.();
|
|
||||||
setIsPlaying((p) => ({ ...p, [idx]: false }));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(
|
|
||||||
"SongReady pause on seek start",
|
|
||||||
e?.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSeek={(ratio) => onSeek(idx, ratio)}
|
|
||||||
onSeekEnd={async () => {
|
|
||||||
try {
|
|
||||||
const player = idx === 0 ? player0 : player1;
|
|
||||||
if (player && wasPlayingBeforeSeek.current[idx]) {
|
|
||||||
if (player.resume) {
|
|
||||||
await player.resume?.();
|
|
||||||
} else {
|
|
||||||
await player.play?.();
|
|
||||||
}
|
|
||||||
setIsPlaying((p) => ({ ...p, [idx]: true }));
|
|
||||||
}
|
|
||||||
wasPlayingBeforeSeek.current[idx] = false;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(
|
|
||||||
"SongReady resume after seek",
|
|
||||||
e?.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setSelectedIndex(idx)}
|
|
||||||
style={{
|
|
||||||
...Style.containerCenter,
|
|
||||||
...size({ size: 24 }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
borderRadius: 9,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: isSelected ? Palette.primary : "white",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 5,
|
|
||||||
backgroundColor: Palette.primary,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</BlurView>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
@@ -562,6 +286,230 @@ const SongReady = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SongOptionCard = ({
|
||||||
|
index,
|
||||||
|
url,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
registerPlayer,
|
||||||
|
onTogglePlayback,
|
||||||
|
projectId,
|
||||||
|
selectedProject,
|
||||||
|
}) => {
|
||||||
|
const trackTitle =
|
||||||
|
(Array.isArray(selectedProject?.musicTitles)
|
||||||
|
? selectedProject.musicTitles?.[index]
|
||||||
|
: null) ||
|
||||||
|
selectedProject?.title ||
|
||||||
|
`Morceau ${index + 1}`;
|
||||||
|
|
||||||
|
const player = useSharedAudioPlayer(url ? { uri: url } : undefined, {
|
||||||
|
id: url ? `songready-${url}` : undefined,
|
||||||
|
title: trackTitle,
|
||||||
|
artwork: selectedProject?.coverUrl || null,
|
||||||
|
coverUrl: selectedProject?.coverUrl || null,
|
||||||
|
metadata: { index, projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const wasPlayingBeforeSeek = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerPlayer(index, player);
|
||||||
|
return () => registerPlayer(index, null);
|
||||||
|
}, [index, player, registerPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!player || !url) return;
|
||||||
|
const preload = async () => {
|
||||||
|
try {
|
||||||
|
await player.load?.({ startPositionMs: 0 });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady preload error", error?.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
preload();
|
||||||
|
}, [player, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!player) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setProgressInfo({ pos: 0, dur: 0 });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const durationMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((Number(player.duration) || 0) * 1000),
|
||||||
|
);
|
||||||
|
const positionMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((Number(player.currentTime) || 0) * 1000),
|
||||||
|
);
|
||||||
|
setProgressInfo((prev) => {
|
||||||
|
if (
|
||||||
|
Math.abs((prev?.dur || 0) - durationMs) < 5 &&
|
||||||
|
Math.abs((prev?.pos || 0) - positionMs) < 5
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { pos: positionMs, dur: durationMs };
|
||||||
|
});
|
||||||
|
setIsPlaying((prev) => {
|
||||||
|
const next = !!player.playing;
|
||||||
|
if (prev !== next) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
const handleSeek = async (ratio) => {
|
||||||
|
if (!player || !progressInfo?.dur) return;
|
||||||
|
const dur = progressInfo.dur || 0;
|
||||||
|
const pos = Math.max(0, Math.floor(dur * ratio));
|
||||||
|
try {
|
||||||
|
await player.seekTo?.(Math.floor(pos / 1000));
|
||||||
|
setProgressInfo((prev) => ({ ...prev, pos }));
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady seek error", error?.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeekStart = async () => {
|
||||||
|
onSelect(index);
|
||||||
|
if (!player) return;
|
||||||
|
try {
|
||||||
|
wasPlayingBeforeSeek.current = !!player.playing;
|
||||||
|
if (player.playing) {
|
||||||
|
await player.pause?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady pause on seek start", error?.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeekEnd = async () => {
|
||||||
|
if (!player) return;
|
||||||
|
try {
|
||||||
|
if (wasPlayingBeforeSeek.current) {
|
||||||
|
if (player.resume) {
|
||||||
|
await player.resume?.();
|
||||||
|
} else {
|
||||||
|
await player.play?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("SongReady resume after seek", error?.message);
|
||||||
|
} finally {
|
||||||
|
wasPlayingBeforeSeek.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
onSelect(index);
|
||||||
|
onTogglePlayback?.(index, isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onSelect(index)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
{
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: isSelected ? 2 : 1,
|
||||||
|
borderColor: isSelected ? Palette.primary : Palette.ultraLightWhite,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
pressed && { opacity: 0.96 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={40}
|
||||||
|
tint="dark"
|
||||||
|
style={{
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: "#FFFFFF0A",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={{
|
||||||
|
...Style.containerCenter,
|
||||||
|
...size({ size: 48 }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image source={isPlaying ? icons.pause : icons.play} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "white",
|
||||||
|
marginBottom: responsiveHeight(1),
|
||||||
|
}}
|
||||||
|
>{`Morceau ${index + 1}`}</Text>
|
||||||
|
<Slider
|
||||||
|
value={formatTime(progressInfo?.pos)}
|
||||||
|
maxValue={formatTime(progressInfo?.dur)}
|
||||||
|
progress={
|
||||||
|
progressInfo?.dur
|
||||||
|
? (progressInfo.pos || 0) / progressInfo.dur
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
seekEnabled={!!url}
|
||||||
|
onSeekStart={handleSeekStart}
|
||||||
|
onSeek={handleSeek}
|
||||||
|
onSeekEnd={handleSeekEnd}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onSelect(index)}
|
||||||
|
style={{
|
||||||
|
...Style.containerCenter,
|
||||||
|
...size({ size: 24 }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isSelected ? Palette.primary : "white",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: Palette.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const Studio = () => {
|
|||||||
/>
|
/>
|
||||||
{!selectedProject?.coverUrl && (
|
{!selectedProject?.coverUrl && (
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title="Générer une pochette"
|
title="Générer la pochette"
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
marginTop: responsiveHeight(2),
|
marginTop: responsiveHeight(2),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -154,9 +154,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
Array.isArray(customStructure) &&
|
Array.isArray(customStructure) &&
|
||||||
fallbackStructure?.length &&
|
fallbackStructure?.length &&
|
||||||
(customStructure.length !== fallbackStructure.length ||
|
(customStructure.length !== fallbackStructure.length ||
|
||||||
customStructure.some(
|
customStructure.some((value, idx) => value !== fallbackStructure[idx]))
|
||||||
(value, idx) => value !== fallbackStructure[idx]
|
|
||||||
))
|
|
||||||
) {
|
) {
|
||||||
setCustomStructure(fallbackStructure);
|
setCustomStructure(fallbackStructure);
|
||||||
}
|
}
|
||||||
@@ -371,11 +369,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100}
|
keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100}
|
||||||
backgroundImg={background.writingBG}
|
backgroundImg={background.writingBG}
|
||||||
>
|
>
|
||||||
<MusicLandHeader
|
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
|
||||||
onPressBack={onPressBack}
|
|
||||||
progress={progress}
|
|
||||||
logo={icons.musicLandWriting}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -420,11 +420,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100}
|
keyboardVerticalOffset={Platform.OS === "ios" ? 64 : 100}
|
||||||
backgroundImg={background.libraryBgWeb}
|
backgroundImg={background.libraryBgWeb}
|
||||||
>
|
>
|
||||||
<MusicLandHeader
|
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
|
||||||
onPressBack={onPressBack}
|
|
||||||
progress={progress}
|
|
||||||
// logo={icons.musicLandWriting}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { goBack, navigate } from "../../navigation/NavigationService";
|
|||||||
import { useUserData } from "../../providers/UserDataProvider";
|
import { useUserData } from "../../providers/UserDataProvider";
|
||||||
import { Palette, gutters } from "../../styles";
|
import { Palette, gutters } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo.web";
|
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
|
||||||
|
|
||||||
const CTA_BUTTON_HEIGHT = 58;
|
const CTA_BUTTON_HEIGHT = 58;
|
||||||
const CTA_BUTTON_MAX_WIDTH = 520;
|
const CTA_BUTTON_MAX_WIDTH = 520;
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export const ChooseCoverType = () => {
|
|||||||
/> */}
|
/> */}
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title={
|
title={
|
||||||
hasFinalCover ? "Pochette déjà validée" : "Générer une pochette"
|
hasFinalCover ? "Pochette déjà validée" : "Générer la pochette"
|
||||||
}
|
}
|
||||||
onPress={handleGenerateCover}
|
onPress={handleGenerateCover}
|
||||||
disabled={hasFinalCover}
|
disabled={hasFinalCover}
|
||||||
|
|||||||
Reference in New Issue
Block a user