Files
musicland/src/screens/Playback/RecordPlayback.web.js
T
2025-10-07 14:14:42 +02:00

960 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useFocusEffect } from "@react-navigation/native";
import { useCameraPermissions } from "expo-camera";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle } from "react-native-svg";
import alert from "../../components/Alert";
import GradientButton from "../../components/GradientButton";
import KaraokeLyrics from "../../components/KaraokeLyrics";
import MusicLandHeader from "../../components/MusicLandHeader";
import { increment, projectsRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const TIME_BEFORE_INCREMENT_MS = 20000;
const LOG_PREFIX = "[RecordPlayback.web]";
const toSeconds = (v) => {
const n = Number(v ?? 0);
if (!Number.isFinite(n) || n < 0) return 0;
return n > 10000 ? n / 1000 : n; // heuristique ms → s
};
const pickRecorderMimeType = () => {
if (typeof window === "undefined" || typeof MediaRecorder === "undefined")
return undefined;
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm;codecs=vp8",
"video/webm",
];
for (const mimeType of candidates) {
try {
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType;
} catch {}
}
return undefined;
};
const RecordPlayback = ({ route }) => {
const { top, bottom } = useSafeAreaInsets();
const { project } = route.params || {};
const songIndex = Number(project?.songIndex ?? 0) || 0;
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
// Player
const songUrl = project?.songUrl || null;
const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: project?.id,
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: "RecordPlayback" },
});
// MediaStream / Recorder (web only)
const previewVideoRef = useRef(null);
const mediaStreamRef = useRef(null);
const mediaRecorderRef = useRef(null);
const recordedChunksRef = useRef([]);
const stopRecordingPromiseRef = useRef(null);
const stopRecordingResolveRef = useRef(null);
const recordedUrlRef = useRef(null);
const [mediaReady, setMediaReady] = useState(false);
const [mediaError, setMediaError] = useState(null);
// UI state
const [countdown, setCountdown] = useState(0);
const [isPreparing, setIsPreparing] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [pos, setPos] = useState(0);
const [dur, setDur] = useState(0);
// timers / refs
const progressTimerRef = useRef(null);
const listenTimerRef = useRef(null);
const countdownTimerRef = useRef(null);
const perfStartRef = useRef(null);
const listenedMsRef = useRef(0);
const viewsIncrementedRef = useRef(false);
const correctedInitialJumpRef = useRef(false);
const progressDebugCounterRef = useRef(0);
// Lyrics
const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[songIndex];
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
return arr.map((w) => ({
word: String(w?.word ?? ""),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
}));
}, [project?.musicTimestamps, songIndex]);
const clearAllTimers = () => {
console.log(LOG_PREFIX, "clearAllTimers");
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
if (listenTimerRef.current) clearInterval(listenTimerRef.current);
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
progressTimerRef.current =
listenTimerRef.current =
countdownTimerRef.current =
null;
};
const resetUI = () => {
console.log(LOG_PREFIX, "resetUI");
setIsPreparing(false);
setIsRecording(false);
setCountdown(0);
setPos(0);
setDur(0);
listenedMsRef.current = 0;
viewsIncrementedRef.current = false;
perfStartRef.current = null;
correctedInitialJumpRef.current = false;
};
const releaseRecordingUrl = useCallback(() => {
if (recordedUrlRef.current) {
try {
URL.revokeObjectURL(recordedUrlRef.current);
} catch {}
recordedUrlRef.current = null;
}
}, []);
const startRecorder = useCallback(() => {
if (!mediaStreamRef.current) {
console.log(LOG_PREFIX, "mediaRecorder:start:noStream");
return false;
}
try {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
}
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:stopPrevious:error",
String(e?.message || e || "")
);
}
recordedChunksRef.current = [];
releaseRecordingUrl();
const mimeType = pickRecorderMimeType();
let recorder;
try {
recorder =
mimeType != null
? new MediaRecorder(mediaStreamRef.current, { mimeType })
: new MediaRecorder(mediaStreamRef.current);
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:createError",
String(e?.message || e || "")
);
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
return false;
}
mediaRecorderRef.current = recorder;
stopRecordingPromiseRef.current = new Promise((resolve) => {
stopRecordingResolveRef.current = resolve;
});
recorder.ondataavailable = (event) => {
if (event?.data && event.data.size > 0) {
recordedChunksRef.current.push(event.data);
}
};
recorder.onerror = (event) => {
const err = event?.error || event;
console.log(
LOG_PREFIX,
"mediaRecorder:error",
String(err?.message || err || "")
);
setMediaError(err instanceof Error ? err : new Error(String(err || "")));
};
recorder.onstop = () => {
let url = null;
try {
if (recordedChunksRef.current.length > 0) {
const blob = new Blob(recordedChunksRef.current, {
type: recorder.mimeType || mimeType || "video/webm",
});
url = URL.createObjectURL(blob);
recordedUrlRef.current = url;
} else {
releaseRecordingUrl();
}
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:onstop:createBlobError",
String(e?.message || e || "")
);
releaseRecordingUrl();
}
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(url);
stopRecordingResolveRef.current = null;
}
recordedChunksRef.current = [];
mediaRecorderRef.current = null;
stopRecordingPromiseRef.current = null;
};
try {
recorder.start(1000);
console.log(LOG_PREFIX, "mediaRecorder:start", {
mimeType: recorder.mimeType,
});
return true;
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:startError",
String(e?.message || e || "")
);
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(null);
stopRecordingResolveRef.current = null;
}
stopRecordingPromiseRef.current = null;
mediaRecorderRef.current = null;
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
return false;
}
}, [releaseRecordingUrl, setMediaError]);
const stopRecorderAndGetUrl = useCallback(async () => {
let waitPromise = stopRecordingPromiseRef.current;
try {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== "inactive") {
if (!waitPromise) {
waitPromise = new Promise((resolve) => {
stopRecordingResolveRef.current = resolve;
});
stopRecordingPromiseRef.current = waitPromise;
}
recorder.stop();
}
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:stopError",
String(e?.message || e || "")
);
}
if (!waitPromise) {
return recordedUrlRef.current || null;
}
let url = null;
try {
url = await waitPromise;
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:waitStopError",
String(e?.message || e || "")
);
} finally {
stopRecordingPromiseRef.current = null;
stopRecordingResolveRef.current = null;
}
return url || recordedUrlRef.current || null;
}, []);
const resetSession = useCallback(async () => {
console.log(LOG_PREFIX, "resetSession:start");
clearAllTimers();
resetUI();
try {
await player?.pause?.();
await player?.seekTo?.(0);
console.log(LOG_PREFIX, "resetSession:playerReset");
} catch (e) {
console.log(
LOG_PREFIX,
"resetSession:error",
String(e?.message || e || "")
);
}
try {
await stopRecorderAndGetUrl();
releaseRecordingUrl();
} catch (e) {
console.log(
LOG_PREFIX,
"resetSession:recorderError",
String(e?.message || e || "")
);
}
console.log(LOG_PREFIX, "resetSession:end");
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
useFocusEffect(
useCallback(() => {
void resetSession();
return () => {};
}, [resetSession])
);
useEffect(() => {
(async () => {
try {
if (!cameraPermission?.granted) {
console.log(LOG_PREFIX, "requestingCameraPermission");
await requestCameraPermission();
console.log(LOG_PREFIX, "requestingCameraPermission:done");
}
} catch (e) {
console.log(
LOG_PREFIX,
"requestingCameraPermission:error",
String(e?.message || e || "")
);
}
})();
return () => {
clearAllTimers();
};
}, []); // eslint-disable-line
useEffect(() => {
if (!cameraPermission?.granted) {
setMediaReady(false);
return;
}
if (mediaStreamRef.current) {
setMediaReady(true);
return;
}
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia
) {
setMediaError(
new Error("La capture vidéo n'est pas supportée sur ce navigateur")
);
setMediaReady(false);
return;
}
let cancelled = false;
(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user" },
audio: true,
});
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
mediaStreamRef.current = stream;
setMediaReady(true);
setMediaError(null);
} catch (e) {
if (cancelled) return;
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
setMediaReady(false);
}
})();
return () => {
cancelled = true;
};
}, [cameraPermission?.granted]);
useEffect(() => {
const video = previewVideoRef.current;
const stream = mediaStreamRef.current;
if (!video) return;
if (stream) {
try {
if (video.srcObject !== stream) {
video.srcObject = stream;
}
const playPromise = video.play?.();
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch(() => {});
}
} catch (e) {
console.log(
LOG_PREFIX,
"previewVideo:attachError",
String(e?.message || e || "")
);
}
} else {
try {
video.srcObject = null;
} catch {}
}
}, [mediaReady, mediaError]);
useEffect(() => {
return () => {
try {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
}
} catch {}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
releaseRecordingUrl();
};
}, [releaseRecordingUrl]);
const stopAndNavigate = useCallback(async () => {
console.log(LOG_PREFIX, "stopAndNavigate:start", {
pos,
dur,
listenedMs: listenedMsRef.current,
});
clearAllTimers();
try {
await player?.pause?.();
console.log(LOG_PREFIX, "stopAndNavigate:playerPaused");
} catch (e) {
console.log(
LOG_PREFIX,
"stopAndNavigate:pauseError",
String(e?.message || e || "")
);
}
let videoUrl = null;
try {
videoUrl = await stopRecorderAndGetUrl();
if (!videoUrl) {
videoUrl = recordedUrlRef.current || null;
}
} catch (e) {
console.log(
LOG_PREFIX,
"stopAndNavigate:recorderError",
String(e?.message || e || "")
);
}
setIsRecording(false);
console.log(LOG_PREFIX, "stopAndNavigate:navigate", {
route: Routes.RecordedPlayback,
hasVideo: !!videoUrl,
});
navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null });
}, [dur, player, pos, project, stopRecorderAndGetUrl]);
const startProgressLoop = useCallback(() => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
progressDebugCounterRef.current = 0;
console.log(LOG_PREFIX, "startProgressLoop");
progressTimerRef.current = setInterval(async () => {
const rawDur = toSeconds(player?.duration);
const rawPos = toSeconds(player?.currentTime);
// fallback monotone si le player ne donne rien
const fallback =
perfStartRef.current != null
? Math.max(0, (performance.now() - perfStartRef.current) / 1000)
: 0;
let nextDur = rawDur > 0 ? rawDur : dur || 0;
let nextPos =
Number.isFinite(rawPos) && rawPos >= 0
? rawPos
: fallback > 0
? fallback
: 0;
// 🔧 Correction 1-shot du "jump initial" (ex: 0 → 93s au 1er tick)
if (!correctedInitialJumpRef.current && nextPos > 1 && fallback < 1.5) {
try {
await player?.seekTo?.(0);
correctedInitialJumpRef.current = true;
nextPos = 0;
console.log(LOG_PREFIX, "progressLoop:correctedInitialJump");
} catch (e) {
console.log(
LOG_PREFIX,
"progressLoop:correctInitialJumpError",
String(e?.message || e || "")
);
}
}
const timeSinceStart =
perfStartRef.current != null
? performance.now() - perfStartRef.current
: null;
const shouldTrustFallback =
fallback > 0 &&
((nextDur > 0 && nextPos > nextDur + 0.5) ||
nextPos - fallback > 1.5 ||
(timeSinceStart != null &&
timeSinceStart < 5000 &&
nextPos > fallback + 1));
if (shouldTrustFallback) {
console.log(LOG_PREFIX, "progressLoop:useFallback", {
rawPos,
fallback,
nextPosBeforeFallback: nextPos,
timeSinceStart,
});
nextPos = fallback;
}
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur;
setDur(nextDur);
setPos(nextPos);
progressDebugCounterRef.current += 1;
if (
progressDebugCounterRef.current <= 12 ||
nextPos >= nextDur - 0.5 ||
progressDebugCounterRef.current % 20 === 0
) {
console.log(LOG_PREFIX, "progressLoop:tick", {
tick: progressDebugCounterRef.current,
rawDur,
rawPos,
fallback,
nextDur,
nextPos,
playing: !!player?.playing,
});
}
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
console.log(LOG_PREFIX, "progressLoop:willStop", {
nextDur,
nextPos,
});
void stopAndNavigate();
}
}, 250);
}, [player, stopAndNavigate]);
const startListenLoop = useCallback(() => {
if (listenTimerRef.current) clearInterval(listenTimerRef.current);
listenTimerRef.current = setInterval(async () => {
try {
if (player?.playing) {
listenedMsRef.current += 500;
if (
!viewsIncrementedRef.current &&
listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS
) {
viewsIncrementedRef.current = true;
console.log(LOG_PREFIX, "listenLoop:incrementViews");
if (project?.id) {
try {
await projectsRef
.doc(project.id)
.set({ views: increment(1) }, { merge: true });
console.log(LOG_PREFIX, "listenLoop:incrementViews:done");
} catch (e) {
console.log(
LOG_PREFIX,
"listenLoop:incrementViews:error",
String(e?.message || e || "")
);
}
}
}
}
} catch (e) {
console.log(
LOG_PREFIX,
"listenLoop:error",
String(e?.message || e || "")
);
}
}, 500);
}, [player, project?.id]);
const startPlayback = useCallback(async () => {
console.log(LOG_PREFIX, "startPlayback:start");
try {
await player?.pause?.(); // s'assure qu'on repart propre
await player?.seekTo?.(0); // tente un seek d'amorçage
const recorderStarted = startRecorder();
if (!recorderStarted) {
console.log(LOG_PREFIX, "startPlayback:recorderNotStarted");
}
await player?.play?.(); // attend la promesse → l'élément est prêt
perfStartRef.current = perfStartRef.current ?? performance.now();
correctedInitialJumpRef.current = false; // autorise la correction 1-shot
setIsRecording(true);
console.log(LOG_PREFIX, "startPlayback:playing", {
duration: toSeconds(player?.duration),
currentTime: toSeconds(player?.currentTime),
});
startProgressLoop();
startListenLoop();
} catch (e) {
console.log(
LOG_PREFIX,
"startPlayback:error",
String(e?.message || e || "")
);
setIsRecording(false);
setIsPreparing(false);
const msg = String(e?.message || e || "");
if (/not supported on the simulator/i.test(msg)) {
alert(
"Indisponible sur simulateur",
"Lenregistrement vidéo nest pas disponible sur le simulateur. Merci dutiliser un appareil réel.",
[
{
text: "OK",
onPress: () => {
try {
goBack();
} catch {}
},
},
],
{ cancelable: false }
);
}
}
}, [player, startRecorder, startProgressLoop, startListenLoop]);
const permissionsGranted = !!cameraPermission?.granted;
const canRecord = permissionsGranted && mediaReady && !mediaError;
const startCountdownThenRecord = useCallback(async () => {
if (!songUrl || !canRecord) return;
console.log(LOG_PREFIX, "startCountdownThenRecord");
await resetSession();
setIsPreparing(true);
setCountdown(5);
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
countdownTimerRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
console.log(LOG_PREFIX, "countdown:launchRecording");
clearAllTimers();
setIsPreparing(false);
requestAnimationFrame(() => {
void startPlayback();
});
return 0;
}
console.log(LOG_PREFIX, "countdown:tick", c - 1);
return c - 1;
});
}, 1000);
}, [songUrl, canRecord, resetSession, startPlayback]);
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0;
return (
<Page
style={{ flex: 1 }}
width={"100%"}
maxWidth={2000}
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
headerType="NONE"
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<View
style={{
width: isWeb ? 420 : "100%",
aspectRatio: 9 / 16,
overflow: "hidden",
borderRadius: 12,
alignSelf: "center",
position: "relative",
}}
>
<video
ref={previewVideoRef}
autoPlay
playsInline
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: "scaleX(-1)",
backgroundColor: "#000000",
}}
/>
{/* Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: gutters,
paddingTop: top,
paddingBottom: gutters * 2,
}}
>
<View style={{ flex: 1, marginTop: 11 }}>
{mediaError && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#00000099",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: gutters,
}}
>
<Text
style={{
textAlign: "center",
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{"Caméra indisponible : "}
{String(mediaError?.message || "").trim() ||
"vérifie les permissions"}
</Text>
</View>
)}
{!mediaError && !mediaReady && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#00000066",
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
Initialisation de la caméra
</Text>
</View>
)}
{isPreparing && countdown >= 1 && !isRecording && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 72,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}
>
{countdown}
</Text>
</View>
)}
</View>
{/* Start */}
{permissionsGranted && !isPreparing && !isRecording && (
<GradientButton
title="Lancer ma musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
disabled={!songUrl || !canRecord}
onPress={startCountdownThenRecord}
/>
)}
{/* Progress circulaire */}
{permissionsGranted && (isRecording || isPreparing) && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: 100,
height: 100,
borderRadius: 105,
alignItems: "center",
justifyContent: "center",
}}
>
<ProgressRing
size={100}
strokeWidth={8}
progress={progressRatio}
/>
<View
style={{
position: "absolute",
width: 50,
height: 50,
borderRadius: 55,
backgroundColor: Palette.white,
}}
/>
</View>
</View>
)}
{/* Permission prompt */}
{!permissionsGranted && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
padding: gutters,
}}
>
<GradientButton
title="Autoriser la caméra"
onPress={async () => {
try {
if (!cameraPermission?.granted)
await requestCameraPermission();
} catch {}
}}
/>
</View>
)}
</View>
{/* Lyrics */}
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
right: 0,
bottom: bottom + 130,
paddingHorizontal: gutters,
}}
>
<CreateLyricsHeader>
{isRecording && alignedWords.length > 0 ? (
<KaraokeLyrics alignedWords={alignedWords} currentTimeS={pos} />
) : (
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
L'enregistrement de ta vidéo commencera lorsque tu lanceras ta
musique, et s'arrêtera à la fin du morceau.
</Text>
)}
</CreateLyricsHeader>
</View>
</View>
</Page>
);
};
const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
const r = size / 2 - strokeWidth / 2;
const c = 2 * Math.PI * r;
const clamped = Math.max(0, Math.min(1, progress || 0));
const offset = c * (1 - clamped);
return (
<Svg
width={size}
height={size}
style={{
transform: [{ rotate: "-90deg" }],
backgroundColor: "#ffffff3d",
borderRadius: 55,
}}
>
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={Palette.white}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset}
fill="transparent"
/>
</Svg>
);
};
export default RecordPlayback;