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
+1 -5
View File
@@ -128,7 +128,6 @@ const alphaWhite = (alpha) => `rgba(255,255,255,${alpha})`
export default function KaraokeLyrics({ export default function KaraokeLyrics({
alignedWords = [], alignedWords = [],
currentTimeS = 0, currentTimeS = 0,
timeOffsetS = 0,
showContext = true, showContext = true,
removeTags = true, removeTags = true,
mode = 'focus', mode = 'focus',
@@ -148,10 +147,7 @@ export default function KaraokeLyrics({
) )
const activeLines = mode === 'teleprompter' ? teleprompterData : lines const activeLines = mode === 'teleprompter' ? teleprompterData : lines
const effectiveTimeS = useMemo( const effectiveTimeS = Math.max(0, Number(currentTimeS || 0))
() => Math.max(0, Number(currentTimeS || 0) + Number(timeOffsetS || 0)),
[currentTimeS, timeOffsetS]
)
const currentLineIdx = useMemo(() => { const currentLineIdx = useMemo(() => {
return findFocusedLineIndex(activeLines, effectiveTimeS) return findFocusedLineIndex(activeLines, effectiveTimeS)
+16 -7
View File
@@ -69,13 +69,14 @@ const useSharedAudioPlayer = (source, options = null) => {
durationMs: 0, durationMs: 0,
}) })
useEffect(() => { // Keep the imperative player getters on the exact snapshot that triggered
stateRef.current = { // this render. Consumers can therefore use the shared player as their only
isPlaying, // playback clock without maintaining a second timer.
positionMs, stateRef.current = {
durationMs, isPlaying,
} positionMs,
}, [isPlaying, positionMs, durationMs]) durationMs,
}
const controlsRef = useRef({ const controlsRef = useRef({
play, play,
@@ -135,6 +136,14 @@ const useSharedAudioPlayer = (source, options = null) => {
enumerable: true, enumerable: true,
get: () => stateRef.current.positionMs / 1000, get: () => stateRef.current.positionMs / 1000,
}, },
positionMs: {
enumerable: true,
get: () => stateRef.current.positionMs,
},
durationMs: {
enumerable: true,
get: () => stateRef.current.durationMs,
},
}) })
player.load = async ({ startPositionMs = 0 } = {}) => { player.load = async ({ startPositionMs = 0 } = {}) => {
+40 -303
View File
@@ -25,13 +25,12 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
const COUNTDOWN_SECONDS = 10 const COUNTDOWN_SECONDS = 10
const CAMERA_STARTUP_DELAY_MS = 250 const CAMERA_STARTUP_DELAY_MS = 250
const KARAOKE_LEAD_S = 0.68
const LOG_PREFIX = '[RecordPlayback]' const LOG_PREFIX = '[RecordPlayback]'
const KEEP_AWAKE_TAG = 'record-playback' const KEEP_AWAKE_TAG = 'record-playback'
const log = const log =
typeof __DEV__ === 'undefined' || __DEV__ typeof __DEV__ === 'undefined' || __DEV__
? (...args) => console.log(LOG_PREFIX, ...args) ? (...args) => console.log(LOG_PREFIX, ...args)
: () => { } : () => {}
const CAMERA_FACING_OPTIONS = [ const CAMERA_FACING_OPTIONS = [
{ label: 'Avant', value: 'front' }, { label: 'Avant', value: 'front' },
@@ -53,7 +52,6 @@ const RecordPlayback = ({ route }) => {
const countdownTimerRef = useRef(null) const countdownTimerRef = useRef(null)
const listenTimerRef = useRef(null) const listenTimerRef = useRef(null)
const checkSongEndRef = useRef(null) const checkSongEndRef = useRef(null)
const isPausedRef = useRef(false)
const stopRequestedRef = useRef(false) const stopRequestedRef = useRef(false)
const restartRequestedRef = useRef(false) const restartRequestedRef = useRef(false)
const recordingStartTimeRef = useRef(0) // Track recording start time for duration check const recordingStartTimeRef = useRef(0) // Track recording start time for duration check
@@ -63,12 +61,8 @@ const RecordPlayback = ({ route }) => {
const exitRequestedRef = useRef(false) const exitRequestedRef = useRef(false)
const startedRef = useRef(false) // empêche les doubles démarrages const startedRef = useRef(false) // empêche les doubles démarrages
const countdownActiveRef = useRef(false) // évite le déclenchement avant 1er tick 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 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 originalLoopingValueRef = useRef({ hasValue: false, value: false })
const latestLoopingValueRef = useRef(isLooping ?? false) const latestLoopingValueRef = useRef(isLooping ?? false)
@@ -80,9 +74,7 @@ const RecordPlayback = ({ route }) => {
const [isPreparing, setIsPreparing] = useState(false) const [isPreparing, setIsPreparing] = useState(false)
const [countdown, setCountdown] = useState(0) const [countdown, setCountdown] = useState(0)
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [showProgress, setShowProgress] = useState(false) const [showProgress, setShowProgress] = useState(false)
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 })
const [cameraFacing, setCameraFacing] = useState('front') const [cameraFacing, setCameraFacing] = useState('front')
// Musique // Musique
@@ -103,10 +95,6 @@ const RecordPlayback = ({ route }) => {
useEffect(() => { useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false latestLoopingValueRef.current = isLooping ?? false
}, [isLooping]) }, [isLooping])
useEffect(() => {
isPausedRef.current = isPaused
}, [isPaused])
useEffect(() => { useEffect(() => {
const shouldKeepAwake = isPreparing || isRecording const shouldKeepAwake = isPreparing || isRecording
const updateKeepAwake = async () => { const updateKeepAwake = async () => {
@@ -116,13 +104,13 @@ const RecordPlayback = ({ route }) => {
} else { } else {
await deactivateKeepAwake(KEEP_AWAKE_TAG) await deactivateKeepAwake(KEEP_AWAKE_TAG)
} }
} catch (_) { } } catch (_) {}
} }
void updateKeepAwake() void updateKeepAwake()
return () => { return () => {
try { try {
void deactivateKeepAwake(KEEP_AWAKE_TAG) void deactivateKeepAwake(KEEP_AWAKE_TAG)
} catch (_) { } } catch (_) {}
} }
}, [isPreparing, isRecording]) }, [isPreparing, isRecording])
@@ -150,156 +138,23 @@ const RecordPlayback = ({ route }) => {
})) }))
}, [project?.musicTimestamps, musicIndex]) }, [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(() => { useEffect(() => {
listenedMsRef.current = 0 listenedMsRef.current = 0
incrementDoneRef.current = false incrementDoneRef.current = false
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
playbackStartedRef.current = false playbackStartedRef.current = false
playbackStartedAtRef.current = 0 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 }) log('Song URL changed, reset counters', { songUrl })
}, [songUrl]) }, [songUrl])
// Poll player -> progress ring // The audio player is the single clock for the progress ring and lyrics.
useEffect(() => { const positionMs = player?.positionMs || 0
if (!player) return const durationMs = player?.durationMs || 0
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])
const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0) const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0)
// Permissions au mount + cleanup // Permissions au mount + cleanup
useEffect(() => { useEffect(() => {
; (async () => { ;(async () => {
try { try {
if (!cameraPermission?.granted) { if (!cameraPermission?.granted) {
log('Requesting camera permission on mount') log('Requesting camera permission on mount')
@@ -307,7 +162,7 @@ const RecordPlayback = ({ route }) => {
} else { } else {
log('Camera permission already granted on mount') log('Camera permission already granted on mount')
} }
} catch (_) { } } catch (_) {}
})() })()
return () => { return () => {
try { try {
@@ -318,7 +173,7 @@ const RecordPlayback = ({ route }) => {
listenTimerRef.current = null listenTimerRef.current = null
checkSongEndRef.current = null checkSongEndRef.current = null
log('Cleanup on unmount, cleared timers') log('Cleanup on unmount, cleared timers')
} catch (_) { } } catch (_) {}
} }
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
@@ -355,30 +210,23 @@ const RecordPlayback = ({ route }) => {
incrementDoneRef.current = false incrementDoneRef.current = false
playbackStartedRef.current = false playbackStartedRef.current = false
playbackStartedAtRef.current = 0 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') log('Session reset')
setIsPreparing(false) setIsPreparing(false)
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
setShowProgress(false) setShowProgress(false)
setCountdown(0) setCountdown(0)
try { try {
await cameraRef.current?.resumePreview?.() await cameraRef.current?.resumePreview?.()
} catch (_) { } } catch (_) {}
if (player) { if (player) {
try { try {
if (player.playing) await player.pause?.() if (player.playing) await player.pause?.()
await player.seekTo?.(0) await player.seekTo?.(0)
} catch (_) { } } catch (_) {}
} }
} catch (_) { } } catch (_) {}
}, },
[player] [player]
) )
@@ -456,8 +304,6 @@ const RecordPlayback = ({ route }) => {
preserveSongEndWatcher, preserveSongEndWatcher,
preserveStopRequest, preserveStopRequest,
}) })
setIsPaused(false)
isPausedRef.current = false
if (!preserveRestartFlag) { if (!preserveRestartFlag) {
restartRequestedRef.current = false restartRequestedRef.current = false
manualRestartInFlightRef.current = false manualRestartInFlightRef.current = false
@@ -504,23 +350,16 @@ const RecordPlayback = ({ route }) => {
try { try {
stopRequestedRef.current = false stopRequestedRef.current = false
stopRequestedAtRef.current = 0 stopRequestedAtRef.current = 0
recordingStartTimeRef.current = Date.now() // Start timer
listenedMsRef.current = 0 listenedMsRef.current = 0
incrementDoneRef.current = false incrementDoneRef.current = false
setIsRecording(true) setIsRecording(true)
setShowProgress(true) setShowProgress(true)
setIsPaused(false)
isPausedRef.current = false
try { try {
await cameraRef.current?.resumePreview?.() await cameraRef.current?.resumePreview?.()
} catch (_) { } } catch (_) {}
playbackStartedRef.current = false playbackStartedRef.current = false
playbackProbeRef.current = { lastPos: 0, lastAt: 0 } playbackStartedAtRef.current = 0
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
log('startRecordingWithMusic', { log('startRecordingWithMusic', {
hasPlayer: !!player, hasPlayer: !!player,
songUrl, songUrl,
@@ -586,6 +425,7 @@ const RecordPlayback = ({ route }) => {
throw new Error("L'enregistrement vidéo n'est pas supporté sur cet appareil.") throw new Error("L'enregistrement vidéo n'est pas supporté sur cet appareil.")
})() })()
recordingStartTimeRef.current = Date.now()
activeRecordingPromiseRef.current = recordPromise activeRecordingPromiseRef.current = recordPromise
if (player && songUrl) { 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) // Give the native recorder a short warm-up, then measure the real gap
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS)) // between the recording request and the audio start for the final trim.
await new Promise((resolve) => setTimeout(resolve, CAMERA_STARTUP_DELAY_MS))
try { try {
playbackStartRequestAtRef.current = Date.now()
await player.play?.() await player.play?.()
playbackStartedAtRef.current = Date.now()
playbackStartedRef.current = true
log('player.play invoked') log('player.play invoked')
} catch (error) { } catch (error) {
log('player.play failed', { log('player.play failed', {
@@ -625,10 +467,10 @@ const RecordPlayback = ({ route }) => {
try { try {
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true }) await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
log('Views incremented') log('Views incremented')
} catch (_) { } } catch (_) {}
} }
} }
} catch (_) { } } catch (_) {}
}, 500) }, 500)
} }
@@ -638,7 +480,6 @@ const RecordPlayback = ({ route }) => {
checkSongEndRef.current = setInterval(() => { checkSongEndRef.current = setInterval(() => {
try { try {
if (!player) return if (!player) return
if (isPausedRef.current) return
if (stopRequestedRef.current) { if (stopRequestedRef.current) {
const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0) const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0)
if (sinceLastRequest >= 1200) { if (sinceLastRequest >= 1200) {
@@ -646,7 +487,7 @@ const RecordPlayback = ({ route }) => {
try { try {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
log('stopRecording retried while awaiting stop') log('stopRecording retried while awaiting stop')
} catch (_) { } } catch (_) {}
} }
return return
} }
@@ -671,9 +512,9 @@ const RecordPlayback = ({ route }) => {
try { try {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
log('stopRecording triggered') log('stopRecording triggered')
} catch (_) { } } catch (_) {}
} }
} catch (_) { } } catch (_) {}
}, 500) }, 500)
} }
@@ -691,7 +532,7 @@ const RecordPlayback = ({ route }) => {
} }
try { try {
if (player?.playing) await player.pause?.() if (player?.playing) await player.pause?.()
} catch (_) { } } catch (_) {}
if (listenTimerRef.current) { if (listenTimerRef.current) {
clearInterval(listenTimerRef.current) clearInterval(listenTimerRef.current)
listenTimerRef.current = null listenTimerRef.current = null
@@ -699,12 +540,9 @@ const RecordPlayback = ({ route }) => {
} }
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
setShowProgress(false) setShowProgress(false)
log('Recording flow completed', { hasVideo: !!video?.uri }) log('Recording flow completed', { hasVideo: !!video?.uri })
playbackStartedRef.current = false playbackStartedRef.current = false
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
const exitRequested = exitRequestedRef.current const exitRequested = exitRequestedRef.current
const shouldRestart = restartRequestedRef.current const shouldRestart = restartRequestedRef.current
// Duration check logic // Duration check logic
@@ -724,7 +562,7 @@ const RecordPlayback = ({ route }) => {
await discardRecordingFile(video?.uri, 'too_short') await discardRecordingFile(video?.uri, 'too_short')
try { try {
goBack() goBack()
} catch (_) { } } catch (_) {}
return return
} }
} }
@@ -782,8 +620,6 @@ const RecordPlayback = ({ route }) => {
stopRequestedAtRef.current = 0 stopRequestedAtRef.current = 0
setIsRecording(false) setIsRecording(false)
setIsPreparing(false) setIsPreparing(false)
setIsPaused(false)
isPausedRef.current = false
setShowProgress(false) setShowProgress(false)
if (listenTimerRef.current) { if (listenTimerRef.current) {
clearInterval(listenTimerRef.current) clearInterval(listenTimerRef.current)
@@ -796,7 +632,6 @@ const RecordPlayback = ({ route }) => {
log('checkSongEndRef cleared (error path)') log('checkSongEndRef cleared (error path)')
} }
playbackStartedRef.current = false playbackStartedRef.current = false
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
const exitRequested = exitRequestedRef.current const exitRequested = exitRequestedRef.current
const shouldRestart = restartRequestedRef.current const shouldRestart = restartRequestedRef.current
if (exitRequested) { if (exitRequested) {
@@ -832,7 +667,7 @@ const RecordPlayback = ({ route }) => {
onPress: () => { onPress: () => {
try { try {
goBack() goBack()
} catch (_) { } } catch (_) {}
}, },
}, },
], ],
@@ -859,21 +694,21 @@ const RecordPlayback = ({ route }) => {
if (player?.playing) { if (player?.playing) {
const maybePromise = player.pause?.() const maybePromise = player.pause?.()
if (maybePromise && typeof maybePromise.catch === 'function') { if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => { }) maybePromise.catch(() => {})
} }
} }
} catch (_) { } } catch (_) {}
try { try {
if (isRecording) { if (isRecording) {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
} }
} catch (_) { } } catch (_) {}
} else { } else {
exitRequestedRef.current = false exitRequestedRef.current = false
} }
try { try {
goBack() goBack()
} catch (_) { } } catch (_) {}
return true return true
}, [goBack, isPreparing, isRecording, player]) }, [goBack, isPreparing, isRecording, player])
@@ -886,33 +721,6 @@ const RecordPlayback = ({ route }) => {
}, [handleBackPress]) }, [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 () => { const handleRestartRecording = async () => {
try { try {
const hasRecordingPending = !!activeRecordingPromiseRef.current const hasRecordingPending = !!activeRecordingPromiseRef.current
@@ -921,11 +729,9 @@ const RecordPlayback = ({ route }) => {
isRecording, isRecording,
hasRecordingPending, hasRecordingPending,
}) })
setIsPaused(false)
isPausedRef.current = false
try { try {
await cameraRef.current?.resumePreview?.() await cameraRef.current?.resumePreview?.()
} catch (_) { } } catch (_) {}
if (isPreparing) { if (isPreparing) {
if (hasRecordingPending) { if (hasRecordingPending) {
await startCountdownThenRecord({ await startCountdownThenRecord({
@@ -952,16 +758,16 @@ const RecordPlayback = ({ route }) => {
stopRequestedAtRef.current = Date.now() stopRequestedAtRef.current = Date.now()
try { try {
if (player?.playing) await player.pause?.() if (player?.playing) await player.pause?.()
} catch (_) { } } catch (_) {}
try { try {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
} catch (_) { } } catch (_) {}
await startCountdownThenRecord({ await startCountdownThenRecord({
preserveRestartFlag: true, preserveRestartFlag: true,
preserveSongEndWatcher: true, preserveSongEndWatcher: true,
preserveStopRequest: true, preserveStopRequest: true,
}) })
} catch (_) { } } catch (_) {}
} }
const permissionsGranted = !!cameraPermission?.granted const permissionsGranted = !!cameraPermission?.granted
@@ -1033,7 +839,7 @@ const RecordPlayback = ({ route }) => {
onPress={async () => { onPress={async () => {
try { try {
if (!cameraPermission?.granted) await requestCameraPermission() if (!cameraPermission?.granted) await requestCameraPermission()
} catch (_) { } } catch (_) {}
}} }}
/> />
</View> </View>
@@ -1074,9 +880,7 @@ const RecordPlayback = ({ route }) => {
<ProgressRing <ProgressRing
size={120} size={120}
strokeWidth={8} strokeWidth={8}
progress={ progress={durationMs ? Math.min(1, positionMs / durationMs) : 0}
progressInfo.dur ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) : 0
}
/> />
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
@@ -1099,34 +903,6 @@ const RecordPlayback = ({ route }) => {
<RestartSpinnerIcon /> <RestartSpinnerIcon />
</TouchableOpacity> </TouchableOpacity>
</View> </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> </View>
)} )}
</View> </View>
@@ -1146,8 +922,7 @@ const RecordPlayback = ({ route }) => {
{(isRecording || showProgress) && alignedWords?.length > 0 ? ( {(isRecording || showProgress) && alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics
alignedWords={alignedWords} alignedWords={alignedWords}
currentTimeS={(progressInfo?.pos || 0) / 1000} currentTimeS={positionMs / 1000}
timeOffsetS={KARAOKE_LEAD_S}
mode="teleprompter" mode="teleprompter"
teleprompterLines={6} teleprompterLines={6}
teleprompterAnchorLine={1} teleprompterAnchorLine={1}
@@ -1202,44 +977,6 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
export default RecordPlayback 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 (
<View style={{ gap: 4 }}>
{/* {prev ? (
<Text style={{ color: "#FFFFFF99", fontSize: 14, textAlign: "center", fontFamily: FONT_FAMILY.InterMedium }}>
{prev}
</Text>
) : null} */}
<Text
style={{
color: Palette.white,
fontSize: 18,
textAlign: 'center',
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
{curr}
</Text>
{next ? (
<Text
style={{
color: '#FFFFFF99',
fontSize: 14,
textAlign: 'center',
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{next}
</Text>
) : null}
</View>
)
}
const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => { const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => {
return ( return (
<View <View
+64 -212
View File
@@ -29,14 +29,6 @@ const WEB_PREVIEW_WIDTH = 360
const MEDIA_BOOTSTRAP_DELAY_MS = 250 const MEDIA_BOOTSTRAP_DELAY_MS = 250
const MEDIA_RETRY_DELAY_MS = 700 const MEDIA_RETRY_DELAY_MS = 700
const MEDIA_MAX_RETRIES = 2 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 = () => { const pickRecorderMimeType = () => {
if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined
@@ -51,7 +43,7 @@ const pickRecorderMimeType = () => {
for (const mimeType of candidates) { for (const mimeType of candidates) {
try { try {
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType if (MediaRecorder.isTypeSupported(mimeType)) return mimeType
} catch { } } catch {}
} }
return undefined return undefined
@@ -74,6 +66,8 @@ const RecordPlayback = ({ route }) => {
coverUrl: project?.coverUrl || null, coverUrl: project?.coverUrl || null,
metadata: { projectId: project?.id, screen: 'RecordPlayback' }, metadata: { projectId: project?.id, screen: 'RecordPlayback' },
}) })
const positionMs = player?.positionMs || 0
const durationMs = player?.durationMs || 0
// MediaStream / Recorder (web only) // MediaStream / Recorder (web only)
const previewVideoRef = useRef(null) const previewVideoRef = useRef(null)
@@ -96,31 +90,19 @@ const RecordPlayback = ({ route }) => {
const [countdown, setCountdown] = useState(0) const [countdown, setCountdown] = useState(0)
const [isPreparing, setIsPreparing] = useState(false) const [isPreparing, setIsPreparing] = useState(false)
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0)
// timers / refs // timers / refs
const isPausedRef = useRef(false)
const progressTimerRef = useRef(null)
const listenTimerRef = useRef(null) const listenTimerRef = useRef(null)
const countdownTimerRef = useRef(null) const countdownTimerRef = useRef(null)
const perfStartRef = useRef(null)
const pausedAtRef = useRef(null)
const pausedMsRef = useRef(0)
const listenedMsRef = useRef(0) const listenedMsRef = useRef(0)
const viewsIncrementedRef = useRef(false) const viewsIncrementedRef = useRef(false)
const correctedInitialJumpRef = useRef(false)
const playbackStartedAtRef = useRef(null) const playbackStartedAtRef = useRef(null)
const recordingStartAtRef = useRef(null) const recordingStartAtRef = useRef(null)
const stopInFlightRef = useRef(false)
useEffect(() => { useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false latestLoopingValueRef.current = isLooping ?? false
}, [isLooping]) }, [isLooping])
useEffect(() => {
isPausedRef.current = isPaused
}, [isPaused])
// Lyrics // Lyrics
const alignedWords = useMemo(() => { const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[songIndex] const ts = project?.musicTimestamps?.[songIndex]
@@ -134,10 +116,9 @@ const RecordPlayback = ({ route }) => {
}, [project?.musicTimestamps, songIndex]) }, [project?.musicTimestamps, songIndex])
const clearAllTimers = () => { const clearAllTimers = () => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current)
if (listenTimerRef.current) clearInterval(listenTimerRef.current) if (listenTimerRef.current) clearInterval(listenTimerRef.current)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current) if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
progressTimerRef.current = listenTimerRef.current = countdownTimerRef.current = null listenTimerRef.current = countdownTimerRef.current = null
} }
const clearMediaRetry = useCallback(() => { const clearMediaRetry = useCallback(() => {
@@ -150,19 +131,12 @@ const RecordPlayback = ({ route }) => {
const resetUI = () => { const resetUI = () => {
setIsPreparing(false) setIsPreparing(false)
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
pausedAtRef.current = null
pausedMsRef.current = 0
setCountdown(0) setCountdown(0)
setPos(0)
setDur(0)
listenedMsRef.current = 0 listenedMsRef.current = 0
viewsIncrementedRef.current = false viewsIncrementedRef.current = false
perfStartRef.current = null
correctedInitialJumpRef.current = false
playbackStartedAtRef.current = null playbackStartedAtRef.current = null
recordingStartAtRef.current = null recordingStartAtRef.current = null
stopInFlightRef.current = false
} }
const releaseRecordingUrl = useCallback(() => { const releaseRecordingUrl = useCallback(() => {
@@ -191,14 +165,14 @@ const RecordPlayback = ({ route }) => {
if (video.srcObject !== stream) { if (video.srcObject !== stream) {
video.srcObject = stream video.srcObject = stream
} }
} catch (e) { } } catch (e) {}
try { try {
const playPromise = video.play?.() const playPromise = video.play?.()
if (playPromise && typeof playPromise.catch === 'function') { if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => { }) playPromise.catch(() => {})
} }
} catch (e) { } } catch (e) {}
return true return true
}, []) }, [])
@@ -271,16 +245,16 @@ const RecordPlayback = ({ route }) => {
[clearMediaRetry, ensureMediaStream] [clearMediaRetry, ensureMediaStream]
) )
const startRecorder = useCallback(() => { const startRecorder = useCallback(async () => {
if (!mediaStreamRef.current) { if (!mediaStreamRef.current) {
return false return null
} }
try { try {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop() mediaRecorderRef.current.stop()
} }
} catch (e) { } } catch (e) {}
recordedChunksRef.current = [] recordedChunksRef.current = []
releaseRecordingUrl() releaseRecordingUrl()
@@ -294,7 +268,7 @@ const RecordPlayback = ({ route }) => {
: new MediaRecorder(mediaStreamRef.current) : new MediaRecorder(mediaStreamRef.current)
} catch (e) { } catch (e) {
setMediaError(e instanceof Error ? e : new Error(String(e || ''))) setMediaError(e instanceof Error ? e : new Error(String(e || '')))
return false return null
} }
mediaRecorderRef.current = recorder 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) => { recorder.onerror = (event) => {
const err = event?.error || event const err = event?.error || event
setMediaError(err instanceof Error ? err : new Error(String(err || ''))) setMediaError(err instanceof Error ? err : new Error(String(err || '')))
resolveRecorderStart?.(null)
resolveRecorderStart = null
} }
recorder.onstop = () => { recorder.onstop = () => {
@@ -340,8 +326,10 @@ const RecordPlayback = ({ route }) => {
try { try {
recorder.start(1000) recorder.start(1000)
return true return await recorderStartedPromise
} catch (e) { } catch (e) {
resolveRecorderStart?.(null)
resolveRecorderStart = null
if (stopRecordingResolveRef.current) { if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(null) stopRecordingResolveRef.current(null)
stopRecordingResolveRef.current = null stopRecordingResolveRef.current = null
@@ -349,7 +337,7 @@ const RecordPlayback = ({ route }) => {
stopRecordingPromiseRef.current = null stopRecordingPromiseRef.current = null
mediaRecorderRef.current = null mediaRecorderRef.current = null
setMediaError(e instanceof Error ? e : new Error(String(e || ''))) setMediaError(e instanceof Error ? e : new Error(String(e || '')))
return false return null
} }
}, [releaseRecordingUrl, setMediaError]) }, [releaseRecordingUrl, setMediaError])
@@ -366,7 +354,7 @@ const RecordPlayback = ({ route }) => {
} }
recorder.stop() recorder.stop()
} }
} catch (e) { } } catch (e) {}
if (!waitPromise) { if (!waitPromise) {
return recordedUrlRef.current || null return recordedUrlRef.current || null
@@ -390,10 +378,10 @@ const RecordPlayback = ({ route }) => {
try { try {
await player?.pause?.() await player?.pause?.()
await player?.seekTo?.(0) await player?.seekTo?.(0)
} catch (e) { } } catch (e) {}
try { try {
await stopRecorderAndGetUrl() await stopRecorderAndGetUrl()
} catch (e) { } } catch (e) {}
if (!preserveRecordingForNextScreenRef.current) { if (!preserveRecordingForNextScreenRef.current) {
releaseRecordingUrl() releaseRecordingUrl()
} }
@@ -433,12 +421,12 @@ const RecordPlayback = ({ route }) => {
) )
useEffect(() => { useEffect(() => {
; (async () => { ;(async () => {
try { try {
if (!cameraPermission?.granted) { if (!cameraPermission?.granted) {
await requestCameraPermission() await requestCameraPermission()
} }
} catch (e) { } } catch (e) {}
})() })()
return () => { return () => {
clearAllTimers() clearAllTimers()
@@ -481,7 +469,7 @@ const RecordPlayback = ({ route }) => {
} else { } else {
try { try {
video.srcObject = null video.srcObject = null
} catch { } } catch {}
} }
}, [attachPreview, mediaReady, mediaError]) }, [attachPreview, mediaReady, mediaError])
@@ -513,7 +501,7 @@ const RecordPlayback = ({ route }) => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop() mediaRecorderRef.current.stop()
} }
} catch { } } catch {}
stopMediaStream() stopMediaStream()
clearMediaRetry() clearMediaRetry()
if (!preserveRecordingForNextScreenRef.current) { if (!preserveRecordingForNextScreenRef.current) {
@@ -523,11 +511,13 @@ const RecordPlayback = ({ route }) => {
}, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream]) }, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream])
const stopAndNavigate = useCallback(async () => { const stopAndNavigate = useCallback(async () => {
if (stopInFlightRef.current) return
stopInFlightRef.current = true
preserveRecordingForNextScreenRef.current = true preserveRecordingForNextScreenRef.current = true
clearAllTimers() clearAllTimers()
try { try {
await player?.pause?.() await player?.pause?.()
} catch (e) { } } catch (e) {}
let videoUrl = null let videoUrl = null
try { try {
@@ -535,13 +525,9 @@ const RecordPlayback = ({ route }) => {
if (!videoUrl) { if (!videoUrl) {
videoUrl = recordedUrlRef.current || null videoUrl = recordedUrlRef.current || null
} }
} catch (e) { } } catch (e) {}
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
pausedAtRef.current = null
pausedMsRef.current = 0
const rawOffsetMs = const rawOffsetMs =
playbackStartedAtRef.current != null && recordingStartAtRef.current != null playbackStartedAtRef.current != null && recordingStartAtRef.current != null
@@ -556,68 +542,10 @@ const RecordPlayback = ({ route }) => {
}) })
}, [player, project, stopRecorderAndGetUrl]) }, [player, project, stopRecorderAndGetUrl])
const startProgressLoop = useCallback(() => { useEffect(() => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current) if (!isRecording || !durationMs || positionMs < durationMs - 300) return
void stopAndNavigate()
progressTimerRef.current = setInterval(async () => { }, [durationMs, isRecording, positionMs, stopAndNavigate])
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])
const startListenLoop = useCallback(() => { const startListenLoop = useCallback(() => {
if (listenTimerRef.current) clearInterval(listenTimerRef.current) if (listenTimerRef.current) clearInterval(listenTimerRef.current)
@@ -630,39 +558,31 @@ const RecordPlayback = ({ route }) => {
if (project?.id) { if (project?.id) {
try { try {
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true }) await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
} catch (e) { } } catch (e) {}
} }
} }
} }
} catch (e) { } } catch (e) {}
}, 500) }, 500)
}, [player, project?.id]) }, [player, project?.id])
const startPlayback = useCallback(async () => { const startPlayback = useCallback(async () => {
try { try {
setIsPaused(false) await player?.pause?.()
isPausedRef.current = false await player?.seekTo?.(0)
pausedAtRef.current = null const recordingStartedAt = await startRecorder()
pausedMsRef.current = 0 if (recordingStartedAt == null) {
await player?.pause?.() // s'assure qu'on repart propre throw new Error("Impossible de démarrer l'enregistrement vidéo")
await player?.seekTo?.(0) // tente un seek d'amorçage
recordingStartAtRef.current = performance.now()
const recorderStarted = startRecorder()
if (!recorderStarted) {
} }
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS)) recordingStartAtRef.current = recordingStartedAt
await player?.play?.() // attend la promesse → l'élément est prêt await player?.play?.()
perfStartRef.current = perfStartRef.current ?? performance.now() playbackStartedAtRef.current = performance.now()
correctedInitialJumpRef.current = false // autorise la correction 1-shot
setIsRecording(true) setIsRecording(true)
startProgressLoop()
startListenLoop() startListenLoop()
} catch (e) { } catch (e) {
setIsRecording(false) setIsRecording(false)
setIsPreparing(false) setIsPreparing(false)
setIsPaused(false) stopInFlightRef.current = false
isPausedRef.current = false
pausedAtRef.current = null
const msg = String(e?.message || e || '') const msg = String(e?.message || e || '')
if (/not supported on the simulator/i.test(msg)) { if (/not supported on the simulator/i.test(msg)) {
alert( alert(
@@ -674,7 +594,7 @@ const RecordPlayback = ({ route }) => {
onPress: () => { onPress: () => {
try { try {
goBack() goBack()
} catch { } } catch {}
}, },
}, },
], ],
@@ -682,7 +602,7 @@ const RecordPlayback = ({ route }) => {
) )
} }
} }
}, [player, startRecorder, startProgressLoop, startListenLoop]) }, [player, startRecorder, startListenLoop])
const permissionsGranted = !!cameraPermission?.granted const permissionsGranted = !!cameraPermission?.granted
const canRecord = permissionsGranted && mediaReady && !mediaError const canRecord = permissionsGranted && mediaReady && !mediaError
@@ -690,8 +610,6 @@ const RecordPlayback = ({ route }) => {
const startCountdownThenRecord = useCallback(async () => { const startCountdownThenRecord = useCallback(async () => {
if (!songUrl || !canRecord) return if (!songUrl || !canRecord) return
await resetSession() await resetSession()
setIsPaused(false)
isPausedRef.current = false
setIsPreparing(true) setIsPreparing(true)
setCountdown(COUNTDOWN_SECONDS) setCountdown(COUNTDOWN_SECONDS)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current) if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
@@ -710,50 +628,13 @@ const RecordPlayback = ({ route }) => {
}, 1000) }, 1000)
}, [songUrl, canRecord, resetSession, startPlayback]) }, [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 () => { const handleRestartRecording = useCallback(async () => {
try { try {
await startCountdownThenRecord() await startCountdownThenRecord()
} catch (e) { } } catch (e) {}
}, [startCountdownThenRecord]) }, [startCountdownThenRecord])
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0 const progressRatio = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0
return ( return (
<Page <Page
@@ -761,7 +642,7 @@ const RecordPlayback = ({ route }) => {
// containerStyle={{ backgroundColor: Palette.gray }} // containerStyle={{ backgroundColor: Palette.gray }}
headerType="NONE" headerType="NONE"
backgroundColor={Palette.grayMid} backgroundColor={Palette.grayMid}
// backgroundImg={background.playbackBG2} // backgroundImg={background.playbackBG2}
> >
<MusicLandHeader progress={9} onPressBack={goBack} /> <MusicLandHeader progress={9} onPressBack={goBack} />
@@ -793,9 +674,9 @@ const RecordPlayback = ({ route }) => {
const video = event?.currentTarget const video = event?.currentTarget
const playPromise = video?.play?.() const playPromise = video?.play?.()
if (playPromise && typeof playPromise.catch === 'function') { if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => { }) playPromise.catch(() => {})
} }
} catch { } } catch {}
}} }}
/> />
@@ -941,34 +822,6 @@ const RecordPlayback = ({ route }) => {
<RestartSpinnerIcon /> <RestartSpinnerIcon />
</TouchableOpacity> </TouchableOpacity>
</View> </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> </View>
)} )}
@@ -988,7 +841,7 @@ const RecordPlayback = ({ route }) => {
onPress={async () => { onPress={async () => {
try { try {
if (!cameraPermission?.granted) await requestCameraPermission() if (!cameraPermission?.granted) await requestCameraPermission()
} catch { } } catch {}
}} }}
/> />
</View> </View>
@@ -1010,8 +863,7 @@ const RecordPlayback = ({ route }) => {
{isRecording && alignedWords.length > 0 ? ( {isRecording && alignedWords.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics
alignedWords={alignedWords} alignedWords={alignedWords}
currentTimeS={pos} currentTimeS={positionMs / 1000}
timeOffsetS={KARAOKE_LEAD_S}
mode="teleprompter" mode="teleprompter"
teleprompterLines={6} teleprompterLines={6}
teleprompterAnchorLine={1} teleprompterAnchorLine={1}
+33 -29
View File
@@ -79,6 +79,20 @@ const RecordedPlayback = ({ route }) => {
[audioPlayer, videoPlayer] [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 () => { const stopPlayback = useCallback(async () => {
try { try {
if (audioPlayer) await audioPlayer.pause?.() if (audioPlayer) await audioPlayer.pause?.()
@@ -104,8 +118,7 @@ const RecordedPlayback = ({ route }) => {
const start = async () => { const start = async () => {
try { try {
await alignPlaybackToTimeline(0, initialSyncOffsetMs) await alignPlaybackToTimeline(0, initialSyncOffsetMs)
if (audioPlayer && songUrl) await audioPlayer.play?.() await playSynchronized()
if (videoPlayer) videoPlayer.play()
} catch (e) {} } catch (e) {}
} }
@@ -115,7 +128,7 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
void stopPlayback() void stopPlayback()
} }
}, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoPlayer]) }, [alignPlaybackToTimeline, initialSyncOffsetMs, playSynchronized, stopPlayback])
useEffect(() => { useEffect(() => {
const id = global.setInterval(() => { const id = global.setInterval(() => {
@@ -139,7 +152,7 @@ const RecordedPlayback = ({ route }) => {
const currentVideoMs = (videoPlayer.currentTime || 0) * 1000 const currentVideoMs = (videoPlayer.currentTime || 0) * 1000
const driftMs = Math.abs(currentVideoMs - expectedVideoMs) const driftMs = Math.abs(currentVideoMs - expectedVideoMs)
if (driftMs > 350) { if (driftMs > 120) {
videoPlayer.currentTime = Math.max(0, expectedVideoMs / 1000) videoPlayer.currentTime = Math.max(0, expectedVideoMs / 1000)
} }
} }
@@ -175,16 +188,9 @@ const RecordedPlayback = ({ route }) => {
const resumeAfterSeek = useCallback(async () => { const resumeAfterSeek = useCallback(async () => {
try { try {
if (audioPlayer?.resume) { await playSynchronized()
await audioPlayer.resume?.()
} else if (audioPlayer) {
await audioPlayer.play?.()
}
} catch (e) {} } catch (e) {}
try { }, [playSynchronized])
if (videoPlayer) videoPlayer.play()
} catch (e) {}
}, [audioPlayer, videoPlayer])
const handleSyncOffsetChange = useCallback( const handleSyncOffsetChange = useCallback(
(nextOffsetMs) => { (nextOffsetMs) => {
@@ -261,23 +267,14 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
if (songUrl && audioPlayer) { await playSynchronized()
if (audioPlayer?.resume) {
await audioPlayer.resume?.()
} else {
await audioPlayer.play?.()
}
}
if (videoPlayer) videoPlayer.play()
} catch (e) {} } catch (e) {}
}, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, videoPlayer]) }, [alignPlaybackToTimeline, audioPlayer, playSynchronized, progressInfo, videoPlayer])
return ( return (
<Page backgroundColor={Palette.grayMid} headerType="NONE"> <Page backgroundColor={Palette.grayMid} headerType="NONE">
<MusicLandHeader progress={19} onPressBack={goBack} /> <MusicLandHeader progress={19} onPressBack={goBack} />
<View style={{ flex: 1, paddingBottom: gutters * 2 }}> <View style={{ flex: 1, paddingBottom: gutters * 2 }}>
{/* Vidéo avec play/pause en overlay */} {/* Vidéo avec play/pause en overlay */}
<Pressable <Pressable
onPress={handleTogglePlayback} onPress={handleTogglePlayback}
@@ -363,7 +360,14 @@ const RecordedPlayback = ({ route }) => {
Synchro audio / vidéo Synchro audio / vidéo
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}> <View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<Pressable <Pressable
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))} onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))}
disabled={!songUrl || currentSyncOffsetMs <= -2000} disabled={!songUrl || currentSyncOffsetMs <= -2000}
@@ -398,8 +402,8 @@ const RecordedPlayback = ({ route }) => {
{currentSyncOffsetMs === 0 {currentSyncOffsetMs === 0
? 'ms · synchronisé' ? 'ms · synchronisé'
: currentSyncOffsetMs > 0 : currentSyncOffsetMs > 0
? 'ms · son en avance' ? 'ms · son en avance'
: 'ms · son en retard'} : 'ms · son en retard'}
</Text> </Text>
</View> </View>
@@ -433,8 +437,8 @@ const RecordedPlayback = ({ route }) => {
!songUrl || currentSyncOffsetMs === 0 !songUrl || currentSyncOffsetMs === 0
? 'rgba(255,255,255,0.06)' ? 'rgba(255,255,255,0.06)'
: pressed : pressed
? 'rgba(255,255,255,0.18)' ? 'rgba(255,255,255,0.18)'
: 'rgba(255,255,255,0.1)', : 'rgba(255,255,255,0.1)',
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1, opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
})} })}
> >
+43 -42
View File
@@ -78,6 +78,24 @@ const RecordedPlayback = ({ route }) => {
[audioPlayer, videoUri] [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 () => { const stopPlayback = useCallback(async () => {
try { try {
if (audioPlayer) await audioPlayer.pause?.() if (audioPlayer) await audioPlayer.pause?.()
@@ -113,15 +131,7 @@ const RecordedPlayback = ({ route }) => {
const start = async () => { const start = async () => {
try { try {
await alignPlaybackToTimeline(0, initialSyncOffsetMs) await alignPlaybackToTimeline(0, initialSyncOffsetMs)
await playSynchronized()
if (audioPlayer && songUrl) {
await audioPlayer.play?.()
}
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
videoElRef.current.play().catch(() => {})
}
} catch {} } catch {}
} }
@@ -131,7 +141,7 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
void stopPlayback() void stopPlayback()
} }
}, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoUri]) }, [alignPlaybackToTimeline, initialSyncOffsetMs, playSynchronized, stopPlayback])
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
@@ -156,7 +166,7 @@ const RecordedPlayback = ({ route }) => {
const currentVideoMs = Number(videoElRef.current.currentTime || 0) * 1000 const currentVideoMs = Number(videoElRef.current.currentTime || 0) * 1000
const driftMs = Math.abs(currentVideoMs - expectedVideoMs) const driftMs = Math.abs(currentVideoMs - expectedVideoMs)
if (driftMs > 350) { if (driftMs > 120) {
videoElRef.current.currentTime = Math.max(0, expectedVideoMs / 1000) videoElRef.current.currentTime = Math.max(0, expectedVideoMs / 1000)
} }
} }
@@ -194,19 +204,9 @@ const RecordedPlayback = ({ route }) => {
const resumeAfterSeek = useCallback(async () => { const resumeAfterSeek = useCallback(async () => {
try { try {
if (audioPlayer?.resume) { await playSynchronized()
await audioPlayer.resume?.()
} else if (audioPlayer) {
await audioPlayer.play?.()
}
} catch {} } catch {}
try { }, [playSynchronized])
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
videoElRef.current.play().catch(() => {})
}
} catch {}
}, [audioPlayer, videoUri])
const handleSyncOffsetChange = useCallback( const handleSyncOffsetChange = useCallback(
(nextOffsetMs) => { (nextOffsetMs) => {
@@ -238,7 +238,12 @@ const RecordedPlayback = ({ route }) => {
void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs) void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs)
}, },
[alignPlaybackToTimeline, audioPlayer?.playing, getCurrentRawAudioDurationMs, getCurrentRawAudioPositionMs] [
alignPlaybackToTimeline,
audioPlayer?.playing,
getCurrentRawAudioDurationMs,
getCurrentRawAudioPositionMs,
]
) )
useEffect(() => { useEffect(() => {
@@ -278,20 +283,9 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
if (songUrl && audioPlayer) { await playSynchronized()
if (audioPlayer?.resume) {
await audioPlayer.resume?.()
} else {
await audioPlayer.play?.()
}
}
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
videoElRef.current.play().catch(() => {})
}
} catch {} } catch {}
}, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, stopPlayback, videoUri]) }, [alignPlaybackToTimeline, audioPlayer, playSynchronized, progressInfo, stopPlayback])
return ( return (
<Page <Page
@@ -419,7 +413,14 @@ const RecordedPlayback = ({ route }) => {
Synchro audio / vidéo Synchro audio / vidéo
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}> <View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<Pressable <Pressable
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))} onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))}
disabled={!songUrl || currentSyncOffsetMs <= -2000} disabled={!songUrl || currentSyncOffsetMs <= -2000}
@@ -454,8 +455,8 @@ const RecordedPlayback = ({ route }) => {
{currentSyncOffsetMs === 0 {currentSyncOffsetMs === 0
? 'ms · synchronisé' ? 'ms · synchronisé'
: currentSyncOffsetMs > 0 : currentSyncOffsetMs > 0
? 'ms · son en avance' ? 'ms · son en avance'
: 'ms · son en retard'} : 'ms · son en retard'}
</Text> </Text>
</View> </View>
@@ -489,8 +490,8 @@ const RecordedPlayback = ({ route }) => {
!songUrl || currentSyncOffsetMs === 0 !songUrl || currentSyncOffsetMs === 0
? 'rgba(255,255,255,0.06)' ? 'rgba(255,255,255,0.06)'
: pressed : pressed
? 'rgba(255,255,255,0.18)' ? 'rgba(255,255,255,0.18)'
: 'rgba(255,255,255,0.1)', : 'rgba(255,255,255,0.1)',
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1, opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
cursor: !songUrl || currentSyncOffsetMs === 0 ? 'not-allowed' : 'pointer', cursor: !songUrl || currentSyncOffsetMs === 0 ? 'not-allowed' : 'pointer',
})} })}
@@ -19,7 +19,6 @@ import { Feather } from '@expo/vector-icons'
// Debug logging toggle for web playback // Debug logging toggle for web playback
const DEBUG_PLAYBACK_WEB = true const DEBUG_PLAYBACK_WEB = true
const PLAYBACK_LYRICS_LEAD_S = 0.18
// NOTE: Web-only implementation that uses a native <video> element instead of expo-video // NOTE: Web-only implementation that uses a native <video> element instead of expo-video
// - No Platform checks // - No Platform checks
@@ -474,11 +473,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid, onBackgroundSyn
<View style={styles.lyricsContainer}> <View style={styles.lyricsContainer}>
<BlurView tint="dark" intensity={20} style={styles.lyricsCard}> <BlurView tint="dark" intensity={20} style={styles.lyricsCard}>
{alignedWords?.length > 0 ? ( {alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
alignedWords={alignedWords}
currentTimeS={currentTimeS}
timeOffsetS={PLAYBACK_LYRICS_LEAD_S}
/>
) : ( ) : (
<Text style={styles.lyricsText}>{descriptionText}</Text> <Text style={styles.lyricsText}>{descriptionText}</Text>
)} )}
@@ -5,18 +5,12 @@ import KaraokeLyrics from '../../../components/KaraokeLyrics'
import { Palette } from '../../../styles' import { Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts' import { FONT_FAMILY } from '../../../styles/Fonts'
const PLAYBACK_LYRICS_LEAD_S = 0.18
const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => { const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BlurView tint="dark" intensity={Platform.OS !== 'ios' ? 10 : 20} style={styles.blur}> <BlurView tint="dark" intensity={Platform.OS !== 'ios' ? 10 : 20} style={styles.blur}>
{alignedWords?.length > 0 ? ( {alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
alignedWords={alignedWords}
currentTimeS={currentTimeS}
timeOffsetS={PLAYBACK_LYRICS_LEAD_S}
/>
) : ( ) : (
<Text style={styles.title}>{fallbackTitle || 'Description chanson'}</Text> <Text style={styles.title}>{fallbackTitle || 'Description chanson'}</Text>
)} )}