Files
musicland/src/screens/cover/SongDownload.js
T
2026-03-11 11:54:10 +01:00

724 lines
23 KiB
JavaScript

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 (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={handleBackPress} progress={84} />
{shouldShowDownloadPage ? (
<ScrollView
contentContainerStyle={[
styles.container,
!isWeb && styles.containerMobile,
styles.scrollContent,
]}
showsVerticalScrollIndicator={false}
>
<CreateLyricsHeader title="Ta pochette est validée" />
<ClubAdvantagesCard
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}>
{coverUrl ? (
<ExpoImage source={{ uri: 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,
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
]}
onPress={handleDownloadPress}
disabled={isDownloading || isCheckoutLaunching}
>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
<Text style={styles.downloadText}>{downloadLabel}</Text>
</Pressable>
{!canDownloadDirectly && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>
Paiement en attente de confirmation...
</Text>
) : null}
</View>
</View>
}
/>
<GradientButton
title="Revenir à l'accueil"
onPress={handleGoHome}
containerStyle={styles.returnHomeButton}
/>
</ScrollView>
) : (
<View style={styles.adventureGatePlaceholder} />
)}
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
<AdventureChoiceModal
isVisible={showAdventureModal}
setIsVisible={setShowAdventureModal}
onContinue={handleContinueAdventure}
onStop={handleStopAdventure}
/>
<ShareAndCreditsModal
isVisible={showShareModal}
setIsVisible={setShowShareModal}
onNavigateToCredits={() => {
setShowShareModal(false)
navigate(Routes.DownloadPrices, { action: 'song' })
}}
project={projectForStage}
selectedOption={selectedOption}
/>
</Page>
)
}
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