feat: synchronise audio and video playback
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 } = {}) => {
|
||||
|
||||
@@ -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 (_) {}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -1074,9 +880,7 @@ const RecordPlayback = ({ route }) => {
|
||||
<ProgressRing
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
progress={
|
||||
progressInfo.dur ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) : 0
|
||||
}
|
||||
progress={durationMs ? Math.min(1, positionMs / durationMs) : 0}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
@@ -1099,34 +903,6 @@ const RecordPlayback = ({ route }) => {
|
||||
<RestartSpinnerIcon />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{isRecording && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleTogglePause}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 999,
|
||||
backgroundColor: Palette.white,
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.black,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{isPaused ? 'Reprendre' : 'Mettre en pause'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -1146,8 +922,7 @@ const RecordPlayback = ({ route }) => {
|
||||
{(isRecording || showProgress) && alignedWords?.length > 0 ? (
|
||||
<KaraokeLyrics
|
||||
alignedWords={alignedWords}
|
||||
currentTimeS={(progressInfo?.pos || 0) / 1000}
|
||||
timeOffsetS={KARAOKE_LEAD_S}
|
||||
currentTimeS={positionMs / 1000}
|
||||
mode="teleprompter"
|
||||
teleprompterLines={6}
|
||||
teleprompterAnchorLine={1}
|
||||
@@ -1202,44 +977,6 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 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 (
|
||||
<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 }) => {
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -29,14 +29,6 @@ const WEB_PREVIEW_WIDTH = 360
|
||||
const MEDIA_BOOTSTRAP_DELAY_MS = 250
|
||||
const MEDIA_RETRY_DELAY_MS = 700
|
||||
const MEDIA_MAX_RETRIES = 2
|
||||
const CAMERA_STARTUP_DELAY_MS = 250
|
||||
const KARAOKE_LEAD_S = 0.68
|
||||
|
||||
const toSeconds = (v) => {
|
||||
const n = Number(v ?? 0)
|
||||
if (!Number.isFinite(n) || n < 0) return 0
|
||||
return n > 10000 ? n / 1000 : n // heuristique ms → s
|
||||
}
|
||||
|
||||
const pickRecorderMimeType = () => {
|
||||
if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined
|
||||
@@ -51,7 +43,7 @@ const pickRecorderMimeType = () => {
|
||||
for (const mimeType of candidates) {
|
||||
try {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return undefined
|
||||
@@ -74,6 +66,8 @@ const RecordPlayback = ({ route }) => {
|
||||
coverUrl: project?.coverUrl || null,
|
||||
metadata: { projectId: project?.id, screen: 'RecordPlayback' },
|
||||
})
|
||||
const positionMs = player?.positionMs || 0
|
||||
const durationMs = player?.durationMs || 0
|
||||
|
||||
// MediaStream / Recorder (web only)
|
||||
const previewVideoRef = useRef(null)
|
||||
@@ -96,31 +90,19 @@ const RecordPlayback = ({ route }) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [isPreparing, setIsPreparing] = useState(false)
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [pos, setPos] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
|
||||
// timers / refs
|
||||
const isPausedRef = useRef(false)
|
||||
const progressTimerRef = useRef(null)
|
||||
const listenTimerRef = useRef(null)
|
||||
const countdownTimerRef = useRef(null)
|
||||
const perfStartRef = useRef(null)
|
||||
const pausedAtRef = useRef(null)
|
||||
const pausedMsRef = useRef(0)
|
||||
const listenedMsRef = useRef(0)
|
||||
const viewsIncrementedRef = useRef(false)
|
||||
const correctedInitialJumpRef = useRef(false)
|
||||
const playbackStartedAtRef = useRef(null)
|
||||
const recordingStartAtRef = useRef(null)
|
||||
const stopInFlightRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
latestLoopingValueRef.current = isLooping ?? false
|
||||
}, [isLooping])
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused
|
||||
}, [isPaused])
|
||||
|
||||
// Lyrics
|
||||
const alignedWords = useMemo(() => {
|
||||
const ts = project?.musicTimestamps?.[songIndex]
|
||||
@@ -134,10 +116,9 @@ const RecordPlayback = ({ route }) => {
|
||||
}, [project?.musicTimestamps, songIndex])
|
||||
|
||||
const clearAllTimers = () => {
|
||||
if (progressTimerRef.current) clearInterval(progressTimerRef.current)
|
||||
if (listenTimerRef.current) clearInterval(listenTimerRef.current)
|
||||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
|
||||
progressTimerRef.current = listenTimerRef.current = countdownTimerRef.current = null
|
||||
listenTimerRef.current = countdownTimerRef.current = null
|
||||
}
|
||||
|
||||
const clearMediaRetry = useCallback(() => {
|
||||
@@ -150,19 +131,12 @@ const RecordPlayback = ({ route }) => {
|
||||
const resetUI = () => {
|
||||
setIsPreparing(false)
|
||||
setIsRecording(false)
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
pausedMsRef.current = 0
|
||||
setCountdown(0)
|
||||
setPos(0)
|
||||
setDur(0)
|
||||
listenedMsRef.current = 0
|
||||
viewsIncrementedRef.current = false
|
||||
perfStartRef.current = null
|
||||
correctedInitialJumpRef.current = false
|
||||
playbackStartedAtRef.current = null
|
||||
recordingStartAtRef.current = null
|
||||
stopInFlightRef.current = false
|
||||
}
|
||||
|
||||
const releaseRecordingUrl = useCallback(() => {
|
||||
@@ -191,14 +165,14 @@ const RecordPlayback = ({ route }) => {
|
||||
if (video.srcObject !== stream) {
|
||||
video.srcObject = stream
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const playPromise = video.play?.()
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => { })
|
||||
playPromise.catch(() => {})
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
return true
|
||||
}, [])
|
||||
@@ -271,16 +245,16 @@ const RecordPlayback = ({ route }) => {
|
||||
[clearMediaRetry, ensureMediaStream]
|
||||
)
|
||||
|
||||
const startRecorder = useCallback(() => {
|
||||
const startRecorder = useCallback(async () => {
|
||||
if (!mediaStreamRef.current) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
recordedChunksRef.current = []
|
||||
releaseRecordingUrl()
|
||||
@@ -294,7 +268,7 @@ const RecordPlayback = ({ route }) => {
|
||||
: new MediaRecorder(mediaStreamRef.current)
|
||||
} catch (e) {
|
||||
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
mediaRecorderRef.current = recorder
|
||||
@@ -308,9 +282,21 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
}
|
||||
|
||||
let resolveRecorderStart
|
||||
const recorderStartedPromise = new Promise((resolve) => {
|
||||
resolveRecorderStart = resolve
|
||||
})
|
||||
|
||||
recorder.onstart = () => {
|
||||
resolveRecorderStart?.(performance.now())
|
||||
resolveRecorderStart = null
|
||||
}
|
||||
|
||||
recorder.onerror = (event) => {
|
||||
const err = event?.error || event
|
||||
setMediaError(err instanceof Error ? err : new Error(String(err || '')))
|
||||
resolveRecorderStart?.(null)
|
||||
resolveRecorderStart = null
|
||||
}
|
||||
|
||||
recorder.onstop = () => {
|
||||
@@ -340,8 +326,10 @@ const RecordPlayback = ({ route }) => {
|
||||
|
||||
try {
|
||||
recorder.start(1000)
|
||||
return true
|
||||
return await recorderStartedPromise
|
||||
} catch (e) {
|
||||
resolveRecorderStart?.(null)
|
||||
resolveRecorderStart = null
|
||||
if (stopRecordingResolveRef.current) {
|
||||
stopRecordingResolveRef.current(null)
|
||||
stopRecordingResolveRef.current = null
|
||||
@@ -349,7 +337,7 @@ const RecordPlayback = ({ route }) => {
|
||||
stopRecordingPromiseRef.current = null
|
||||
mediaRecorderRef.current = null
|
||||
setMediaError(e instanceof Error ? e : new Error(String(e || '')))
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}, [releaseRecordingUrl, setMediaError])
|
||||
|
||||
@@ -366,7 +354,7 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
recorder.stop()
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
if (!waitPromise) {
|
||||
return recordedUrlRef.current || null
|
||||
@@ -390,10 +378,10 @@ const RecordPlayback = ({ route }) => {
|
||||
try {
|
||||
await player?.pause?.()
|
||||
await player?.seekTo?.(0)
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
try {
|
||||
await stopRecorderAndGetUrl()
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
if (!preserveRecordingForNextScreenRef.current) {
|
||||
releaseRecordingUrl()
|
||||
}
|
||||
@@ -433,12 +421,12 @@ const RecordPlayback = ({ route }) => {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
; (async () => {
|
||||
;(async () => {
|
||||
try {
|
||||
if (!cameraPermission?.granted) {
|
||||
await requestCameraPermission()
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
})()
|
||||
return () => {
|
||||
clearAllTimers()
|
||||
@@ -481,7 +469,7 @@ const RecordPlayback = ({ route }) => {
|
||||
} else {
|
||||
try {
|
||||
video.srcObject = null
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
}, [attachPreview, mediaReady, mediaError])
|
||||
|
||||
@@ -513,7 +501,7 @@ const RecordPlayback = ({ route }) => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
stopMediaStream()
|
||||
clearMediaRetry()
|
||||
if (!preserveRecordingForNextScreenRef.current) {
|
||||
@@ -523,11 +511,13 @@ const RecordPlayback = ({ route }) => {
|
||||
}, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream])
|
||||
|
||||
const stopAndNavigate = useCallback(async () => {
|
||||
if (stopInFlightRef.current) return
|
||||
stopInFlightRef.current = true
|
||||
preserveRecordingForNextScreenRef.current = true
|
||||
clearAllTimers()
|
||||
try {
|
||||
await player?.pause?.()
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
let videoUrl = null
|
||||
try {
|
||||
@@ -535,13 +525,9 @@ const RecordPlayback = ({ route }) => {
|
||||
if (!videoUrl) {
|
||||
videoUrl = recordedUrlRef.current || null
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
|
||||
setIsRecording(false)
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
pausedMsRef.current = 0
|
||||
|
||||
const rawOffsetMs =
|
||||
playbackStartedAtRef.current != null && recordingStartAtRef.current != null
|
||||
@@ -556,68 +542,10 @@ const RecordPlayback = ({ route }) => {
|
||||
})
|
||||
}, [player, project, stopRecorderAndGetUrl])
|
||||
|
||||
const startProgressLoop = useCallback(() => {
|
||||
if (progressTimerRef.current) clearInterval(progressTimerRef.current)
|
||||
|
||||
progressTimerRef.current = setInterval(async () => {
|
||||
const rawDur = toSeconds(player?.duration)
|
||||
const rawPos = toSeconds(player?.currentTime)
|
||||
const pausedSince =
|
||||
pausedAtRef.current != null ? Math.max(0, performance.now() - pausedAtRef.current) : 0
|
||||
const pausedTotal = pausedMsRef.current + pausedSince
|
||||
|
||||
// fallback monotone si le player ne donne rien
|
||||
const fallback =
|
||||
perfStartRef.current != null
|
||||
? Math.max(0, (performance.now() - perfStartRef.current - pausedTotal) / 1000)
|
||||
: 0
|
||||
|
||||
let nextDur = rawDur > 0 ? rawDur : dur || 0
|
||||
let nextPos = Number.isFinite(rawPos) && rawPos >= 0 ? rawPos : fallback > 0 ? fallback : 0
|
||||
|
||||
// 🔧 Correction 1-shot du "jump initial" (ex: 0 → 93s au 1er tick)
|
||||
if (!correctedInitialJumpRef.current && nextPos > 1 && fallback < 1.5) {
|
||||
try {
|
||||
await player?.seekTo?.(0)
|
||||
correctedInitialJumpRef.current = true
|
||||
nextPos = 0
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const timeSinceStart =
|
||||
perfStartRef.current != null
|
||||
? Math.max(0, performance.now() - perfStartRef.current - pausedTotal)
|
||||
: null
|
||||
|
||||
const shouldTrustFallback =
|
||||
fallback > 0 &&
|
||||
((nextDur > 0 && nextPos > nextDur + 0.5) ||
|
||||
nextPos - fallback > 1.5 ||
|
||||
(timeSinceStart != null && timeSinceStart < 5000 && nextPos > fallback + 1))
|
||||
|
||||
if (shouldTrustFallback) {
|
||||
nextPos = fallback
|
||||
}
|
||||
|
||||
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur
|
||||
|
||||
setDur(nextDur)
|
||||
setPos(nextPos)
|
||||
|
||||
if (nextPos > 0 && playbackStartedAtRef.current == null && perfStartRef.current != null) {
|
||||
playbackStartedAtRef.current =
|
||||
perfStartRef.current + Math.max(0, (nextPos * 1000) - pausedTotal)
|
||||
}
|
||||
|
||||
if (isPausedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
|
||||
void stopAndNavigate()
|
||||
}
|
||||
}, 250)
|
||||
}, [player, stopAndNavigate])
|
||||
useEffect(() => {
|
||||
if (!isRecording || !durationMs || positionMs < durationMs - 300) return
|
||||
void stopAndNavigate()
|
||||
}, [durationMs, isRecording, positionMs, stopAndNavigate])
|
||||
|
||||
const startListenLoop = useCallback(() => {
|
||||
if (listenTimerRef.current) clearInterval(listenTimerRef.current)
|
||||
@@ -630,39 +558,31 @@ const RecordPlayback = ({ route }) => {
|
||||
if (project?.id) {
|
||||
try {
|
||||
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
}, 500)
|
||||
}, [player, project?.id])
|
||||
|
||||
const startPlayback = useCallback(async () => {
|
||||
try {
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
pausedMsRef.current = 0
|
||||
await player?.pause?.() // s'assure qu'on repart propre
|
||||
await player?.seekTo?.(0) // tente un seek d'amorçage
|
||||
recordingStartAtRef.current = performance.now()
|
||||
const recorderStarted = startRecorder()
|
||||
if (!recorderStarted) {
|
||||
await player?.pause?.()
|
||||
await player?.seekTo?.(0)
|
||||
const recordingStartedAt = await startRecorder()
|
||||
if (recordingStartedAt == null) {
|
||||
throw new Error("Impossible de démarrer l'enregistrement vidéo")
|
||||
}
|
||||
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS))
|
||||
await player?.play?.() // attend la promesse → l'élément est prêt
|
||||
perfStartRef.current = perfStartRef.current ?? performance.now()
|
||||
correctedInitialJumpRef.current = false // autorise la correction 1-shot
|
||||
recordingStartAtRef.current = recordingStartedAt
|
||||
await player?.play?.()
|
||||
playbackStartedAtRef.current = performance.now()
|
||||
setIsRecording(true)
|
||||
startProgressLoop()
|
||||
startListenLoop()
|
||||
} catch (e) {
|
||||
setIsRecording(false)
|
||||
setIsPreparing(false)
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
pausedAtRef.current = null
|
||||
stopInFlightRef.current = false
|
||||
const msg = String(e?.message || e || '')
|
||||
if (/not supported on the simulator/i.test(msg)) {
|
||||
alert(
|
||||
@@ -674,7 +594,7 @@ const RecordPlayback = ({ route }) => {
|
||||
onPress: () => {
|
||||
try {
|
||||
goBack()
|
||||
} catch { }
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -682,7 +602,7 @@ const RecordPlayback = ({ route }) => {
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [player, startRecorder, startProgressLoop, startListenLoop])
|
||||
}, [player, startRecorder, startListenLoop])
|
||||
|
||||
const permissionsGranted = !!cameraPermission?.granted
|
||||
const canRecord = permissionsGranted && mediaReady && !mediaError
|
||||
@@ -690,8 +610,6 @@ const RecordPlayback = ({ route }) => {
|
||||
const startCountdownThenRecord = useCallback(async () => {
|
||||
if (!songUrl || !canRecord) return
|
||||
await resetSession()
|
||||
setIsPaused(false)
|
||||
isPausedRef.current = false
|
||||
setIsPreparing(true)
|
||||
setCountdown(COUNTDOWN_SECONDS)
|
||||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
|
||||
@@ -710,50 +628,13 @@ const RecordPlayback = ({ route }) => {
|
||||
}, 1000)
|
||||
}, [songUrl, canRecord, resetSession, startPlayback])
|
||||
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
try {
|
||||
if (!isRecording) return
|
||||
if (!isPausedRef.current) {
|
||||
isPausedRef.current = true
|
||||
setIsPaused(true)
|
||||
pausedAtRef.current = performance.now()
|
||||
try {
|
||||
await player?.pause?.()
|
||||
} catch (e) { }
|
||||
try {
|
||||
const recorder = mediaRecorderRef.current
|
||||
if (recorder && recorder.state === 'recording') {
|
||||
recorder.pause?.()
|
||||
}
|
||||
} catch (e) { }
|
||||
return
|
||||
}
|
||||
const pausedAt = pausedAtRef.current
|
||||
if (pausedAt != null) {
|
||||
pausedMsRef.current += performance.now() - pausedAt
|
||||
pausedAtRef.current = null
|
||||
}
|
||||
isPausedRef.current = false
|
||||
setIsPaused(false)
|
||||
try {
|
||||
const recorder = mediaRecorderRef.current
|
||||
if (recorder && recorder.state === 'paused') {
|
||||
recorder.resume?.()
|
||||
}
|
||||
} catch (e) { }
|
||||
try {
|
||||
await player?.play?.()
|
||||
} catch (e) { }
|
||||
} catch (e) { }
|
||||
}, [isRecording, player])
|
||||
|
||||
const handleRestartRecording = useCallback(async () => {
|
||||
try {
|
||||
await startCountdownThenRecord()
|
||||
} catch (e) { }
|
||||
} catch (e) {}
|
||||
}, [startCountdownThenRecord])
|
||||
|
||||
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0
|
||||
const progressRatio = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -761,7 +642,7 @@ const RecordPlayback = ({ route }) => {
|
||||
// containerStyle={{ backgroundColor: Palette.gray }}
|
||||
headerType="NONE"
|
||||
backgroundColor={Palette.grayMid}
|
||||
// backgroundImg={background.playbackBG2}
|
||||
// backgroundImg={background.playbackBG2}
|
||||
>
|
||||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||||
|
||||
@@ -793,9 +674,9 @@ const RecordPlayback = ({ route }) => {
|
||||
const video = event?.currentTarget
|
||||
const playPromise = video?.play?.()
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => { })
|
||||
playPromise.catch(() => {})
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -941,34 +822,6 @@ const RecordPlayback = ({ route }) => {
|
||||
<RestartSpinnerIcon />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{isRecording && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleTogglePause}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 999,
|
||||
backgroundColor: Palette.white,
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.black,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{isPaused ? 'Reprendre' : 'Mettre en pause'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -988,7 +841,7 @@ const RecordPlayback = ({ route }) => {
|
||||
onPress={async () => {
|
||||
try {
|
||||
if (!cameraPermission?.granted) await requestCameraPermission()
|
||||
} catch { }
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -1010,8 +863,7 @@ const RecordPlayback = ({ route }) => {
|
||||
{isRecording && alignedWords.length > 0 ? (
|
||||
<KaraokeLyrics
|
||||
alignedWords={alignedWords}
|
||||
currentTimeS={pos}
|
||||
timeOffsetS={KARAOKE_LEAD_S}
|
||||
currentTimeS={positionMs / 1000}
|
||||
mode="teleprompter"
|
||||
teleprompterLines={6}
|
||||
teleprompterAnchorLine={1}
|
||||
|
||||
@@ -79,6 +79,20 @@ const RecordedPlayback = ({ route }) => {
|
||||
[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 (
|
||||
<Page backgroundColor={Palette.grayMid} headerType="NONE">
|
||||
<MusicLandHeader progress={19} onPressBack={goBack} />
|
||||
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
||||
|
||||
{/* Vidéo avec play/pause en overlay */}
|
||||
<Pressable
|
||||
onPress={handleTogglePlayback}
|
||||
@@ -363,7 +360,14 @@ const RecordedPlayback = ({ route }) => {
|
||||
Synchro audio / vidéo
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => 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'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<Page
|
||||
@@ -419,7 +413,14 @@ const RecordedPlayback = ({ route }) => {
|
||||
Synchro audio / vidéo
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => 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'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -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',
|
||||
})}
|
||||
|
||||
@@ -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 <video> element instead of expo-video
|
||||
// - No Platform checks
|
||||
@@ -474,11 +473,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid, onBackgroundSyn
|
||||
<View style={styles.lyricsContainer}>
|
||||
<BlurView tint="dark" intensity={20} style={styles.lyricsCard}>
|
||||
{alignedWords?.length > 0 ? (
|
||||
<KaraokeLyrics
|
||||
alignedWords={alignedWords}
|
||||
currentTimeS={currentTimeS}
|
||||
timeOffsetS={PLAYBACK_LYRICS_LEAD_S}
|
||||
/>
|
||||
<KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
|
||||
) : (
|
||||
<Text style={styles.lyricsText}>{descriptionText}</Text>
|
||||
)}
|
||||
|
||||
@@ -5,18 +5,12 @@ import KaraokeLyrics from '../../../components/KaraokeLyrics'
|
||||
import { Palette } from '../../../styles'
|
||||
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||
|
||||
const PLAYBACK_LYRICS_LEAD_S = 0.18
|
||||
|
||||
const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BlurView tint="dark" intensity={Platform.OS !== 'ios' ? 10 : 20} style={styles.blur}>
|
||||
{alignedWords?.length > 0 ? (
|
||||
<KaraokeLyrics
|
||||
alignedWords={alignedWords}
|
||||
currentTimeS={currentTimeS}
|
||||
timeOffsetS={PLAYBACK_LYRICS_LEAD_S}
|
||||
/>
|
||||
<KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
|
||||
) : (
|
||||
<Text style={styles.title}>{fallbackTitle || 'Description chanson'}</Text>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user