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 = () => {
{
>
{/*