import { useFocusEffect } from '@react-navigation/native' import { CameraView, useCameraPermissions } from 'expo-camera' import * as FileSystem from 'expo-file-system' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BackHandler, 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 RestartSpinnerIcon from '../../assets/UI/RestartSpinnerIcon' import usePlayer from '../../hooks/usePlayer' import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer' 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 // 20s const COUNTDOWN_SECONDS = 10 const LOG_PREFIX = '[RecordPlayback]' const log = typeof __DEV__ === 'undefined' || __DEV__ ? (...args) => console.log(LOG_PREFIX, ...args) : () => {} const CAMERA_FACING_OPTIONS = [ { label: 'Avant', value: 'front' }, { label: 'Arrière', value: 'back' }, ] const RecordPlayback = ({ route }) => { const { top, bottom } = useSafeAreaInsets() const { project } = route.params || {} log('Route params received', { hasProject: !!project }) const projectId = project?.id const songIndex = project?.songIndex const { setLooping, isLooping } = usePlayer() || {} // Permissions const [cameraPermission, requestCameraPermission] = useCameraPermissions() // Refs const cameraRef = useRef(null) const countdownTimerRef = useRef(null) const listenTimerRef = useRef(null) const checkSongEndRef = useRef(null) const isPausedRef = useRef(false) const stopRequestedRef = useRef(false) const restartRequestedRef = useRef(false) const recordingStartTimeRef = useRef(0) // Track recording start time for duration check const manualRestartInFlightRef = useRef(false) const stopRequestedAtRef = useRef(0) const activeRecordingPromiseRef = useRef(null) const exitRequestedRef = useRef(false) const startedRef = useRef(false) // empêche les doubles démarrages const countdownActiveRef = useRef(false) // évite le déclenchement avant 1er tick const playbackStartedRef = useRef(false) // devient vrai lorsque l'audio progresse réellement const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 }) const originalLoopingValueRef = useRef({ hasValue: false, value: false }) const latestLoopingValueRef = useRef(isLooping ?? false) // Compteurs vues const listenedMsRef = useRef(0) const incrementDoneRef = useRef(false) // UI / state const [isPreparing, setIsPreparing] = useState(false) const [countdown, setCountdown] = useState(0) const [isRecording, setIsRecording] = useState(false) const [isPaused, setIsPaused] = useState(false) const [showProgress, setShowProgress] = useState(false) const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }) const [cameraFacing, setCameraFacing] = useState('front') // Musique const songUrl = project?.songUrl || null const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { id: projectId ? `record-${projectId}` : songUrl ? `record-${songUrl}` : undefined, 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, screen: 'RecordPlayback' }, }) useEffect(() => { log('Screen params', { projectId, songUrl, songIndex }) }, [projectId, songUrl, songIndex]) useEffect(() => { latestLoopingValueRef.current = isLooping ?? false }, [isLooping]) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) // Removed AppState listener as it was too aggressive. // We now use duration validation at the end of recording. const musicIndex = useMemo(() => { const i = Number(songIndex) return Number.isFinite(i) && i >= 0 ? i : 0 }, [songIndex]) const alignedWords = useMemo(() => { const ts = project?.musicTimestamps?.[musicIndex] 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, musicIndex]) // Build line groups to display line-by-line const lines = useMemo(() => { const out = [] let buf = [] let start = null const clean = (txt) => String(txt || '') .replace(/\s+/g, ' ') .trim() const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()) const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim()) for (let i = 0; i < alignedWords.length; i++) { const w = alignedWords[i] const original = String(w.word || '') const textNoNewline = original.replace(/\n/g, ' ') const gapToNext = i < alignedWords.length - 1 ? Math.max(0, alignedWords[i + 1].startS - w.endS) : 0 // If buffer empty, mark start if (buf.length === 0) start = w.startS // Section tag becomes its own line if (isSectionTag(textNoNewline)) { const joined = clean(textNoNewline) if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS }) buf = [] start = null continue } buf.push(textNoNewline) const eolByNewline = /\n/.test(original) const eolByPause = gapToNext >= 0.6 const eolByPunct = isSentenceEnd(textNoNewline) const isLast = i === alignedWords.length - 1 if (eolByNewline || eolByPause || eolByPunct || isLast) { const joined = clean(buf.join(' ')) if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS }) buf = [] start = null } } return out }, [alignedWords]) useEffect(() => { listenedMsRef.current = 0 incrementDoneRef.current = false progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } playbackStartedRef.current = false log('Song URL changed, reset counters', { songUrl }) }, [songUrl]) // Poll player -> progress ring useEffect(() => { if (!player) return const id = setInterval(() => { try { const dur = (player?.duration || 0) * 1000 const pos = (player?.currentTime || 0) * 1000 if (!playbackStartedRef.current && (player?.playing || pos > 0)) { playbackStartedRef.current = true log('Playback detected', { pos, dur }) } setProgressInfo((prev) => { if (prev.pos === pos && prev.dur === dur) return prev return { pos, dur } }) if (dur <= 0) { if (progressLogRef.current.lastDur !== 0) { progressLogRef.current = { bucket: -1, lastPos: pos, lastDur: 0 } log('Progress waiting for duration', { pos, playing: player?.playing, }) } else { progressLogRef.current.lastPos = pos } return } const pct = Math.max(0, Math.min(1, pos / dur)) const bucket = Math.floor(pct * 10) const { bucket: prevBucket } = progressLogRef.current if (bucket !== prevBucket) { log('Progress update', { bucket, pct: Number.isFinite(pct) ? Number(pct.toFixed(2)) : pct, pos, dur, playing: player?.playing, }) } progressLogRef.current = { bucket, lastPos: pos, lastDur: dur } } catch (_) {} }, 250) return () => clearInterval(id) }, [player]) // Compute current line index from playback time const currentTimeS = (progressInfo?.pos || 0) / 1000 const currentLineIdx = useMemo(() => { if (!lines || lines.length === 0) return -1 for (let i = 0; i < lines.length; i++) { const L = lines[i] if (currentTimeS >= L.startS && currentTimeS <= L.endS) return i } if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1 return -1 }, [lines, currentTimeS]) const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0) // Permissions au mount + cleanup useEffect(() => { ;(async () => { try { if (!cameraPermission?.granted) { log('Requesting camera permission on mount') await requestCameraPermission() } else { log('Camera permission already granted on mount') } } catch (_) {} })() return () => { try { if (countdownTimerRef.current) clearInterval(countdownTimerRef.current) if (listenTimerRef.current) clearInterval(listenTimerRef.current) if (checkSongEndRef.current) clearInterval(checkSongEndRef.current) countdownTimerRef.current = null listenTimerRef.current = null checkSongEndRef.current = null log('Cleanup on unmount, cleared timers') } catch (_) {} } }, []) // eslint-disable-line react-hooks/exhaustive-deps // Reset complet const resetSession = useCallback( async ({ preserveSongEndWatcher = false, preserveStopRequest = false } = {}) => { try { if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current) countdownTimerRef.current = null } if (listenTimerRef.current) { clearInterval(listenTimerRef.current) listenTimerRef.current = null log('listenTimerRef cleared') } if (checkSongEndRef.current) { if (preserveSongEndWatcher) { log('checkSongEndRef preserved') } else { clearInterval(checkSongEndRef.current) checkSongEndRef.current = null log('checkSongEndRef cleared') } } startedRef.current = false if (!preserveStopRequest) { stopRequestedRef.current = false stopRequestedAtRef.current = 0 } countdownActiveRef.current = false listenedMsRef.current = 0 incrementDoneRef.current = false playbackStartedRef.current = false progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } log('Session reset') setIsPreparing(false) setIsRecording(false) setIsPaused(false) isPausedRef.current = false setShowProgress(false) setCountdown(0) try { await cameraRef.current?.resumePreview?.() } catch (_) {} if (player) { try { if (player.playing) await player.pause?.() await player.seekTo?.(0) } catch (_) {} } } catch (_) {} }, [player] ) const resetSessionRef = useRef(resetSession) useEffect(() => { resetSessionRef.current = resetSession }, [resetSession]) useFocusEffect( useCallback(() => { log('Screen focused, resetting session') exitRequestedRef.current = false void resetSessionRef.current?.() return () => { log('Screen blurred, stopping playback') void resetSessionRef.current?.() } }, []) ) 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]) ) const discardRecordingFile = useCallback(async (uri, reason = '') => { if (!uri) return try { const info = await FileSystem.getInfoAsync(uri) if (info?.exists) { await FileSystem.deleteAsync(uri, { idempotent: true }) log('Discarded recording file', { reason: reason || 'cleanup' }) } } catch (error) { log('Failed to discard recording', { reason: reason || 'cleanup', message: error?.message || String(error || ''), }) } }, []) // Lancer le compte à rebours (le tick décrémente uniquement) const startCountdownThenRecord = async ({ preserveRestartFlag = false, preserveSongEndWatcher = false, preserveStopRequest = false, } = {}) => { if (!songUrl) { log('startCountdownThenRecord aborted: missing song URL') return } log('startCountdownThenRecord invoked', { projectId, songUrl, preserveRestartFlag, preserveSongEndWatcher, preserveStopRequest, }) await resetSession({ preserveSongEndWatcher, preserveStopRequest, }) setIsPaused(false) isPausedRef.current = false if (!preserveRestartFlag) { restartRequestedRef.current = false manualRestartInFlightRef.current = false } setIsPreparing(true) setShowProgress(false) setCountdown(COUNTDOWN_SECONDS) // On démarre l'intervalle puis on active le flag if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current) countdownTimerRef.current = null } countdownTimerRef.current = setInterval(() => { setCountdown((c) => Math.max(0, c - 1)) }, 1000) countdownActiveRef.current = true log('Countdown timer armed') } // Quand le compteur a réellement démarré ET atteint 0, on démarre useEffect(() => { if (!isPreparing) return if (!countdownActiveRef.current) return // évite l'auto-start if (countdown === 0 && !startedRef.current) { log('Countdown complete, launching recording sequence') startedRef.current = true if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current) countdownTimerRef.current = null } countdownActiveRef.current = false // Bascule après rendu de la frame courante requestAnimationFrame(() => { setIsPreparing(false) setShowProgress(true) void startRecordingWithMusic() }) } }, [countdown, isPreparing]) const startRecordingWithMusic = async () => { try { stopRequestedRef.current = false stopRequestedAtRef.current = 0 recordingStartTimeRef.current = Date.now() // Start timer listenedMsRef.current = 0 incrementDoneRef.current = false setIsRecording(true) setShowProgress(true) setIsPaused(false) isPausedRef.current = false try { await cameraRef.current?.resumePreview?.() } catch (_) {} playbackStartedRef.current = false progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } log('startRecordingWithMusic', { hasPlayer: !!player, songUrl, hasCamera: !!cameraRef.current, }) if (activeRecordingPromiseRef.current) { log('Waiting for previous recording to finish before starting a new one') try { await activeRecordingPromiseRef.current } catch (error) { log('Previous recording promise rejected', { message: error?.message || String(error || ''), }) } } const recordPromise = (() => { const camera = cameraRef.current if (!camera) throw new Error('Caméra indisponible') if (typeof camera.recordAsync === 'function') { log('Camera supports recordAsync') return camera.recordAsync({ mute: true, maxDuration: 600 }) } if (typeof camera.startRecording === 'function') { // Expo CameraView (SDK 52) expose startRecording au lieu de recordAsync log('Camera using startRecording fallback') return new Promise((resolve, reject) => { let settled = false try { camera.startRecording({ mute: true, maxDuration: 600, onRecordingFinished: (video) => { settled = true log('Camera onRecordingFinished', { hasUri: !!video?.uri }) resolve(video) }, onRecordingError: (error) => { if (settled) return log('Camera onRecordingError', { message: error?.message || String(error || ''), }) const err = error instanceof Error ? error : new Error(String(error || 'Recording error')) reject(err) }, }) } catch (error) { log('Camera startRecording threw', { message: error?.message || String(error || ''), }) reject( error instanceof Error ? error : new Error(String(error || 'Recording start failed')) ) } }) } throw new Error("L'enregistrement vidéo n'est pas supporté sur cet appareil.") })() activeRecordingPromiseRef.current = recordPromise if (player && songUrl) { try { log('Seeking player to start') await player.seekTo?.(0) } catch (error) { log('player.seekTo failed', { message: error?.message || String(error || ''), }) } try { await player.play?.() log('player.play invoked') } catch (error) { log('player.play failed', { message: error?.message || String(error || ''), }) } } // Incrément des vues if (!listenTimerRef.current && project?.id) { log('listenTimerRef armed', { projectId: project.id }) listenTimerRef.current = setInterval(async () => { try { if (player?.playing) { listenedMsRef.current += 500 if (!incrementDoneRef.current && listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS) { incrementDoneRef.current = true log('Views increment threshold reached') try { await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true }) log('Views incremented') } catch (_) {} } } } catch (_) {} }, 500) } // Fin du morceau -> stop recording if (!checkSongEndRef.current) { log('Song end watcher armed') checkSongEndRef.current = setInterval(() => { try { if (!player) return if (isPausedRef.current) return if (stopRequestedRef.current) { const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0) if (sinceLastRequest >= 1200) { stopRequestedAtRef.current = Date.now() try { cameraRef.current?.stopRecording?.() log('stopRecording retried while awaiting stop') } catch (_) {} } return } const duration = (player?.duration || 0) * 1000 const currentTime = (player?.currentTime || 0) * 1000 if (!playbackStartedRef.current && (player?.playing || currentTime > 0)) { playbackStartedRef.current = true } const nearEnd = duration > 0 && currentTime >= duration - 600 const playbackEnded = playbackStartedRef.current && !player.playing && currentTime > 1200 if (nearEnd || playbackEnded) { log('Requesting stopRecording', { reason: nearEnd ? 'nearEnd' : 'playbackEnded', duration, currentTime, playing: player?.playing, }) stopRequestedRef.current = true stopRequestedAtRef.current = Date.now() try { cameraRef.current?.stopRecording?.() log('stopRecording triggered') } catch (_) {} } } catch (_) {} }, 500) } const video = await recordPromise const recordEndTime = Date.now() log('Recording promise resolved', { hasVideo: !!video?.uri }) activeRecordingPromiseRef.current = null stopRequestedRef.current = false stopRequestedAtRef.current = 0 if (checkSongEndRef.current) { clearInterval(checkSongEndRef.current) checkSongEndRef.current = null log('checkSongEndRef cleared') } try { if (player?.playing) await player.pause?.() } catch (_) {} if (listenTimerRef.current) { clearInterval(listenTimerRef.current) listenTimerRef.current = null log('listenTimerRef cleared') } setIsRecording(false) setIsPaused(false) isPausedRef.current = false setShowProgress(false) log('Recording flow completed', { hasVideo: !!video?.uri }) playbackStartedRef.current = false progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } const exitRequested = exitRequestedRef.current const shouldRestart = restartRequestedRef.current // Duration check logic const recordedDurationMs = recordEndTime - recordingStartTimeRef.current const expectedDurationMs = (player?.duration || 0) * 1000 if (!exitRequested && !shouldRestart) { // If the app was backgrounded/interrupted, the camera stops early, // resulting in a duration significantly shorter than the song. // We only check this if we have a valid song duration. // Tolerance: 1.5s if (expectedDurationMs > 0 && recordedDurationMs < expectedDurationMs - 1500) { log('Recording too short compared to song duration - likely interrupted', { recordedDurationMs, expectedDurationMs, }) await discardRecordingFile(video?.uri, 'too_short') try { goBack() } catch (_) {} return } } if (exitRequested) { exitRequestedRef.current = false restartRequestedRef.current = false manualRestartInFlightRef.current = false await discardRecordingFile(video?.uri, 'exit') log('Recording aborted before completion, skipping navigation') return } if (shouldRestart) { restartRequestedRef.current = false await discardRecordingFile(video?.uri, 'restart') const manualRestartPending = manualRestartInFlightRef.current manualRestartInFlightRef.current = false if (manualRestartPending) { log('Manual restart already scheduled, waiting for countdown') } else { log('Restart requested, relaunching countdown') requestAnimationFrame(() => { void startCountdownThenRecord() }) } return } if (video?.uri) { log('Navigating to RecordedPlayback with video', { uriLength: video.uri.length, }) navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }) } else { log('Navigating to RecordedPlayback without video', { hasVideo: !!video, }) navigate(Routes.RecordedPlayback, { project }) } } catch (e) { log('startRecordingWithMusic error', { message: e?.message || String(e || ''), }) console.log('RecordPlayback error:', e) activeRecordingPromiseRef.current = null stopRequestedRef.current = false stopRequestedAtRef.current = 0 setIsRecording(false) setIsPreparing(false) setIsPaused(false) isPausedRef.current = false setShowProgress(false) if (listenTimerRef.current) { clearInterval(listenTimerRef.current) listenTimerRef.current = null log('listenTimerRef cleared (error path)') } if (checkSongEndRef.current) { clearInterval(checkSongEndRef.current) checkSongEndRef.current = null log('checkSongEndRef cleared (error path)') } playbackStartedRef.current = false progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } const exitRequested = exitRequestedRef.current const shouldRestart = restartRequestedRef.current if (exitRequested) { exitRequestedRef.current = false restartRequestedRef.current = false manualRestartInFlightRef.current = false log('Recording aborted, skipping error handling') return } if (shouldRestart) { restartRequestedRef.current = false const manualRestartPending = manualRestartInFlightRef.current manualRestartInFlightRef.current = false if (manualRestartPending) { log('Manual restart already scheduled after error') } else { log('Restart requested despite error, restarting flow') requestAnimationFrame(() => { void startCountdownThenRecord() }) } return } // Inform the user when using a simulator where recording isn't supported 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 } ) } } } const handleBackPress = useCallback(() => { const busy = isPreparing || isRecording log('Back button pressed', { isPreparing, isRecording, busy }) if (busy) { exitRequestedRef.current = true restartRequestedRef.current = false manualRestartInFlightRef.current = false stopRequestedRef.current = true stopRequestedAtRef.current = Date.now() if (isPreparing && countdownTimerRef.current) { clearInterval(countdownTimerRef.current) countdownTimerRef.current = null } try { if (player?.playing) { const maybePromise = player.pause?.() if (maybePromise && typeof maybePromise.catch === 'function') { maybePromise.catch(() => {}) } } } catch (_) {} try { if (isRecording) { cameraRef.current?.stopRecording?.() } } catch (_) {} } else { exitRequestedRef.current = false } try { goBack() } catch (_) {} return true }, [goBack, isPreparing, isRecording, player]) useFocusEffect( useCallback(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => handleBackPress() ) return () => subscription.remove() }, [handleBackPress]) ) const handleTogglePause = useCallback(async () => { try { if (!isRecording) return if (!isPausedRef.current) { setIsPaused(true) isPausedRef.current = true stopRequestedRef.current = false stopRequestedAtRef.current = 0 try { await player?.pause?.() } catch (_) {} try { await cameraRef.current?.pausePreview?.() } catch (_) {} return } setIsPaused(false) isPausedRef.current = false try { await cameraRef.current?.resumePreview?.() } catch (_) {} try { await player?.play?.() } catch (_) {} } catch (_) {} }, [isRecording, player]) const handleRestartRecording = async () => { try { const hasRecordingPending = !!activeRecordingPromiseRef.current log('Restart button pressed', { isPreparing, isRecording, hasRecordingPending, }) setIsPaused(false) isPausedRef.current = false try { await cameraRef.current?.resumePreview?.() } catch (_) {} if (isPreparing) { if (hasRecordingPending) { await startCountdownThenRecord({ preserveRestartFlag: true, preserveSongEndWatcher: true, preserveStopRequest: true, }) return } restartRequestedRef.current = false manualRestartInFlightRef.current = false await startCountdownThenRecord() return } if (!isRecording) { restartRequestedRef.current = false manualRestartInFlightRef.current = false await startCountdownThenRecord() return } restartRequestedRef.current = true manualRestartInFlightRef.current = true stopRequestedRef.current = true stopRequestedAtRef.current = Date.now() try { if (player?.playing) await player.pause?.() } catch (_) {} try { cameraRef.current?.stopRecording?.() } catch (_) {} await startCountdownThenRecord({ preserveRestartFlag: true, preserveSongEndWatcher: true, preserveStopRequest: true, }) } catch (_) {} } const permissionsGranted = !!cameraPermission?.granted useEffect(() => { log('Camera permission state updated', { granted: permissionsGranted }) }, [permissionsGranted]) return ( {/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */} {isPreparing && countdown >= 1 && !showProgress && ( {countdown} )} {/* Permission prompt */} {!permissionsGranted && ( { try { if (!cameraPermission?.granted) await requestCameraPermission() } catch (_) {} }} /> )} {/* Start button */} {permissionsGranted && !isPreparing && !isRecording && ( )} {/* Progress circulaire + restart */} {permissionsGranted && (isRecording || showProgress || isPreparing) && ( {isRecording && ( {isPaused ? 'Reprendre' : 'Mettre en pause'} )} )} {/* CreateLyricsHeader overlay outside of CameraView */} {(isRecording || showProgress) && alignedWords?.length > 0 ? ( ) : ( L'enregistrement de ta vidéo commencera lorsque tu lanceras ta musique, et s'arrêtera à la fin du morceau. )} ) } // Progress ring SVG 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 // Render previous/current/next line to reduce jumpiness const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => { const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : '' const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : '' const next = currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : '' return ( {/* {prev ? ( {prev} ) : null} */} {curr} {next ? ( {next} ) : null} ) } const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => { return ( {CAMERA_FACING_OPTIONS.map((option) => { const isActive = option.value === value return ( { if (typeof onChange === 'function') onChange(option.value) }} disabled={isActive} style={{ paddingVertical: 6, paddingHorizontal: 14, borderRadius: 999, backgroundColor: isActive ? Palette.white : 'transparent', }} > {option.label} ) })} ) }