new song structure

This commit is contained in:
2025-10-21 11:38:48 +02:00
parent 59328aaaed
commit 3a5d449424
11 changed files with 807 additions and 175 deletions
+144 -22
View File
@@ -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,
},
});