Files
musicland/src/screens/Playback/RecordPlayback.js
T
2026-02-12 15:46:44 +01:00

1236 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useFocusEffect } from '@react-navigation/native'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
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 CAMERA_STARTUP_DELAY_MS = 250
const MAX_SYNC_OFFSET_MS = 2000
const LOG_PREFIX = '[RecordPlayback]'
const KEEP_AWAKE_TAG = 'record-playback'
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 playbackStartedAtRef = useRef(0)
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])
useEffect(() => {
const shouldKeepAwake = isPreparing || isRecording
const updateKeepAwake = async () => {
try {
if (shouldKeepAwake) {
await activateKeepAwakeAsync(KEEP_AWAKE_TAG)
} else {
await deactivateKeepAwake(KEEP_AWAKE_TAG)
}
} catch (_) { }
}
void updateKeepAwake()
return () => {
try {
void deactivateKeepAwake(KEEP_AWAKE_TAG)
} catch (_) { }
}
}, [isPreparing, isRecording])
// 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
playbackStartedAtRef.current = 0
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
const now = Date.now()
const estimatedStartAt = pos > 0 ? now - pos : now
playbackStartedAtRef.current = estimatedStartAt
log('Playback detected', { pos, dur, startedAt: estimatedStartAt })
}
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
playbackStartedAtRef.current = 0
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 || ''),
})
}
// Add artificial delay to compensate for camera startup latency (Audio usually starts faster than video recording)
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS))
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
}
const rawOffsetMs =
playbackStartedAtRef.current && recordingStartTimeRef.current
? playbackStartedAtRef.current - recordingStartTimeRef.current
: 0
const syncOffsetMs = Math.max(0, Math.min(MAX_SYNC_OFFSET_MS, Math.round(rawOffsetMs || 0)))
log('Computed sync offset', { rawOffsetMs, syncOffsetMs })
if (video?.uri) {
log('Navigating to RecordedPlayback with video', {
uriLength: video.uri.length,
})
navigate(Routes.RecordedPlayback, { videoUri: video.uri, project, syncOffsetMs })
} else {
log('Navigating to RecordedPlayback without video', {
hasVideo: !!video,
})
navigate(Routes.RecordedPlayback, { project, syncOffsetMs })
}
} 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',
'Lenregistrement vidéo nest pas disponible sur le simulateur. Merci dutiliser 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 (
<View style={{ flex: 1 }}>
<CameraView ref={cameraRef} style={{ flex: 1 }} facing={cameraFacing} mode="video" mute>
<View
style={{
paddingHorizontal: gutters,
paddingTop: top,
flex: 1,
paddingBottom: gutters * 2,
}}
>
<MusicLandHeader progress={9} onPressBack={handleBackPress} />
<View style={{ marginTop: 12, alignItems: 'flex-end' }}>
<CameraFacingSelector
value={cameraFacing}
onChange={setCameraFacing}
disabled={!permissionsGranted || isPreparing || isRecording}
/>
</View>
<View style={{ flex: 1, marginTop: 11 }}>
{/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
{isPreparing && countdown >= 1 && !showProgress && (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 72,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}
>
{countdown}
</Text>
</View>
)}
{/* Permission prompt */}
{!permissionsGranted && (
<View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: gutters,
}}
>
<GradientButton
title="Autoriser la caméra"
onPress={async () => {
try {
if (!cameraPermission?.granted) await requestCameraPermission()
} catch (_) { }
}}
/>
</View>
)}
</View>
{/* Start button */}
{permissionsGranted && !isPreparing && !isRecording && (
<GradientButton
title="Lancer ma musique"
containerStyle={{ width: '80%', alignSelf: 'center' }}
disabled={!songUrl}
onPress={startCountdownThenRecord}
/>
)}
{/* Progress circulaire + restart */}
{permissionsGranted && (isRecording || showProgress || isPreparing) && (
<View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: gutters,
alignItems: 'center',
justifyContent: 'center',
}}
>
<View
style={{
width: 120,
height: 120,
borderRadius: 120,
alignItems: 'center',
justifyContent: 'center',
}}
>
<ProgressRing
size={120}
strokeWidth={8}
progress={
progressInfo.dur ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) : 0
}
/>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleRestartRecording}
style={{
position: 'absolute',
width: 70,
height: 70,
borderRadius: 80,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Palette.white,
shadowColor: '#000000',
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
}}
>
<RestartSpinnerIcon />
</TouchableOpacity>
</View>
{isRecording && (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleTogglePause}
style={{
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 999,
backgroundColor: Palette.white,
shadowColor: '#000000',
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
}}
>
<Text
style={{
color: Palette.black,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
}}
>
{isPaused ? 'Reprendre' : 'Mettre en pause'}
</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
</CameraView>
{/* CreateLyricsHeader overlay outside of CameraView */}
<View
pointerEvents="box-none"
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: bottom + 200,
paddingHorizontal: gutters,
}}
>
<CreateLyricsHeader>
{(isRecording || showProgress) && alignedWords?.length > 0 ? (
<KaraokeLyrics
alignedWords={alignedWords}
currentTimeS={(progressInfo?.pos || 0) / 1000}
/>
) : (
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
L'enregistrement de ta vidéo commencera lorsque tu lanceras ta musique, et s'arrêtera
à la fin du morceau.
</Text>
)}
</CreateLyricsHeader>
</View>
</View>
)
}
// 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 (
<Svg
width={size}
height={size}
style={{
transform: [{ rotate: '-90deg' }],
borderRadius: size / 2,
}}
>
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={Palette.white}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset}
fill="transparent"
/>
</Svg>
)
}
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 (
<View style={{ gap: 4 }}>
{/* {prev ? (
<Text style={{ color: "#FFFFFF99", fontSize: 14, textAlign: "center", fontFamily: FONT_FAMILY.InterMedium }}>
{prev}
</Text>
) : null} */}
<Text
style={{
color: Palette.white,
fontSize: 18,
textAlign: 'center',
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
{curr}
</Text>
{next ? (
<Text
style={{
color: '#FFFFFF99',
fontSize: 14,
textAlign: 'center',
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{next}
</Text>
) : null}
</View>
)
}
const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => {
return (
<View
style={{
flexDirection: 'row',
backgroundColor: 'rgba(0,0,0,0.35)',
borderRadius: 999,
padding: 4,
gap: 4,
opacity: disabled ? 0.6 : 1,
}}
pointerEvents={disabled ? 'none' : 'auto'}
>
{CAMERA_FACING_OPTIONS.map((option) => {
const isActive = option.value === value
return (
<TouchableOpacity
key={option.value}
activeOpacity={0.8}
onPress={() => {
if (typeof onChange === 'function') onChange(option.value)
}}
disabled={isActive}
style={{
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: isActive ? Palette.white : 'transparent',
}}
>
<Text
style={{
color: isActive ? Palette.black : Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
}}
>
{option.label}
</Text>
</TouchableOpacity>
)
})}
</View>
)
}