diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js index c3e0b8b..deda536 100644 --- a/src/screens/Writing/CreateLyricsWithAi.js +++ b/src/screens/Writing/CreateLyricsWithAi.js @@ -23,6 +23,7 @@ import SongTo from "./SongTo"; import SpecificityContext from "./SpecificityContext"; const { width: windowWidth } = Dimensions.get("window"); const MAX_STEP_INDEX = 8; +const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }; const CreateLyricsWithAi = () => { const { isWeb } = useLayoutType(); @@ -140,10 +141,10 @@ const CreateLyricsWithAi = () => { // Structure personnalisée: prioriser selections.customStructure puis config.structure const savedCustom = Array.isArray(sel.customStructure) - ? sanitizeStructureList(sel.customStructure) + ? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS) : null; const cfgStructure = Array.isArray(cfg.structure) - ? sanitizeStructureList(cfg.structure) + ? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS) : null; const fallbackStructure = savedCustom && savedCustom.length ? savedCustom : cfgStructure; @@ -196,7 +197,7 @@ const CreateLyricsWithAi = () => { : style || undefined; const sanitizedCustom = Array.isArray(customStructure) && customStructure.length > 0 - ? sanitizeStructureList(customStructure) + ? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS) : []; const sanitizedParsed = Array.isArray(parsedStructure) ? sanitizeStructureList(parsedStructure) diff --git a/src/screens/Writing/CreateLyricsWithAi.web.js b/src/screens/Writing/CreateLyricsWithAi.web.js index 325aa8c..295f953 100644 --- a/src/screens/Writing/CreateLyricsWithAi.web.js +++ b/src/screens/Writing/CreateLyricsWithAi.web.js @@ -21,6 +21,7 @@ import SongTo from "./SongTo"; import SpecificityContext from "./SpecificityContext"; const MAX_STEP_INDEX = 8; +const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }; const CreateLyricsWithAi = () => { const { selectedProject } = useUser(); @@ -133,10 +134,10 @@ const CreateLyricsWithAi = () => { // Structure personnalisée: prioriser selections.customStructure puis config.structure const savedCustom = Array.isArray(sel.customStructure) - ? sanitizeStructureList(sel.customStructure) + ? sanitizeStructureList(sel.customStructure, CUSTOM_SANITIZE_OPTIONS) : null; const cfgStructure = Array.isArray(cfg.structure) - ? sanitizeStructureList(cfg.structure) + ? sanitizeStructureList(cfg.structure, CUSTOM_SANITIZE_OPTIONS) : null; const fallbackStructure = savedCustom && savedCustom.length ? savedCustom : cfgStructure; @@ -201,7 +202,7 @@ const CreateLyricsWithAi = () => { : style || undefined; const sanitizedCustom = Array.isArray(customStructure) && customStructure.length > 0 - ? sanitizeStructureList(customStructure) + ? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS) : []; const sanitizedParsed = Array.isArray(parsedStructure) ? sanitizeStructureList(parsedStructure) diff --git a/src/screens/Writing/CreatingLyrics.js b/src/screens/Writing/CreatingLyrics.js index 95237d6..f85f0dd 100644 --- a/src/screens/Writing/CreatingLyrics.js +++ b/src/screens/Writing/CreatingLyrics.js @@ -20,6 +20,7 @@ import { const FAKE_PROGRESS_MAX = 96; const PROGRESS_INTERVAL_MS = 250; +const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }; const CreatingLyrics = ({ active, config, selections }) => { const [progress, setProgress] = useState(0); @@ -188,7 +189,8 @@ const CreatingLyrics = ({ active, config, selections }) => { if (persistedSelections) { if ("customStructure" in persistedSelections) { persistedSelections.customStructure = sanitizeStructureList( - persistedSelections.customStructure + persistedSelections.customStructure, + CUSTOM_SANITIZE_OPTIONS ); } if ("parsedStructure" in persistedSelections) { diff --git a/src/screens/Writing/CustomizeSongStructure.js b/src/screens/Writing/CustomizeSongStructure.js index ee5f03b..150c117 100644 --- a/src/screens/Writing/CustomizeSongStructure.js +++ b/src/screens/Writing/CustomizeSongStructure.js @@ -28,16 +28,17 @@ const createSortableItem = (type, index = 0) => ({ type, value: type, }); +const SANITIZE_OPTIONS = { autoInjectPreChorus: false }; const buildSortableItems = (values = []) => - sanitizeStructureList(values).map((type, index) => + sanitizeStructureList(values, SANITIZE_OPTIONS).map((type, index) => createSortableItem(type, index) ); const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const scrollRef = useAnimatedRef(); const sanitizedBase = useMemo( - () => sanitizeStructureList(baseStructure), + () => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS), [baseStructure] ); const extractValues = useCallback( @@ -100,7 +101,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { (itemId) => { setSortableItems((prev) => { const remaining = prev.filter((item) => item.id !== itemId); - const sanitized = sanitizeStructureList(extractValues(remaining)); + const sanitized = sanitizeStructureList( + extractValues(remaining), + SANITIZE_OPTIONS + ); return buildSortableItems(sanitized); }); }, @@ -124,12 +128,12 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const allowsMultiple = meta?.allowMultiple !== false; if (!allowsMultiple && nextValues.includes(type)) { const filtered = nextValues.filter((value) => value !== type); - const sanitized = sanitizeStructureList(filtered); + const sanitized = sanitizeStructureList(filtered, SANITIZE_OPTIONS); return buildSortableItems(sanitized); } nextValues.push(type); - const sanitized = sanitizeStructureList(nextValues); + const sanitized = sanitizeStructureList(nextValues, SANITIZE_OPTIONS); return buildSortableItems(sanitized); }); }, @@ -219,7 +223,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { scrollableRef={scrollRef} onDragEnd={({ data: newData }) => { const values = extractValues(newData); - const sanitized = sanitizeStructureList(values); + const sanitized = sanitizeStructureList( + values, + SANITIZE_OPTIONS + ); setSortableItems(buildSortableItems(sanitized)); }} /> diff --git a/src/screens/Writing/CustomizeSongStructure.web.js b/src/screens/Writing/CustomizeSongStructure.web.js index bdac674..425d542 100644 --- a/src/screens/Writing/CustomizeSongStructure.web.js +++ b/src/screens/Writing/CustomizeSongStructure.web.js @@ -14,7 +14,9 @@ import { TouchableOpacity, View, } from "react-native"; +import { useGlobal } from "reactn"; import { icons } from "../../assets"; +import ItemContainer from "../../components/ItemContainer/ItemContainer"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { @@ -24,10 +26,21 @@ import { OPTIONAL_STRUCTURE_SEGMENTS, sanitizeStructureList, } from "../../utils/songStructure"; -import ItemContainer from "../../components/ItemContainer/ItemContainer"; import CreateLyricsHeader from "./components/CreateLyricsHeader"; const ROW_HEIGHT = 54; +const PRE_REFRAIN_TYPE = "pre_refrain_instrumental"; +const INTRO_TYPES = new Set([ + "short_intro", + "long_intro", + "intro", + "introduction", +]); +const PRE_REFRAIN_ERROR_MESSAGE = + "Impossible de positionner un pré-refrain ici. Place-le juste avant un refrain."; +const INTRO_ERROR_MESSAGE = + "L'introduction musicale doit rester en première position."; +const SANITIZE_OPTIONS = { autoInjectPreChorus: false }; const PALETTE_SEGMENT_OVERRIDES = [ { @@ -56,16 +69,72 @@ const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0); const randomSuffix = () => Math.random().toString(36).slice(2, 8); -const createSortableItem = (type, index = 0) => ({ - id: `${type}-${index}-${randomSuffix()}`, +const createSortableItem = (type, occurrence = 1) => ({ + id: `${type}-${occurrence}-${randomSuffix()}`, type, value: type, + occurrence, }); -const buildSortableItems = (values = []) => - sanitizeStructureList(values).map((type, index) => - createSortableItem(type, index) +const buildSortableItems = (values = []) => { + const counts = {}; + return sanitizeStructureList(values, SANITIZE_OPTIONS).map((type) => { + const occurrence = (counts[type] || 0) + 1; + counts[type] = occurrence; + return createSortableItem(type, occurrence); + }); +}; + +const getMaxOccurrence = (items = [], type) => + items.reduce((max, item) => { + if (item?.type !== type) return max; + const value = typeof item?.occurrence === "number" ? item.occurrence : 0; + return Math.max(max, value); + }, 0); + +const recalculateOccurrences = (items = []) => { + const counts = {}; + return items.map((item) => { + const type = item?.type || item?.value; + const occurrence = (counts[type] || 0) + 1; + counts[type] = occurrence; + if (item?.occurrence === occurrence) { + return item; + } + return { ...item, occurrence }; + }); +}; + +const validateStructureRules = (items = []) => { + for (let index = 0; index < items.length; index += 1) { + const current = items[index]; + const type = current?.type || current?.value; + if (type === PRE_REFRAIN_TYPE) { + const next = items[index + 1]; + const nextType = next?.type || next?.value; + if (nextType !== "refrain") { + return { + valid: false, + reason: "pre_refrain_invalid_position", + message: PRE_REFRAIN_ERROR_MESSAGE, + }; + } + } + } + + const introIndex = items.findIndex((item) => + INTRO_TYPES.has(item?.type || item?.value) ); + if (introIndex > 0) { + return { + valid: false, + reason: "intro_must_be_first", + message: INTRO_ERROR_MESSAGE, + }; + } + + return { valid: true }; +}; const reorder = (list, from, to) => { if (from === to) return list; @@ -78,8 +147,9 @@ const reorder = (list, from, to) => { const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { + const [, setTooltip] = useGlobal("_tooltip"); const sanitizedBase = useMemo( - () => sanitizeStructureList(baseStructure), + () => sanitizeStructureList(baseStructure, SANITIZE_OPTIONS), [baseStructure] ); const [sortableItems, setSortableItems] = useState(() => @@ -97,8 +167,17 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { containerRect: null, targetIndex: null, isOverContainer: false, + startItems: null, + blockedReason: null, }); const globalListenersRef = useRef({ move: null, up: null }); + const showTooltip = useCallback( + (text, type = "error") => { + if (!text) return; + setTooltip({ text, type }); + }, + [setTooltip] + ); const extractValues = useCallback( (items) => @@ -121,23 +200,25 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { }, [sortableItems]); useEffect(() => { - onChange?.(extractValues(sortableItems)); + const values = extractValues(sortableItems); + onChange?.(sanitizeStructureList(values, SANITIZE_OPTIONS)); }, [sortableItems, extractValues, 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), - }; - }); - }, [sortableItems]); + const labeledItems = useMemo( + () => + sortableItems.map((item) => { + const baseType = item.type || item.value; + const occurrence = + typeof item.occurrence === "number" ? item.occurrence : 1; + return { + ...item, + type: baseType, + value: baseType, + label: formatStructureLabel(baseType, occurrence), + }; + }), + [sortableItems] + ); const paletteSegments = useMemo( () => @@ -163,46 +244,95 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { return counts; }, [sortableItems, extractValues]); - const handleRemoveItem = useCallback( - (itemId) => { - setSortableItems((prev) => { - const remaining = prev.filter((item) => item.id !== itemId); - const sanitized = sanitizeStructureList(extractValues(remaining)); - return buildSortableItems(sanitized); - }); - }, - [extractValues] - ); + const handleRemoveItem = useCallback((itemId) => { + setSortableItems((prev) => { + const remaining = prev.filter((item) => item.id !== itemId); + return recalculateOccurrences(remaining); + }); + }, []); const addOptionalSegment = useCallback( (type, insertIndex = null) => { const meta = getSegmentMeta(type); - setSortableItems((prev) => { - const currentValues = extractValues(prev); - let nextValues = [...currentValues]; + if (!meta) { + return { success: false, reason: "unknown_segment" }; + } - if (meta?.exclusiveGroup) { - nextValues = nextValues.filter((value) => { - const itemMeta = getSegmentMeta(value); - return itemMeta?.exclusiveGroup !== meta.exclusiveGroup; - }); + const currentItems = sortableItemsRef.current || []; + let targetIndex = + typeof insertIndex === "number" + ? clamp(insertIndex, 0, currentItems.length) + : currentItems.length; + + const nextItems = []; + let reusedOccurrence = null; + + currentItems.forEach((item, index) => { + const itemMeta = getSegmentMeta(item?.type); + const conflictsExclusive = + meta?.exclusiveGroup && + itemMeta?.exclusiveGroup === meta.exclusiveGroup; + const conflictsSameType = + meta?.allowMultiple === false && item?.type === type; + + if (conflictsExclusive || conflictsSameType) { + if (index < targetIndex) { + targetIndex -= 1; + } + if (item?.type === type && reusedOccurrence == null) { + reusedOccurrence = + typeof item?.occurrence === "number" ? item.occurrence : 1; + } + return; } - if (meta?.allowMultiple === false) { - nextValues = nextValues.filter((value) => value !== type); - } - - const targetIndex = - typeof insertIndex === "number" - ? clamp(insertIndex, 0, nextValues.length) - : nextValues.length; - - nextValues.splice(targetIndex, 0, type); - const sanitized = sanitizeStructureList(nextValues); - return buildSortableItems(sanitized); + nextItems.push(item); }); + + if ( + type === PRE_REFRAIN_TYPE && + insertIndex == null && + nextItems.length > 0 + ) { + const firstRefrainIndex = nextItems.findIndex( + (item) => item?.type === "refrain" + ); + if (firstRefrainIndex !== -1) { + targetIndex = firstRefrainIndex; + } + } + + if (meta?.exclusiveGroup === "intro") { + targetIndex = 0; + } else if (meta?.exclusiveGroup === "outro") { + targetIndex = nextItems.length; + } + + const occurrence = + meta?.allowMultiple === false + ? reusedOccurrence ?? 1 + : getMaxOccurrence(nextItems, type) + 1; + + const insertionIndex = clamp(targetIndex, 0, nextItems.length); + const draftedItems = [...nextItems]; + draftedItems.splice( + insertionIndex, + 0, + createSortableItem(type, occurrence) + ); + + const recalculatedDraft = recalculateOccurrences(draftedItems); + + const validation = validateStructureRules(recalculatedDraft); + if (!validation.valid) { + showTooltip(validation.message); + return { success: false, reason: validation.reason }; + } + + setSortableItems(recalculatedDraft); + return { success: true }; }, - [extractValues] + [showTooltip] ); const resetDragState = useCallback(() => { @@ -214,6 +344,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { containerRect: null, targetIndex: null, isOverContainer: false, + startItems: null, + blockedReason: null, }; }, []); @@ -264,6 +396,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const pointerY = absoluteY - (dragState.containerTop || 0); if (dragState.itemId) { + dragStateRef.current.blockedReason = null; setSortableItems((prev) => { const currentIndex = prev.findIndex( (it) => it.id === dragState.itemId @@ -275,8 +408,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { 0, maxIndex ); + const movingItem = prev[currentIndex]; + const movingType = movingItem?.type || movingItem?.value; + const topType = prev[0]?.type || prev[0]?.value; + if ( + targetIndex === 0 && + INTRO_TYPES.has(topType) && + !INTRO_TYPES.has(movingType) + ) { + dragStateRef.current.blockedReason = "intro_must_be_first"; + return prev; + } if (targetIndex === currentIndex) return prev; - return reorder(prev, currentIndex, targetIndex); + const reordered = reorder(prev, currentIndex, targetIndex); + return recalculateOccurrences(reordered); }); return; } @@ -327,9 +472,20 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { } if (dragState.itemId) { - setSortableItems((prev) => - buildSortableItems(extractValues(prev)) + if (dragState.blockedReason === "intro_must_be_first") { + showTooltip(INTRO_ERROR_MESSAGE); + } + const validation = validateStructureRules( + sortableItemsRef.current || [] ); + if (!validation.valid) { + const fallbackItems = + dragState.startItems?.map((item) => item) || + sortableItemsRef.current?.map((item) => item) || + []; + setSortableItems(fallbackItems); + showTooltip(validation.message); + } } else if (dragState.paletteType) { let isOver = dragState.isOverContainer; let targetIndex = dragState.targetIndex; @@ -363,6 +519,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { if (isOver && typeof targetIndex === "number") { addOptionalSegment(dragState.paletteType, targetIndex); + } else if (dragState.paletteType === PRE_REFRAIN_TYPE) { + showTooltip(PRE_REFRAIN_ERROR_MESSAGE); } } @@ -370,7 +528,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { setInsertPreviewIndex(null); cleanupPointerListeners(); }, - [addOptionalSegment, cleanupPointerListeners, extractValues] + [addOptionalSegment, cleanupPointerListeners, showTooltip] ); const attachGlobalListeners = useCallback(() => { @@ -406,6 +564,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { ? nativeEvent.clientY + scrollY : scrollY; const containerTop = rect ? rect.top + scrollY : fallbackY; + const snapshot = sortableItemsRef.current?.map((item) => item) || []; dragStateRef.current = { itemId, @@ -426,6 +585,8 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { : null, targetIndex: null, isOverContainer: true, + startItems: snapshot, + blockedReason: null, }; setActiveItemId(itemId); @@ -477,6 +638,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { : null, targetIndex: null, isOverContainer: false, + blockedReason: null, }; setActiveItemId(`palette-${type}`); @@ -496,11 +658,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const baseType = item?.type || item?.value; const meta = getSegmentMeta(baseType); const canRemove = meta?.optional === true; - const isIntroduction = - baseType === "short_intro" || - baseType === "long_intro" || - baseType === "intro" || - baseType === "introduction"; + const isIntroduction = INTRO_TYPES.has(baseType); const containerStyle = StyleSheet.flatten([ styles.rowContainer, activeItemId === item.id ? styles.activeItem : null, @@ -509,6 +667,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { styles.itemBlur, activeItemId === item.id ? styles.activeBlur : null, ]); + const rowStyle = StyleSheet.flatten([ + styles.row, + isIntroduction ? styles.rowDisabled : styles.rowDraggable, + ]); return ( { containerStyle={containerStyle} blurViewStyle={blurStyle} > - + { + if (isIntroduction) return; + startItemDrag(item.id, event); + }} + > {item.label} {canRemove && ( handleRemoveItem(item.id)} + onPressIn={(ev) => ev?.stopPropagation?.()} + onPress={(ev) => { + ev?.stopPropagation?.(); + handleRemoveItem(item.id); + }} style={styles.removeButton} hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }} > @@ -533,7 +705,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { {!isIntroduction && ( startItemDrag(item.id, event)} + onPointerDown={(event) => { + event?.stopPropagation?.(); + startItemDrag(item.id, event); + }} > @@ -633,9 +808,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { onPressIn={(ev) => ev?.stopPropagation?.()} onPress={(ev) => { ev?.stopPropagation?.(); - addOptionalSegment(segment.type); - setInsertPreviewIndex(null); - setActiveItemId(null); + const result = addOptionalSegment(segment.type); + if (result?.success) { + setInsertPreviewIndex(null); + setActiveItemId(null); + } }} hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }} style={({ pressed }) => @@ -645,7 +822,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { ]) } > - + {count} @@ -753,6 +933,12 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "space-between", }, + rowDraggable: { + cursor: "grab", + }, + rowDisabled: { + cursor: "default", + }, rowText: { fontSize: 16, color: Palette.white, diff --git a/src/utils/songStructure.js b/src/utils/songStructure.js index f97bca0..fb98d5a 100644 --- a/src/utils/songStructure.js +++ b/src/utils/songStructure.js @@ -284,8 +284,9 @@ export const normalizeStructureType = (value) => { return sanitized; }; -export const sanitizeStructureList = (structure = []) => { +export const sanitizeStructureList = (structure = [], options = {}) => { if (!Array.isArray(structure)) return []; + const { autoInjectPreChorus = true } = options || {}; const output = []; let shouldInjectInstrumentalPreChorus = false; @@ -311,7 +312,7 @@ export const sanitizeStructureList = (structure = []) => { } } - if (type === "pre_refrain_instrumental") { + if (type === "pre_refrain_instrumental" && autoInjectPreChorus) { shouldInjectInstrumentalPreChorus = true; return; } @@ -321,7 +322,7 @@ export const sanitizeStructureList = (structure = []) => { let result = output; - if (shouldInjectInstrumentalPreChorus) { + if (autoInjectPreChorus && shouldInjectInstrumentalPreChorus) { const expanded = []; let hasRefrain = false;