feat: synchronise audio and video playback

This commit is contained in:
2026-08-11 16:33:44 +02:00
parent 7dbe60f500
commit 4fb8a3e1e0
8 changed files with 199 additions and 611 deletions
+64 -212
View File
@@ -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}