more fixes and changes, crash, design …

This commit is contained in:
Thomas Demirdjian
2025-11-14 18:25:28 +01:00
parent cd31d42c0c
commit 705fda0ce3
17 changed files with 1136 additions and 785 deletions
+307 -359
View File
@@ -11,9 +11,7 @@ import GradientButton from "../../components/GradientButton";
import ValidateModal from "../../components/modal/ValidateModal";
import MusicLandHeader from "../../components/MusicLandHeader";
import Slider from "../../components/Slider";
import CreditAmount from "../../components/CreditAmount";
import { isWeb } from "../../hooks/useLayoutType";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { navigate } from "../../navigation/NavigationService";
@@ -21,11 +19,20 @@ import { useUser } from "../../providers/UserDataProvider";
import { Palette, Style } from "../../styles";
import { gutters, size } from "../../styles/Style";
import { FONT_FAMILY } from "../../styles/Fonts";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const MUSIC_GENERATION_COIN_COST = 8;
const SONG_OPTIONS_PER_GENERATION = 2;
const REGENERATE_MODAL_MAX_WIDTH = 540;
const formatTime = (ms) => {
const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000));
const minutes = Math.floor(totalSeconds / 60)
.toString()
.padStart(1, "0");
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
};
const SongReady = () => {
const {
@@ -38,219 +45,72 @@ const SongReady = () => {
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [musicUrls, setMusicUrls] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState({ 0: false, 1: false });
const [progressInfo, setProgressInfo] = useState({
0: { pos: 0, dur: 0 },
1: { pos: 0, dur: 0 },
});
const playerRefs = useRef({});
const player0 = useSharedAudioPlayer(
musicUrls[0] ? { uri: musicUrls[0] } : undefined,
{
id: musicUrls[0] ? `songready-${musicUrls[0]}` : undefined,
title:
(Array.isArray(selectedProject?.musicTitles)
? selectedProject?.musicTitles?.[0]
: null) ||
selectedProject?.title ||
"Option 1",
artwork: selectedProject?.coverUrl || null,
coverUrl: selectedProject?.coverUrl || null,
metadata: { index: 0, projectId },
const registerPlayer = useCallback((index, player) => {
if (player) {
playerRefs.current[index] = player;
} else {
delete playerRefs.current[index];
}
}, []);
const pauseAllExcept = useCallback(async (keepIndex = null) => {
const tasks = Object.entries(playerRefs.current).map(
async ([key, player]) => {
const idx = Number(key);
if (!player || idx === keepIndex) return;
try {
await player.pause?.();
} catch (error) {
console.log("SongReady pause error", error?.message);
}
},
);
await Promise.all(tasks);
}, []);
const pauseAllPlayers = useCallback(async () => {
await pauseAllExcept(null);
}, [pauseAllExcept]);
const handleTogglePlayback = useCallback(
async (index, isPlayingNow) => {
const player = playerRefs.current[index];
if (!player) return;
if (isPlayingNow) {
try {
await player.pause?.();
} catch (error) {
console.log("SongReady pause toggle", error?.message);
}
return;
}
await pauseAllExcept(index);
try {
await player.play?.();
} catch (error) {
console.log("SongReady play error", error?.message);
}
},
[pauseAllExcept],
);
const player1 = useSharedAudioPlayer(
musicUrls[1] ? { uri: musicUrls[1] } : undefined,
{
id: musicUrls[1] ? `songready-${musicUrls[1]}` : undefined,
title:
(Array.isArray(selectedProject?.musicTitles)
? selectedProject?.musicTitles?.[1]
: null) ||
selectedProject?.title ||
"Option 2",
artwork: selectedProject?.coverUrl || null,
coverUrl: selectedProject?.coverUrl || null,
metadata: { index: 1, projectId },
},
);
const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });
const prefetchedRef = useRef({
0: { url: null, done: false },
1: { url: null, done: false },
});
// Sync URLs from provider's selectedProject
useEffect(() => {
const urls = Array.isArray(selectedProject?.musicUrls)
? selectedProject.musicUrls.slice(0, 2)
? selectedProject.musicUrls
.filter((url) => typeof url === "string" && url.trim())
.map((url) => url.trim())
: [];
setMusicUrls(urls);
setSelectedIndex((prev) => {
if (!urls.length) return 0;
return Math.min(prev, urls.length - 1);
});
}, [selectedProject?.musicUrls]);
const firstUrl = musicUrls[0] ?? null;
const secondUrl = musicUrls[1] ?? null;
useEffect(() => {
let isCancelled = false;
const waitForDuration = async (index) => {
const getDurationSeconds = () => {
const targetPlayer = index === 0 ? player0 : player1;
if (!targetPlayer) return 0;
const raw = Number(targetPlayer.duration || 0);
return Number.isFinite(raw) ? raw : 0;
};
const start = Date.now();
while (!isCancelled) {
const durationSeconds = getDurationSeconds();
if (durationSeconds > 0) {
const durationMs = Math.round(durationSeconds * 1000);
setProgressInfo((prev) => {
const current = prev?.[index] || { pos: 0, dur: 0 };
if (Math.abs(current.dur - durationMs) < 5) return prev;
return {
...prev,
[index]: {
...current,
dur: durationMs,
},
};
});
return true;
}
if (Date.now() - start > 6000) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
return false;
};
const prefetch = async () => {
const entries = [
[0, firstUrl, player0],
[1, secondUrl, player1],
];
for (const [idx, url, player] of entries) {
const cacheEntry = prefetchedRef.current[idx];
if (!url || !player?.load) {
if (cacheEntry) {
cacheEntry.url = url ?? null;
cacheEntry.done = false;
}
continue;
}
if (cacheEntry && cacheEntry.url === url && cacheEntry.done) {
continue;
}
if (cacheEntry) {
cacheEntry.url = url;
cacheEntry.done = false;
}
try {
await player.load({ startPositionMs: 0 });
const resolved = await waitForDuration(idx);
if (cacheEntry && !isCancelled) {
cacheEntry.done = resolved;
}
} catch (err) {
if (cacheEntry) {
cacheEntry.done = false;
}
if (!isCancelled) {
console.log("SongReady preload error", err?.message);
}
}
if (isCancelled) {
break;
}
}
};
prefetch();
return () => {
isCancelled = true;
};
}, [firstUrl, secondUrl, player0, player1]);
// Sync progression depuis les players
useEffect(() => {
// Ensure we consistently work with milliseconds whatever the platform
const toMs = (t) => {
const n = Number(t || 0);
if (!isFinite(n) || n <= 0) return 0;
return n >= 1000 ? n : n * 1000;
};
const id = setInterval(() => {
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);
return () => clearInterval(id);
}, [player0, player1]);
const togglePlay = async (idx) => {
setSelectedIndex(idx);
const url = musicUrls[idx];
if (!url) return;
try {
// Pause l'autre piste si elle joue
const other = idx === 0 ? 1 : 0;
if (isPlaying[other]) {
if (other === 0) await player0?.pause?.();
else await player1?.pause?.();
setIsPlaying((p) => ({ ...p, [other]: false }));
}
const player = idx === 0 ? player0 : player1;
if (!player) return;
if (player.playing) {
await player.pause?.();
setIsPlaying((p) => ({ ...p, [idx]: false }));
} else {
await player.play?.();
setIsPlaying((p) => ({ ...p, [idx]: true }));
}
} catch (e) {
console.log("Audio error", e?.message);
}
};
const fmt = (ms) => {
const total = Math.max(0, Math.floor((ms || 0) / 1000));
const m = Math.floor(total / 60)
.toString()
.padStart(1, "0");
const s = (total % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const onSeek = async (idx, ratio) => {
try {
const info = progressInfo[idx] || {};
const dur = info.dur || 0;
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) {
console.log("Seek error", e?.message);
}
};
const validateSelection = async () => {
try {
@@ -277,8 +137,7 @@ const SongReady = () => {
shouldSetTooltip: false,
});
await player0?.pause?.();
await player1?.pause?.();
await pauseAllPlayers();
navigate(Routes.ChooseCoverType);
} catch (e) {
console.log("Validate error", e?.message);
@@ -289,22 +148,16 @@ const SongReady = () => {
useFocusEffect(
useCallback(() => {
return () => {
try {
player0?.pause?.();
player1?.pause?.();
} catch {}
pauseAllPlayers();
};
}, [player0, player1]),
}, [pauseAllPlayers]),
);
useEffect(() => {
return () => {
try {
player0?.pause?.();
player1?.pause?.();
} catch {}
pauseAllPlayers();
};
}, [player0, player1]);
}, [pauseAllPlayers]);
const handleChooseTrack = () => {
if (isWeb) {
@@ -334,8 +187,7 @@ const SongReady = () => {
const handleConfirmRegenerate = async () => {
setShowRegenerateModal(false);
try {
await player0?.pause?.();
await player1?.pause?.();
await pauseAllPlayers();
} catch {}
navigate(Routes.ComposeSong, { isRegeneration: true });
};
@@ -368,8 +220,7 @@ const SongReady = () => {
<MusicLandHeader
onPressBack={async () => {
try {
await player0?.pause?.();
await player1?.pause?.();
await pauseAllPlayers();
} catch {}
navigate(Routes.Home);
}}
@@ -384,146 +235,19 @@ const SongReady = () => {
}}
/>
<View style={{ gap: 16 }}>
{[0, 1].map((idx) => {
const isSelected = selectedIndex === idx;
return (
<Pressable
key={idx}
onPress={() => setSelectedIndex(idx)}
style={({ pressed }) => [
{
borderRadius: 20,
borderWidth: isSelected ? 2 : 1,
borderColor: isSelected
? Palette.primary
: Palette.ultraLightWhite,
overflow: "hidden",
},
pressed && { opacity: 0.96 },
]}
>
<BlurView
intensity={40}
tint="dark"
style={{
borderRadius: 18,
overflow: "hidden",
padding: 12,
backgroundColor: "#FFFFFF0A",
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
<Pressable
onPress={() => togglePlay(idx)}
style={{
...Style.containerCenter,
...size({ size: 48 }),
}}
>
<Image
source={isPlaying[idx] ? icons.pause : icons.play}
/>
</Pressable>
<View style={{ flex: 1 }}>
<Text
style={{
color: "white",
marginBottom: responsiveHeight(1),
}}
>{`Morceau ${idx + 1}`}</Text>
<Slider
value={fmt(progressInfo[idx]?.pos)}
maxValue={fmt(progressInfo[idx]?.dur)}
progress={
progressInfo[idx]?.dur
? (progressInfo[idx].pos || 0) /
progressInfo[idx].dur
: 0
}
seekEnabled={!!musicUrls[idx]}
onSeekStart={async () => {
setSelectedIndex(idx);
try {
const player = idx === 0 ? player0 : player1;
wasPlayingBeforeSeek.current[idx] =
!!player?.playing;
if (player?.playing) {
await player.pause?.();
setIsPlaying((p) => ({ ...p, [idx]: false }));
}
} catch (e) {
console.log(
"SongReady pause on seek start",
e?.message,
);
}
}}
onSeek={(ratio) => onSeek(idx, ratio)}
onSeekEnd={async () => {
try {
const player = idx === 0 ? player0 : player1;
if (player && wasPlayingBeforeSeek.current[idx]) {
if (player.resume) {
await player.resume?.();
} else {
await player.play?.();
}
setIsPlaying((p) => ({ ...p, [idx]: true }));
}
wasPlayingBeforeSeek.current[idx] = false;
} catch (e) {
console.log(
"SongReady resume after seek",
e?.message,
);
}
}}
/>
</View>
<Pressable
onPress={() => setSelectedIndex(idx)}
style={{
...Style.containerCenter,
...size({ size: 24 }),
}}
>
<View
style={{
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: isSelected ? Palette.primary : "white",
alignItems: "center",
justifyContent: "center",
}}
>
{isSelected && (
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: Palette.primary,
}}
/>
)}
</View>
</Pressable>
</View>
</BlurView>
</Pressable>
);
})}
{musicUrls.map((url, idx) => (
<SongOptionCard
key={`${url || "song"}-${idx}`}
index={idx}
url={url}
isSelected={selectedIndex === idx}
onSelect={setSelectedIndex}
registerPlayer={registerPlayer}
onTogglePlayback={handleTogglePlayback}
projectId={projectId}
selectedProject={selectedProject}
/>
))}
</View>
</View>
<View
@@ -562,6 +286,230 @@ const SongReady = () => {
);
};
const SongOptionCard = ({
index,
url,
isSelected,
onSelect,
registerPlayer,
onTogglePlayback,
projectId,
selectedProject,
}) => {
const trackTitle =
(Array.isArray(selectedProject?.musicTitles)
? selectedProject.musicTitles?.[index]
: null) ||
selectedProject?.title ||
`Morceau ${index + 1}`;
const player = useSharedAudioPlayer(url ? { uri: url } : undefined, {
id: url ? `songready-${url}` : undefined,
title: trackTitle,
artwork: selectedProject?.coverUrl || null,
coverUrl: selectedProject?.coverUrl || null,
metadata: { index, projectId },
});
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
const [isPlaying, setIsPlaying] = useState(false);
const wasPlayingBeforeSeek = useRef(false);
useEffect(() => {
registerPlayer(index, player);
return () => registerPlayer(index, null);
}, [index, player, registerPlayer]);
useEffect(() => {
if (!player || !url) return;
const preload = async () => {
try {
await player.load?.({ startPositionMs: 0 });
} catch (error) {
console.log("SongReady preload error", error?.message);
}
};
preload();
}, [player, url]);
useEffect(() => {
if (!player) {
setIsPlaying(false);
setProgressInfo({ pos: 0, dur: 0 });
return undefined;
}
const id = setInterval(() => {
const durationMs = Math.max(
0,
Math.round((Number(player.duration) || 0) * 1000),
);
const positionMs = Math.max(
0,
Math.round((Number(player.currentTime) || 0) * 1000),
);
setProgressInfo((prev) => {
if (
Math.abs((prev?.dur || 0) - durationMs) < 5 &&
Math.abs((prev?.pos || 0) - positionMs) < 5
) {
return prev;
}
return { pos: positionMs, dur: durationMs };
});
setIsPlaying((prev) => {
const next = !!player.playing;
if (prev !== next) {
return next;
}
return prev;
});
}, 300);
return () => clearInterval(id);
}, [player]);
const handleSeek = async (ratio) => {
if (!player || !progressInfo?.dur) return;
const dur = progressInfo.dur || 0;
const pos = Math.max(0, Math.floor(dur * ratio));
try {
await player.seekTo?.(Math.floor(pos / 1000));
setProgressInfo((prev) => ({ ...prev, pos }));
} catch (error) {
console.log("SongReady seek error", error?.message);
}
};
const handleSeekStart = async () => {
onSelect(index);
if (!player) return;
try {
wasPlayingBeforeSeek.current = !!player.playing;
if (player.playing) {
await player.pause?.();
}
} catch (error) {
console.log("SongReady pause on seek start", error?.message);
}
};
const handleSeekEnd = async () => {
if (!player) return;
try {
if (wasPlayingBeforeSeek.current) {
if (player.resume) {
await player.resume?.();
} else {
await player.play?.();
}
}
} catch (error) {
console.log("SongReady resume after seek", error?.message);
} finally {
wasPlayingBeforeSeek.current = false;
}
};
const handleToggle = () => {
onSelect(index);
onTogglePlayback?.(index, isPlaying);
};
return (
<Pressable
onPress={() => onSelect(index)}
style={({ pressed }) => [
{
borderRadius: 20,
borderWidth: isSelected ? 2 : 1,
borderColor: isSelected ? Palette.primary : Palette.ultraLightWhite,
overflow: "hidden",
},
pressed && { opacity: 0.96 },
]}
>
<BlurView
intensity={40}
tint="dark"
style={{
borderRadius: 18,
overflow: "hidden",
padding: 12,
backgroundColor: "#FFFFFF0A",
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
<Pressable
onPress={handleToggle}
style={{
...Style.containerCenter,
...size({ size: 48 }),
}}
>
<Image source={isPlaying ? icons.pause : icons.play} />
</Pressable>
<View style={{ flex: 1 }}>
<Text
style={{
color: "white",
marginBottom: responsiveHeight(1),
}}
>{`Morceau ${index + 1}`}</Text>
<Slider
value={formatTime(progressInfo?.pos)}
maxValue={formatTime(progressInfo?.dur)}
progress={
progressInfo?.dur
? (progressInfo.pos || 0) / progressInfo.dur
: 0
}
seekEnabled={!!url}
onSeekStart={handleSeekStart}
onSeek={handleSeek}
onSeekEnd={handleSeekEnd}
/>
</View>
<Pressable
onPress={() => onSelect(index)}
style={{
...Style.containerCenter,
...size({ size: 24 }),
}}
>
<View
style={{
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: isSelected ? Palette.primary : "white",
alignItems: "center",
justifyContent: "center",
}}
>
{isSelected && (
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: Palette.primary,
}}
/>
)}
</View>
</Pressable>
</View>
</BlurView>
</Pressable>
);
};
const RegenerateModal = ({ visible, onClose, onConfirm }) => {
return (
<Modal