diff --git a/App.js b/App.js index c9d3272..883b2fa 100644 --- a/App.js +++ b/App.js @@ -37,6 +37,7 @@ import Providers from "./src/providers"; import { LogBox } from "react-native"; import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet"; import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer"; +import AppDownloadBanner from "./src/components/AppDownloadBanner"; import "./src/utils/Sheet"; console.disableYellowBox = true; @@ -49,8 +50,7 @@ setGlobal(initialGlobalState); SplashScreen.preventAutoHideAsync(); const App = () => { - const [currentUID, setCurrentUID] = useGlobal("currentUID"); - const [pendingData, setPendingData] = useGlobal("pendingData"); + const [, setCurrentUID] = useGlobal("currentUID"); const [, setCurrentUserRoles] = useGlobal("currentUserRoles"); const [appIsReady, setAppIsReady] = useState(false); @@ -198,6 +198,7 @@ const App = () => { + {/* */} diff --git a/android/app/build.gradle b/android/app/build.gradle index ea70ce6..b33733b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,7 +92,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "0.0.1" + versionName "0.0.2" } signingConfigs { debug { diff --git a/functions/helpers/gemini.js b/functions/helpers/gemini.js index 4dcf6a1..4ae93ad 100644 --- a/functions/helpers/gemini.js +++ b/functions/helpers/gemini.js @@ -62,6 +62,45 @@ exports.analyseLyrics = async ({ title = "", lyrics }) => { ); } + // Détection heuristique d'insultes explicites pour durcir la modération avant l'IA + const sanitizedLyrics = lyricsText + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const vulgarTerms = [ + "trou du cul", + "trouduc", + "encule", + "enculer", + "connard", + "connasse", + "con", + "fdp", + "fils de pute", + "pute", + "putain", + "salope", + "salaud", + "enfoire", + "batard", + "ordure", + "ta gueule", + "nique ta mere", + "ntm", + ]; + const detectedVulgarities = vulgarTerms.filter((term) => { + const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`, "i"); + return pattern.test(sanitizedLyrics); + }); + const vulgarityHints = detectedVulgarities.length + ? `${detectedVulgarities.join(", ")}` + : "Aucune détectée heuristiquement."; + // --- Schéma de sortie (inchangé pour compatibilité) --- const moderationSchema = z.object({ title: z.string().describe("Titre analysé (copie du titre fourni)"), @@ -101,6 +140,7 @@ Objectif: protéger les utilisateurs en BLOQUANT uniquement les cas réellement Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique. Évalue TOUJOURS: 1) l'intention, 2) le contexte (récit, citation, dénonciation, rôle/persona), 3) la cible, 4) la probabilité de tort réel. +Ne laisse PAS passer des insultes vulgaires explicites (connard, trou du cul, enculé, fdp, salope, etc.): au minimum flagged=true avec un score significatif; blocked=true si c'est dirigé ou répété sans distance. Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON. `.trim(); @@ -135,6 +175,12 @@ Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON. Si le passage relève de la narration, satire, critique ou roleplay: privilégie flagged=true et blocked=false. Si le passage constitue une injonction réelle ou un appel clair au tort: blocked=true. +
+ INSULTES ET VULGARITES + Insultes vulgaires explicites (trou du cul, connard, enculé, fdp, salope, ta gueule, nique ta mere, etc.) = flagged=true minimum et score ≥ 0.45. + Si ces insultes visent quelqu'un de manière directe ou répétée, ou incitent à rabaisser/attaquer: blocked=true ou severity high/critical (au moins high si pas certain). + Si c'est de l'auto-dérision ou du langage cru sans cible ni incitation: flagged=true mais blocked=false, score limité. +
`.trim(); @@ -153,9 +199,15 @@ ${lyricsText} "blocked" = true UNIQUEMENT pour les cas graves listés dans la section A. Si le contenu relève du récit, de la critique, de la satire ou d'une mise en contexte artistique, laisse blocked=false (flagged=true si nécessaire). Fournis des "excerpts" courts en citant exactement les passages sensibles. + Si des insultes vulgaires explicites sont présentes, flagged=true au minimum (score >=0.45) et blocked=true si elles visent clairement quelqu'un. ${decisionPolicy} + + + ${vulgarityHints} + Si la liste ci-dessus n'est pas vide, considère ces termes comme signaux forts de harcèlement verbal: flagged=true au minimum et score ajusté en conséquence. + `.trim(); const { output } = await genkit({ @@ -167,6 +219,30 @@ ${decisionPolicy} output: { schema: moderationSchema }, }); + if (output && detectedVulgarities.length) { + output.flagged = true; + const bumpScore = detectedVulgarities.length >= 3 ? 0.65 : 0.5; + const baseScore = Number.isFinite(output.score) ? output.score : 0; + output.score = Math.min(1, Math.max(baseScore, bumpScore)); + + const reasons = Array.isArray(output.reasons) ? output.reasons : []; + const reason = `Vulgarités détectées (${detectedVulgarities.join(", ")})`; + if (!reasons.includes(reason)) reasons.push(reason); + output.reasons = reasons; + + const excerpts = Array.isArray(output.excerpts) ? output.excerpts : []; + const slots = Math.max(0, 10 - excerpts.length); + if (slots > 0) { + const severity = detectedVulgarities.length > 2 ? "high" : "medium"; + const newExcerpts = detectedVulgarities.slice(0, slots).map((term) => ({ + quote: term, + category: "harassment", + severity, + })); + output.excerpts = [...excerpts, ...newExcerpts]; + } + } + return output; }; diff --git a/functions/src/upload.js b/functions/src/upload.js index 22aaff4..2bb322a 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -11,9 +11,14 @@ const crypto = require("node:crypto"); const ffmpeg = require("fluent-ffmpeg"); const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); const { Buffer } = require("node:buffer"); +const { FieldValue } = require("firebase-admin/firestore"); + if (!admin.apps.length) admin.initializeApp(); ffmpeg.setFfmpegPath(ffmpegInstaller.path); +const db = admin.firestore(); +const PLAYBACK_CODEC_TAG = "h264-v1"; + async function downloadToFile(url, destPath) { if (!/^https?:\/\//i.test(url || "")) { throw new HttpsError("invalid-argument", `URL non supportée: ${url}`); @@ -23,6 +28,9 @@ async function downloadToFile(url, destPath) { return res.headers?.["content-type"] || ""; } +const SCALE_FILTER = + "scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"; + async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { return new Promise((resolve, reject) => { ffmpeg() @@ -33,8 +41,21 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { "0:v:0", // garder la 1re piste vidéo de l'entrée 0 "-map", "1:a:0", // prendre la 1re piste audio de l'entrée 1 + // Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème) + "-vf", + SCALE_FILTER, "-c:v", - "copy", // ne pas réencoder la vidéo + "libx264", + "-preset", + "veryfast", + "-crf", + "22", + "-pix_fmt", + "yuv420p", + "-profile:v", + "high", + "-level:v", + "4.1", "-c:a", "aac", "-b:a", @@ -42,6 +63,8 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { "-movflags", "+faststart", "-shortest", // couper à la plus courte des 2 sources + "-tag:v", + "avc1", ]) .on("error", reject) .on("end", resolve) @@ -49,6 +72,67 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { }); } +async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-")); + const videoPath = path.join(tmpDir, "video.mp4"); + const audioPath = path.join(tmpDir, "audio.mp3"); + const outPath = path.join(tmpDir, "output.mp4"); + + try { + logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl }); + + await downloadToFile(videoUrl, videoPath); + await downloadToFile(audioUrl, audioPath); + + logger.info("[merge] transcodage/mux ffmpeg"); + await muxAudioIntoVideo({ videoPath, audioPath, outPath }); + + const bucket = admin.storage().bucket(); + const downloadToken = crypto.randomUUID(); + + await bucket.upload(outPath, { + destination: storagePath, + metadata: { + contentType: "video/mp4", + cacheControl: "public,max-age=86400", + metadata: { firebaseStorageDownloadTokens: downloadToken }, + }, + }); + + const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( + storagePath + )}?alt=media&token=${downloadToken}`; + + logger.info("[merge] upload terminé", { storagePath }); + + return { + success: true, + url: fileUrl, + contentType: "video/mp4", + storagePath, + }; + } catch (err) { + logger.error("[merge] échec", { error: err?.message || String(err) }); + if (err instanceof HttpsError) throw err; + throw new HttpsError("internal", err?.message || "Fusion échouée"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { + if (!projectId) return; + const docRef = db.collection("projects").doc(projectId); + await docRef.set( + { + "playbackCompatibility.codec": PLAYBACK_CODEC_TAG, + "playbackCompatibility.migratedAt": FieldValue.serverTimestamp(), + "playbackCompatibility.migratedBy": initiatorUid, + }, + { merge: true } + ); +} + exports.mergeVideoAndAudio = onCall( { timeoutSeconds: 540, memory: "1GiB" }, async ({ data = {}, auth }) => { @@ -56,7 +140,7 @@ exports.mergeVideoAndAudio = onCall( if (!uid) throw new HttpsError("unauthenticated", "Authentification requise"); - const { videoUrl, audioUrl, storagePath } = data || {}; + const { videoUrl, audioUrl, storagePath, projectId } = data || {}; if (!videoUrl || !audioUrl || !storagePath) { throw new HttpsError( @@ -65,7 +149,6 @@ exports.mergeVideoAndAudio = onCall( ); } - // sécurité simple: forcer dans le dossier de l'utilisateur si tu veux const expectedPrefix = `users/${uid}/`; if (!storagePath.startsWith(expectedPrefix)) { throw new HttpsError( @@ -74,50 +157,76 @@ exports.mergeVideoAndAudio = onCall( ); } - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-")); - const videoPath = path.join(tmpDir, "video.mp4"); - const audioPath = path.join(tmpDir, "audio.mp3"); - const outPath = path.join(tmpDir, "output.mp4"); - - try { - logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl }); - - await downloadToFile(videoUrl, videoPath); - await downloadToFile(audioUrl, audioPath); - - logger.info("[merge] fusion ffmpeg (mux)"); - await muxAudioIntoVideo({ videoPath, audioPath, outPath }); - - const bucket = admin.storage().bucket(); - const downloadToken = crypto.randomUUID(); - - await bucket.upload(outPath, { - destination: storagePath, - metadata: { - contentType: "video/mp4", - cacheControl: "public,max-age=86400", - metadata: { firebaseStorageDownloadTokens: downloadToken }, - }, - }); - - const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( - storagePath - )}?alt=media&token=${downloadToken}`; - - logger.info("[merge] upload terminé", { storagePath }); - - return { - success: true, - url: fileUrl, - contentType: "video/mp4", - storagePath, - }; - } catch (err) { - logger.error("[merge] échec", { error: err?.message || String(err) }); - if (err instanceof HttpsError) throw err; - throw new HttpsError("internal", err?.message || "Fusion échouée"); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }); + if (projectId) { + await markPlaybackCompatibility({ projectId, initiatorUid: uid }); } + return result; + } +); + +exports.reencodePlayback = onCall( + { timeoutSeconds: 540, memory: "1GiB" }, + async ({ data = {}, auth }) => { + const uid = auth?.uid; + if (!uid) + throw new HttpsError("unauthenticated", "Authentification requise"); + + const projectId = data?.projectId; + if (!projectId) { + throw new HttpsError( + "invalid-argument", + "Requis: { projectId } pour relancer le transcodage" + ); + } + + logger.info("[reencodePlayback] request received", { + projectId, + initiator: uid, + }); + const projectRef = db.collection("projects").doc(projectId); + const projectSnap = await projectRef.get(); + if (!projectSnap.exists) { + logger.warn("[reencodePlayback] project not found", { + projectId, + initiator: uid, + }); + throw new HttpsError("not-found", "Projet introuvable"); + } + const project = projectSnap.data() || {}; + const videoUrl = project.playbackUrl; + const audioUrl = project.songUrl; + const ownerId = project.userId; + + if (!videoUrl || !audioUrl || !ownerId) { + logger.warn("[reencodePlayback] missing fields", { + projectId, + hasPlaybackUrl: !!videoUrl, + hasSongUrl: !!audioUrl, + ownerId, + }); + throw new HttpsError( + "failed-precondition", + "playbackUrl, songUrl ou userId manquant" + ); + } + + const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`; + const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }); + + await projectRef.set( + { + playbackUrl: result.url, + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true } + ); + await markPlaybackCompatibility({ projectId, initiatorUid: uid }); + logger.info("[reencodePlayback] success", { + projectId, + initiator: uid, + storagePath, + }); + return result; } ); diff --git a/src/assets/UI/homeBGWeb.png b/src/assets/UI/homeBGWeb.png index 261575b..00e75d3 100644 Binary files a/src/assets/UI/homeBGWeb.png and b/src/assets/UI/homeBGWeb.png differ diff --git a/src/assets/UI/theo.png b/src/assets/UI/theo.png index 4eb0ba7..b95d30c 100644 Binary files a/src/assets/UI/theo.png and b/src/assets/UI/theo.png differ diff --git a/src/components/AppDownloadBanner.js b/src/components/AppDownloadBanner.js new file mode 100644 index 0000000..08abfb6 --- /dev/null +++ b/src/components/AppDownloadBanner.js @@ -0,0 +1,242 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import React, { useCallback, useEffect, useState } from "react"; +import { + Image, + Linking, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; + +import { icons } from "../assets"; +import { appleAppStoreUrl, googlePlayStoreUrl } from "../data"; +import useLayoutType from "../hooks/useLayoutType"; +import { Palette, gutters } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; + +const STORAGE_KEY = "appDownloadBanner:dismissed"; + +const AppDownloadBanner = () => { + const { isMobileWeb } = useLayoutType(); + const [isVisible, setIsVisible] = useState(false); + const [hasHydrated, setHasHydrated] = useState(false); + + useEffect(() => { + let mounted = true; + + if (!isMobileWeb) { + setIsVisible(false); + setHasHydrated(false); + return undefined; + } + + AsyncStorage.getItem(STORAGE_KEY) + .then((value) => { + if (!mounted) return; + setIsVisible(value !== "hidden"); + setHasHydrated(true); + }) + .catch(() => { + if (!mounted) return; + setIsVisible(true); + setHasHydrated(true); + }); + + return () => { + mounted = false; + }; + }, [isMobileWeb]); + + const handleDismiss = useCallback(() => { + setIsVisible(false); + AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {}); + }, []); + + const openLink = useCallback((url) => { + if (typeof url !== "string") return; + const target = url.trim(); + if (!target) return; + + if (Platform.OS === "web") { + try { + window.open(target, "_blank", "noopener,noreferrer"); + return; + } catch (error) { + console.warn("AppDownloadBanner: failed to open link in new tab", error); + } + } + + Linking.openURL(target).catch((error) => { + console.warn("AppDownloadBanner: failed to open store link", error); + }); + }, []); + + const handleOpenStore = useCallback( + (store) => { + if (store === "ios") { + openLink(appleAppStoreUrl); + return; + } + if (store === "android") { + openLink(googlePlayStoreUrl); + } + }, + [openLink], + ); + + if (!isMobileWeb || !isVisible || !hasHydrated) { + return null; + } + + return ( + + + + + + + + + Télécharge l'app MusicLand + + Pour une expérience mobile plus fluide, utilise l'application + native et retrouve toutes les fonctionnalités. + + + + + × + + + + handleOpenStore("ios")} + > + App Store + + handleOpenStore("android")} + > + Google Play + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "fixed", + bottom: gutters, + left: gutters, + right: gutters, + alignItems: "center", + zIndex: 80, + }, + banner: { + width: "100%", + maxWidth: 520, + borderRadius: 18, + paddingVertical: 14, + paddingHorizontal: 16, + backgroundColor: "rgba(12, 10, 16, 0.95)", + borderWidth: 1, + borderColor: Palette.ultraLightWhite, + shadowColor: Palette.black, + shadowOpacity: 0.3, + shadowRadius: 16, + shadowOffset: { width: 0, height: 10 }, + elevation: 10, + }, + headerRow: { + flexDirection: "row", + alignItems: "flex-start", + marginBottom: 12, + }, + titleRow: { + flex: 1, + flexDirection: "row", + alignItems: "center", + }, + logoWrapper: { + width: 50, + height: 50, + borderRadius: 12, + backgroundColor: "rgba(255, 255, 255, 0.06)", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: Palette.ultraLightWhite, + }, + logo: { + width: "80%", + height: "80%", + resizeMode: "contain", + }, + titleContent: { + flex: 1, + marginLeft: 12, + }, + title: { + fontSize: 16, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + subtitle: { + fontSize: 14, + color: Palette.gray, + fontFamily: FONT_FAMILY.InterRegular, + lineHeight: 18, + marginTop: 4, + }, + closeBtn: { + marginLeft: 10, + }, + closeText: { + color: Palette.white, + fontSize: 22, + lineHeight: 22, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + actions: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + cta: { + flex: 1, + height: 46, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: Palette.ultraLightWhite, + }, + appStoreCta: { + backgroundColor: Palette.transparentWhite, + marginRight: 8, + }, + playStoreCta: { + backgroundColor: Palette.primary, + borderColor: Palette.primary, + marginLeft: 8, + }, + ctaText: { + fontSize: 15, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + }, +}); + +export default AppDownloadBanner; diff --git a/src/components/FullscreenIntroVideo.native.js b/src/components/FullscreenIntroVideo.native.js index fb25cd7..ab20580 100644 --- a/src/components/FullscreenIntroVideo.native.js +++ b/src/components/FullscreenIntroVideo.native.js @@ -9,6 +9,8 @@ import { Portal } from "@gorhom/portal"; // - url?: string | number (require), source of the video. Defaults to videos.test // - visible?: boolean, when false returns null // - onClose: () => void, called when user skips or when video ends +const CLOSE_THRESHOLD_SECONDS = 0.35; + const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { const source = url ? typeof url === "string" @@ -61,7 +63,19 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { return; } const remaining = player.duration - currentTime; - if (Number.isFinite(remaining) && remaining <= 0.1) { + if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) { + handleClose(); + } + } + ); + const playingChangeSub = player.addListener?.( + "playingChange", + ({ isPlaying } = {}) => { + if (isPlaying || !player?.duration || hasClosedRef.current) { + return; + } + const remaining = player.duration - (player.currentTime ?? 0); + if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) { handleClose(); } } @@ -70,6 +84,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { try { playToEndSub?.remove?.(); timeUpdateSub?.remove?.(); + playingChangeSub?.remove?.(); } catch (e) {} }; }, [handleClose, player, visible]); diff --git a/src/components/FullscreenIntroVideo.web.js b/src/components/FullscreenIntroVideo.web.js index 4e145f0..a96bb57 100644 --- a/src/components/FullscreenIntroVideo.web.js +++ b/src/components/FullscreenIntroVideo.web.js @@ -3,6 +3,9 @@ import { Asset } from "expo-asset"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Pressable, Text, View } from "react-native"; +const CLOSE_THRESHOLD_SECONDS = 0.35; +const CLOSE_POLL_INTERVAL_MS = 500; + const overlayStyle = { position: "fixed", top: 0, @@ -112,8 +115,29 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { return undefined; } - const handleEnded = handleClose; + const shouldClose = () => { + if (hasClosedRef.current) { + return false; + } + if (video.ended) { + return true; + } + const remaining = video.duration - video.currentTime; + return Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS; + }; + + const tryHandleClose = () => { + if (shouldClose()) { + handleClose(); + } + }; + const handleEnded = tryHandleClose; + const handleTimeUpdate = tryHandleClose; + const handlePause = tryHandleClose; video.addEventListener("ended", handleEnded); + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("pause", handlePause); + const pollId = setInterval(tryHandleClose, CLOSE_POLL_INTERVAL_MS); video.currentTime = 0; const attemptPlay = () => { @@ -133,6 +157,9 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { return () => { video.pause(); video.removeEventListener("ended", handleEnded); + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("pause", handlePause); + clearInterval(pollId); }; }, [handleClose, muted, uri, visible]); @@ -154,7 +181,6 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { return null; } - console.log("video uri", uri); return ( diff --git a/src/components/MusicLandHeader.js b/src/components/MusicLandHeader.js index f8825c7..0d8074a 100644 --- a/src/components/MusicLandHeader.js +++ b/src/components/MusicLandHeader.js @@ -14,6 +14,9 @@ const MusicLandHeader = ({ style, }) => { const isWeb = Platform.OS === "web"; + const backHitSlop = isWeb + ? undefined + : { top: 16, right: 16, bottom: 16, left: 16 }; const backButtonStyle = isWeb ? { ...Style.containerRow, @@ -37,7 +40,11 @@ const MusicLandHeader = ({ return ( - + {!hideBackButton && ( - onBackPressed?.() || goBack()}> + onBackPressed?.() || goBack()} + hitSlop={{ top: 16, right: 16, bottom: 16, left: 16 }} + > { try { let workingURI = uri; + let uploadBlob = providedBlob; // Optionally compress before upload try { @@ -30,10 +32,12 @@ export function uploadFileToFirebase({ workingURI = uri; } - const response = await fetch(workingURI); - const blob = await response.blob(); + if (!uploadBlob) { + const response = await fetch(workingURI); + uploadBlob = await response.blob(); + } - const uploadTask = firebase.storage().ref(path).put(blob); + const uploadTask = firebase.storage().ref(path).put(uploadBlob); uploadTask.on( Platform.OS === "web" diff --git a/src/providers/StripeProvider.js b/src/providers/StripeProvider.js index 02d3c47..33554e1 100644 --- a/src/providers/StripeProvider.js +++ b/src/providers/StripeProvider.js @@ -6,7 +6,6 @@ import { } from "@stripe/react-stripe-js"; import { loadStripe } from "@stripe/stripe-js"; import * as WebBrowser from "expo-web-browser"; - import Overlay from "../components/Overlay"; import Button from "../components/Button"; import useLayoutType from "../hooks/useLayoutType"; @@ -125,45 +124,42 @@ const StripeProvider = ({ children }) => { setClientSecret(null); }, []); - const redirectToCheckout = React.useCallback( - async (checkoutUrl, { safariWindow } = {}) => { - if (!checkoutUrl) { - throw new Error("Session Stripe introuvable."); - } + const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => { + if (!checkoutUrl) { + throw new Error("Session Stripe introuvable."); + } - if (isWeb) { - throw new Error( - "Impossible d'ouvrir le paiement Stripe sans checkout intégré.", + if (isWeb) { + throw new Error( + "Impossible d'ouvrir le paiement Stripe sans checkout intégré.", + ); + } + + const isMobileApp = Platform.OS === "ios" || Platform.OS === "android"; + + if (isMobileApp) { + try { + await WebBrowser.openBrowserAsync(checkoutUrl, { + enableDefaultShareMenu: false, + dismissButtonStyle: "close", + presentationStyle: + WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET, + }); + return; + } catch (webBrowserError) { + console.warn( + "[StripeProvider] WebBrowser checkout fallback", + webBrowserError, ); } + } - const isMobileApp = Platform.OS === "ios" || Platform.OS === "android"; - - if (isMobileApp) { - try { - await WebBrowser.openBrowserAsync(checkoutUrl, { - enableDefaultShareMenu: false, - dismissButtonStyle: "close", - presentationStyle: - WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET, - }); - return; - } catch (webBrowserError) { - console.warn( - "[StripeProvider] WebBrowser checkout fallback", - webBrowserError, - ); - } - } - - const canOpen = await Linking.canOpenURL(checkoutUrl); - if (!canOpen) { - throw new Error("Impossible d'ouvrir l'URL de paiement."); - } - await Linking.openURL(checkoutUrl); - }, - [], - ); + const canOpen = await Linking.canOpenURL(checkoutUrl); + if (!canOpen) { + throw new Error("Impossible d'ouvrir l'URL de paiement."); + } + await Linking.openURL(checkoutUrl); + }, []); const runCheckoutSession = React.useCallback( async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => { diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index b7ddfd2..4ddf631 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -729,8 +729,8 @@ const styles = StyleSheet.create({ alignItems: isWeb ? "center" : "stretch", justifyContent: isWeb ? "center" : "flex-start", gap: 12, - marginTop: isWeb ? 24 : 0, - marginBottom: isWeb ? 8 : 16, + marginTop: isWeb ? 12 : 0, + marginBottom: isWeb ? 4 : 16, paddingHorizontal: isWeb ? 0 : gutters, zIndex: 10, }, @@ -786,8 +786,8 @@ const styles = StyleSheet.create({ }, subtitleWrapper: { width: "100%", - marginBottom: 16, - marginTop: 15, + marginBottom: isWeb ? 12 : 16, + marginTop: isWeb ? 8 : 15, alignItems: "center", }, subtitleGradient: { @@ -812,7 +812,7 @@ const styles = StyleSheet.create({ backgroundColor: Palette.glass, }, subtitleInnerWeb: { - paddingVertical: 12, + paddingVertical: 10, backgroundColor: Palette.lightPurple, }, subtitle: { @@ -828,7 +828,7 @@ const styles = StyleSheet.create({ flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", - rowGap: 20, + rowGap: isWeb ? 16 : 20, }, cardsStack: { width: "100%", diff --git a/src/screens/Home/components/ClubCard.js b/src/screens/Home/components/ClubCard.js index e0486f0..fa7aea8 100644 --- a/src/screens/Home/components/ClubCard.js +++ b/src/screens/Home/components/ClubCard.js @@ -4,6 +4,7 @@ import { Image as ExpoImage } from "expo-image"; import { FONT_FAMILY } from "../../../styles/Fonts"; import { Palette } from "../../../styles"; import { icons } from "../../../assets"; +import { isWeb } from "../../../hooks/useLayoutType.js"; const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => { const subtitle = hasActiveSubscription @@ -32,7 +33,7 @@ export default memo(ClubCard); const styles = StyleSheet.create({ card: { - marginTop: 28, + marginTop: isWeb ? 16 : 28, borderRadius: 20, backgroundColor: "#252438", overflow: "hidden", diff --git a/src/screens/Home/components/StageCard.js b/src/screens/Home/components/StageCard.js index 378255b..d5dd902 100644 --- a/src/screens/Home/components/StageCard.js +++ b/src/screens/Home/components/StageCard.js @@ -71,8 +71,8 @@ const StageCard = ({ style={[ { width: "100%", - height: isMobileVariant ? 190 : "100%", - minHeight: isMobileVariant ? 190 : 210, + height: 190, + minHeight: 190, borderRadius: 0, }, isMobileVariant @@ -92,7 +92,7 @@ const StageCard = ({ width: "100%", flexDirection: "row", alignItems: "center", - marginBottom: 8, + marginBottom: isMobileVariant ? 8 : 6, }, isMobileVariant ? { justifyContent: "center" } @@ -125,7 +125,7 @@ const StageCard = ({ const textBlockBaseStyle = { flexGrow: 1, flexShrink: 1, - paddingVertical: 20, + paddingVertical: isMobileVariant ? 20 : 16, paddingHorizontal: 20, justifyContent: "center", alignSelf: "center", diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index cd9ff0f..d650055 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -63,6 +63,8 @@ const MusicDetails = ({ route }) => { const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); const wasPlayingBeforeSeek = useRef(false); + const hasCapturedSeekStateRef = useRef(false); + const lastSeekTargetMsRef = useRef(null); const listenedMsRef = useRef(0); const incrementDoneRef = useRef(false); const timerRef = useRef(null); @@ -330,8 +332,12 @@ const MusicDetails = ({ route }) => { const handleSliderSeekStart = useCallback(async () => { if (!trackDescriptor) return; - try { + lastSeekTargetMsRef.current = null; + if (!hasCapturedSeekStateRef.current) { + hasCapturedSeekStateRef.current = true; wasPlayingBeforeSeek.current = isTrackPlaying; + } + try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: positionMs, autoPlay: false }); } @@ -355,6 +361,7 @@ const MusicDetails = ({ route }) => { const dur = sliderDurationMs || 0; if (!trackDescriptor || dur <= 0) return; const targetMs = Math.max(0, Math.floor(dur * ratio)); + lastSeekTargetMsRef.current = targetMs; try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }); @@ -422,11 +429,24 @@ const MusicDetails = ({ route }) => { ]); const handleSliderSeekEnd = useCallback(async () => { + const targetMs = + typeof lastSeekTargetMsRef.current === "number" + ? Math.max(0, lastSeekTargetMsRef.current) + : null; try { if (wasPlayingBeforeSeek.current) { if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: positionMs, autoPlay: true }); + await ensureLoaded({ + startPositionMs: + targetMs !== null && Number.isFinite(targetMs) + ? targetMs + : positionMs, + autoPlay: true, + }); } else { + if (targetMs !== null && Number.isFinite(targetMs)) { + await seekTrackTo(targetMs); + } await resumeTrack(); } } @@ -434,8 +454,10 @@ const MusicDetails = ({ route }) => { console.log("MusicDetails seek end error", e?.message); } finally { wasPlayingBeforeSeek.current = false; + hasCapturedSeekStateRef.current = false; + lastSeekTargetMsRef.current = null; } - }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack]); + }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]); const handleSeekBySeconds = useCallback( async (deltaSeconds) => { diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index b76130e..d12109a 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -49,7 +49,7 @@ const Playback = ({ route, navigation }) => { return ( - + navigation.navigate(Routes.Home)} progress={25} diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 83747fa..9e33442 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -52,6 +52,7 @@ const RecordPlayback = ({ route }) => { const countdownTimerRef = useRef(null); const listenTimerRef = useRef(null); const checkSongEndRef = useRef(null); + const isPausedRef = useRef(false); const stopRequestedRef = useRef(false); const restartRequestedRef = useRef(false); const manualRestartInFlightRef = useRef(false); @@ -73,6 +74,7 @@ const RecordPlayback = ({ route }) => { const [isPreparing, setIsPreparing] = useState(false); const [countdown, setCountdown] = useState(0); const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); const [showProgress, setShowProgress] = useState(false); const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); const [cameraFacing, setCameraFacing] = useState("front"); @@ -100,6 +102,9 @@ const RecordPlayback = ({ route }) => { useEffect(() => { latestLoopingValueRef.current = isLooping ?? false; }, [isLooping]); + useEffect(() => { + isPausedRef.current = isPaused; + }, [isPaused]); const musicIndex = useMemo(() => { const i = Number(songIndex); @@ -302,8 +307,13 @@ const RecordPlayback = ({ route }) => { setIsPreparing(false); setIsRecording(false); + setIsPaused(false); + isPausedRef.current = false; setShowProgress(false); setCountdown(0); + try { + await cameraRef.current?.resumePreview?.(); + } catch (_) {} if (player) { try { @@ -392,6 +402,8 @@ const RecordPlayback = ({ route }) => { preserveSongEndWatcher, preserveStopRequest, }); + setIsPaused(false); + isPausedRef.current = false; if (!preserveRestartFlag) { restartRequestedRef.current = false; manualRestartInFlightRef.current = false; @@ -443,6 +455,11 @@ const RecordPlayback = ({ route }) => { setIsRecording(true); setShowProgress(true); + setIsPaused(false); + isPausedRef.current = false; + try { + await cameraRef.current?.resumePreview?.(); + } catch (_) {} playbackStartedRef.current = false; progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }; log("startRecordingWithMusic", { @@ -566,6 +583,7 @@ const RecordPlayback = ({ route }) => { checkSongEndRef.current = setInterval(() => { try { if (!player) return; + if (isPausedRef.current) return; if (stopRequestedRef.current) { const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0); if (sinceLastRequest >= 1200) { @@ -630,6 +648,8 @@ const RecordPlayback = ({ route }) => { } setIsRecording(false); + setIsPaused(false); + isPausedRef.current = false; setShowProgress(false); log("Recording flow completed", { hasVideo: !!video?.uri }); playbackStartedRef.current = false; @@ -683,6 +703,8 @@ const RecordPlayback = ({ route }) => { stopRequestedAtRef.current = 0; setIsRecording(false); setIsPreparing(false); + setIsPaused(false); + isPausedRef.current = false; setShowProgress(false); if (listenTimerRef.current) { clearInterval(listenTimerRef.current); @@ -786,6 +808,33 @@ const RecordPlayback = ({ route }) => { }, [handleBackPress]) ); + const handleTogglePause = useCallback(async () => { + try { + if (!isRecording) return; + if (!isPausedRef.current) { + setIsPaused(true); + isPausedRef.current = true; + stopRequestedRef.current = false; + stopRequestedAtRef.current = 0; + try { + await player?.pause?.(); + } catch (_) {} + try { + await cameraRef.current?.pausePreview?.(); + } catch (_) {} + return; + } + setIsPaused(false); + isPausedRef.current = false; + try { + await cameraRef.current?.resumePreview?.(); + } catch (_) {} + try { + await player?.play?.(); + } catch (_) {} + } catch (_) {} + }, [isRecording, player]); + const handleRestartRecording = async () => { try { const hasRecordingPending = !!activeRecordingPromiseRef.current; @@ -794,6 +843,11 @@ const RecordPlayback = ({ route }) => { isRecording, hasRecordingPending, }); + setIsPaused(false); + isPausedRef.current = false; + try { + await cameraRef.current?.resumePreview?.(); + } catch (_) {} if (isPreparing) { if (hasRecordingPending) { await startCountdownThenRecord({ @@ -980,6 +1034,34 @@ const RecordPlayback = ({ route }) => { + {isRecording && ( + + + {isPaused ? "Reprendre" : "Mettre en pause"} + + + )} )} diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index 8f08106..42518d4 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -25,6 +25,7 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import { background } from "../../assets"; import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon"; +import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache"; const TIME_BEFORE_INCREMENT_MS = 20000; const WEB_PREVIEW_WIDTH = 360; @@ -82,6 +83,7 @@ const RecordPlayback = ({ route }) => { const stopRecordingPromiseRef = useRef(null); const stopRecordingResolveRef = useRef(null); const recordedUrlRef = useRef(null); + const preserveRecordingForNextScreenRef = useRef(false); const originalLoopingValueRef = useRef({ hasValue: false, value: false }); const latestLoopingValueRef = useRef(isLooping ?? false); const [mediaReady, setMediaReady] = useState(false); @@ -91,14 +93,18 @@ const RecordPlayback = ({ route }) => { const [countdown, setCountdown] = useState(0); const [isPreparing, setIsPreparing] = useState(false); const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); const [pos, setPos] = useState(0); const [dur, setDur] = useState(0); // timers / refs + const isPausedRef = useRef(false); const progressTimerRef = useRef(null); const listenTimerRef = useRef(null); const countdownTimerRef = useRef(null); const perfStartRef = useRef(null); + const pausedAtRef = useRef(null); + const pausedMsRef = useRef(0); const listenedMsRef = useRef(0); const viewsIncrementedRef = useRef(false); const correctedInitialJumpRef = useRef(false); @@ -106,6 +112,9 @@ const RecordPlayback = ({ route }) => { useEffect(() => { latestLoopingValueRef.current = isLooping ?? false; }, [isLooping]); + useEffect(() => { + isPausedRef.current = isPaused; + }, [isPaused]); // Lyrics const alignedWords = useMemo(() => { @@ -131,6 +140,10 @@ const RecordPlayback = ({ route }) => { const resetUI = () => { setIsPreparing(false); setIsRecording(false); + setIsPaused(false); + isPausedRef.current = false; + pausedAtRef.current = null; + pausedMsRef.current = 0; setCountdown(0); setPos(0); setDur(0); @@ -141,12 +154,13 @@ const RecordPlayback = ({ route }) => { }; const releaseRecordingUrl = useCallback(() => { - if (recordedUrlRef.current) { - try { - URL.revokeObjectURL(recordedUrlRef.current); - } catch {} - recordedUrlRef.current = null; + if (!recordedUrlRef.current) { + preserveRecordingForNextScreenRef.current = false; + return; } + releaseBlobUrl(recordedUrlRef.current); + recordedUrlRef.current = null; + preserveRecordingForNextScreenRef.current = false; }, []); const startRecorder = useCallback(() => { @@ -203,6 +217,7 @@ const RecordPlayback = ({ route }) => { }); url = URL.createObjectURL(blob); recordedUrlRef.current = url; + registerBlobUrl(url, blob); } else { releaseRecordingUrl(); } @@ -273,8 +288,10 @@ const RecordPlayback = ({ route }) => { } catch (e) {} try { await stopRecorderAndGetUrl(); - releaseRecordingUrl(); } catch (e) {} + if (!preserveRecordingForNextScreenRef.current) { + releaseRecordingUrl(); + } }, [player, releaseRecordingUrl, stopRecorderAndGetUrl]); const resetSessionRef = useRef(resetSession); @@ -410,11 +427,14 @@ const RecordPlayback = ({ route }) => { mediaStreamRef.current.getTracks().forEach((track) => track.stop()); mediaStreamRef.current = null; } - releaseRecordingUrl(); + if (!preserveRecordingForNextScreenRef.current) { + releaseRecordingUrl(); + } }; - }, [releaseRecordingUrl]); + }, [releaseRecordingUrl, preserveRecordingForNextScreenRef]); const stopAndNavigate = useCallback(async () => { + preserveRecordingForNextScreenRef.current = true; clearAllTimers(); try { await player?.pause?.(); @@ -429,6 +449,10 @@ const RecordPlayback = ({ route }) => { } catch (e) {} setIsRecording(false); + setIsPaused(false); + isPausedRef.current = false; + pausedAtRef.current = null; + pausedMsRef.current = 0; navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null }); }, [dur, player, pos, project, stopRecorderAndGetUrl]); @@ -438,11 +462,19 @@ const RecordPlayback = ({ route }) => { progressTimerRef.current = setInterval(async () => { const rawDur = toSeconds(player?.duration); const rawPos = toSeconds(player?.currentTime); + const pausedSince = + pausedAtRef.current != null + ? Math.max(0, performance.now() - pausedAtRef.current) + : 0; + const pausedTotal = pausedMsRef.current + pausedSince; // fallback monotone si le player ne donne rien const fallback = perfStartRef.current != null - ? Math.max(0, (performance.now() - perfStartRef.current) / 1000) + ? Math.max( + 0, + (performance.now() - perfStartRef.current - pausedTotal) / 1000 + ) : 0; let nextDur = rawDur > 0 ? rawDur : dur || 0; @@ -464,7 +496,7 @@ const RecordPlayback = ({ route }) => { const timeSinceStart = perfStartRef.current != null - ? performance.now() - perfStartRef.current + ? Math.max(0, performance.now() - perfStartRef.current - pausedTotal) : null; const shouldTrustFallback = @@ -484,6 +516,10 @@ const RecordPlayback = ({ route }) => { setDur(nextDur); setPos(nextPos); + if (isPausedRef.current) { + return; + } + if (nextDur > 0 && nextPos >= nextDur - 0.3) { void stopAndNavigate(); } @@ -516,6 +552,10 @@ const RecordPlayback = ({ route }) => { const startPlayback = useCallback(async () => { try { + setIsPaused(false); + isPausedRef.current = false; + pausedAtRef.current = null; + pausedMsRef.current = 0; await player?.pause?.(); // s'assure qu'on repart propre await player?.seekTo?.(0); // tente un seek d'amorçage const recorderStarted = startRecorder(); @@ -530,6 +570,9 @@ const RecordPlayback = ({ route }) => { } catch (e) { setIsRecording(false); setIsPreparing(false); + setIsPaused(false); + isPausedRef.current = false; + pausedAtRef.current = null; const msg = String(e?.message || e || ""); if (/not supported on the simulator/i.test(msg)) { alert( @@ -557,6 +600,8 @@ const RecordPlayback = ({ route }) => { const startCountdownThenRecord = useCallback(async () => { if (!songUrl || !canRecord) return; await resetSession(); + setIsPaused(false); + isPausedRef.current = false; setIsPreparing(true); setCountdown(5); if (countdownTimerRef.current) clearInterval(countdownTimerRef.current); @@ -575,6 +620,43 @@ const RecordPlayback = ({ route }) => { }, 1000); }, [songUrl, canRecord, resetSession, startPlayback]); + const handleTogglePause = useCallback(async () => { + try { + if (!isRecording) return; + if (!isPausedRef.current) { + isPausedRef.current = true; + setIsPaused(true); + pausedAtRef.current = performance.now(); + try { + await player?.pause?.(); + } catch (e) {} + try { + const recorder = mediaRecorderRef.current; + if (recorder && recorder.state === "recording") { + recorder.pause?.(); + } + } catch (e) {} + return; + } + const pausedAt = pausedAtRef.current; + if (pausedAt != null) { + pausedMsRef.current += performance.now() - pausedAt; + pausedAtRef.current = null; + } + isPausedRef.current = false; + setIsPaused(false); + try { + const recorder = mediaRecorderRef.current; + if (recorder && recorder.state === "paused") { + recorder.resume?.(); + } + } catch (e) {} + try { + await player?.play?.(); + } catch (e) {} + } catch (e) {} + }, [isRecording, player]); + const handleRestartRecording = useCallback(async () => { try { await startCountdownThenRecord(); @@ -586,9 +668,7 @@ const RecordPlayback = ({ route }) => { return ( @@ -766,6 +846,34 @@ const RecordPlayback = ({ route }) => { + {isRecording && ( + + + {isPaused ? "Reprendre" : "Mettre en pause"} + + + )} )} diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index 664fc9d..89840e0 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -15,7 +15,6 @@ import { gutters } from "../../styles"; const RecordedPlayback = ({ route }) => { const { videoUri, project } = route.params || {}; const songUrl = project?.songUrl || null; - console.log("video uri is : ", videoUri); const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined, title: typeof project?.title === "string" ? project.title : "Sans titre", diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js index 069c4e9..7b67bc9 100644 --- a/src/screens/Playback/RecordedPlayback.web.js +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -17,6 +17,7 @@ import { goBack, navigate } from "../../navigation/NavigationService"; import { gutters, Palette } from "../../styles"; import { isWeb } from "../../hooks/useLayoutType"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; +import { releaseBlobUrl } from "../../utils/blobUrlCache"; const fmtSeconds = (s) => { const total = Math.max(0, Math.floor(Number(s || 0))); @@ -36,7 +37,6 @@ const WEB_PREVIEW_WIDTH = 360; const RecordedPlayback = ({ route }) => { const { videoUri, project } = route.params || {}; const songUrl = project?.songUrl || null; - console.log("video uri is : ", videoUri); // AUDIO PLAYER (expo-audio → seconds) const audioPlayer = useSharedAudioPlayer( songUrl ? { uri: songUrl } : undefined, @@ -58,6 +58,7 @@ const RecordedPlayback = ({ route }) => { // Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source const videoElRef = useRef(null); const playbackEndedRef = useRef(false); + const shouldPreserveBlobRef = useRef(false); const [progress, setProgress] = useState({ posS: 0, // secondes @@ -76,6 +77,14 @@ const RecordedPlayback = ({ route }) => { } catch {} }, [audioPlayer]); + useEffect(() => { + return () => { + if (!shouldPreserveBlobRef.current) { + releaseBlobUrl(videoUri || null); + } + }; + }, [videoUri]); + // Démarrage / arrêt useEffect(() => { playbackEndedRef.current = false; @@ -345,6 +354,7 @@ const RecordedPlayback = ({ route }) => { { + shouldPreserveBlobRef.current = true; navigate(Routes.DownloadSongs, { action: "playback", uri: videoUri || null, // peut être null sur web diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index 2ee192b..38c949f 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -8,14 +8,21 @@ import React, { useRef, useState, } from "react"; -import { Image, Platform, Pressable, Text, View } from "react-native"; +import { + ActivityIndicator, + Image, + Platform, + Pressable, + Text, + View, +} from "react-native"; import Carousel from "react-native-reanimated-carousel"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { icons, img } from "../../assets"; import { openComments } from "../../components/bottomsheets/CommentsBottomSheet"; import KaraokeLyrics from "../../components/KaraokeLyrics"; import ProfilePicture from "../../components/ProfilePicture"; -import { projectsRef, usersRef } from "../../config/firebase"; +import firebase, { projectsRef, usersRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import { Routes } from "../../navigation"; import { navigate } from "../../navigation/NavigationService"; @@ -36,14 +43,37 @@ import { import { SheetManager } from "react-native-actions-sheet"; import { Feather } from "@expo/vector-icons"; +const PLAYBACK_CODEC_TAG = "h264-v1"; + const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const { currentUID, followUser, unfollowUser } = useUser() || {}; - const videoUrl = item?.playbackUrl || null; + const baseVideoUrl = item?.playbackUrl || null; + const [overrideVideoUrl, setOverrideVideoUrl] = useState(null); + const videoUrl = overrideVideoUrl || baseVideoUrl; const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK); const [isLiked, setIsLiked] = useState( currentUID ? initialLikedBy.includes(currentUID) : false ); const [likesCount, setLikesCount] = useState(initialLikedBy.length); + const [repairState, setRepairState] = useState({ + running: false, + error: null, + }); + const repairAttemptedRef = useRef(false); + + useEffect(() => { + setOverrideVideoUrl(null); + repairAttemptedRef.current = false; + setRepairState({ running: false, error: null }); + }, [item?.id, baseVideoUrl]); + + useEffect(() => { + logPlaybackEvent("state-change", { + baseVideoUrl: baseVideoUrl || null, + overrideVideoUrl, + repairState, + }); + }, [baseVideoUrl, logPlaybackEvent, overrideVideoUrl, repairState]); const commentsCount = useMemo( () => Number(item?.commentsCount || 0), @@ -122,31 +152,137 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { return ""; }, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]); + const needsCodecRepair = + Platform.OS === "ios" && + !!baseVideoUrl && + item?.playbackCompatibility?.codec !== PLAYBACK_CODEC_TAG; + + const logPlaybackEvent = useCallback( + (event, extra = {}) => { + if (Platform.OS !== "ios") return; + try { + console.log("[Playbacks][iOS]", event, { + projectId: item?.id, + isActive, + hasVideoUrl: !!videoUrl, + hasOverride: !!overrideVideoUrl, + needsCodecRepair, + compatibility: item?.playbackCompatibility || null, + repairRunning: repairState.running, + repairError: repairState.error?.message || null, + ...extra, + }); + } catch (loggingError) { + // ignore logging errors + } + }, + [ + item?.id, + isActive, + needsCodecRepair, + overrideVideoUrl, + repairState.error?.message, + repairState.running, + videoUrl, + ] + ); + + const handleRepair = useCallback( + async (force = false) => { + if ((!needsCodecRepair && !force) || !item?.id) { + logPlaybackEvent("repair-skip", { force }); + return; + } + if (repairAttemptedRef.current) { + logPlaybackEvent("repair-skip-already-attempted", { force }); + return; + } + repairAttemptedRef.current = true; + setRepairState({ running: true, error: null }); + logPlaybackEvent("repair-start", { force }); + try { + const callable = + firebase.functions().httpsCallable("upload-reencodePlayback"); + const { data } = await callable({ projectId: item.id }); + const nextUrl = data?.url || null; + if (nextUrl) { + setOverrideVideoUrl(nextUrl); + } + setRepairState({ running: false, error: null }); + logPlaybackEvent("repair-success", { nextUrlPresent: !!nextUrl }); + } catch (error) { + console.log("[Playbacks] reencode failed", { + projectId: item?.id, + message: error?.message || String(error || ""), + code: error?.code, + }); + setRepairState({ + running: false, + error: + error instanceof Error + ? error + : new Error(String(error || "Playback repair failed")), + }); + logPlaybackEvent("repair-failed", { + error: error?.message || String(error || ""), + code: error?.code, + }); + } + }, + [item?.id, logPlaybackEvent, needsCodecRepair] + ); + + useEffect(() => { + if (!isActive || !needsCodecRepair) return; + handleRepair(); + }, [isActive, needsCodecRepair, handleRepair]); + const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { p.loop = false; p.muted = false; p.timeUpdateEventInterval = 0.2; }); + useEffect(() => { + if (Platform.OS !== "ios") return; + if (!videoPlayer?.addListener) return; + const sub = videoPlayer.addListener("error", (event) => { + console.log("[Playbacks] video error", { + projectId: item?.id, + error: event?.error || event, + }); + logPlaybackEvent("video-error", { error: event?.error || event }); + if (!repairAttemptedRef.current) { + handleRepair(true); + } + }); + return () => { + try { + sub?.remove?.(); + } catch (e) {} + }; + }, [videoPlayer, handleRepair, item?.id]); + + const shouldAutoPlay = isActive && !repairState.running; + useEffect(() => { const toggle = async () => { try { - if (isActive) { + if (shouldAutoPlay) { try { if (videoPlayer) videoPlayer.currentTime = 0; } catch (e) {} - // Lancer quasi simultanément (éviter await pour limiter le décalage) try { if (videoPlayer) videoPlayer.play(); } catch (e) {} - } else { - if (videoPlayer?.playing) videoPlayer.pause(); + } else if (videoPlayer?.playing) { + videoPlayer.pause(); } } catch (e) {} }; toggle(); - }, [isActive, videoPlayer]); + }, [shouldAutoPlay, videoPlayer]); useEffect(() => { return () => { @@ -232,6 +368,59 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { /> )} + {Platform.OS === "ios" && repairState.running && ( + + + + Optimisation vidéo iOS... + + + )} + + {Platform.OS === "ios" && repairState.error && ( + + + Impossible de lire cette vidéo. Veuillez réessayer plus tard. + + + )} + {/* Right side actions */} { + if (Platform.OS !== "web") return; + if (!url) return; + if (typeof document === "undefined") return; + const baseName = (title || "Playback").toString().trim() || "Playback"; + const sanitized = baseName.replace(/[\\/:*?"<>|]/g, "-"); + const filename = `${sanitized}.mp4`; + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.rel = "noopener noreferrer"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } catch (error) { + console.log("[DownloadSongs] web download fallback", { + message: error?.message, + }); + try { + window.open(url, "_blank", "noopener,noreferrer"); + } catch {} + } +}; const guessExtension = (inputUri = "") => { const cleaned = inputUri.split("?")[0] || ""; @@ -55,11 +81,17 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { sourcePath, }); + const cachedBlob = getBlobForUrl(uri); + console.log("[DownloadSongs] uploadSourceRecording blob cache", { + hasBlob: Boolean(cachedBlob), + }); + const { resultURI: videoUrl } = await uploadFileToFirebase({ uri, path: sourcePath, shouldCompress: false, fileType: "VIDEO", + blob: cachedBlob || undefined, }); return { sourcePath, videoUrl }; @@ -97,6 +129,12 @@ const DownloadSongs = ({ route }) => { setIsAfterPlaybackVideoVisible(true); }, [action, afterPlaybackUrl]); + useEffect(() => { + return () => { + releaseBlobUrl(uri || null); + }; + }, [uri]); + const handleDownloadUri = async () => { if (action === "playback" && project?.id) { // Publication du playback @@ -128,6 +166,7 @@ const DownloadSongs = ({ route }) => { .httpsCallable("upload-mergeVideoAndAudio"); const payload = { + projectId: project?.id, videoUrl, audioUrl, storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, @@ -156,6 +195,7 @@ const DownloadSongs = ({ route }) => { type: "success", text: "Vidéo uploadée", }); + triggerWebDownload(resultURI, project?.title); if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete(); @@ -191,6 +231,7 @@ const DownloadSongs = ({ route }) => { ), }); } finally { + releaseBlobUrl(uri || null); setIsLoading(false); } // Handle playback download diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index f8f2291..1e7a498 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -28,6 +28,7 @@ const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; const FUNCTIONS_REGION = "europe-west1"; const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"]; +const OPTIONAL_VOICE_CATEGORIES = new Set(["SENSIBILITÉ", "TECHNIQUE"]); const FIRST_VOICE_STEP_INDEX = 1; const TOTAL_STEPS = 1 + VOICE_SECTION_TITLES.length + 2; // genre + voices + instruments + rhythm const LAST_STEP_INDEX = TOTAL_STEPS - 1; @@ -78,9 +79,18 @@ const ComposeSong = () => { if (selectedIndex === 0) { return Array.isArray(genres) && genres.length > 0; } - if (selectedIndex === FIRST_VOICE_STEP_INDEX) { - return !!(voice && typeof voice === "object" && voice.BASE); + + const voiceStepIndex = selectedIndex - FIRST_VOICE_STEP_INDEX; + const isVoiceStep = + voiceStepIndex >= 0 && voiceStepIndex < VOICE_SECTION_TITLES.length; + if (isVoiceStep) { + const category = VOICE_SECTION_TITLES[voiceStepIndex]; + if (OPTIONAL_VOICE_CATEGORIES.has(category)) { + return true; + } + return !!(voice && typeof voice === "object" && voice[category]); } + if (selectedIndex === INSTRUMENT_STEP_INDEX) { return Array.isArray(instruments) && instruments.length > 0; } diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index 33b8336..db218c6 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -33,6 +33,8 @@ const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; const FUNCTIONS_REGION = "europe-west1"; const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"]; +const FIRST_VOICE_STEP_INDEX = 1; +const OPTIONAL_VOICE_CATEGORIES = new Set(["SENSIBILITÉ", "TECHNIQUE"]); const ComposeSong = () => { const scrollRef = useRef(null); @@ -115,9 +117,18 @@ const ComposeSong = () => { if (selectedIndex === 0) { return Array.isArray(genres) && genres.length > 0; } - if (selectedIndex === 1) { - return !!(voice && typeof voice === "object" && voice.BASE); + + const voiceStepIndex = selectedIndex - FIRST_VOICE_STEP_INDEX; + const isVoiceStep = + voiceStepIndex >= 0 && voiceStepIndex < VOICE_SECTION_TITLES.length; + if (isVoiceStep) { + const category = VOICE_SECTION_TITLES[voiceStepIndex]; + if (OPTIONAL_VOICE_CATEGORIES.has(category)) { + return true; + } + return !!(voice && typeof voice === "object" && voice[category]); } + if (selectedIndex === instrumentStepIndex) { return Array.isArray(instruments) && instruments.length > 0; } diff --git a/src/screens/Studio/CustomizeVoice.js b/src/screens/Studio/CustomizeVoice.js index 2196dc2..5d6ae89 100644 --- a/src/screens/Studio/CustomizeVoice.js +++ b/src/screens/Studio/CustomizeVoice.js @@ -9,7 +9,7 @@ import ListSelection from "../../components/ListSelection/ListSelection"; const SECTION_INSTRUCTIONS = { BASE: "Choisis ta base", SENSIBILITE: "Choisis ta sensibilité", - TECHNIQUE: "Choisis ta technique", + TECHNIQUE: "Choisis ta technique (Facultatif)", }; const normalizeCategory = (value) => { diff --git a/src/screens/Writing/Lyrics.js b/src/screens/Writing/Lyrics.js index 6c2913b..e7e291c 100644 --- a/src/screens/Writing/Lyrics.js +++ b/src/screens/Writing/Lyrics.js @@ -40,7 +40,7 @@ const Lyrics = ({ navigation }) => { const hasExistingMusicDraft = useMemo(() => { if (!Array.isArray(selectedProject?.musicUrls)) return false; return selectedProject.musicUrls.some( - (url) => typeof url === "string" && url.trim() + (url) => typeof url === "string" && url.trim(), ); }, [selectedProject?.musicUrls]); const [isFocus, setIsFocus] = useState(null); @@ -53,6 +53,28 @@ const Lyrics = ({ navigation }) => { const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] = useState(false); const sensitiveContentResolverRef = useRef(null); + const updateLayoutAtIndex = useCallback((index, layout) => { + if (typeof index !== "number" || !layout) return; + setItemsContainerLayout((prev) => { + const next = [...prev]; + next[index] = layout; + return next; + }); + }, []); + const handleSectionFocus = useCallback( + (idx) => { + setIsFocus(idx); + if (isWeb) return; + const targetLayout = itemsContainerLayout[idx + 1]; + if (typeof targetLayout?.y === "number") { + scrollRef?.current?.scrollTo({ + y: targetLayout.y, + animated: true, + }); + } + }, + [itemsContainerLayout, isWeb], + ); const closeSensitiveContentModal = useCallback((result) => { setSensitiveContentModal((prev) => ({ ...prev, visible: false })); @@ -115,7 +137,7 @@ const Lyrics = ({ navigation }) => { }); const remainingTextual = remaining.filter((item) => - segmentRequiresLyrics(item.type) + segmentRequiresLyrics(item.type), ); sections.push(...remainingTextual); @@ -194,7 +216,7 @@ const Lyrics = ({ navigation }) => { if (invalid) { alertMessage( "Champs incomplets", - "Chaque section doit contenir du texte." + "Chaque section doit contenir du texte.", ); return; } @@ -217,7 +239,7 @@ const Lyrics = ({ navigation }) => { normalizedOldLyrics.every( (s, i) => s.type === normalizedNewLyrics[i]?.type && - s.lyrics === normalizedNewLyrics[i]?.lyrics + s.lyrics === normalizedNewLyrics[i]?.lyrics, ); // 1) Appel de la Cloud Function de modération avant tout enregistrement @@ -241,7 +263,7 @@ const Lyrics = ({ navigation }) => { : null; alertMessage( "Contenu interdit", - [data?.message, quotes].filter(Boolean).join("\n\n") + [data?.message, quotes].filter(Boolean).join("\n\n"), ); return; // stop here } @@ -249,7 +271,7 @@ const Lyrics = ({ navigation }) => { if (data?.errorCode === "ANALYSE_FAILED") { alertMessage( "Analyse indisponible", - "Impossible de vérifier la toxicité pour le moment. Réessayez plus tard." + "Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.", ); return; } @@ -267,18 +289,18 @@ const Lyrics = ({ navigation }) => { const proceed = await confirmSensitiveContent( "Contenu potentiellement sensible", - msg + msg, ); if (!proceed) return; } } catch (moderationError) { console.log( "Moderation call failed", - moderationError?.message || moderationError + moderationError?.message || moderationError, ); alertMessage( "Analyse indisponible", - "Impossible de vérifier la toxicité pour le moment. Réessayez plus tard." + "Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.", ); return; } @@ -339,7 +361,7 @@ const Lyrics = ({ navigation }) => { text: "Continuer vers le Studio", onPress: handleNavigateToStudio, }, - ] + ], ); return; } catch (e) { @@ -404,11 +426,10 @@ const Lyrics = ({ navigation }) => { {strings.writing.lyrics.instructions} - - - {strings.writing.lyrics.personalizationBanner} - - + + N’hesites pas a personnaliser les paroles proposées en ajoutant + ta touche personnelle mettre mieux en évidence + { multiline={false} maxLength={60} onLayout={(e) => { - e.persist(); - setItemsContainerLayout((prev) => [ - ...prev, - e?.nativeEvent?.layout, - ]); + updateLayoutAtIndex(0, e?.nativeEvent?.layout); }} /> {sections.map((s, idx) => { @@ -460,18 +477,9 @@ const Lyrics = ({ navigation }) => { height={inputHeight} value={s?.lyrics || ""} setValue={(val) => setSectionAt(idx, val)} - onFocus={() => { - setIsFocus(idx); - scrollRef?.current?.scrollTo({ - y: itemsContainerLayout[idx + 1]?.y, - }); - }} + onFocus={() => handleSectionFocus(idx)} onLayout={(e) => { - e.persist(); - setItemsContainerLayout((prev) => [ - ...prev, - e?.nativeEvent?.layout, - ]); + updateLayoutAtIndex(idx + 1, e?.nativeEvent?.layout); }} /> ); @@ -558,18 +566,12 @@ const styles = StyleSheet.create({ fontSize: 14, lineHeight: 20, }, - personalizationBanner: { - backgroundColor: Palette.ultraLightWhite, - borderRadius: 12, - paddingVertical: 10, - paddingHorizontal: 12, - marginTop: 4, - }, personalizationText: { color: Palette.white, - fontFamily: FONT_FAMILY.InterMedium, - fontSize: 14, - lineHeight: 20, + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 18, + lineHeight: 24, + marginTop: 8, }, instrumentalBlock: { padding: 16, diff --git a/src/screens/cover/ChooseCoverType.js b/src/screens/cover/ChooseCoverType.js index e1a2c33..f39ca96 100644 --- a/src/screens/cover/ChooseCoverType.js +++ b/src/screens/cover/ChooseCoverType.js @@ -115,49 +115,6 @@ export const ChooseCoverType = () => { [currentUID, projectId], ); - // const pickUserImage = useCallback(async () => { - // try { - // await setIsLoading(true); - // const result = await ImagePicker.launchImageLibraryAsync({ - // mediaTypes: ["images"], - // allowsEditing: true, - // aspect: [1, 1], - // quality: 1, - // }); - - // const uri = result?.assets?.[0]?.uri || null; - // if (!uri) return; - - // if (!projectId) return; - // const path = `musics/${projectId}/userSelectedCover.png`; - - // const { resultURI } = await uploadFileToFirebase({ - // uri, - // path, - // shouldCompress: true, - // fileType: "IMAGE", - // }); - - // if (!resultURI) { - // throw new Error("Erreur lors de l'upload"); - // } - // await updateProjectData({ - // cover: { - // result: resultURI, - // }, - // }); - // navigate(Routes.ValidateCover); - // } catch (e) { - // console.log("onPickUserImage error", e?.message); - // } finally { - // await setIsLoading(false); - // } - // }, [projectId, setIsLoading, updateProjectData]); - - // const handlePickUserImage = useCallback(() => { - // ensureArtistPreference(pickUserImage); - // }, [ensureArtistPreference, pickUserImage]); - const handleGenerateCover = useCallback(() => { if (hasFinalCover || hasGeneratedOptions) { navigate(Routes.ValidateCover); @@ -300,10 +257,10 @@ export const ChooseCoverType = () => { /> - setShowIntro(false)} - /> + {/* setShowIntro(false)}*/} + {/*/>*/} { + if (globalScope && globalScope[REGISTRY_KEY] instanceof Map) { + return globalScope[REGISTRY_KEY]; + } + const registry = new Map(); + if (globalScope) { + try { + globalScope[REGISTRY_KEY] = registry; + } catch {} + } + return registry; +})(); + +const isValidBlobUrl = (url) => + hasObjectUrlSupport && typeof url === "string" && url.startsWith("blob:"); + +export const registerBlobUrl = (url, blob) => { + if (!isValidBlobUrl(url) || !blob) return; + blobRegistry.set(url, blob); +}; + +export const getBlobForUrl = (url) => { + if (!isValidBlobUrl(url)) return null; + return blobRegistry.get(url) || null; +}; + +export const releaseBlobUrl = (url) => { + if (!isValidBlobUrl(url)) return; + blobRegistry.delete(url); + try { + globalScope.URL.revokeObjectURL(url); + } catch {} +}; diff --git a/src/utils/projectStages.js b/src/utils/projectStages.js index 1abf34e..18da832 100644 --- a/src/utils/projectStages.js +++ b/src/utils/projectStages.js @@ -7,10 +7,34 @@ export const CREATION_STAGE_KEYS = [ "publisher", ]; +const getSectionLyrics = (section) => { + if (typeof section === "string") { + return section.trim(); + } + if (section && typeof section === "object") { + const value = + typeof section.lyrics === "string" ? section.lyrics.trim() : ""; + return value; + } + return ""; +}; + const getLyricsCount = (project) => { if (!project) return 0; const { lyrics } = project; - return Array.isArray(lyrics) ? lyrics.length : 0; + if (Array.isArray(lyrics)) { + return lyrics.reduce( + (count, section) => (getSectionLyrics(section) ? count + 1 : count), + 0, + ); + } + if (lyrics && typeof lyrics === "object") { + return Object.values(lyrics).reduce( + (count, value) => (getSectionLyrics(value) ? count + 1 : count), + 0, + ); + } + return getSectionLyrics(lyrics) ? 1 : 0; }; const getStageMetadata = (project) => { @@ -73,6 +97,9 @@ const getStageLockState = (key, metadata) => { } return metadata.lyricsCount <= 0; case "director": + if (metadata.hasPlaybackAsset) { + return true; + } return !metadata.hasCover; case "publisher": return !metadata.hasPlaybackAsset;