record playback

This commit is contained in:
2025-10-01 17:10:40 +02:00
parent 1fd63c254d
commit f78dc64ec9
4 changed files with 714 additions and 72 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ import ChooseDecor from "../screens/Playback/ChooseDecor";
import CreatingDecor from "../screens/Playback/CreatingDecor"; import CreatingDecor from "../screens/Playback/CreatingDecor";
import Playback from "../screens/Playback/Playback"; import Playback from "../screens/Playback/Playback";
import PlaybackOnboarding from "../screens/Playback/PlaybackOnboarding"; 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 RecordedPlayback from "../screens/Playback/RecordedPlayback";
import VideoFinalize from "../screens/Playback/VideoFinalize"; import VideoFinalize from "../screens/Playback/VideoFinalize";
import PrivacyPolicy from "../screens/PrivacyPolicy"; import PrivacyPolicy from "../screens/PrivacyPolicy";
+79 -67
View File
@@ -1,25 +1,25 @@
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from "@react-navigation/native";
import { useAudioPlayer } from 'expo-audio'; import { useAudioPlayer } from "expo-audio";
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from "expo-camera";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from "react";
import { Alert, Text, View } from 'react-native'; import { Alert, Text, View } from "react-native";
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle } from 'react-native-svg'; import Svg, { Circle } from "react-native-svg";
import GradientButton from '../../components/GradientButton'; import GradientButton from "../../components/GradientButton";
import KaraokeLyrics from '../../components/KaraokeLyrics'; import KaraokeLyrics from "../../components/KaraokeLyrics";
import MusicLandHeader from '../../components/MusicLandHeader'; import MusicLandHeader from "../../components/MusicLandHeader";
import { increment, projectsRef } from '../../config/firebase'; import { increment, projectsRef } from "../../config/firebase";
import { Routes } from '../../navigation'; import { Routes } from "../../navigation";
import { goBack, navigate } from '../../navigation/NavigationService'; import { goBack, navigate } from "../../navigation/NavigationService";
import { gutters, Palette } from '../../styles'; import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from '../../styles/Fonts'; import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
const TIME_BEFORE_INCREMENT_MS = 20000; // 20s const TIME_BEFORE_INCREMENT_MS = 20000; // 20s
@@ -65,7 +65,7 @@ const RecordPlayback = ({ route }) => {
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []; const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
return arr.map((w) => ({ return arr.map((w) => ({
word: String(w?.word ?? ''), word: String(w?.word ?? ""),
startS: Number(w?.startS ?? 0), startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0), endS: Number(w?.endS ?? 0),
})); }));
@@ -77,16 +77,16 @@ const RecordPlayback = ({ route }) => {
let buf = []; let buf = [];
let start = null; let start = null;
const clean = (txt) => const clean = (txt) =>
String(txt || '') String(txt || "")
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.trim(); .trim();
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()); const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim());
const isSectionTag = (txt) => const isSectionTag = (txt) =>
/^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim()); /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim());
for (let i = 0; i < alignedWords.length; i++) { for (let i = 0; i < alignedWords.length; i++) {
const w = alignedWords[i]; const w = alignedWords[i];
const original = String(w.word || ''); const original = String(w.word || "");
const textNoNewline = original.replace(/\n/g, ' '); const textNoNewline = original.replace(/\n/g, " ");
const gapToNext = const gapToNext =
i < alignedWords.length - 1 i < alignedWords.length - 1
? Math.max(0, alignedWords[i + 1].startS - w.endS) ? Math.max(0, alignedWords[i + 1].startS - w.endS)
@@ -113,7 +113,7 @@ const RecordPlayback = ({ route }) => {
const isLast = i === alignedWords.length - 1; const isLast = i === alignedWords.length - 1;
if (eolByNewline || eolByPause || eolByPunct || isLast) { if (eolByNewline || eolByPause || eolByPunct || isLast) {
const joined = clean(buf.join(' ')); const joined = clean(buf.join(" "));
if (joined) if (joined)
out.push({ text: joined, startS: start ?? w.startS, endS: w.endS }); out.push({ text: joined, startS: start ?? w.startS, endS: w.endS });
buf = []; buf = [];
@@ -343,7 +343,7 @@ const RecordPlayback = ({ route }) => {
navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); navigate(Routes.RecordedPlayback, { videoUri: video.uri, project });
else navigate(Routes.RecordedPlayback, { project }); else navigate(Routes.RecordedPlayback, { project });
} catch (e) { } catch (e) {
console.log('RecordPlayback error:', e); console.log("RecordPlayback error:", e);
setIsRecording(false); setIsRecording(false);
setIsPreparing(false); setIsPreparing(false);
setShowProgress(false); setShowProgress(false);
@@ -356,14 +356,14 @@ const RecordPlayback = ({ route }) => {
checkSongEndRef.current = null; checkSongEndRef.current = null;
} }
// Inform the user when using a simulator where recording isn't supported // 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)) { if (/not supported on the simulator/i.test(msg)) {
Alert.alert( Alert.alert(
'Indisponible sur simulateur', "Indisponible sur simulateur",
'Lenregistrement vidéo nest pas disponible sur le simulateur. Merci dutiliser un appareil réel.', "Lenregistrement vidéo nest pas disponible sur le simulateur. Merci dutiliser un appareil réel.",
[ [
{ {
text: 'OK', text: "OK",
onPress: () => { onPress: () => {
try { try {
goBack(); goBack();
@@ -384,15 +384,17 @@ const RecordPlayback = ({ route }) => {
<CameraView <CameraView
ref={cameraRef} ref={cameraRef}
style={{ flex: 1 }} style={{ flex: 1 }}
facing='front' facing="front"
mode='video'> mode="video"
>
<View <View
style={{ style={{
paddingHorizontal: gutters, paddingHorizontal: gutters,
paddingTop: top, paddingTop: top,
flex: 1, flex: 1,
paddingBottom: gutters * 2, paddingBottom: gutters * 2,
}}> }}
>
<MusicLandHeader progress={9} onPressBack={goBack} /> <MusicLandHeader progress={9} onPressBack={goBack} />
<View style={{ flex: 1, marginTop: 11 }}> <View style={{ flex: 1, marginTop: 11 }}>
@@ -400,20 +402,22 @@ const RecordPlayback = ({ route }) => {
{isPreparing && countdown >= 1 && !showProgress && ( {isPreparing && countdown >= 1 && !showProgress && (
<View <View
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}}> }}
>
<Text <Text
style={{ style={{
fontSize: 72, fontSize: 72,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueBold, fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}> }}
>
{countdown} {countdown}
</Text> </Text>
</View> </View>
@@ -423,14 +427,15 @@ const RecordPlayback = ({ route }) => {
{!permissionsGranted && ( {!permissionsGranted && (
<View <View
style={{ style={{
position: 'absolute', position: "absolute",
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
padding: gutters, padding: gutters,
}}> }}
>
<GradientButton <GradientButton
title='Autoriser la caméra' title="Autoriser la caméra"
onPress={async () => { onPress={async () => {
try { try {
if (!cameraPermission?.granted) if (!cameraPermission?.granted)
@@ -445,8 +450,8 @@ const RecordPlayback = ({ route }) => {
{/* Start button */} {/* Start button */}
{permissionsGranted && !isPreparing && !isRecording && ( {permissionsGranted && !isPreparing && !isRecording && (
<GradientButton <GradientButton
title='Lancer ma musique' title="Lancer ma musique"
containerStyle={{ width: '80%', alignSelf: 'center' }} containerStyle={{ width: "80%", alignSelf: "center" }}
disabled={!songUrl} disabled={!songUrl}
onPress={startCountdownThenRecord} onPress={startCountdownThenRecord}
/> />
@@ -456,21 +461,23 @@ const RecordPlayback = ({ route }) => {
{permissionsGranted && (isRecording || showProgress) && ( {permissionsGranted && (isRecording || showProgress) && (
<View <View
style={{ style={{
position: 'absolute', position: "absolute",
left: 0, left: 0,
right: 0, right: 0,
bottom: gutters, bottom: gutters,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}}> }}
>
<View <View
style={{ style={{
width: 100, width: 100,
height: 100, height: 100,
borderRadius: 105, borderRadius: 105,
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}}> }}
>
<ProgressRing <ProgressRing
size={100} size={100}
strokeWidth={8} strokeWidth={8}
@@ -482,7 +489,7 @@ const RecordPlayback = ({ route }) => {
/> />
<View <View
style={{ style={{
position: 'absolute', position: "absolute",
width: 50, width: 50,
height: 50, height: 50,
borderRadius: 55, borderRadius: 55,
@@ -496,14 +503,15 @@ const RecordPlayback = ({ route }) => {
</CameraView> </CameraView>
{/* CreateLyricsHeader overlay outside of CameraView */} {/* CreateLyricsHeader overlay outside of CameraView */}
<View <View
pointerEvents='box-none' pointerEvents="box-none"
style={{ style={{
position: 'absolute', position: "absolute",
left: 0, left: 0,
right: 0, right: 0,
bottom: bottom + 130, bottom: bottom + 130,
paddingHorizontal: gutters, paddingHorizontal: gutters,
}}> }}
>
<CreateLyricsHeader> <CreateLyricsHeader>
{(isRecording || showProgress) && alignedWords?.length > 0 ? ( {(isRecording || showProgress) && alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics
@@ -516,7 +524,8 @@ const RecordPlayback = ({ route }) => {
fontSize: 16, fontSize: 16,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
}}> }}
>
L'enregistrement de ta vidéo commencera lorsque tu lanceras ta L'enregistrement de ta vidéo commencera lorsque tu lanceras ta
musique, et s'arrêtera à la fin du morceau. musique, et s'arrêtera à la fin du morceau.
</Text> </Text>
@@ -538,20 +547,21 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
width={size} width={size}
height={size} height={size}
style={{ style={{
transform: [{ rotate: '-90deg' }], transform: [{ rotate: "-90deg" }],
backgroundColor: '#ffffff3d', backgroundColor: "#ffffff3d",
borderRadius: 55, borderRadius: 55,
}}> }}
>
<Circle <Circle
cx={size / 2} cx={size / 2}
cy={size / 2} cy={size / 2}
r={r} r={r}
stroke={Palette.white} stroke={Palette.white}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap='round' strokeLinecap="round"
strokeDasharray={`${c} ${c}`} strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset} strokeDashoffset={offset}
fill='transparent' fill="transparent"
/> />
</Svg> </Svg>
); );
@@ -561,10 +571,10 @@ export default RecordPlayback;
// Render previous/current/next line to reduce jumpiness // Render previous/current/next line to reduce jumpiness
const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => { const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ''; const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : ''; const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : "";
const next = const next =
currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''; currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : "";
return ( return (
<View style={{ gap: 4 }}> <View style={{ gap: 4 }}>
{/* {prev ? ( {/* {prev ? (
@@ -576,19 +586,21 @@ const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
style={{ style={{
color: Palette.white, color: Palette.white,
fontSize: 18, fontSize: 18,
textAlign: 'center', textAlign: "center",
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}}> }}
>
{curr} {curr}
</Text> </Text>
{next ? ( {next ? (
<Text <Text
style={{ style={{
color: '#FFFFFF99', color: "#FFFFFF99",
fontSize: 14, fontSize: 14,
textAlign: 'center', textAlign: "center",
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
}}> }}
>
{next} {next}
</Text> </Text>
) : null} ) : null}
+630
View File
@@ -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",
"Lenregistrement vidéo nest pas disponible sur le simulateur. Merci dutiliser un appareil réel.",
[
{
text: "OK",
onPress: () => {
try {
goBack();
} catch (_) {}
},
},
],
{ cancelable: false }
);
}
}
};
const permissionsGranted = !!cameraPermission?.granted;
return (
<Page
style={{ flex: 1 }}
width={"100%"}
maxWidth={2000}
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
headerType="NONE"
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<View
style={{
// ✅ cadre strict 9:16 sur web (et mobile si tu veux)
width: isWeb ? 420 : "100%",
aspectRatio: 9 / 16,
overflow: "hidden",
borderRadius: 12,
alignSelf: "center",
}}
>
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing="front"
mode="video"
>
<View
style={{
paddingHorizontal: gutters,
paddingTop: top,
flex: 1,
paddingBottom: gutters * 2,
}}
>
<View style={{ flex: 1, marginTop: 11 }}>
{/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
{isPreparing && countdown >= 1 && !showProgress && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 72,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}
>
{countdown}
</Text>
</View>
)}
{/* Permission prompt */}
{!permissionsGranted && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: gutters,
}}
>
<GradientButton
title="Autoriser la caméra"
onPress={async () => {
try {
if (!cameraPermission?.granted)
await requestCameraPermission();
} catch (_) {}
}}
/>
</View>
)}
</View>
{/* Start button */}
{permissionsGranted && !isPreparing && !isRecording && (
<GradientButton
title="Lancer ma musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
disabled={!songUrl}
onPress={startCountdownThenRecord}
/>
)}
{/* Progress circulaire */}
{permissionsGranted && (isRecording || showProgress) && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: 100,
height: 100,
borderRadius: 105,
alignItems: "center",
justifyContent: "center",
}}
>
<ProgressRing
size={100}
strokeWidth={8}
progress={
progressInfo.dur
? Math.min(
1,
(progressInfo.pos || 0) / progressInfo.dur
)
: 0
}
/>
<View
style={{
position: "absolute",
width: 50,
height: 50,
borderRadius: 55,
backgroundColor: Palette.white,
}}
/>
</View>
</View>
)}
</View>
</CameraView>
{/* CreateLyricsHeader overlay outside of CameraView */}
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
right: 0,
bottom: bottom + 130,
paddingHorizontal: gutters,
}}
>
<CreateLyricsHeader>
{(isRecording || showProgress) && alignedWords?.length > 0 ? (
<KaraokeLyrics
alignedWords={alignedWords}
currentTimeS={(progressInfo?.pos || 0) / 1000}
/>
) : (
<Text
style={{
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.
</Text>
)}
</CreateLyricsHeader>
</View>
</View>
</Page>
);
};
// 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 (
<Svg
width={size}
height={size}
style={{
transform: [{ rotate: "-90deg" }],
backgroundColor: "#ffffff3d",
borderRadius: 55,
}}
>
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={Palette.white}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset}
fill="transparent"
/>
</Svg>
);
};
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>
);
};
+4 -4
View File
@@ -35,10 +35,10 @@ const SongReady = () => {
1: { pos: 0, dur: 0 }, 1: { pos: 0, dur: 0 },
}); });
const player0 = useAudioPlayer( const player0 = useAudioPlayer(
musicUrls[0] ? { uri: musicUrls[0] } : undefined, musicUrls[0] ? { uri: musicUrls[0] } : undefined
); );
const player1 = useAudioPlayer( const player1 = useAudioPlayer(
musicUrls[1] ? { uri: musicUrls[1] } : undefined, musicUrls[1] ? { uri: musicUrls[1] } : undefined
); );
const wasPlayingBeforeSeek = useRef({ 0: false, 1: false }); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });
@@ -146,7 +146,7 @@ const SongReady = () => {
player1?.pause?.(); player1?.pause?.();
} catch {} } catch {}
}; };
}, [player0, player1]), }, [player0, player1])
); );
useEffect(() => { useEffect(() => {
@@ -230,7 +230,7 @@ const SongReady = () => {
} catch (e) { } catch (e) {
console.log( console.log(
"SongReady pause on seek start", "SongReady pause on seek start",
e?.message, e?.message
); );
} }
}} }}