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",
"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;
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 (
{/* 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 */}
{permissionsGranted && (isRecording || isPreparing) && (
)}
{/* 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;