diff --git a/docs/qa/step2-navigation.md b/docs/qa/step2-navigation.md new file mode 100644 index 0000000..4e78e17 --- /dev/null +++ b/docs/qa/step2-navigation.md @@ -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. diff --git a/functions/assets/musicLandLogo.png b/functions/assets/musicLandLogo.png new file mode 100644 index 0000000..9687651 Binary files /dev/null and b/functions/assets/musicLandLogo.png differ diff --git a/functions/assets/musicLandProduction.png b/functions/assets/musicLandProduction.png deleted file mode 100644 index 3a2e05d..0000000 Binary files a/functions/assets/musicLandProduction.png and /dev/null differ diff --git a/functions/config/keys.js b/functions/config/keys.js index 173987a..c3185ae 100644 --- a/functions/config/keys.js +++ b/functions/config/keys.js @@ -1,5 +1,5 @@ exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y"; -// exports.SUNO_API_KEY = "543b5650b30d93e98d8046a830244c80"; client api key + exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key exports.RESEND_API_KEY = "re_"; diff --git a/functions/helpers/email.js b/functions/helpers/email.js index 6554c57..999fa13 100644 --- a/functions/helpers/email.js +++ b/functions/helpers/email.js @@ -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 }) { const btn = button?.url ? `

${ @@ -6,6 +15,9 @@ function basicTemplate({ title = "", content = "", button = null }) { : ""; return `

+
+ MusicLand +

${title}

${content}
${btn} @@ -22,6 +34,9 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
+
+ MusicLand +

${greeting}

Bienvenue sur MusicLand

diff --git a/functions/src/notifications.js b/functions/src/notifications.js index a31c072..63b0280 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -27,6 +27,7 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( receiverCollection = "users", message = "", data: notifData = {}, + mailOnly = false, } = event.data.data(); if (!receiver || !message) { @@ -40,27 +41,29 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( emailNotifications = false, } = (await db.collection(receiverCollection).doc(receiver).get()).data(); - const tokensSet = new Set( - [] - .concat(Array.isArray(pushTokens) ? pushTokens : []) - .concat(pushToken ? [pushToken] : []) - .filter(Boolean), - ); - const tokens = Array.from(tokensSet); + if (!mailOnly) { + const tokensSet = new Set( + [] + .concat(Array.isArray(pushTokens) ? pushTokens : []) + .concat(pushToken ? [pushToken] : []) + .filter(Boolean), + ); + const tokens = Array.from(tokensSet); - if (!tokens?.length) { - console.warn("User push token not found"); - return null; + if (!tokens?.length) { + console.warn("User push token not found"); + return null; + } + + await sendExpoNotification({ + tokens, + receiverId: receiver, + receiverCollection, + title: title || "MusicLand", + message: message, + data: notifData || {}, + }); } - - await sendExpoNotification({ - tokens, - receiverId: receiver, - receiverCollection, - title: title || "MusicLand", - message: message, - data: notifData || {}, - }); if (emailNotifications && !!email) { try { if (!email) { @@ -219,6 +222,7 @@ const sendNotification = async ({ receiverCollection = "users", title = "", message = null, + mailOnly = false, data = {}, }) => { try { @@ -234,6 +238,7 @@ const sendNotification = async ({ time: admin.firestore.FieldValue.serverTimestamp(), read: false, readAt: null, + mailOnly, data, }; const { id } = await refList.notifications.add(payload); diff --git a/functions/src/users.js b/functions/src/users.js index 7c87ac9..e0bba5d 100644 --- a/functions/src/users.js +++ b/functions/src/users.js @@ -23,12 +23,18 @@ exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => { return; } try { - await resendClient.emails.send({ + console.log(`Sending welcome email to ${email}`); + const { data, error } = await resendClient.emails.send({ from: WELCOME_EMAIL_FROM, to: [email], subject: WELCOME_EMAIL_SUBJECT, html: welcomeTemplate({ firstName, lastName }), }); + if (error) { + console.log("Failed to send welcome email:", error); + return; + } + console.log("Welcome email sent:", data); } catch (error) { console.log("Failed to send welcome email:", error); } diff --git a/src/assets/comments.png b/src/assets/comments.png deleted file mode 100644 index ebd856b..0000000 Binary files a/src/assets/comments.png and /dev/null differ diff --git a/src/components/AppActionSheet.js b/src/components/AppActionSheet.js index 98312c2..32e438e 100644 --- a/src/components/AppActionSheet.js +++ b/src/components/AppActionSheet.js @@ -1,26 +1,38 @@ import { Portal } from "@gorhom/portal"; import { BlurView } from "expo-blur"; -import React from "react"; +import React, { useCallback } from "react"; 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 { isWeb } from "../hooks/useLayoutType"; import { gutters, Palette } from "../styles"; const AppActionSheet = ({ + id, children, webModal = false, onClose = () => {}, ...sheetProps }) => { const insets = useSafeAreaInsets(); + const handleRequestClose = useCallback(() => { + if (id) { + Promise.resolve(SheetManager.hide(id)) + .catch(() => {}) + .finally(() => { + onClose?.(); + }); + return; + } + onClose?.(); + }, [id, onClose]); if (isWeb && webModal) { return ( @@ -36,6 +48,7 @@ const AppActionSheet = ({ return ( { + 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 ( - {icon && } + {icon && } { + const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium"; + const buttonHeight = HEIGHT_BY_SIZE[resolvedSize]; + const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]; + return ( } {title} diff --git a/src/components/ListSelection/ListSelection.js b/src/components/ListSelection/ListSelection.js index 55fb6c0..e3425bc 100644 --- a/src/components/ListSelection/ListSelection.js +++ b/src/components/ListSelection/ListSelection.js @@ -166,6 +166,7 @@ const ListSelection = ({ const itemKey = buildItemKey(String(val ?? ""), cat); const isHovered = isWeb && !disableHover && hoveredKey === itemKey; const showHoverOutline = isHovered && !sel; + const showSelectionOutline = !isWeb && sel; const baseContent = renderSimpleContent(item); const headerContainerStyle = { @@ -205,7 +206,7 @@ const ListSelection = ({ {withWebBlueGradientIfSelected(baseContent, sel)} - {showHoverOutline && ( + {(showHoverOutline || showSelectionOutline) && ( { if (typeof onPress === "function") { onPress(); @@ -41,7 +45,7 @@ export default function ShareBtn({ style, onPress = null }) { marginRight: 5, }} > - {"Partager l’expérience"} + {label} diff --git a/src/components/modal/DeleteAccountModal.js b/src/components/modal/DeleteAccountModal.js index 611d34c..f0f1f08 100644 --- a/src/components/modal/DeleteAccountModal.js +++ b/src/components/modal/DeleteAccountModal.js @@ -30,7 +30,7 @@ const DeleteAccountModal = () => { onPress: () => { alert( "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: () => {} }, { @@ -85,8 +85,8 @@ const DeleteAccountModal = () => { textAlign: "center", }} > - Es-tu sur de vouloir supprimer ton compte?{"\n"}Attention, cette - action est irréversible! + Es-tu sûr de vouloir supprimer ton compte ?{"\n"}Attention, cette + action est irréversible ! diff --git a/src/components/modal/ReportModal.js b/src/components/modal/ReportModal.js new file mode 100644 index 0000000..91c0ab5 --- /dev/null +++ b/src/components/modal/ReportModal.js @@ -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 ( + + + + + + + Signaler ce contenu + {targetContext?.title ? ( + {targetContext.title} + ) : null} + + + + {REPORT_OPTIONS.map((option) => { + const isSelected = option.key === selectedReason; + return ( + setSelectedReason(option.key)} + > + + {option.label} + + ); + })} + + + {showDetailsField ? ( + + Détails (optionnel) + + + ) : null} + + + + + + + + + + ); +}; + +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; diff --git a/src/config/firebase.js b/src/config/firebase.js index f8e86ac..139abaf 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -52,6 +52,7 @@ export const playlistsRef = firestore.collection("playlists"); export const chatsRef = firestore.collection("chats"); export const videosRef = firestore.collection("videos"); export const notificationsRef = firestore.collection("notifications"); +export const reportsRef = firestore.collection("reports"); export const { arrayUnion, arrayRemove, increment, serverTimestamp } = firebase.firestore.FieldValue; diff --git a/src/config/initialGlobalState.js b/src/config/initialGlobalState.js index 66d499d..d9361d7 100644 --- a/src/config/initialGlobalState.js +++ b/src/config/initialGlobalState.js @@ -1,4 +1,5 @@ import { Palette } from "../styles"; +import { musiclandProductionBaseUrl } from "../data"; export default { currentUID: null, @@ -16,6 +17,7 @@ export default { url: null, title: "", }, + productionBaseUrl: musiclandProductionBaseUrl, _isLoading: false, _loadingMessage: null, diff --git a/src/constants/reportOptions.js b/src/constants/reportOptions.js new file mode 100644 index 0000000..0fe27d9 --- /dev/null +++ b/src/constants/reportOptions.js @@ -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…", + }, +]; diff --git a/src/constants/strings.js b/src/constants/strings.js new file mode 100644 index 0000000..02c3c57 --- /dev/null +++ b/src/constants/strings.js @@ -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; diff --git a/src/data/index.js b/src/data/index.js index d351ef6..533848a 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -13,7 +13,9 @@ export const firebaseDashboardUrl = export const appleAppStoreUrl = "https://apps.apple.com/app"; 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 musiclandShareMessage = "Invite tes proches a decouvrir MusicLand et cree des experiences musicales ensemble."; diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js index 8d802e2..401f568 100644 --- a/src/providers/PlayerProvider.js +++ b/src/providers/PlayerProvider.js @@ -54,6 +54,8 @@ const HIDDEN_ROUTE_NAMES = new Set([ Routes.ChangePassword, Routes.Notifications, Routes.Language, + Routes.Login, + Routes.Register, ]); const noopAsync = async () => {}; diff --git a/src/providers/UniversalLinkProvider.js b/src/providers/UniversalLinkProvider.js index 73c13fc..49cc68d 100644 --- a/src/providers/UniversalLinkProvider.js +++ b/src/providers/UniversalLinkProvider.js @@ -2,7 +2,8 @@ import { useContext, useState, useEffect } from "reactn"; import * as Linking from "expo-linking"; import { UserDataContext } from "./UserDataProvider"; 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"; const appJson = require("../../app.json"); @@ -16,6 +17,7 @@ const UniversalLinkProvider = ({ children }) => { const { isFullyLoaded } = useContext(SplashAnimationContext); const [tempTaskData, setTempTaskData] = useState(null); + const [pendingMusicId, setPendingMusicId] = useState(null); useEffect(() => { const handleDeepLink = async (event) => { @@ -51,13 +53,13 @@ const UniversalLinkProvider = ({ children }) => { } } catch (error) { console.error("Redirection error:", error); - handleParams(path); + handleParams(path, queryParams); } } else { - handleParams(path); + handleParams(path, queryParams); } } else { - handleParams(path); + handleParams(path, queryParams); } }; @@ -86,8 +88,31 @@ const UniversalLinkProvider = ({ children }) => { } }, [tempTaskData, currentUID, isFullyLoaded]); - const handleParams = (path) => { - // console.log("path", path); + useEffect(() => { + 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(); }; diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index 34ee177..9e712e3 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -17,6 +17,7 @@ import { import { useGlobal } from "reactn"; import { background, icons } from "../../assets"; import PressableScale from "../../components/PressableScale"; +import ShareBtn from "../../components/ShareBtn/ShareBtn"; import Slider from "../../components/Slider"; import { arrayRemove, @@ -37,6 +38,11 @@ import { getSegmentMeta, normalizeStructureType, } from "../../utils/songStructure"; +import { + createMusicSharePayload, + openShareSheet, +} from "../../utils/shareSheet"; +import { Feather } from "@expo/vector-icons"; // 20 secondes const timeBeforeIncrement = 20000; @@ -44,6 +50,7 @@ const timeBeforeIncrement = 20000; const MusicDetails = ({ route }) => { const { params } = route || {}; const action = params?.action; + const autoPlayRequested = params?.autoPlay; const projectId = params?.projectId || null; const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); @@ -51,6 +58,7 @@ const MusicDetails = ({ route }) => { const listenedMsRef = useRef(0); const incrementDoneRef = useRef(false); const timerRef = useRef(null); + const hasAutoPlayedRef = useRef(false); const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, @@ -105,6 +113,33 @@ const MusicDetails = ({ route }) => { }; }, [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 { isCurrent: isCurrentTrack, isPlaying: isTrackPlaying, @@ -137,6 +172,7 @@ const MusicDetails = ({ route }) => { useEffect(() => { listenedMsRef.current = 0; incrementDoneRef.current = false; + hasAutoPlayedRef.current = false; }, [trackId]); // Start/stop a timer to accumulate listened milliseconds while playing @@ -247,6 +283,34 @@ const MusicDetails = ({ route }) => { [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 () => { try { if (wasPlayingBeforeSeek.current) { @@ -627,17 +691,24 @@ const MusicDetails = ({ route }) => { style={styles.img} /> )} - - - {title} - {artist} - - - { - if (!projectId || !currentUID) return; - const next = !fav; - setFav(next); + + + + {title} + {artist} + + + { + if (!projectId || !currentUID) return; + const next = !fav; + setFav(next); try { await projectsRef.doc(projectId).update({ likedBy: next @@ -655,6 +726,12 @@ const MusicDetails = ({ route }) => { resizeMode="contain" /> + + + + {!!sharePayload && ( + + )} SheetManager.show("Playlist", { payload: { projectId } }) @@ -666,6 +743,7 @@ const MusicDetails = ({ route }) => { resizeMode="contain" /> + diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index 920017a..264f27b 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -24,6 +24,7 @@ import { useGlobal } from "reactn"; import { background, icons } from "../../assets"; import PressableScale from "../../components/PressableScale"; import Slider from "../../components/Slider"; +import Svg, { Circle } from "react-native-svg"; import { arrayRemove, arrayUnion, @@ -45,6 +46,11 @@ import { normalizeStructureType, segmentRequiresLyrics, } from "../../utils/songStructure"; +import { + createMusicSharePayload, + openShareSheet, +} from "../../utils/shareSheet"; +import { Feather } from "@expo/vector-icons"; // 20 secondes const timeBeforeIncrement = 20000; @@ -52,14 +58,17 @@ const timeBeforeIncrement = 20000; const MusicDetails = ({ route }) => { const { params } = route || {}; const action = params?.action; + const autoPlayRequested = params?.autoPlay; const projectId = params?.projectId || null; const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); + const [, setTooltip] = useGlobal("_tooltip"); const wasPlayingBeforeSeek = React.useRef(false); const lastSeekTargetMs = React.useRef(null); const listenedMsRef = React.useRef(0); const incrementDoneRef = React.useRef(false); const timerRef = React.useRef(null); + const hasAutoPlayedRef = React.useRef(false); const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, @@ -116,6 +125,108 @@ const MusicDetails = ({ route }) => { }; }, [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 { isCurrent: isCurrentTrack, isPlaying: isTrackPlaying, @@ -150,6 +261,7 @@ const MusicDetails = ({ route }) => { useEffect(() => { listenedMsRef.current = 0; incrementDoneRef.current = false; + hasAutoPlayedRef.current = false; }, [trackId]); // Start/stop a timer to accumulate listened milliseconds while playing @@ -282,6 +394,38 @@ const MusicDetails = ({ route }) => { [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 targetMs = typeof lastSeekTargetMs.current === "number" @@ -677,6 +821,14 @@ const MusicDetails = ({ route }) => { backgroundImg={ action === "userProfile" ? background.profileBG : background.libraryBG2 } + shareBtn={ + sharePayload + ? { + label: "Partager le morceau", + onPress: handleShare, + } + : false + } {...(action === "userProfile" && { containerStyle: { backgroundColor: "#0000004D", @@ -761,6 +913,45 @@ const MusicDetails = ({ route }) => { resizeMode="contain" /> + + + + + {isDownloading ? ( + + ) : null} + + + + SheetManager.show("Playlist", { payload: { projectId } }) @@ -975,3 +1166,38 @@ const styles = StyleSheet.create({ 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 ( + + + + + ); +}; diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index d094f28..0fb01b8 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -28,6 +28,8 @@ import { LIKE_TARGET, toggleProjectLike, } from "../../utils/likes"; +import { SheetManager } from "react-native-actions-sheet"; +import { Feather } from "@expo/vector-icons"; const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const { currentUID, followUser, unfollowUser } = useUser() || {}; @@ -142,6 +144,18 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { } catch (e) {} }; }, [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 const alignedWords = useMemo(() => { @@ -365,6 +379,20 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { > + + + + Signaler + + { + 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 { width = 0, height = 0 } = event?.nativeEvent?.layout || {}; setLayoutSize((prev) => ({ @@ -486,6 +501,13 @@ const PlaybackItem = ({ resizeMode="contain" /> + + + Signaler + diff --git a/src/screens/Studio/GeneratingSong.js b/src/screens/Studio/GeneratingSong.js index 2a7a844..1ce6154 100644 --- a/src/screens/Studio/GeneratingSong.js +++ b/src/screens/Studio/GeneratingSong.js @@ -240,16 +240,7 @@ const GeneratingSong = () => { if (titleOk && isIdle && !askedRef.current) { askedRef.current = true; - AppAlert("Attention", "Une génération va être lancée. Continuer ?", [ - { - text: "Non", - style: "cancel", - onPress: () => { - askedRef.current = false; - }, - }, - { text: "Oui", onPress: () => startMusicGenerationOnce() }, - ]); + startMusicGenerationOnce(); } }, [ isFocused, diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js index aa6f20b..c3e0b8b 100644 --- a/src/screens/Writing/CreateLyricsWithAi.js +++ b/src/screens/Writing/CreateLyricsWithAi.js @@ -8,8 +8,7 @@ import MusicLandHeader from "../../components/MusicLandHeader"; import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data"; import useLayoutType from "../../hooks/useLayoutType"; import Page from "../../layouts/Page"; -import { Routes } from "../../navigation"; -import { goBack, navigate } from "../../navigation/NavigationService"; +import { goBack } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; import { sanitizeStructureList } from "../../utils/songStructure"; @@ -23,13 +22,13 @@ import SongStyle from "./SongStyle"; import SongTo from "./SongTo"; import SpecificityContext from "./SpecificityContext"; const { width: windowWidth } = Dimensions.get("window"); +const MAX_STEP_INDEX = 8; const CreateLyricsWithAi = () => { const { isWeb } = useLayoutType(); - const { selectedProject, updateProjectData } = useUser(); - const hasLyrics = selectedProject?.hasLyrics === true; + const { selectedProject } = useUser(); const scrollRef = useRef(null); - const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0); + const [selectedIndex, setSelectedIndex] = useState(0); const [progress, setProgress] = useState(16); const [parentLayout, setparentLayout] = useState(null); const containerWidth = windowWidth; @@ -188,13 +187,6 @@ const CreateLyricsWithAi = () => { } }, [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 resolvedObjective = shouldUseOtherObjective ? persistedOtherObjective || undefined @@ -297,37 +289,7 @@ const CreateLyricsWithAi = () => { ]); const onPressNext = async () => { - // Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6), - // 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); + setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX)); }; const onPressBack = () => { @@ -344,14 +306,36 @@ const CreateLyricsWithAi = () => { // Sync scroll position and progress with selectedIndex React.useEffect(() => { if (!parentLayout?.height) return; - try { - const nextProgress = 16 + selectedIndex * 9; - setProgress(nextProgress); - scrollRef.current?.scrollToIndex?.({ - index: selectedIndex, - animated: true, - }); - } catch (_) {} + const nextProgress = Math.max(0, Math.min(100, 16 + selectedIndex * 9)); + setProgress(nextProgress); + + const ref = scrollRef.current; + if (!ref || typeof ref.scrollToIndex !== "function") { + return; + } + + 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]); return ( diff --git a/src/screens/Writing/CreateLyricsWithAi.web.js b/src/screens/Writing/CreateLyricsWithAi.web.js index 4ec6b3f..325aa8c 100644 --- a/src/screens/Writing/CreateLyricsWithAi.web.js +++ b/src/screens/Writing/CreateLyricsWithAi.web.js @@ -6,8 +6,7 @@ import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data"; import Page from "../../layouts/Page"; -import { Routes } from "../../navigation"; -import { goBack, navigate } from "../../navigation/NavigationService"; +import { goBack } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; import { sanitizeStructureList } from "../../utils/songStructure"; @@ -21,10 +20,11 @@ import SongStyle from "./SongStyle"; import SongTo from "./SongTo"; import SpecificityContext from "./SpecificityContext"; +const MAX_STEP_INDEX = 8; + const CreateLyricsWithAi = () => { - const { selectedProject, updateProjectData } = useUser(); - const hasLyrics = selectedProject?.hasLyrics === true; - const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0); + const { selectedProject } = useUser(); + const [selectedIndex, setSelectedIndex] = useState(0); // Collected state across steps const [objective, setObjective] = useState(null); // from Goals list @@ -187,13 +187,6 @@ const CreateLyricsWithAi = () => { } }, [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( () => 16 + selectedIndex * 9, [selectedIndex] @@ -301,37 +294,7 @@ const CreateLyricsWithAi = () => { ]); const onPressNext = async () => { - // Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6), - // 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); + setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX)); }; const onPressBack = () => { diff --git a/src/screens/Writing/EmotionConvey.js b/src/screens/Writing/EmotionConvey.js index 86b2b63..f0bd4c1 100644 --- a/src/screens/Writing/EmotionConvey.js +++ b/src/screens/Writing/EmotionConvey.js @@ -3,6 +3,7 @@ import { View } from "react-native"; import ItemContainer from "../../components/ItemContainer/ItemContainer"; import ListSelection from "../../components/ListSelection/ListSelection"; import { EMOTION_CONVEY } from "../../data/data"; +import { strings } from "../../constants/strings"; import CreateLyricsHeader from "./components/CreateLyricsHeader"; const EmotionConvey = ({ @@ -19,8 +20,8 @@ const EmotionConvey = ({ return ( + + + {strings.writing.steps.contextExamples} + + { }} > - Proposition de paroles - - Relis attentivement la proposition générée avant de valider. + + {strings.writing.lyrics.title} - - Personnalisation : - {" "} - modifie le titre et chaque section pour que les paroles te - ressemblent. - - - - Nouvelle génération : - {" "} - appuie sur "Générer d'autres paroles" si tu souhaites une autre - suggestion. + {strings.writing.lyrics.instructions} + + + {strings.writing.lyrics.personalizationBanner} + + { const { createNewProject, selectedProject, updateProjectData } = useUserData(); const { setIsLoading } = useMinuit(); - async function createAndNavigate({ hasLyrics = false }) { + const startWriting = React.useCallback(async () => { try { await setIsLoading(true); if (selectedProject) { - updateProjectData({ hasLyrics }); + updateProjectData({ hasLyrics: false }); setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500); return; } - const newProjectId = await createNewProject({ hasLyrics }); + const newProjectId = await createNewProject({ hasLyrics: false }); if (!newProjectId) { return; } @@ -35,7 +37,7 @@ const WritingLyrics = () => { } finally { await setIsLoading(false); } - } + }, [createNewProject, navigate, selectedProject, setIsLoading, updateProjectData]); return ( { progress={9} logo={icons.musicLandWriting} /> - - + + + + {strings.writing.onboarding.heroTitle} + + + {strings.writing.onboarding.heroSubtitle} + + + createAndNavigate({ hasLyrics: false })} + title={strings.writing.onboarding.primaryCta} + onPress={startWriting} + textStyle={{ fontFamily: FONT_FAMILY.InterSemiBold }} /> createAndNavigate({ hasLyrics: true })} + size="small" + title={strings.writing.onboarding.secondaryCta} + disabled containerStyle={{ alignSelf: "center", width: "100%" }} + maxWidth={360} /> + + {strings.writing.onboarding.secondaryNote} + @@ -85,4 +94,39 @@ const styles = StyleSheet.create({ bottom: -40, 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", + }, }); diff --git a/src/utils/Sheet.js b/src/utils/Sheet.js index 4eee952..5da7c38 100644 --- a/src/utils/Sheet.js +++ b/src/utils/Sheet.js @@ -7,6 +7,7 @@ import DeleteAudioModal from "../components/modal/DeleteAudioModal"; import PlaylistModal from "../components/modal/PlaylistModal"; import PlaybackPickerModal from "../components/modal/PlaybackPickerModal"; import ShareModal from "../components/modal/ShareModal"; +import ReportModal from "../components/modal/ReportModal"; registerSheet("Delete", DeleteModal); registerSheet("ProfileSettings", ProfileSettingsModal); @@ -16,5 +17,6 @@ registerSheet("DeleteAudio", DeleteAudioModal); registerSheet("Playlist", PlaylistModal); registerSheet("PlaybackPicker", PlaybackPickerModal); registerSheet("Share", ShareModal); +registerSheet("Report", ReportModal); export {}; diff --git a/src/utils/shareSheet.js b/src/utils/shareSheet.js index d8ec1b1..84d9b7d 100644 --- a/src/utils/shareSheet.js +++ b/src/utils/shareSheet.js @@ -14,6 +14,48 @@ const defaultPayload = { 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 = {}) => { SheetManager.show("Share", { payload: {