923 lines
28 KiB
JavaScript
923 lines
28 KiB
JavaScript
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 (
|
||
<Page
|
||
style={{ flex: 1 }}
|
||
// containerStyle={{ backgroundColor: Palette.gray }}
|
||
headerType="NONE"
|
||
backgroundColor={Palette.grayMid}
|
||
// backgroundImg={background.playbackBG2}
|
||
>
|
||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||
|
||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||
<View
|
||
style={{
|
||
width: WEB_PREVIEW_WIDTH,
|
||
maxWidth: '100%',
|
||
aspectRatio: 9 / 16,
|
||
overflow: 'hidden',
|
||
borderRadius: 12,
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
<video
|
||
ref={previewVideoRef}
|
||
autoPlay
|
||
playsInline
|
||
muted
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
transform: 'scaleX(-1)',
|
||
backgroundColor: 'transparent',
|
||
}}
|
||
onLoadedMetadata={(event) => {
|
||
try {
|
||
const video = event?.currentTarget
|
||
const playPromise = video?.play?.()
|
||
if (playPromise && typeof playPromise.catch === 'function') {
|
||
playPromise.catch(() => {})
|
||
}
|
||
} catch {}
|
||
}}
|
||
/>
|
||
|
||
{/* Overlay */}
|
||
<View
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
paddingHorizontal: gutters,
|
||
paddingTop: top,
|
||
paddingBottom: gutters * 2,
|
||
}}
|
||
>
|
||
<View style={{ flex: 1, marginTop: 11 }}>
|
||
{mediaError && (
|
||
<View
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'transparent',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingHorizontal: gutters,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
textAlign: 'center',
|
||
color: Palette.white,
|
||
fontFamily: FONT_FAMILY.InterMedium,
|
||
}}
|
||
>
|
||
{'Caméra indisponible : '}
|
||
{String(mediaError?.message || '').trim() || 'vérifie les permissions'}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{!mediaError && !mediaReady && (
|
||
<View
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'transparent',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: Palette.white,
|
||
fontFamily: FONT_FAMILY.InterMedium,
|
||
}}
|
||
>
|
||
Initialisation de la caméra…
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{isPreparing && countdown >= 1 && !isRecording && (
|
||
<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>
|
||
)}
|
||
</View>
|
||
|
||
{/* Start */}
|
||
{permissionsGranted && !isPreparing && !isRecording && (
|
||
<GradientButton
|
||
title="Lancer ma musique"
|
||
containerStyle={{ width: '80%', alignSelf: 'center' }}
|
||
disabled={!songUrl || !canRecord}
|
||
onPress={startCountdownThenRecord}
|
||
/>
|
||
)}
|
||
|
||
{/* Progress circulaire + restart */}
|
||
{permissionsGranted && (isRecording || isPreparing) && (
|
||
<View
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
// bottom: gutters,
|
||
bottom: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
width: 120,
|
||
height: 120,
|
||
borderRadius: 120,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<ProgressRing size={120} strokeWidth={8} progress={progressRatio} />
|
||
<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>
|
||
</View>
|
||
)}
|
||
|
||
{/* Permission prompt */}
|
||
{!permissionsGranted && (
|
||
<View
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: gutters,
|
||
padding: gutters,
|
||
}}
|
||
>
|
||
<GradientButton
|
||
title="Autoriser la caméra"
|
||
onPress={async () => {
|
||
try {
|
||
if (!cameraPermission?.granted) await requestCameraPermission()
|
||
} catch {}
|
||
}}
|
||
/>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Lyrics */}
|
||
<View
|
||
pointerEvents="box-none"
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: top + 90,
|
||
paddingHorizontal: gutters,
|
||
}}
|
||
>
|
||
<CreateLyricsHeader>
|
||
{isRecording && alignedWords.length > 0 ? (
|
||
<KaraokeLyrics
|
||
alignedWords={alignedWords}
|
||
currentTimeS={positionMs / 1000}
|
||
mode="teleprompter"
|
||
teleprompterLines={6}
|
||
teleprompterAnchorLine={1}
|
||
/>
|
||
) : (
|
||
<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>
|
||
</View>
|
||
{/* </View> */}
|
||
</Page>
|
||
)
|
||
}
|
||
|
||
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' }],
|
||
backgroundColor: '#ffffff3d',
|
||
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
|