This commit is contained in:
Thomas Demirdjian
2026-08-04 10:59:27 +02:00
parent 761f3cc200
commit c8656d6273
25 changed files with 1743 additions and 75 deletions
+68 -11
View File
@@ -1,5 +1,6 @@
import React, { useCallback } from 'react'
import { StyleSheet, View } from 'react-native'
import React, { useCallback, useMemo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { useIsFocused } from '@react-navigation/native'
import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton'
@@ -9,13 +10,31 @@ import Page from '../../layouts/Page'
import { isWeb } from '../../hooks/useLayoutType'
import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService'
import { useUser } from '../../providers/UserDataProvider'
import { gutters } from '../../styles'
import { useUser, useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles'
import {
getPlaybackAiStatusLabel,
isPlaybackAiBlockingManual,
shouldResumePlaybackAi,
} from '../../utils/playbackAi'
const Playback = ({ route, navigation }) => {
const { project, returnToAdventureModal = false } = route.params || {}
const isFocused = useIsFocused()
const { project: routeProject, returnToAdventureModal = false } = route.params || {}
const { videos } = useUser()
const { selectedProject } = useUserData() || {}
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo
const project = useMemo(() => {
if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject || null
}, [routeProject, selectedProject])
const aiStatus = project?.playbackStatus || null
const isManualBlocked = isPlaybackAiBlockingManual(project)
const aiButtonLabel = shouldResumePlaybackAi(project)
? 'Voir mon playback IA'
: 'Créer mon playback IA'
const handleBackPress = useCallback(() => {
if (returnToAdventureModal) {
@@ -26,18 +45,29 @@ const Playback = ({ route, navigation }) => {
}, [navigation, returnToAdventureModal])
const onPressRecord = useCallback(() => {
if (isManualBlocked) return
navigate(Routes.RecordPlayback, { project, returnToAdventureModal })
}, [project, returnToAdventureModal])
}, [isManualBlocked, project, returnToAdventureModal])
const onPressPlaybackAi = useCallback(() => {
if (shouldResumePlaybackAi(project)) {
navigate(Routes.PlaybackAiStatus, { project })
return
}
navigate(Routes.PlaybackAi, { project })
}, [project])
return (
<Page
headerType="NONE"
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
backgroundContent={
<BackgroundVideo
source={theoUrl}
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
/>
isFocused ? (
<BackgroundVideo
source={theoUrl}
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
/>
) : null
}
containerStyle={isWeb ? styles.videoPage : undefined}
>
@@ -51,7 +81,21 @@ const Playback = ({ route, navigation }) => {
}}
>
<View style={{ width: '80%', alignSelf: 'center', gap: 12 }}>
<GradientButton title="Enregistrer mon Playback" onPress={onPressRecord} />
<GradientButton
title="Enregistrer mon Playback"
onPress={onPressRecord}
disabled={!project || isManualBlocked}
/>
<BorderGradientButton title={aiButtonLabel} onPress={onPressPlaybackAi} disabled={!project} />
{isManualBlocked ? (
<Text style={styles.blockedText}>
Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de
validation.
</Text>
) : null}
{shouldResumePlaybackAi(project) ? (
<Text style={styles.statusText}>{getPlaybackAiStatusLabel(aiStatus)}</Text>
) : null}
<BorderGradientButton
title="Guide du Playbacker"
onPress={() => navigate(Routes.PlaybackGuide, { returnToAdventureModal })}
@@ -74,4 +118,17 @@ const styles = StyleSheet.create({
right: 24,
left: 'auto',
},
blockedText: {
color: Palette.white,
opacity: 0.75,
fontSize: 12,
lineHeight: 18,
textAlign: 'center',
},
statusText: {
color: Palette.white,
opacity: 0.55,
fontSize: 12,
textAlign: 'center',
},
})
+245
View File
@@ -0,0 +1,245 @@
import * as ImagePicker from 'expo-image-picker'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import React, { useMemo, useState } from 'react'
import { Image, Text, View } from 'react-native'
import { background } from '../../assets'
import ActivityLoader from '../../components/ActivityLoader'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import { getFunctionsClient } 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 } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const FUNCTIONS_REGION = 'europe-west1'
const trimString = (value) => (typeof value === 'string' ? value.trim() : '')
const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value))
const resolveProject = ({ routeProject, selectedProject }) => {
if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject || null
}
const getInitialPhotoUri = ({ routePhotoUrl, project }) =>
trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null
const callStartPlaybackGeneration = (payload) => {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration')
return callable(payload)
}
const PlaybackAi = ({ route }) => {
const { project: routeProject, initialPhotoUrl = null } = route.params || {}
const { selectedProject, currentUID } = useUserData() || {}
const { setTooltip } = useMinuit()
const [selectedPhotoUri, setSelectedPhotoUri] = useState(() =>
getInitialPhotoUri({
routePhotoUrl: initialPhotoUrl,
project: resolveProject({ routeProject, selectedProject }),
})
)
const [isSubmitting, setIsSubmitting] = useState(false)
const project = useMemo(
() => resolveProject({ routeProject, selectedProject }),
[routeProject, selectedProject]
)
const handleSelectPhoto = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [9, 16],
quality: 1,
})
const nextUri = result?.assets?.[0]?.uri || null
if (nextUri) {
setSelectedPhotoUri(nextUri)
}
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible de sélectionner une photo',
})
}
}
const handleGenerate = async () => {
try {
if (!project?.id) {
throw new Error('Projet introuvable')
}
if (!currentUID) {
throw new Error('Utilisateur non authentifié')
}
if (!selectedPhotoUri) {
throw new Error('Choisis une photo avant de lancer la génération')
}
setIsSubmitting(true)
let photoUrl = trimString(selectedPhotoUri)
if (!isRemoteUrl(photoUrl)) {
const extension = trimString(selectedPhotoUri.split('.').pop()) || 'jpg'
const uploadResult = await uploadFileToFirebase({
uri: selectedPhotoUri,
path: `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`,
shouldCompress: true,
fileType: 'IMAGE',
})
photoUrl = trimString(uploadResult?.resultURI)
}
if (!photoUrl) {
throw new Error("Impossible d'envoyer la photo vers Firebase")
}
await callStartPlaybackGeneration({
projectId: project.id,
photoUrl,
})
navigate(Routes.PlaybackAiStatus, {
project: {
...project,
playbackStatus: PLAYBACK_AI_STATUS.GENERATING,
playbackGenerating: true,
playbackPhotoUrl: photoUrl,
},
})
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Erreur lors du lancement du playback IA',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Page
backgroundImg={background.playbackBG2}
backgroundColor="#07111B"
headerType="NONE"
>
<MusicLandHeader progress={31} onPressBack={goBack} />
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
<CreateLyricsHeader title="Créer ton playback IA" />
<View style={styles.content}>
<View style={styles.summaryCard}>
<Text style={styles.summaryTitle}>{project?.title || 'Sans titre'}</Text>
<Text style={styles.summaryText}>
Ajoute une photo portrait de toi. HeyGen générera ensuite une vidéo verticale 9:16
tu chantes la musique de ton projet.
</Text>
</View>
<View style={styles.previewCard}>
{selectedPhotoUri ? (
<Image source={{ uri: selectedPhotoUri }} style={styles.photoPreview} resizeMode="cover" />
) : (
<View style={[styles.photoPreview, styles.photoPlaceholder]}>
<Text style={styles.photoPlaceholderText}>Aucune photo sélectionnée</Text>
</View>
)}
</View>
<View style={styles.actions}>
<BorderGradientButton
title={selectedPhotoUri ? 'Changer de photo' : 'Choisir une photo'}
onPress={handleSelectPhoto}
disabled={isSubmitting}
/>
<GradientButton
title="Générer ma vidéo"
onPress={handleGenerate}
disabled={isSubmitting || !selectedPhotoUri}
/>
{isSubmitting ? (
<ActivityLoader
defaultMessage="Envoi de la photo et lancement de HeyGen..."
containerStyle={styles.loader}
/>
) : null}
</View>
</View>
</View>
</Page>
)
}
const styles = {
content: {
flex: 1,
width: '90%',
alignSelf: 'center',
gap: 16,
},
summaryCard: {
padding: 18,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.76)',
gap: 8,
},
summaryTitle: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
summaryText: {
color: Palette.white,
opacity: 0.78,
lineHeight: 20,
fontFamily: FONT_FAMILY.InterRegular,
},
previewCard: {
flex: 1,
minHeight: 320,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.8)',
padding: 12,
},
photoPreview: {
width: '100%',
height: '100%',
borderRadius: 18,
backgroundColor: '#141414',
},
photoPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
},
photoPlaceholderText: {
color: Palette.white,
opacity: 0.5,
fontFamily: FONT_FAMILY.InterRegular,
},
actions: {
gap: 12,
},
loader: {
marginTop: 4,
},
}
export default PlaybackAi
+279
View File
@@ -0,0 +1,279 @@
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import { VideoView, useVideoPlayer } from 'expo-video'
import React, { useMemo, useState } from 'react'
import { Image, Text, View } from 'react-native'
import { background } from '../../assets'
import useDataFromRef from '../../hooks/useDataFromRef'
import ActivityLoader from '../../components/ActivityLoader'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import { getFunctionsClient, projectsRef } from '../../config/firebase'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation'
import { goBack, navigate, push } from '../../navigation/NavigationService'
import { useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi'
const FUNCTIONS_REGION = 'europe-west1'
const resolveProject = ({ routeProject, selectedProject, liveProject }) => {
if (liveProject?.id) return liveProject
if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject || null
}
const callValidatePlaybackDraft = (payload) => {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-validatePlaybackDraft')
return callable(payload)
}
const PlaybackAiStatus = ({ route }) => {
const { project: routeProject } = route.params || {}
const { selectedProject } = useUserData() || {}
const { setTooltip } = useMinuit()
const [isValidating, setIsValidating] = useState(false)
const projectId = routeProject?.id || selectedProject?.id || null
const { data: liveProject = null } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
refreshArray: [projectId],
})
const project = useMemo(
() => resolveProject({ routeProject, selectedProject, liveProject }),
[liveProject, routeProject, selectedProject]
)
const status =
project?.playbackStatus ||
(routeProject?.playbackGenerating ? PLAYBACK_AI_STATUS.GENERATING : null)
const draftUrl = project?.playbackDraftUrl || routeProject?.playbackDraftUrl || null
const player = useVideoPlayer(draftUrl ? { uri: draftUrl } : null, (instance) => {
instance.loop = false
instance.muted = false
})
const handleValidate = async () => {
try {
if (!project?.id) {
throw new Error('Projet introuvable')
}
setIsValidating(true)
await callValidatePlaybackDraft({ projectId: project.id })
const latestProjectSnap = await projectsRef.doc(project.id).get()
const latestProject = latestProjectSnap.exists
? { ...latestProjectSnap.data(), id: latestProjectSnap.id }
: project
navigate(Routes.PlaybackDownload, {
action: 'playback',
project: latestProject,
})
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible de valider le playback IA',
})
} finally {
setIsValidating(false)
}
}
const handleRetry = () => {
push(Routes.PlaybackAi, {
project,
initialPhotoUrl: project?.playbackPhotoUrl || null,
})
}
return (
<Page
backgroundImg={background.playbackBG2}
backgroundColor="#07111B"
headerType="NONE"
>
<MusicLandHeader progress={38} onPressBack={goBack} />
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
<CreateLyricsHeader title="Playback IA" />
<View style={styles.content}>
<View style={styles.headerCard}>
{project?.coverUrl ? (
<Image source={{ uri: project.coverUrl }} style={styles.cover} resizeMode="cover" />
) : null}
<View style={{ flex: 1, gap: 4 }}>
<Text style={styles.title}>{project?.title || 'Sans titre'}</Text>
<Text style={styles.subtitle}>Format vertical 9:16 généré avec HeyGen</Text>
</View>
</View>
{status === PLAYBACK_AI_STATUS.GENERATING ? (
<View style={styles.stateCard}>
<ActivityLoader defaultMessage="HeyGen génère ton playback IA..." />
<Text style={styles.helperText}>
Tu peux fermer cet écran et revenir plus tard. Une notification te sera envoyée dès
que ta vidéo sera prête.
</Text>
</View>
) : null}
{status === PLAYBACK_AI_STATUS.FAILED ? (
<View style={styles.stateCard}>
<Text style={styles.errorTitle}>La génération a échoué</Text>
<Text style={styles.helperText}>
{project?.playbackError || 'HeyGen na pas réussi à générer la vidéo.'}
</Text>
<BorderGradientButton title="Réessayer" onPress={handleRetry} />
</View>
) : null}
{status === PLAYBACK_AI_STATUS.DRAFT_READY ? (
<>
<View style={styles.videoCard}>
{draftUrl ? (
<VideoView
player={player}
nativeControls
contentFit="contain"
style={styles.video}
/>
) : (
<View style={[styles.video, styles.emptyVideo]}>
<Text style={styles.helperText}>La vidéo HeyGen est introuvable.</Text>
</View>
)}
</View>
<View style={styles.actions}>
<GradientButton
title="Valider ce playback"
onPress={handleValidate}
disabled={isValidating || !draftUrl}
/>
<BorderGradientButton
title="Recommencer"
onPress={handleRetry}
disabled={isValidating}
/>
{isValidating ? (
<ActivityLoader defaultMessage="Validation du playback IA..." />
) : null}
</View>
</>
) : null}
{status === PLAYBACK_AI_STATUS.READY && project?.playbackUrl ? (
<View style={styles.stateCard}>
<Text style={styles.successTitle}>Le playback IA est déjà validé</Text>
<GradientButton
title="Continuer"
onPress={() =>
navigate(Routes.PlaybackDownload, {
action: 'playback',
project,
})
}
/>
</View>
) : null}
</View>
</View>
</Page>
)
}
const styles = {
content: {
flex: 1,
width: '92%',
alignSelf: 'center',
gap: 16,
},
headerCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 16,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.76)',
},
cover: {
width: 68,
height: 68,
borderRadius: 14,
},
title: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
subtitle: {
color: Palette.white,
opacity: 0.7,
fontFamily: FONT_FAMILY.InterRegular,
},
stateCard: {
flex: 1,
minHeight: 240,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.8)',
alignItems: 'center',
justifyContent: 'center',
gap: 14,
paddingHorizontal: 20,
},
videoCard: {
width: '60%',
maxWidth: 360,
aspectRatio: 9 / 16,
alignSelf: 'center',
borderRadius: 22,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.8)',
},
video: {
width: '100%',
height: '100%',
backgroundColor: '#0A0A0A',
},
emptyVideo: {
alignItems: 'center',
justifyContent: 'center',
},
helperText: {
color: Palette.white,
opacity: 0.72,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
textAlign: 'center',
},
errorTitle: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
successTitle: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
actions: {
gap: 12,
},
}
export default PlaybackAiStatus
@@ -0,0 +1,283 @@
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import React, { useMemo, useState } from 'react'
import { Image, Text, View } from 'react-native'
import { background } from '../../assets'
import useDataFromRef from '../../hooks/useDataFromRef'
import ActivityLoader from '../../components/ActivityLoader'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import { getFunctionsClient, projectsRef } from '../../config/firebase'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation'
import { goBack, navigate, push } from '../../navigation/NavigationService'
import { useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi'
const FUNCTIONS_REGION = 'europe-west1'
const resolveProject = ({ routeProject, selectedProject, liveProject }) => {
if (liveProject?.id) return liveProject
if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject || null
}
const callValidatePlaybackDraft = (payload) => {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-validatePlaybackDraft')
return callable(payload)
}
const PlaybackAiStatus = ({ route }) => {
const { project: routeProject } = route.params || {}
const { selectedProject } = useUserData() || {}
const { setTooltip } = useMinuit()
const [isValidating, setIsValidating] = useState(false)
const projectId = routeProject?.id || selectedProject?.id || null
const { data: liveProject = null } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
refreshArray: [projectId],
})
const project = useMemo(
() => resolveProject({ routeProject, selectedProject, liveProject }),
[liveProject, routeProject, selectedProject]
)
const status =
project?.playbackStatus ||
(routeProject?.playbackGenerating ? PLAYBACK_AI_STATUS.GENERATING : null)
const draftUrl = project?.playbackDraftUrl || routeProject?.playbackDraftUrl || null
const handleValidate = async () => {
try {
if (!project?.id) {
throw new Error('Projet introuvable')
}
setIsValidating(true)
await callValidatePlaybackDraft({ projectId: project.id })
const latestProjectSnap = await projectsRef.doc(project.id).get()
const latestProject = latestProjectSnap.exists
? { ...latestProjectSnap.data(), id: latestProjectSnap.id }
: project
navigate(Routes.PlaybackDownload, {
action: 'playback',
project: latestProject,
})
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible de valider le playback IA',
})
} finally {
setIsValidating(false)
}
}
const handleRetry = () => {
push(Routes.PlaybackAi, {
project,
initialPhotoUrl: project?.playbackPhotoUrl || null,
})
}
return (
<Page
backgroundImg={background.playbackBG2}
backgroundColor="#07111B"
headerType="NONE"
>
<MusicLandHeader progress={38} onPressBack={goBack} />
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
<CreateLyricsHeader title="Playback IA" />
<View style={styles.content}>
<View style={styles.headerCard}>
{project?.coverUrl ? (
<Image source={{ uri: project.coverUrl }} style={styles.cover} resizeMode="cover" />
) : null}
<View style={{ flex: 1, gap: 4 }}>
<Text style={styles.title}>{project?.title || 'Sans titre'}</Text>
<Text style={styles.subtitle}>Format vertical 9:16 généré avec HeyGen</Text>
</View>
</View>
{status === PLAYBACK_AI_STATUS.GENERATING ? (
<View style={styles.stateCard}>
<ActivityLoader defaultMessage="HeyGen génère ton playback IA..." />
<Text style={styles.helperText}>
Tu peux fermer cet écran et revenir plus tard. Une notification te sera envoyée dès
que ta vidéo sera prête.
</Text>
</View>
) : null}
{status === PLAYBACK_AI_STATUS.FAILED ? (
<View style={styles.stateCard}>
<Text style={styles.errorTitle}>La génération a échoué</Text>
<Text style={styles.helperText}>
{project?.playbackError || 'HeyGen na pas réussi à générer la vidéo.'}
</Text>
<BorderGradientButton title="Réessayer" onPress={handleRetry} />
</View>
) : null}
{status === PLAYBACK_AI_STATUS.DRAFT_READY ? (
<>
<View style={styles.videoCard}>
{draftUrl ? (
<video
src={draftUrl}
controls
playsInline
style={styles.video}
/>
) : (
<View style={[styles.videoFallback, styles.emptyVideo]}>
<Text style={styles.helperText}>La vidéo HeyGen est introuvable.</Text>
</View>
)}
</View>
<View style={styles.actions}>
<GradientButton
title="Valider ce playback"
onPress={handleValidate}
disabled={isValidating || !draftUrl}
/>
<BorderGradientButton
title="Recommencer"
onPress={handleRetry}
disabled={isValidating}
/>
{isValidating ? (
<ActivityLoader defaultMessage="Validation du playback IA..." />
) : null}
</View>
</>
) : null}
{status === PLAYBACK_AI_STATUS.READY && project?.playbackUrl ? (
<View style={styles.stateCard}>
<Text style={styles.successTitle}>Le playback IA est déjà validé</Text>
<GradientButton
title="Continuer"
onPress={() =>
navigate(Routes.PlaybackDownload, {
action: 'playback',
project,
})
}
/>
</View>
) : null}
</View>
</View>
</Page>
)
}
const styles = {
content: {
flex: 1,
width: '92%',
alignSelf: 'center',
gap: 16,
},
headerCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 16,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.76)',
},
cover: {
width: 68,
height: 68,
borderRadius: 14,
},
title: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
subtitle: {
color: Palette.white,
opacity: 0.7,
fontFamily: FONT_FAMILY.InterRegular,
},
stateCard: {
flex: 1,
minHeight: 240,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.8)',
alignItems: 'center',
justifyContent: 'center',
gap: 14,
paddingHorizontal: 20,
},
videoCard: {
width: '60%',
maxWidth: 360,
aspectRatio: 9 / 16,
alignSelf: 'center',
borderRadius: 22,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.14)',
backgroundColor: 'rgba(5,12,24,0.8)',
padding: 12,
},
video: {
width: '100%',
height: '100%',
borderRadius: 18,
backgroundColor: '#0A0A0A',
objectFit: 'contain',
},
videoFallback: {
width: '100%',
height: '100%',
borderRadius: 18,
backgroundColor: '#0A0A0A',
},
emptyVideo: {
alignItems: 'center',
justifyContent: 'center',
},
helperText: {
color: Palette.white,
opacity: 0.72,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
textAlign: 'center',
},
errorTitle: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
successTitle: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterSemiBold,
},
actions: {
gap: 12,
},
}
export default PlaybackAiStatus
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { View, StyleSheet } from 'react-native'
import { Pressable, View, StyleSheet } from 'react-native'
import { responsiveHeight } from 'react-native-responsive-dimensions'
import { SheetManager } from 'react-native-actions-sheet'
import { useUser } from '../../../providers/UserDataProvider'
@@ -53,6 +53,18 @@ const PlaybackItem = ({ item, isActive, userCache }) => {
isActive,
})
const togglePlayback = useCallback(() => {
if (!isActive || !videoUrl || !videoPlayer) return
try {
if (videoPlayer.playing) {
videoPlayer.pause()
} else {
videoPlayer.play()
}
} catch (e) {}
}, [isActive, videoPlayer, videoUrl])
const alignedWords = useMemo(() => {
const idx = Number(item?.songIndex) || 0
const ts = item?.musicTimestamps?.[idx]
@@ -124,7 +136,11 @@ const PlaybackItem = ({ item, isActive, userCache }) => {
}, [item?.userId])
return (
<View style={styles.container}>
<Pressable
accessible={false}
onPress={togglePlayback}
style={styles.container}
>
<PlaybackVideo videoUrl={videoUrl} videoPlayer={videoPlayer} />
<View style={styles.overlay}>
<PlaybackActions
@@ -148,7 +164,7 @@ const PlaybackItem = ({ item, isActive, userCache }) => {
fallbackTitle={item?.title}
/>
</View>
</View>
</Pressable>
)
}
+5 -6
View File
@@ -4,6 +4,7 @@ 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 { background } from '../../assets'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import BorderGradientButton from '../../components/BorderGradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
@@ -424,12 +425,10 @@ const PlaybackDownload = ({ route }) => {
onClose={() => setIsAfterPlaybackVideoVisible(false)}
/>
<Page
// backgroundImg={
// action === "playback"
// ? background.playbackBG2
// : background.productionBG2
// }
backgroundColor={Palette.grayMid}
backgroundImg={
action === 'playback' ? background.playbackBG2 : background.productionBG2
}
backgroundColor="#07111B"
headerType="NONE"
>
<MusicLandHeader onPressBack={goBack} progress={50} />
+5 -2
View File
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState } from 'react'
import { useRoute } from '@react-navigation/native'
import { useIsFocused, useRoute } from '@react-navigation/native'
import { Dimensions, Modal, Text, View } from 'react-native'
import SwiperFlatList from 'react-native-swiper-flatlist'
import { background, icons } from '../../assets'
@@ -37,6 +37,7 @@ const INSTRUMENT_STEP_INDEX = FIRST_VOICE_STEP_INDEX + VOICE_SECTION_TITLES.leng
const RHYTHM_STEP_INDEX = LAST_STEP_INDEX
const ComposeSong = () => {
const isFocused = useIsFocused()
const scrollRef = useRef(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [progress, setProgress] = useState(18)
@@ -295,7 +296,9 @@ const ComposeSong = () => {
return (
<Page
backgroundImg={background.studioBG2}
backgroundContent={<BackgroundVideo source={videos?.malik} />}
backgroundContent={
isFocused ? <BackgroundVideo source={videos?.malik} /> : null
}
headerType="NONE"
>
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
+8 -5
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRoute } from '@react-navigation/native'
import { useIsFocused, useRoute } from '@react-navigation/native'
import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
@@ -34,6 +34,7 @@ const PAGE_MAX_WIDTH = 1200
const PAGE_HORIZONTAL_PADDING = gutters * 2
const ComposeSong = () => {
const isFocused = useIsFocused()
const scrollRef = useRef(null)
const { width: windowWidth } = useWindowDimensions()
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -298,10 +299,12 @@ const ComposeSong = () => {
<Page
backgroundImg={background.studioBG2}
backgroundContent={
<BackgroundVideo
source={videos?.malikWeb}
soundButtonStyle={styles.rightSideControl}
/>
isFocused ? (
<BackgroundVideo
source={videos?.malikWeb}
soundButtonStyle={styles.rightSideControl}
/>
) : null
}
coinBadgeContainerStyle={styles.rightSideCredits}
containerStyle={styles.videoQuestionPage}