import React, { useEffect, useMemo, useRef, useState } from "react"; import { Image, Platform, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native"; import { MaterialCommunityIcons } from "@expo/vector-icons"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { background } from "../../assets"; import FullscreenIntroVideo from "../../components/FullscreenIntroVideo"; import BorderGradientButton from "../../components/BorderGradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import AppCheckbox from "../../components/AppCheckbox"; import firebase, { projectsRef, serverTimestamp, videosRef, } from "../../config/firebase"; import { uploadFileToFirebase } from "../../helpers/uploadToFirebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUserData, useUser } from "../../providers/UserDataProvider"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { size } from "../../styles/Style"; import { getBlobForUrl, releaseBlobUrl } from "../../utils/blobUrlCache"; import ClubAdvantagesCard from "../Profile/components/ClubAdvantagesCard"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import { Image as ExpoImage } from "expo-image"; import SubscriptionConfirmModal from "../../components/SubscriptionConfirmModal"; import { isWeb } from "../../hooks/useLayoutType"; const triggerWebDownload = async (url, title) => { 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 extension = guessExtension(url) || "mp4"; const filename = `${sanitized}.${extension}`; try { const response = await fetch(url); if (!response.ok || response.type === "opaque") { throw new Error(`download_failed_${response.status || "opaque"}`); } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = blobUrl; anchor.download = filename; anchor.rel = "noopener noreferrer"; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(blobUrl); } catch (error) { console.log("[PlaybackDownload] web download fallback", { message: error?.message, }); try { const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.rel = "noopener noreferrer"; anchor.target = "_blank"; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); return; } catch (fallbackError) { console.log("[PlaybackDownload] anchor fallback failed", { message: fallbackError?.message, }); } try { window.open(url, "_blank", "noopener,noreferrer"); } catch {} } }; const guessExtension = (inputUri = "") => { const cleaned = inputUri.split("?")[0] || ""; const match = cleaned.match(/\.([a-z0-9]+)$/i); if (match && match[1]) return match[1].toLowerCase(); if (Platform.OS === "web") return "webm"; return "mp4"; }; const uploadSourceRecording = async ({ uri, uid, projectId }) => { console.log("[PlaybackDownload] uploadSourceRecording params", { hasUri: Boolean(uri), uid, projectId, }); if (!uri) throw new Error("Aucune vidéo trouvée"); if (!uid) throw new Error("Utilisateur non authentifié"); const extension = guessExtension(uri); const sourcePath = `users/${uid}/projects/${projectId}/recordings/source-${Date.now()}.${extension}`; console.log("source path : ", sourcePath); console.log("[PlaybackDownload] uploadSourceRecording source path", { extension, sourcePath, }); const cachedBlob = getBlobForUrl(uri); console.log("[PlaybackDownload] uploadSourceRecording blob cache", { hasBlob: Boolean(cachedBlob), }); const { resultURI: videoUrl } = await uploadFileToFirebase({ uri, path: sourcePath, shouldCompress: true, fileType: "VIDEO", blob: cachedBlob || undefined, }); return { sourcePath, videoUrl }; }; const PlaybackDownload = ({ route }) => { const { currentUID } = useUserData(); const { hasActiveSubscription } = useUser() || {}; const { action, uri, project } = route.params || {}; console.log("[PlaybackDownload] route params", { action, projectId: project?.id, hasUri: Boolean(uri), currentUID, }); const { setIsLoading, setTooltip } = useMinuit(); const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState( project?.playbackUrl || null, ); const [isPublishing, setIsPublishing] = useState(false); const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true); const hasShownAfterPlaybackRef = useRef(false); const { data: video } = useDataFromRef({ ref: videosRef.doc("fr"), simpleRef: true, }); const afterPlaybackUrl = useMemo(() => { if (!video) return null; return video?.afterPlayback; }, [video]); useEffect(() => { if (action !== "playback") return; if (!afterPlaybackUrl) return; if (hasShownAfterPlaybackRef.current) return; hasShownAfterPlaybackRef.current = true; setIsAfterPlaybackVideoVisible(true); }, [action, afterPlaybackUrl]); useEffect(() => { if (project?.playbackUrl) { setPendingPlaybackUrl(project.playbackUrl); } }, [project?.playbackUrl]); useEffect(() => { return () => { releaseBlobUrl(uri || null); }; }, [uri]); const handleDownloadUri = async () => { if (isPublishing) return; if (action === "playback" && project?.id) { if (pendingPlaybackUrl) { await triggerWebDownload(pendingPlaybackUrl, project?.title); return; } // Publication du playback console.log("[PlaybackDownload] handleDownloadUri playback", { projectId: project.id, uri, currentUID, }); const audioUrl = project?.songUrl || null; if (!audioUrl) { setTooltip({ type: "error", text: "Aucune piste audio disponible pour ce projet", }); return; } let tempSourcePath = null; try { setIsLoading(true); const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, projectId: project.id, }); tempSourcePath = sourcePath; const callable = firebase .functions() .httpsCallable("upload-mergeVideoAndAudio"); const payload = { projectId: project?.id, videoUrl, audioUrl, storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, }; console.log( "[PlaybackDownload] calling upload-mergeVideoAndAudio", payload, ); const { data: result } = await callable(payload); console.log( "[PlaybackDownload] upload-mergeVideoAndAudio result", result, ); const resultURI = result?.url || null; if (resultURI) { setPendingPlaybackUrl(resultURI); setTooltip({ type: "success", text: "Playback prêt à télécharger", }); await triggerWebDownload(resultURI, project?.title); if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete(); } catch (cleanupError) { console.log("[PlaybackDownload] unable to delete temp source", { message: cleanupError?.message, code: cleanupError?.code, }); } } } else { setTooltip({ type: "error", text: "Erreur lors de la publication du playback", }); } } catch (error) { console.log("[PlaybackDownload] error upload playback", { message: error?.message, code: error?.code, name: error?.name, details: error?.details, }); if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete(); } catch {} } setTooltip({ type: "error", text: String( error?.message || "Erreur lors de la publication du playback", ), }); } finally { releaseBlobUrl(uri || null); setIsLoading(false); } // Handle playback download } else { // Handle song download } }; const handlePublish = async () => { if (isPublishing) return; if (!hasAcceptedPublication) { setTooltip({ type: "error", text: "Confirme la diffusion sur Musicland et YouTube avant de publier", }); return; } if (!project?.id) { if (action === "playback") { setTooltip({ type: "success", text: "Playback publié !", }); navigate(Routes.Home); } else { navigate(Routes.SongRelease, { action }); } return; } let tempSourcePath = null; let playbackUrlToSave = pendingPlaybackUrl || project?.playbackUrl || null; const audioUrl = project?.songUrl || null; if (!audioUrl && !playbackUrlToSave) { setTooltip({ type: "error", text: "Aucune piste audio disponible pour ce projet", }); return; } try { setIsPublishing(true); setIsLoading(true); if (!playbackUrlToSave) { const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, projectId: project.id, }); tempSourcePath = sourcePath; const callable = firebase .functions() .httpsCallable("upload-mergeVideoAndAudio"); const payload = { projectId: project?.id, videoUrl, audioUrl, storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, }; console.log("[PlaybackDownload] publish: calling merge", payload); const { data: result } = await callable(payload); playbackUrlToSave = result?.url || null; if (!playbackUrlToSave) { throw new Error("merge_failed"); } setPendingPlaybackUrl(playbackUrlToSave); } await projectsRef.doc(project.id).set( { playbackUrl: playbackUrlToSave, updatedAt: serverTimestamp(), }, { merge: true }, ); if (action === "playback") { setTooltip({ type: "success", text: "Playback publié !", }); navigate(Routes.Home); } else { navigate(Routes.SongRelease, { action }); } } catch (error) { console.log("[PlaybackDownload] publish error", { message: error?.message, code: error?.code, name: error?.name, details: error?.details, }); setTooltip({ type: "error", text: String(error?.message || "Publication impossible"), }); } finally { if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete(); } catch (cleanupError) { console.log("[PlaybackDownload] unable to delete temp source", { message: cleanupError?.message, code: cleanupError?.code, }); } } setIsPublishing(false); setIsLoading(false); } }; const continueLabel = hasActiveSubscription ? "Publier" : "Publier sans générer de revenus"; return ( <> {project?.coverUrl ? ( ) : ( Aucune pochette )} Télécharger le playback setHasAcceptedPublication((prevState) => !prevState) } label="J'accepte la diffusion de mon playback sur Musicland et YouTube." /> Cette confirmation est requise avant de lancer la publication. { if (hasActiveSubscription) { handlePublish(); } else { setShowConfirmModal(true); } }} disabled={isPublishing || !hasAcceptedPublication} containerStyle={styles.continueButton} /> navigate(Routes.Payments)} onContinue={handlePublish} /> ); }; export default PlaybackDownload; const styles = StyleSheet.create({ container: { flexGrow: 1, paddingHorizontal: 16, paddingVertical: 12, gap: 14, }, coverRow: { flexDirection: isWeb ? "row" : "column", alignItems: "center", justifyContent: isWeb ? "center" : "center", gap: 12, }, coverImage: { ...size({ size: 140 }), borderRadius: 14, borderWidth: 1, borderColor: "rgba(255, 255, 255, 0.18)", }, downloadTile: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10, paddingHorizontal: 14, borderRadius: 14, backgroundColor: "#8C4BFF", }, downloadText: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 15, color: Palette.white, }, clubCardSpacing: { marginTop: 10, }, publishConsentContainer: { gap: 6, marginTop: 2, }, publishConsentDescription: { fontFamily: FONT_FAMILY.InterRegular, fontSize: 13, color: Palette.white, opacity: 0.8, }, continueButton: { marginTop: 10, }, });