import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import * as FileSystem from 'expo-file-system' import { shareAsync } from 'expo-sharing' import { background } from '../../assets' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import BorderGradientButton from '../../components/BorderGradientButton' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { navigate } from '../../navigation/NavigationService' import { useUserData, useUser } from '../../providers/UserDataProvider' import { useStripe } from '../../providers/StripeProvider' import { Palette } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' import { size } from '../../styles/Style' import { getBlobForUrl, releaseBlobUrl } from '../../utils/blobUrlCache' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import { Image as ExpoImage } from 'expo-image' import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync' const triggerDownload = async (url, title) => { if (!url) return false const baseName = (title || 'Playback').toString().trim() || 'Playback' const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-') const extension = guessExtension(url) || 'mp4' const filename = `${sanitized}.${extension}` // WEB if (Platform.OS === 'web') { if (typeof document === 'undefined') return 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) // Revoke with delay to ensure browser captures it setTimeout(() => URL.revokeObjectURL(blobUrl), 150) return true } catch (error) { console.log('[PlaybackDownload] web download fallback', { message: error?.message, }) // First fallback: Anchor with _blank 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 true } catch (fallbackError) { console.log('[PlaybackDownload] anchor fallback failed', { message: fallbackError?.message, }) // Ultimate fallback: direct window open try { window.open(url, '_blank', 'noopener,noreferrer') return true } catch {} } } } else { // NATIVE (Android / iOS) try { if (FileSystem?.downloadAsync) { const fileUri = FileSystem.documentDirectory + filename const { uri: localUri } = await FileSystem.downloadAsync(url, fileUri) console.log('Finished downloading to ', localUri) if (shareAsync) { await shareAsync(localUri) } return true } } catch (e) { console.error(e) } } return false } 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 FUNCTIONS_REGION = 'europe-west1' const callMergeVideoAndAudio = (payload) => { const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('upload-mergeVideoAndAudio') return callable(payload) } const PlaybackDownload = ({ route }) => { const { currentUID, selectedProject } = useUserData() const { hasActiveSubscription, hasPurchased, videos } = useUser() || {} const { createPlaybackDownloadCheckout } = useStripe() const { action: routeAction, uri, project: routeProject, syncOffsetMs: routeSyncOffsetMs = 0, } = route.params || {} const action = routeAction || 'playback' const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs) console.log('[PlaybackDownload] route params', { action, projectId: routeProject?.id, hasUri: Boolean(uri), currentUID, syncOffsetMs, }) const { setIsLoading, setTooltip } = useMinuit() const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false) const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null) const [isPublishing, setIsPublishing] = useState(false) const [isDownloading, setIsDownloading] = useState(false) const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null) const hasShownAfterPlaybackRef = useRef(false) const projectForDownload = useMemo(() => { if (routeProject && typeof routeProject === 'object' && routeProject.id) { if (selectedProject?.id === routeProject.id) { return selectedProject } return routeProject } return selectedProject?.id ? selectedProject : null }, [routeProject, selectedProject]) const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid' const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased const downloadAccessLabel = canDownloadPlayback ? 'Téléchargement inclus avec ton accès actuel.' : 'Le téléchargement de ce playback coûte 1,99 €.' const afterPlaybackUrl = useMemo(() => { if (!videos) return null return videos?.afterPlayback || null }, [videos]) useEffect(() => { if (action !== 'playback') return if (!afterPlaybackUrl) return if (hasShownAfterPlaybackRef.current) return hasShownAfterPlaybackRef.current = true setIsAfterPlaybackVideoVisible(true) }, [action, afterPlaybackUrl]) useEffect(() => { if (projectForDownload?.playbackUrl) { setPendingPlaybackUrl(projectForDownload.playbackUrl) } }, [projectForDownload?.playbackUrl]) const resolveAudioUrl = useCallback(() => { if (typeof projectForDownload?.songUrl === 'string' && projectForDownload.songUrl.trim()) { return projectForDownload.songUrl.trim() } const urls = Array.isArray(projectForDownload?.musicUrls) ? projectForDownload.musicUrls : [] if (!urls.length) return null const idx = Number.isFinite(Number(projectForDownload?.songIndex)) ? Number(projectForDownload.songIndex) : 0 const candidate = urls[idx] if (typeof candidate === 'string' && candidate.trim()) { return candidate.trim() } return null }, [projectForDownload]) const processMerge = useCallback(async () => { if (action !== 'playback') { throw new Error('Téléchargement indisponible pour cette action') } const cachedPlaybackUrl = pendingPlaybackUrl || projectForDownload?.playbackUrl || null if (cachedPlaybackUrl) return cachedPlaybackUrl if (!projectForDownload?.id) { throw new Error('Projet introuvable pour ce playback') } if (!uri) { throw new Error('Aucune vidéo trouvée pour ce playback') } if (!currentUID) { throw new Error('Utilisateur non authentifié') } const audioUrl = resolveAudioUrl() if (!audioUrl) { throw new Error('Aucune piste audio disponible pour ce projet') } console.log('[PlaybackDownload] processMerge playback', { projectId: projectForDownload.id, uri, currentUID, }) let tempSourcePath = null try { setIsLoading(true) const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, projectId: projectForDownload.id, }) tempSourcePath = sourcePath const payload = { projectId: projectForDownload.id, videoUrl, audioUrl, storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`, syncOffset: toSyncOffsetSeconds(syncOffsetMs), } console.log('[PlaybackDownload] calling upload-mergeVideoAndAudio', payload) const { data: result } = await callMergeVideoAndAudio(payload) console.log('[PlaybackDownload] upload-mergeVideoAndAudio result', result) const resultURI = result?.url || null if (!resultURI) { throw new Error('Erreur lors de la publication du playback') } setPendingPlaybackUrl(resultURI) return resultURI } catch (error) { console.log('[PlaybackDownload] error upload playback', { message: error?.message, code: error?.code, name: error?.name, details: error?.details, }) throw error } 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, }) } } releaseBlobUrl(uri || null) setIsLoading(false) } }, [ action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, syncOffsetMs, uri, ]) const executePlaybackChoice = useCallback( async (intent) => { if (intent !== 'personal' && intent !== 'publish') return if (isPublishing || isDownloading || !projectForDownload?.id) return try { setIsDownloading(true) setIsPublishing(intent === 'publish') const playbackUrl = await processMerge() const didDownload = await triggerDownload(playbackUrl, projectForDownload.title) if (!didDownload) { throw new Error("Le téléchargement du playback n'a pas abouti.") } await projectsRef.doc(projectForDownload.id).set( { playbackUrl, playbackPublishedOnMusicLand: intent === 'publish', playbackPublishedOnMusicLandAt: intent === 'publish' ? serverTimestamp() : null, updatedAt: serverTimestamp(), }, { merge: true } ) setTooltip({ type: 'success', text: intent === 'publish' ? 'Playback téléchargé et publié sur MusicLand !' : 'Playback téléchargé à des fins personnelles.', }) if (intent === 'publish') { navigate(Routes.Home) } } catch (error) { setTooltip({ type: 'error', text: String(error?.message || "Impossible de terminer l'opération."), }) } finally { setIsDownloading(false) setIsPublishing(false) } }, [isDownloading, isPublishing, processMerge, projectForDownload, setTooltip] ) const startPlaybackDownloadCheckout = useCallback( async (intent) => { if (!projectForDownload?.id) { setTooltip({ type: 'error', text: "Impossible d'identifier le projet pour le paiement.", }) return } if (isCheckoutLaunching) { return } setPendingDownloadIntent(intent) setIsCheckoutLaunching(true) setDownloadPaymentPending(true) try { await createPlaybackDownloadCheckout(projectForDownload.id) } catch (error) { setDownloadPaymentPending(false) setPendingDownloadIntent(null) setTooltip({ type: 'error', text: error?.message || 'Une erreur est survenue lors du paiement.', }) } finally { setIsCheckoutLaunching(false) } }, [ createPlaybackDownloadCheckout, isCheckoutLaunching, projectForDownload, setTooltip, ] ) const handlePlaybackChoice = useCallback( (intent) => { if (isDownloading || isPublishing || isCheckoutLaunching) return if (canDownloadPlayback) { executePlaybackChoice(intent) return } startPlaybackDownloadCheckout(intent) }, [ canDownloadPlayback, executePlaybackChoice, isCheckoutLaunching, isDownloading, isPublishing, startPlaybackDownloadCheckout, ] ) useEffect(() => { if ( !downloadPaymentPending || !hasPaidPlaybackDownload || !pendingDownloadIntent || isDownloading ) { return } const intent = pendingDownloadIntent setDownloadPaymentPending(false) setPendingDownloadIntent(null) executePlaybackChoice(intent) }, [ downloadPaymentPending, executePlaybackChoice, hasPaidPlaybackDownload, isDownloading, pendingDownloadIntent, ]) const handleGoHome = useCallback(() => { navigate(Routes.BottomTab, { screen: Routes.HomeStack, params: { screen: Routes.Home, }, }) }, []) return ( <> setIsAfterPlaybackVideoVisible(false)} /> {projectForDownload?.coverUrl ? ( ) : ( Aucune pochette )} {projectForDownload?.title || 'Mon playback'} Téléchargement {downloadAccessLabel} handlePlaybackChoice('personal')} disabled={isPublishing || isDownloading || isCheckoutLaunching} loading={isDownloading && !isPublishing} loadingText="Téléchargement en cours..." height={58} titleStyle={styles.choiceButtonText} /> handlePlaybackChoice('publish')} disabled={isPublishing || isDownloading || isCheckoutLaunching} height={58} textStyle={styles.choiceButtonText} /> {!canDownloadPlayback && downloadPaymentPending ? ( Paiement en attente de confirmation... ) : null} ) } export default PlaybackDownload const styles = StyleSheet.create({ container: { flexGrow: 1, width: '100%', maxWidth: 620, alignSelf: 'center', paddingHorizontal: 16, paddingVertical: 12, gap: 14, }, summaryCard: { alignItems: 'center', justifyContent: 'center', gap: 12, padding: 16, borderRadius: 20, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.14)', backgroundColor: 'rgba(5, 12, 24, 0.76)', }, coverImage: { ...size({ size: 140 }), borderRadius: 14, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.18)', }, coverPlaceholder: { alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(255, 255, 255, 0.06)', }, placeholderText: { fontFamily: FONT_FAMILY.InterMedium, color: Palette.grayMid, }, playbackTitle: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 18, color: Palette.white, textAlign: 'center', }, choiceSection: { gap: 10, padding: 16, borderRadius: 20, backgroundColor: 'rgba(37, 36, 56, 0.94)', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.12)', }, sectionTitle: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 18, color: Palette.white, }, sectionDescription: { fontFamily: FONT_FAMILY.InterRegular, fontSize: 14, lineHeight: 20, color: Palette.white, opacity: 0.8, marginBottom: 4, }, choiceButtonText: { textAlign: 'center', fontSize: 14, }, downloadPendingText: { marginTop: 6, fontFamily: FONT_FAMILY.InterRegular, fontSize: 12, color: Palette.white, opacity: 0.8, }, homeButton: { marginTop: 2, marginBottom: 18, }, })