621 lines
20 KiB
JavaScript
621 lines
20 KiB
JavaScript
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
import { ScrollView, StyleSheet, Text, View } from 'react-native'
|
|
import { background, icons } from '../../assets'
|
|
import alert from '../../components/Alert'
|
|
import AppCheckbox from '../../components/AppCheckbox'
|
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
|
import GradientButton from '../../components/GradientButton'
|
|
import ItemContainer from '../../components/ItemContainer/ItemContainer'
|
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
|
import Overlay from '../../components/Overlay'
|
|
import firebase, { getFunctionsClient, projectsRef } from '../../config/firebase'
|
|
import { strings } from '../../constants/strings'
|
|
import { isWeb } from '../../hooks/useLayoutType'
|
|
import Page from '../../layouts/Page'
|
|
import { Routes } from '../../navigation'
|
|
import { navigate } from '../../navigation/NavigationService'
|
|
import { LoaderIndicator } from '../../providers/LoadingProvider'
|
|
import { useUser } from '../../providers/UserDataProvider'
|
|
import { Palette, gutters } from '../../styles'
|
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
|
import {
|
|
formatStructureLabel,
|
|
getPromptLabelForStructure,
|
|
getSegmentMeta,
|
|
normalizeStructureType,
|
|
sanitizeStructureList,
|
|
segmentRequiresLyrics,
|
|
} from '../../utils/songStructure'
|
|
import { getStageAction } from '../../utils/projectStages'
|
|
import CustomInput from './components/CustomInput'
|
|
|
|
const RESPONSIBILITY_CHECKBOX_LABEL =
|
|
'Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.'
|
|
const FUNCTIONS_REGION = 'europe-west1'
|
|
|
|
const Lyrics = ({ navigation }) => {
|
|
const scrollRef = useRef(null)
|
|
const [containerLayout, setContainerLayout] = useState(null)
|
|
const { selectedProjectId, selectedProject } = useUser()
|
|
const hasExistingMusicDraft = useMemo(() => {
|
|
if (!Array.isArray(selectedProject?.musicUrls)) return false
|
|
return selectedProject.musicUrls.some((url) => typeof url === 'string' && url.trim())
|
|
}, [selectedProject?.musicUrls])
|
|
const [isFocus, setIsFocus] = useState(null)
|
|
const [itemsContainerLayout, setItemsContainerLayout] = useState([])
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isBlockingUI, setIsBlockingUI] = useState(false)
|
|
const isSubmittingRef = useRef(false)
|
|
const [sensitiveContentModal, setSensitiveContentModal] = useState({
|
|
visible: false,
|
|
title: '',
|
|
message: '',
|
|
})
|
|
const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] = useState(false)
|
|
const sensitiveContentResolverRef = useRef(null)
|
|
const updateLayoutAtIndex = useCallback((index, layout) => {
|
|
if (typeof index !== 'number' || !layout) return
|
|
setItemsContainerLayout((prev) => {
|
|
const next = [...prev]
|
|
next[index] = layout
|
|
return next
|
|
})
|
|
}, [])
|
|
const handleSectionFocus = useCallback(
|
|
(idx) => {
|
|
setIsFocus(idx)
|
|
if (isWeb) return
|
|
const targetLayout = itemsContainerLayout[idx + 1]
|
|
if (typeof targetLayout?.y === 'number') {
|
|
scrollRef?.current?.scrollTo({
|
|
y: targetLayout.y,
|
|
animated: true,
|
|
})
|
|
}
|
|
},
|
|
[itemsContainerLayout, isWeb]
|
|
)
|
|
|
|
const closeSensitiveContentModal = useCallback((result) => {
|
|
setSensitiveContentModal((prev) => ({ ...prev, visible: false }))
|
|
setIsSensitiveContentAcknowledged(false)
|
|
if (sensitiveContentResolverRef.current) {
|
|
sensitiveContentResolverRef.current(result)
|
|
sensitiveContentResolverRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
// Effective sources from provider only
|
|
const projectTitle = selectedProject?.title || ''
|
|
const projectLyrics = Array.isArray(selectedProject?.lyrics) ? selectedProject.lyrics : []
|
|
const projectConfig = selectedProject?.config || null
|
|
const projectSelections = selectedProject?.selections || null
|
|
const projectHasLyrics = !!selectedProject?.hasLyrics
|
|
|
|
const initial = useMemo(() => {
|
|
const title = projectTitle || ''
|
|
const normalizedSections = Array.isArray(projectLyrics)
|
|
? projectLyrics.map((s) => ({
|
|
type: normalizeStructureType(s?.type),
|
|
lyrics: s?.lyrics || '',
|
|
}))
|
|
: []
|
|
const targetStructure = Array.isArray(projectConfig?.structure)
|
|
? sanitizeStructureList(projectConfig.structure)
|
|
: null
|
|
|
|
const structureToUse =
|
|
targetStructure && targetStructure.length
|
|
? targetStructure
|
|
: normalizedSections.map((s) => s.type)
|
|
|
|
if (!structureToUse.length && normalizedSections.length) {
|
|
return { title, sections: normalizedSections }
|
|
}
|
|
|
|
const remaining = [...normalizedSections]
|
|
const takeMatching = (type) => {
|
|
const index = remaining.findIndex((s) => s.type === type)
|
|
if (index !== -1) {
|
|
return remaining.splice(index, 1)[0]
|
|
}
|
|
return remaining.shift() || null
|
|
}
|
|
|
|
const sections = structureToUse.map((segmentType) => {
|
|
const normalizedType = normalizeStructureType(segmentType)
|
|
if (!segmentRequiresLyrics(normalizedType)) {
|
|
return { type: normalizedType, lyrics: '' }
|
|
}
|
|
const matched = takeMatching(normalizedType)
|
|
return {
|
|
type: normalizedType,
|
|
lyrics: matched?.lyrics || '',
|
|
}
|
|
})
|
|
|
|
const remainingTextual = remaining.filter((item) => segmentRequiresLyrics(item.type))
|
|
sections.push(...remainingTextual)
|
|
|
|
return { title, sections }
|
|
}, [projectTitle, projectLyrics, projectConfig])
|
|
|
|
const [titleValue, setTitleValue] = useState(initial.title || '')
|
|
const [sections, setSections] = useState(initial.sections || [])
|
|
const setSectionAt = (index, value) => {
|
|
setSections((prev) => {
|
|
const next = [...prev]
|
|
if (next[index]) next[index] = { ...next[index], lyrics: value }
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Plus d'appel direct à la cloud function ici: on retourne à l'étape précédente
|
|
const regenerate = useCallback(() => {
|
|
navigate(Routes.CreateLyricsWithAi)
|
|
}, [])
|
|
|
|
const sanitize = (obj) => {
|
|
if (obj === undefined) return null
|
|
if (obj === null) return null
|
|
if (Array.isArray(obj)) return obj.map((v) => sanitize(v))
|
|
if (typeof obj === 'object') {
|
|
const out = {}
|
|
Object.keys(obj).forEach((k) => {
|
|
const v = obj[k]
|
|
if (v === undefined) return // omit undefined
|
|
out[k] = sanitize(v)
|
|
})
|
|
return out
|
|
}
|
|
return obj
|
|
}
|
|
|
|
const alertMessage = useCallback((title, message) => {
|
|
alert(title, message)
|
|
}, [])
|
|
|
|
const confirmSensitiveContent = useCallback((title, message) => {
|
|
return new Promise((resolve) => {
|
|
sensitiveContentResolverRef.current = resolve
|
|
setSensitiveContentModal({
|
|
visible: true,
|
|
title,
|
|
message,
|
|
})
|
|
setIsSensitiveContentAcknowledged(false)
|
|
})
|
|
}, [])
|
|
|
|
const handleSensitiveCancel = useCallback(() => {
|
|
closeSensitiveContentModal(false)
|
|
}, [closeSensitiveContentModal])
|
|
|
|
const handleSensitiveConfirm = useCallback(() => {
|
|
if (!isSensitiveContentAcknowledged) return
|
|
closeSensitiveContentModal(true)
|
|
}, [closeSensitiveContentModal, isSensitiveContentAcknowledged])
|
|
|
|
const onValidate = useCallback(async () => {
|
|
if (isSubmittingRef.current) return
|
|
isSubmittingRef.current = true
|
|
setIsSubmitting(true)
|
|
setIsBlockingUI(true)
|
|
try {
|
|
const titleTrimmed = (titleValue || '').trim()
|
|
if (!titleTrimmed) {
|
|
setIsBlockingUI(false)
|
|
alertMessage('Titre manquant', 'Veuillez renseigner un titre.')
|
|
return
|
|
}
|
|
const invalid = (sections || []).some((s) => {
|
|
const type = normalizeStructureType(s?.type)
|
|
if (!segmentRequiresLyrics(type)) return false
|
|
return !(s?.lyrics || '').trim()
|
|
})
|
|
if (invalid) {
|
|
setIsBlockingUI(false)
|
|
alertMessage('Champs incomplets', 'Chaque section doit contenir du texte.')
|
|
return
|
|
}
|
|
const normalizedNewLyrics = (sections || []).map((s) => ({
|
|
type: normalizeStructureType(s?.type),
|
|
lyrics: segmentRequiresLyrics(normalizeStructureType(s?.type))
|
|
? (s?.lyrics || '').trim()
|
|
: '',
|
|
}))
|
|
const normalizedOldLyrics = Array.isArray(projectLyrics)
|
|
? projectLyrics.map((s) => ({
|
|
type: normalizeStructureType(s?.type),
|
|
lyrics: (s?.lyrics || '').trim(),
|
|
}))
|
|
: []
|
|
const sameLength = normalizedOldLyrics.length === normalizedNewLyrics.length
|
|
const isSame =
|
|
sameLength &&
|
|
normalizedOldLyrics.every(
|
|
(s, i) =>
|
|
s.type === normalizedNewLyrics[i]?.type && s.lyrics === normalizedNewLyrics[i]?.lyrics
|
|
)
|
|
|
|
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
|
try {
|
|
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
|
'lyrics-analyseLyricsToxicity'
|
|
)
|
|
const { data } = await callable({
|
|
title: titleTrimmed,
|
|
lyrics: normalizedNewLyrics,
|
|
})
|
|
|
|
if (!data?.success) {
|
|
// Blocage dur: afficher le message et ne pas sauvegarder
|
|
if (data?.errorCode === 'TOXIC_CONTENT_BLOCKED') {
|
|
const quotes = Array.isArray(data?.result?.excerpts)
|
|
? data.result.excerpts
|
|
.slice(0, 3)
|
|
.map((e) => `• ${e.quote}`)
|
|
.join('\n')
|
|
: null
|
|
setIsBlockingUI(false)
|
|
alertMessage('Contenu interdit', [data?.message, quotes].filter(Boolean).join('\n\n'))
|
|
return // stop here
|
|
}
|
|
// Erreur d'analyse: informer et arrêter
|
|
if (data?.errorCode === 'ANALYSE_FAILED') {
|
|
setIsBlockingUI(false)
|
|
alertMessage(
|
|
'Analyse indisponible',
|
|
'Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.'
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Cas signalé mais non bloquant: demander confirmation
|
|
if (data?.errorCode === 'TOXIC_CONTENT_FLAGGED') {
|
|
const quotes = Array.isArray(data?.result?.excerpts)
|
|
? data.result.excerpts
|
|
.slice(0, 3)
|
|
.map((e) => `• ${e.quote}`)
|
|
.join('\n')
|
|
: null
|
|
const msg = [data?.message, quotes].filter(Boolean).join('\n\n')
|
|
|
|
setIsBlockingUI(false)
|
|
const proceed = await confirmSensitiveContent('Contenu potentiellement sensible', msg)
|
|
if (!proceed) return
|
|
setIsBlockingUI(true)
|
|
}
|
|
} catch (moderationError) {
|
|
console.log('Moderation call failed', moderationError?.message || moderationError)
|
|
setIsBlockingUI(false)
|
|
alertMessage(
|
|
'Analyse indisponible',
|
|
'Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.'
|
|
)
|
|
return
|
|
}
|
|
|
|
const baseData = {
|
|
title: titleTrimmed,
|
|
lyrics: normalizedNewLyrics,
|
|
config: sanitize(projectConfig),
|
|
selections: sanitize(projectSelections),
|
|
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
hasLyrics: projectHasLyrics,
|
|
}
|
|
|
|
const updateData = { ...baseData }
|
|
if (!isSame) {
|
|
updateData.musicUrls = firebase.firestore.FieldValue.delete()
|
|
updateData.musicStatus = firebase.firestore.FieldValue.delete()
|
|
}
|
|
await projectsRef.doc(selectedProjectId).set(updateData, { merge: true })
|
|
const projectForStage = {
|
|
...selectedProject,
|
|
title: titleTrimmed,
|
|
lyrics: normalizedNewLyrics,
|
|
config: sanitize(projectConfig),
|
|
selections: sanitize(projectSelections),
|
|
hasLyrics: true,
|
|
}
|
|
if (!isSame) {
|
|
projectForStage.musicUrls = undefined
|
|
projectForStage.musicStatus = undefined
|
|
}
|
|
const beatmakerStage = getStageAction('beatmaker', projectForStage)
|
|
setIsBlockingUI(false)
|
|
if (hasExistingMusicDraft) {
|
|
navigate(Routes.ComposeSong, { isRegeneration: true, fromLyrics: true })
|
|
return
|
|
}
|
|
const handleNavigateToStudio = () => {
|
|
const targetRoute = beatmakerStage?.route || Routes.ComposeSong
|
|
navigate(targetRoute, {
|
|
...(beatmakerStage?.params || {}),
|
|
fromLyrics: true,
|
|
})
|
|
}
|
|
alert(
|
|
'Céline',
|
|
"Bravo, tu as créé les paroles de ta chanson! Prochaine étape, le studio! Malik t'attends pour mettre ça en musique! A bientôt! and...Feel The Good Vibe!",
|
|
[
|
|
{
|
|
text: "Retour à l'accueil",
|
|
style: 'cancel',
|
|
onPress: () =>
|
|
navigate(Routes.BottomTab, {
|
|
screen: Routes.HomeStack,
|
|
params: {
|
|
screen: Routes.Home,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
text: 'Continuer vers le Studio',
|
|
onPress: handleNavigateToStudio,
|
|
},
|
|
]
|
|
)
|
|
return
|
|
} catch (e) {
|
|
console.log(e)
|
|
setIsBlockingUI(false)
|
|
alertMessage('Erreur', "Échec de l'enregistrement dans le projet.")
|
|
} finally {
|
|
isSubmittingRef.current = false
|
|
setIsSubmitting(false)
|
|
setIsBlockingUI(false)
|
|
}
|
|
}, [
|
|
titleValue,
|
|
sections,
|
|
projectConfig,
|
|
projectSelections,
|
|
alertMessage,
|
|
confirmSensitiveContent,
|
|
sanitize,
|
|
selectedProjectId,
|
|
projectHasLyrics,
|
|
projectLyrics,
|
|
selectedProject,
|
|
hasExistingMusicDraft,
|
|
])
|
|
|
|
const typeOccurrences = {}
|
|
|
|
return (
|
|
<Page backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG} headerType="NONE">
|
|
<MusicLandHeader onPressBack={() => navigate(Routes.Home)} progress={95} />
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
marginTop: 16,
|
|
gap: 48,
|
|
}}
|
|
onLayout={(e) => setContainerLayout(e.nativeEvent.layout)}
|
|
>
|
|
<ItemContainer
|
|
height={containerLayout?.height}
|
|
disableKeyboardHeight={isFocus !== sections.length - 1 || !isFocus}
|
|
>
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 10,
|
|
gap: 10,
|
|
flexGrow: 1,
|
|
}}
|
|
>
|
|
<View style={styles.headerContainer}>
|
|
<Text style={styles.headerTitle}>{strings.writing.lyrics.title}</Text>
|
|
<Text style={styles.instructions}>{strings.writing.lyrics.instructions}</Text>
|
|
<Text style={styles.personalizationText}>
|
|
Tu peux personnaliser le texte proposé, apporter ta touche personnelle mais respecte
|
|
la structure et le nombre de syllabes pour un résultat optimal
|
|
</Text>
|
|
</View>
|
|
<CustomInput
|
|
label="Titre"
|
|
placeholder="Titre"
|
|
height={45}
|
|
value={titleValue}
|
|
setValue={setTitleValue}
|
|
multiline={false}
|
|
maxLength={60}
|
|
onLayout={(e) => {
|
|
updateLayoutAtIndex(0, e?.nativeEvent?.layout)
|
|
}}
|
|
/>
|
|
{sections.map((s, idx) => {
|
|
const normalizedType = normalizeStructureType(s?.type)
|
|
const key = normalizedType || 'section'
|
|
typeOccurrences[key] = (typeOccurrences[key] || 0) + 1
|
|
const occurrence = typeOccurrences[key]
|
|
const label = formatStructureLabel(key, occurrence)
|
|
const meta = getSegmentMeta(key)
|
|
const inputHeight =
|
|
typeof meta?.inputHeight === 'number'
|
|
? meta.inputHeight
|
|
: key === 'refrain'
|
|
? 170
|
|
: 225
|
|
const placeholder = meta?.label || label
|
|
const requiresLyrics = segmentRequiresLyrics(key)
|
|
if (!requiresLyrics) {
|
|
return (
|
|
<View key={idx} style={styles.instrumentalBlock}>
|
|
<Text style={styles.instrumentalTitle}>{label}</Text>
|
|
<Text style={styles.instrumentalText}>
|
|
{getPromptLabelForStructure(key)} — section instrumentale sans paroles.
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
return (
|
|
<CustomInput
|
|
key={idx}
|
|
label={label}
|
|
placeholder={placeholder}
|
|
height={inputHeight}
|
|
value={s?.lyrics || ''}
|
|
setValue={(val) => setSectionAt(idx, val)}
|
|
onFocus={() => handleSectionFocus(idx)}
|
|
onLayout={(e) => {
|
|
updateLayoutAtIndex(idx + 1, e?.nativeEvent?.layout)
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
</ScrollView>
|
|
</ItemContainer>
|
|
</View>
|
|
<View
|
|
style={{
|
|
paddingTop: gutters,
|
|
paddingBottom: gutters * 2,
|
|
paddingHorizontal: gutters,
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{!projectHasLyrics && (
|
|
<BorderGradientButton
|
|
title="Générer d'autres paroles"
|
|
onPress={regenerate}
|
|
disabled={isSubmitting}
|
|
/>
|
|
)}
|
|
<GradientButton title="Valider" onPress={onValidate} disabled={isSubmitting} />
|
|
</View>
|
|
{isBlockingUI ? (
|
|
<View
|
|
pointerEvents="auto"
|
|
style={[styles.loadingOverlay, { position: isWeb ? 'fixed' : 'absolute' }]}
|
|
>
|
|
<LoaderIndicator />
|
|
</View>
|
|
) : null}
|
|
<Overlay
|
|
isVisible={sensitiveContentModal.visible}
|
|
setIsVisible={(visible) => {
|
|
if (visible === false) {
|
|
handleSensitiveCancel()
|
|
}
|
|
}}
|
|
contentContainerStyle={{
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<View style={styles.sensitiveModal}>
|
|
<Text style={styles.sensitiveModalTitle}>{sensitiveContentModal.title}</Text>
|
|
<Text style={styles.sensitiveModalMessage}>{sensitiveContentModal.message}</Text>
|
|
<View style={styles.sensitiveCheckboxWrapper}>
|
|
<AppCheckbox
|
|
selected={isSensitiveContentAcknowledged}
|
|
onPress={() => setIsSensitiveContentAcknowledged((prev) => !prev)}
|
|
label={RESPONSIBILITY_CHECKBOX_LABEL}
|
|
/>
|
|
</View>
|
|
<View style={styles.sensitiveModalActions}>
|
|
<BorderGradientButton
|
|
title="Annuler"
|
|
onPress={handleSensitiveCancel}
|
|
containerStyle={{ flex: 1 }}
|
|
/>
|
|
<GradientButton
|
|
title="Continuer"
|
|
onPress={handleSensitiveConfirm}
|
|
disabled={!isSensitiveContentAcknowledged}
|
|
containerStyle={{ flex: 1 }}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</Overlay>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
export default Lyrics
|
|
|
|
const styles = StyleSheet.create({
|
|
headerContainer: {
|
|
gap: 8,
|
|
paddingHorizontal: 2,
|
|
},
|
|
headerTitle: {
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 20,
|
|
},
|
|
instructions: {
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
},
|
|
personalizationText: {
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 18,
|
|
lineHeight: 24,
|
|
marginTop: 8,
|
|
},
|
|
instrumentalBlock: {
|
|
padding: 16,
|
|
borderRadius: 12,
|
|
backgroundColor: Palette.ultraLightWhite,
|
|
gap: 6,
|
|
},
|
|
instrumentalTitle: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
},
|
|
instrumentalText: {
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
opacity: 0.8,
|
|
},
|
|
sensitiveModal: {
|
|
width: '90%',
|
|
maxWidth: 460,
|
|
backgroundColor: Palette.lightPurple,
|
|
borderRadius: 18,
|
|
paddingVertical: 24,
|
|
paddingHorizontal: 20,
|
|
gap: 16,
|
|
},
|
|
sensitiveModalTitle: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 20,
|
|
color: Palette.white,
|
|
textAlign: 'center',
|
|
},
|
|
sensitiveModalMessage: {
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 15,
|
|
color: Palette.white,
|
|
lineHeight: 20,
|
|
textAlign: 'left',
|
|
opacity: 0.9,
|
|
},
|
|
sensitiveCheckboxWrapper: {
|
|
paddingVertical: 4,
|
|
},
|
|
sensitiveModalActions: {
|
|
flexDirection: 'row',
|
|
gap: 12,
|
|
},
|
|
loadingOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
zIndex: 10000,
|
|
backgroundColor: Palette.transparentBlack,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
})
|