more fixes and changes, crash, design …

This commit is contained in:
Thomas Demirdjian
2025-11-14 18:25:28 +01:00
parent cd31d42c0c
commit 705fda0ce3
17 changed files with 1136 additions and 785 deletions
+1
View File
@@ -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
View File
@@ -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",
});
} }
}); });
+28 -3
View File
@@ -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>
+1 -1
View File
@@ -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",
+22 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 labonnement 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 labonnement 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%",
},
}); });
+36 -6
View File
@@ -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>
); );
+27 -24
View File
@@ -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",
}} }}
+8 -3
View File
@@ -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) {}
}; };
+14 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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),
}} }}
+2 -8
View File
@@ -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,
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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}