380 lines
11 KiB
JavaScript
380 lines
11 KiB
JavaScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { Image, Pressable, Text, View } from "react-native";
|
|
import { background, icons } from "../../assets";
|
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
|
import GradientButton from "../../components/GradientButton";
|
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
|
import Slider from "../../components/Slider";
|
|
import Page from "../../layouts/Page";
|
|
import { Routes } from "../../navigation";
|
|
import { goBack, navigate } from "../../navigation/NavigationService";
|
|
import { gutters, Palette } from "../../styles";
|
|
import { isWeb } from "../../hooks/useLayoutType";
|
|
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
|
import { releaseBlobUrl } from "../../utils/blobUrlCache";
|
|
|
|
const fmtSeconds = (s) => {
|
|
const total = Math.max(0, Math.floor(Number(s || 0)));
|
|
const m = Math.floor(total / 60).toString();
|
|
const sec = (total % 60).toString().padStart(2, "0");
|
|
return `${m}:${sec}`;
|
|
};
|
|
|
|
const toSeconds = (value) => {
|
|
const n = Number(value ?? 0);
|
|
if (!Number.isFinite(n) || n < 0) return 0;
|
|
return n > 10000 ? n / 1000 : n; // heuristique ms → s
|
|
};
|
|
|
|
const WEB_PREVIEW_WIDTH = 360;
|
|
|
|
const RecordedPlayback = ({ route }) => {
|
|
const { videoUri, project } = route.params || {};
|
|
const songUrl = project?.songUrl || null;
|
|
// AUDIO PLAYER (expo-audio → seconds)
|
|
const audioPlayer = useSharedAudioPlayer(
|
|
songUrl ? { uri: songUrl } : undefined,
|
|
{
|
|
id: project?.id
|
|
? `recorded-${project.id}`
|
|
: songUrl
|
|
? `recorded-${songUrl}`
|
|
: undefined,
|
|
title: typeof project?.title === "string" ? project.title : "Sans titre",
|
|
artist:
|
|
typeof project?.userName === "string" ? project.userName : "MusicLand",
|
|
artwork: project?.coverUrl || null,
|
|
coverUrl: project?.coverUrl || null,
|
|
metadata: { projectId: project?.id, screen: "RecordedPlayback" },
|
|
},
|
|
);
|
|
|
|
// Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
|
|
const videoElRef = useRef(null);
|
|
const playbackEndedRef = useRef(false);
|
|
const shouldPreserveBlobRef = useRef(false);
|
|
|
|
const [progress, setProgress] = useState({
|
|
posS: 0, // secondes
|
|
durS: 0, // secondes
|
|
playing: false,
|
|
});
|
|
|
|
const stopPlayback = useCallback(async () => {
|
|
try {
|
|
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
|
} catch {}
|
|
try {
|
|
if (videoElRef.current && !videoElRef.current.paused) {
|
|
videoElRef.current.pause();
|
|
}
|
|
} catch {}
|
|
}, [audioPlayer]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (!shouldPreserveBlobRef.current) {
|
|
releaseBlobUrl(videoUri || null);
|
|
}
|
|
};
|
|
}, [videoUri]);
|
|
|
|
// Démarrage / arrêt
|
|
useEffect(() => {
|
|
playbackEndedRef.current = false;
|
|
const start = async () => {
|
|
try {
|
|
if (audioPlayer && songUrl) {
|
|
await audioPlayer.play?.();
|
|
}
|
|
if (videoElRef.current && videoUri) {
|
|
// Lecture vidéo HTML5 (muet pour éviter les policies)
|
|
videoElRef.current.muted = true;
|
|
videoElRef.current.play().catch(() => {});
|
|
}
|
|
} catch {}
|
|
};
|
|
start();
|
|
|
|
return () => {
|
|
playbackEndedRef.current = false;
|
|
void stopPlayback();
|
|
};
|
|
}, [audioPlayer, songUrl, stopPlayback, videoUri]);
|
|
|
|
// Boucle de progression + éventuelle sync de la vidéo si fournie
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
try {
|
|
const durS = toSeconds(audioPlayer?.duration); // secondes
|
|
const posS = toSeconds(audioPlayer?.currentTime); // secondes
|
|
|
|
setProgress({
|
|
posS,
|
|
durS,
|
|
playing: !!audioPlayer?.playing,
|
|
});
|
|
|
|
// Sync vidéo si on a une source vidéo
|
|
if (
|
|
videoElRef.current &&
|
|
videoUri &&
|
|
!Number.isNaN(videoElRef.current.currentTime)
|
|
) {
|
|
const v = Number(videoElRef.current.currentTime || 0);
|
|
const drift = Math.abs(v - posS);
|
|
if (drift > 0.35) {
|
|
videoElRef.current.currentTime = Math.max(0, posS);
|
|
}
|
|
}
|
|
} catch {}
|
|
}, 250);
|
|
return () => clearInterval(id);
|
|
}, [audioPlayer, videoUri]);
|
|
|
|
// Slider: ratio 0..1
|
|
const sliderProgress = useMemo(() => {
|
|
return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0;
|
|
}, [progress]);
|
|
|
|
useEffect(() => {
|
|
const duration = progress?.durS || 0;
|
|
if (!duration) return;
|
|
const position = progress?.posS || 0;
|
|
if (playbackEndedRef.current && duration - position > 1) {
|
|
playbackEndedRef.current = false;
|
|
return;
|
|
}
|
|
const remaining = Math.max(0, duration - position);
|
|
if (remaining <= 0.35 && !playbackEndedRef.current) {
|
|
playbackEndedRef.current = true;
|
|
void stopPlayback();
|
|
}
|
|
}, [progress, stopPlayback]);
|
|
|
|
const onSeek = async (ratio) => {
|
|
try {
|
|
const durS = Number(progress.durS || 0);
|
|
const target = durS * ratio; // secondes
|
|
if (audioPlayer && durS > 0) {
|
|
await audioPlayer.seekTo?.(Math.floor(target)); // seek en secondes
|
|
}
|
|
if (videoElRef.current && videoUri) {
|
|
videoElRef.current.currentTime = Math.max(0, target);
|
|
}
|
|
} catch {}
|
|
};
|
|
|
|
const wasPlayingRef = useRef(false);
|
|
const isSeekingRef = useRef(false);
|
|
const onSeekStart = useCallback(async () => {
|
|
try {
|
|
if (isSeekingRef.current) return;
|
|
isSeekingRef.current = true;
|
|
wasPlayingRef.current = !!audioPlayer?.playing;
|
|
playbackEndedRef.current = false;
|
|
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
|
if (videoElRef.current && !videoElRef.current.paused) {
|
|
videoElRef.current.pause();
|
|
}
|
|
} catch {}
|
|
}, [audioPlayer]);
|
|
const onSeekEnd = useCallback(async () => {
|
|
try {
|
|
if (!isSeekingRef.current) return;
|
|
isSeekingRef.current = false;
|
|
if (wasPlayingRef.current) {
|
|
if (audioPlayer) await audioPlayer.play?.();
|
|
if (videoElRef.current && videoUri)
|
|
videoElRef.current.play().catch(() => {});
|
|
}
|
|
} catch {}
|
|
}, [audioPlayer, videoUri]);
|
|
|
|
const handleTogglePlayback = useCallback(async () => {
|
|
try {
|
|
const duration = Number(progress?.durS || 0);
|
|
const position = Number(progress?.posS || 0);
|
|
const isAtEnd = duration > 0 && duration - position < 0.35;
|
|
const isCurrentlyPlaying = !!audioPlayer?.playing;
|
|
|
|
if (isCurrentlyPlaying) {
|
|
playbackEndedRef.current = false;
|
|
await stopPlayback();
|
|
return;
|
|
}
|
|
|
|
if (isAtEnd) {
|
|
if (audioPlayer) await audioPlayer.seekTo?.(0);
|
|
if (videoElRef.current && videoUri) {
|
|
videoElRef.current.currentTime = 0;
|
|
}
|
|
}
|
|
|
|
playbackEndedRef.current = false;
|
|
if (songUrl && audioPlayer) {
|
|
await audioPlayer.play?.();
|
|
}
|
|
if (videoElRef.current && videoUri) {
|
|
videoElRef.current.muted = true;
|
|
videoElRef.current.play().catch(() => {});
|
|
}
|
|
} catch {}
|
|
}, [audioPlayer, songUrl, stopPlayback, progress, videoUri]);
|
|
|
|
return (
|
|
<Page
|
|
containerStyle={{
|
|
alignItems: "none",
|
|
}}
|
|
backgroundImg={background.playbackBG2}
|
|
headerType="NONE"
|
|
>
|
|
<MusicLandHeader
|
|
progress={19}
|
|
onPressBack={goBack}
|
|
style={{
|
|
marginBottom: 40,
|
|
}}
|
|
/>
|
|
<View
|
|
style={{
|
|
width: "100%",
|
|
}}
|
|
>
|
|
{/* Bloc vidéo optionnel si jamais tu as un videoUri sur web */}
|
|
{videoUri ? (
|
|
<Pressable
|
|
onPress={handleTogglePlayback}
|
|
style={{
|
|
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
|
|
maxWidth: "100%",
|
|
aspectRatio: 9 / 16,
|
|
borderRadius: 12,
|
|
backgroundColor: "#00000066",
|
|
overflow: "hidden",
|
|
alignSelf: "center",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<video
|
|
ref={videoElRef}
|
|
src={videoUri}
|
|
muted
|
|
playsInline
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
backgroundColor: "black",
|
|
}}
|
|
controls={false}
|
|
/>
|
|
{!progress.playing && (
|
|
<View
|
|
pointerEvents="none"
|
|
style={{
|
|
position: "absolute",
|
|
left: "50%",
|
|
top: "50%",
|
|
transform: [{ translateX: -36 }, { translateY: -36 }],
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 36,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: "rgba(10, 5, 24, 0.75)",
|
|
borderWidth: 1,
|
|
borderColor: "#F94697",
|
|
}}
|
|
>
|
|
<Image source={icons.play} style={{ width: 26, height: 26 }} />
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
) : (
|
|
// Placeholder quand pas de vidéo sur web
|
|
<View
|
|
style={{
|
|
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
|
|
maxWidth: "100%",
|
|
aspectRatio: 9 / 16,
|
|
borderRadius: 12,
|
|
backgroundColor: "#00000066",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
alignSelf: "center",
|
|
}}
|
|
>
|
|
<Text style={{ color: Palette.white, opacity: 0.8 }}>
|
|
Aperçu vidéo non disponible sur le web
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
<View
|
|
style={{
|
|
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
|
|
maxWidth: "100%",
|
|
alignSelf: "center",
|
|
marginTop: 6,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<View style={{ width: "100%" }}>
|
|
<Slider
|
|
value={fmtSeconds(progress.posS)} // mm:ss (seconds)
|
|
maxValue={fmtSeconds(progress.durS)} // mm:ss (seconds)
|
|
progress={sliderProgress}
|
|
seekEnabled={!!songUrl}
|
|
onSeek={onSeek}
|
|
onSeekStart={onSeekStart}
|
|
onSeekEnd={onSeekEnd}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<View
|
|
style={{
|
|
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
|
|
maxWidth: "100%",
|
|
alignSelf: "center",
|
|
gap: 12,
|
|
marginTop: 50,
|
|
}}
|
|
>
|
|
<GradientButton
|
|
title="Je valide"
|
|
onPress={() => {
|
|
shouldPreserveBlobRef.current = true;
|
|
navigate(Routes.PlaybackDownload, {
|
|
action: "playback",
|
|
uri: videoUri || null, // peut être null sur web
|
|
project,
|
|
});
|
|
}}
|
|
/>
|
|
<BorderGradientButton
|
|
title="Recommencer"
|
|
onPress={async () => {
|
|
try {
|
|
await stopPlayback();
|
|
} catch {}
|
|
navigate(Routes.RecordPlayback, { project });
|
|
}}
|
|
/>
|
|
</View>
|
|
</Page>
|
|
);
|
|
};
|
|
|
|
export default RecordedPlayback;
|