Files
musicland/src/screens/Production/PlaybackDownload.js
T
leon b176729599 Merge remote-tracking branch 'origin/jacques'
# Conflicts:
#	functions/helpers/gemini.js
#	functions/src/heygen.js
#	functions/src/lyrics.js
#	functions/src/music.js
#	src/screens/CreatePassword.js
#	src/screens/Home/Home.js
#	src/screens/Playback/PlaybackAi.js
#	src/screens/Production/PlaybackDownload.js
2026-09-08 14:37:47 +02:00

609 lines
19 KiB
JavaScript

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 (
<>
<FullscreenIntroVideo
visible={isAfterPlaybackVideoVisible}
url={afterPlaybackUrl}
onClose={() => setIsAfterPlaybackVideoVisible(false)}
/>
<Page
backgroundImg={action === 'playback' ? background.playbackBG2 : background.productionBG2}
backgroundColor="#07111B"
headerType="NONE"
>
<MusicLandHeader onPressBack={handleGoHome} progress={50} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader
title="Ton playback est prêt"
subTitle="Choisis l'utilisation de ta vidéo."
/>
<View style={styles.summaryCard}>
{projectForDownload?.coverUrl ? (
<ExpoImage
source={{ uri: projectForDownload.coverUrl }}
style={styles.coverImage}
contentFit="cover"
/>
) : (
<View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<Text style={styles.playbackTitle}>{projectForDownload?.title || 'Mon playback'}</Text>
</View>
<View style={styles.choiceSection}>
<Text style={styles.sectionTitle}>Téléchargement</Text>
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
<BorderGradientButton
title="Télécharger à des fins personnelles"
onPress={() => handlePlaybackChoice('personal')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
loading={isDownloading && !isPublishing}
loadingText="Téléchargement en cours..."
height={58}
titleStyle={styles.choiceButtonText}
/>
<GradientButton
title={
isPublishing
? 'Téléchargement et publication en cours...'
: 'Télécharger et publier sur MusicLand'
}
onPress={() => handlePlaybackChoice('publish')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
height={58}
textStyle={styles.choiceButtonText}
/>
{!canDownloadPlayback && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
) : null}
</View>
<BorderGradientButton
title="Retour à l'accueil"
onPress={handleGoHome}
disabled={isPublishing || isDownloading}
containerStyle={styles.homeButton}
/>
</ScrollView>
</Page>
</>
)
}
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,
},
})