Files
musicland/src/screens/Writing/Lyrics.js
T
2026-08-25 15:55:11 +02:00

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',
},
})