From 3a5d449424f1f4e383aec4688302f3bb95713e5e Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 21 Oct 2025 11:38:48 +0200 Subject: [PATCH] new song structure --- functions/src/lyrics.js | 87 +++++- src/screens/Library/MusicDetails.web.js | 38 ++- src/screens/Studio/ComposeSong.js | 3 +- src/screens/Studio/ComposeSong.web.js | 3 +- src/screens/Writing/CreateLyricsWithAi.js | 47 ++- src/screens/Writing/CreateLyricsWithAi.web.js | 47 ++- src/screens/Writing/CreatingLyrics.js | 64 +++- src/screens/Writing/CustomizeSongStructure.js | 276 ++++++++++++------ .../Writing/CustomizeSongStructure.web.js | 166 +++++++++-- src/screens/Writing/Lyrics.js | 60 ++-- src/utils/songStructure.js | 191 ++++++++++++ 11 files changed, 807 insertions(+), 175 deletions(-) create mode 100644 src/utils/songStructure.js diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index aedb356..1f1ecfb 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -10,6 +10,67 @@ const axios = require("axios"); const admin = require("firebase-admin"); const { refList } = require("../index"); +const STRUCTURE_PROMPT_LABELS = { + couplet: "couplet", + refrain: "refrain", + short_intro: "introduction instrumentale courte", + long_intro: "introduction instrumentale longue", + pre_chorus: "pré-refrain", +}; + +const STRUCTURE_ALIASES = { + "short intro": "short_intro", + "introduction instrumentale courte": "short_intro", + "intro instrumentale courte": "short_intro", + "long intro": "long_intro", + "introduction instrumentale longue": "long_intro", + "intro instrumentale longue": "long_intro", + "pré-refrain": "pre_chorus", + "pre-refrain": "pre_chorus", + "pre chorus": "pre_chorus", + "pre-chorus": "pre_chorus", + prechorus: "pre_chorus", +}; + +const normalizeStructureValue = (value) => { + const raw = String(value || "").trim().toLowerCase(); + if (!raw) return ""; + if (STRUCTURE_PROMPT_LABELS[raw]) return raw; + if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]; + const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + if (STRUCTURE_PROMPT_LABELS[sanitized]) return sanitized; + if (STRUCTURE_ALIASES[sanitized]) return STRUCTURE_ALIASES[sanitized]; + return sanitized; +}; + +const sanitizeStructureEntries = (structure = []) => { + if (!Array.isArray(structure)) return []; + const output = []; + structure.forEach((entry) => { + const normalized = normalizeStructureValue(entry); + if (!normalized) return; + if (normalized === "short_intro" || normalized === "long_intro") { + const existingIndex = output.findIndex( + (item) => item === "short_intro" || item === "long_intro", + ); + if (existingIndex !== -1) output.splice(existingIndex, 1); + output.push(normalized); + return; + } + if (normalized === "pre_chorus") { + if (!output.includes("pre_chorus")) output.push("pre_chorus"); + return; + } + output.push(normalized); + }); + return output; +}; + +const mapStructureToPrompt = (structure = []) => + sanitizeStructureEntries(structure).map( + (entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, + ); + const TOXIC_KEYWORDS = [ /\bnazis?\b/i, /\bnazisme\b/i, @@ -39,8 +100,9 @@ const buildModerationBrief = ({ `${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}`, ]; - if (Array.isArray(structure) && structure.length) { - sections.push(`${structure.join(" | ")}`); + const promptStructure = mapStructureToPrompt(structure); + if (promptStructure.length) { + sections.push(`${promptStructure.join(" | ")}`); } return `\n${sections.join("\n")}\n`; @@ -76,17 +138,29 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { style = "", audience = "", emotion = "", - structure = ["couplet", "refrain", "couplet", "refrain"], + structure: rawStructure = [ + "couplet", + "refrain", + "couplet", + "refrain", + ], rhymes = "", } = data; + const sanitizedStructure = sanitizeStructureEntries(rawStructure); + const promptStructure = sanitizedStructure.length + ? sanitizedStructure.map( + (entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, + ) + : []; + const moderationPayload = { objective, context, style, audience, emotion, - structure, + structure: promptStructure, rhymes, }; @@ -137,7 +211,7 @@ Génère un titre accrocheur et mémorable en plus des paroles, tout en respecta Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres textes. `.trim(); - const structureTags = (Array.isArray(structure) ? structure : []) + const structureTags = (Array.isArray(promptStructure) ? promptStructure : []) .map((part, index) => { const content = part || ""; return `
${content}
`; @@ -191,8 +265,7 @@ ${structureTags || fallbackStructureTags} type: z .string() .describe( - 'Type de section : "couplet" ou "refrain" ' + - "selon la structure", + "Type de section : respecter exactement le nom de la section demandé dans la structure", ), lyrics: z .string() diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index b3dceb1..9de8558 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -37,6 +37,11 @@ import Page from "../../layouts/Page"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import Style, { gutters, size } from "../../styles/Style"; +import { + formatStructureLabel, + getSegmentMeta, + normalizeStructureType, +} from "../../utils/songStructure"; // 20 secondes const timeBeforeIncrement = 20000; @@ -325,12 +330,14 @@ const MusicDetails = ({ route }) => { const description = useMemo(() => { // Build a readable text from lyrics with section labels if (Array.isArray(project?.lyrics)) { + const counts = {}; return project.lyrics .map((s) => { const body = (s?.lyrics || "").trim(); if (!body) return null; - const t = (s?.type || "").toLowerCase(); - const label = t === "refrain" ? "Refrain" : "Couplet"; + const type = normalizeStructureType(s?.type); + counts[type] = (counts[type] || 0) + 1; + const label = formatStructureLabel(type, counts[type]); return `[${label}]\n${body}`; }) .filter(Boolean) @@ -370,22 +377,41 @@ const MusicDetails = ({ route }) => { if (!match) return null; const label = match[1]?.trim() || ""; const lower = label.toLowerCase(); + let type = "section"; if (lower.includes("refrain") || lower.includes("chorus")) type = "refrain"; else if (lower.includes("couplet") || lower.includes("verse")) type = "couplet"; + else if ( + lower.includes("pré") || + lower.includes("prechorus") || + lower.includes("pre-chorus") || + lower.includes("pre chorus") || + lower.includes("pre-refrain") + ) + type = "pre_chorus"; else if (lower.includes("bridge")) type = "bridge"; - else if (lower.includes("intro")) type = "intro"; + else if (lower.includes("intro")) { + if (lower.includes("long")) type = "long_intro"; + else type = "short_intro"; + } const indexMatch = label.match(/(\d+)/); const index = indexMatch ? Number(indexMatch[1]) : undefined; return { label, type, index }; }; const formatSectionLabel = (type, index, fallback) => { if (fallback) return fallback; - if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain"; - if (type === "couplet") return index > 1 ? `Couplet ${index}` : "Couplet"; + const normalized = normalizeStructureType(type); + const meta = getSegmentMeta(normalized); + if (meta) { + return formatStructureLabel(normalized, index); + } + if (normalized === "refrain") + return index > 1 ? `Refrain ${index}` : "Refrain"; + if (normalized === "couplet") + return index > 1 ? `Couplet ${index}` : "Couplet"; if (type === "bridge") return index > 1 ? `Pont ${index}` : "Pont"; - if (type === "intro") return "Intro"; + if (type === "intro") return index > 1 ? `Intro ${index}` : "Intro"; return index > 1 ? `Section ${index}` : "Section"; }; diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index e405002..db8a8cf 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -10,6 +10,7 @@ import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; +import { normalizeStructureType } from "../../utils/songStructure"; import ChooseGenre from "./ChooseGenre"; import ChooseInstruments from "./ChooseInstruments"; import ChooseRhythm from "./ChooseRhythm"; @@ -51,7 +52,7 @@ const ComposeSong = () => { let lyricsArr = []; if (Array.isArray(selectedProject?.lyrics)) { lyricsArr = selectedProject.lyrics.map((s) => ({ - type: (s?.type || "").toLowerCase(), + type: normalizeStructureType(s?.type), lyrics: s?.lyrics || "", })); } else { diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index 1e0fc91..794c4e2 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -15,6 +15,7 @@ import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; +import { normalizeStructureType } from "../../utils/songStructure"; import ChooseGenre from "./ChooseGenre"; import ChooseInstruments from "./ChooseInstruments"; import ChooseRhythm from "./ChooseRhythm"; @@ -54,7 +55,7 @@ const ComposeSong = () => { let lyricsArr = []; if (Array.isArray(selectedProject?.lyrics)) { lyricsArr = selectedProject.lyrics.map((s) => ({ - type: (s?.type || "").toLowerCase(), + type: normalizeStructureType(s?.type), lyrics: s?.lyrics || "", })); } else { diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js index ee8e924..aa6f20b 100644 --- a/src/screens/Writing/CreateLyricsWithAi.js +++ b/src/screens/Writing/CreateLyricsWithAi.js @@ -12,6 +12,7 @@ import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; +import { sanitizeStructureList } from "../../utils/songStructure"; import CreatingLyrics from "./CreatingLyrics"; import CustomizeSongStructure from "./CustomizeSongStructure"; import EmotionConvey from "./EmotionConvey"; @@ -140,11 +141,24 @@ const CreateLyricsWithAi = () => { // Structure personnalisée: prioriser selections.customStructure puis config.structure const savedCustom = Array.isArray(sel.customStructure) - ? sel.customStructure + ? sanitizeStructureList(sel.customStructure) : null; - const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null; - if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) { - setCustomStructure(savedCustom || cfgStructure); + const cfgStructure = Array.isArray(cfg.structure) + ? sanitizeStructureList(cfg.structure) + : null; + const fallbackStructure = + savedCustom && savedCustom.length ? savedCustom : cfgStructure; + if (!Array.isArray(customStructure) && fallbackStructure?.length) { + setCustomStructure(fallbackStructure); + } else if ( + Array.isArray(customStructure) && + fallbackStructure?.length && + (customStructure.length !== fallbackStructure.length || + customStructure.some( + (value, idx) => value !== fallbackStructure[idx] + )) + ) { + setCustomStructure(fallbackStructure); } }, [selectedProject]); @@ -167,7 +181,8 @@ const CreateLyricsWithAi = () => { for (let i = 0; i < count; i++) result.push("refrain"); } }); - return result.length ? result : null; + const sanitizedResult = sanitizeStructureList(result); + return sanitizedResult.length ? sanitizedResult : null; } catch (e) { return null; } @@ -187,6 +202,13 @@ const CreateLyricsWithAi = () => { const resolvedStyle = shouldUseOtherStyle ? persistedOtherStyle || undefined : style || undefined; + const sanitizedCustom = + Array.isArray(customStructure) && customStructure.length > 0 + ? sanitizeStructureList(customStructure) + : []; + const sanitizedParsed = Array.isArray(parsedStructure) + ? sanitizeStructureList(parsedStructure) + : []; return { objective: resolvedObjective, @@ -198,9 +220,11 @@ const CreateLyricsWithAi = () => { style: resolvedStyle, audience: audience?.trim() ? audience.trim() : undefined, structure: - (Array.isArray(customStructure) && customStructure.length > 0 - ? customStructure - : parsedStructure) || undefined, + sanitizedCustom.length > 0 + ? sanitizedCustom + : sanitizedParsed.length > 0 + ? sanitizedParsed + : undefined, rhymes: rhymes || undefined, }; }, [ @@ -276,12 +300,13 @@ const CreateLyricsWithAi = () => { // Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6), // sauter Rhymes et CreatingLyrics et aller directement sur Lyrics if (hasLyrics && selectedIndex === 6) { - const chosenStructure = + const chosenStructure = sanitizeStructureList( Array.isArray(customStructure) && customStructure.length > 0 ? customStructure : Array.isArray(parsedStructure) ? parsedStructure - : []; + : [] + ); await updateProjectData({ config: { structure: chosenStructure }, selections: { @@ -294,7 +319,7 @@ const CreateLyricsWithAi = () => { audience, structure, parsedStructure, - customStructure, + customStructure: sanitizeStructureList(customStructure), rhymes, }, hasLyrics: true, diff --git a/src/screens/Writing/CreateLyricsWithAi.web.js b/src/screens/Writing/CreateLyricsWithAi.web.js index 46bcffd..743de56 100644 --- a/src/screens/Writing/CreateLyricsWithAi.web.js +++ b/src/screens/Writing/CreateLyricsWithAi.web.js @@ -10,6 +10,7 @@ import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { gutters } from "../../styles"; +import { sanitizeStructureList } from "../../utils/songStructure"; import CreatingLyrics from "./CreatingLyrics"; import CustomizeSongStructure from "./CustomizeSongStructure"; import EmotionConvey from "./EmotionConvey"; @@ -138,11 +139,24 @@ const CreateLyricsWithAi = () => { // Structure personnalisée: prioriser selections.customStructure puis config.structure const savedCustom = Array.isArray(sel.customStructure) - ? sel.customStructure + ? sanitizeStructureList(sel.customStructure) : null; - const cfgStructure = Array.isArray(cfg.structure) ? cfg.structure : null; - if (!Array.isArray(customStructure) && (savedCustom || cfgStructure)) { - setCustomStructure(savedCustom || cfgStructure); + const cfgStructure = Array.isArray(cfg.structure) + ? sanitizeStructureList(cfg.structure) + : null; + const fallbackStructure = + savedCustom && savedCustom.length ? savedCustom : cfgStructure; + if (!Array.isArray(customStructure) && fallbackStructure?.length) { + setCustomStructure(fallbackStructure); + } else if ( + Array.isArray(customStructure) && + fallbackStructure?.length && + (customStructure.length !== fallbackStructure.length || + customStructure.some( + (value, idx) => value !== fallbackStructure[idx] + )) + ) { + setCustomStructure(fallbackStructure); } }, [selectedProject]); @@ -172,7 +186,8 @@ const CreateLyricsWithAi = () => { for (let i = 0; i < count; i++) result.push("refrain"); } }); - return result.length ? result : null; + const sanitizedResult = sanitizeStructureList(result); + return sanitizedResult.length ? sanitizedResult : null; } catch (e) { return null; } @@ -192,6 +207,13 @@ const CreateLyricsWithAi = () => { const resolvedStyle = shouldUseOtherStyle ? persistedOtherStyle || undefined : style || undefined; + const sanitizedCustom = + Array.isArray(customStructure) && customStructure.length > 0 + ? sanitizeStructureList(customStructure) + : []; + const sanitizedParsed = Array.isArray(parsedStructure) + ? sanitizeStructureList(parsedStructure) + : []; return { objective: resolvedObjective, @@ -203,9 +225,11 @@ const CreateLyricsWithAi = () => { style: resolvedStyle, audience: audience?.trim() ? audience.trim() : undefined, structure: - (Array.isArray(customStructure) && customStructure.length > 0 - ? customStructure - : parsedStructure) || undefined, + sanitizedCustom.length > 0 + ? sanitizedCustom + : sanitizedParsed.length > 0 + ? sanitizedParsed + : undefined, rhymes: rhymes || undefined, }; }, [ @@ -281,12 +305,13 @@ const CreateLyricsWithAi = () => { // Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6), // sauter Rhymes et CreatingLyrics et aller directement sur Lyrics if (hasLyrics && selectedIndex === 6) { - const chosenStructure = + const chosenStructure = sanitizeStructureList( Array.isArray(customStructure) && customStructure.length > 0 ? customStructure : Array.isArray(parsedStructure) ? parsedStructure - : []; + : [] + ); await updateProjectData({ config: { structure: chosenStructure }, selections: { @@ -299,7 +324,7 @@ const CreateLyricsWithAi = () => { audience, structure, parsedStructure, - customStructure, + customStructure: sanitizeStructureList(customStructure), rhymes, }, hasLyrics: true, diff --git a/src/screens/Writing/CreatingLyrics.js b/src/screens/Writing/CreatingLyrics.js index 0fad850..c902994 100644 --- a/src/screens/Writing/CreatingLyrics.js +++ b/src/screens/Writing/CreatingLyrics.js @@ -12,6 +12,7 @@ import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { Palette, Style } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { sanitizeStructureList } from "../../utils/songStructure"; const CreatingLyrics = ({ active, config, selections }) => { const [progress, setProgress] = useState(0); @@ -58,6 +59,7 @@ const CreatingLyrics = ({ active, config, selections }) => { console.log("🚀 [CreatingLyrics] Lancement de la génération", { platform: Platform.OS, }); + const sanitizedStructure = sanitizeStructureList(config?.structure); const callable = firebase .functions() .httpsCallable("lyrics-generateLyrics"); @@ -67,10 +69,32 @@ const CreatingLyrics = ({ active, config, selections }) => { emotion: config?.emotion, style: config?.style, audience: config?.audience, - structure: config?.structure, + structure: sanitizedStructure, rhymes: config?.rhymes, }); - setResult(data); + const rawLyrics = Array.isArray(data?.lyrics) ? data.lyrics : []; + const normalizedLyrics = rawLyrics.map((section) => { + const sanitizedType = + sanitizeStructureList([section?.type])[0] || section?.type || ""; + return { + ...section, + type: sanitizedType, + }; + }); + const alignedLyrics = + sanitizedStructure.length > 0 && + sanitizedStructure.length === normalizedLyrics.length + ? normalizedLyrics.map((section, index) => ({ + ...section, + type: sanitizedStructure[index], + })) + : normalizedLyrics; + const processedResult = { + ...data, + lyrics: alignedLyrics, + structure: sanitizedStructure, + }; + setResult(processedResult); setProgress(100); console.log("✨ [CreatingLyrics] Génération réussie"); } catch (e) { @@ -106,12 +130,44 @@ const CreatingLyrics = ({ active, config, selections }) => { setSaved(true); await setIsLoading(true); console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles"); + const sanitizedStructure = sanitizeStructureList( + result?.structure || config?.structure + ); + const baseConfig = + config && typeof config === "object" && !Array.isArray(config) + ? { ...config } + : {}; + if (sanitizedStructure.length > 0) { + baseConfig.structure = sanitizedStructure; + } else if (Object.prototype.hasOwnProperty.call(baseConfig, "structure")) { + delete baseConfig.structure; + } + const persistedConfig = + Object.keys(baseConfig).length > 0 ? baseConfig : null; + + let persistedSelections = + selections && typeof selections === "object" && !Array.isArray(selections) + ? { ...selections } + : null; + if (persistedSelections) { + if ("customStructure" in persistedSelections) { + persistedSelections.customStructure = sanitizeStructureList( + persistedSelections.customStructure + ); + } + if ("parsedStructure" in persistedSelections) { + persistedSelections.parsedStructure = sanitizeStructureList( + persistedSelections.parsedStructure + ); + } + } + await updateProjectData({ title: result?.title || "", lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [], description: result?.lyricsDescription, - config: config || null, - selections: selections || null, + config: persistedConfig, + selections: persistedSelections, hasLyrics: false, }); navigate(Routes.Lyrics); diff --git a/src/screens/Writing/CustomizeSongStructure.js b/src/screens/Writing/CustomizeSongStructure.js index e1e33a2..104e1ca 100644 --- a/src/screens/Writing/CustomizeSongStructure.js +++ b/src/screens/Writing/CustomizeSongStructure.js @@ -1,103 +1,165 @@ import { BlurView } from "expo-blur"; -import React, { useEffect, useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { FlatList, Image, Platform, StyleSheet, Text, + TouchableOpacity, View, } from "react-native"; import Animated, { useAnimatedRef } from "react-native-reanimated"; import Sortable from "react-native-sortables"; import { icons } from "../../assets"; -import { Palette, Style } from "../../styles"; +import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { + formatStructureLabel, + getSegmentMeta, + OPTIONAL_STRUCTURE_SEGMENTS, + sanitizeStructureList, +} from "../../utils/songStructure"; import CreateLyricsHeader from "./components/CreateLyricsHeader"; -// baseStructure: array like ['couplet','refrain',...] -// onChange: callback that receives array like ['couplet','refrain',...] +const randomSuffix = () => Math.random().toString(36).slice(2, 8); +const createItemId = (type) => `${type}-${Date.now()}-${randomSuffix()}`; +const createSortableItem = (type, index = 0) => ({ + id: `${type}-${index}-${randomSuffix()}`, + type, + value: type, +}); + +const buildInitialItems = (structure = []) => + sanitizeStructureList(structure).map((type, index) => + createSortableItem(type, index) + ); + const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const scrollRef = useAnimatedRef(); + const sanitizedBase = useMemo( + () => sanitizeStructureList(baseStructure), + [baseStructure] + ); + const [sortableItems, setSortableItems] = useState(() => + buildInitialItems(sanitizedBase) + ); - // Build stable keyed data like { 'couplet-1': { label, value }, ... } - const data = useMemo(() => { - const counters = { couplet: 0, refrain: 0 }; - const out = {}; - (baseStructure || []).forEach((type) => { - const key = (type || "").toLowerCase(); - counters[key] = (counters[key] || 0) + 1; - const idx = counters[key]; - const id = `${key}-${idx}`; - out[id] = { - id, - label: `${key === "couplet" ? "Couplet" : "Refrain"} ${idx}`, - value: key, + useEffect(() => { + setSortableItems((prev) => { + const prevTypes = prev.map((item) => item.type); + if ( + prevTypes.length === sanitizedBase.length && + prevTypes.every((type, index) => type === sanitizedBase[index]) + ) { + return prev; + } + return buildInitialItems(sanitizedBase); + }); + }, [sanitizedBase]); + + useEffect(() => { + const values = sortableItems.map((item) => item.type).filter(Boolean); + onChange?.(values); + }, [sortableItems, onChange]); + + const labeledItems = useMemo(() => { + const counts = {}; + return sortableItems.map((item) => { + const baseType = item.type || item.value; + const nextCount = (counts[baseType] || 0) + 1; + counts[baseType] = nextCount; + return { + ...item, + type: baseType, + value: baseType, + label: formatStructureLabel(baseType, nextCount), + sortable: true, }; }); - return out; - }, [baseStructure]); + }, [sortableItems]); - const items = useMemo(() => Object.values(data), [data]); + const optionalSegments = useMemo( + () => + OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { + const meta = getSegmentMeta(type); + return { type, label: meta?.label || formatStructureLabel(type, 1) }; + }), + [] + ); - const newItems = [ - { - id: "short_intro", - label: "Introduction instrumentale courte", - value: "short_intro", - }, - { - id: "long_intro", - label: "Introduction instrumentale longue", - value: "long_intro", - }, - { - id: "pre_chorus", - label: "Pré-refrain", - value: "pre_chorus", - }, - ]; - // Initialize parent with current order (so it's saved even without drag) - useEffect(() => { - const initialValues = (items || []).map((it) => it?.value).filter(Boolean); - onChange?.(initialValues); - // We only want to run when baseStructure-derived items change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items.length]); + const selectedTypes = useMemo(() => { + const set = new Set(); + sortableItems.forEach((item) => { + if (item?.type) set.add(item.type); + else if (item?.value) set.add(item.value); + }); + return set; + }, [sortableItems]); - const Row = ({ item: rowData }) => ( - - { + setSortableItems((prev) => { + const existsIndex = prev.findIndex((item) => item.type === type); + if (existsIndex !== -1) { + return prev.filter((item) => item.type !== type); + } + const meta = getSegmentMeta(type); + let next = [...prev]; + if (meta?.exclusiveGroup) { + next = next.filter((item) => { + const itemMeta = getSegmentMeta(item.type || item.value); + return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; + }); + } + next.push(createSortableItem(type, next.length)); + return next; + }); + }; + + const Row = ({ item: rowData, onPress }) => { + const isButton = typeof onPress === "function"; + const Wrapper = isButton ? TouchableOpacity : View; + const selected = rowData?.selected; + const icon = rowData?.icon; + return ( + - - - - - - - - - - - - - + + + {rowData?.label || ""} - - {/* Render handle only for items that come from the sortable data (have an id like 'couplet-1') */} - {String(rowData?.id || "").includes("-") ? ( - - - - ) : null} + {rowData?.sortable ? ( + + + + ) : ( + + )} + - - - ); + + ); + }; return ( @@ -115,29 +177,42 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { } customHandle - // Enable auto-scroll when dragging near edges scrollableRef={scrollRef} onDragEnd={({ data: newData }) => { - const values = (newData || []) - .map((it) => it?.value) - .filter(Boolean); - onChange?.(values); + const next = (newData || []).map((it, index) => { + const nextType = it?.type || it?.value; + return { + id: it?.id || createItemId(nextType || `section-${index}`), + type: nextType, + value: nextType, + }; + }); + setSortableItems(next.filter((item) => item.type)); }} /> ( - - - - )} - keyExtractor={(item) => item.id} + renderItem={({ item }) => { + const selected = selectedTypes.has(item.type); + return ( + + toggleOptionalSegment(item.type)} + /> + + ); + }} + keyExtractor={(item) => item.type} contentContainerStyle={{ paddingBottom: 24 }} - // Prevent FlatList from capturing scroll gestures so Sortable can handle drags scrollEnabled={false} nestedScrollEnabled={false} /> @@ -151,6 +226,9 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { export default CustomizeSongStructure; const styles = StyleSheet.create({ + touchable: { + width: "100%", + }, itemContainer: { height: 54, backgroundColor: Palette.glass, @@ -165,13 +243,32 @@ const styles = StyleSheet.create({ shadowRadius: 3.84, elevation: 5, }, + sortableItem: { + marginBottom: 0, + }, + selectedItem: { + borderWidth: 1, + borderColor: Palette.white, + }, rowBlur: { flex: 1, backgroundColor: Palette.glass, justifyContent: "center", paddingHorizontal: 12, + flexDirection: "row", + alignItems: "center", height: "100%", }, + buttonBlur: { + justifyContent: "space-between", + }, + selectedBlur: { + backgroundColor: Palette.transparentWhite, + }, + rowContent: { + flex: 1, + justifyContent: "center", + }, rowText: { fontSize: 16, color: Palette.white, @@ -182,4 +279,9 @@ const styles = StyleSheet.create({ height: 22, tintColor: Palette.white, }, + optionIcon: { + width: 20, + height: 20, + tintColor: Palette.white, + }, }); diff --git a/src/screens/Writing/CustomizeSongStructure.web.js b/src/screens/Writing/CustomizeSongStructure.web.js index b70df4f..20b0601 100644 --- a/src/screens/Writing/CustomizeSongStructure.web.js +++ b/src/screens/Writing/CustomizeSongStructure.web.js @@ -1,30 +1,30 @@ import React from "react"; -import { Image, StyleSheet, Text, View } from "react-native"; +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { icons } from "../../assets"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { + formatStructureLabel, + getSegmentMeta, + OPTIONAL_STRUCTURE_SEGMENTS, + sanitizeStructureList, +} from "../../utils/songStructure"; import CreateLyricsHeader from "./components/CreateLyricsHeader"; const ROW_HEIGHT = 54; -const VALID_SEGMENTS = ["couplet", "refrain"]; const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0); -const sanitizeStructure = (structure = []) => - structure - .map((segment) => `${segment}`.trim().toLowerCase()) - .filter((segment) => VALID_SEGMENTS.includes(segment)); +const randomSuffix = () => Math.random().toString(36).slice(2, 8); const buildItems = (structure = []) => { - const counters = { couplet: 0, refrain: 0 }; + const counters = {}; return structure.map((segment, index) => { - counters[segment] += 1; + counters[segment] = (counters[segment] || 0) + 1; return { - id: `${segment}-${index}-${counters[segment]}`, + id: `${segment}-${index}-${randomSuffix()}`, + type: segment, value: segment, - label: `${segment === "couplet" ? "Couplet" : "Refrain"} ${ - counters[segment] - }`, }; }); }; @@ -41,7 +41,7 @@ const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const sanitized = React.useMemo( - () => sanitizeStructure(baseStructure), + () => sanitizeStructureList(baseStructure), [baseStructure] ); const [items, setItems] = React.useState(() => buildItems(sanitized)); @@ -55,11 +55,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { React.useEffect(() => { console.debug("[CustomizeSongStructure.web] sanitized", sanitized); setItems((prev) => { - if ( - prev.length === sanitized.length && - prev.every((item, idx) => item.value === sanitized[idx]) - ) { - return prev; + const prevTypes = prev.map((item) => item.type); + if (prevTypes.length === sanitized.length) { + const same = prevTypes.every((type, idx) => type === sanitized[idx]); + if (same) return prev; } return buildItems(sanitized); }); @@ -69,11 +68,59 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { React.useEffect(() => { console.debug("[CustomizeSongStructure.web] items", items); - onChange?.(items.map((item) => item.value)); + onChange?.(items.map((item) => item.type)); }, [items, onChange]); + const labeledItems = React.useMemo(() => { + const counts = {}; + return items.map((item) => { + counts[item.type] = (counts[item.type] || 0) + 1; + return { + ...item, + label: formatStructureLabel(item.type, counts[item.type]), + }; + }); + }, [items]); + + const selectedTypes = React.useMemo( + () => new Set(items.map((item) => item.type)), + [items] + ); + + const optionalSegments = React.useMemo( + () => + OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { + const meta = getSegmentMeta(type); + return { type, label: meta?.label }; + }), + [] + ); + + const toggleOptionalSegment = React.useCallback((type) => { + setItems((prev) => { + const exists = prev.some((item) => item.type === type); + if (exists) { + return prev.filter((item) => item.type !== type); + } + const meta = getSegmentMeta(type); + let next = [...prev]; + if (meta?.exclusiveGroup) { + next = next.filter((item) => { + const itemMeta = getSegmentMeta(item.type); + return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; + }); + } + next.push({ + id: `${type}-${Date.now()}-${randomSuffix()}`, + type, + value: type, + }); + return next; + }); + }, []); + const handleGrant = (index, event) => { - const item = items[index]; + const item = labeledItems[index]; if (!item) return; const rect = containerRef.current?.getBoundingClientRect(); const scrollY = getScrollY(); @@ -132,13 +179,32 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { } }; - if (!items.length) { + if (!labeledItems.length) { return ( + + {optionalSegments.map((segment) => { + const selected = selectedTypes.has(segment.type); + return ( + toggleOptionalSegment(segment.type)} + style={[styles.optionItem, selected && styles.selectedItem]} + > + {segment.label} + + + ); + })} + ); } @@ -150,7 +216,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { subTitle="Glisse-dépose pour réorganiser" /> - {items.map((item, index) => ( + {labeledItems.map((item, index) => ( { ))} + + {optionalSegments.map((segment) => { + const selected = selectedTypes.has(segment.type); + return ( + toggleOptionalSegment(segment.type)} + style={[styles.optionItem, selected && styles.selectedItem]} + > + {segment.label} + + + ); + })} + ); }; @@ -214,4 +302,38 @@ const styles = StyleSheet.create({ tintColor: Palette.white, cursor: "grab", }, + optionsContainer: { + marginTop: 16, + gap: 12, + }, + optionItem: { + height: ROW_HEIGHT, + borderRadius: 14, + backgroundColor: Palette.glass, + shadowColor: "#00000040", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + overflow: "hidden", + paddingHorizontal: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + optionText: { + fontSize: 16, + color: Palette.white, + fontFamily: FONT_FAMILY.InterRegular, + }, + optionIcon: { + width: 20, + height: 20, + tintColor: Palette.white, + }, + + selectedItem: { + borderWidth: 1, + borderColor: Palette.white, + }, }); diff --git a/src/screens/Writing/Lyrics.js b/src/screens/Writing/Lyrics.js index 7c50abb..1ae48b0 100644 --- a/src/screens/Writing/Lyrics.js +++ b/src/screens/Writing/Lyrics.js @@ -15,6 +15,12 @@ import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { Palette, gutters } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { + formatStructureLabel, + getSegmentMeta, + normalizeStructureType, + sanitizeStructureList, +} from "../../utils/songStructure"; import CustomInput from "./components/CustomInput"; const Lyrics = ({ navigation }) => { @@ -36,26 +42,23 @@ const Lyrics = ({ navigation }) => { const initial = useMemo(() => { const title = projectTitle || ""; - const aiSections = Array.isArray(projectLyrics) ? projectLyrics : []; - // Respecter l'ordre de la structure choisie si disponible + const normalizedSections = Array.isArray(projectLyrics) + ? projectLyrics.map((s) => ({ + type: normalizeStructureType(s?.type), + lyrics: s?.lyrics || "", + })) + : []; const targetStructure = Array.isArray(projectConfig?.structure) - ? projectConfig.structure.map((t) => (t || "").toLowerCase()) + ? sanitizeStructureList(projectConfig.structure) : null; - // 1) Si des paroles existent déjà, les utiliser en priorité - if (aiSections.length) { - // Optionnel: si une structure cible de même longueur existe, garder l'ordre courant - // et harmoniser les types en minuscule. + if (normalizedSections.length) { return { title, - sections: aiSections.map((s) => ({ - type: (s?.type || "").toLowerCase(), - lyrics: s?.lyrics || "", - })), + sections: normalizedSections, }; } - // 2) Sinon, créer à partir de la structure si fournie if (targetStructure && targetStructure.length) { return { title, @@ -63,7 +66,6 @@ const Lyrics = ({ navigation }) => { }; } - // 3) Fallback vide return { title, sections: [] }; }, [projectTitle, projectLyrics, projectConfig]); @@ -133,17 +135,17 @@ const Lyrics = ({ navigation }) => { if (invalid) { alertMessage( "Champs incomplets", - "Chaque couplet et refrain doit contenir du texte." + "Chaque section doit contenir du texte." ); return; } const normalizedNewLyrics = (sections || []).map((s) => ({ - type: (s?.type || "").toLowerCase(), + type: normalizeStructureType(s?.type), lyrics: (s?.lyrics || "").trim(), })); const normalizedOldLyrics = Array.isArray(projectLyrics) ? projectLyrics.map((s) => ({ - type: (s?.type || "").toLowerCase(), + type: normalizeStructureType(s?.type), lyrics: (s?.lyrics || "").trim(), })) : []; @@ -264,6 +266,8 @@ const Lyrics = ({ navigation }) => { projectLyrics, ]); + const typeOccurrences = {}; + return ( { }} /> {sections.map((s, idx) => { - // Calculer l'index humain par type - const type = (s?.type || "").toLowerCase(); - const countBefore = sections - .slice(0, idx) - .filter((x) => (x?.type || "").toLowerCase() === type).length; - const labelBase = type === "refrain" ? "Refrain" : "Couplet"; - const label = `${labelBase} ${countBefore + 1}`; + 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; return ( setSectionAt(idx, val)} onFocus={() => { diff --git a/src/utils/songStructure.js b/src/utils/songStructure.js new file mode 100644 index 0000000..935da45 --- /dev/null +++ b/src/utils/songStructure.js @@ -0,0 +1,191 @@ +const START_CASE_REGEX = /[_\-\s]+/g; + +const toStartCase = (value) => { + const str = String(value || "") + .replace(START_CASE_REGEX, " ") + .trim(); + if (!str) return ""; + return str + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +export const STRUCTURE_SEGMENTS = { + couplet: { + label: "Couplet", + promptLabel: "Couplet", + showIndex: true, + allowMultiple: true, + optional: false, + inputHeight: 225, + aliases: ["vers", "verse", "couplet"], + }, + refrain: { + label: "Refrain", + promptLabel: "Refrain", + showIndex: true, + allowMultiple: true, + optional: false, + inputHeight: 170, + aliases: ["chorus", "refrain"], + }, + short_intro: { + label: "Introduction instrumentale courte", + promptLabel: "Introduction instrumentale courte", + showIndex: false, + allowMultiple: false, + optional: true, + inputHeight: 150, + exclusiveGroup: "intro", + aliases: [ + "introduction instrumentale courte", + "intro courte", + "intro instrumentale courte", + "short intro", + ], + }, + long_intro: { + label: "Introduction instrumentale longue", + promptLabel: "Introduction instrumentale longue", + showIndex: false, + allowMultiple: false, + optional: true, + inputHeight: 150, + exclusiveGroup: "intro", + aliases: [ + "introduction instrumentale longue", + "intro longue", + "long intro", + "intro instrumentale longue", + ], + }, + pre_chorus: { + label: "Pré-refrain", + promptLabel: "Pré-refrain", + showIndex: false, + allowMultiple: false, + optional: true, + inputHeight: 150, + aliases: [ + "pré-refrain", + "pre-refrain", + "prechorus", + "pre chorus", + "pré chorus", + ], + }, +}; + +const ALIAS_TO_KEY = Object.entries(STRUCTURE_SEGMENTS).reduce( + (acc, [key, meta]) => { + const aliases = Array.isArray(meta.aliases) ? meta.aliases : []; + aliases.forEach((alias) => { + acc[String(alias).toLowerCase()] = key; + }); + return acc; + }, + {} +); + +const sanitizeToken = (value) => + String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +export const getSegmentMeta = (type) => { + const key = String(type || "").toLowerCase(); + return STRUCTURE_SEGMENTS[key] || null; +}; + +export const OPTIONAL_STRUCTURE_SEGMENTS = Object.keys( + STRUCTURE_SEGMENTS +).filter((key) => STRUCTURE_SEGMENTS[key]?.optional); + +export const normalizeStructureType = (value) => { + const raw = String(value || "").trim(); + if (!raw) return ""; + const lower = raw.toLowerCase(); + if (STRUCTURE_SEGMENTS[lower]) return lower; + if (ALIAS_TO_KEY[lower]) return ALIAS_TO_KEY[lower]; + + const sanitized = sanitizeToken(raw); + if (STRUCTURE_SEGMENTS[sanitized]) return sanitized; + if (ALIAS_TO_KEY[sanitized]) return ALIAS_TO_KEY[sanitized]; + return sanitized; +}; + +export const sanitizeStructureList = (structure = []) => { + if (!Array.isArray(structure)) return []; + const output = []; + + structure.forEach((segment) => { + const type = normalizeStructureType(segment); + if (!type) return; + const meta = getSegmentMeta(type); + + if (meta) { + if (meta.allowMultiple === false) { + const alreadyIncluded = output.some((item) => item === type); + if (alreadyIncluded) return; + } + + if (meta.exclusiveGroup) { + const previousIndex = output.findIndex((item) => { + const existingMeta = getSegmentMeta(item); + return existingMeta?.exclusiveGroup === meta.exclusiveGroup; + }); + if (previousIndex !== -1) { + output.splice(previousIndex, 1); + } + } + } + + output.push(type); + }); + + return output; +}; + +export const formatStructureLabel = (type, occurrence = 1) => { + const normalized = normalizeStructureType(type); + const meta = getSegmentMeta(normalized); + const baseLabel = meta?.label || toStartCase(normalized || type); + const showIndex = meta?.showIndex === true; + if (showIndex) return `${baseLabel} ${occurrence}`; + if (!meta && occurrence > 1) return `${baseLabel} ${occurrence}`; + return baseLabel; +}; + +export const getStructureInputHeight = (type) => { + const normalized = normalizeStructureType(type); + const meta = getSegmentMeta(normalized); + return typeof meta?.inputHeight === "number" ? meta.inputHeight : undefined; +}; + +export const getPromptLabelForStructure = (type) => { + const normalized = normalizeStructureType(type); + const meta = getSegmentMeta(normalized); + if (meta?.promptLabel) return meta.promptLabel; + if (meta?.label) return meta.label; + return toStartCase(normalized || type); +}; + +export const isOptionalStructureType = (type) => { + const normalized = normalizeStructureType(type); + const meta = getSegmentMeta(normalized); + return !!meta?.optional; +}; + +export const arraysAreSame = (a = [], b = []) => { + if (a === b) return true; + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +};