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
+189 -87
View File
@@ -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,
},
});