Files
musicland/src/screens/Playback/RecordPlayback.js
T
2025-09-09 11:57:21 +02:00

610 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
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 (
<View style={{ flex: 1 }}>
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing="front"
mode="video"
>
<View
style={{
paddingHorizontal: gutters,
paddingTop: top,
flex: 1,
paddingBottom: gutters * 2,
}}
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<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>
);
};
// 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>
);
};