import { useFocusEffect } from "@react-navigation/native";
import { useCameraPermissions } from "expo-camera";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text, TouchableOpacity, 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 usePlayer from "../../hooks/usePlayer";
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";
import { background } from "../../assets";
import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon";
import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache";
const TIME_BEFORE_INCREMENT_MS = 20000;
const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10;
const WEB_PREVIEW_WIDTH = 360;
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();
const { setLooping, isLooping } = usePlayer() || {};
// 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 preserveRecordingForNextScreenRef = useRef(false);
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
const latestLoopingValueRef = useRef(isLooping ?? false);
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 [isPaused, setIsPaused] = useState(false);
const [pos, setPos] = useState(0);
const [dur, setDur] = useState(0);
// timers / refs
const isPausedRef = useRef(false);
const progressTimerRef = useRef(null);
const listenTimerRef = useRef(null);
const countdownTimerRef = useRef(null);
const perfStartRef = useRef(null);
const pausedAtRef = useRef(null);
const pausedMsRef = useRef(0);
const listenedMsRef = useRef(0);
const viewsIncrementedRef = useRef(false);
const correctedInitialJumpRef = useRef(false);
useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false;
}, [isLooping]);
useEffect(() => {
isPausedRef.current = isPaused;
}, [isPaused]);
// 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 = () => {
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 = () => {
setIsPreparing(false);
setIsRecording(false);
setIsPaused(false);
isPausedRef.current = false;
pausedAtRef.current = null;
pausedMsRef.current = 0;
setCountdown(0);
setPos(0);
setDur(0);
listenedMsRef.current = 0;
viewsIncrementedRef.current = false;
perfStartRef.current = null;
correctedInitialJumpRef.current = false;
};
const releaseRecordingUrl = useCallback(() => {
if (!recordedUrlRef.current) {
preserveRecordingForNextScreenRef.current = false;
return;
}
releaseBlobUrl(recordedUrlRef.current);
recordedUrlRef.current = null;
preserveRecordingForNextScreenRef.current = false;
}, []);
const startRecorder = useCallback(() => {
if (!mediaStreamRef.current) {
return false;
}
try {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
}
} catch (e) { }
recordedChunksRef.current = [];
releaseRecordingUrl();
const mimeType = pickRecorderMimeType();
let recorder;
try {
recorder =
mimeType != null
? new MediaRecorder(mediaStreamRef.current, { mimeType })
: new MediaRecorder(mediaStreamRef.current);
} catch (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;
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;
registerBlobUrl(url, blob);
} else {
releaseRecordingUrl();
}
} catch (e) {
releaseRecordingUrl();
}
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(url);
stopRecordingResolveRef.current = null;
}
recordedChunksRef.current = [];
mediaRecorderRef.current = null;
stopRecordingPromiseRef.current = null;
};
try {
recorder.start(1000);
return true;
} catch (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) { }
if (!waitPromise) {
return recordedUrlRef.current || null;
}
let url = null;
try {
url = await waitPromise;
} catch (e) {
} finally {
stopRecordingPromiseRef.current = null;
stopRecordingResolveRef.current = null;
}
return url || recordedUrlRef.current || null;
}, []);
const resetSession = useCallback(async () => {
clearAllTimers();
resetUI();
try {
await player?.pause?.();
await player?.seekTo?.(0);
} catch (e) { }
try {
await stopRecorderAndGetUrl();
} catch (e) { }
if (!preserveRecordingForNextScreenRef.current) {
releaseRecordingUrl();
}
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
const resetSessionRef = useRef(resetSession);
useEffect(() => {
resetSessionRef.current = resetSession;
}, [resetSession]);
useFocusEffect(
useCallback(() => {
if (typeof setLooping === "function") {
originalLoopingValueRef.current = {
hasValue: true,
value: latestLoopingValueRef.current,
};
if (latestLoopingValueRef.current) {
setLooping(false);
}
}
return () => {
if (
typeof setLooping === "function" &&
originalLoopingValueRef.current?.hasValue
) {
setLooping(!!originalLoopingValueRef.current.value);
}
};
}, [setLooping]),
);
useFocusEffect(
useCallback(() => {
void resetSessionRef.current?.();
return () => {
void resetSessionRef.current?.();
};
}, []),
);
useEffect(() => {
(async () => {
try {
if (!cameraPermission?.granted) {
await requestCameraPermission();
}
} catch (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: false,
});
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) { }
} 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;
}
if (!preserveRecordingForNextScreenRef.current) {
releaseRecordingUrl();
}
};
}, [releaseRecordingUrl, preserveRecordingForNextScreenRef]);
const stopAndNavigate = useCallback(async () => {
preserveRecordingForNextScreenRef.current = true;
clearAllTimers();
try {
await player?.pause?.();
} catch (e) { }
let videoUrl = null;
try {
videoUrl = await stopRecorderAndGetUrl();
if (!videoUrl) {
videoUrl = recordedUrlRef.current || null;
}
} catch (e) { }
setIsRecording(false);
setIsPaused(false);
isPausedRef.current = false;
pausedAtRef.current = null;
pausedMsRef.current = 0;
navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null });
}, [dur, player, pos, project, stopRecorderAndGetUrl]);
const startProgressLoop = useCallback(() => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
progressTimerRef.current = setInterval(async () => {
const rawDur = toSeconds(player?.duration);
const rawPos = toSeconds(player?.currentTime);
const pausedSince =
pausedAtRef.current != null
? Math.max(0, performance.now() - pausedAtRef.current)
: 0;
const pausedTotal = pausedMsRef.current + pausedSince;
// fallback monotone si le player ne donne rien
const fallback =
perfStartRef.current != null
? Math.max(
0,
(performance.now() - perfStartRef.current - pausedTotal) / 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;
} catch (e) { }
}
const timeSinceStart =
perfStartRef.current != null
? Math.max(0, performance.now() - perfStartRef.current - pausedTotal)
: null;
const shouldTrustFallback =
fallback > 0 &&
((nextDur > 0 && nextPos > nextDur + 0.5) ||
nextPos - fallback > 1.5 ||
(timeSinceStart != null &&
timeSinceStart < 5000 &&
nextPos > fallback + 1));
if (shouldTrustFallback) {
nextPos = fallback;
}
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur;
setDur(nextDur);
setPos(nextPos);
if (isPausedRef.current) {
return;
}
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
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;
if (project?.id) {
try {
await projectsRef
.doc(project.id)
.set({ views: increment(1) }, { merge: true });
} catch (e) { }
}
}
}
} catch (e) { }
}, 500);
}, [player, project?.id]);
const startPlayback = useCallback(async () => {
try {
setIsPaused(false);
isPausedRef.current = false;
pausedAtRef.current = null;
pausedMsRef.current = 0;
await player?.pause?.(); // s'assure qu'on repart propre
await player?.seekTo?.(0); // tente un seek d'amorçage
const recorderStarted = startRecorder();
if (!recorderStarted) {
}
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);
startProgressLoop();
startListenLoop();
} catch (e) {
setIsRecording(false);
setIsPreparing(false);
setIsPaused(false);
isPausedRef.current = false;
pausedAtRef.current = null;
const msg = String(e?.message || e || "");
if (/not supported on the simulator/i.test(msg)) {
alert(
"Indisponible sur simulateur",
"L’enregistrement vidéo n’est pas disponible sur le simulateur. Merci d’utiliser 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;
await resetSession();
setIsPaused(false);
isPausedRef.current = false;
setIsPreparing(true);
setCountdown(COUNTDOWN_SECONDS);
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
countdownTimerRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearAllTimers();
setIsPreparing(false);
requestAnimationFrame(() => {
void startPlayback();
});
return 0;
}
return c - 1;
});
}, 1000);
}, [songUrl, canRecord, resetSession, startPlayback]);
const handleTogglePause = useCallback(async () => {
try {
if (!isRecording) return;
if (!isPausedRef.current) {
isPausedRef.current = true;
setIsPaused(true);
pausedAtRef.current = performance.now();
try {
await player?.pause?.();
} catch (e) { }
try {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state === "recording") {
recorder.pause?.();
}
} catch (e) { }
return;
}
const pausedAt = pausedAtRef.current;
if (pausedAt != null) {
pausedMsRef.current += performance.now() - pausedAt;
pausedAtRef.current = null;
}
isPausedRef.current = false;
setIsPaused(false);
try {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state === "paused") {
recorder.resume?.();
}
} catch (e) { }
try {
await player?.play?.();
} catch (e) { }
} catch (e) { }
}, [isRecording, player]);
const handleRestartRecording = useCallback(async () => {
try {
await startCountdownThenRecord();
} catch (e) { }
}, [startCountdownThenRecord]);
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0;
return (
{/* Overlay */}
{mediaError && (
{"Caméra indisponible : "}
{String(mediaError?.message || "").trim() ||
"vérifie les permissions"}
)}
{!mediaError && !mediaReady && (
Initialisation de la caméra…
)}
{isPreparing && countdown >= 1 && !isRecording && (
{countdown}
)}
{/* Start */}
{permissionsGranted && !isPreparing && !isRecording && (
)}
{/* Progress circulaire + restart */}
{permissionsGranted && (isRecording || isPreparing) && (
{isRecording && (
{isPaused ? "Reprendre" : "Mettre en pause"}
)}
)}
{/* Permission prompt */}
{!permissionsGranted && (
{
try {
if (!cameraPermission?.granted)
await requestCameraPermission();
} catch { }
}}
/>
)}
{/* Lyrics */}
{isRecording && alignedWords.length > 0 ? (
) : (
L'enregistrement de ta vidéo commencera lorsque tu lanceras ta
musique, et s'arrêtera à la fin du morceau.
)}
{/* */}
);
};
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 (
);
};
export default RecordPlayback;