Files
musicland/src/screens/Studio/SongReady.js
T

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