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 ( ); }; 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;