new song structure
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user