feat: download options
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
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 { 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 AppCheckbox from '../../components/AppCheckbox'
|
||||
import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase'
|
||||
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
|
||||
import Page from '../../layouts/Page'
|
||||
@@ -20,15 +19,12 @@ 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
|
||||
if (!url) return false
|
||||
const baseName = (title || 'Playback').toString().trim() || 'Playback'
|
||||
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-')
|
||||
const extension = guessExtension(url) || 'mp4'
|
||||
@@ -53,6 +49,7 @@ const triggerDownload = async (url, title) => {
|
||||
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,
|
||||
@@ -67,6 +64,7 @@ const triggerDownload = async (url, title) => {
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
return true
|
||||
} catch (fallbackError) {
|
||||
console.log('[PlaybackDownload] anchor fallback failed', {
|
||||
message: fallbackError?.message,
|
||||
@@ -74,7 +72,8 @@ const triggerDownload = async (url, title) => {
|
||||
// Ultimate fallback: direct window open
|
||||
try {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
} catch { }
|
||||
return true
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -87,11 +86,13 @@ const triggerDownload = async (url, title) => {
|
||||
if (shareAsync) {
|
||||
await shareAsync(localUri)
|
||||
}
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const guessExtension = (inputUri = '') => {
|
||||
@@ -146,7 +147,12 @@ 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,
|
||||
uri,
|
||||
project: routeProject,
|
||||
syncOffsetMs: routeSyncOffsetMs = 0,
|
||||
} = route.params || {}
|
||||
const action = routeAction || 'playback'
|
||||
const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs)
|
||||
console.log('[PlaybackDownload] route params', {
|
||||
@@ -158,15 +164,13 @@ const PlaybackDownload = ({ route }) => {
|
||||
})
|
||||
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 [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
|
||||
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) {
|
||||
@@ -178,9 +182,9 @@ const PlaybackDownload = ({ route }) => {
|
||||
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 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
|
||||
@@ -302,36 +306,66 @@ const PlaybackDownload = ({ route }) => {
|
||||
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,
|
||||
action,
|
||||
currentUID,
|
||||
pendingPlaybackUrl,
|
||||
projectForDownload,
|
||||
setTooltip,
|
||||
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 ({ force = false } = {}) => {
|
||||
async (intent, { force = false } = {}) => {
|
||||
if (!projectForDownload?.id) {
|
||||
setTooltip({
|
||||
type: 'error',
|
||||
@@ -344,15 +378,17 @@ const PlaybackDownload = ({ route }) => {
|
||||
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.",
|
||||
text: error?.message || 'Une erreur est survenue lors du paiement.',
|
||||
})
|
||||
} finally {
|
||||
setIsCheckoutLaunching(false)
|
||||
@@ -367,60 +403,45 @@ const PlaybackDownload = ({ route }) => {
|
||||
]
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 || isDownloading) {
|
||||
if (
|
||||
!downloadPaymentPending ||
|
||||
!hasPaidPlaybackDownload ||
|
||||
!pendingDownloadIntent ||
|
||||
isDownloading
|
||||
) {
|
||||
return
|
||||
}
|
||||
const intent = pendingDownloadIntent
|
||||
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 }
|
||||
)
|
||||
|
||||
setTooltip({
|
||||
type: 'success',
|
||||
text: 'Playback publié sur MusicLand !',
|
||||
})
|
||||
navigate(Routes.Home)
|
||||
} catch (err) {
|
||||
setTooltip({
|
||||
type: 'error',
|
||||
text: String(err?.message || 'Erreur de publication'),
|
||||
})
|
||||
} finally {
|
||||
setIsPublishing(false)
|
||||
}
|
||||
}
|
||||
setPendingDownloadIntent(null)
|
||||
executePlaybackChoice(intent)
|
||||
}, [
|
||||
downloadPaymentPending,
|
||||
executePlaybackChoice,
|
||||
hasPaidPlaybackDownload,
|
||||
isDownloading,
|
||||
pendingDownloadIntent,
|
||||
])
|
||||
return (
|
||||
<>
|
||||
<FullscreenIntroVideo
|
||||
@@ -429,75 +450,59 @@ const PlaybackDownload = ({ route }) => {
|
||||
onClose={() => setIsAfterPlaybackVideoVisible(false)}
|
||||
/>
|
||||
<Page
|
||||
backgroundImg={
|
||||
action === 'playback' ? background.playbackBG2 : background.productionBG2
|
||||
}
|
||||
backgroundImg={action === 'playback' ? background.playbackBG2 : background.productionBG2}
|
||||
backgroundColor="#07111B"
|
||||
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>
|
||||
}
|
||||
<CreateLyricsHeader
|
||||
title="Ton playback est prêt"
|
||||
subTitle="Choisis l'utilisation de ta vidéo."
|
||||
/>
|
||||
|
||||
<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 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>
|
||||
|
||||
<BorderGradientButton
|
||||
title={'Publier'}
|
||||
onPress={() => {
|
||||
handlePublish()
|
||||
}}
|
||||
disabled={isPublishing || !hasAcceptedPublication}
|
||||
loading={isPublishing}
|
||||
loadingText="Publication en cours..."
|
||||
containerStyle={styles.continueButton}
|
||||
/>
|
||||
<View style={styles.choiceSection}>
|
||||
<Text style={styles.sectionTitle}>Téléchargement</Text>
|
||||
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
|
||||
<BorderGradientButton
|
||||
title="Option 1 · Téléchargement à 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...'
|
||||
: 'Option 2 · 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>
|
||||
</ScrollView>
|
||||
</Page>
|
||||
</>
|
||||
@@ -509,15 +514,22 @@ export default PlaybackDownload
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
maxWidth: 620,
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 14,
|
||||
},
|
||||
coverRow: {
|
||||
flexDirection: isWeb ? 'row' : 'column',
|
||||
summaryCard: {
|
||||
alignItems: 'center',
|
||||
justifyContent: isWeb ? 'center' : '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 }),
|
||||
@@ -525,23 +537,45 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.18)',
|
||||
},
|
||||
downloadTile: {
|
||||
flexDirection: 'row',
|
||||
coverPlaceholder: {
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#8C4BFF',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
downloadText: {
|
||||
placeholderText: {
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
color: Palette.grayMid,
|
||||
},
|
||||
playbackTitle: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 15,
|
||||
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,
|
||||
},
|
||||
downloadColumn: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
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,
|
||||
@@ -550,20 +584,4 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user