fix tickets add reort mail and design fixes
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
# Étape 2 – Blocage après "Suivant"
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
- Plateforme : Web & mobile (Expo) – navigation `Routes.CreateLyricsWithAi`.
|
||||||
|
- Préconditions : Projet sans paroles existantes.
|
||||||
|
- Étapes :
|
||||||
|
1. Lancer l'atelier d'écriture et sélectionner un objectif (Étape 1).
|
||||||
|
2. Renseigner un contexte dans l'input (Étape 2).
|
||||||
|
3. Appuyer sur `Suivant`.
|
||||||
|
- Résultat observé : le bouton devient inactif, l'écran reste figé sur l'étape 2 et il est impossible de poursuivre car le swipe est désactivé.
|
||||||
|
|
||||||
|
## Analyse & cause racine
|
||||||
|
- L'étape dépend de `SwiperFlatList` avec `disableGesture={true}` : seule la navigation programmée via `scrollToIndex` permet d'avancer.
|
||||||
|
- L'appel `scrollRef.current.scrollToIndex` levait par intermittence une exception (`Invariant Violation: scrollToIndex out of range`) lorsque la mesure des vues n'était pas prête au moment du clic.
|
||||||
|
- L'erreur était avalée silencieusement (`catch {}`) ce qui laissait l'index interne sur `1` et donc l'utilisateur bloqué sur Étape 2.
|
||||||
|
|
||||||
|
## Correctif
|
||||||
|
- Ajout d'une logique de re-tentative autour de `scrollToIndex` avec journalisation pour diagnostiquer les cas anormaux.
|
||||||
|
- Clamp de l'index (`Math.min(idx + 1, MAX_STEP_INDEX)`) pour éviter toute sortie de plage.
|
||||||
|
- Progression recalculée en même temps que la navigation afin d'éviter les états intermédiaires incohérents.
|
||||||
|
|
||||||
|
## Validation manuelle
|
||||||
|
1. Rejouer le scénario de reproduction sur web et mobile (Expo Go).
|
||||||
|
2. Vérifier la console : absence d'avertissements `[CreateLyricsWithAi] scrollToIndex failed`.
|
||||||
|
3. Confirmer que l'écran passe correctement à "Étape 3: Quelle émotion veux-tu transmettre ?" et que le bouton `Suivant` se réactive après sélection d'une émotion.
|
||||||
|
4. Revenir en arrière (`Précédent`) pour s'assurer que l'étape 2 reste éditable et que l'avancement se met à jour (progression dynamique).
|
||||||
|
|
||||||
|
## Points de suivi
|
||||||
|
- Ajouter une suite E2E complète une fois l'infrastructure de tests dispo (Detox/Playwright) pour vérifier la navigation multi-étapes sans interaction manuelle.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
@@ -1,5 +1,5 @@
|
|||||||
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
||||||
// exports.SUNO_API_KEY = "543b5650b30d93e98d8046a830244c80"; client api key
|
|
||||||
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
|
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
|
||||||
|
|
||||||
exports.RESEND_API_KEY = "re_";
|
exports.RESEND_API_KEY = "re_";
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const musicLandLogoBase64 = fs.readFileSync(
|
||||||
|
path.join(__dirname, "../assets/musicLandLogo.png"),
|
||||||
|
{ encoding: "base64" },
|
||||||
|
);
|
||||||
|
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
|
||||||
|
|
||||||
function basicTemplate({ title = "", content = "", button = null }) {
|
function basicTemplate({ title = "", content = "", button = null }) {
|
||||||
const btn = button?.url
|
const btn = button?.url
|
||||||
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
|
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
|
||||||
@@ -6,6 +15,9 @@ function basicTemplate({ title = "", content = "", button = null }) {
|
|||||||
: "";
|
: "";
|
||||||
return `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#111">
|
return `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#111">
|
||||||
<div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px">
|
<div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px">
|
||||||
|
<div style="text-align:center;margin-bottom:20px">
|
||||||
|
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:40px" />
|
||||||
|
</div>
|
||||||
<h2 style="margin:0 0 16px">${title}</h2>
|
<h2 style="margin:0 0 16px">${title}</h2>
|
||||||
<div>${content}</div>
|
<div>${content}</div>
|
||||||
${btn}
|
${btn}
|
||||||
@@ -22,6 +34,9 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
|
|||||||
<div style="max-width:640px;margin:0 auto;padding:40px 24px">
|
<div style="max-width:640px;margin:0 auto;padding:40px 24px">
|
||||||
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
||||||
<div style="padding:32px 28px">
|
<div style="padding:32px 28px">
|
||||||
|
<div style="text-align:center;margin-bottom:24px">
|
||||||
|
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:48px" />
|
||||||
|
</div>
|
||||||
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
|
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
|
||||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
|
||||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)">
|
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
|||||||
receiverCollection = "users",
|
receiverCollection = "users",
|
||||||
message = "",
|
message = "",
|
||||||
data: notifData = {},
|
data: notifData = {},
|
||||||
|
mailOnly = false,
|
||||||
} = event.data.data();
|
} = event.data.data();
|
||||||
|
|
||||||
if (!receiver || !message) {
|
if (!receiver || !message) {
|
||||||
@@ -40,27 +41,29 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
|||||||
emailNotifications = false,
|
emailNotifications = false,
|
||||||
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
|
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
|
||||||
|
|
||||||
const tokensSet = new Set(
|
if (!mailOnly) {
|
||||||
[]
|
const tokensSet = new Set(
|
||||||
.concat(Array.isArray(pushTokens) ? pushTokens : [])
|
[]
|
||||||
.concat(pushToken ? [pushToken] : [])
|
.concat(Array.isArray(pushTokens) ? pushTokens : [])
|
||||||
.filter(Boolean),
|
.concat(pushToken ? [pushToken] : [])
|
||||||
);
|
.filter(Boolean),
|
||||||
const tokens = Array.from(tokensSet);
|
);
|
||||||
|
const tokens = Array.from(tokensSet);
|
||||||
|
|
||||||
if (!tokens?.length) {
|
if (!tokens?.length) {
|
||||||
console.warn("User push token not found");
|
console.warn("User push token not found");
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendExpoNotification({
|
||||||
|
tokens,
|
||||||
|
receiverId: receiver,
|
||||||
|
receiverCollection,
|
||||||
|
title: title || "MusicLand",
|
||||||
|
message: message,
|
||||||
|
data: notifData || {},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendExpoNotification({
|
|
||||||
tokens,
|
|
||||||
receiverId: receiver,
|
|
||||||
receiverCollection,
|
|
||||||
title: title || "MusicLand",
|
|
||||||
message: message,
|
|
||||||
data: notifData || {},
|
|
||||||
});
|
|
||||||
if (emailNotifications && !!email) {
|
if (emailNotifications && !!email) {
|
||||||
try {
|
try {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -219,6 +222,7 @@ const sendNotification = async ({
|
|||||||
receiverCollection = "users",
|
receiverCollection = "users",
|
||||||
title = "",
|
title = "",
|
||||||
message = null,
|
message = null,
|
||||||
|
mailOnly = false,
|
||||||
data = {},
|
data = {},
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
@@ -234,6 +238,7 @@ const sendNotification = async ({
|
|||||||
time: admin.firestore.FieldValue.serverTimestamp(),
|
time: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
read: false,
|
read: false,
|
||||||
readAt: null,
|
readAt: null,
|
||||||
|
mailOnly,
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
const { id } = await refList.notifications.add(payload);
|
const { id } = await refList.notifications.add(payload);
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await resendClient.emails.send({
|
console.log(`Sending welcome email to ${email}`);
|
||||||
|
const { data, error } = await resendClient.emails.send({
|
||||||
from: WELCOME_EMAIL_FROM,
|
from: WELCOME_EMAIL_FROM,
|
||||||
to: [email],
|
to: [email],
|
||||||
subject: WELCOME_EMAIL_SUBJECT,
|
subject: WELCOME_EMAIL_SUBJECT,
|
||||||
html: welcomeTemplate({ firstName, lastName }),
|
html: welcomeTemplate({ firstName, lastName }),
|
||||||
});
|
});
|
||||||
|
if (error) {
|
||||||
|
console.log("Failed to send welcome email:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Welcome email sent:", data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Failed to send welcome email:", error);
|
console.log("Failed to send welcome email:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 457 KiB |
@@ -1,26 +1,38 @@
|
|||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from "@gorhom/portal";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Platform, Pressable, StyleSheet, View } from "react-native";
|
import { Platform, Pressable, StyleSheet, View } from "react-native";
|
||||||
import ActionSheet from "react-native-actions-sheet";
|
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { isWeb } from "../hooks/useLayoutType";
|
import { isWeb } from "../hooks/useLayoutType";
|
||||||
import { gutters, Palette } from "../styles";
|
import { gutters, Palette } from "../styles";
|
||||||
|
|
||||||
const AppActionSheet = ({
|
const AppActionSheet = ({
|
||||||
|
id,
|
||||||
children,
|
children,
|
||||||
webModal = false,
|
webModal = false,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
...sheetProps
|
...sheetProps
|
||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const handleRequestClose = useCallback(() => {
|
||||||
|
if (id) {
|
||||||
|
Promise.resolve(SheetManager.hide(id))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
onClose?.();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose?.();
|
||||||
|
}, [id, onClose]);
|
||||||
|
|
||||||
if (isWeb && webModal) {
|
if (isWeb && webModal) {
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<View style={styles.webOverlay}>
|
<View style={styles.webOverlay}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={handleRequestClose}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={styles.webBackdrop}
|
style={styles.webBackdrop}
|
||||||
/>
|
/>
|
||||||
@@ -36,6 +48,7 @@ const AppActionSheet = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionSheet
|
<ActionSheet
|
||||||
|
id={id}
|
||||||
containerStyle={{ backgroundColor: Palette.tran }}
|
containerStyle={{ backgroundColor: Palette.tran }}
|
||||||
gestureEnabled
|
gestureEnabled
|
||||||
indicatorStyle={{
|
indicatorStyle={{
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import { FONT_FAMILY } from "../styles/Fonts";
|
|||||||
import { size } from "../styles/Style";
|
import { size } from "../styles/Style";
|
||||||
import BorderGradient from "./BorderGradient/BorderGradient";
|
import BorderGradient from "./BorderGradient/BorderGradient";
|
||||||
|
|
||||||
|
const HEIGHT_BY_SIZE = {
|
||||||
|
small: 40,
|
||||||
|
medium: 50,
|
||||||
|
large: 58,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT_SIZE_BY_SIZE = {
|
||||||
|
small: 13,
|
||||||
|
medium: 15,
|
||||||
|
large: 17,
|
||||||
|
};
|
||||||
|
|
||||||
const BorderGradientButton = ({
|
const BorderGradientButton = ({
|
||||||
title = "J’ai déjà mes paroles",
|
title = "J’ai déjà mes paroles",
|
||||||
onPress,
|
onPress,
|
||||||
@@ -15,7 +27,13 @@ const BorderGradientButton = ({
|
|||||||
tint = "dark",
|
tint = "dark",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
maxWidth = null,
|
maxWidth = null,
|
||||||
|
size = "medium",
|
||||||
}) => {
|
}) => {
|
||||||
|
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
|
||||||
|
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize];
|
||||||
|
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
|
||||||
|
const iconSize = resolvedSize === "small" ? 14 : 16;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -34,7 +52,7 @@ const BorderGradientButton = ({
|
|||||||
locations: [0, 1],
|
locations: [0, 1],
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
height: 50,
|
height: buttonHeight,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
@@ -61,10 +79,10 @@ const BorderGradientButton = ({
|
|||||||
gap: 11,
|
gap: 11,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon && <Image source={icon} style={size({ size: 16 })} />}
|
{icon && <Image source={icon} style={size({ size: iconSize })} />}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 15,
|
fontSize,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
...titleStyle,
|
...titleStyle,
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ import { FONT_FAMILY } from "../styles/Fonts";
|
|||||||
import { size } from "../styles/Style";
|
import { size } from "../styles/Style";
|
||||||
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
import { LinearGradient } from "./LinearGradient/LinearGradient";
|
||||||
|
|
||||||
|
const HEIGHT_BY_SIZE = {
|
||||||
|
small: 44,
|
||||||
|
medium: 50,
|
||||||
|
large: 58,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT_SIZE_BY_SIZE = {
|
||||||
|
small: 14,
|
||||||
|
medium: 15,
|
||||||
|
large: 17,
|
||||||
|
};
|
||||||
|
|
||||||
const GradientButton = ({
|
const GradientButton = ({
|
||||||
title = "",
|
title = "",
|
||||||
colors = ["#F94697", "#7023F7"],
|
colors = ["#F94697", "#7023F7"],
|
||||||
@@ -14,7 +26,14 @@ const GradientButton = ({
|
|||||||
icon,
|
icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
maxWidth = null,
|
maxWidth = null,
|
||||||
|
size = "medium",
|
||||||
|
textStyle = {},
|
||||||
|
gradientStyle = {},
|
||||||
}) => {
|
}) => {
|
||||||
|
const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium";
|
||||||
|
const buttonHeight = HEIGHT_BY_SIZE[resolvedSize];
|
||||||
|
const fontSize = FONT_SIZE_BY_SIZE[resolvedSize];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -30,9 +49,10 @@ const GradientButton = ({
|
|||||||
style={{
|
style={{
|
||||||
...Style.containerCenter,
|
...Style.containerCenter,
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
height: 50,
|
height: buttonHeight,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
|
...gradientStyle,
|
||||||
}}
|
}}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
@@ -41,10 +61,11 @@ const GradientButton = ({
|
|||||||
{icon && <Image source={icon} style={size({ size: 16 })} />}
|
{icon && <Image source={icon} style={size({ size: 16 })} />}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 15,
|
fontSize,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
|
...textStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ const ListSelection = ({
|
|||||||
const itemKey = buildItemKey(String(val ?? ""), cat);
|
const itemKey = buildItemKey(String(val ?? ""), cat);
|
||||||
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
|
const isHovered = isWeb && !disableHover && hoveredKey === itemKey;
|
||||||
const showHoverOutline = isHovered && !sel;
|
const showHoverOutline = isHovered && !sel;
|
||||||
|
const showSelectionOutline = !isWeb && sel;
|
||||||
|
|
||||||
const baseContent = renderSimpleContent(item);
|
const baseContent = renderSimpleContent(item);
|
||||||
const headerContainerStyle = {
|
const headerContainerStyle = {
|
||||||
@@ -205,7 +206,7 @@ const ListSelection = ({
|
|||||||
{withWebBlueGradientIfSelected(baseContent, sel)}
|
{withWebBlueGradientIfSelected(baseContent, sel)}
|
||||||
</CreateLyricsHeader>
|
</CreateLyricsHeader>
|
||||||
|
|
||||||
{showHoverOutline && (
|
{(showHoverOutline || showSelectionOutline) && (
|
||||||
<View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { FONT_FAMILY } from "../../styles/Fonts";
|
|||||||
import { Entypo } from "@expo/vector-icons";
|
import { Entypo } from "@expo/vector-icons";
|
||||||
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 = "Partager l’expérience",
|
||||||
|
}) {
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (typeof onPress === "function") {
|
if (typeof onPress === "function") {
|
||||||
onPress();
|
onPress();
|
||||||
@@ -41,7 +45,7 @@ export default function ShareBtn({ style, onPress = null }) {
|
|||||||
marginRight: 5,
|
marginRight: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{"Partager l’expérience"}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Entypo name="share-alternative" size={16} color="white" />
|
<Entypo name="share-alternative" size={16} color="white" />
|
||||||
</BlurView>
|
</BlurView>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const DeleteAccountModal = () => {
|
|||||||
onPress: () => {
|
onPress: () => {
|
||||||
alert(
|
alert(
|
||||||
"Confirmation",
|
"Confirmation",
|
||||||
"Dernière vérification: supprimer définitivement votre profil ?",
|
"Dernière vérification : souhaitez-vous supprimer définitivement votre profil ?",
|
||||||
[
|
[
|
||||||
{ text: "Non", style: "cancel", onPress: () => {} },
|
{ text: "Non", style: "cancel", onPress: () => {} },
|
||||||
{
|
{
|
||||||
@@ -85,8 +85,8 @@ const DeleteAccountModal = () => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Es-tu sur de vouloir supprimer ton compte?{"\n"}Attention, cette
|
Es-tu sûr de vouloir supprimer ton compte ?{"\n"}Attention, cette
|
||||||
action est irréversible!
|
action est irréversible !
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
|
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useGlobal } from "reactn";
|
||||||
|
import BorderGradientButton from "../BorderGradientButton";
|
||||||
|
import GradientButton from "../GradientButton";
|
||||||
|
import { REPORT_OPTIONS } from "../../constants/reportOptions";
|
||||||
|
import { reportsRef, serverTimestamp } from "../../config/firebase";
|
||||||
|
import { Palette } from "../../styles";
|
||||||
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
|
|
||||||
|
const SHEET_ID = "Report";
|
||||||
|
|
||||||
|
const ReportModal = ({ payload }) => {
|
||||||
|
const [, setTooltip] = useGlobal("_tooltip");
|
||||||
|
const [selectedReason, setSelectedReason] = useState(REPORT_OPTIONS[0].key);
|
||||||
|
const [details, setDetails] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setSelectedReason(REPORT_OPTIONS[0].key);
|
||||||
|
setDetails("");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const targetContext = useMemo(() => {
|
||||||
|
return {
|
||||||
|
type: payload?.targetType || "music",
|
||||||
|
projectId: payload?.projectId || null,
|
||||||
|
playbackId: payload?.playbackId || null,
|
||||||
|
title: payload?.title || "",
|
||||||
|
ownerId: payload?.ownerId || null,
|
||||||
|
};
|
||||||
|
}, [payload]);
|
||||||
|
|
||||||
|
const closeSheet = useCallback(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
Promise.resolve(SheetManager.hide(SHEET_ID))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
resetForm();
|
||||||
|
});
|
||||||
|
}, [resetForm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
}, [payload]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
if (selectedReason === "other" && !details.trim()) return;
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const selectedOption = REPORT_OPTIONS.find(
|
||||||
|
(option) => option.key === selectedReason,
|
||||||
|
);
|
||||||
|
await reportsRef.add({
|
||||||
|
...targetContext,
|
||||||
|
reason: selectedReason,
|
||||||
|
reasonLabel: selectedOption?.label || selectedReason,
|
||||||
|
details: details.trim() || null,
|
||||||
|
platform: Platform.OS,
|
||||||
|
createdAt: serverTimestamp(),
|
||||||
|
});
|
||||||
|
setTooltip({
|
||||||
|
type: "success",
|
||||||
|
text: "Merci pour ton signalement, notre équipe va l'examiner.",
|
||||||
|
});
|
||||||
|
closeSheet();
|
||||||
|
} catch (error) {
|
||||||
|
setTooltip({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
error?.message ||
|
||||||
|
"Impossible d'envoyer le signalement pour le moment.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
details,
|
||||||
|
closeSheet,
|
||||||
|
isSubmitting,
|
||||||
|
selectedReason,
|
||||||
|
setTooltip,
|
||||||
|
targetContext,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const showDetailsField = selectedReason === "other";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={isVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeSheet}
|
||||||
|
onDismiss={resetForm}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={closeSheet} />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={styles.modalWrapper}
|
||||||
|
>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={{ gap: 8 }}>
|
||||||
|
<Text style={styles.heading}>Signaler ce contenu</Text>
|
||||||
|
{targetContext?.title ? (
|
||||||
|
<Text style={styles.subheading}>{targetContext.title}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{REPORT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = option.key === selectedReason;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.key}
|
||||||
|
style={[
|
||||||
|
styles.optionRow,
|
||||||
|
isSelected ? styles.optionRowSelected : null,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedReason(option.key)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.radio,
|
||||||
|
isSelected ? styles.radioSelected : null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.optionLabel}>{option.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showDetailsField ? (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Text style={styles.fieldLabel}>Détails (optionnel)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textArea}
|
||||||
|
placeholder="Explique-nous rapidement le problème…"
|
||||||
|
placeholderTextColor="rgba(255, 255, 255, 0.55)"
|
||||||
|
multiline
|
||||||
|
onChangeText={setDetails}
|
||||||
|
value={details}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<GradientButton
|
||||||
|
title={isSubmitting ? "Envoi en cours…" : "Envoyer"}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
(selectedReason === "other" && details.trim().length === 0)
|
||||||
|
}
|
||||||
|
gradientStyle={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
<BorderGradientButton
|
||||||
|
title="Annuler"
|
||||||
|
onPress={closeSheet}
|
||||||
|
containerStyle={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
modalWrapper: {
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
gap: 20,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: "rgba(18, 16, 24, 0.94)",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
subheading: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Palette.transparentWhite,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
optionRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
optionRowSelected: {
|
||||||
|
backgroundColor: "rgba(251, 104, 168, 0.18)",
|
||||||
|
},
|
||||||
|
radio: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: Palette.transparentWhite,
|
||||||
|
},
|
||||||
|
radioSelected: {
|
||||||
|
borderColor: Palette.primary,
|
||||||
|
backgroundColor: Palette.primary,
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.transparentWhite,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
minHeight: 90,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.12)",
|
||||||
|
padding: 12,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.25)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ReportModal;
|
||||||
@@ -52,6 +52,7 @@ export const playlistsRef = firestore.collection("playlists");
|
|||||||
export const chatsRef = firestore.collection("chats");
|
export const chatsRef = firestore.collection("chats");
|
||||||
export const videosRef = firestore.collection("videos");
|
export const videosRef = firestore.collection("videos");
|
||||||
export const notificationsRef = firestore.collection("notifications");
|
export const notificationsRef = firestore.collection("notifications");
|
||||||
|
export const reportsRef = firestore.collection("reports");
|
||||||
|
|
||||||
export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
|
export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
|
||||||
firebase.firestore.FieldValue;
|
firebase.firestore.FieldValue;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Palette } from "../styles";
|
import { Palette } from "../styles";
|
||||||
|
import { musiclandProductionBaseUrl } from "../data";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
currentUID: null,
|
currentUID: null,
|
||||||
@@ -16,6 +17,7 @@ export default {
|
|||||||
url: null,
|
url: null,
|
||||||
title: "",
|
title: "",
|
||||||
},
|
},
|
||||||
|
productionBaseUrl: musiclandProductionBaseUrl,
|
||||||
|
|
||||||
_isLoading: false,
|
_isLoading: false,
|
||||||
_loadingMessage: null,
|
_loadingMessage: null,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const REPORT_OPTIONS = [
|
||||||
|
{
|
||||||
|
key: "copyright",
|
||||||
|
label: "Ce morceau enfreint mes droits d'auteur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "problematic_content",
|
||||||
|
label: "Le contenu de ce morceau est problématique",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "spam",
|
||||||
|
label: "Il s'agit de spam ou de contenu promotionnel non sollicité",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
label: "Autre…",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const strings = {
|
||||||
|
writing: {
|
||||||
|
steps: {
|
||||||
|
contextTitle: "Étape 2: Quel est le contexte ?",
|
||||||
|
contextSubtitle:
|
||||||
|
"Ajoute quelques détails pour nous guider (ex.: anniversaire, équipe de sport ou d’entreprise…)",
|
||||||
|
contextExamples:
|
||||||
|
"Exemples : « Anniversaire de Clara – ton joyeux et pop », « Hymne d’équipe – style rap énergique », « Esprit d’entreprise – ambiance motivante »",
|
||||||
|
emotionTitle: "Étape 3: Quelle émotion veux-tu transmettre ?",
|
||||||
|
emotionSubtitle: "Sélectionne une seule intention émotionnelle.",
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
otherObjective: "As-tu un autre objectif ?",
|
||||||
|
otherObjectivePlaceholder: "Décrire l’objectif",
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
heroTitle: "Bienvenue dans ton atelier d'écriture",
|
||||||
|
heroSubtitle:
|
||||||
|
"C'est ici qu'ensemble nous allons écrire ta chanson, alors vas-y, suis le Guide et Étape 1 Quel est ton objectif?",
|
||||||
|
primaryCta: "Je crée mes paroles",
|
||||||
|
secondaryCta: "J’ai déjà mes paroles",
|
||||||
|
secondaryNote:
|
||||||
|
"*Si tu as déjà des paroles tu auras l’occasion de les intégrer le moment venu*",
|
||||||
|
},
|
||||||
|
lyrics: {
|
||||||
|
title: "Proposition de paroles",
|
||||||
|
instructions:
|
||||||
|
"Relis et personnalise les paroles proposées. Tu peux soit personnaliser la version actuelle, soit lancer une nouvelle génération.",
|
||||||
|
personalizationBanner:
|
||||||
|
"Personnalisation : tu peux modifier chaque section et si tu as tes paroles intègre les à la structure proposée",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default strings;
|
||||||
+3
-1
@@ -13,7 +13,9 @@ export const firebaseDashboardUrl =
|
|||||||
export const appleAppStoreUrl = "https://apps.apple.com/app";
|
export const appleAppStoreUrl = "https://apps.apple.com/app";
|
||||||
export const algoliaAppUrl = "https://dashboard.algolia.com/apps";
|
export const algoliaAppUrl = "https://dashboard.algolia.com/apps";
|
||||||
|
|
||||||
export const musiclandShareUrl = "https://musicland.ai";
|
export const musiclandProductionBaseUrl =
|
||||||
|
"https://musicland-one.vercel.app";
|
||||||
|
export const musiclandShareUrl = musiclandProductionBaseUrl;
|
||||||
export const musiclandShareHeading = "Partagez l'expérience MusicLand";
|
export const musiclandShareHeading = "Partagez l'expérience MusicLand";
|
||||||
export const musiclandShareMessage =
|
export const musiclandShareMessage =
|
||||||
"Invite tes proches a decouvrir MusicLand et cree des experiences musicales ensemble.";
|
"Invite tes proches a decouvrir MusicLand et cree des experiences musicales ensemble.";
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const HIDDEN_ROUTE_NAMES = new Set([
|
|||||||
Routes.ChangePassword,
|
Routes.ChangePassword,
|
||||||
Routes.Notifications,
|
Routes.Notifications,
|
||||||
Routes.Language,
|
Routes.Language,
|
||||||
|
Routes.Login,
|
||||||
|
Routes.Register,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const noopAsync = async () => {};
|
const noopAsync = async () => {};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useContext, useState, useEffect } from "reactn";
|
|||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { UserDataContext } from "./UserDataProvider";
|
import { UserDataContext } from "./UserDataProvider";
|
||||||
import { isWeb } from "../hooks/useLayoutType";
|
import { isWeb } from "../hooks/useLayoutType";
|
||||||
import { navigateToTask } from "../navigation/NavigationService";
|
import { navigate, navigateToTask } from "../navigation/NavigationService";
|
||||||
|
import { Routes } from "../navigation/Routes";
|
||||||
import { SplashAnimationContext } from "./SplashAnimationProvider";
|
import { SplashAnimationContext } from "./SplashAnimationProvider";
|
||||||
const appJson = require("../../app.json");
|
const appJson = require("../../app.json");
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const UniversalLinkProvider = ({ children }) => {
|
|||||||
const { isFullyLoaded } = useContext(SplashAnimationContext);
|
const { isFullyLoaded } = useContext(SplashAnimationContext);
|
||||||
|
|
||||||
const [tempTaskData, setTempTaskData] = useState(null);
|
const [tempTaskData, setTempTaskData] = useState(null);
|
||||||
|
const [pendingMusicId, setPendingMusicId] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDeepLink = async (event) => {
|
const handleDeepLink = async (event) => {
|
||||||
@@ -51,13 +53,13 @@ const UniversalLinkProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Redirection error:", error);
|
console.error("Redirection error:", error);
|
||||||
handleParams(path);
|
handleParams(path, queryParams);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handleParams(path);
|
handleParams(path, queryParams);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handleParams(path);
|
handleParams(path, queryParams);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,8 +88,31 @@ const UniversalLinkProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [tempTaskData, currentUID, isFullyLoaded]);
|
}, [tempTaskData, currentUID, isFullyLoaded]);
|
||||||
|
|
||||||
const handleParams = (path) => {
|
useEffect(() => {
|
||||||
// console.log("path", path);
|
if (!pendingMusicId || !isFullyLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(Routes.MusicDetails, {
|
||||||
|
projectId: pendingMusicId,
|
||||||
|
autoPlay: true,
|
||||||
|
action: "share",
|
||||||
|
});
|
||||||
|
setPendingMusicId(null);
|
||||||
|
}, [isFullyLoaded, pendingMusicId]);
|
||||||
|
|
||||||
|
const handleParams = (path, _queryParams = {}) => {
|
||||||
|
if (typeof path === "string" && path.length > 0) {
|
||||||
|
const normalized = path.replace(/^\/+/, "");
|
||||||
|
const [first, second] = normalized.split("/");
|
||||||
|
if (first?.toLowerCase() === "music" && second) {
|
||||||
|
try {
|
||||||
|
setPendingMusicId(decodeURIComponent(second));
|
||||||
|
} catch (_) {
|
||||||
|
setPendingMusicId(second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cleanURL();
|
cleanURL();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
|
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||||
import Slider from "../../components/Slider";
|
import Slider from "../../components/Slider";
|
||||||
import {
|
import {
|
||||||
arrayRemove,
|
arrayRemove,
|
||||||
@@ -37,6 +38,11 @@ import {
|
|||||||
getSegmentMeta,
|
getSegmentMeta,
|
||||||
normalizeStructureType,
|
normalizeStructureType,
|
||||||
} from "../../utils/songStructure";
|
} from "../../utils/songStructure";
|
||||||
|
import {
|
||||||
|
createMusicSharePayload,
|
||||||
|
openShareSheet,
|
||||||
|
} from "../../utils/shareSheet";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
|
||||||
// 20 secondes
|
// 20 secondes
|
||||||
const timeBeforeIncrement = 20000;
|
const timeBeforeIncrement = 20000;
|
||||||
@@ -44,6 +50,7 @@ const timeBeforeIncrement = 20000;
|
|||||||
const MusicDetails = ({ route }) => {
|
const MusicDetails = ({ route }) => {
|
||||||
const { params } = route || {};
|
const { params } = route || {};
|
||||||
const action = params?.action;
|
const action = params?.action;
|
||||||
|
const autoPlayRequested = params?.autoPlay;
|
||||||
const projectId = params?.projectId || null;
|
const projectId = params?.projectId || null;
|
||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
@@ -51,6 +58,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
const listenedMsRef = useRef(0);
|
const listenedMsRef = useRef(0);
|
||||||
const incrementDoneRef = useRef(false);
|
const incrementDoneRef = useRef(false);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
|
const hasAutoPlayedRef = useRef(false);
|
||||||
|
|
||||||
const { data: project } = useDataFromRef({
|
const { data: project } = useDataFromRef({
|
||||||
ref: projectId ? projectsRef.doc(projectId) : null,
|
ref: projectId ? projectsRef.doc(projectId) : null,
|
||||||
@@ -105,6 +113,33 @@ const MusicDetails = ({ route }) => {
|
|||||||
};
|
};
|
||||||
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
||||||
|
|
||||||
|
const sharePayload = useMemo(
|
||||||
|
() =>
|
||||||
|
createMusicSharePayload({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
}),
|
||||||
|
[projectId, title, artist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShare = useCallback(() => {
|
||||||
|
if (!sharePayload) return;
|
||||||
|
openShareSheet(sharePayload);
|
||||||
|
}, [sharePayload]);
|
||||||
|
|
||||||
|
const handleReport = useCallback(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
SheetManager.show("Report", {
|
||||||
|
payload: {
|
||||||
|
targetType: "music",
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
ownerId: project?.userId || owner?.id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [owner?.id, project?.userId, projectId, title]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isCurrent: isCurrentTrack,
|
isCurrent: isCurrentTrack,
|
||||||
isPlaying: isTrackPlaying,
|
isPlaying: isTrackPlaying,
|
||||||
@@ -137,6 +172,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listenedMsRef.current = 0;
|
listenedMsRef.current = 0;
|
||||||
incrementDoneRef.current = false;
|
incrementDoneRef.current = false;
|
||||||
|
hasAutoPlayedRef.current = false;
|
||||||
}, [trackId]);
|
}, [trackId]);
|
||||||
|
|
||||||
// Start/stop a timer to accumulate listened milliseconds while playing
|
// Start/stop a timer to accumulate listened milliseconds while playing
|
||||||
@@ -247,6 +283,34 @@ const MusicDetails = ({ route }) => {
|
|||||||
[durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo]
|
[durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoPlayRequested || hasAutoPlayedRef.current) return;
|
||||||
|
if (!trackDescriptor || !songUrl) return;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
await ensureLoaded({ startPositionMs: 0, autoPlay: true });
|
||||||
|
} else if (!isTrackPlaying) {
|
||||||
|
await resumeTrack();
|
||||||
|
}
|
||||||
|
hasAutoPlayedRef.current = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("MusicDetails autoplay error", e?.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
}, [
|
||||||
|
autoPlayRequested,
|
||||||
|
trackDescriptor,
|
||||||
|
songUrl,
|
||||||
|
isCurrentTrack,
|
||||||
|
ensureLoaded,
|
||||||
|
isTrackPlaying,
|
||||||
|
resumeTrack,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSliderSeekEnd = useCallback(async () => {
|
const handleSliderSeekEnd = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (wasPlayingBeforeSeek.current) {
|
if (wasPlayingBeforeSeek.current) {
|
||||||
@@ -627,17 +691,24 @@ const MusicDetails = ({ route }) => {
|
|||||||
style={styles.img}
|
style={styles.img}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<View style={{ ...Style.containerSpaceBetween }}>
|
<View style={{ gap: 12 }}>
|
||||||
<View>
|
<View
|
||||||
<Text style={styles.title}>{title}</Text>
|
style={{
|
||||||
<Text style={styles.name}>{artist}</Text>
|
...Style.containerRow,
|
||||||
</View>
|
justifyContent: "space-between",
|
||||||
<View style={{ ...Style.containerRow, gap: 12 }}>
|
alignItems: "center",
|
||||||
<PressableScale
|
}}
|
||||||
onPress={async () => {
|
>
|
||||||
if (!projectId || !currentUID) return;
|
<View>
|
||||||
const next = !fav;
|
<Text style={styles.title}>{title}</Text>
|
||||||
setFav(next);
|
<Text style={styles.name}>{artist}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ ...Style.containerRow, gap: 16 }}>
|
||||||
|
<PressableScale
|
||||||
|
onPress={async () => {
|
||||||
|
if (!projectId || !currentUID) return;
|
||||||
|
const next = !fav;
|
||||||
|
setFav(next);
|
||||||
try {
|
try {
|
||||||
await projectsRef.doc(projectId).update({
|
await projectsRef.doc(projectId).update({
|
||||||
likedBy: next
|
likedBy: next
|
||||||
@@ -655,6 +726,12 @@ const MusicDetails = ({ route }) => {
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
|
<PressableScale onPress={handleReport}>
|
||||||
|
<Feather name="flag" size={22} color={Palette.white} style={{ marginHorizontal: 2 }} />
|
||||||
|
</PressableScale>
|
||||||
|
{!!sharePayload && (
|
||||||
|
<ShareBtn style={{ borderRadius: 18 }} onPress={handleShare} />
|
||||||
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
SheetManager.show("Playlist", { payload: { projectId } })
|
SheetManager.show("Playlist", { payload: { projectId } })
|
||||||
@@ -666,6 +743,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useGlobal } from "reactn";
|
|||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
import Slider from "../../components/Slider";
|
import Slider from "../../components/Slider";
|
||||||
|
import Svg, { Circle } from "react-native-svg";
|
||||||
import {
|
import {
|
||||||
arrayRemove,
|
arrayRemove,
|
||||||
arrayUnion,
|
arrayUnion,
|
||||||
@@ -45,6 +46,11 @@ import {
|
|||||||
normalizeStructureType,
|
normalizeStructureType,
|
||||||
segmentRequiresLyrics,
|
segmentRequiresLyrics,
|
||||||
} from "../../utils/songStructure";
|
} from "../../utils/songStructure";
|
||||||
|
import {
|
||||||
|
createMusicSharePayload,
|
||||||
|
openShareSheet,
|
||||||
|
} from "../../utils/shareSheet";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
|
||||||
// 20 secondes
|
// 20 secondes
|
||||||
const timeBeforeIncrement = 20000;
|
const timeBeforeIncrement = 20000;
|
||||||
@@ -52,14 +58,17 @@ const timeBeforeIncrement = 20000;
|
|||||||
const MusicDetails = ({ route }) => {
|
const MusicDetails = ({ route }) => {
|
||||||
const { params } = route || {};
|
const { params } = route || {};
|
||||||
const action = params?.action;
|
const action = params?.action;
|
||||||
|
const autoPlayRequested = params?.autoPlay;
|
||||||
const projectId = params?.projectId || null;
|
const projectId = params?.projectId || null;
|
||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
|
const [, setTooltip] = useGlobal("_tooltip");
|
||||||
const wasPlayingBeforeSeek = React.useRef(false);
|
const wasPlayingBeforeSeek = React.useRef(false);
|
||||||
const lastSeekTargetMs = React.useRef(null);
|
const lastSeekTargetMs = React.useRef(null);
|
||||||
const listenedMsRef = React.useRef(0);
|
const listenedMsRef = React.useRef(0);
|
||||||
const incrementDoneRef = React.useRef(false);
|
const incrementDoneRef = React.useRef(false);
|
||||||
const timerRef = React.useRef(null);
|
const timerRef = React.useRef(null);
|
||||||
|
const hasAutoPlayedRef = React.useRef(false);
|
||||||
|
|
||||||
const { data: project } = useDataFromRef({
|
const { data: project } = useDataFromRef({
|
||||||
ref: projectId ? projectsRef.doc(projectId) : null,
|
ref: projectId ? projectsRef.doc(projectId) : null,
|
||||||
@@ -116,6 +125,108 @@ const MusicDetails = ({ route }) => {
|
|||||||
};
|
};
|
||||||
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
||||||
|
|
||||||
|
const sharePayload = useMemo(
|
||||||
|
() =>
|
||||||
|
createMusicSharePayload({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
}),
|
||||||
|
[projectId, title, artist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShare = useCallback(() => {
|
||||||
|
if (!sharePayload) return;
|
||||||
|
openShareSheet(sharePayload);
|
||||||
|
}, [sharePayload]);
|
||||||
|
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (!songUrl || isDownloading) return;
|
||||||
|
try {
|
||||||
|
setIsDownloading(true);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
const response = await fetch(songUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`download_failed_${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") || "audio/mpeg";
|
||||||
|
const total = Number(response.headers.get("content-length")) || 0;
|
||||||
|
const sanitizedTitle = String(title || "musicland-track")
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 80);
|
||||||
|
const fileName = `${sanitizedTitle || "musicland-track"}.mp3`;
|
||||||
|
|
||||||
|
if (response.body && typeof response.body.getReader === "function") {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks = [];
|
||||||
|
let received = 0;
|
||||||
|
let pseudoProgress = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) {
|
||||||
|
chunks.push(value);
|
||||||
|
received += value.length;
|
||||||
|
if (total > 0) {
|
||||||
|
setDownloadProgress(Math.min(1, received / total));
|
||||||
|
} else {
|
||||||
|
pseudoProgress = Math.min(0.95, pseudoProgress + 0.05);
|
||||||
|
setDownloadProgress(pseudoProgress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blob = new Blob(chunks, { type: contentType });
|
||||||
|
setDownloadProgress(1);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
const blob = await response.blob();
|
||||||
|
setDownloadProgress(1);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MusicDetails.download", error);
|
||||||
|
setTooltip({
|
||||||
|
type: "error",
|
||||||
|
text: "Téléchargement impossible",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
setTimeout(() => setDownloadProgress(0), 400);
|
||||||
|
}
|
||||||
|
}, [isDownloading, setTooltip, songUrl, title]);
|
||||||
|
|
||||||
|
const handleReport = useCallback(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
SheetManager.show("Report", {
|
||||||
|
payload: {
|
||||||
|
targetType: "music",
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
ownerId: project?.userId || owner?.id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [owner?.id, project?.userId, projectId, title]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isCurrent: isCurrentTrack,
|
isCurrent: isCurrentTrack,
|
||||||
isPlaying: isTrackPlaying,
|
isPlaying: isTrackPlaying,
|
||||||
@@ -150,6 +261,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listenedMsRef.current = 0;
|
listenedMsRef.current = 0;
|
||||||
incrementDoneRef.current = false;
|
incrementDoneRef.current = false;
|
||||||
|
hasAutoPlayedRef.current = false;
|
||||||
}, [trackId]);
|
}, [trackId]);
|
||||||
|
|
||||||
// Start/stop a timer to accumulate listened milliseconds while playing
|
// Start/stop a timer to accumulate listened milliseconds while playing
|
||||||
@@ -282,6 +394,38 @@ const MusicDetails = ({ route }) => {
|
|||||||
[trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
|
[trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoPlayRequested || hasAutoPlayedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trackDescriptor || !songUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
if (!isCurrentTrack) {
|
||||||
|
await ensureLoaded({ startPositionMs: 0, autoPlay: true });
|
||||||
|
} else if (!isPlaying) {
|
||||||
|
await resumeTrack();
|
||||||
|
}
|
||||||
|
hasAutoPlayedRef.current = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("MusicDetails autoplay error", e?.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
}, [
|
||||||
|
autoPlayRequested,
|
||||||
|
ensureLoaded,
|
||||||
|
isCurrentTrack,
|
||||||
|
isPlaying,
|
||||||
|
resumeTrack,
|
||||||
|
songUrl,
|
||||||
|
trackDescriptor,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSliderSeekEnd = useCallback(async () => {
|
const handleSliderSeekEnd = useCallback(async () => {
|
||||||
const targetMs =
|
const targetMs =
|
||||||
typeof lastSeekTargetMs.current === "number"
|
typeof lastSeekTargetMs.current === "number"
|
||||||
@@ -677,6 +821,14 @@ const MusicDetails = ({ route }) => {
|
|||||||
backgroundImg={
|
backgroundImg={
|
||||||
action === "userProfile" ? background.profileBG : background.libraryBG2
|
action === "userProfile" ? background.profileBG : background.libraryBG2
|
||||||
}
|
}
|
||||||
|
shareBtn={
|
||||||
|
sharePayload
|
||||||
|
? {
|
||||||
|
label: "Partager le morceau",
|
||||||
|
onPress: handleShare,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
{...(action === "userProfile" && {
|
{...(action === "userProfile" && {
|
||||||
containerStyle: {
|
containerStyle: {
|
||||||
backgroundColor: "#0000004D",
|
backgroundColor: "#0000004D",
|
||||||
@@ -761,6 +913,45 @@ const MusicDetails = ({ route }) => {
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<PressableScale onPress={handleReport}>
|
||||||
|
<Feather
|
||||||
|
name="flag"
|
||||||
|
size={22}
|
||||||
|
color={Palette.white}
|
||||||
|
style={{ marginHorizontal: 2 }}
|
||||||
|
/>
|
||||||
|
</PressableScale>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<DownloadProgressRing
|
||||||
|
progress={downloadProgress}
|
||||||
|
size={36}
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDownload}
|
||||||
|
disabled={isDownloading || !songUrl}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.08)",
|
||||||
|
opacity: isDownloading || !songUrl ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name="download" size={18} color={Palette.white} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
SheetManager.show("Playlist", { payload: { projectId } })
|
SheetManager.show("Playlist", { payload: { projectId } })
|
||||||
@@ -975,3 +1166,38 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DownloadProgressRing = ({ progress = 0, size = 36, strokeWidth = 3 }) => {
|
||||||
|
const radius = size / 2 - strokeWidth / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const clamped = Math.max(0, Math.min(1, progress || 0));
|
||||||
|
const offset = circumference * (1 - clamped);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
style={{ position: "absolute", top: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="rgba(255, 255, 255, 0.15)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={Palette.primary}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${circumference} ${circumference}`}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
LIKE_TARGET,
|
LIKE_TARGET,
|
||||||
toggleProjectLike,
|
toggleProjectLike,
|
||||||
} from "../../utils/likes";
|
} from "../../utils/likes";
|
||||||
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
|
||||||
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
||||||
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
||||||
@@ -142,6 +144,18 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
}, [videoPlayer]);
|
}, [videoPlayer]);
|
||||||
|
const handleReport = useCallback(() => {
|
||||||
|
if (!item?.id) return;
|
||||||
|
SheetManager.show("Report", {
|
||||||
|
payload: {
|
||||||
|
targetType: "playback",
|
||||||
|
projectId: item?.id || null,
|
||||||
|
playbackId: item?.id || null,
|
||||||
|
title: item?.title || "",
|
||||||
|
ownerId: item?.userId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [item?.id, item?.title, item?.userId]);
|
||||||
|
|
||||||
// Build alignedWords from item musicTimestamps
|
// Build alignedWords from item musicTimestamps
|
||||||
const alignedWords = useMemo(() => {
|
const alignedWords = useMemo(() => {
|
||||||
@@ -365,6 +379,20 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
|||||||
>
|
>
|
||||||
<Image source={icons.share} style={size({ size: 26 })} />
|
<Image source={icons.share} style={size({ size: 26 })} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable onPress={handleReport} style={{ alignItems: "center" }}>
|
||||||
|
<Feather name="flag" size={26} color={Palette.white} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 4,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Signaler
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ paddingHorizontal: 28 }}>
|
<View style={{ paddingHorizontal: 28 }}>
|
||||||
<BlurView
|
<BlurView
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
LIKE_TARGET,
|
LIKE_TARGET,
|
||||||
toggleProjectLike,
|
toggleProjectLike,
|
||||||
} from "../../../utils/likes";
|
} from "../../../utils/likes";
|
||||||
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
|
||||||
// Debug logging toggle for web playback
|
// Debug logging toggle for web playback
|
||||||
const DEBUG_PLAYBACK_WEB = true;
|
const DEBUG_PLAYBACK_WEB = true;
|
||||||
@@ -252,6 +254,19 @@ const PlaybackItem = ({
|
|||||||
const descriptionText =
|
const descriptionText =
|
||||||
item?.description || item?.title || "Description chanson";
|
item?.description || item?.title || "Description chanson";
|
||||||
|
|
||||||
|
const handleReport = useCallback(() => {
|
||||||
|
if (!item?.id) return;
|
||||||
|
SheetManager.show("Report", {
|
||||||
|
payload: {
|
||||||
|
targetType: "playback",
|
||||||
|
projectId: item?.id || null,
|
||||||
|
playbackId: item?.id || null,
|
||||||
|
title: item?.title || "",
|
||||||
|
ownerId: item?.userId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [item?.id, item?.title, item?.userId]);
|
||||||
|
|
||||||
const handleLayout = useCallback((event) => {
|
const handleLayout = useCallback((event) => {
|
||||||
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
|
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
|
||||||
setLayoutSize((prev) => ({
|
setLayoutSize((prev) => ({
|
||||||
@@ -486,6 +501,13 @@ const PlaybackItem = ({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleReport}
|
||||||
|
style={[styles.actionButton, styles.actionSpacing]}
|
||||||
|
>
|
||||||
|
<Feather name="flag" size={20} color={Palette.white} />
|
||||||
|
<Text style={styles.actionLabel}>Signaler</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.lyricsContainer}>
|
<View style={styles.lyricsContainer}>
|
||||||
|
|||||||
@@ -240,16 +240,7 @@ const GeneratingSong = () => {
|
|||||||
|
|
||||||
if (titleOk && isIdle && !askedRef.current) {
|
if (titleOk && isIdle && !askedRef.current) {
|
||||||
askedRef.current = true;
|
askedRef.current = true;
|
||||||
AppAlert("Attention", "Une génération va être lancée. Continuer ?", [
|
startMusicGenerationOnce();
|
||||||
{
|
|
||||||
text: "Non",
|
|
||||||
style: "cancel",
|
|
||||||
onPress: () => {
|
|
||||||
askedRef.current = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ text: "Oui", onPress: () => startMusicGenerationOnce() },
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isFocused,
|
isFocused,
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import MusicLandHeader from "../../components/MusicLandHeader";
|
|||||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
|
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
|
||||||
import useLayoutType from "../../hooks/useLayoutType";
|
import useLayoutType from "../../hooks/useLayoutType";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { goBack } from "../../navigation/NavigationService";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
|
||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
import { sanitizeStructureList } from "../../utils/songStructure";
|
import { sanitizeStructureList } from "../../utils/songStructure";
|
||||||
@@ -23,13 +22,13 @@ import SongStyle from "./SongStyle";
|
|||||||
import SongTo from "./SongTo";
|
import SongTo from "./SongTo";
|
||||||
import SpecificityContext from "./SpecificityContext";
|
import SpecificityContext from "./SpecificityContext";
|
||||||
const { width: windowWidth } = Dimensions.get("window");
|
const { width: windowWidth } = Dimensions.get("window");
|
||||||
|
const MAX_STEP_INDEX = 8;
|
||||||
|
|
||||||
const CreateLyricsWithAi = () => {
|
const CreateLyricsWithAi = () => {
|
||||||
const { isWeb } = useLayoutType();
|
const { isWeb } = useLayoutType();
|
||||||
const { selectedProject, updateProjectData } = useUser();
|
const { selectedProject } = useUser();
|
||||||
const hasLyrics = selectedProject?.hasLyrics === true;
|
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [progress, setProgress] = useState(16);
|
const [progress, setProgress] = useState(16);
|
||||||
const [parentLayout, setparentLayout] = useState(null);
|
const [parentLayout, setparentLayout] = useState(null);
|
||||||
const containerWidth = windowWidth;
|
const containerWidth = windowWidth;
|
||||||
@@ -188,13 +187,6 @@ const CreateLyricsWithAi = () => {
|
|||||||
}
|
}
|
||||||
}, [structure]);
|
}, [structure]);
|
||||||
|
|
||||||
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (hasLyrics && selectedIndex < 5) {
|
|
||||||
setSelectedIndex(5);
|
|
||||||
}
|
|
||||||
}, [hasLyrics]);
|
|
||||||
|
|
||||||
const lyricsConfig = useMemo(() => {
|
const lyricsConfig = useMemo(() => {
|
||||||
const resolvedObjective = shouldUseOtherObjective
|
const resolvedObjective = shouldUseOtherObjective
|
||||||
? persistedOtherObjective || undefined
|
? persistedOtherObjective || undefined
|
||||||
@@ -297,37 +289,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const onPressNext = async () => {
|
const onPressNext = async () => {
|
||||||
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
|
setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX));
|
||||||
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
|
|
||||||
if (hasLyrics && selectedIndex === 6) {
|
|
||||||
const chosenStructure = sanitizeStructureList(
|
|
||||||
Array.isArray(customStructure) && customStructure.length > 0
|
|
||||||
? customStructure
|
|
||||||
: Array.isArray(parsedStructure)
|
|
||||||
? parsedStructure
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
await updateProjectData({
|
|
||||||
config: { structure: chosenStructure },
|
|
||||||
selections: {
|
|
||||||
objective,
|
|
||||||
otherObjective: persistedOtherObjective,
|
|
||||||
context,
|
|
||||||
emotion,
|
|
||||||
style,
|
|
||||||
otherStyle: persistedOtherStyle,
|
|
||||||
audience,
|
|
||||||
structure,
|
|
||||||
parsedStructure,
|
|
||||||
customStructure: sanitizeStructureList(customStructure),
|
|
||||||
rhymes,
|
|
||||||
},
|
|
||||||
hasLyrics: true,
|
|
||||||
});
|
|
||||||
navigate(Routes.Lyrics);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedIndex((idx) => idx + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPressBack = () => {
|
const onPressBack = () => {
|
||||||
@@ -344,14 +306,36 @@ const CreateLyricsWithAi = () => {
|
|||||||
// Sync scroll position and progress with selectedIndex
|
// Sync scroll position and progress with selectedIndex
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!parentLayout?.height) return;
|
if (!parentLayout?.height) return;
|
||||||
try {
|
const nextProgress = Math.max(0, Math.min(100, 16 + selectedIndex * 9));
|
||||||
const nextProgress = 16 + selectedIndex * 9;
|
setProgress(nextProgress);
|
||||||
setProgress(nextProgress);
|
|
||||||
scrollRef.current?.scrollToIndex?.({
|
const ref = scrollRef.current;
|
||||||
index: selectedIndex,
|
if (!ref || typeof ref.scrollToIndex !== "function") {
|
||||||
animated: true,
|
return;
|
||||||
});
|
}
|
||||||
} catch (_) {}
|
|
||||||
|
let cancelled = false;
|
||||||
|
const attemptScroll = (retries = 0) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
ref.scrollToIndex({ index: selectedIndex, animated: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (retries < 3) {
|
||||||
|
setTimeout(() => attemptScroll(retries + 1), 40);
|
||||||
|
} else {
|
||||||
|
console.warn("[CreateLyricsWithAi] scrollToIndex failed", {
|
||||||
|
index: selectedIndex,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [selectedIndex, parentLayout?.height]);
|
}, [selectedIndex, parentLayout?.height]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import GradientButton from "../../components/GradientButton";
|
|||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
|
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { goBack } from "../../navigation/NavigationService";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
|
||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { gutters } from "../../styles";
|
import { gutters } from "../../styles";
|
||||||
import { sanitizeStructureList } from "../../utils/songStructure";
|
import { sanitizeStructureList } from "../../utils/songStructure";
|
||||||
@@ -21,10 +20,11 @@ import SongStyle from "./SongStyle";
|
|||||||
import SongTo from "./SongTo";
|
import SongTo from "./SongTo";
|
||||||
import SpecificityContext from "./SpecificityContext";
|
import SpecificityContext from "./SpecificityContext";
|
||||||
|
|
||||||
|
const MAX_STEP_INDEX = 8;
|
||||||
|
|
||||||
const CreateLyricsWithAi = () => {
|
const CreateLyricsWithAi = () => {
|
||||||
const { selectedProject, updateProjectData } = useUser();
|
const { selectedProject } = useUser();
|
||||||
const hasLyrics = selectedProject?.hasLyrics === true;
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0);
|
|
||||||
|
|
||||||
// Collected state across steps
|
// Collected state across steps
|
||||||
const [objective, setObjective] = useState(null); // from Goals list
|
const [objective, setObjective] = useState(null); // from Goals list
|
||||||
@@ -187,13 +187,6 @@ const CreateLyricsWithAi = () => {
|
|||||||
}
|
}
|
||||||
}, [structure]);
|
}, [structure]);
|
||||||
|
|
||||||
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (hasLyrics && selectedIndex < 5) {
|
|
||||||
setSelectedIndex(5);
|
|
||||||
}
|
|
||||||
}, [hasLyrics]);
|
|
||||||
|
|
||||||
const progress = useMemo(
|
const progress = useMemo(
|
||||||
() => 16 + selectedIndex * 9,
|
() => 16 + selectedIndex * 9,
|
||||||
[selectedIndex]
|
[selectedIndex]
|
||||||
@@ -301,37 +294,7 @@ const CreateLyricsWithAi = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const onPressNext = async () => {
|
const onPressNext = async () => {
|
||||||
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
|
setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX));
|
||||||
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
|
|
||||||
if (hasLyrics && selectedIndex === 6) {
|
|
||||||
const chosenStructure = sanitizeStructureList(
|
|
||||||
Array.isArray(customStructure) && customStructure.length > 0
|
|
||||||
? customStructure
|
|
||||||
: Array.isArray(parsedStructure)
|
|
||||||
? parsedStructure
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
await updateProjectData({
|
|
||||||
config: { structure: chosenStructure },
|
|
||||||
selections: {
|
|
||||||
objective,
|
|
||||||
otherObjective: persistedOtherObjective,
|
|
||||||
context,
|
|
||||||
emotion,
|
|
||||||
style,
|
|
||||||
otherStyle: persistedOtherStyle,
|
|
||||||
audience,
|
|
||||||
structure,
|
|
||||||
parsedStructure,
|
|
||||||
customStructure: sanitizeStructureList(customStructure),
|
|
||||||
rhymes,
|
|
||||||
},
|
|
||||||
hasLyrics: true,
|
|
||||||
});
|
|
||||||
navigate(Routes.Lyrics);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedIndex((idx) => idx + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPressBack = () => {
|
const onPressBack = () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { View } from "react-native";
|
|||||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||||
import { EMOTION_CONVEY } from "../../data/data";
|
import { EMOTION_CONVEY } from "../../data/data";
|
||||||
|
import { strings } from "../../constants/strings";
|
||||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||||
|
|
||||||
const EmotionConvey = ({
|
const EmotionConvey = ({
|
||||||
@@ -19,8 +20,8 @@ const EmotionConvey = ({
|
|||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
title={`Quelle émotion veux-tu\ntransmettre ?`}
|
title={strings.writing.steps.emotionTitle}
|
||||||
subTitle="Sélectionne une seule intention émotionnelle."
|
subTitle={strings.writing.steps.emotionSubtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Platform, ScrollView, StyleSheet, View } from "react-native";
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||||
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
|
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
|
||||||
|
import { strings } from "../../constants/strings";
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||||
@@ -39,9 +40,14 @@ const Goals = ({
|
|||||||
<ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}>
|
<ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}>
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
<CreateLyricsHeader
|
<CreateLyricsHeader
|
||||||
title="Quel est le contexte ?"
|
title={strings.writing.steps.contextTitle}
|
||||||
subTitle="Besoin d'idées ? Pense à un anniversaire, une équipe de sport ou ton entreprise."
|
subTitle={strings.writing.steps.contextSubtitle}
|
||||||
/>
|
/>
|
||||||
|
<View style={styles.examplesContainer}>
|
||||||
|
<Text style={styles.examplesText}>
|
||||||
|
{strings.writing.steps.contextExamples}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<ItemContainer>
|
<ItemContainer>
|
||||||
<ListSelection
|
<ListSelection
|
||||||
options={goalsOptions}
|
options={goalsOptions}
|
||||||
@@ -55,8 +61,8 @@ const Goals = ({
|
|||||||
</ItemContainer>
|
</ItemContainer>
|
||||||
</View>
|
</View>
|
||||||
<CustomInput
|
<CustomInput
|
||||||
label="As-tu un autre objectif ?"
|
label={strings.writing.labels.otherObjective}
|
||||||
placeholder="Décrire l’objectif"
|
placeholder={strings.writing.labels.otherObjectivePlaceholder}
|
||||||
value={otherObjective}
|
value={otherObjective}
|
||||||
setValue={handleOtherObjectiveChange}
|
setValue={handleOtherObjectiveChange}
|
||||||
height={Platform.OS === "web" ? 160 : undefined}
|
height={Platform.OS === "web" ? 160 : undefined}
|
||||||
@@ -95,4 +101,15 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
examplesContainer: {
|
||||||
|
backgroundColor: Palette.ultraLightWhite,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
examplesText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import GradientButton from "../../components/GradientButton";
|
|||||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import firebase, { projectsRef } from "../../config/firebase";
|
import firebase, { projectsRef } from "../../config/firebase";
|
||||||
|
import { strings } from "../../constants/strings";
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
@@ -363,24 +364,17 @@ const Lyrics = ({ navigation }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
<Text style={styles.headerTitle}>Proposition de paroles</Text>
|
<Text style={styles.headerTitle}>
|
||||||
<Text style={styles.instructions}>
|
{strings.writing.lyrics.title}
|
||||||
Relis attentivement la proposition générée avant de valider.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.instructions}>
|
<Text style={styles.instructions}>
|
||||||
<Text style={styles.instructionsHighlight}>
|
{strings.writing.lyrics.instructions}
|
||||||
Personnalisation :
|
|
||||||
</Text>{" "}
|
|
||||||
modifie le titre et chaque section pour que les paroles te
|
|
||||||
ressemblent.
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.instructions}>
|
|
||||||
<Text style={styles.instructionsHighlight}>
|
|
||||||
Nouvelle génération :
|
|
||||||
</Text>{" "}
|
|
||||||
appuie sur "Générer d'autres paroles" si tu souhaites une autre
|
|
||||||
suggestion.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={styles.personalizationBanner}>
|
||||||
|
<Text style={styles.personalizationText}>
|
||||||
|
{strings.writing.lyrics.personalizationBanner}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<CustomInput
|
<CustomInput
|
||||||
label="Titre"
|
label="Titre"
|
||||||
@@ -489,8 +483,18 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
instructionsHighlight: {
|
personalizationBanner: {
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
backgroundColor: Palette.ultraLightWhite,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
personalizationText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
instrumentalBlock: {
|
instrumentalBlock: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Image, StyleSheet, View } from "react-native";
|
import { Image, 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 { ai, background, icons } from "../../assets";
|
import { ai, background, icons } from "../../assets";
|
||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
@@ -10,22 +10,24 @@ import Page from "../../layouts/Page";
|
|||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||||
import { useUserData } from "../../providers/UserDataProvider";
|
import { useUserData } from "../../providers/UserDataProvider";
|
||||||
import { gutters } from "../../styles";
|
import { strings } from "../../constants/strings";
|
||||||
|
import { Palette, gutters } from "../../styles";
|
||||||
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
|
|
||||||
const WritingLyrics = () => {
|
const WritingLyrics = () => {
|
||||||
const { createNewProject, selectedProject, updateProjectData } =
|
const { createNewProject, selectedProject, updateProjectData } =
|
||||||
useUserData();
|
useUserData();
|
||||||
const { setIsLoading } = useMinuit();
|
const { setIsLoading } = useMinuit();
|
||||||
|
|
||||||
async function createAndNavigate({ hasLyrics = false }) {
|
const startWriting = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await setIsLoading(true);
|
await setIsLoading(true);
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
updateProjectData({ hasLyrics });
|
updateProjectData({ hasLyrics: false });
|
||||||
setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500);
|
setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newProjectId = await createNewProject({ hasLyrics });
|
const newProjectId = await createNewProject({ hasLyrics: false });
|
||||||
if (!newProjectId) {
|
if (!newProjectId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,7 @@ const WritingLyrics = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await setIsLoading(false);
|
await setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [createNewProject, navigate, selectedProject, setIsLoading, updateProjectData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
@@ -48,27 +50,34 @@ const WritingLyrics = () => {
|
|||||||
progress={9}
|
progress={9}
|
||||||
logo={icons.musicLandWriting}
|
logo={icons.musicLandWriting}
|
||||||
/>
|
/>
|
||||||
<View
|
<View style={styles.contentWrapper}>
|
||||||
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
|
<View style={styles.heroContainer}>
|
||||||
>
|
<Text style={styles.heroTitle}>
|
||||||
<View
|
{strings.writing.onboarding.heroTitle}
|
||||||
style={{
|
</Text>
|
||||||
paddingBottom: gutters * 2,
|
<Text style={styles.heroSubtitle}>
|
||||||
paddingHorizontal: gutters,
|
{strings.writing.onboarding.heroSubtitle}
|
||||||
gap: 12,
|
</Text>
|
||||||
}}
|
</View>
|
||||||
>
|
<View style={styles.ctaGroup}>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
maxWidth={500}
|
size="large"
|
||||||
|
maxWidth={520}
|
||||||
containerStyle={{ alignSelf: "center", width: "100%" }}
|
containerStyle={{ alignSelf: "center", width: "100%" }}
|
||||||
title="Écrire des paroles avec une IA"
|
title={strings.writing.onboarding.primaryCta}
|
||||||
onPress={() => createAndNavigate({ hasLyrics: false })}
|
onPress={startWriting}
|
||||||
|
textStyle={{ fontFamily: FONT_FAMILY.InterSemiBold }}
|
||||||
/>
|
/>
|
||||||
<BorderGradientButton
|
<BorderGradientButton
|
||||||
maxWidth={500}
|
size="small"
|
||||||
onPress={() => createAndNavigate({ hasLyrics: true })}
|
title={strings.writing.onboarding.secondaryCta}
|
||||||
|
disabled
|
||||||
containerStyle={{ alignSelf: "center", width: "100%" }}
|
containerStyle={{ alignSelf: "center", width: "100%" }}
|
||||||
|
maxWidth={360}
|
||||||
/>
|
/>
|
||||||
|
<Text style={styles.secondaryNote}>
|
||||||
|
{strings.writing.onboarding.secondaryNote}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
@@ -85,4 +94,39 @@ const styles = StyleSheet.create({
|
|||||||
bottom: -40,
|
bottom: -40,
|
||||||
right: -30,
|
right: -30,
|
||||||
},
|
},
|
||||||
|
contentWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingTop: gutters * 6,
|
||||||
|
paddingBottom: gutters * 2,
|
||||||
|
paddingHorizontal: gutters,
|
||||||
|
gap: 32,
|
||||||
|
},
|
||||||
|
heroContainer: {
|
||||||
|
gap: 12,
|
||||||
|
maxWidth: 560,
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
heroSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
opacity: 0.88,
|
||||||
|
},
|
||||||
|
ctaGroup: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
secondaryNote: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 14,
|
||||||
|
opacity: 0.85,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DeleteAudioModal from "../components/modal/DeleteAudioModal";
|
|||||||
import PlaylistModal from "../components/modal/PlaylistModal";
|
import PlaylistModal from "../components/modal/PlaylistModal";
|
||||||
import PlaybackPickerModal from "../components/modal/PlaybackPickerModal";
|
import PlaybackPickerModal from "../components/modal/PlaybackPickerModal";
|
||||||
import ShareModal from "../components/modal/ShareModal";
|
import ShareModal from "../components/modal/ShareModal";
|
||||||
|
import ReportModal from "../components/modal/ReportModal";
|
||||||
|
|
||||||
registerSheet("Delete", DeleteModal);
|
registerSheet("Delete", DeleteModal);
|
||||||
registerSheet("ProfileSettings", ProfileSettingsModal);
|
registerSheet("ProfileSettings", ProfileSettingsModal);
|
||||||
@@ -16,5 +17,6 @@ registerSheet("DeleteAudio", DeleteAudioModal);
|
|||||||
registerSheet("Playlist", PlaylistModal);
|
registerSheet("Playlist", PlaylistModal);
|
||||||
registerSheet("PlaybackPicker", PlaybackPickerModal);
|
registerSheet("PlaybackPicker", PlaybackPickerModal);
|
||||||
registerSheet("Share", ShareModal);
|
registerSheet("Share", ShareModal);
|
||||||
|
registerSheet("Report", ReportModal);
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -14,6 +14,48 @@ const defaultPayload = {
|
|||||||
qrUrl: musiclandShareUrl,
|
qrUrl: musiclandShareUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trimTrailingSlash = (value) =>
|
||||||
|
typeof value === "string" ? value.replace(/\/+$/, "") : "";
|
||||||
|
|
||||||
|
const resolveBaseShareUrl = () => {
|
||||||
|
if (typeof window !== "undefined" && window.location?.origin) {
|
||||||
|
const origin = window.location.origin;
|
||||||
|
if (/localhost|127\.0\.0\.1/i.test(origin)) {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return musiclandShareUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMusicSharePayload = ({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
} = {}) => {
|
||||||
|
if (!projectId) return null;
|
||||||
|
|
||||||
|
const baseUrl = trimTrailingSlash(resolveBaseShareUrl());
|
||||||
|
const shareUrl = `${baseUrl}/music/${projectId}`;
|
||||||
|
|
||||||
|
let shareMessage = musiclandShareMessage;
|
||||||
|
if (title) {
|
||||||
|
shareMessage = `Découvre "${title}"`;
|
||||||
|
if (artist) {
|
||||||
|
shareMessage += ` de ${artist}`;
|
||||||
|
}
|
||||||
|
shareMessage += " sur MusicLand.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
heading: "Partager le morceau",
|
||||||
|
shareTitle: title ? `${title} - MusicLand` : "MusicLand",
|
||||||
|
shareMessage,
|
||||||
|
url: shareUrl,
|
||||||
|
qrUrl: shareUrl,
|
||||||
|
linkLabel: shareUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const openShareSheet = (payload = {}) => {
|
export const openShareSheet = (payload = {}) => {
|
||||||
SheetManager.show("Share", {
|
SheetManager.show("Share", {
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
Reference in New Issue
Block a user