From 1fd63c254d6cc46bce33af45cc057e5d033659ed Mon Sep 17 00:00:00 2001 From: leon-morival Date: Wed, 1 Oct 2025 16:49:12 +0200 Subject: [PATCH] second flow --- src/components/Slider.js | 25 ++- src/components/Slider.web.js | 182 +++++++++++++++++++++ src/screens/Studio/ComposeSong.web.js | 217 ++++++++++++++++++++++++++ src/screens/Studio/GeneratingSong.js | 27 +++- src/screens/Studio/SongReady.js | 17 +- src/screens/cover/PhotoCover.js | 40 +++-- src/screens/cover/PouchReady.js | 37 +++-- src/screens/cover/ValidateCover.js | 25 +-- 8 files changed, 505 insertions(+), 65 deletions(-) create mode 100644 src/components/Slider.web.js create mode 100644 src/screens/Studio/ComposeSong.web.js diff --git a/src/components/Slider.js b/src/components/Slider.js index 3da13f6..2a935dc 100644 --- a/src/components/Slider.js +++ b/src/components/Slider.js @@ -2,17 +2,25 @@ import { useEffect, useState } from "react"; import { StyleSheet, Text, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { + runOnJS, useAnimatedStyle, useSharedValue, - runOnJS, } from "react-native-reanimated"; -import { LinearGradient } from "./LinearGradient/LinearGradient"; import { Palette, Style } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; +import { LinearGradient } from "./LinearGradient/LinearGradient"; const INITIAL_BOX_SIZE = 6; -export default ({ value, maxValue, progress, onSeek, onSeekStart, onSeekEnd, seekEnabled = false }) => { +export default ({ + value, + maxValue, + progress, + onSeek, + onSeekStart, + onSeekEnd, + seekEnabled = false, +}) => { const offset = useSharedValue(0); const boxWidth = useSharedValue(INITIAL_BOX_SIZE); const [layout, setLayout] = useState(null); @@ -23,7 +31,7 @@ export default ({ value, maxValue, progress, onSeek, onSeekStart, onSeekEnd, see const pan = Gesture.Pan() .enabled(seekEnabled) .onBegin(() => { - if (seekEnabled && typeof onSeekStart === 'function') { + if (seekEnabled && typeof onSeekStart === "function") { // Notify JS thread that user started seeking (e.g., pause audio) runOnJS(onSeekStart)(); } @@ -34,8 +42,8 @@ export default ({ value, maxValue, progress, onSeek, onSeekStart, onSeekEnd, see ? offset.value + event.changeX <= 0 ? 0 : offset.value + event.changeX >= MAX_VALUE - ? MAX_VALUE - : offset.value + event.changeX + ? MAX_VALUE + : offset.value + event.changeX : offset.value; const newWidth = INITIAL_BOX_SIZE + offset.value; @@ -43,12 +51,13 @@ export default ({ value, maxValue, progress, onSeek, onSeekStart, onSeekEnd, see }) .onEnd(() => { if (!seekEnabled || !onSeek || !MAX_VALUE) return; - const ratio = MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0; + const ratio = + MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0; // Reanimated -> JS thread bridge runOnJS(onSeek)(ratio); }) .onFinalize(() => { - if (seekEnabled && typeof onSeekEnd === 'function') { + if (seekEnabled && typeof onSeekEnd === "function") { runOnJS(onSeekEnd)(); } }); diff --git a/src/components/Slider.web.js b/src/components/Slider.web.js new file mode 100644 index 0000000..4ea1850 --- /dev/null +++ b/src/components/Slider.web.js @@ -0,0 +1,182 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { Palette, Style } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; +import { LinearGradient } from "./LinearGradient/LinearGradient"; + +const INITIAL_BOX_SIZE = 6; +const HANDLE_SIZE = 20; +const HANDLE_TOP_OFFSET = (INITIAL_BOX_SIZE - HANDLE_SIZE) / 2; + +const clamp01 = (value) => Math.min(1, Math.max(0, value)); + +const Slider = ({ + value, + maxValue, + progress, + onSeek, + onSeekStart, + onSeekEnd, + seekEnabled = false, +}) => { + const [layoutWidth, setLayoutWidth] = useState(0); + const [ratio, setRatio] = useState( + typeof progress === "number" ? clamp01(progress) : 0, + ); + const draggingRef = useRef(false); + + useEffect(() => { + if (!draggingRef.current && typeof progress === "number") { + setRatio(clamp01(progress)); + } + }, [progress]); + + const updateRatioFromX = useCallback( + (x) => { + if (layoutWidth <= 0) return; + const available = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0); + if (available <= 0) { + setRatio(0); + return; + } + const clampedX = Math.min(Math.max(x, 0), layoutWidth); + const nextRatio = clamp01(clampedX / available); + setRatio(nextRatio); + }, + [layoutWidth], + ); + + const handleGrant = useCallback( + (event) => { + if (!seekEnabled) return; + draggingRef.current = true; + updateRatioFromX(event?.nativeEvent?.locationX || 0); + if (typeof onSeekStart === "function") { + onSeekStart(); + } + }, + [seekEnabled, updateRatioFromX, onSeekStart], + ); + + const handleMove = useCallback( + (event) => { + if (!seekEnabled || !draggingRef.current) return; + updateRatioFromX(event?.nativeEvent?.locationX || 0); + }, + [seekEnabled, updateRatioFromX], + ); + + const finishSeeking = useCallback(() => { + if (!seekEnabled || !draggingRef.current) return; + draggingRef.current = false; + const currentRatio = clamp01(ratio); + if (typeof onSeek === "function") { + onSeek(currentRatio); + } + if (typeof onSeekEnd === "function") { + onSeekEnd(); + } + }, [seekEnabled, ratio, onSeek, onSeekEnd]); + + const handleLayout = useCallback((event) => { + const width = event?.nativeEvent?.layout?.width || 0; + if (width !== layoutWidth) { + setLayoutWidth(width); + } + }, [layoutWidth]); + + const maxOffset = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0); + const offset = maxOffset * ratio; + + return ( + + seekEnabled} + onStartShouldSetResponder={() => seekEnabled} + onMoveShouldSetResponder={() => seekEnabled} + onResponderGrant={handleGrant} + onResponderMove={handleMove} + onResponderRelease={finishSeeking} + onResponderTerminate={finishSeeking} + > + + + + + + + + + {value} + {maxValue} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: "100%", + }, + interactionArea: { + width: "100%", + height: HANDLE_SIZE, + justifyContent: "center", + position: "relative", + }, + pointerEnabled: { + cursor: "pointer", + }, + sliderTrack: { + width: "100%", + height: INITIAL_BOX_SIZE, + backgroundColor: "#0F0C19", + borderRadius: 25, + overflow: "hidden", + }, + box: { + height: INITIAL_BOX_SIZE, + borderRadius: 20, + position: "absolute", + left: 0, + top: 0, + zIndex: 1, + overflow: "hidden", + }, + sliderHandle: { + width: HANDLE_SIZE, + height: HANDLE_SIZE, + backgroundColor: "#f8f9ff", + borderRadius: HANDLE_SIZE / 2, + position: "absolute", + top: HANDLE_TOP_OFFSET, + zIndex: 2, + borderWidth: 4, + borderColor: "#9B4DFF", + shadowColor: "#8951FC", + shadowOffset: { + width: 0, + height: 3, + }, + shadowOpacity: 0.17, + shadowRadius: 3.05, + elevation: 4, + }, + time: { + fontSize: 14, + color: Palette.white, + fontFamily: FONT_FAMILY.InterRegular, + }, +}); + +export default Slider; diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js new file mode 100644 index 0000000..d5e3c72 --- /dev/null +++ b/src/screens/Studio/ComposeSong.web.js @@ -0,0 +1,217 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Dimensions, FlatList, View } from "react-native"; +import { background } from "../../assets"; +import GradientButton from "../../components/GradientButton"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import firebase from "../../config/firebase"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +import { useUser } from "../../providers/UserDataProvider"; +import { gutters } from "../../styles"; +import ChooseGenre from "./ChooseGenre"; +import ChooseInstruments from "./ChooseInstruments"; +import ChooseRhythm from "./ChooseRhythm"; +import CustomizeVoice from "./CustomizeVoice"; + +const { width: windowWidth } = Dimensions.get("window"); + +const ComposeSong = () => { + const scrollRef = useRef(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [progress, setProgress] = useState(18); + const [parentLayout, setParentLayout] = useState(null); + const containerWidth = parentLayout?.width || windowWidth || 1; + const { selectedProjectId, selectedProject, updateProjectData } = useUser(); + + const [genres, setGenres] = useState([]); + const [voice, setVoice] = useState({}); + const [instruments, setInstruments] = useState([]); + const [rhythm, setRhythm] = useState(null); + + const isStepValid = useMemo(() => { + switch (selectedIndex) { + case 0: + return Array.isArray(genres) && genres.length > 0; + case 1: + return !!(voice && typeof voice === "object" && voice.base); + case 2: + return Array.isArray(instruments) && instruments.length > 0; + case 3: + return !!rhythm; + default: + return true; + } + }, [selectedIndex, genres, voice, instruments, rhythm]); + + const musicConfig = useMemo(() => { + let lyricsArr = []; + if (Array.isArray(selectedProject?.lyrics)) { + lyricsArr = selectedProject.lyrics.map((s) => ({ + type: (s?.type || "").toLowerCase(), + lyrics: s?.lyrics || "", + })); + } else { + const c = selectedProject?.lyrics?.couplet; + const r = selectedProject?.lyrics?.refrain; + if (c) lyricsArr.push({ type: "couplet", lyrics: c }); + if (r) lyricsArr.push({ type: "refrain", lyrics: r }); + } + const voiceArray = Object.entries(voice || {}) + .filter(([, v]) => typeof v === "string" && v.trim()) + .map(([category, value]) => ({ category, value })); + + return { + title: selectedProject?.title || "", + lyrics: lyricsArr, + genres: Array.isArray(genres) ? genres : [], + voice: voiceArray, + instruments: Array.isArray(instruments) ? instruments : [], + tempo: rhythm || undefined, + projectId: selectedProjectId || undefined, + }; + }, [selectedProject, genres, voice, instruments, rhythm, selectedProjectId]); + + const steps = useMemo( + () => [ + { + key: "genres", + render: () => , + }, + { + key: "voice", + render: () => ( + + ), + }, + { + key: "instruments", + render: () => ( + + ), + }, + { + key: "rhythm", + render: () => ( + + ), + }, + ], + [genres, voice, instruments, rhythm] + ); + + useEffect(() => { + const nextProgress = 18 + selectedIndex * 9; + if (nextProgress !== progress) { + setProgress(nextProgress); + } + try { + scrollRef.current?.scrollToIndex?.({ + index: selectedIndex, + animated: true, + }); + } catch (_) {} + }, [selectedIndex, progress, containerWidth]); + + const getItemLayout = useCallback( + (_data, index) => ({ + length: containerWidth, + offset: containerWidth * index, + index, + }), + [containerWidth] + ); + + const onPressNext = async () => { + if (selectedIndex === steps.length - 1) { + try { + if (selectedProjectId) { + await updateProjectData({ + musicConfig: { + title: musicConfig?.title || "", + lyrics: Array.isArray(musicConfig?.lyrics) + ? musicConfig.lyrics + : [], + genres: Array.isArray(musicConfig?.genres) + ? musicConfig.genres + : [], + voice: Array.isArray(musicConfig?.voice) ? musicConfig.voice : [], + instruments: Array.isArray(musicConfig?.instruments) + ? musicConfig.instruments + : [], + tempo: musicConfig?.tempo || "", + }, + musicStatus: null, + sunoTaskId: firebase.firestore.FieldValue.delete(), + musicUrls: firebase.firestore.FieldValue.delete(), + }); + } + } catch (e) {} + navigate(Routes.GeneratingSong, { config: musicConfig }); + return; + } + setSelectedIndex((prev) => Math.min(prev + 1, steps.length - 1)); + }; + + const onPressBack = () => { + if (selectedIndex > 0) { + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else { + goBack(); + } + }; + + return ( + + + + setParentLayout(event.nativeEvent.layout)} + > + item.key} + horizontal + pagingEnabled + scrollEnabled={false} + showsHorizontalScrollIndicator={false} + initialScrollIndex={selectedIndex} + getItemLayout={getItemLayout} + style={{ width: containerWidth }} + renderItem={({ item }) => ( + + {item.render()} + + )} + /> + + {selectedIndex !== steps.length && ( + + )} + + + ); +}; + +export default ComposeSong; diff --git a/src/screens/Studio/GeneratingSong.js b/src/screens/Studio/GeneratingSong.js index 2146995..39be21f 100644 --- a/src/screens/Studio/GeneratingSong.js +++ b/src/screens/Studio/GeneratingSong.js @@ -2,14 +2,16 @@ import { useIsFocused } from "@react-navigation/native"; import { BlurView } from "expo-blur"; import moment from "moment"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Image, Platform, Text, View } from "react-native"; +import { Image, Platform, Text, View } from "react-native"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { ai, background } from "../../assets"; +import AppAlert from "../../components/Alert"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import ProgressBar from "../../components/ProgressBar"; import firebase, { projectsRef } from "../../config/firebase"; +import { isWeb } from "../../hooks/useLayoutType"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { navigate } from "../../navigation/NavigationService"; @@ -36,7 +38,7 @@ const GeneratingSong = () => { const totalMs = 8 * 60 * 1000; const clearTimer = () => { if (progressTimerRef.current) { - clearInterval(progressTimerRef.current); + global.clearInterval(progressTimerRef.current); progressTimerRef.current = null; } }; @@ -80,7 +82,7 @@ const GeneratingSong = () => { update(); clearTimer(); - progressTimerRef.current = setInterval(update, 1000); + progressTimerRef.current = global.setInterval(update, 1000); return () => clearTimer(); }, [selectedProject?.musicStatus, selectedProject?.generationStartAt]); @@ -163,11 +165,11 @@ const GeneratingSong = () => { .catch(() => {}) .finally(() => { // Keep locked while status transitions to GENERATING; will be prevented by guards - setTimeout(() => { + global.setTimeout(() => { callingRef.current = false; }, 500); }); - }, []); + }, [startMusicGeneration]); // Trigger generation only when focused; ask once while idle useEffect(() => { @@ -183,7 +185,7 @@ const GeneratingSong = () => { if (titleOk && isIdle && !askedRef.current) { askedRef.current = true; - Alert.alert("Attention", "Une génération va être lancée. Continuer ?", [ + AppAlert("Attention", "Une génération va être lancée. Continuer ?", [ { text: "Non", style: "cancel", @@ -194,7 +196,12 @@ const GeneratingSong = () => { { text: "Oui", onPress: () => startMusicGenerationOnce() }, ]); } - }, [isFocused, selectedProject?.title, selectedProject?.musicStatus]); + }, [ + isFocused, + selectedProject?.title, + selectedProject?.musicStatus, + startMusicGenerationOnce, + ]); return ( @@ -227,7 +234,11 @@ const GeneratingSong = () => { > diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index db881e7..fe9068c 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -1,3 +1,4 @@ +/* global setInterval, clearInterval */ import { useFocusEffect } from "@react-navigation/native"; import { useAudioPlayer } from "expo-audio"; import { BlurView } from "expo-blur"; @@ -51,11 +52,18 @@ const SongReady = () => { // Sync progression depuis les players useEffect(() => { + // expo-audio returns seconds on native, but on web values can be milliseconds + const toMs = (t) => { + const n = Number(t || 0); + if (!isFinite(n) || n <= 0) return 0; + return Platform.OS === "web" ? n : n * 1000; + }; + const id = setInterval(() => { - const d0 = (player0?.duration || 0) * 1000; - const p0 = (player0?.currentTime || 0) * 1000; - const d1 = (player1?.duration || 0) * 1000; - const p1 = (player1?.currentTime || 0) * 1000; + const d0 = toMs(player0?.duration); + const p0 = toMs(player0?.currentTime); + const d1 = toMs(player1?.duration); + const p1 = toMs(player1?.currentTime); setProgressInfo({ 0: { pos: p0, dur: d0 }, 1: { pos: p1, dur: d1 } }); setIsPlaying({ 0: !!player0?.playing, 1: !!player1?.playing }); }, 300); @@ -103,6 +111,7 @@ const SongReady = () => { const pos = Math.floor(dur * ratio); const player = idx === 0 ? player0 : player1; if (player && dur > 0) { + // seekTo expects seconds await player.seekTo?.(Math.floor((pos || 0) / 1000)); } } catch (e) { diff --git a/src/screens/cover/PhotoCover.js b/src/screens/cover/PhotoCover.js index b858f9c..df8c214 100644 --- a/src/screens/cover/PhotoCover.js +++ b/src/screens/cover/PhotoCover.js @@ -1,20 +1,21 @@ -import { ActivityIndicator, Text, View } from "react-native"; -import React from "react"; -import MusicLandHeader from "../../components/MusicLandHeader"; -import { background } from "../../assets"; -import Page from "../../layouts/Page"; -import { goBack, navigate } from "../../navigation/NavigationService"; -import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; -import { gutters, Palette, Style } from "../../styles"; -import BorderGradientButton from "../../components/BorderGradientButton"; -import { Routes } from "../../navigation"; -import GradientButton from "../../components/GradientButton"; -import * as ImagePicker from "expo-image-picker"; -import { uploadFileToFirebase } from "../../helpers/uploadToFirebase"; -import { useUserData } from "../../providers/UserDataProvider"; -import useMinuit from "react-native-minuit/src/hooks/useMinuit"; -import firebase, { tasksRef } from "../../config/firebase"; import { Image as ExpoImage } from "expo-image"; +import * as ImagePicker from "expo-image-picker"; +import React from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import useMinuit from "react-native-minuit/src/hooks/useMinuit"; +import { background } from "../../assets"; +import BorderGradientButton from "../../components/BorderGradientButton"; +import GradientButton from "../../components/GradientButton"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import firebase, { tasksRef } from "../../config/firebase"; +import { uploadFileToFirebase } from "../../helpers/uploadToFirebase"; +import { isWeb } from "../../hooks/useLayoutType"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +import { useUserData } from "../../providers/UserDataProvider"; +import { gutters, Palette, Style } from "../../styles"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const PhotoCover = () => { const { selectedProject, selectedProjectId, updateProjectData } = @@ -81,7 +82,12 @@ const PhotoCover = () => { priority="high" contentFit="cover" transition={120} - style={{ width: "100%", height: 300, borderRadius: 20 }} + style={{ + width: isWeb ? 300 : "100%", + height: 300, + borderRadius: 20, + alignSelf: "center", + }} /> ) : ( { const { selectedProjectId, selectedProject, updateProjectData } = useUser(); @@ -101,12 +95,17 @@ const PouchReady = () => { priority="high" contentFit="cover" transition={120} - style={{ width: "100%", height: 300, borderRadius: 20 }} + style={{ + width: isWeb ? 300 : "100%", + height: 300, + borderRadius: 20, + alignSelf: "center", + }} /> ) : ( { const { selectedProject, updateProjectData } = useUserData(); @@ -57,10 +58,11 @@ const ValidateCover = () => { { > {/*