import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Alert, 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 * as FileSystem from 'expo-file-system' import { shareAsync } from 'expo-sharing' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import BorderGradientButton from '../../components/BorderGradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import AppCheckbox from '../../components/AppCheckbox' import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { goBack, 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 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 triggerDownload = async (url, title) => { if (!url) return 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) } 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) } catch (fallbackError) { console.log('[PlaybackDownload] anchor fallback failed', { message: fallbackError?.message, }) // Ultimate fallback: direct window open try { window.open(url, '_blank', 'noopener,noreferrer') } 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) } } } catch (e) { console.error(e) } } } 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 } = route.params || {} const action = routeAction || 'playback' console.log('[PlaybackDownload] route params', { action, projectId: routeProject?.id, hasUri: Boolean(uri), currentUID, }) const { setIsLoading, setTooltip } = useMinuit() const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false) const [showConfirmModal, setShowConfirmModal] = 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 [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) const hasShownAfterPlaybackRef = useRef(false) const publishSuccessMessage = action === 'playback' ? 'Playback publié !' : 'Chanson publiée !' const projectForDownload = useMemo(() => { if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { return selectedProject } return routeProject || selectedProject || null }, [routeProject, selectedProject]) const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid' const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased const downloadLabel = canDownloadPlayback ? 'Télécharger le playback' : 'Acheter le playback pour 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`, } 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, uri]) const handleDownloadUri = useCallback(async () => { if (isPublishing || isDownloading) return try { setIsDownloading(true) const resultURI = await processMerge() setTooltip({ type: 'success', text: 'Playback prêt à télécharger', }) await triggerDownload(resultURI, projectForDownload?.title) } catch (error) { setTooltip({ type: 'error', text: String(error?.message || 'Erreur lors de la publication du playback'), }) } finally { setIsDownloading(false) } }, [ isDownloading, isPublishing, processMerge, projectForDownload, setTooltip, ]) const startPlaybackDownloadCheckout = useCallback( async ({ force = false } = {}) => { if (!projectForDownload?.id) { setTooltip({ type: 'error', text: "Impossible d'identifier le projet pour le paiement.", }) return } if (!force && (isCheckoutLaunching || downloadPaymentPending)) { return } setIsCheckoutLaunching(true) setDownloadPaymentPending(true) try { await createPlaybackDownloadCheckout(projectForDownload.id) } catch (error) { setDownloadPaymentPending(false) setTooltip({ type: 'error', text: error?.message || "Une erreur est survenue lors du paiement.", }) } finally { setIsCheckoutLaunching(false) } }, [ createPlaybackDownloadCheckout, downloadPaymentPending, isCheckoutLaunching, projectForDownload, setTooltip, ] ) const handleDownloadPress = async () => { if (!canDownloadPlayback) return startPlaybackDownloadCheckout() if (isDownloading || isPublishing) return try { setIsDownloading(true) const url = await processMerge() await triggerDownload(url, projectForDownload?.title) } catch (err) { setTooltip({ type: 'error', text: err.message }) } finally { setIsDownloading(false) } } useEffect(() => { if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) { return } setDownloadPaymentPending(false) handleDownloadUri() }, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) const handlePublish = async () => { if (!hasAcceptedPublication) return if (isPublishing) return try { setIsPublishing(true) const url = await processMerge() await projectsRef.doc(projectForDownload.id).set( { playbackUrl: url, updatedAt: serverTimestamp(), }, { merge: true } ) navigate(Routes.PublishYoutube, { projectId: projectForDownload.id }) } catch (err) { setTooltip({ type: 'error', text: String(err?.message || 'Erreur de publication'), }) } finally { setIsPublishing(false) } } return ( <> setIsAfterPlaybackVideoVisible(false)} /> {projectForDownload?.coverUrl ? ( ) : ( Aucune pochette )} {downloadLabel} {!canDownloadPlayback && downloadPaymentPending ? ( Paiement en attente de confirmation... ) : null} } /> setHasAcceptedPublication((prevState) => !prevState)} label="J'accepte la diffusion de mon playback sur Musicland." /> Cette confirmation est requise avant de lancer la publication. { handlePublish() }} disabled={isPublishing || !hasAcceptedPublication} loading={isPublishing} loadingText="Publication en cours..." containerStyle={styles.continueButton} /> ) } 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, }, downloadColumn: { alignItems: 'center', gap: 4, }, downloadPendingText: { marginTop: 6, fontFamily: FONT_FAMILY.InterRegular, fontSize: 12, color: Palette.white, opacity: 0.8, }, clubCardSpacing: { marginTop: 10, }, publishConsentContainer: { gap: 6, marginTop: 2, }, publishConsentDescription: { fontFamily: FONT_FAMILY.InterRegular, fontSize: 13, color: Palette.white, opacity: 0.8, }, continueButton: { marginTop: 10, }, })