From 4fb8a3e1e06a53c2e379df7e38dbad844a0f69e6 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 11 Aug 2026 16:33:44 +0200 Subject: [PATCH] feat: synchronise audio and video playback --- src/components/KaraokeLyrics.js | 6 +- src/hooks/useSharedAudioPlayer.js | 23 +- src/screens/Playback/RecordPlayback.js | 343 ++---------------- src/screens/Playback/RecordPlayback.web.js | 276 ++++---------- src/screens/Playback/RecordedPlayback.js | 62 ++-- src/screens/Playback/RecordedPlayback.web.js | 85 ++--- .../Playbacks/components/PlaybackItem.web.js | 7 +- .../components/PlaybackLyricsCard.js | 8 +- 8 files changed, 199 insertions(+), 611 deletions(-) diff --git a/src/components/KaraokeLyrics.js b/src/components/KaraokeLyrics.js index 06d5ff6..711d878 100644 --- a/src/components/KaraokeLyrics.js +++ b/src/components/KaraokeLyrics.js @@ -128,7 +128,6 @@ const alphaWhite = (alpha) => `rgba(255,255,255,${alpha})` export default function KaraokeLyrics({ alignedWords = [], currentTimeS = 0, - timeOffsetS = 0, showContext = true, removeTags = true, mode = 'focus', @@ -148,10 +147,7 @@ export default function KaraokeLyrics({ ) const activeLines = mode === 'teleprompter' ? teleprompterData : lines - const effectiveTimeS = useMemo( - () => Math.max(0, Number(currentTimeS || 0) + Number(timeOffsetS || 0)), - [currentTimeS, timeOffsetS] - ) + const effectiveTimeS = Math.max(0, Number(currentTimeS || 0)) const currentLineIdx = useMemo(() => { return findFocusedLineIndex(activeLines, effectiveTimeS) diff --git a/src/hooks/useSharedAudioPlayer.js b/src/hooks/useSharedAudioPlayer.js index 1e3e702..d09ae25 100644 --- a/src/hooks/useSharedAudioPlayer.js +++ b/src/hooks/useSharedAudioPlayer.js @@ -69,13 +69,14 @@ const useSharedAudioPlayer = (source, options = null) => { durationMs: 0, }) - useEffect(() => { - stateRef.current = { - isPlaying, - positionMs, - durationMs, - } - }, [isPlaying, positionMs, durationMs]) + // Keep the imperative player getters on the exact snapshot that triggered + // this render. Consumers can therefore use the shared player as their only + // playback clock without maintaining a second timer. + stateRef.current = { + isPlaying, + positionMs, + durationMs, + } const controlsRef = useRef({ play, @@ -135,6 +136,14 @@ const useSharedAudioPlayer = (source, options = null) => { enumerable: true, get: () => stateRef.current.positionMs / 1000, }, + positionMs: { + enumerable: true, + get: () => stateRef.current.positionMs, + }, + durationMs: { + enumerable: true, + get: () => stateRef.current.durationMs, + }, }) player.load = async ({ startPositionMs = 0 } = {}) => { diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 187dba1..c43489f 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -25,13 +25,12 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' const TIME_BEFORE_INCREMENT_MS = 20000 // 20s const COUNTDOWN_SECONDS = 10 const CAMERA_STARTUP_DELAY_MS = 250 -const KARAOKE_LEAD_S = 0.68 const LOG_PREFIX = '[RecordPlayback]' const KEEP_AWAKE_TAG = 'record-playback' const log = typeof __DEV__ === 'undefined' || __DEV__ ? (...args) => console.log(LOG_PREFIX, ...args) - : () => { } + : () => {} const CAMERA_FACING_OPTIONS = [ { label: 'Avant', value: 'front' }, @@ -53,7 +52,6 @@ const RecordPlayback = ({ route }) => { const countdownTimerRef = useRef(null) const listenTimerRef = useRef(null) const checkSongEndRef = useRef(null) - const isPausedRef = useRef(false) const stopRequestedRef = useRef(false) const restartRequestedRef = useRef(false) const recordingStartTimeRef = useRef(0) // Track recording start time for duration check @@ -63,12 +61,8 @@ const RecordPlayback = ({ route }) => { const exitRequestedRef = useRef(false) const startedRef = useRef(false) // empêche les doubles démarrages const countdownActiveRef = useRef(false) // évite le déclenchement avant 1er tick - const playbackStartedRef = useRef(false) // devient vrai lorsque l'audio progresse réellement + const playbackStartedRef = useRef(false) const playbackStartedAtRef = useRef(0) - const playbackProbeRef = useRef({ lastPos: 0, lastAt: 0 }) - const playbackStartRequestAtRef = useRef(0) - const playbackStartWarnedRef = useRef(false) - const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 }) const originalLoopingValueRef = useRef({ hasValue: false, value: false }) const latestLoopingValueRef = useRef(isLooping ?? false) @@ -80,9 +74,7 @@ const RecordPlayback = ({ route }) => { const [isPreparing, setIsPreparing] = useState(false) const [countdown, setCountdown] = useState(0) const [isRecording, setIsRecording] = useState(false) - const [isPaused, setIsPaused] = useState(false) const [showProgress, setShowProgress] = useState(false) - const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }) const [cameraFacing, setCameraFacing] = useState('front') // Musique @@ -103,10 +95,6 @@ const RecordPlayback = ({ route }) => { useEffect(() => { latestLoopingValueRef.current = isLooping ?? false }, [isLooping]) - useEffect(() => { - isPausedRef.current = isPaused - }, [isPaused]) - useEffect(() => { const shouldKeepAwake = isPreparing || isRecording const updateKeepAwake = async () => { @@ -116,13 +104,13 @@ const RecordPlayback = ({ route }) => { } else { await deactivateKeepAwake(KEEP_AWAKE_TAG) } - } catch (_) { } + } catch (_) {} } void updateKeepAwake() return () => { try { void deactivateKeepAwake(KEEP_AWAKE_TAG) - } catch (_) { } + } catch (_) {} } }, [isPreparing, isRecording]) @@ -150,156 +138,23 @@ const RecordPlayback = ({ route }) => { })) }, [project?.musicTimestamps, musicIndex]) - // Build line groups to display line-by-line - const lines = useMemo(() => { - const out = [] - let buf = [] - let start = null - const clean = (txt) => - String(txt || '') - .replace(/\s+/g, ' ') - .trim() - const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()) - const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim()) - for (let i = 0; i < alignedWords.length; i++) { - const w = alignedWords[i] - const original = String(w.word || '') - const textNoNewline = original.replace(/\n/g, ' ') - const gapToNext = - i < alignedWords.length - 1 ? Math.max(0, alignedWords[i + 1].startS - w.endS) : 0 - - // If buffer empty, mark start - if (buf.length === 0) start = w.startS - - // Section tag becomes its own line - if (isSectionTag(textNoNewline)) { - const joined = clean(textNoNewline) - if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS }) - buf = [] - start = null - continue - } - - buf.push(textNoNewline) - - const eolByNewline = /\n/.test(original) - const eolByPause = gapToNext >= 0.6 - const eolByPunct = isSentenceEnd(textNoNewline) - const isLast = i === alignedWords.length - 1 - - if (eolByNewline || eolByPause || eolByPunct || isLast) { - const joined = clean(buf.join(' ')) - if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS }) - buf = [] - start = null - } - } - return out - }, [alignedWords]) - useEffect(() => { listenedMsRef.current = 0 incrementDoneRef.current = false - progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } playbackStartedRef.current = false playbackStartedAtRef.current = 0 - playbackProbeRef.current = { lastPos: 0, lastAt: 0 } - playbackStartRequestAtRef.current = 0 - playbackStartWarnedRef.current = false - setProgressInfo({ pos: 0, dur: 0 }) log('Song URL changed, reset counters', { songUrl }) }, [songUrl]) - // Poll player -> progress ring - useEffect(() => { - if (!player) return - const id = setInterval(() => { - try { - const now = Date.now() - const dur = (player?.duration || 0) * 1000 - const pos = (player?.currentTime || 0) * 1000 - const lastProbe = playbackProbeRef.current - const advancing = pos > (lastProbe?.lastPos || 0) + 30 - const playbackHasAdvanced = pos >= 80 || advancing - playbackProbeRef.current = { lastPos: pos, lastAt: now } - - if ( - playbackStartRequestAtRef.current && - !playbackStartedRef.current && - !playbackStartWarnedRef.current && - now - playbackStartRequestAtRef.current > 1500 - ) { - playbackStartWarnedRef.current = true - log('Playback not started yet', { - playing: player?.playing, - pos, - dur, - }) - } - - if (!playbackStartedRef.current && playbackHasAdvanced) { - playbackStartedRef.current = true - const estimatedStartAt = pos > 0 ? now - pos : now - playbackStartedAtRef.current = estimatedStartAt - log('Playback detected', { - pos, - dur, - startedAt: estimatedStartAt, - reason: advancing ? 'advancing' : 'position', - }) - } - setProgressInfo((prev) => { - const effectivePos = playbackStartedRef.current ? pos : 0 - if (prev.pos === effectivePos && prev.dur === dur) return prev - return { pos: effectivePos, dur } - }) - if (dur <= 0) { - if (progressLogRef.current.lastDur !== 0) { - progressLogRef.current = { bucket: -1, lastPos: pos, lastDur: 0 } - log('Progress waiting for duration', { - pos, - playing: player?.playing, - }) - } else { - progressLogRef.current.lastPos = pos - } - return - } - const pct = Math.max(0, Math.min(1, pos / dur)) - const bucket = Math.floor(pct * 10) - const { bucket: prevBucket } = progressLogRef.current - if (bucket !== prevBucket) { - log('Progress update', { - bucket, - pct: Number.isFinite(pct) ? Number(pct.toFixed(2)) : pct, - pos, - dur, - playing: player?.playing, - }) - } - progressLogRef.current = { bucket, lastPos: pos, lastDur: dur } - } catch (_) { } - }, 250) - return () => clearInterval(id) - }, [player]) - - // Compute current line index from playback time - const currentTimeS = (progressInfo?.pos || 0) / 1000 - const currentLineIdx = useMemo(() => { - if (!lines || lines.length === 0) return -1 - for (let i = 0; i < lines.length; i++) { - const L = lines[i] - if (currentTimeS >= L.startS && currentTimeS <= L.endS) return i - } - if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1 - return -1 - }, [lines, currentTimeS]) + // The audio player is the single clock for the progress ring and lyrics. + const positionMs = player?.positionMs || 0 + const durationMs = player?.durationMs || 0 const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0) // Permissions au mount + cleanup useEffect(() => { - ; (async () => { + ;(async () => { try { if (!cameraPermission?.granted) { log('Requesting camera permission on mount') @@ -307,7 +162,7 @@ const RecordPlayback = ({ route }) => { } else { log('Camera permission already granted on mount') } - } catch (_) { } + } catch (_) {} })() return () => { try { @@ -318,7 +173,7 @@ const RecordPlayback = ({ route }) => { listenTimerRef.current = null checkSongEndRef.current = null log('Cleanup on unmount, cleared timers') - } catch (_) { } + } catch (_) {} } }, []) // eslint-disable-line react-hooks/exhaustive-deps @@ -355,30 +210,23 @@ const RecordPlayback = ({ route }) => { incrementDoneRef.current = false playbackStartedRef.current = false playbackStartedAtRef.current = 0 - playbackProbeRef.current = { lastPos: 0, lastAt: 0 } - playbackStartRequestAtRef.current = 0 - playbackStartWarnedRef.current = false - setProgressInfo({ pos: 0, dur: 0 }) - progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } log('Session reset') setIsPreparing(false) setIsRecording(false) - setIsPaused(false) - isPausedRef.current = false setShowProgress(false) setCountdown(0) try { await cameraRef.current?.resumePreview?.() - } catch (_) { } + } catch (_) {} if (player) { try { if (player.playing) await player.pause?.() await player.seekTo?.(0) - } catch (_) { } + } catch (_) {} } - } catch (_) { } + } catch (_) {} }, [player] ) @@ -456,8 +304,6 @@ const RecordPlayback = ({ route }) => { preserveSongEndWatcher, preserveStopRequest, }) - setIsPaused(false) - isPausedRef.current = false if (!preserveRestartFlag) { restartRequestedRef.current = false manualRestartInFlightRef.current = false @@ -504,23 +350,16 @@ const RecordPlayback = ({ route }) => { try { stopRequestedRef.current = false stopRequestedAtRef.current = 0 - recordingStartTimeRef.current = Date.now() // Start timer listenedMsRef.current = 0 incrementDoneRef.current = false setIsRecording(true) setShowProgress(true) - setIsPaused(false) - isPausedRef.current = false try { await cameraRef.current?.resumePreview?.() - } catch (_) { } + } catch (_) {} playbackStartedRef.current = false - playbackProbeRef.current = { lastPos: 0, lastAt: 0 } - playbackStartRequestAtRef.current = 0 - playbackStartWarnedRef.current = false - setProgressInfo({ pos: 0, dur: 0 }) - progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } + playbackStartedAtRef.current = 0 log('startRecordingWithMusic', { hasPlayer: !!player, songUrl, @@ -586,6 +425,7 @@ const RecordPlayback = ({ route }) => { throw new Error("L'enregistrement vidéo n'est pas supporté sur cet appareil.") })() + recordingStartTimeRef.current = Date.now() activeRecordingPromiseRef.current = recordPromise if (player && songUrl) { @@ -598,12 +438,14 @@ const RecordPlayback = ({ route }) => { }) } - // Add artificial delay to compensate for camera startup latency (Audio usually starts faster than video recording) - await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS)) + // Give the native recorder a short warm-up, then measure the real gap + // between the recording request and the audio start for the final trim. + await new Promise((resolve) => setTimeout(resolve, CAMERA_STARTUP_DELAY_MS)) try { - playbackStartRequestAtRef.current = Date.now() await player.play?.() + playbackStartedAtRef.current = Date.now() + playbackStartedRef.current = true log('player.play invoked') } catch (error) { log('player.play failed', { @@ -625,10 +467,10 @@ const RecordPlayback = ({ route }) => { try { await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true }) log('Views incremented') - } catch (_) { } + } catch (_) {} } } - } catch (_) { } + } catch (_) {} }, 500) } @@ -638,7 +480,6 @@ const RecordPlayback = ({ route }) => { checkSongEndRef.current = setInterval(() => { try { if (!player) return - if (isPausedRef.current) return if (stopRequestedRef.current) { const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0) if (sinceLastRequest >= 1200) { @@ -646,7 +487,7 @@ const RecordPlayback = ({ route }) => { try { cameraRef.current?.stopRecording?.() log('stopRecording retried while awaiting stop') - } catch (_) { } + } catch (_) {} } return } @@ -671,9 +512,9 @@ const RecordPlayback = ({ route }) => { try { cameraRef.current?.stopRecording?.() log('stopRecording triggered') - } catch (_) { } + } catch (_) {} } - } catch (_) { } + } catch (_) {} }, 500) } @@ -691,7 +532,7 @@ const RecordPlayback = ({ route }) => { } try { if (player?.playing) await player.pause?.() - } catch (_) { } + } catch (_) {} if (listenTimerRef.current) { clearInterval(listenTimerRef.current) listenTimerRef.current = null @@ -699,12 +540,9 @@ const RecordPlayback = ({ route }) => { } setIsRecording(false) - setIsPaused(false) - isPausedRef.current = false setShowProgress(false) log('Recording flow completed', { hasVideo: !!video?.uri }) playbackStartedRef.current = false - progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } const exitRequested = exitRequestedRef.current const shouldRestart = restartRequestedRef.current // Duration check logic @@ -724,7 +562,7 @@ const RecordPlayback = ({ route }) => { await discardRecordingFile(video?.uri, 'too_short') try { goBack() - } catch (_) { } + } catch (_) {} return } } @@ -782,8 +620,6 @@ const RecordPlayback = ({ route }) => { stopRequestedAtRef.current = 0 setIsRecording(false) setIsPreparing(false) - setIsPaused(false) - isPausedRef.current = false setShowProgress(false) if (listenTimerRef.current) { clearInterval(listenTimerRef.current) @@ -796,7 +632,6 @@ const RecordPlayback = ({ route }) => { log('checkSongEndRef cleared (error path)') } playbackStartedRef.current = false - progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 } const exitRequested = exitRequestedRef.current const shouldRestart = restartRequestedRef.current if (exitRequested) { @@ -832,7 +667,7 @@ const RecordPlayback = ({ route }) => { onPress: () => { try { goBack() - } catch (_) { } + } catch (_) {} }, }, ], @@ -859,21 +694,21 @@ const RecordPlayback = ({ route }) => { if (player?.playing) { const maybePromise = player.pause?.() if (maybePromise && typeof maybePromise.catch === 'function') { - maybePromise.catch(() => { }) + maybePromise.catch(() => {}) } } - } catch (_) { } + } catch (_) {} try { if (isRecording) { cameraRef.current?.stopRecording?.() } - } catch (_) { } + } catch (_) {} } else { exitRequestedRef.current = false } try { goBack() - } catch (_) { } + } catch (_) {} return true }, [goBack, isPreparing, isRecording, player]) @@ -886,33 +721,6 @@ const RecordPlayback = ({ route }) => { }, [handleBackPress]) ) - const handleTogglePause = useCallback(async () => { - try { - if (!isRecording) return - if (!isPausedRef.current) { - setIsPaused(true) - isPausedRef.current = true - stopRequestedRef.current = false - stopRequestedAtRef.current = 0 - try { - await player?.pause?.() - } catch (_) { } - try { - await cameraRef.current?.pausePreview?.() - } catch (_) { } - return - } - setIsPaused(false) - isPausedRef.current = false - try { - await cameraRef.current?.resumePreview?.() - } catch (_) { } - try { - await player?.play?.() - } catch (_) { } - } catch (_) { } - }, [isRecording, player]) - const handleRestartRecording = async () => { try { const hasRecordingPending = !!activeRecordingPromiseRef.current @@ -921,11 +729,9 @@ const RecordPlayback = ({ route }) => { isRecording, hasRecordingPending, }) - setIsPaused(false) - isPausedRef.current = false try { await cameraRef.current?.resumePreview?.() - } catch (_) { } + } catch (_) {} if (isPreparing) { if (hasRecordingPending) { await startCountdownThenRecord({ @@ -952,16 +758,16 @@ const RecordPlayback = ({ route }) => { stopRequestedAtRef.current = Date.now() try { if (player?.playing) await player.pause?.() - } catch (_) { } + } catch (_) {} try { cameraRef.current?.stopRecording?.() - } catch (_) { } + } catch (_) {} await startCountdownThenRecord({ preserveRestartFlag: true, preserveSongEndWatcher: true, preserveStopRequest: true, }) - } catch (_) { } + } catch (_) {} } const permissionsGranted = !!cameraPermission?.granted @@ -1033,7 +839,7 @@ const RecordPlayback = ({ route }) => { onPress={async () => { try { if (!cameraPermission?.granted) await requestCameraPermission() - } catch (_) { } + } catch (_) {} }} /> @@ -1074,9 +880,7 @@ const RecordPlayback = ({ route }) => { { - {isRecording && ( - - - {isPaused ? 'Reprendre' : 'Mettre en pause'} - - - )} )} @@ -1146,8 +922,7 @@ const RecordPlayback = ({ route }) => { {(isRecording || showProgress) && alignedWords?.length > 0 ? ( { export default RecordPlayback -// Render previous/current/next line to reduce jumpiness -const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => { - const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : '' - const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : '' - const next = currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : '' - return ( - - {/* {prev ? ( - - {prev} - - ) : null} */} - - {curr} - - {next ? ( - - {next} - - ) : null} - - ) -} - const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => { return ( { - 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 ( { // containerStyle={{ backgroundColor: Palette.gray }} headerType="NONE" backgroundColor={Palette.grayMid} - // backgroundImg={background.playbackBG2} + // backgroundImg={background.playbackBG2} > @@ -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 }) => { - {isRecording && ( - - - {isPaused ? 'Reprendre' : 'Mettre en pause'} - - - )} )} @@ -988,7 +841,7 @@ const RecordPlayback = ({ route }) => { onPress={async () => { try { if (!cameraPermission?.granted) await requestCameraPermission() - } catch { } + } catch {} }} /> @@ -1010,8 +863,7 @@ const RecordPlayback = ({ route }) => { {isRecording && alignedWords.length > 0 ? ( { [audioPlayer, videoPlayer] ) + const syncVideoToAudio = useCallback(() => { + if (!videoPlayer) return + const rawAudioMs = getCurrentRawAudioPositionMs() + const timelineMs = getTimelinePositionMs(rawAudioMs, syncOffsetRef.current) + const expectedVideoMs = getVideoPositionMs(timelineMs, syncOffsetRef.current) + videoPlayer.currentTime = expectedVideoMs / 1000 + }, [getCurrentRawAudioPositionMs, videoPlayer]) + + const playSynchronized = useCallback(async () => { + if (audioPlayer && songUrl) await audioPlayer.play?.() + if (videoPlayer) videoPlayer.play() + syncVideoToAudio() + }, [audioPlayer, songUrl, syncVideoToAudio, videoPlayer]) + const stopPlayback = useCallback(async () => { try { if (audioPlayer) await audioPlayer.pause?.() @@ -104,8 +118,7 @@ const RecordedPlayback = ({ route }) => { const start = async () => { try { await alignPlaybackToTimeline(0, initialSyncOffsetMs) - if (audioPlayer && songUrl) await audioPlayer.play?.() - if (videoPlayer) videoPlayer.play() + await playSynchronized() } catch (e) {} } @@ -115,7 +128,7 @@ const RecordedPlayback = ({ route }) => { playbackEndedRef.current = false void stopPlayback() } - }, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoPlayer]) + }, [alignPlaybackToTimeline, initialSyncOffsetMs, playSynchronized, stopPlayback]) useEffect(() => { const id = global.setInterval(() => { @@ -139,7 +152,7 @@ const RecordedPlayback = ({ route }) => { const currentVideoMs = (videoPlayer.currentTime || 0) * 1000 const driftMs = Math.abs(currentVideoMs - expectedVideoMs) - if (driftMs > 350) { + if (driftMs > 120) { videoPlayer.currentTime = Math.max(0, expectedVideoMs / 1000) } } @@ -175,16 +188,9 @@ const RecordedPlayback = ({ route }) => { const resumeAfterSeek = useCallback(async () => { try { - if (audioPlayer?.resume) { - await audioPlayer.resume?.() - } else if (audioPlayer) { - await audioPlayer.play?.() - } + await playSynchronized() } catch (e) {} - try { - if (videoPlayer) videoPlayer.play() - } catch (e) {} - }, [audioPlayer, videoPlayer]) + }, [playSynchronized]) const handleSyncOffsetChange = useCallback( (nextOffsetMs) => { @@ -261,23 +267,14 @@ const RecordedPlayback = ({ route }) => { playbackEndedRef.current = false - if (songUrl && audioPlayer) { - if (audioPlayer?.resume) { - await audioPlayer.resume?.() - } else { - await audioPlayer.play?.() - } - } - - if (videoPlayer) videoPlayer.play() + await playSynchronized() } catch (e) {} - }, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, videoPlayer]) + }, [alignPlaybackToTimeline, audioPlayer, playSynchronized, progressInfo, videoPlayer]) return ( - {/* Vidéo avec play/pause en overlay */} { Synchro audio / vidéo - + handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))} disabled={!songUrl || currentSyncOffsetMs <= -2000} @@ -398,8 +402,8 @@ const RecordedPlayback = ({ route }) => { {currentSyncOffsetMs === 0 ? 'ms · synchronisé' : currentSyncOffsetMs > 0 - ? 'ms · son en avance' - : 'ms · son en retard'} + ? 'ms · son en avance' + : 'ms · son en retard'} @@ -433,8 +437,8 @@ const RecordedPlayback = ({ route }) => { !songUrl || currentSyncOffsetMs === 0 ? 'rgba(255,255,255,0.06)' : pressed - ? 'rgba(255,255,255,0.18)' - : 'rgba(255,255,255,0.1)', + ? 'rgba(255,255,255,0.18)' + : 'rgba(255,255,255,0.1)', opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1, })} > diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js index 7849d01..e51379f 100644 --- a/src/screens/Playback/RecordedPlayback.web.js +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -78,6 +78,24 @@ const RecordedPlayback = ({ route }) => { [audioPlayer, videoUri] ) + const syncVideoToAudio = useCallback(() => { + const video = videoElRef.current + if (!video || !videoUri) return + const rawAudioMs = getCurrentRawAudioPositionMs() + const timelineMs = getTimelinePositionMs(rawAudioMs, syncOffsetRef.current) + const expectedVideoMs = getVideoPositionMs(timelineMs, syncOffsetRef.current) + video.currentTime = expectedVideoMs / 1000 + }, [getCurrentRawAudioPositionMs, videoUri]) + + const playSynchronized = useCallback(async () => { + if (audioPlayer && songUrl) await audioPlayer.play?.() + if (videoElRef.current && videoUri) { + videoElRef.current.muted = true + await videoElRef.current.play().catch(() => {}) + } + syncVideoToAudio() + }, [audioPlayer, songUrl, syncVideoToAudio, videoUri]) + const stopPlayback = useCallback(async () => { try { if (audioPlayer) await audioPlayer.pause?.() @@ -113,15 +131,7 @@ const RecordedPlayback = ({ route }) => { const start = async () => { try { await alignPlaybackToTimeline(0, initialSyncOffsetMs) - - if (audioPlayer && songUrl) { - await audioPlayer.play?.() - } - - if (videoElRef.current && videoUri) { - videoElRef.current.muted = true - videoElRef.current.play().catch(() => {}) - } + await playSynchronized() } catch {} } @@ -131,7 +141,7 @@ const RecordedPlayback = ({ route }) => { playbackEndedRef.current = false void stopPlayback() } - }, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoUri]) + }, [alignPlaybackToTimeline, initialSyncOffsetMs, playSynchronized, stopPlayback]) useEffect(() => { const id = setInterval(() => { @@ -156,7 +166,7 @@ const RecordedPlayback = ({ route }) => { const currentVideoMs = Number(videoElRef.current.currentTime || 0) * 1000 const driftMs = Math.abs(currentVideoMs - expectedVideoMs) - if (driftMs > 350) { + if (driftMs > 120) { videoElRef.current.currentTime = Math.max(0, expectedVideoMs / 1000) } } @@ -194,19 +204,9 @@ const RecordedPlayback = ({ route }) => { const resumeAfterSeek = useCallback(async () => { try { - if (audioPlayer?.resume) { - await audioPlayer.resume?.() - } else if (audioPlayer) { - await audioPlayer.play?.() - } + await playSynchronized() } catch {} - try { - if (videoElRef.current && videoUri) { - videoElRef.current.muted = true - videoElRef.current.play().catch(() => {}) - } - } catch {} - }, [audioPlayer, videoUri]) + }, [playSynchronized]) const handleSyncOffsetChange = useCallback( (nextOffsetMs) => { @@ -238,7 +238,12 @@ const RecordedPlayback = ({ route }) => { void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs) }, - [alignPlaybackToTimeline, audioPlayer?.playing, getCurrentRawAudioDurationMs, getCurrentRawAudioPositionMs] + [ + alignPlaybackToTimeline, + audioPlayer?.playing, + getCurrentRawAudioDurationMs, + getCurrentRawAudioPositionMs, + ] ) useEffect(() => { @@ -278,20 +283,9 @@ const RecordedPlayback = ({ route }) => { playbackEndedRef.current = false - if (songUrl && audioPlayer) { - if (audioPlayer?.resume) { - await audioPlayer.resume?.() - } else { - await audioPlayer.play?.() - } - } - - if (videoElRef.current && videoUri) { - videoElRef.current.muted = true - videoElRef.current.play().catch(() => {}) - } + await playSynchronized() } catch {} - }, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, stopPlayback, videoUri]) + }, [alignPlaybackToTimeline, audioPlayer, playSynchronized, progressInfo, stopPlayback]) return ( { Synchro audio / vidéo - + handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))} disabled={!songUrl || currentSyncOffsetMs <= -2000} @@ -454,8 +455,8 @@ const RecordedPlayback = ({ route }) => { {currentSyncOffsetMs === 0 ? 'ms · synchronisé' : currentSyncOffsetMs > 0 - ? 'ms · son en avance' - : 'ms · son en retard'} + ? 'ms · son en avance' + : 'ms · son en retard'} @@ -489,8 +490,8 @@ const RecordedPlayback = ({ route }) => { !songUrl || currentSyncOffsetMs === 0 ? 'rgba(255,255,255,0.06)' : pressed - ? 'rgba(255,255,255,0.18)' - : 'rgba(255,255,255,0.1)', + ? 'rgba(255,255,255,0.18)' + : 'rgba(255,255,255,0.1)', opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1, cursor: !songUrl || currentSyncOffsetMs === 0 ? 'not-allowed' : 'pointer', })} diff --git a/src/screens/Playbacks/components/PlaybackItem.web.js b/src/screens/Playbacks/components/PlaybackItem.web.js index 76f3d7e..15b3769 100644 --- a/src/screens/Playbacks/components/PlaybackItem.web.js +++ b/src/screens/Playbacks/components/PlaybackItem.web.js @@ -19,7 +19,6 @@ import { Feather } from '@expo/vector-icons' // Debug logging toggle for web playback const DEBUG_PLAYBACK_WEB = true -const PLAYBACK_LYRICS_LEAD_S = 0.18 // NOTE: Web-only implementation that uses a native