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