import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Image as ExpoImage } from 'expo-image' import { BackHandler, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, View, Alert, } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import * as FileSystem from 'expo-file-system' import * as Sharing from 'expo-sharing' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import Page from '../../layouts/Page' import { Routes } from '../../navigation/Routes' import { goBack, navigate, push } from '../../navigation/NavigationService' import { useUser } from '../../providers/UserDataProvider' import { useStripe } from '../../providers/StripeProvider' import { gutters, Palette, Style } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import { background } from '../../assets' import { getStageAction } from '../../utils/projectStages' import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard' import useGlobalLoading from '../../hooks/useGlobalLoading' import { MaterialCommunityIcons } from '@expo/vector-icons' import { isWeb } from '../../hooks/useLayoutType' import { getArtistDisplayName } from '../../utils/artistName' import { toDate } from '../../utils/dateFormatting' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import AdventureChoiceModal from './components/AdventureChoiceModal' import ShareAndCreditsModal from './components/ShareAndCreditsModal' import { musiclandShareHeading } from '../../data' const SongDownload = ({ route }) => { const { project: routeProject, selectedOption: routeSelectedOption, coverOptions: routeCoverOptions, skipAdventureGate = false, promptPurchaseConfirm = false, backRoute = null, } = route?.params || {} const { selectedProject, updateProjectData, hasActiveSubscription, hasPurchased } = useUser() const { createSongDownloadCheckout } = useStripe() const { setTooltip } = useMinuit() const { setLoading } = useGlobalLoading() const [isDownloading, setIsDownloading] = useState(false) const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const { videos } = useUser() const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1 const [showIntro, setShowIntro] = useState(null) const [showAdventureModal, setShowAdventureModal] = useState(false) const [showShareModal, setShowShareModal] = useState(false) const [adventureChoice, setAdventureChoice] = useState(() => skipAdventureGate ? 'stop' : 'pending' ) const adventureGateTriggeredRef = useRef(false) const reopenAdventureModalOnFocusRef = useRef(false) const projectForStage = useMemo(() => { if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { return selectedProject } return routeProject || selectedProject || null }, [routeProject, selectedProject]) const coverOptions = useMemo(() => { if (Array.isArray(routeCoverOptions) && routeCoverOptions.length) { return routeCoverOptions.filter(Boolean) } if (Array.isArray(projectForStage?.cover?.options)) { return projectForStage.cover.options.filter(Boolean) } return [] }, [projectForStage?.cover?.options, routeCoverOptions]) const selectedOption = useMemo(() => { if (routeSelectedOption) { return routeSelectedOption } const selectedId = projectForStage?.cover?.selectedOptionId || projectForStage?.cover?.selectedOption?.id || null if (selectedId && coverOptions.length) { const match = coverOptions.find((option) => option?.id === selectedId) if (match) return match } return coverOptions[0] || null }, [coverOptions, projectForStage?.cover, routeSelectedOption]) const coverUrl = selectedOption?.finalUrl || selectedOption?.generatedUrl || projectForStage?.coverUrl || projectForStage?.cover?.result || projectForStage?.cover?.generatedBackground || null const trackTitle = typeof projectForStage?.title === 'string' && projectForStage.title.trim() ? projectForStage.title.trim() : 'Musicland Track' const projectId = projectForStage?.id || null const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null const hasPaidDownload = downloadPurchaseStatus === 'paid' const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased const downloadLabel = canDownloadDirectly ? 'Télécharger mon morceau' : 'Télécharger ce morceau pour 1,99€' const shouldShowDownloadPage = adventureChoice === 'stop' const reopenAdventureChoice = useCallback(() => { reopenAdventureModalOnFocusRef.current = false setShowIntro(null) setAdventureChoice('pending') setShowAdventureModal(true) }, []) const handleBackPress = useCallback(() => { if (shouldShowDownloadPage) { reopenAdventureChoice() return true } if (backRoute) { navigate(backRoute) return true } goBack() return true }, [backRoute, reopenAdventureChoice, shouldShowDownloadPage]) useFocusEffect( useCallback(() => { if (Platform.OS !== 'android' || (!backRoute && !shouldShowDownloadPage)) { return undefined } const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress) return () => subscription.remove() }, [backRoute, handleBackPress, shouldShowDownloadPage]) ) useFocusEffect( useCallback(() => { if (!reopenAdventureModalOnFocusRef.current) { return undefined } setShowIntro(null) setShowAdventureModal(true) return undefined }, []) ) useEffect(() => { if (adventureGateTriggeredRef.current) { return } if (adventureChoice !== 'pending') { return } if (typeof videos === 'undefined') { return } adventureGateTriggeredRef.current = true if (part1Url) { setShowIntro(part1Url) } else { setShowAdventureModal(true) } }, [adventureChoice, part1Url, videos]) useEffect(() => { if (adventureChoice !== 'pending') { return } if (!adventureGateTriggeredRef.current) { return } if (showIntro) { return } if (!showAdventureModal) { setShowAdventureModal(true) } }, [adventureChoice, showAdventureModal, showIntro]) const continueFlow = useCallback(async () => { if (!selectedOption) { return } try { await setLoading(true, { message: 'Sauvegarde de ta pochette...' }) const existingCover = projectForStage?.cover || {} const finalUrl = selectedOption.finalUrl || selectedOption.generatedUrl || coverUrl const nextCoverData = { ...existingCover, options: coverOptions, selectedOptionId: selectedOption.id, result: finalUrl, generatedBackground: selectedOption.generatedUrl || selectedOption.finalUrl || null, } await updateProjectData({ cover: nextCoverData, coverUrl: finalUrl, }) const nextProject = { ...projectForStage, cover: nextCoverData, coverUrl: finalUrl, } const nextPlaybackStage = getStageAction('director', nextProject) const targetRoute = nextPlaybackStage?.route || Routes.Playback const params = nextPlaybackStage?.params || { project: nextProject } push(targetRoute, { ...params, returnToAdventureModal: true, }) } catch (error) { console.log('[SongDownload] continue error', error?.message) } finally { await setLoading(false) } }, [ coverOptions, coverUrl, projectForStage, push, selectedOption, setLoading, updateProjectData, ]) const handleCloseIntro = useCallback(() => { setShowIntro(null) setShowAdventureModal(true) }, []) const handleContinueAdventure = useCallback(() => { reopenAdventureModalOnFocusRef.current = true setShowAdventureModal(false) setAdventureChoice('continue') continueFlow() }, [continueFlow]) const handleDownload = useCallback(async () => { const downloadUrl = projectForStage?.songUrl || projectForStage?.playbackUrl || selectedOption?.finalUrl || selectedOption?.generatedUrl || null if (!downloadUrl || isDownloading) { return } await setLoading(true, { message: 'Préparation du téléchargement...' }) const artist = getArtistDisplayName(projectForStage, 'MusicLand') const createdDate = toDate(projectForStage?.createdAt) || new Date() const createdLabel = createdDate ? createdDate.toISOString().split('T')[0] : '' const triggerWebDownload = async (url, title) => { setIsDownloading(true) await setLoading(true, { message: 'Préparation du téléchargement...' }) try { const response = await fetch(url) if (!response.ok) { throw new Error(`download_failed_${response.status}`) } const contentType = response.headers.get('content-type') || 'audio/mpeg' const baseName = String(trackTitle || 'musicland-track') .replace(/[\\/:*?"<>|]+/g, '-') .trim() || 'musicland-track' const filename = `${baseName}.mp3` const buffer = await response.arrayBuffer() if (contentType.includes('audio')) { const toSynchSafe = (size) => { const out = new Uint8Array(4) out[0] = (size >> 21) & 0x7f out[1] = (size >> 14) & 0x7f out[2] = (size >> 7) & 0x7f out[3] = size & 0x7f return out } const concatBytes = (...arrays) => { const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0) const result = new Uint8Array(totalLength) let offset = 0 arrays.forEach((arr) => { result.set(arr, offset) offset += arr.length }) return result } const buildTextFrame = (id, value) => { const encoder = new TextEncoder() const textBytes = encoder.encode(value || '') const data = concatBytes(new Uint8Array([0x03]), textBytes) const header = concatBytes( new TextEncoder().encode(id), toSynchSafe(data.length), new Uint8Array([0x00, 0x00]) ) return concatBytes(header, data) } const buildApicFrame = (imageBytes, mime) => { if (!imageBytes) return null const encoder = new TextEncoder() const mimeBytes = encoder.encode(mime || 'image/jpeg') const data = concatBytes( new Uint8Array([0x03]), mimeBytes, new Uint8Array([0x00]), // mime terminator new Uint8Array([0x03]), // front cover new Uint8Array([0x00]), // empty description new Uint8Array(imageBytes) ) const header = concatBytes( new TextEncoder().encode('APIC'), toSynchSafe(data.length), new Uint8Array([0x00, 0x00]) ) return concatBytes(header, data) } const buildId3Tag = (audioBytes, coverBytes, coverMime) => { const frames = [] frames.push(buildTextFrame('TIT2', trackTitle)) frames.push(buildTextFrame('TPE1', artist)) frames.push(buildTextFrame('TDRC', createdLabel)) const apic = buildApicFrame(coverBytes, coverMime) if (apic) frames.push(apic) const framesData = concatBytes(...frames) const header = concatBytes( new TextEncoder().encode('ID3'), new Uint8Array([0x04, 0x00]), // version 2.4.0 new Uint8Array([0x00]), // flags toSynchSafe(framesData.length) ) return concatBytes(header, framesData, audioBytes) } const fetchCoverBytes = async () => { if (!coverUrl) return { bytes: null, mime: null } try { const res = await fetch(coverUrl) const mime = res.headers?.get('content-type') || 'image/jpeg' const bufferImage = await res.arrayBuffer() return { bytes: new Uint8Array(bufferImage), mime } } catch { return { bytes: null, mime: null } } } const { bytes: coverBytes, mime: coverMime } = await fetchCoverBytes() const merged = buildId3Tag(new Uint8Array(buffer), coverBytes, coverMime) const blob = new Blob([merged], { type: contentType }) const blobUrl = URL.createObjectURL(blob) const downloadLink = document.createElement('a') downloadLink.href = blobUrl downloadLink.download = filename document.body.appendChild(downloadLink) downloadLink.click() document.body.removeChild(downloadLink) URL.revokeObjectURL(blobUrl) } else { const blob = await response.blob() const blobUrl = URL.createObjectURL(blob) const downloadLink = document.createElement('a') downloadLink.href = blobUrl downloadLink.download = filename document.body.appendChild(downloadLink) downloadLink.click() document.body.removeChild(downloadLink) URL.revokeObjectURL(blobUrl) } } finally { setIsDownloading(false) } } if (isWeb) { await triggerWebDownload(downloadUrl, projectForStage?.title) await setLoading(false) return } setIsDownloading(true) try { const baseName = String(trackTitle || 'musicland-track') .replace(/[\\/:*?"<>|]+/g, '-') .trim() || 'musicland-track' const fileName = `${baseName}.mp3` const targetUri = `${FileSystem.cacheDirectory || ''}${fileName}` const downloadResult = await FileSystem.downloadAsync(downloadUrl, targetUri) if (!downloadResult?.uri) { return } if (Platform.OS === 'android') { const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync() if (!permissions.granted || !permissions.directoryUri) { return } const base64 = await FileSystem.readAsStringAsync(downloadResult.uri, { encoding: FileSystem.EncodingType.Base64, }) try { const destUri = await FileSystem.StorageAccessFramework.createFileAsync( permissions.directoryUri, fileName, 'audio/mpeg' ) await FileSystem.writeAsStringAsync(destUri, base64, { encoding: FileSystem.EncodingType.Base64, }) } catch (error) { console.log('[SongDownload] SAF write error', error?.message) } } else { if (await Sharing.isAvailableAsync()) { try { await Sharing.shareAsync(downloadResult.uri, { mimeType: 'audio/mpeg', dialogTitle: musiclandShareHeading, }) } catch (error) { console.log('[SongDownload] share error', error?.message) } } else { try { await Linking.openURL(downloadResult.uri) } catch (error) { console.log('[SongDownload] open local file error', error?.message) } } } } catch (error) { console.log('[SongDownload] native download open error', error?.message) } finally { setIsDownloading(false) await setLoading(false) } }, [coverUrl, isDownloading, projectForStage, selectedOption, trackTitle]) const startSongDownloadCheckout = useCallback( async ({ force = false } = {}) => { if (!projectId) { if (setTooltip) { setTooltip({ type: 'error', text: "Impossible d'identifier le projet pour le paiement.", }) } else { Alert.alert('Paiement', "Impossible d'identifier le projet pour le paiement.") } return } if (!force && (isCheckoutLaunching || downloadPaymentPending)) { return } setIsCheckoutLaunching(true) setDownloadPaymentPending(true) try { await createSongDownloadCheckout(projectId) } catch (error) { setDownloadPaymentPending(false) if (setTooltip) { setTooltip({ type: 'error', text: error?.message || "Une erreur est survenue lors du paiement.", }) } else { Alert.alert('Paiement', error?.message || "Une erreur est survenue lors du paiement.") } } finally { setIsCheckoutLaunching(false) } }, [ createSongDownloadCheckout, downloadPaymentPending, isCheckoutLaunching, projectId, setTooltip, ] ) const handleDownloadPress = useCallback(() => { if (isDownloading || isCheckoutLaunching) { return } if (canDownloadDirectly) { handleDownload() return } if (downloadPaymentPending) { Alert.alert( 'Paiement en attente', "Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.", [ { text: 'Attendre', style: 'cancel' }, { text: 'Relancer le paiement', onPress: () => startSongDownloadCheckout({ force: true }) }, ] ) return } if (promptPurchaseConfirm) { Alert.alert( 'Télécharger ce morceau', 'Voulez-vous télécharger ce morceau pour 1,99€ ?', [ { text: 'Annuler', style: 'cancel' }, { text: 'Acheter', onPress: () => startSongDownloadCheckout() }, ], { cancelable: true } ) return } startSongDownloadCheckout() }, [ canDownloadDirectly, downloadPaymentPending, handleDownload, isCheckoutLaunching, isDownloading, promptPurchaseConfirm, startSongDownloadCheckout, ]) useEffect(() => { if (!downloadPaymentPending || !hasPaidDownload || isDownloading) { return } setDownloadPaymentPending(false) handleDownload() }, [downloadPaymentPending, handleDownload, hasPaidDownload, isDownloading]) const handleStopAdventure = useCallback(() => { reopenAdventureModalOnFocusRef.current = false setShowAdventureModal(false) setAdventureChoice('stop') }, []) const handleGoHome = useCallback(() => { navigate(Routes.BottomTab, { screen: Routes.HomeStack, params: { screen: Routes.Home, }, }) }, []) return ( {shouldShowDownloadPage ? ( {coverUrl ? ( ) : ( Aucune pochette )} {downloadLabel} {!canDownloadDirectly && downloadPaymentPending ? ( Paiement en attente de confirmation... ) : null} } /> ) : ( )} { setShowShareModal(false) navigate(Routes.DownloadPrices, { action: 'song' }) }} project={projectForStage} selectedOption={selectedOption} /> ) } const styles = StyleSheet.create({ container: { flexGrow: 1, width: '100%', paddingHorizontal: gutters * 1.2, paddingBottom: gutters * 1.8, paddingTop: gutters, gap: gutters * 1.2, }, containerMobile: { paddingHorizontal: 0, paddingBottom: gutters * 1.2, paddingTop: gutters * 0.8, }, scrollContent: { paddingBottom: gutters * 2.6, }, coverRow: { flexDirection: isWeb ? 'row' : 'column', alignItems: 'center', justifyContent: 'center', gap: gutters * 0.8, }, coverImage: { width: 150, height: 150, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.18)', }, coverPlaceholder: { ...Style.centered, backgroundColor: 'rgba(255, 255, 255, 0.06)', }, placeholderText: { fontFamily: FONT_FAMILY.InterMedium, color: Palette.grayMid, }, downloadTile: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: gutters * 0.9, paddingHorizontal: gutters * 1.2, borderRadius: 14, backgroundColor: '#8C4BFF', borderWidth: 0, }, downloadColumn: { alignItems: 'center', gap: 4, }, downloadText: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 15, color: Palette.white, }, downloadPendingText: { marginTop: 6, fontFamily: FONT_FAMILY.InterRegular, fontSize: 12, color: Palette.white, opacity: 0.8, }, adventureGatePlaceholder: { flex: 1, }, clubCardSpacing: { marginTop: gutters * 0.5, }, returnHomeButton: { width: '100%', maxWidth: 320, alignSelf: 'center', marginTop: gutters * 0.6, }, }) export default SongDownload