diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index 6b6dad7..703919b 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -361,6 +361,7 @@ const MusicDetails = ({ route }) => { padding: 10, borderWidth: 1, borderColor: Palette.transparentWhite, + maxWidth: "50%", }} > @@ -500,69 +501,73 @@ const MusicDetails = ({ route }) => { {/* Right column: lyrics */} - - {lines?.length ? ( - - {lines.map((L, i) => ( - { - lineYRef.current[i] = e.nativeEvent.layout.y; - }} + + {lines.length > 0 && ( + + {lines?.length ? ( + + {lines.map((L, i) => ( + { + lineYRef.current[i] = e.nativeEvent.layout.y; + }} + style={{ + marginBottom: 8, + flexDirection: "row", + flexWrap: "wrap", + }} + > + {(L.words || []).map((w, j) => ( + = (w.startS || 0) + ? styles.karaokeWordActive + : styles.karaokeWord + } + > + {w.text} + {j < (L.words?.length || 1) - 1 ? " " : ""} + + ))} + + ))} + + ) : description?.length > 0 ? ( + + - {(L.words || []).map((w, j) => ( - = (w.startS || 0) - ? styles.karaokeWordActive - : styles.karaokeWord - } - > - {w.text} - {j < (L.words?.length || 1) - 1 ? " " : ""} - - ))} - - ))} - - ) : description?.length > 0 ? ( - - - {description} - - - ) : null} - + {description} + + + ) : null} + + )} ); diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index 8e2ea22..dcdd74a 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -1,6 +1,6 @@ import { useFocusEffect } from "@react-navigation/native"; import { useAudioPlayer } from "expo-audio"; -import { CameraView, useCameraPermissions } from "expo-camera"; +import { useCameraPermissions } from "expo-camera"; import React, { useCallback, useEffect, @@ -23,193 +23,302 @@ import { gutters, Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; -const TIME_BEFORE_INCREMENT_MS = 20000; // 20s +const TIME_BEFORE_INCREMENT_MS = 20000; +const LOG_PREFIX = "[RecordPlayback.web]"; + +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; + + const candidates = [ + "video/webm;codecs=vp9,opus", + "video/webm;codecs=vp8,opus", + "video/webm;codecs=vp8", + "video/webm", + ]; + + for (const mimeType of candidates) { + try { + if (MediaRecorder.isTypeSupported(mimeType)) return mimeType; + } catch {} + } + + return undefined; +}; const RecordPlayback = ({ route }) => { const { top, bottom } = useSafeAreaInsets(); const { project } = route.params || {}; - const projectId = project?.id; - const songIndex = project?.songIndex; - // Permissions + const songIndex = Number(project?.songIndex ?? 0) || 0; const [cameraPermission, requestCameraPermission] = useCameraPermissions(); - // Refs - const cameraRef = useRef(null); - const countdownTimerRef = useRef(null); - const listenTimerRef = useRef(null); - const checkSongEndRef = useRef(null); - const stopRequestedRef = useRef(false); - const startedRef = useRef(false); // empêche les doubles démarrages - const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick - - // Compteurs vues - const listenedMsRef = useRef(0); - const incrementDoneRef = useRef(false); - - // UI / state - const [isPreparing, setIsPreparing] = useState(false); - const [countdown, setCountdown] = useState(0); - const [isRecording, setIsRecording] = useState(false); - const [showProgress, setShowProgress] = useState(false); - const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); - - // Musique + // Player const songUrl = project?.songUrl || null; const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); - const musicIndex = useMemo(() => { - const i = Number(songIndex); - return Number.isFinite(i) && i >= 0 ? i : 0; - }, [songIndex]); + // MediaStream / Recorder (web only) + const previewVideoRef = useRef(null); + const mediaStreamRef = useRef(null); + const mediaRecorderRef = useRef(null); + const recordedChunksRef = useRef([]); + const stopRecordingPromiseRef = useRef(null); + const stopRecordingResolveRef = useRef(null); + const recordedUrlRef = useRef(null); + const [mediaReady, setMediaReady] = useState(false); + const [mediaError, setMediaError] = useState(null); + // UI state + const [countdown, setCountdown] = useState(0); + const [isPreparing, setIsPreparing] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [pos, setPos] = useState(0); + const [dur, setDur] = useState(0); + + // timers / refs + const progressTimerRef = useRef(null); + const listenTimerRef = useRef(null); + const countdownTimerRef = useRef(null); + const perfStartRef = useRef(null); + const listenedMsRef = useRef(0); + const viewsIncrementedRef = useRef(false); + const correctedInitialJumpRef = useRef(false); + const progressDebugCounterRef = useRef(0); + + // Lyrics const alignedWords = useMemo(() => { - const ts = project?.musicTimestamps?.[musicIndex]; - + const ts = project?.musicTimestamps?.[songIndex]; const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []; return arr.map((w) => ({ word: String(w?.word ?? ""), startS: Number(w?.startS ?? 0), endS: Number(w?.endS ?? 0), })); - }, [project?.musicTimestamps, musicIndex]); + }, [project?.musicTimestamps, songIndex]); - // 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; + const clearAllTimers = () => { + console.log(LOG_PREFIX, "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; + }; - // 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]); - // console.log("alignedWords:", alignedWords); - useEffect(() => { + const resetUI = () => { + console.log(LOG_PREFIX, "resetUI"); + setIsPreparing(false); + setIsRecording(false); + setCountdown(0); + setPos(0); + setDur(0); listenedMsRef.current = 0; - incrementDoneRef.current = false; - }, [songUrl]); + viewsIncrementedRef.current = false; + perfStartRef.current = null; + correctedInitialJumpRef.current = false; + }; - // Poll player -> progress ring - useEffect(() => { - if (!player) return; - const id = setInterval(() => { + const releaseRecordingUrl = useCallback(() => { + if (recordedUrlRef.current) { try { - const dur = (player?.duration || 0) * 1000; - const pos = (player?.currentTime || 0) * 1000; - setProgressInfo({ pos, 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; + URL.revokeObjectURL(recordedUrlRef.current); + } catch {} + recordedUrlRef.current = null; } - if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) - return lines.length - 1; - return -1; - }, [lines, currentTimeS]); + }, []); - // Permissions au mount + cleanup - useEffect(() => { - (async () => { - try { - if (!cameraPermission?.granted) await requestCameraPermission(); - } catch (_) {} - })(); - return () => { - try { - if (countdownTimerRef.current) clearInterval(countdownTimerRef.current); - if (listenTimerRef.current) clearInterval(listenTimerRef.current); - if (checkSongEndRef.current) clearInterval(checkSongEndRef.current); - countdownTimerRef.current = null; - listenTimerRef.current = null; - checkSongEndRef.current = null; - } catch (_) {} - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const startRecorder = useCallback(() => { + if (!mediaStreamRef.current) { + console.log(LOG_PREFIX, "mediaRecorder:start:noStream"); + return false; + } - // Reset complet - const resetSession = useCallback(async () => { try { - if (countdownTimerRef.current) { - clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - if (listenTimerRef.current) { - clearInterval(listenTimerRef.current); - listenTimerRef.current = null; - } - if (checkSongEndRef.current) { - clearInterval(checkSongEndRef.current); - checkSongEndRef.current = null; + if ( + mediaRecorderRef.current && + mediaRecorderRef.current.state !== "inactive" + ) { + mediaRecorderRef.current.stop(); } + } catch (e) { + console.log( + LOG_PREFIX, + "mediaRecorder:stopPrevious:error", + String(e?.message || e || "") + ); + } - startedRef.current = false; - stopRequestedRef.current = false; - countdownActiveRef.current = false; - listenedMsRef.current = 0; - incrementDoneRef.current = false; + recordedChunksRef.current = []; + releaseRecordingUrl(); - setIsPreparing(false); - setIsRecording(false); - setShowProgress(false); - setCountdown(0); + const mimeType = pickRecorderMimeType(); + let recorder; + try { + recorder = + mimeType != null + ? new MediaRecorder(mediaStreamRef.current, { mimeType }) + : new MediaRecorder(mediaStreamRef.current); + } catch (e) { + console.log( + LOG_PREFIX, + "mediaRecorder:createError", + String(e?.message || e || "") + ); + setMediaError(e instanceof Error ? e : new Error(String(e || ""))); + return false; + } - if (player) { - try { - if (player.playing) await player.pause?.(); - await player.seekTo?.(0); - } catch (_) {} + mediaRecorderRef.current = recorder; + stopRecordingPromiseRef.current = new Promise((resolve) => { + stopRecordingResolveRef.current = resolve; + }); + + recorder.ondataavailable = (event) => { + if (event?.data && event.data.size > 0) { + recordedChunksRef.current.push(event.data); } - } catch (_) {} - }, [player]); + }; + + recorder.onerror = (event) => { + const err = event?.error || event; + console.log( + LOG_PREFIX, + "mediaRecorder:error", + String(err?.message || err || "") + ); + setMediaError( + err instanceof Error ? err : new Error(String(err || "")) + ); + }; + + recorder.onstop = () => { + let url = null; + try { + if (recordedChunksRef.current.length > 0) { + const blob = new Blob(recordedChunksRef.current, { + type: recorder.mimeType || mimeType || "video/webm", + }); + url = URL.createObjectURL(blob); + recordedUrlRef.current = url; + } else { + releaseRecordingUrl(); + } + } catch (e) { + console.log( + LOG_PREFIX, + "mediaRecorder:onstop:createBlobError", + String(e?.message || e || "") + ); + releaseRecordingUrl(); + } + if (stopRecordingResolveRef.current) { + stopRecordingResolveRef.current(url); + stopRecordingResolveRef.current = null; + } + recordedChunksRef.current = []; + mediaRecorderRef.current = null; + stopRecordingPromiseRef.current = null; + }; + + try { + recorder.start(1000); + console.log(LOG_PREFIX, "mediaRecorder:start", { + mimeType: recorder.mimeType, + }); + return true; + } catch (e) { + console.log( + LOG_PREFIX, + "mediaRecorder:startError", + String(e?.message || e || "") + ); + if (stopRecordingResolveRef.current) { + stopRecordingResolveRef.current(null); + stopRecordingResolveRef.current = null; + } + stopRecordingPromiseRef.current = null; + mediaRecorderRef.current = null; + setMediaError(e instanceof Error ? e : new Error(String(e || ""))); + return false; + } + }, [releaseRecordingUrl, setMediaError]); + + const stopRecorderAndGetUrl = useCallback(async () => { + let waitPromise = stopRecordingPromiseRef.current; + try { + const recorder = mediaRecorderRef.current; + if (recorder && recorder.state !== "inactive") { + if (!waitPromise) { + waitPromise = new Promise((resolve) => { + stopRecordingResolveRef.current = resolve; + }); + stopRecordingPromiseRef.current = waitPromise; + } + recorder.stop(); + } + } catch (e) { + console.log( + LOG_PREFIX, + "mediaRecorder:stopError", + String(e?.message || e || "") + ); + } + + if (!waitPromise) { + return recordedUrlRef.current || null; + } + + let url = null; + try { + url = await waitPromise; + } catch (e) { + console.log( + LOG_PREFIX, + "mediaRecorder:waitStopError", + String(e?.message || e || "") + ); + } finally { + stopRecordingPromiseRef.current = null; + stopRecordingResolveRef.current = null; + } + + return url || recordedUrlRef.current || null; + }, []); + + const resetSession = useCallback(async () => { + console.log(LOG_PREFIX, "resetSession:start"); + clearAllTimers(); + resetUI(); + try { + await player?.pause?.(); + await player?.seekTo?.(0); + console.log(LOG_PREFIX, "resetSession:playerReset"); + } catch (e) { + console.log( + LOG_PREFIX, + "resetSession:error", + String(e?.message || e || "") + ); + } + try { + await stopRecorderAndGetUrl(); + releaseRecordingUrl(); + } catch (e) { + console.log( + LOG_PREFIX, + "resetSession:recorderError", + String(e?.message || e || "") + ); + } + console.log(LOG_PREFIX, "resetSession:end"); + }, [player, releaseRecordingUrl, stopRecorderAndGetUrl]); useFocusEffect( useCallback(() => { @@ -218,146 +327,324 @@ const RecordPlayback = ({ route }) => { }, [resetSession]) ); - // Lancer le compte à rebours (le tick décrémente uniquement) - const startCountdownThenRecord = async () => { - if (!songUrl) return; - await resetSession(); - setIsPreparing(true); - setShowProgress(false); - setCountdown(5); - - // On démarre l'intervalle puis on active le flag - if (countdownTimerRef.current) { - clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - countdownTimerRef.current = setInterval(() => { - setCountdown((c) => Math.max(0, c - 1)); - }, 1000); - countdownActiveRef.current = true; - }; - - // Quand le compteur a réellement démarré ET atteint 0, on démarre useEffect(() => { - if (!isPreparing) return; - if (!countdownActiveRef.current) return; // évite l'auto-start - - if (countdown === 0 && !startedRef.current) { - startedRef.current = true; - if (countdownTimerRef.current) { - clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - countdownActiveRef.current = false; - // Bascule après rendu de la frame courante - requestAnimationFrame(() => { - setIsPreparing(false); - setShowProgress(true); - void startRecordingWithMusic(); - }); - } - }, [countdown, isPreparing]); - - const startRecordingWithMusic = async () => { - try { - stopRequestedRef.current = false; - listenedMsRef.current = 0; - incrementDoneRef.current = false; - - setIsRecording(true); - setShowProgress(true); - const recordPromise = cameraRef.current?.recordAsync?.({ - mute: true, - maxDuration: 600, - }); - - if (player && songUrl) { - try { - await player.seekTo?.(0); - } catch (_) {} - await player.play?.(); - } - - // Incrément des vues - if (!listenTimerRef.current && project?.id) { - listenTimerRef.current = setInterval(async () => { - try { - if (player?.playing) { - listenedMsRef.current += 500; - if ( - !incrementDoneRef.current && - listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS - ) { - incrementDoneRef.current = true; - try { - await projectsRef - .doc(project.id) - .set({ views: increment(1) }, { merge: true }); - } catch (_) {} - } - } - } catch (_) {} - }, 500); - } - - // Fin du morceau -> stop recording - if (!checkSongEndRef.current) { - checkSongEndRef.current = setInterval(() => { - try { - if (!player) return; - const duration = (player?.duration || 0) * 1000; - const currentTime = (player?.currentTime || 0) * 1000; - if ( - (!player.playing && !stopRequestedRef.current) || - (duration > 0 && currentTime >= duration - 600) - ) { - stopRequestedRef.current = true; - if (checkSongEndRef.current) { - clearInterval(checkSongEndRef.current); - checkSongEndRef.current = null; - } - try { - cameraRef.current?.stopRecording?.(); - } catch (_) {} - } - } catch (_) {} - }, 500); - } - - const video = await recordPromise; - - if (checkSongEndRef.current) { - clearInterval(checkSongEndRef.current); - checkSongEndRef.current = null; - } + (async () => { try { - if (player?.playing) await player.pause?.(); - } catch (_) {} - if (listenTimerRef.current) { - clearInterval(listenTimerRef.current); - listenTimerRef.current = null; + if (!cameraPermission?.granted) { + console.log(LOG_PREFIX, "requestingCameraPermission"); + await requestCameraPermission(); + console.log(LOG_PREFIX, "requestingCameraPermission:done"); + } + } catch (e) { + console.log( + LOG_PREFIX, + "requestingCameraPermission:error", + String(e?.message || e || "") + ); + } + })(); + return () => { + clearAllTimers(); + }; + }, []); // eslint-disable-line + + useEffect(() => { + if (!cameraPermission?.granted) { + setMediaReady(false); + return; + } + + if (mediaStreamRef.current) { + setMediaReady(true); + return; + } + + if ( + typeof navigator === "undefined" || + !navigator.mediaDevices?.getUserMedia + ) { + setMediaError( + new Error("La capture vidéo n'est pas supportée sur ce navigateur") + ); + setMediaReady(false); + return; + } + + let cancelled = false; + (async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "user" }, + audio: true, + }); + if (cancelled) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + mediaStreamRef.current = stream; + setMediaReady(true); + setMediaError(null); + } catch (e) { + if (cancelled) return; + setMediaError(e instanceof Error ? e : new Error(String(e || ""))); + setMediaReady(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [cameraPermission?.granted]); + + useEffect(() => { + const video = previewVideoRef.current; + const stream = mediaStreamRef.current; + if (!video) return; + + if (stream) { + try { + if (video.srcObject !== stream) { + video.srcObject = stream; + } + const playPromise = video.play?.(); + if (playPromise && typeof playPromise.catch === "function") { + playPromise.catch(() => {}); + } + } catch (e) { + console.log( + LOG_PREFIX, + "previewVideo:attachError", + String(e?.message || e || "") + ); + } + } else { + try { + video.srcObject = null; + } catch {} + } + }, [mediaReady, mediaError]); + + useEffect(() => { + return () => { + try { + if ( + mediaRecorderRef.current && + mediaRecorderRef.current.state !== "inactive" + ) { + mediaRecorderRef.current.stop(); + } + } catch {} + if (mediaStreamRef.current) { + mediaStreamRef.current.getTracks().forEach((track) => track.stop()); + mediaStreamRef.current = null; + } + releaseRecordingUrl(); + }; + }, [releaseRecordingUrl]); + + const stopAndNavigate = useCallback(async () => { + console.log(LOG_PREFIX, "stopAndNavigate:start", { + pos, + dur, + listenedMs: listenedMsRef.current, + }); + clearAllTimers(); + try { + await player?.pause?.(); + console.log(LOG_PREFIX, "stopAndNavigate:playerPaused"); + } catch (e) { + console.log( + LOG_PREFIX, + "stopAndNavigate:pauseError", + String(e?.message || e || "") + ); + } + + let videoUrl = null; + try { + videoUrl = await stopRecorderAndGetUrl(); + if (!videoUrl) { + videoUrl = recordedUrlRef.current || null; + } + } catch (e) { + console.log( + LOG_PREFIX, + "stopAndNavigate:recorderError", + String(e?.message || e || "") + ); + } + + setIsRecording(false); + console.log( + LOG_PREFIX, + "stopAndNavigate:navigate", + { + route: Routes.RecordedPlayback, + hasVideo: !!videoUrl, + } + ); + navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null }); + }, [dur, player, pos, project, stopRecorderAndGetUrl]); + + const startProgressLoop = useCallback(() => { + if (progressTimerRef.current) clearInterval(progressTimerRef.current); + progressDebugCounterRef.current = 0; + console.log(LOG_PREFIX, "startProgressLoop"); + + progressTimerRef.current = setInterval(async () => { + const rawDur = toSeconds(player?.duration); + const rawPos = toSeconds(player?.currentTime); + + // fallback monotone si le player ne donne rien + const fallback = + perfStartRef.current != null + ? Math.max(0, (performance.now() - perfStartRef.current) / 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; + console.log(LOG_PREFIX, "progressLoop:correctedInitialJump"); + } catch (e) { + console.log( + LOG_PREFIX, + "progressLoop:correctInitialJumpError", + String(e?.message || e || "") + ); + } } - setIsRecording(false); - setShowProgress(false); + const timeSinceStart = + perfStartRef.current != null + ? performance.now() - perfStartRef.current + : null; - if (video?.uri) - navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); - else navigate(Routes.RecordedPlayback, { project }); + const shouldTrustFallback = + fallback > 0 && + ((nextDur > 0 && nextPos > nextDur + 0.5) || + nextPos - fallback > 1.5 || + (timeSinceStart != null && + timeSinceStart < 5000 && + nextPos > fallback + 1)); + + if (shouldTrustFallback) { + console.log(LOG_PREFIX, "progressLoop:useFallback", { + rawPos, + fallback, + nextPosBeforeFallback: nextPos, + timeSinceStart, + }); + nextPos = fallback; + } + + if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur; + + setDur(nextDur); + setPos(nextPos); + + progressDebugCounterRef.current += 1; + if ( + progressDebugCounterRef.current <= 12 || + nextPos >= nextDur - 0.5 || + progressDebugCounterRef.current % 20 === 0 + ) { + console.log(LOG_PREFIX, "progressLoop:tick", { + tick: progressDebugCounterRef.current, + rawDur, + rawPos, + fallback, + nextDur, + nextPos, + playing: !!player?.playing, + }); + } + + if (nextDur > 0 && nextPos >= nextDur - 0.3) { + console.log(LOG_PREFIX, "progressLoop:willStop", { + nextDur, + nextPos, + }); + void stopAndNavigate(); + } + }, 250); + }, [player, stopAndNavigate]); + + const startListenLoop = useCallback(() => { + if (listenTimerRef.current) clearInterval(listenTimerRef.current); + listenTimerRef.current = setInterval(async () => { + try { + if (player?.playing) { + listenedMsRef.current += 500; + if ( + !viewsIncrementedRef.current && + listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS + ) { + viewsIncrementedRef.current = true; + console.log(LOG_PREFIX, "listenLoop:incrementViews"); + if (project?.id) { + try { + await projectsRef + .doc(project.id) + .set({ views: increment(1) }, { merge: true }); + console.log(LOG_PREFIX, "listenLoop:incrementViews:done"); + } catch (e) { + console.log( + LOG_PREFIX, + "listenLoop:incrementViews:error", + String(e?.message || e || "") + ); + } + } + } + } + } catch (e) { + console.log( + LOG_PREFIX, + "listenLoop:error", + String(e?.message || e || "") + ); + } + }, 500); + }, [player, project?.id]); + + const startPlayback = useCallback(async () => { + console.log(LOG_PREFIX, "startPlayback:start"); + try { + await player?.pause?.(); // s'assure qu'on repart propre + await player?.seekTo?.(0); // tente un seek d'amorçage + const recorderStarted = startRecorder(); + if (!recorderStarted) { + console.log(LOG_PREFIX, "startPlayback:recorderNotStarted"); + } + 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 + setIsRecording(true); + console.log(LOG_PREFIX, "startPlayback:playing", { + duration: toSeconds(player?.duration), + currentTime: toSeconds(player?.currentTime), + }); + startProgressLoop(); + startListenLoop(); } catch (e) { - console.log("RecordPlayback error:", e); + console.log( + LOG_PREFIX, + "startPlayback:error", + String(e?.message || e || "") + ); setIsRecording(false); setIsPreparing(false); - setShowProgress(false); - if (listenTimerRef.current) { - clearInterval(listenTimerRef.current); - listenTimerRef.current = null; - } - if (checkSongEndRef.current) { - clearInterval(checkSongEndRef.current); - checkSongEndRef.current = null; - } - // Inform the user when using a simulator where recording isn't supported const msg = String(e?.message || e || ""); if (/not supported on the simulator/i.test(msg)) { Alert.alert( @@ -369,7 +656,7 @@ const RecordPlayback = ({ route }) => { onPress: () => { try { goBack(); - } catch (_) {} + } catch {} }, }, ], @@ -377,9 +664,35 @@ const RecordPlayback = ({ route }) => { ); } } - }; + }, [player, startRecorder, startProgressLoop, startListenLoop]); const permissionsGranted = !!cameraPermission?.granted; + const canRecord = permissionsGranted && mediaReady && !mediaError; + + const startCountdownThenRecord = useCallback(async () => { + if (!songUrl || !canRecord) return; + console.log(LOG_PREFIX, "startCountdownThenRecord"); + await resetSession(); + setIsPreparing(true); + setCountdown(5); + if (countdownTimerRef.current) clearInterval(countdownTimerRef.current); + countdownTimerRef.current = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + console.log(LOG_PREFIX, "countdown:launchRecording"); + clearAllTimers(); + setIsPreparing(false); + requestAnimationFrame(() => { + void startPlayback(); + }); + return 0; + } + console.log(LOG_PREFIX, "countdown:tick", c - 1); + return c - 1; + }); + }, 1000); + }, [songUrl, canRecord, resetSession, startPlayback]); + const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0; return ( { headerType="NONE" > + - + + {/* Overlay */} + - - - {/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */} - {isPreparing && countdown >= 1 && !showProgress && ( - - - {countdown} - - - )} - - {/* Permission prompt */} - {!permissionsGranted && ( - - { - try { - if (!cameraPermission?.granted) - await requestCameraPermission(); - } catch (_) {} - }} - /> - - )} - - - {/* Start button */} - {permissionsGranted && !isPreparing && !isRecording && ( - - )} - - {/* Progress circulaire */} - {permissionsGranted && (isRecording || showProgress) && ( + + {mediaError && ( + + {"Caméra indisponible : "} + {String(mediaError?.message || "").trim() || + "vérifie les permissions"} + + + )} + {!mediaError && !mediaReady && ( + - - - - + Initialisation de la caméra… + + + )} + {isPreparing && countdown >= 1 && !isRecording && ( + + + {countdown} + )} - - {/* CreateLyricsHeader overlay outside of CameraView */} + + {/* Start */} + {permissionsGranted && !isPreparing && !isRecording && ( + + )} + + {/* Progress circulaire */} + {permissionsGranted && (isRecording || isPreparing) && ( + + + + + + + )} + + {/* Permission prompt */} + {!permissionsGranted && ( + + { + try { + if (!cameraPermission?.granted) + await requestCameraPermission(); + } catch {} + }} + /> + + )} + + + {/* Lyrics */} { }} > - {(isRecording || showProgress) && alignedWords?.length > 0 ? ( - + {isRecording && alignedWords.length > 0 ? ( + ) : ( { ); }; -// Progress ring SVG const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => { const r = size / 2 - strokeWidth / 2; const c = 2 * Math.PI * r; @@ -589,42 +954,3 @@ 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 ( - - {/* {prev ? ( - - {prev} - - ) : null} */} - - {curr} - - {next ? ( - - {next} - - ) : null} - - ); -}; diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index cfeed6e..a45988c 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -15,7 +15,7 @@ import { gutters } from "../../styles"; const RecordedPlayback = ({ route }) => { const { videoUri, project } = route.params || {}; const songUrl = project?.songUrl || null; - + console.log("video uri is : ", videoUri); const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); const videoPlayer = useVideoPlayer(videoUri || null, (p) => { p.loop = false; diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js new file mode 100644 index 0000000..521043d --- /dev/null +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -0,0 +1,235 @@ +import { useAudioPlayer } from "expo-audio"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import { background } from "../../assets"; +import BorderGradientButton from "../../components/BorderGradientButton"; +import GradientButton from "../../components/GradientButton"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import Slider from "../../components/Slider"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +import { gutters, Palette } from "../../styles"; + +const fmtSeconds = (s) => { + const total = Math.max(0, Math.floor(Number(s || 0))); + const m = Math.floor(total / 60).toString(); + const sec = (total % 60).toString().padStart(2, "0"); + return `${m}:${sec}`; +}; + +const toSeconds = (value) => { + const n = Number(value ?? 0); + if (!Number.isFinite(n) || n < 0) return 0; + return n > 10000 ? n / 1000 : n; // heuristique ms → s +}; + +const RecordedPlayback = ({ route }) => { + const { videoUri, project } = route.params || {}; + const songUrl = project?.songUrl || null; + console.log("video uri is : ", videoUri); + // AUDIO PLAYER (expo-audio → seconds) + const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + + // Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source + const videoElRef = useRef(null); + + const [progress, setProgress] = useState({ + posS: 0, // secondes + durS: 0, // secondes + playing: false, + }); + + // Démarrage / arrêt + useEffect(() => { + let mounted = true; + const start = async () => { + try { + if (audioPlayer && songUrl) { + await audioPlayer.play?.(); + } + if (videoElRef.current && videoUri) { + // Lecture vidéo HTML5 (muet pour éviter les policies) + videoElRef.current.muted = true; + videoElRef.current.play().catch(() => {}); + } + } catch {} + }; + start(); + + return () => { + mounted = false; + try { + if (audioPlayer?.playing) audioPlayer.pause?.(); + } catch {} + try { + if (videoElRef.current) { + videoElRef.current.pause(); + } + } catch {} + }; + }, [audioPlayer, songUrl, videoUri]); + + // Boucle de progression + éventuelle sync de la vidéo si fournie + useEffect(() => { + const id = setInterval(() => { + try { + const durS = toSeconds(audioPlayer?.duration); // secondes + const posS = toSeconds(audioPlayer?.currentTime); // secondes + + setProgress({ + posS, + durS, + playing: !!audioPlayer?.playing, + }); + + // Sync vidéo si on a une source vidéo + if ( + videoElRef.current && + videoUri && + !Number.isNaN(videoElRef.current.currentTime) + ) { + const v = Number(videoElRef.current.currentTime || 0); + const drift = Math.abs(v - posS); + if (drift > 0.35) { + videoElRef.current.currentTime = Math.max(0, posS); + } + } + } catch {} + }, 250); + return () => clearInterval(id); + }, [audioPlayer, videoUri]); + + // Slider: ratio 0..1 + const sliderProgress = useMemo(() => { + return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0; + }, [progress]); + + const onSeek = async (ratio) => { + try { + const durS = Number(progress.durS || 0); + const target = durS * ratio; // secondes + if (audioPlayer && durS > 0) { + await audioPlayer.seekTo?.(Math.floor(target)); // seek en secondes + } + if (videoElRef.current && videoUri) { + videoElRef.current.currentTime = Math.max(0, target); + } + } catch {} + }; + + const wasPlayingRef = useRef(false); + const onSeekStart = async () => { + try { + wasPlayingRef.current = !!audioPlayer?.playing; + if (audioPlayer?.playing) await audioPlayer.pause?.(); + if (videoElRef.current && !videoElRef.current.paused) { + videoElRef.current.pause(); + } + } catch {} + }; + const onSeekEnd = async () => { + try { + if (wasPlayingRef.current) { + if (audioPlayer) await audioPlayer.play?.(); + if (videoElRef.current && videoUri) + videoElRef.current.play().catch(() => {}); + } + } catch {} + }; + + return ( + + + + + {/* Bloc vidéo optionnel si jamais tu as un videoUri sur web */} + {videoUri ? ( + + + ) : ( + // Placeholder quand pas de vidéo sur web + + + Aperçu vidéo non disponible sur le web + + + )} + + + + + + { + navigate(Routes.DownloadSongs, { + action: "playback", + uri: videoUri || null, // peut être null sur web + project, + }); + }} + /> + { + try { + if (audioPlayer?.playing) audioPlayer.pause?.(); + } catch {} + try { + if (videoElRef.current && !videoElRef.current.paused) + videoElRef.current.pause(); + } catch {} + navigate(Routes.RecordPlayback, { project }); + }} + /> + + + + ); +}; + +export default RecordedPlayback; diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/DownloadSongs.js index fcf103f..a519da1 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/DownloadSongs.js @@ -17,11 +17,11 @@ import { uploadFileToFirebase } from "../../helpers/uploadToFirebase"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; +import { useUserData } from "../../providers/UserDataProvider"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { size } from "../../styles/Style"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; -import { useUserData } from "../../providers/UserDataProvider"; const DownloadSongs = ({ route }) => { const { currentUID } = useUserData(); @@ -50,11 +50,11 @@ const DownloadSongs = ({ route }) => { playbackUrl: resultURI, updatedAt: serverTimestamp(), }, - { merge: true }, + { merge: true } ); setTooltip({ type: "success", - text: "Vidéo uploadée, conversion HLS en cours…", + text: "Vidéo uploadée", }); } else { setTooltip({