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 ` `;
@@ -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;
+};