import { useFocusEffect } from '@react-navigation/native'
import { useCameraPermissions } from 'expo-camera'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Svg, { Circle } from 'react-native-svg'
import alert from '../../components/Alert'
import GradientButton from '../../components/GradientButton'
import KaraokeLyrics from '../../components/KaraokeLyrics'
import MusicLandHeader from '../../components/MusicLandHeader'
import { increment, projectsRef } from '../../config/firebase'
import usePlayer from '../../hooks/usePlayer'
import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { background } from '../../assets'
import RestartSpinnerIcon from '../../assets/UI/RestartSpinnerIcon'
import { registerBlobUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
import { clampSyncOffsetMs } from '../../utils/playbackSync'
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
const TIME_BEFORE_INCREMENT_MS = 20000
const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10
const WEB_PREVIEW_WIDTH = 360
const MEDIA_BOOTSTRAP_DELAY_MS = 250
const MEDIA_RETRY_DELAY_MS = 700
const MEDIA_MAX_RETRIES = 2
const pickRecorderMimeType = () => {
if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
]
for (const mimeType of candidates) {
try {
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType
} catch {}
}
return undefined
}
const RecordPlayback = ({ route }) => {
const { top, bottom } = useSafeAreaInsets()
const { project } = route.params || {}
const songIndex = Number(project?.songIndex ?? 0) || 0
const [cameraPermission, requestCameraPermission] = useCameraPermissions()
const { setLooping, isLooping } = usePlayer() || {}
// Player
const songUrl = project?.songUrl || null
const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: project?.id,
title: typeof project?.title === 'string' ? project.title : 'Sans titre',
artist: typeof project?.userName === 'string' ? project.userName : 'MusicLand',
artwork: project?.coverUrl || null,
coverUrl: project?.coverUrl || null,
metadata: { projectId: project?.id, screen: 'RecordPlayback' },
})
const positionMs = player?.positionMs || 0
const durationMs = player?.durationMs || 0
// MediaStream / Recorder (web only)
const previewVideoRef = useRef(null)
const mediaStreamRef = useRef(null)
const mediaRecorderRef = useRef(null)
const recordedChunksRef = useRef([])
const stopRecordingPromiseRef = useRef(null)
const stopRecordingResolveRef = useRef(null)
const recordedUrlRef = useRef(null)
const preserveRecordingForNextScreenRef = useRef(false)
const originalLoopingValueRef = useRef({ hasValue: false, value: false })
const latestLoopingValueRef = useRef(isLooping ?? false)
const mediaInitInFlightRef = useRef(false)
const mediaRetryTimerRef = useRef(null)
const mediaInitAttemptsRef = useRef(0)
const [mediaReady, setMediaReady] = useState(false)
const [mediaError, setMediaError] = useState(null)
// UI state
const [countdown, setCountdown] = useState(0)
const [isPreparing, setIsPreparing] = useState(false)
const [isRecording, setIsRecording] = useState(false)
// timers / refs
const listenTimerRef = useRef(null)
const countdownTimerRef = useRef(null)
const listenedMsRef = useRef(0)
const viewsIncrementedRef = useRef(false)
const playbackStartedAtRef = useRef(null)
const recordingStartAtRef = useRef(null)
const stopInFlightRef = useRef(false)
useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false
}, [isLooping])
// Lyrics
const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[songIndex]
const rawAlignedWords = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []
const trimmedAlignedWords = trimLeadingDuplicateSection(rawAlignedWords)
return trimmedAlignedWords.map((w) => ({
word: String(w?.word ?? ''),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
}))
}, [project?.musicTimestamps, songIndex])
const clearAllTimers = () => {
if (listenTimerRef.current) clearInterval(listenTimerRef.current)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
listenTimerRef.current = countdownTimerRef.current = null
}
const clearMediaRetry = useCallback(() => {
if (mediaRetryTimerRef.current) {
clearTimeout(mediaRetryTimerRef.current)
mediaRetryTimerRef.current = null
}
}, [])
const resetUI = () => {
setIsPreparing(false)
setIsRecording(false)
setCountdown(0)
listenedMsRef.current = 0
viewsIncrementedRef.current = false
playbackStartedAtRef.current = null
recordingStartAtRef.current = null
stopInFlightRef.current = false
}
const releaseRecordingUrl = useCallback(() => {
if (!recordedUrlRef.current) {
preserveRecordingForNextScreenRef.current = false
return
}
releaseBlobUrl(recordedUrlRef.current)
recordedUrlRef.current = null
preserveRecordingForNextScreenRef.current = false
}, [])
const stopMediaStream = useCallback(() => {
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
mediaStreamRef.current = null
}
}, [])
const attachPreview = useCallback(() => {
const video = previewVideoRef.current
const stream = mediaStreamRef.current
if (!video || !stream) return false
try {
if (video.srcObject !== stream) {
video.srcObject = stream
}
} catch (e) {}
try {
const playPromise = video.play?.()
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {})
}
} catch (e) {}
return true
}, [])
const ensureMediaStream = useCallback(
async ({ force = false } = {}) => {
if (!cameraPermission?.granted) return false
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
setMediaError(new Error("La capture vidéo n'est pas supportée sur ce navigateur"))
setMediaReady(false)
return false
}
if (mediaInitInFlightRef.current) return false
if (!force && mediaStreamRef.current) {
setMediaReady(true)
setMediaError(null)
attachPreview()
return true
}
mediaInitInFlightRef.current = true
try {
if (force && mediaStreamRef.current) {
stopMediaStream()
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
audio: false,
})
mediaStreamRef.current = stream
setMediaReady(true)
setMediaError(null)
mediaInitAttemptsRef.current = 0
attachPreview()
return true
} catch (e) {
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
setMediaReady(false)
return false
} finally {
mediaInitInFlightRef.current = false
}
},
[attachPreview, cameraPermission?.granted, stopMediaStream]
)
const schedulePreviewRetry = useCallback(
function schedulePreviewRetry() {
clearMediaRetry()
if (mediaInitAttemptsRef.current >= MEDIA_MAX_RETRIES) return
mediaRetryTimerRef.current = setTimeout(async () => {
const video = previewVideoRef.current
const previewOk =
!!video &&
video.readyState >= 2 &&
video.videoWidth > 0 &&
video.videoHeight > 0 &&
!video.paused
if (previewOk) return
mediaInitAttemptsRef.current += 1
const ok = await ensureMediaStream({ force: true })
if (ok) schedulePreviewRetry()
}, MEDIA_RETRY_DELAY_MS)
},
[clearMediaRetry, ensureMediaStream]
)
const startRecorder = useCallback(async () => {
if (!mediaStreamRef.current) {
return null
}
try {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
} catch (e) {}
recordedChunksRef.current = []
releaseRecordingUrl()
const mimeType = pickRecorderMimeType()
let recorder
try {
recorder =
mimeType != null
? new MediaRecorder(mediaStreamRef.current, { mimeType })
: new MediaRecorder(mediaStreamRef.current)
} catch (e) {
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
return null
}
mediaRecorderRef.current = recorder
stopRecordingPromiseRef.current = new Promise((resolve) => {
stopRecordingResolveRef.current = resolve
})
recorder.ondataavailable = (event) => {
if (event?.data && event.data.size > 0) {
recordedChunksRef.current.push(event.data)
}
}
let resolveRecorderStart
const recorderStartedPromise = new Promise((resolve) => {
resolveRecorderStart = resolve
})
recorder.onstart = () => {
resolveRecorderStart?.(performance.now())
resolveRecorderStart = null
}
recorder.onerror = (event) => {
const err = event?.error || event
setMediaError(err instanceof Error ? err : new Error(String(err || '')))
resolveRecorderStart?.(null)
resolveRecorderStart = null
}
recorder.onstop = () => {
let url = null
try {
if (recordedChunksRef.current.length > 0) {
const blob = new Blob(recordedChunksRef.current, {
type: recorder.mimeType || mimeType || 'video/webm',
})
url = URL.createObjectURL(blob)
recordedUrlRef.current = url
registerBlobUrl(url, blob)
} else {
releaseRecordingUrl()
}
} catch (e) {
releaseRecordingUrl()
}
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(url)
stopRecordingResolveRef.current = null
}
recordedChunksRef.current = []
mediaRecorderRef.current = null
stopRecordingPromiseRef.current = null
}
try {
recorder.start(1000)
return await recorderStartedPromise
} catch (e) {
resolveRecorderStart?.(null)
resolveRecorderStart = null
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(null)
stopRecordingResolveRef.current = null
}
stopRecordingPromiseRef.current = null
mediaRecorderRef.current = null
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
return null
}
}, [releaseRecordingUrl, setMediaError])
const stopRecorderAndGetUrl = useCallback(async () => {
let waitPromise = stopRecordingPromiseRef.current
try {
const recorder = mediaRecorderRef.current
if (recorder && recorder.state !== 'inactive') {
if (!waitPromise) {
waitPromise = new Promise((resolve) => {
stopRecordingResolveRef.current = resolve
})
stopRecordingPromiseRef.current = waitPromise
}
recorder.stop()
}
} catch (e) {}
if (!waitPromise) {
return recordedUrlRef.current || null
}
let url = null
try {
url = await waitPromise
} catch (e) {
} finally {
stopRecordingPromiseRef.current = null
stopRecordingResolveRef.current = null
}
return url || recordedUrlRef.current || null
}, [])
const resetSession = useCallback(async () => {
clearAllTimers()
resetUI()
try {
await player?.pause?.()
await player?.seekTo?.(0)
} catch (e) {}
try {
await stopRecorderAndGetUrl()
} catch (e) {}
if (!preserveRecordingForNextScreenRef.current) {
releaseRecordingUrl()
}
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl])
const resetSessionRef = useRef(resetSession)
useEffect(() => {
resetSessionRef.current = resetSession
}, [resetSession])
useFocusEffect(
useCallback(() => {
if (typeof setLooping === 'function') {
originalLoopingValueRef.current = {
hasValue: true,
value: latestLoopingValueRef.current,
}
if (latestLoopingValueRef.current) {
setLooping(false)
}
}
return () => {
if (typeof setLooping === 'function' && originalLoopingValueRef.current?.hasValue) {
setLooping(!!originalLoopingValueRef.current.value)
}
}
}, [setLooping])
)
useFocusEffect(
useCallback(() => {
void resetSessionRef.current?.()
return () => {
void resetSessionRef.current?.()
}
}, [])
)
useEffect(() => {
;(async () => {
try {
if (!cameraPermission?.granted) {
await requestCameraPermission()
}
} catch (e) {}
})()
return () => {
clearAllTimers()
}
}, []) // eslint-disable-line
useEffect(() => {
if (!cameraPermission?.granted) {
setMediaReady(false)
clearMediaRetry()
mediaInitAttemptsRef.current = 0
return
}
let cancelled = false
const boot = async () => {
const ok = await ensureMediaStream()
if (!cancelled && ok) {
schedulePreviewRetry()
}
}
const timeoutId = setTimeout(boot, MEDIA_BOOTSTRAP_DELAY_MS)
return () => {
cancelled = true
clearTimeout(timeoutId)
clearMediaRetry()
}
}, [cameraPermission?.granted, clearMediaRetry, ensureMediaStream, schedulePreviewRetry])
useEffect(() => {
const video = previewVideoRef.current
const stream = mediaStreamRef.current
if (!video) return
if (stream) {
attachPreview()
} else {
try {
video.srcObject = null
} catch {}
}
}, [attachPreview, mediaReady, mediaError])
useFocusEffect(
useCallback(() => {
let active = true
const timeoutId = setTimeout(() => {
if (!active) return
if (mediaStreamRef.current) {
attachPreview()
schedulePreviewRetry()
return
}
if (cameraPermission?.granted) {
void ensureMediaStream()
}
}, MEDIA_BOOTSTRAP_DELAY_MS)
return () => {
active = false
clearTimeout(timeoutId)
}
}, [attachPreview, cameraPermission?.granted, ensureMediaStream, schedulePreviewRetry])
)
useEffect(() => {
return () => {
try {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
} catch {}
stopMediaStream()
clearMediaRetry()
if (!preserveRecordingForNextScreenRef.current) {
releaseRecordingUrl()
}
}
}, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream])
const stopAndNavigate = useCallback(async () => {
if (stopInFlightRef.current) return
stopInFlightRef.current = true
preserveRecordingForNextScreenRef.current = true
clearAllTimers()
try {
await player?.pause?.()
} catch (e) {}
let videoUrl = null
try {
videoUrl = await stopRecorderAndGetUrl()
if (!videoUrl) {
videoUrl = recordedUrlRef.current || null
}
} catch (e) {}
setIsRecording(false)
const rawOffsetMs =
playbackStartedAtRef.current != null && recordingStartAtRef.current != null
? playbackStartedAtRef.current - recordingStartAtRef.current
: 0
const syncOffsetMs = clampSyncOffsetMs(Math.round(rawOffsetMs || 0))
navigate(Routes.RecordedPlayback, {
project,
videoUri: videoUrl || null,
syncOffsetMs,
})
}, [player, project, stopRecorderAndGetUrl])
useEffect(() => {
if (!isRecording || !durationMs || positionMs < durationMs - 300) return
void stopAndNavigate()
}, [durationMs, isRecording, positionMs, stopAndNavigate])
const startListenLoop = useCallback(() => {
if (listenTimerRef.current) clearInterval(listenTimerRef.current)
listenTimerRef.current = setInterval(async () => {
try {
if (player?.playing) {
listenedMsRef.current += 500
if (!viewsIncrementedRef.current && listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS) {
viewsIncrementedRef.current = true
if (project?.id) {
try {
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
} catch (e) {}
}
}
}
} catch (e) {}
}, 500)
}, [player, project?.id])
const startPlayback = useCallback(async () => {
try {
await player?.pause?.()
await player?.seekTo?.(0)
const recordingStartedAt = await startRecorder()
if (recordingStartedAt == null) {
throw new Error("Impossible de démarrer l'enregistrement vidéo")
}
recordingStartAtRef.current = recordingStartedAt
await player?.play?.()
playbackStartedAtRef.current = performance.now()
setIsRecording(true)
startListenLoop()
} catch (e) {
setIsRecording(false)
setIsPreparing(false)
stopInFlightRef.current = false
const msg = String(e?.message || e || '')
if (/not supported on the simulator/i.test(msg)) {
alert(
'Indisponible sur simulateur',
'L’enregistrement vidéo n’est pas disponible sur le simulateur. Merci d’utiliser un appareil réel.',
[
{
text: 'OK',
onPress: () => {
try {
goBack()
} catch {}
},
},
],
{ cancelable: false }
)
}
}
}, [player, startRecorder, startListenLoop])
const permissionsGranted = !!cameraPermission?.granted
const canRecord = permissionsGranted && mediaReady && !mediaError
const startCountdownThenRecord = useCallback(async () => {
if (!songUrl || !canRecord) return
await resetSession()
setIsPreparing(true)
setCountdown(COUNTDOWN_SECONDS)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
countdownTimerRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearAllTimers()
setIsPreparing(false)
requestAnimationFrame(() => {
void startPlayback()
})
return 0
}
return c - 1
})
}, 1000)
}, [songUrl, canRecord, resetSession, startPlayback])
const handleRestartRecording = useCallback(async () => {
try {
await startCountdownThenRecord()
} catch (e) {}
}, [startCountdownThenRecord])
const progressRatio = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0
return (
{/* */}
)
}
const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
const r = size / 2 - strokeWidth / 2
const c = 2 * Math.PI * r
const clamped = Math.max(0, Math.min(1, progress || 0))
const offset = c * (1 - clamped)
return (
)
}
export default RecordPlayback