feat: synchronise audio and video playback
This commit is contained in:
@@ -29,14 +29,6 @@ const WEB_PREVIEW_WIDTH = 360
|
||||
const MEDIA_BOOTSTRAP_DELAY_MS = 250
|
||||
const MEDIA_RETRY_DELAY_MS = 700
|
||||
const MEDIA_MAX_RETRIES = 2
|
||||
const CAMERA_STARTUP_DELAY_MS = 250
|
||||
const KARAOKE_LEAD_S = 0.68
|
||||
|
||||
const toSeconds = (v) => {
|
||||
const n = Number(v ?? 0)
|
||||
if (!Number.isFinite(n) || n < 0) return 0
|
||||
return n > 10000 ? n / 1000 : n // heuristique ms → s
|
||||
}
|
||||
|
||||
const pickRecorderMimeType = () => {
|
||||
if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined
|
||||
@@ -51,7 +43,7 @@ const pickRecorderMimeType = () => {
|
||||
for (const mimeType of candidates) {
|
||||
try {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return undefined
|
||||
@@ -74,6 +66,8 @@ const RecordPlayback = ({ route }) => {
|
||||
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)
|
||||
@@ -96,31 +90,19 @@ const RecordPlayback = ({ route }) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [isPreparing, setIsPreparing] = useState(false)
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [pos, setPos] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
|
||||
// timers / refs
|
||||
const isPausedRef = useRef(false)
|
||||
const progressTimerRef = useRef(null)
|
||||
const listenTimerRef = useRef(null)
|
||||
const countdownTimerRef = useRef(null)
|
||||
const perfStartRef = useRef(null)
|
||||
const pausedAtRef = useRef(null)
|
||||
const pausedMsRef = useRef(0)
|
||||
const listenedMsRef = useRef(0)
|
||||
const viewsIncrementedRef = useRef(false)
|
||||
const correctedInitialJumpRef = useRef(false)
|
||||
const playbackStartedAtRef = useRef(null)
|
||||
const recordingStartAtRef = useRef(null)
|
||||
const stopInFlightRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
latestLoopingValueRef.current = isLooping ?? false
|
||||
}, [isLooping])
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused
|
||||
}, [isPaused])
|
||||
|
||||
// Lyrics
|
||||
const alignedWords = useMemo(() => {
|
||||
const ts = project?.musicTimestamps?.[songIndex]
|
||||
@@ -134,10 +116,9 @@ const RecordPlayback = ({ route }) => {
|
||||
}, [project?.musicTimestamps, songIndex])
|
||||
|
||||
const clearAllTimers = () => {
|
||||
if (progressTimerRef.current) clearInterval(progressTimerRef.current)
|
||||
if (listenTimerRef.current) clearInterval(listenTimerRef.current)
|
||||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
|
||||
progressTimerRef.current = listenTimerRef.current = countdownTimerRef.current = null
|
||||
listenTimerRef.current = countdownTimerRef.current = null
|
||||
}
|
||||
|
||||
const clearMediaRetry = useCallback(() => {
|
||||
@@ -150,19 +131,12 @@ const RecordPlayback = ({ route }) => {
|
||||
const resetUI = () => {
|
||||
setIsPreparing(false)
|
||||
setIsRecording(false)
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
pausedMsRef.current = 0
|
||||
setCountdown(0)
|
||||
setPos(0)
|
||||
setDur(0)
|
||||
listenedMsRef.current = 0
|
||||
viewsIncrementedRef.current = false
|
||||
perfStartRef.current = null
|
||||
correctedInitialJumpRef.current = false
|
||||
playbackStartedAtRef.current = null
|
||||
recordingStartAtRef.current = null
|
||||
stopInFlightRef.current = false
|
||||
}
|
||||
|
||||
const releaseRecordingUrl = useCallback(() => {
|
||||
@@ -191,14 +165,14 @@ const RecordPlayback = ({ route }) => {
|
||||
if (video.srcObject !== stream) {
|
||||
video.srcObject = stream
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const playPromise = video.play?.()
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => { })
|
||||
playPromise.catch(() => {})
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
return true
|
||||
}, [])
|
||||
@@ -271,16 +245,16 @@ const RecordPlayback = ({ route }) => {
|
||||
[clearMediaRetry, ensureMediaStream]
|
||||
)
|
||||
|
||||
const startRecorder = useCallback(() => {
|
||||
const startRecorder = useCallback(async () => {
|
||||
if (!mediaStreamRef.current) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
recordedChunksRef.current = []
|
||||
releaseRecordingUrl()
|
||||
@@ -294,7 +268,7 @@ const RecordPlayback = ({ route }) => {
|
||||
: new MediaRecorder(mediaStreamRef.current)
|
||||
} catch (e) {
|
||||
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
mediaRecorderRef.current = recorder
|
||||
@@ -308,9 +282,21 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
@@ -340,8 +326,10 @@ const RecordPlayback = ({ route }) => {
|
||||
|
||||
try {
|
||||
recorder.start(1000)
|
||||
return true
|
||||
return await recorderStartedPromise
|
||||
} catch (e) {
|
||||
resolveRecorderStart?.(null)
|
||||
resolveRecorderStart = null
|
||||
if (stopRecordingResolveRef.current) {
|
||||
stopRecordingResolveRef.current(null)
|
||||
stopRecordingResolveRef.current = null
|
||||
@@ -349,7 +337,7 @@ const RecordPlayback = ({ route }) => {
|
||||
stopRecordingPromiseRef.current = null
|
||||
mediaRecorderRef.current = null
|
||||
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}, [releaseRecordingUrl, setMediaError])
|
||||
|
||||
@@ -366,7 +354,7 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
recorder.stop()
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
if (!waitPromise) {
|
||||
return recordedUrlRef.current || null
|
||||
@@ -390,10 +378,10 @@ const RecordPlayback = ({ route }) => {
|
||||
try {
|
||||
await player?.pause?.()
|
||||
await player?.seekTo?.(0)
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
try {
|
||||
await stopRecorderAndGetUrl()
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
if (!preserveRecordingForNextScreenRef.current) {
|
||||
releaseRecordingUrl()
|
||||
}
|
||||
@@ -433,12 +421,12 @@ const RecordPlayback = ({ route }) => {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
; (async () => {
|
||||
;(async () => {
|
||||
try {
|
||||
if (!cameraPermission?.granted) {
|
||||
await requestCameraPermission()
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
})()
|
||||
return () => {
|
||||
clearAllTimers()
|
||||
@@ -481,7 +469,7 @@ const RecordPlayback = ({ route }) => {
|
||||
} else {
|
||||
try {
|
||||
video.srcObject = null
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
}, [attachPreview, mediaReady, mediaError])
|
||||
|
||||
@@ -513,7 +501,7 @@ const RecordPlayback = ({ route }) => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
stopMediaStream()
|
||||
clearMediaRetry()
|
||||
if (!preserveRecordingForNextScreenRef.current) {
|
||||
@@ -523,11 +511,13 @@ const RecordPlayback = ({ route }) => {
|
||||
}, [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) { }
|
||||
} catch (e) {}
|
||||
|
||||
let videoUrl = null
|
||||
try {
|
||||
@@ -535,13 +525,9 @@ const RecordPlayback = ({ route }) => {
|
||||
if (!videoUrl) {
|
||||
videoUrl = recordedUrlRef.current || null
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
setIsRecording(false)
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
pausedMsRef.current = 0
|
||||
|
||||
const rawOffsetMs =
|
||||
playbackStartedAtRef.current != null && recordingStartAtRef.current != null
|
||||
@@ -556,68 +542,10 @@ const RecordPlayback = ({ route }) => {
|
||||
})
|
||||
}, [player, project, stopRecorderAndGetUrl])
|
||||
|
||||
const startProgressLoop = useCallback(() => {
|
||||
if (progressTimerRef.current) clearInterval(progressTimerRef.current)
|
||||
|
||||
progressTimerRef.current = setInterval(async () => {
|
||||
const rawDur = toSeconds(player?.duration)
|
||||
const rawPos = toSeconds(player?.currentTime)
|
||||
const pausedSince =
|
||||
pausedAtRef.current != null ? Math.max(0, performance.now() - pausedAtRef.current) : 0
|
||||
const pausedTotal = pausedMsRef.current + pausedSince
|
||||
|
||||
// fallback monotone si le player ne donne rien
|
||||
const fallback =
|
||||
perfStartRef.current != null
|
||||
? Math.max(0, (performance.now() - perfStartRef.current - pausedTotal) / 1000)
|
||||
: 0
|
||||
|
||||
let nextDur = rawDur > 0 ? rawDur : dur || 0
|
||||
let nextPos = Number.isFinite(rawPos) && rawPos >= 0 ? rawPos : fallback > 0 ? fallback : 0
|
||||
|
||||
// 🔧 Correction 1-shot du "jump initial" (ex: 0 → 93s au 1er tick)
|
||||
if (!correctedInitialJumpRef.current && nextPos > 1 && fallback < 1.5) {
|
||||
try {
|
||||
await player?.seekTo?.(0)
|
||||
correctedInitialJumpRef.current = true
|
||||
nextPos = 0
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const timeSinceStart =
|
||||
perfStartRef.current != null
|
||||
? Math.max(0, performance.now() - perfStartRef.current - pausedTotal)
|
||||
: null
|
||||
|
||||
const shouldTrustFallback =
|
||||
fallback > 0 &&
|
||||
((nextDur > 0 && nextPos > nextDur + 0.5) ||
|
||||
nextPos - fallback > 1.5 ||
|
||||
(timeSinceStart != null && timeSinceStart < 5000 && nextPos > fallback + 1))
|
||||
|
||||
if (shouldTrustFallback) {
|
||||
nextPos = fallback
|
||||
}
|
||||
|
||||
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur
|
||||
|
||||
setDur(nextDur)
|
||||
setPos(nextPos)
|
||||
|
||||
if (nextPos > 0 && playbackStartedAtRef.current == null && perfStartRef.current != null) {
|
||||
playbackStartedAtRef.current =
|
||||
perfStartRef.current + Math.max(0, (nextPos * 1000) - pausedTotal)
|
||||
}
|
||||
|
||||
if (isPausedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
|
||||
void stopAndNavigate()
|
||||
}
|
||||
}, 250)
|
||||
}, [player, stopAndNavigate])
|
||||
useEffect(() => {
|
||||
if (!isRecording || !durationMs || positionMs < durationMs - 300) return
|
||||
void stopAndNavigate()
|
||||
}, [durationMs, isRecording, positionMs, stopAndNavigate])
|
||||
|
||||
const startListenLoop = useCallback(() => {
|
||||
if (listenTimerRef.current) clearInterval(listenTimerRef.current)
|
||||
@@ -630,39 +558,31 @@ const RecordPlayback = ({ route }) => {
|
||||
if (project?.id) {
|
||||
try {
|
||||
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
}, 500)
|
||||
}, [player, project?.id])
|
||||
|
||||
const startPlayback = useCallback(async () => {
|
||||
try {
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
pausedMsRef.current = 0
|
||||
await player?.pause?.() // s'assure qu'on repart propre
|
||||
await player?.seekTo?.(0) // tente un seek d'amorçage
|
||||
recordingStartAtRef.current = performance.now()
|
||||
const recorderStarted = startRecorder()
|
||||
if (!recorderStarted) {
|
||||
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")
|
||||
}
|
||||
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS))
|
||||
await player?.play?.() // attend la promesse → l'élément est prêt
|
||||
perfStartRef.current = perfStartRef.current ?? performance.now()
|
||||
correctedInitialJumpRef.current = false // autorise la correction 1-shot
|
||||
recordingStartAtRef.current = recordingStartedAt
|
||||
await player?.play?.()
|
||||
playbackStartedAtRef.current = performance.now()
|
||||
setIsRecording(true)
|
||||
startProgressLoop()
|
||||
startListenLoop()
|
||||
} catch (e) {
|
||||
setIsRecording(false)
|
||||
setIsPreparing(false)
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
stopInFlightRef.current = false
|
||||
const msg = String(e?.message || e || '')
|
||||
if (/not supported on the simulator/i.test(msg)) {
|
||||
alert(
|
||||
@@ -674,7 +594,7 @@ const RecordPlayback = ({ route }) => {
|
||||
onPress: () => {
|
||||
try {
|
||||
goBack()
|
||||
} catch { }
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -682,7 +602,7 @@ const RecordPlayback = ({ route }) => {
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [player, startRecorder, startProgressLoop, startListenLoop])
|
||||
}, [player, startRecorder, startListenLoop])
|
||||
|
||||
const permissionsGranted = !!cameraPermission?.granted
|
||||
const canRecord = permissionsGranted && mediaReady && !mediaError
|
||||
@@ -690,8 +610,6 @@ const RecordPlayback = ({ route }) => {
|
||||
const startCountdownThenRecord = useCallback(async () => {
|
||||
if (!songUrl || !canRecord) return
|
||||
await resetSession()
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
setIsPreparing(true)
|
||||
setCountdown(COUNTDOWN_SECONDS)
|
||||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
|
||||
@@ -710,50 +628,13 @@ const RecordPlayback = ({ route }) => {
|
||||
}, 1000)
|
||||
}, [songUrl, canRecord, resetSession, startPlayback])
|
||||
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
try {
|
||||
if (!isRecording) return
|
||||
if (!isPausedRef.current) {
|
||||
isPausedRef.current = true
|
||||
setIsPaused(true)
|
||||
pausedAtRef.current = performance.now()
|
||||
try {
|
||||
await player?.pause?.()
|
||||
} catch (e) { }
|
||||
try {
|
||||
const recorder = mediaRecorderRef.current
|
||||
if (recorder && recorder.state === 'recording') {
|
||||
recorder.pause?.()
|
||||
}
|
||||
} catch (e) { }
|
||||
return
|
||||
}
|
||||
const pausedAt = pausedAtRef.current
|
||||
if (pausedAt != null) {
|
||||
pausedMsRef.current += performance.now() - pausedAt
|
||||
pausedAtRef.current = null
|
||||
}
|
||||
isPausedRef.current = false
|
||||
setIsPaused(false)
|
||||
try {
|
||||
const recorder = mediaRecorderRef.current
|
||||
if (recorder && recorder.state === 'paused') {
|
||||
recorder.resume?.()
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
await player?.play?.()
|
||||
} catch (e) { }
|
||||
} catch (e) { }
|
||||
}, [isRecording, player])
|
||||
|
||||
const handleRestartRecording = useCallback(async () => {
|
||||
try {
|
||||
await startCountdownThenRecord()
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
}, [startCountdownThenRecord])
|
||||
|
||||
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0
|
||||
const progressRatio = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -761,7 +642,7 @@ const RecordPlayback = ({ route }) => {
|
||||
// containerStyle={{ backgroundColor: Palette.gray }}
|
||||
headerType="NONE"
|
||||
backgroundColor={Palette.grayMid}
|
||||
// backgroundImg={background.playbackBG2}
|
||||
// backgroundImg={background.playbackBG2}
|
||||
>
|
||||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||||
|
||||
@@ -793,9 +674,9 @@ const RecordPlayback = ({ route }) => {
|
||||
const video = event?.currentTarget
|
||||
const playPromise = video?.play?.()
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => { })
|
||||
playPromise.catch(() => {})
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -941,34 +822,6 @@ const RecordPlayback = ({ route }) => {
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -988,7 +841,7 @@ const RecordPlayback = ({ route }) => {
|
||||
onPress={async () => {
|
||||
try {
|
||||
if (!cameraPermission?.granted) await requestCameraPermission()
|
||||
} catch { }
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -1010,8 +863,7 @@ const RecordPlayback = ({ route }) => {
|
||||
{isRecording && alignedWords.length > 0 ? (
|
||||
<KaraokeLyrics
|
||||
alignedWords={alignedWords}
|
||||
currentTimeS={pos}
|
||||
timeOffsetS={KARAOKE_LEAD_S}
|
||||
currentTimeS={positionMs / 1000}
|
||||
mode="teleprompter"
|
||||
teleprompterLines={6}
|
||||
teleprompterAnchorLine={1}
|
||||
|
||||
Reference in New Issue
Block a user