701 lines
20 KiB
JavaScript
701 lines
20 KiB
JavaScript
/* global setInterval, clearInterval */
|
|
import { useFocusEffect } from '@react-navigation/native'
|
|
import { BlurView } from 'expo-blur'
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
import {
|
|
FlatList,
|
|
Image,
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
Text,
|
|
View,
|
|
useWindowDimensions,
|
|
} from 'react-native'
|
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
|
import { background, icons } from '../../assets'
|
|
import alert from '../../components/Alert'
|
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
|
import CreditAmount from '../../components/CreditAmount'
|
|
import GradientButton from '../../components/GradientButton'
|
|
import ValidateModal from '../../components/modal/ValidateModal'
|
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
|
import ProgressSlider from '../../components/player/ProgressSlider'
|
|
import { isDesktopWeb, isWeb } from '../../hooks/useLayoutType'
|
|
import Page from '../../layouts/Page'
|
|
import { Routes } from '../../navigation'
|
|
import { navigate, reset } from '../../navigation/NavigationService'
|
|
import { useUser } from '../../providers/UserDataProvider'
|
|
import { Palette, Style } from '../../styles'
|
|
import { gutters, size } from '../../styles/Style'
|
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
|
import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
|
|
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|
|
|
const MUSIC_GENERATION_COIN_COST = 8
|
|
const SONG_OPTIONS_PER_GENERATION = 2
|
|
const REGENERATE_MODAL_MAX_WIDTH = 540
|
|
|
|
const SongReady = () => {
|
|
const {
|
|
selectedProjectId: projectId,
|
|
selectedProject,
|
|
updateProjectData,
|
|
updateUserData,
|
|
} = useUser()
|
|
const [showValidateModal, setShowValidateModal] = useState(false)
|
|
const [showRegenerateModal, setShowRegenerateModal] = useState(false)
|
|
const [musicUrls, setMusicUrls] = useState([])
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const playerRefs = useRef({})
|
|
|
|
const registerPlayer = useCallback((index, player) => {
|
|
if (player) {
|
|
playerRefs.current[index] = player
|
|
} else {
|
|
delete playerRefs.current[index]
|
|
}
|
|
}, [])
|
|
|
|
const pauseAllExcept = useCallback(async (keepIndex = null) => {
|
|
const tasks = Object.entries(playerRefs.current).map(async ([key, player]) => {
|
|
const idx = Number(key)
|
|
if (!player || idx === keepIndex) return
|
|
try {
|
|
await player.pause?.()
|
|
} catch (error) {
|
|
console.log('SongReady pause error', error?.message)
|
|
}
|
|
})
|
|
await Promise.all(tasks)
|
|
}, [])
|
|
|
|
const pauseAllPlayers = useCallback(async () => {
|
|
await pauseAllExcept(null)
|
|
}, [pauseAllExcept])
|
|
|
|
const handleTogglePlayback = useCallback(
|
|
async (index, isPlayingNow) => {
|
|
const player = playerRefs.current[index]
|
|
if (!player) return
|
|
if (isPlayingNow) {
|
|
try {
|
|
await player.pause?.()
|
|
} catch (error) {
|
|
console.log('SongReady pause toggle', error?.message)
|
|
}
|
|
return
|
|
}
|
|
await pauseAllExcept(index)
|
|
try {
|
|
await player.play?.()
|
|
} catch (error) {
|
|
console.log('SongReady play error', error?.message)
|
|
}
|
|
},
|
|
[pauseAllExcept]
|
|
)
|
|
|
|
// Sync URLs from provider's selectedProject
|
|
useEffect(() => {
|
|
const urls = Array.isArray(selectedProject?.musicUrls)
|
|
? selectedProject.musicUrls
|
|
.filter((url) => typeof url === 'string' && url.trim())
|
|
.map((url) => url.trim())
|
|
: []
|
|
setMusicUrls(urls)
|
|
setSelectedIndex((prev) => {
|
|
if (!urls.length) return 0
|
|
return Math.min(prev, urls.length - 1)
|
|
})
|
|
}, [selectedProject?.musicUrls])
|
|
|
|
const validateSelection = async () => {
|
|
try {
|
|
const url = musicUrls[selectedIndex]
|
|
|
|
if (!projectId || !url) return
|
|
await updateProjectData({
|
|
songIndex: selectedIndex,
|
|
songUrl: url,
|
|
sunoAudioId: selectedProject?.musicAudioIds?.[selectedIndex] || null,
|
|
songSunoTaskId: selectedProject?.musicTaskIds?.[selectedIndex] || null,
|
|
lyricsSyncStatus: 'PENDING',
|
|
lyricsSyncError: null,
|
|
cover: null,
|
|
coverUrl: null,
|
|
coverStatus: null,
|
|
playbackUrl: null,
|
|
playbackStatus: null,
|
|
playbackGenerating: null,
|
|
songPublishedOnMusicLand: false,
|
|
songPublishedOnMusicLandAt: null,
|
|
playbackPublishedOnMusicLand: false,
|
|
playbackPublishedOnMusicLandAt: null,
|
|
})
|
|
|
|
// Incrémenter le compteur de chansons générées
|
|
const currentGeneratedSongs = selectedProject?.user?.generatedSongs || 0
|
|
await updateUserData({
|
|
data: {
|
|
generatedSongs: currentGeneratedSongs + 1,
|
|
},
|
|
shouldSetTooltip: false,
|
|
})
|
|
|
|
await pauseAllPlayers()
|
|
reset({
|
|
index: 1,
|
|
routes: [{ name: Routes.BottomTab }, { name: Routes.ChooseCoverType }],
|
|
})
|
|
} catch (e) {
|
|
console.log('Validate error', e?.message)
|
|
}
|
|
}
|
|
|
|
// Pause audio when screen loses focus (navigate/reset) and on unmount
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
return () => {
|
|
pauseAllPlayers()
|
|
}
|
|
}, [pauseAllPlayers])
|
|
)
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
pauseAllPlayers()
|
|
}
|
|
}, [pauseAllPlayers])
|
|
|
|
const handleChooseTrack = () => {
|
|
if (isWeb) {
|
|
alert(
|
|
'Attention !',
|
|
'Lorsque tu cliques sur valider, tu ne pourras plus changer ni le texte ni la mélodie.',
|
|
[
|
|
{
|
|
text: 'Retour',
|
|
style: 'cancel',
|
|
},
|
|
{
|
|
text: 'Valider',
|
|
style: 'confirm',
|
|
onPress: () => {
|
|
validateSelection()
|
|
},
|
|
},
|
|
],
|
|
{ cancelable: false }
|
|
)
|
|
return
|
|
}
|
|
setShowValidateModal(true)
|
|
}
|
|
|
|
const handleConfirmRegenerate = async () => {
|
|
setShowRegenerateModal(false)
|
|
try {
|
|
await pauseAllPlayers()
|
|
} catch {}
|
|
navigate(Routes.Lyrics)
|
|
}
|
|
|
|
const handleRegeneratePress = () => {
|
|
if (isWeb) {
|
|
const descriptionTextStyle = {
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: 'center',
|
|
}
|
|
const amountTextStyle = {
|
|
...descriptionTextStyle,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
}
|
|
|
|
alert(
|
|
'Re-générer le morceau',
|
|
<View
|
|
style={{
|
|
width: '100%',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<Text style={descriptionTextStyle}>
|
|
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
|
|
</Text>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<Text style={descriptionTextStyle}>Cette action coûte</Text>
|
|
<CreditAmount
|
|
value={MUSIC_GENERATION_COIN_COST}
|
|
iconSize={18}
|
|
textStyle={amountTextStyle}
|
|
/>
|
|
</View>
|
|
<Text style={descriptionTextStyle}>
|
|
Les crédits seront utilisés lors de l'étape de génération.
|
|
</Text>
|
|
</View>,
|
|
[
|
|
{
|
|
text: 'Annuler',
|
|
style: 'cancel',
|
|
},
|
|
{
|
|
text: 'Oui',
|
|
style: 'confirm',
|
|
onPress: () => handleConfirmRegenerate(),
|
|
},
|
|
],
|
|
{ cancelable: true }
|
|
)
|
|
return
|
|
}
|
|
setShowRegenerateModal(true)
|
|
}
|
|
|
|
return (
|
|
<Page headerType="NONE" backgroundImg={background.studioBG2} maxWidth={800}>
|
|
<MusicLandHeader
|
|
onPressBack={async () => {
|
|
try {
|
|
await pauseAllPlayers()
|
|
} catch {}
|
|
navigate(Routes.Home)
|
|
}}
|
|
progress={63}
|
|
/>
|
|
<View style={{ flex: 1, marginTop: 16 }}>
|
|
<CreateLyricsHeader
|
|
title="Ta chanson est prête !"
|
|
subTitle="Choisis ton morceau"
|
|
containerStyle={{
|
|
marginBottom: !isDesktopWeb ? responsiveHeight(2) : responsiveHeight(22),
|
|
}}
|
|
/>
|
|
<FlatList
|
|
style={{ flex: 1 }}
|
|
data={musicUrls}
|
|
keyExtractor={(item, idx) => `${item || 'song'}-${idx}`}
|
|
renderItem={({ item, index }) => (
|
|
<SongOptionCard
|
|
index={index}
|
|
url={item}
|
|
isSelected={selectedIndex === index}
|
|
onSelect={setSelectedIndex}
|
|
registerPlayer={registerPlayer}
|
|
onTogglePlayback={handleTogglePlayback}
|
|
projectId={projectId}
|
|
selectedProject={selectedProject}
|
|
fallbackDurationS={selectedProject?.musicDurations?.[index]}
|
|
/>
|
|
)}
|
|
contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
|
|
showsVerticalScrollIndicator={false}
|
|
extraData={selectedIndex}
|
|
/>
|
|
</View>
|
|
<View
|
|
style={{
|
|
paddingBottom: gutters * 2,
|
|
width: '80%',
|
|
alignSelf: 'center',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<GradientButton
|
|
title="Choisir ce morceau"
|
|
onPress={handleChooseTrack}
|
|
disabled={!musicUrls?.length}
|
|
/>
|
|
<BorderGradientButton title="Re-générer le morceau" onPress={handleRegeneratePress} />
|
|
</View>
|
|
{!isWeb && (
|
|
<>
|
|
<ValidateModal
|
|
visible={showValidateModal}
|
|
onClose={() => setShowValidateModal(false)}
|
|
onPressValidate={validateSelection}
|
|
/>
|
|
<RegenerateModal
|
|
visible={showRegenerateModal}
|
|
onClose={() => setShowRegenerateModal(false)}
|
|
onConfirm={handleConfirmRegenerate}
|
|
/>
|
|
</>
|
|
)}
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
const SongOptionCard = ({
|
|
index,
|
|
url,
|
|
isSelected,
|
|
onSelect,
|
|
registerPlayer,
|
|
onTogglePlayback,
|
|
projectId,
|
|
selectedProject,
|
|
fallbackDurationS,
|
|
}) => {
|
|
const trackTitle =
|
|
(Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) ||
|
|
selectedProject?.title ||
|
|
`Morceau ${index + 1}`
|
|
|
|
const player = useSharedAudioPlayer(url ? { uri: url } : undefined, {
|
|
id: url ? `songready-${url}` : undefined,
|
|
title: trackTitle,
|
|
artwork: selectedProject?.coverUrl || null,
|
|
coverUrl: selectedProject?.coverUrl || null,
|
|
metadata: { index, projectId },
|
|
})
|
|
|
|
const fallbackDurationMs = Math.max(0, Math.round((Number(fallbackDurationS) || 0) * 1000))
|
|
const [progressInfo, setProgressInfo] = useState({
|
|
pos: 0,
|
|
dur: fallbackDurationMs,
|
|
})
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!fallbackDurationMs) return
|
|
setProgressInfo((current) =>
|
|
current.dur ? current : { ...current, dur: fallbackDurationMs }
|
|
)
|
|
}, [fallbackDurationMs])
|
|
|
|
useEffect(() => {
|
|
registerPlayer(index, player)
|
|
return () => registerPlayer(index, null)
|
|
}, [index, player, registerPlayer])
|
|
|
|
useEffect(() => {
|
|
if (!player || !url) return
|
|
const preload = async () => {
|
|
try {
|
|
await player.load?.({ startPositionMs: 0 })
|
|
} catch (error) {
|
|
console.log('SongReady preload error', error?.message)
|
|
}
|
|
}
|
|
preload()
|
|
}, [player, url])
|
|
|
|
useEffect(() => {
|
|
if (!player) {
|
|
setIsPlaying(false)
|
|
setProgressInfo({ pos: 0, dur: 0 })
|
|
return undefined
|
|
}
|
|
const id = setInterval(() => {
|
|
const playerDurationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000))
|
|
const durationMs = playerDurationMs || fallbackDurationMs
|
|
const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000))
|
|
setProgressInfo((prev) => {
|
|
if (
|
|
Math.abs((prev?.dur || 0) - durationMs) < 5 &&
|
|
Math.abs((prev?.pos || 0) - positionMs) < 5
|
|
) {
|
|
return prev
|
|
}
|
|
return { pos: positionMs, dur: durationMs }
|
|
})
|
|
setIsPlaying((prev) => {
|
|
const next = !!player.playing
|
|
if (prev !== next) {
|
|
return next
|
|
}
|
|
return prev
|
|
})
|
|
}, 300)
|
|
return () => clearInterval(id)
|
|
}, [fallbackDurationMs, player])
|
|
|
|
const handleSeek = useCallback(
|
|
async (targetMs) => {
|
|
if (!player || !progressInfo?.dur) {
|
|
return
|
|
}
|
|
const dur = progressInfo.dur || 0
|
|
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)))
|
|
|
|
// Mise à jour visuelle immédiate
|
|
setProgressInfo((prev) => ({ ...prev, pos }))
|
|
|
|
try {
|
|
await player.seekTo?.(Math.floor(pos / 1000))
|
|
} catch (error) {
|
|
console.log('SongReady seek error', error?.message)
|
|
}
|
|
},
|
|
[player, progressInfo?.dur]
|
|
)
|
|
|
|
const handleSeekStart = useCallback(() => {
|
|
onSelect(index)
|
|
}, [index, onSelect])
|
|
|
|
const handlePause = useCallback(async () => {
|
|
if (!player) return
|
|
try {
|
|
await player.pause?.()
|
|
} catch (error) {
|
|
console.log('SongReady pause on seek', error?.message)
|
|
}
|
|
}, [player])
|
|
|
|
const handlePlay = useCallback(async () => {
|
|
if (!player) return
|
|
try {
|
|
if (player.resume) {
|
|
await player.resume?.()
|
|
} else {
|
|
await player.play?.()
|
|
}
|
|
} catch (error) {
|
|
console.log('SongReady resume on seek', error?.message)
|
|
}
|
|
}, [player])
|
|
|
|
const handleToggle = () => {
|
|
onSelect(index)
|
|
onTogglePlayback?.(index, isPlaying)
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={() => onSelect(index)}
|
|
style={({ pressed }) => [
|
|
{
|
|
borderRadius: 20,
|
|
borderWidth: isSelected ? 2 : 1,
|
|
borderColor: isSelected ? Palette.primary : Palette.ultraLightWhite,
|
|
overflow: 'hidden',
|
|
},
|
|
pressed && { opacity: 0.96 },
|
|
]}
|
|
>
|
|
<BlurView
|
|
intensity={40}
|
|
tint="dark"
|
|
style={{
|
|
borderRadius: 18,
|
|
overflow: 'hidden',
|
|
padding: 12,
|
|
backgroundColor: '#FFFFFF0A',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<Pressable
|
|
onPress={handleToggle}
|
|
style={{
|
|
...Style.containerCenter,
|
|
...size({ size: 48 }),
|
|
}}
|
|
>
|
|
<Image source={isPlaying ? icons.pause : icons.play} />
|
|
</Pressable>
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={{
|
|
color: 'white',
|
|
marginBottom: responsiveHeight(1),
|
|
}}
|
|
>{`Morceau ${index + 1}`}</Text>
|
|
<ProgressSlider
|
|
positionMs={progressInfo?.pos}
|
|
durationMs={progressInfo?.dur}
|
|
isPlaying={isPlaying}
|
|
onSeekStart={handleSeekStart}
|
|
onSeek={handleSeek}
|
|
onPause={handlePause}
|
|
onPlay={handlePlay}
|
|
disabled={!url}
|
|
/>
|
|
</View>
|
|
<Pressable
|
|
onPress={() => onSelect(index)}
|
|
style={{
|
|
...Style.containerCenter,
|
|
...size({ size: 24 }),
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: 9,
|
|
borderWidth: 2,
|
|
borderColor: isSelected ? Palette.primary : 'white',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{isSelected && (
|
|
<View
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: Palette.primary,
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
</BlurView>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
|
|
const { width, height } = useWindowDimensions()
|
|
const isCompactWidth = width < 420
|
|
const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12)
|
|
const verticalPadding = Math.max(isCompactWidth ? gutters : gutters * 1.5, 12)
|
|
const availableWidth = Math.max(width - horizontalPadding * 2, 0)
|
|
const contentWidth =
|
|
availableWidth > 0 ? Math.min(REGENERATE_MODAL_MAX_WIDTH, availableWidth) : undefined
|
|
const contentGap = isCompactWidth ? 18 : 24
|
|
const buttonGap = isCompactWidth ? 12 : 15
|
|
|
|
return (
|
|
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
}}
|
|
>
|
|
<ScrollView
|
|
bounces={false}
|
|
contentContainerStyle={{
|
|
flexGrow: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingHorizontal: horizontalPadding,
|
|
paddingVertical: verticalPadding,
|
|
minHeight: height,
|
|
}}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<CreateLyricsHeader
|
|
containerStyle={{
|
|
width: contentWidth ?? '100%',
|
|
maxWidth: REGENERATE_MODAL_MAX_WIDTH,
|
|
alignSelf: 'center',
|
|
paddingVertical: contentGap,
|
|
paddingHorizontal: contentGap,
|
|
gap: contentGap,
|
|
flexShrink: 1,
|
|
}}
|
|
>
|
|
<View style={{ gap: contentGap, alignItems: 'center' }}>
|
|
<View
|
|
style={{
|
|
gap: isCompactWidth ? 10 : 12,
|
|
alignItems: 'center',
|
|
paddingHorizontal: isCompactWidth ? 4 : 12,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Re-générer le morceau ?
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
|
|
</Text>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Cette action coûte
|
|
</Text>
|
|
<CreditAmount
|
|
value={MUSIC_GENERATION_COIN_COST}
|
|
iconSize={18}
|
|
textStyle={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
textAlign: 'center',
|
|
}}
|
|
/>
|
|
</View>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Les crédits seront utilisés lors de l'étape de génération.
|
|
</Text>
|
|
</View>
|
|
<View
|
|
style={{
|
|
width: isCompactWidth ? '100%' : '85%',
|
|
alignSelf: 'center',
|
|
gap: buttonGap,
|
|
}}
|
|
>
|
|
<GradientButton title="Oui, modifier mes choix" onPress={onConfirm} />
|
|
<BorderGradientButton title="Retour" onPress={onClose} />
|
|
</View>
|
|
</View>
|
|
</CreateLyricsHeader>
|
|
</ScrollView>
|
|
</View>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default SongReady
|