From f78dc64ec98c12f3fe9ee7274349dc0d76e16203 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Wed, 1 Oct 2025 17:10:40 +0200 Subject: [PATCH] record playback --- src/navigation/MainStack.js | 2 +- src/screens/Playback/RecordPlayback.js | 146 ++--- src/screens/Playback/RecordPlayback.web.js | 630 +++++++++++++++++++++ src/screens/Studio/SongReady.js | 8 +- 4 files changed, 714 insertions(+), 72 deletions(-) create mode 100644 src/screens/Playback/RecordPlayback.web.js diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js index 17a2aa2..6ab9e40 100644 --- a/src/navigation/MainStack.js +++ b/src/navigation/MainStack.js @@ -16,7 +16,7 @@ import ChooseDecor from "../screens/Playback/ChooseDecor"; import CreatingDecor from "../screens/Playback/CreatingDecor"; import Playback from "../screens/Playback/Playback"; import PlaybackOnboarding from "../screens/Playback/PlaybackOnboarding"; -import RecordPlayback from "../screens/Playback/RecordPlayback"; +import RecordPlayback from "../screens/Playback/RecordPlayback.web"; import RecordedPlayback from "../screens/Playback/RecordedPlayback"; import VideoFinalize from "../screens/Playback/VideoFinalize"; import PrivacyPolicy from "../screens/PrivacyPolicy"; diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 1100b26..2af04ac 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -1,25 +1,25 @@ -import { useFocusEffect } from '@react-navigation/native'; -import { useAudioPlayer } from 'expo-audio'; -import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useFocusEffect } from "@react-navigation/native"; +import { useAudioPlayer } from "expo-audio"; +import { CameraView, useCameraPermissions } from "expo-camera"; import React, { useCallback, useEffect, useMemo, useRef, useState, -} from 'react'; -import { Alert, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Svg, { Circle } from 'react-native-svg'; -import GradientButton from '../../components/GradientButton'; -import KaraokeLyrics from '../../components/KaraokeLyrics'; -import MusicLandHeader from '../../components/MusicLandHeader'; -import { increment, projectsRef } from '../../config/firebase'; -import { Routes } from '../../navigation'; -import { goBack, navigate } from '../../navigation/NavigationService'; -import { gutters, Palette } from '../../styles'; -import { FONT_FAMILY } from '../../styles/Fonts'; -import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'; +} from "react"; +import { Alert, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Svg, { Circle } from "react-native-svg"; +import GradientButton from "../../components/GradientButton"; +import KaraokeLyrics from "../../components/KaraokeLyrics"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import { increment, projectsRef } from "../../config/firebase"; +import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +import { gutters, Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const TIME_BEFORE_INCREMENT_MS = 20000; // 20s @@ -65,7 +65,7 @@ const RecordPlayback = ({ route }) => { const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []; return arr.map((w) => ({ - word: String(w?.word ?? ''), + word: String(w?.word ?? ""), startS: Number(w?.startS ?? 0), endS: Number(w?.endS ?? 0), })); @@ -77,16 +77,16 @@ const RecordPlayback = ({ route }) => { let buf = []; let start = null; const clean = (txt) => - String(txt || '') - .replace(/\s+/g, ' ') + String(txt || "") + .replace(/\s+/g, " ") .trim(); - const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()); + const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim()); const isSectionTag = (txt) => - /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim()); + /^\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 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) @@ -113,7 +113,7 @@ const RecordPlayback = ({ route }) => { const isLast = i === alignedWords.length - 1; if (eolByNewline || eolByPause || eolByPunct || isLast) { - const joined = clean(buf.join(' ')); + const joined = clean(buf.join(" ")); if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS }); buf = []; @@ -343,7 +343,7 @@ const RecordPlayback = ({ route }) => { navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); else navigate(Routes.RecordedPlayback, { project }); } catch (e) { - console.log('RecordPlayback error:', e); + console.log("RecordPlayback error:", e); setIsRecording(false); setIsPreparing(false); setShowProgress(false); @@ -356,14 +356,14 @@ const RecordPlayback = ({ route }) => { checkSongEndRef.current = null; } // Inform the user when using a simulator where recording isn't supported - const msg = String(e?.message || e || ''); + const msg = String(e?.message || e || ""); if (/not supported on the simulator/i.test(msg)) { Alert.alert( - 'Indisponible sur simulateur', - 'L’enregistrement vidéo n’est pas disponible sur le simulateur. Merci d’utiliser un appareil réel.', + "Indisponible sur simulateur", + "L’enregistrement vidéo n’est pas disponible sur le simulateur. Merci d’utiliser un appareil réel.", [ { - text: 'OK', + text: "OK", onPress: () => { try { goBack(); @@ -384,15 +384,17 @@ const RecordPlayback = ({ route }) => { + facing="front" + mode="video" + > + }} + > @@ -400,20 +402,22 @@ const RecordPlayback = ({ route }) => { {isPreparing && countdown >= 1 && !showProgress && ( + alignItems: "center", + justifyContent: "center", + }} + > + }} + > {countdown} @@ -423,14 +427,15 @@ const RecordPlayback = ({ route }) => { {!permissionsGranted && ( + }} + > { try { if (!cameraPermission?.granted) @@ -445,8 +450,8 @@ const RecordPlayback = ({ route }) => { {/* Start button */} {permissionsGranted && !isPreparing && !isRecording && ( @@ -456,21 +461,23 @@ const RecordPlayback = ({ route }) => { {permissionsGranted && (isRecording || showProgress) && ( + alignItems: "center", + justifyContent: "center", + }} + > + alignItems: "center", + justifyContent: "center", + }} + > { /> { {/* CreateLyricsHeader overlay outside of CameraView */} + }} + > {(isRecording || showProgress) && alignedWords?.length > 0 ? ( { fontSize: 16, color: Palette.white, fontFamily: FONT_FAMILY.InterMedium, - }}> + }} + > L'enregistrement de ta vidéo commencera lorsque tu lanceras ta musique, et s'arrêtera à la fin du morceau. @@ -538,20 +547,21 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => { width={size} height={size} style={{ - transform: [{ rotate: '-90deg' }], - backgroundColor: '#ffffff3d', + transform: [{ rotate: "-90deg" }], + backgroundColor: "#ffffff3d", borderRadius: 55, - }}> + }} + > ); @@ -561,10 +571,10 @@ 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 prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ""; + const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : ""; const next = - currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''; + currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ""; return ( {/* {prev ? ( @@ -576,19 +586,21 @@ const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => { style={{ color: Palette.white, fontSize: 18, - textAlign: 'center', + textAlign: "center", fontFamily: FONT_FAMILY.InterSemiBold, - }}> + }} + > {curr} {next ? ( + }} + > {next} ) : null} diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js new file mode 100644 index 0000000..8e2ea22 --- /dev/null +++ b/src/screens/Playback/RecordPlayback.web.js @@ -0,0 +1,630 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { useAudioPlayer } from "expo-audio"; +import { CameraView, useCameraPermissions } from "expo-camera"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Alert, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Svg, { Circle } from "react-native-svg"; +import GradientButton from "../../components/GradientButton"; +import KaraokeLyrics from "../../components/KaraokeLyrics"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import { increment, projectsRef } from "../../config/firebase"; +import { isWeb } from "../../hooks/useLayoutType"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +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 RecordPlayback = ({ route }) => { + const { top, bottom } = useSafeAreaInsets(); + const { project } = route.params || {}; + const projectId = project?.id; + const songIndex = project?.songIndex; + // Permissions + 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 + 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]); + + const alignedWords = useMemo(() => { + const ts = project?.musicTimestamps?.[musicIndex]; + + 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]); + + // 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]); + // console.log("alignedWords:", alignedWords); + useEffect(() => { + listenedMsRef.current = 0; + incrementDoneRef.current = false; + }, [songUrl]); + + // Poll player -> progress ring + useEffect(() => { + if (!player) return; + const id = setInterval(() => { + 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; + } + 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 + + // 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; + } + + startedRef.current = false; + stopRequestedRef.current = false; + countdownActiveRef.current = false; + listenedMsRef.current = 0; + incrementDoneRef.current = false; + + setIsPreparing(false); + setIsRecording(false); + setShowProgress(false); + setCountdown(0); + + if (player) { + try { + if (player.playing) await player.pause?.(); + await player.seekTo?.(0); + } catch (_) {} + } + } catch (_) {} + }, [player]); + + useFocusEffect( + useCallback(() => { + void resetSession(); + return () => {}; + }, [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; + } + try { + if (player?.playing) await player.pause?.(); + } catch (_) {} + if (listenTimerRef.current) { + clearInterval(listenTimerRef.current); + listenTimerRef.current = null; + } + + setIsRecording(false); + setShowProgress(false); + + if (video?.uri) + navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); + else navigate(Routes.RecordedPlayback, { project }); + } catch (e) { + console.log("RecordPlayback error:", 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( + "Indisponible sur simulateur", + "L’enregistrement vidéo n’est pas disponible sur le simulateur. Merci d’utiliser un appareil réel.", + [ + { + text: "OK", + onPress: () => { + try { + goBack(); + } catch (_) {} + }, + }, + ], + { cancelable: false } + ); + } + } + }; + + const permissionsGranted = !!cameraPermission?.granted; + + return ( + + + + + + + {/* 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) && ( + + + + + + + )} + + + {/* CreateLyricsHeader overlay outside of CameraView */} + + + {(isRecording || showProgress) && alignedWords?.length > 0 ? ( + + ) : ( + + L'enregistrement de ta vidéo commencera lorsque tu lanceras ta + musique, et s'arrêtera à la fin du morceau. + + )} + + + + + ); +}; + +// Progress ring SVG +const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => { + const r = size / 2 - strokeWidth / 2; + const c = 2 * Math.PI * r; + const clamped = Math.max(0, Math.min(1, progress || 0)); + const offset = c * (1 - clamped); + return ( + + + + ); +}; + +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/Studio/SongReady.js b/src/screens/Studio/SongReady.js index fe9068c..5569a1e 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -35,10 +35,10 @@ const SongReady = () => { 1: { pos: 0, dur: 0 }, }); const player0 = useAudioPlayer( - musicUrls[0] ? { uri: musicUrls[0] } : undefined, + musicUrls[0] ? { uri: musicUrls[0] } : undefined ); const player1 = useAudioPlayer( - musicUrls[1] ? { uri: musicUrls[1] } : undefined, + musicUrls[1] ? { uri: musicUrls[1] } : undefined ); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false }); @@ -146,7 +146,7 @@ const SongReady = () => { player1?.pause?.(); } catch {} }; - }, [player0, player1]), + }, [player0, player1]) ); useEffect(() => { @@ -230,7 +230,7 @@ const SongReady = () => { } catch (e) { console.log( "SongReady pause on seek start", - e?.message, + e?.message ); } }}