1196 lines
39 KiB
JavaScript
1196 lines
39 KiB
JavaScript
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 (
|
||
<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>
|
||
)
|
||
}
|