second flow

This commit is contained in:
2025-10-01 16:49:12 +02:00
parent efc84ce2ab
commit 1fd63c254d
8 changed files with 505 additions and 65 deletions
+15 -6
View File
@@ -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)();
}
@@ -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)();
}
});
+182
View File
@@ -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 (
<View style={styles.container}>
<View
style={[
styles.interactionArea,
seekEnabled ? styles.pointerEnabled : null,
]}
onLayout={handleLayout}
onStartShouldSetResponderCapture={() => seekEnabled}
onStartShouldSetResponder={() => seekEnabled}
onMoveShouldSetResponder={() => seekEnabled}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
onResponderRelease={finishSeeking}
onResponderTerminate={finishSeeking}
>
<View style={styles.sliderTrack}>
<View style={[styles.box, { width: INITIAL_BOX_SIZE + offset }]}>
<LinearGradient
colors={["#F94697", "#7023F7"]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{ flex: 1, borderRadius: 20 }}
/>
</View>
</View>
<View style={[styles.sliderHandle, { left: offset }]} />
</View>
<View style={{ ...Style.containerSpaceBetween, marginTop: 10 }}>
<Text style={styles.time}>{value}</Text>
<Text style={styles.time}>{maxValue}</Text>
</View>
</View>
);
};
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;
+217
View File
@@ -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: () => <ChooseGenre selected={genres} setSelected={setGenres} />,
},
{
key: "voice",
render: () => (
<CustomizeVoice selected={voice} setSelected={setVoice} />
),
},
{
key: "instruments",
render: () => (
<ChooseInstruments
selected={instruments}
setSelected={setInstruments}
/>
),
},
{
key: "rhythm",
render: () => (
<ChooseRhythm selected={rhythm} setSelected={setRhythm} />
),
},
],
[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 (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
<View
style={{ flex: 1 }}
onLayout={(event) => setParentLayout(event.nativeEvent.layout)}
>
<FlatList
ref={scrollRef}
data={steps}
keyExtractor={(item) => item.key}
horizontal
pagingEnabled
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
initialScrollIndex={selectedIndex}
getItemLayout={getItemLayout}
style={{ width: containerWidth }}
renderItem={({ item }) => (
<View
style={{
width: containerWidth,
height: parentLayout?.height,
paddingHorizontal: gutters,
}}
>
{item.render()}
</View>
)}
/>
</View>
{selectedIndex !== steps.length && (
<GradientButton
title={selectedIndex === steps.length - 1 ? "Générer" : "Suivant"}
onPress={onPressNext}
disabled={!isStepValid}
/>
)}
</View>
</Page>
);
};
export default ComposeSong;
+19 -8
View File
@@ -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 (
<Page headerType="NONE" backgroundImg={background.studioBG2}>
@@ -227,7 +234,11 @@ const GeneratingSong = () => {
>
<Image
source={ai.theo}
style={{ width: "100%", height: "100%", right: -10 }}
style={{
width: "100%",
height: isWeb ? 300 : "100%",
right: -10,
}}
resizeMode="contain"
/>
</View>
+13 -4
View File
@@ -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) {
+23 -17
View File
@@ -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",
}}
/>
) : (
<View
+18 -19
View File
@@ -1,26 +1,20 @@
import {
View,
Text,
Image,
ActivityIndicator,
Alert,
setTimeout,
} from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { Image as ExpoImage } from "expo-image";
import React, { useEffect } from "react";
import { useIsFocused } from "@react-navigation/native";
import Page from "../../layouts/Page";
import { background, icons, img } from "../../assets";
import MusicLandHeader from "../../components/MusicLandHeader";
import { goBack, navigate } from "../../navigation/NavigationService";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { gutters, Palette, Style } from "../../styles";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, icons } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import { Routes } from "../../navigation";
import MusicLandHeader from "../../components/MusicLandHeader";
import firebase, { tasksRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { gutters, Palette, Style } from "../../styles";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const PouchReady = () => {
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",
}}
/>
) : (
<View
style={{
width: "100%",
width: isWeb ? 300 : "100%",
height: 300,
borderRadius: 20,
backgroundColor: "#00000040",
+16 -9
View File
@@ -1,16 +1,17 @@
import { View, Image } from "react-native";
import React from "react";
import MusicLandHeader from "../../components/MusicLandHeader";
import { Image, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, img } from "../../assets";
import Page from "../../layouts/Page";
import { goBack, navigate } from "../../navigation/NavigationService";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { gutters, Style } from "../../styles";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
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 useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { gutters, Style } from "../../styles";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const ValidateCover = () => {
const { selectedProject, updateProjectData } = useUserData();
@@ -57,10 +58,11 @@ const ValidateCover = () => {
<Image
source={img.placeholder}
style={{
width: "100%",
width: isWeb ? 300 : "100%",
height: 300,
borderRadius: 20,
transform: [{ rotateY: "180deg" }],
alignSelf: "center",
}}
/>
<View
@@ -68,7 +70,12 @@ const ValidateCover = () => {
>
<Image
source={{ uri: selectedProject?.cover?.result }}
style={{ width: "100%", height: "100%", borderRadius: 20 }}
style={{
width: isWeb ? 300 : "100%",
height: "100%",
borderRadius: 20,
alignSelf: "center",
}}
/>
</View>
{/*<View*/}