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