Files
musicland/src/screens/Playback/RecordPlayback.web.js
T

923 lines
28 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 { 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',
'Lenregistrement vidéo nest pas disponible sur le simulateur. Merci dutiliser 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