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' import { clampSyncOffsetMs } from '../../utils/playbackSync' import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection' const TIME_BEFORE_INCREMENT_MS = 20000 const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10 const WEB_PREVIEW_WIDTH = 360 const MEDIA_BOOTSTRAP_DELAY_MS = 250 const MEDIA_RETRY_DELAY_MS = 700 const MEDIA_MAX_RETRIES = 2 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' }, }) const positionMs = player?.positionMs || 0 const durationMs = player?.durationMs || 0 // 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 mediaInitInFlightRef = useRef(false) const mediaRetryTimerRef = useRef(null) const mediaInitAttemptsRef = useRef(0) 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) // timers / refs const listenTimerRef = useRef(null) const countdownTimerRef = useRef(null) const listenedMsRef = useRef(0) const viewsIncrementedRef = useRef(false) const playbackStartedAtRef = useRef(null) const recordingStartAtRef = useRef(null) const stopInFlightRef = useRef(false) useEffect(() => { latestLoopingValueRef.current = isLooping ?? false }, [isLooping]) // Lyrics const alignedWords = useMemo(() => { const ts = project?.musicTimestamps?.[songIndex] const rawAlignedWords = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [] const trimmedAlignedWords = trimLeadingDuplicateSection(rawAlignedWords) return trimmedAlignedWords.map((w) => ({ word: String(w?.word ?? ''), startS: Number(w?.startS ?? 0), endS: Number(w?.endS ?? 0), })) }, [project?.musicTimestamps, songIndex]) const clearAllTimers = () => { if (listenTimerRef.current) clearInterval(listenTimerRef.current) if (countdownTimerRef.current) clearInterval(countdownTimerRef.current) listenTimerRef.current = countdownTimerRef.current = null } const clearMediaRetry = useCallback(() => { if (mediaRetryTimerRef.current) { clearTimeout(mediaRetryTimerRef.current) mediaRetryTimerRef.current = null } }, []) const resetUI = () => { setIsPreparing(false) setIsRecording(false) setCountdown(0) listenedMsRef.current = 0 viewsIncrementedRef.current = false playbackStartedAtRef.current = null recordingStartAtRef.current = null stopInFlightRef.current = false } const releaseRecordingUrl = useCallback(() => { if (!recordedUrlRef.current) { preserveRecordingForNextScreenRef.current = false return } releaseBlobUrl(recordedUrlRef.current) recordedUrlRef.current = null preserveRecordingForNextScreenRef.current = false }, []) const stopMediaStream = useCallback(() => { if (mediaStreamRef.current) { mediaStreamRef.current.getTracks().forEach((track) => track.stop()) mediaStreamRef.current = null } }, []) const attachPreview = useCallback(() => { const video = previewVideoRef.current const stream = mediaStreamRef.current if (!video || !stream) return false try { if (video.srcObject !== stream) { video.srcObject = stream } } catch (e) {} try { const playPromise = video.play?.() if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch(() => {}) } } catch (e) {} return true }, []) const ensureMediaStream = useCallback( async ({ force = false } = {}) => { if (!cameraPermission?.granted) return false 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 false } if (mediaInitInFlightRef.current) return false if (!force && mediaStreamRef.current) { setMediaReady(true) setMediaError(null) attachPreview() return true } mediaInitInFlightRef.current = true try { if (force && mediaStreamRef.current) { stopMediaStream() } const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false, }) mediaStreamRef.current = stream setMediaReady(true) setMediaError(null) mediaInitAttemptsRef.current = 0 attachPreview() return true } catch (e) { setMediaError(e instanceof Error ? e : new Error(String(e || ''))) setMediaReady(false) return false } finally { mediaInitInFlightRef.current = false } }, [attachPreview, cameraPermission?.granted, stopMediaStream] ) const schedulePreviewRetry = useCallback( function schedulePreviewRetry() { clearMediaRetry() if (mediaInitAttemptsRef.current >= MEDIA_MAX_RETRIES) return mediaRetryTimerRef.current = setTimeout(async () => { const video = previewVideoRef.current const previewOk = !!video && video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0 && !video.paused if (previewOk) return mediaInitAttemptsRef.current += 1 const ok = await ensureMediaStream({ force: true }) if (ok) schedulePreviewRetry() }, MEDIA_RETRY_DELAY_MS) }, [clearMediaRetry, ensureMediaStream] ) const startRecorder = useCallback(async () => { if (!mediaStreamRef.current) { return null } 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 null } 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) } } let resolveRecorderStart const recorderStartedPromise = new Promise((resolve) => { resolveRecorderStart = resolve }) recorder.onstart = () => { resolveRecorderStart?.(performance.now()) resolveRecorderStart = null } recorder.onerror = (event) => { const err = event?.error || event setMediaError(err instanceof Error ? err : new Error(String(err || ''))) resolveRecorderStart?.(null) resolveRecorderStart = null } 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 await recorderStartedPromise } catch (e) { resolveRecorderStart?.(null) resolveRecorderStart = null 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 null } }, [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) clearMediaRetry() mediaInitAttemptsRef.current = 0 return } let cancelled = false const boot = async () => { const ok = await ensureMediaStream() if (!cancelled && ok) { schedulePreviewRetry() } } const timeoutId = setTimeout(boot, MEDIA_BOOTSTRAP_DELAY_MS) return () => { cancelled = true clearTimeout(timeoutId) clearMediaRetry() } }, [cameraPermission?.granted, clearMediaRetry, ensureMediaStream, schedulePreviewRetry]) useEffect(() => { const video = previewVideoRef.current const stream = mediaStreamRef.current if (!video) return if (stream) { attachPreview() } else { try { video.srcObject = null } catch {} } }, [attachPreview, mediaReady, mediaError]) useFocusEffect( useCallback(() => { let active = true const timeoutId = setTimeout(() => { if (!active) return if (mediaStreamRef.current) { attachPreview() schedulePreviewRetry() return } if (cameraPermission?.granted) { void ensureMediaStream() } }, MEDIA_BOOTSTRAP_DELAY_MS) return () => { active = false clearTimeout(timeoutId) } }, [attachPreview, cameraPermission?.granted, ensureMediaStream, schedulePreviewRetry]) ) useEffect(() => { return () => { try { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { mediaRecorderRef.current.stop() } } catch {} stopMediaStream() clearMediaRetry() if (!preserveRecordingForNextScreenRef.current) { releaseRecordingUrl() } } }, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream]) const stopAndNavigate = useCallback(async () => { if (stopInFlightRef.current) return stopInFlightRef.current = true 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) const rawOffsetMs = playbackStartedAtRef.current != null && recordingStartAtRef.current != null ? playbackStartedAtRef.current - recordingStartAtRef.current : 0 const syncOffsetMs = clampSyncOffsetMs(Math.round(rawOffsetMs || 0)) navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null, syncOffsetMs, }) }, [player, project, stopRecorderAndGetUrl]) useEffect(() => { if (!isRecording || !durationMs || positionMs < durationMs - 300) return void stopAndNavigate() }, [durationMs, isRecording, positionMs, 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 { await player?.pause?.() await player?.seekTo?.(0) const recordingStartedAt = await startRecorder() if (recordingStartedAt == null) { throw new Error("Impossible de démarrer l'enregistrement vidéo") } recordingStartAtRef.current = recordingStartedAt await player?.play?.() playbackStartedAtRef.current = performance.now() setIsRecording(true) startListenLoop() } catch (e) { setIsRecording(false) setIsPreparing(false) stopInFlightRef.current = 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, startListenLoop]) const permissionsGranted = !!cameraPermission?.granted const canRecord = permissionsGranted && mediaReady && !mediaError const startCountdownThenRecord = useCallback(async () => { if (!songUrl || !canRecord) return await resetSession() 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 handleRestartRecording = useCallback(async () => { try { await startCountdownThenRecord() } catch (e) {} }, [startCountdownThenRecord]) const progressRatio = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 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