diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index 7853a34..ddd2efc 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -21,6 +21,7 @@ import { gutters, size } from "../../styles/Style"; import { FONT_FAMILY } from "../../styles/Fonts"; import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +import CreditAmount from "../../components/CreditAmount"; const MUSIC_GENERATION_COIN_COST = 8; const SONG_OPTIONS_PER_GENERATION = 2; @@ -65,7 +66,7 @@ const SongReady = () => { } catch (error) { console.log("SongReady pause error", error?.message); } - }, + } ); await Promise.all(tasks); }, []); @@ -93,7 +94,7 @@ const SongReady = () => { console.log("SongReady play error", error?.message); } }, - [pauseAllExcept], + [pauseAllExcept] ); // Sync URLs from provider's selectedProject @@ -110,8 +111,6 @@ const SongReady = () => { }); }, [selectedProject?.musicUrls]); - - const validateSelection = async () => { try { const url = musicUrls[selectedIndex]; @@ -150,7 +149,7 @@ const SongReady = () => { return () => { pauseAllPlayers(); }; - }, [pauseAllPlayers]), + }, [pauseAllPlayers]) ); useEffect(() => { @@ -177,7 +176,7 @@ const SongReady = () => { }, }, ], - { cancelable: false }, + { cancelable: false } ); return; } @@ -208,7 +207,7 @@ const SongReady = () => { onPress: () => handleConfirmRegenerate(), }, ], - { cancelable: true }, + { cancelable: true } ); return; } @@ -234,7 +233,7 @@ const SongReady = () => { marginBottom: responsiveHeight(2), }} /> - + {musicUrls.map((url, idx) => ( { const durationMs = Math.max( 0, - Math.round((Number(player.duration) || 0) * 1000), + Math.round((Number(player.duration) || 0) * 1000) ); const positionMs = Math.max( 0, - Math.round((Number(player.currentTime) || 0) * 1000), + Math.round((Number(player.currentTime) || 0) * 1000) ); setProgressInfo((prev) => { if ( diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js index 22ff8b8..f198b74 100644 --- a/src/screens/Writing/CreateLyricsWithAi.js +++ b/src/screens/Writing/CreateLyricsWithAi.js @@ -317,10 +317,7 @@ const CreateLyricsWithAi = () => { }; const onPressBack = () => { - // If currently on SongStructure, go back to previous screen instead of previous step - if (selectedIndex === 5) { - goBack(); - } else if (selectedIndex > 0) { + if (selectedIndex > 0) { setSelectedIndex((idx) => Math.max(0, idx - 1)); } else { goBack(); diff --git a/src/screens/Writing/CreateLyricsWithAi.web.js b/src/screens/Writing/CreateLyricsWithAi.web.js index 4a68ea8..12ba52a 100644 --- a/src/screens/Writing/CreateLyricsWithAi.web.js +++ b/src/screens/Writing/CreateLyricsWithAi.web.js @@ -319,10 +319,7 @@ const CreateLyricsWithAi = () => { }; const onPressBack = () => { - // If currently on SongStructure, go back to previous screen instead of previous step - if (selectedIndex === 5) { - goBack(); - } else if (selectedIndex > 0) { + if (selectedIndex > 0) { setSelectedIndex((idx) => Math.max(0, idx - 1)); } else { goBack(); diff --git a/src/screens/Writing/CustomizeSongStructure.web.js b/src/screens/Writing/CustomizeSongStructure.web.js index 605a264..ea40e39 100644 --- a/src/screens/Writing/CustomizeSongStructure.web.js +++ b/src/screens/Writing/CustomizeSongStructure.web.js @@ -65,6 +65,17 @@ const PALETTE_SEGMENT_OVERRIDES = [ }, ]; +const getPaletteSegmentInfo = (type) => { + const meta = getSegmentMeta(type); + const override = PALETTE_SEGMENT_OVERRIDES.find( + (segment) => segment.type === type + ); + return { + label: meta?.label || formatStructureLabel(type, 1), + description: override?.description || "", + }; +}; + const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0); const randomSuffix = () => Math.random().toString(36).slice(2, 8); @@ -163,6 +174,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { ); const [activeItemId, setActiveItemId] = useState(null); const [insertPreviewIndex, setInsertPreviewIndex] = useState(null); + const [dragOverlay, setDragOverlay] = useState(null); const containerRef = useRef(null); const sortableItemsRef = useRef(sortableItems); const dragStateRef = useRef({ @@ -175,8 +187,36 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { isOverContainer: false, startItems: null, blockedReason: null, + startIndex: null, + slotIndex: null, + grabOffsetX: null, + grabOffsetY: null, }); const globalListenersRef = useRef({ move: null, up: null }); + const clearDragOverlay = useCallback(() => { + setDragOverlay(null); + }, []); + const updateDragOverlayPosition = useCallback((clientX, clientY) => { + setDragOverlay((prev) => { + if (!prev) return prev; + const offsetX = + typeof dragStateRef.current?.grabOffsetX === "number" + ? dragStateRef.current.grabOffsetX + : prev.width / 2; + const offsetY = + typeof dragStateRef.current?.grabOffsetY === "number" + ? dragStateRef.current.grabOffsetY + : prev.height / 2; + const left = + typeof clientX === "number" ? clientX - offsetX : prev.left; + const top = + typeof clientY === "number" ? clientY - offsetY : prev.top; + if (left === prev.left && top === prev.top) { + return prev; + } + return { ...prev, left, top }; + }); + }, []); const showTooltip = useCallback( (text, type = "error") => { if (!text) return; @@ -229,14 +269,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const paletteSegments = useMemo( () => OPTIONAL_STRUCTURE_SEGMENTS.map((type) => { - const meta = getSegmentMeta(type); - const override = PALETTE_SEGMENT_OVERRIDES.find( - (segment) => segment.type === type - ); + const info = getPaletteSegmentInfo(type); return { type, - label: meta?.label || formatStructureLabel(type, 1), - description: override?.description || "", + label: info.label, + description: info.description, }; }), [] @@ -352,6 +389,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { isOverContainer: false, startItems: null, blockedReason: null, + startIndex: null, + slotIndex: null, + grabOffsetX: null, + grabOffsetY: null, }; }, []); @@ -401,34 +442,77 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { : scrollY; const pointerY = absoluteY - (dragState.containerTop || 0); + updateDragOverlayPosition(event?.clientX, event?.clientY); + if (dragState.itemId) { - dragStateRef.current.blockedReason = null; - setSortableItems((prev) => { - const currentIndex = prev.findIndex( - (it) => it.id === dragState.itemId - ); - if (currentIndex === -1) return prev; - const maxIndex = Math.max(prev.length - 1, 0); - const targetIndex = clamp( - Math.floor(pointerY / ROW_HEIGHT), - 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; + const rect = dragState.containerRect; + const listLength = sortableItemsRef.current.length; + const isOver = + rect == null + ? true + : event.clientX >= rect.left && event.clientX <= rect.right; + dragStateRef.current.isOverContainer = isOver; + + if (!isOver) { + dragStateRef.current.targetIndex = null; + dragStateRef.current.slotIndex = null; + if (insertPreviewIndex !== null) { + setInsertPreviewIndex(null); } - if (targetIndex === currentIndex) return prev; - const reordered = reorder(prev, currentIndex, targetIndex); - return recalculateOccurrences(reordered); - }); + return; + } + + const containerHeight = rect?.height ?? listLength * ROW_HEIGHT; + let slotIndex = Math.round(pointerY / ROW_HEIGHT); + if (pointerY < 0) slotIndex = 0; + if (pointerY > containerHeight) slotIndex = listLength; + slotIndex = clamp(slotIndex, 0, listLength); + + const snapshot = dragState.startItems || sortableItemsRef.current; + const movingIndex = + typeof dragState.startIndex === "number" + ? dragState.startIndex + : snapshot.findIndex((item) => item.id === dragState.itemId); + dragStateRef.current.startIndex = movingIndex; + if (movingIndex < 0) { + dragStateRef.current.slotIndex = null; + return; + } + + const movingItem = snapshot[movingIndex]; + const movingType = movingItem?.type || movingItem?.value; + const topType = snapshot[0]?.type || snapshot[0]?.value; + + if ( + slotIndex === 0 && + INTRO_TYPES.has(topType) && + !INTRO_TYPES.has(movingType) + ) { + dragStateRef.current.blockedReason = "intro_must_be_first"; + return; + } + + dragStateRef.current.blockedReason = null; + const shouldPreview = + typeof movingIndex === "number" && + slotIndex !== movingIndex && + slotIndex !== movingIndex + 1; + if (shouldPreview) { + if (dragStateRef.current.slotIndex !== slotIndex) { + dragStateRef.current.slotIndex = slotIndex; + setInsertPreviewIndex(slotIndex); + } + } else if (insertPreviewIndex !== null) { + dragStateRef.current.slotIndex = null; + setInsertPreviewIndex(null); + } + + let targetIndex = slotIndex; + if (typeof movingIndex === "number" && slotIndex > movingIndex) { + targetIndex -= 1; + } + targetIndex = clamp(targetIndex, 0, Math.max(listLength - 1, 0)); + dragStateRef.current.targetIndex = targetIndex; return; } @@ -462,7 +546,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { setInsertPreviewIndex(targetIndex); } }, - [insertPreviewIndex, setSortableItems] + [insertPreviewIndex, setInsertPreviewIndex, updateDragOverlayPosition] ); const handleGlobalPointerUp = useCallback( @@ -480,17 +564,36 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { if (dragState.itemId) { 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.isOverContainer && + typeof dragState.targetIndex === "number" + ) { + const desiredIndex = dragState.targetIndex; + setSortableItems((prev) => { + const currentIndex = prev.findIndex( + (it) => it.id === dragState.itemId + ); + if (currentIndex === -1) return prev; + const targetIndex = clamp( + desiredIndex, + 0, + Math.max(prev.length, 0) + ); + if (targetIndex === currentIndex) { + return prev; + } + const reordered = reorder(prev, currentIndex, targetIndex); + const recalculated = recalculateOccurrences(reordered); + const validation = validateStructureRules(recalculated); + if (!validation.valid) { + const fallbackItems = + dragState.startItems?.map((item) => item) || + prev; + showTooltip(validation.message); + return recalculateOccurrences(fallbackItems); + } + return recalculated; + }); } } else if (dragState.paletteType) { let isOver = dragState.isOverContainer; @@ -532,9 +635,16 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { setActiveItemId(null); setInsertPreviewIndex(null); + clearDragOverlay(); cleanupPointerListeners(); }, - [addOptionalSegment, cleanupPointerListeners, showTooltip] + [ + addOptionalSegment, + cleanupPointerListeners, + clearDragOverlay, + showTooltip, + setSortableItems, + ] ); const attachGlobalListeners = useCallback(() => { @@ -555,7 +665,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { }, [handleGlobalPointerMove, handleGlobalPointerUp]); const startItemDrag = useCallback( - (itemId, event) => { + (itemId, event, dragElement = null) => { const nativeEvent = event?.nativeEvent || {}; if (typeof nativeEvent.button === "number" && nativeEvent.button !== 0) return; @@ -571,6 +681,32 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { : scrollY; const containerTop = rect ? rect.top + scrollY : fallbackY; const snapshot = sortableItemsRef.current?.map((item) => item) || []; + const startIndex = snapshot.findIndex((item) => item.id === itemId); + const movingItem = startIndex >= 0 ? snapshot[startIndex] : null; + const baseType = movingItem?.type || movingItem?.value; + const occurrence = + typeof movingItem?.occurrence === "number" ? movingItem.occurrence : 1; + const label = + baseType != null ? formatStructureLabel(baseType, occurrence) : ""; + const pointerClientX = + typeof nativeEvent.clientX === "number" ? nativeEvent.clientX : 0; + const pointerClientY = + typeof nativeEvent.clientY === "number" ? nativeEvent.clientY : 0; + const getRect = (node) => + typeof node?.getBoundingClientRect === "function" + ? node.getBoundingClientRect() + : null; + const itemRect = getRect(dragElement) || getRect(event?.currentTarget); + const overlayWidth = itemRect?.width || rect?.width || 280; + const overlayHeight = itemRect?.height || ROW_HEIGHT; + const grabOffsetX = itemRect + ? pointerClientX - itemRect.left + : overlayWidth / 2; + const grabOffsetY = itemRect + ? pointerClientY - itemRect.top + : overlayHeight / 2; + const overlayLeft = pointerClientX - grabOffsetX; + const overlayTop = pointerClientY - grabOffsetY; dragStateRef.current = { itemId, @@ -593,10 +729,23 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { isOverContainer: true, startItems: snapshot, blockedReason: null, + startIndex, + slotIndex: null, + grabOffsetX, + grabOffsetY, }; setActiveItemId(itemId); setInsertPreviewIndex(null); + setDragOverlay({ + kind: "structure", + label, + description: "", + width: overlayWidth, + height: overlayHeight, + left: overlayLeft, + top: overlayTop, + }); if (typeof document !== "undefined") { document.body.style.userSelect = "none"; @@ -624,6 +773,25 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { ? nativeEvent.clientY + scrollY : scrollY; const containerTop = rect ? rect.top + scrollY : fallbackY; + const pointerClientX = + typeof nativeEvent.clientX === "number" ? nativeEvent.clientX : 0; + const pointerClientY = + typeof nativeEvent.clientY === "number" ? nativeEvent.clientY : 0; + const cardRect = + typeof event?.currentTarget?.getBoundingClientRect === "function" + ? event.currentTarget.getBoundingClientRect() + : null; + const overlayWidth = cardRect?.width || 320; + const overlayHeight = cardRect?.height || ROW_HEIGHT; + const grabOffsetX = cardRect + ? pointerClientX - cardRect.left + : overlayWidth / 2; + const grabOffsetY = cardRect + ? pointerClientY - cardRect.top + : overlayHeight / 2; + const overlayLeft = pointerClientX - grabOffsetX; + const overlayTop = pointerClientY - grabOffsetY; + const segmentInfo = getPaletteSegmentInfo(type); dragStateRef.current = { itemId: null, @@ -645,10 +813,23 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { targetIndex: null, isOverContainer: false, blockedReason: null, + startIndex: null, + slotIndex: null, + grabOffsetX, + grabOffsetY, }; setActiveItemId(`palette-${type}`); setInsertPreviewIndex(null); + setDragOverlay({ + kind: "palette", + label: segmentInfo.label, + description: segmentInfo.description, + width: overlayWidth, + height: overlayHeight, + left: overlayLeft, + top: overlayTop, + }); if (typeof document !== "undefined") { document.body.style.userSelect = "none"; @@ -665,17 +846,22 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { const meta = getSegmentMeta(baseType); const canRemove = meta?.optional === true; const isIntroduction = INTRO_TYPES.has(baseType); + const rowRef = useRef(null); + const isActive = activeItemId === item.id; const containerStyle = StyleSheet.flatten([ styles.rowContainer, - activeItemId === item.id ? styles.activeItem : null, + isActive ? styles.activeItem : null, + isActive ? styles.draggingContainer : null, ]); const blurStyle = StyleSheet.flatten([ styles.itemBlur, - activeItemId === item.id ? styles.activeBlur : null, + isActive ? styles.activeBlur : null, + isActive ? styles.draggingBlur : null, ]); const rowStyle = StyleSheet.flatten([ styles.row, isIntroduction ? styles.rowDisabled : styles.rowDraggable, + isActive ? styles.rowHidden : null, ]); return ( @@ -687,10 +873,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { blurViewStyle={blurStyle} > { if (isIntroduction) return; - startItemDrag(item.id, event); + startItemDrag(item.id, event, rowRef.current); }} > {item.label} @@ -714,7 +901,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { style={styles.handleWrapper} onPointerDown={(event) => { event?.stopPropagation?.(); - startItemDrag(item.id, event); + startItemDrag(item.id, event, rowRef.current); }} > @@ -848,6 +1035,52 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { + {dragOverlay ? ( + + + {dragOverlay.kind === "palette" ? ( + + + {dragOverlay.label} + {dragOverlay.description ? ( + + {dragOverlay.description} + + ) : null} + + + ) : ( + + {dragOverlay.label} + + )} + + + ) : null} ); }; @@ -918,6 +1151,9 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: Palette.white, }, + draggingContainer: { + opacity: 0, + }, itemBlur: { minHeight: ROW_HEIGHT, paddingHorizontal: 16, @@ -934,6 +1170,9 @@ const styles = StyleSheet.create({ activeBlur: { backgroundColor: Palette.transparentWhite, }, + draggingBlur: { + opacity: 0, + }, row: { flex: 1, flexDirection: "row", @@ -946,6 +1185,9 @@ const styles = StyleSheet.create({ rowDisabled: { cursor: "default", }, + rowHidden: { + opacity: 0, + }, rowText: { fontSize: 16, color: Palette.white, @@ -1033,4 +1275,21 @@ const styles = StyleSheet.create({ color: Palette.white, fontFamily: FONT_FAMILY.InterMedium, }, + dragOverlay: { + position: "fixed", + zIndex: 999, + pointerEvents: "none", + }, + dragOverlayContainer: { + width: "100%", + }, + dragOverlayBlur: { + cursor: "grabbing", + opacity: 1, + shadowColor: "#000", + shadowOpacity: 0.35, + shadowRadius: 12, + shadowOffset: { width: 0, height: 8 }, + elevation: 6, + }, });