567 lines
18 KiB
JavaScript
567 lines
18 KiB
JavaScript
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'
|
|
import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync'
|
|
|
|
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, 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 [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`,
|
|
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 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 (
|
|
<>
|
|
<FullscreenIntroVideo
|
|
visible={isAfterPlaybackVideoVisible}
|
|
url={afterPlaybackUrl}
|
|
onClose={() => setIsAfterPlaybackVideoVisible(false)}
|
|
/>
|
|
<Page
|
|
// backgroundImg={
|
|
// action === "playback"
|
|
// ? background.playbackBG2
|
|
// : background.productionBG2
|
|
// }
|
|
backgroundColor={Palette.grayMid}
|
|
headerType="NONE"
|
|
>
|
|
<MusicLandHeader onPressBack={goBack} progress={50} />
|
|
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
|
<CreateLyricsHeader title={'Publier ton playback'} />
|
|
|
|
<ClubAdvantagesCard
|
|
style={styles.clubCardSpacing}
|
|
topContent={
|
|
<View style={styles.coverRow}>
|
|
{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>
|
|
)}
|
|
|
|
<View style={styles.downloadColumn}>
|
|
<Pressable
|
|
style={[
|
|
styles.downloadTile,
|
|
(isPublishing || isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
|
|
]}
|
|
onPress={handleDownloadPress}
|
|
disabled={isPublishing || isDownloading || isCheckoutLaunching}
|
|
>
|
|
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
|
|
<Text style={styles.downloadText}>{downloadLabel}</Text>
|
|
</Pressable>
|
|
{!canDownloadPlayback && downloadPaymentPending ? (
|
|
<Text style={styles.downloadPendingText}>
|
|
Paiement en attente de confirmation...
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
}
|
|
/>
|
|
|
|
<View style={styles.publishConsentContainer}>
|
|
<AppCheckbox
|
|
selected={hasAcceptedPublication}
|
|
onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
|
|
label="J'accepte la diffusion de mon playback sur Musicland."
|
|
/>
|
|
<Text style={styles.publishConsentDescription}>
|
|
Cette confirmation est requise avant de lancer la publication.
|
|
</Text>
|
|
</View>
|
|
|
|
<BorderGradientButton
|
|
title={'Publier'}
|
|
onPress={() => {
|
|
handlePublish()
|
|
}}
|
|
disabled={isPublishing || !hasAcceptedPublication}
|
|
loading={isPublishing}
|
|
loadingText="Publication en cours..."
|
|
containerStyle={styles.continueButton}
|
|
/>
|
|
</ScrollView>
|
|
</Page>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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,
|
|
},
|
|
})
|