new song structure
This commit is contained in:
+80
-7
@@ -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 = ({
|
||||
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
|
||||
];
|
||||
|
||||
if (Array.isArray(structure) && structure.length) {
|
||||
sections.push(`<STRUCTURE>${structure.join(" | ")}</STRUCTURE>`);
|
||||
const promptStructure = mapStructureToPrompt(structure);
|
||||
if (promptStructure.length) {
|
||||
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
|
||||
}
|
||||
|
||||
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
|
||||
@@ -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 ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
|
||||
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) => (
|
||||
<View style={styles.itemContainer}>
|
||||
<BlurView
|
||||
intensity={Platform.OS !== "ios" ? 10 : 30}
|
||||
style={styles.rowBlur}
|
||||
// experimentalBlurMethod={Platform.OS !== "ios" ? "dimezisBlurView" : "none"}
|
||||
const toggleOptionalSegment = (type) => {
|
||||
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 (
|
||||
<Wrapper
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
style={isButton ? styles.touchable : undefined}
|
||||
>
|
||||
<View style={{ ...Style.containerSpaceBetween, alignItems: "center" }}>
|
||||
<View>
|
||||
<View>
|
||||
<View>
|
||||
<View />
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<View />
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||
<View>
|
||||
<View
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
selected && styles.selectedItem,
|
||||
rowData?.sortable && styles.sortableItem,
|
||||
]}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS !== "ios" ? 10 : 30}
|
||||
style={[
|
||||
styles.rowBlur,
|
||||
selected && styles.selectedBlur,
|
||||
isButton && styles.buttonBlur,
|
||||
]}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowText}>{rowData?.label || ""}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Render handle only for items that come from the sortable data (have an id like 'couplet-1') */}
|
||||
{String(rowData?.id || "").includes("-") ? (
|
||||
<Sortable.Handle>
|
||||
<Image source={icons.dragDots} style={styles.handleIcon} />
|
||||
</Sortable.Handle>
|
||||
) : null}
|
||||
{rowData?.sortable ? (
|
||||
<Sortable.Handle>
|
||||
<Image source={icons.dragDots} style={styles.handleIcon} />
|
||||
</Sortable.Handle>
|
||||
) : (
|
||||
<Image
|
||||
source={icon || (selected ? icons.check : icons.add)}
|
||||
style={[styles.optionIcon]}
|
||||
/>
|
||||
)}
|
||||
</BlurView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
||||
@@ -115,29 +177,42 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
rowGap={12}
|
||||
data={items}
|
||||
data={labeledItems}
|
||||
renderItem={({ item }) => <Row item={item} />}
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
<FlatList
|
||||
data={newItems}
|
||||
data={optionalSegments}
|
||||
gap={12}
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Row item={item} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const selected = selectedTypes.has(item.type);
|
||||
return (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Row
|
||||
item={{
|
||||
...item,
|
||||
sortable: false,
|
||||
selected,
|
||||
}}
|
||||
onPress={() => toggleOptionalSegment(item.type)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
||||
<CreateLyricsHeader
|
||||
title="Personnalise la structure de ta chanson"
|
||||
subTitle="Sélectionne une structure pour commencer"
|
||||
/>
|
||||
<View style={styles.optionsContainer}>
|
||||
{optionalSegments.map((segment) => {
|
||||
const selected = selectedTypes.has(segment.type);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={segment.type}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => toggleOptionalSegment(segment.type)}
|
||||
style={[styles.optionItem, selected && styles.selectedItem]}
|
||||
>
|
||||
<Text style={styles.optionText}>{segment.label}</Text>
|
||||
<Image
|
||||
source={selected ? icons.check : icons.add}
|
||||
style={[styles.optionIcon]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +216,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
subTitle="Glisse-dépose pour réorganiser"
|
||||
/>
|
||||
<View style={styles.listContainer} ref={containerRef}>
|
||||
{items.map((item, index) => (
|
||||
{labeledItems.map((item, index) => (
|
||||
<View
|
||||
key={item.id}
|
||||
style={[
|
||||
@@ -170,6 +236,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.optionsContainer}>
|
||||
{optionalSegments.map((segment) => {
|
||||
const selected = selectedTypes.has(segment.type);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={segment.type}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => toggleOptionalSegment(segment.type)}
|
||||
style={[styles.optionItem, selected && styles.selectedItem]}
|
||||
>
|
||||
<Text style={styles.optionText}>{segment.label}</Text>
|
||||
<Image
|
||||
source={selected ? icons.check : icons.add}
|
||||
style={[
|
||||
styles.optionIcon,
|
||||
selected && styles.optionIconSelected,
|
||||
]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Page
|
||||
backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG}
|
||||
@@ -332,19 +336,25 @@ const Lyrics = ({ navigation }) => {
|
||||
}}
|
||||
/>
|
||||
{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 (
|
||||
<CustomInput
|
||||
key={idx}
|
||||
label={label}
|
||||
placeholder={labelBase}
|
||||
height={type === "refrain" ? 170 : 225}
|
||||
placeholder={placeholder}
|
||||
height={inputHeight}
|
||||
value={s?.lyrics || ""}
|
||||
setValue={(val) => setSectionAt(idx, val)}
|
||||
onFocus={() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user