Files
musicland/src/screens/Playback/RecordPlayback.js
T
2025-11-17 10:47:36 +01:00

1034 lines
32 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 { CameraView, useCameraPermissions } from "expo-camera";
import * as FileSystem from "expo-file-system";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle, Path, G, Defs, Rect, ClipPath } from "react-native-svg";
import alert from "../../components/Alert";
import GradientButton from "../../components/GradientButton";
import KaraokeLyrics from "../../components/KaraokeLyrics";
import MusicLandHeader from "../../components/MusicLandHeader";
import { increment, projectsRef } from "../../config/firebase";
import usePlayer from "../../hooks/usePlayer";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
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 LOG_PREFIX = "[RecordPlayback]";
const log =
typeof __DEV__ === "undefined" || __DEV__
? (...args) => console.log(LOG_PREFIX, ...args)
: () => {};
const CAMERA_FACING_OPTIONS = [
{ label: "Avant", value: "front" },
{ label: "Arrière", value: "back" },
];
const RecordPlayback = ({ route }) => {
const { top, bottom } = useSafeAreaInsets();
const { project } = route.params || {};
log("Route params received", { hasProject: !!project });
const projectId = project?.id;
const songIndex = project?.songIndex;
const { setLooping, isLooping } = usePlayer() || {};
// 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 restartRequestedRef = useRef(false);
const startedRef = useRef(false); // empêche les doubles démarrages
const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick
const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement
const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 });
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
const latestLoopingValueRef = useRef(isLooping ?? false);
// 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 });
const [cameraFacing, setCameraFacing] = useState("front");
// Musique
const songUrl = project?.songUrl || null;
const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: projectId
? `record-${projectId}`
: songUrl
? `record-${songUrl}`
: undefined,
title: typeof project?.title === "string" ? project.title : "Sans titre",
artist:
typeof project?.userName === "string" ? project.userName : "MusicLand",
artwork: project?.coverUrl || null,
coverUrl: project?.coverUrl || null,
metadata: { projectId, screen: "RecordPlayback" },
});
useEffect(() => {
log("Screen params", { projectId, songUrl, songIndex });
}, [projectId, songUrl, songIndex]);
useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false;
}, [isLooping]);
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]);
useEffect(() => {
listenedMsRef.current = 0;
incrementDoneRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
playbackStartedRef.current = false;
log("Song URL changed, reset counters", { songUrl });
}, [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;
if (!playbackStartedRef.current && (player?.playing || pos > 0)) {
playbackStartedRef.current = true;
log("Playback detected", { pos, dur });
}
setProgressInfo((prev) => {
if (prev.pos === pos && prev.dur === dur) return prev;
return { pos, dur };
});
if (dur <= 0) {
if (progressLogRef.current.lastDur !== 0) {
progressLogRef.current = { bucket: -1, lastPos: pos, lastDur: 0 };
log("Progress waiting for duration", {
pos,
playing: player?.playing,
});
} else {
progressLogRef.current.lastPos = pos;
}
return;
}
const pct = Math.max(0, Math.min(1, pos / dur));
const bucket = Math.floor(pct * 10);
const { bucket: prevBucket } = progressLogRef.current;
if (bucket !== prevBucket) {
log("Progress update", {
bucket,
pct: Number.isFinite(pct) ? Number(pct.toFixed(2)) : pct,
pos,
dur,
playing: player?.playing,
});
}
progressLogRef.current = { bucket, lastPos: pos, lastDur: dur };
} catch (_) {}
}, 250);
return () => clearInterval(id);
}, [player]);
// Compute current line index from playback time
const currentTimeS = (progressInfo?.pos || 0) / 1000;
const currentLineIdx = useMemo(() => {
if (!lines || lines.length === 0) return -1;
for (let i = 0; i < lines.length; i++) {
const L = lines[i];
if (currentTimeS >= L.startS && currentTimeS <= L.endS) return i;
}
if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
return lines.length - 1;
return -1;
}, [lines, currentTimeS]);
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
// Permissions au mount + cleanup
useEffect(() => {
(async () => {
try {
if (!cameraPermission?.granted) {
log("Requesting camera permission on mount");
await requestCameraPermission();
} else {
log("Camera permission already granted on mount");
}
} 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;
log("Cleanup on unmount, cleared timers");
} 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;
log("listenTimerRef cleared");
}
if (checkSongEndRef.current) {
clearInterval(checkSongEndRef.current);
checkSongEndRef.current = null;
log("checkSongEndRef cleared");
}
startedRef.current = false;
stopRequestedRef.current = false;
countdownActiveRef.current = false;
listenedMsRef.current = 0;
incrementDoneRef.current = false;
playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
log("Session reset");
setIsPreparing(false);
setIsRecording(false);
setShowProgress(false);
setCountdown(0);
if (player) {
try {
if (player.playing) await player.pause?.();
await player.seekTo?.(0);
} catch (_) {}
}
} catch (_) {}
}, [player]);
const resetSessionRef = useRef(resetSession);
useEffect(() => {
resetSessionRef.current = resetSession;
}, [resetSession]);
useFocusEffect(
useCallback(() => {
log("Screen focused, resetting session");
void resetSessionRef.current?.();
return () => {
log("Screen blurred, stopping playback");
void resetSessionRef.current?.();
};
}, [])
);
useFocusEffect(
useCallback(() => {
if (typeof setLooping === "function") {
originalLoopingValueRef.current = {
hasValue: true,
value: latestLoopingValueRef.current,
};
if (latestLoopingValueRef.current) {
setLooping(false);
}
}
return () => {
if (
typeof setLooping === "function" &&
originalLoopingValueRef.current?.hasValue
) {
setLooping(!!originalLoopingValueRef.current.value);
}
};
}, [setLooping])
);
// Lancer le compte à rebours (le tick décrémente uniquement)
const startCountdownThenRecord = async () => {
if (!songUrl) {
log("startCountdownThenRecord aborted: missing song URL");
return;
}
log("startCountdownThenRecord invoked", { projectId, songUrl });
await resetSession();
restartRequestedRef.current = false;
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;
log("Countdown timer armed");
};
// 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) {
log("Countdown complete, launching recording sequence");
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);
playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
log("startRecordingWithMusic", {
hasPlayer: !!player,
songUrl,
hasCamera: !!cameraRef.current,
});
const recordPromise = (() => {
const camera = cameraRef.current;
if (!camera) throw new Error("Caméra indisponible");
if (typeof camera.recordAsync === "function") {
log("Camera supports recordAsync");
return camera.recordAsync({ mute: true, maxDuration: 600 });
}
if (typeof camera.startRecording === "function") {
// Expo CameraView (SDK 52) expose startRecording au lieu de recordAsync
log("Camera using startRecording fallback");
return new Promise((resolve, reject) => {
let settled = false;
try {
camera.startRecording({
mute: true,
maxDuration: 600,
onRecordingFinished: (video) => {
settled = true;
log("Camera onRecordingFinished", { hasUri: !!video?.uri });
resolve(video);
},
onRecordingError: (error) => {
if (settled) return;
log("Camera onRecordingError", {
message: error?.message || String(error || ""),
});
const err =
error instanceof Error
? error
: new Error(String(error || "Recording error"));
reject(err);
},
});
} catch (error) {
log("Camera startRecording threw", {
message: error?.message || String(error || ""),
});
reject(
error instanceof Error
? error
: new Error(String(error || "Recording start failed"))
);
}
});
}
throw new Error(
"L'enregistrement vidéo n'est pas supporté sur cet appareil."
);
})();
if (player && songUrl) {
try {
log("Seeking player to start");
await player.seekTo?.(0);
} catch (error) {
log("player.seekTo failed", {
message: error?.message || String(error || ""),
});
}
try {
await player.play?.();
log("player.play invoked");
} catch (error) {
log("player.play failed", {
message: error?.message || String(error || ""),
});
}
}
// Incrément des vues
if (!listenTimerRef.current && project?.id) {
log("listenTimerRef armed", { projectId: 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;
log("Views increment threshold reached");
try {
await projectsRef
.doc(project.id)
.set({ views: increment(1) }, { merge: true });
log("Views incremented");
} catch (_) {}
}
}
} catch (_) {}
}, 500);
}
// Fin du morceau -> stop recording
if (!checkSongEndRef.current) {
log("Song end watcher armed");
checkSongEndRef.current = setInterval(() => {
try {
if (!player || stopRequestedRef.current) return;
const duration = (player?.duration || 0) * 1000;
const currentTime = (player?.currentTime || 0) * 1000;
if (
!playbackStartedRef.current &&
(player?.playing || currentTime > 0)
) {
playbackStartedRef.current = true;
}
const nearEnd = duration > 0 && currentTime >= duration - 600;
const playbackEnded =
playbackStartedRef.current &&
!player.playing &&
currentTime > 1200;
if (nearEnd || playbackEnded) {
log("Requesting stopRecording", {
reason: nearEnd ? "nearEnd" : "playbackEnded",
duration,
currentTime,
playing: player?.playing,
});
stopRequestedRef.current = true;
if (checkSongEndRef.current) {
clearInterval(checkSongEndRef.current);
checkSongEndRef.current = null;
}
try {
cameraRef.current?.stopRecording?.();
log("stopRecording triggered");
} catch (_) {}
}
} catch (_) {}
}, 500);
}
const video = await recordPromise;
log("Recording promise resolved", { hasVideo: !!video?.uri });
if (checkSongEndRef.current) {
clearInterval(checkSongEndRef.current);
checkSongEndRef.current = null;
log("checkSongEndRef cleared");
}
try {
if (player?.playing) await player.pause?.();
} catch (_) {}
if (listenTimerRef.current) {
clearInterval(listenTimerRef.current);
listenTimerRef.current = null;
log("listenTimerRef cleared");
}
setIsRecording(false);
setShowProgress(false);
log("Recording flow completed", { hasVideo: !!video?.uri });
playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
const shouldRestart = restartRequestedRef.current;
if (shouldRestart) {
restartRequestedRef.current = false;
}
if (video?.uri && shouldRestart) {
try {
const info = await FileSystem.getInfoAsync(video.uri);
if (info?.exists) {
await FileSystem.deleteAsync(video.uri, { idempotent: true });
log("Discarded interim recording file");
}
} catch (error) {
log("Failed to discard interim recording", {
message: error?.message || String(error || ""),
});
}
}
if (shouldRestart) {
log("Restart requested, relaunching countdown");
requestAnimationFrame(() => {
void startCountdownThenRecord();
});
return;
}
if (video?.uri) {
log("Navigating to RecordedPlayback with video", {
uriLength: video.uri.length,
});
navigate(Routes.RecordedPlayback, { videoUri: video.uri, project });
} else {
log("Navigating to RecordedPlayback without video", {
hasVideo: !!video,
});
navigate(Routes.RecordedPlayback, { project });
}
} catch (e) {
log("startRecordingWithMusic error", {
message: e?.message || String(e || ""),
});
console.log("RecordPlayback error:", e);
setIsRecording(false);
setIsPreparing(false);
setShowProgress(false);
if (listenTimerRef.current) {
clearInterval(listenTimerRef.current);
listenTimerRef.current = null;
log("listenTimerRef cleared (error path)");
}
if (checkSongEndRef.current) {
clearInterval(checkSongEndRef.current);
checkSongEndRef.current = null;
log("checkSongEndRef cleared (error path)");
}
playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
const shouldRestart = restartRequestedRef.current;
if (shouldRestart) {
restartRequestedRef.current = false;
log("Restart requested despite error, restarting flow");
requestAnimationFrame(() => {
void startCountdownThenRecord();
});
return;
}
// 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(
"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 handleRestartRecording = async () => {
try {
log("Restart button pressed", {
isPreparing,
isRecording,
});
if (isPreparing || !isRecording) {
restartRequestedRef.current = false;
await startCountdownThenRecord();
return;
}
restartRequestedRef.current = true;
stopRequestedRef.current = true;
try {
if (player?.playing) await player.pause?.();
} catch (_) {}
try {
cameraRef.current?.stopRecording?.();
} catch (_) {}
} catch (_) {}
};
const permissionsGranted = !!cameraPermission?.granted;
useEffect(() => {
log("Camera permission state updated", { granted: permissionsGranted });
}, [permissionsGranted]);
return (
<View style={{ flex: 1 }}>
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing={cameraFacing}
mode="video"
mute
>
<View
style={{
paddingHorizontal: gutters,
paddingTop: top,
flex: 1,
paddingBottom: gutters * 2,
}}
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<View style={{ marginTop: 12, alignItems: "flex-end" }}>
<CameraFacingSelector
value={cameraFacing}
onChange={setCameraFacing}
disabled={!permissionsGranted || isPreparing || isRecording}
/>
</View>
<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 + restart */}
{permissionsGranted &&
(isRecording || showProgress || isPreparing) && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: 120,
height: 120,
borderRadius: 120,
alignItems: "center",
justifyContent: "center",
}}
>
<ProgressRing
size={120}
strokeWidth={8}
progress={
progressInfo.dur
? Math.min(
1,
(progressInfo.pos || 0) / progressInfo.dur
)
: 0
}
/>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleRestartRecording}
style={{
position: "absolute",
width: 70,
height: 70,
borderRadius: 80,
alignItems: "center",
justifyContent: "center",
backgroundColor: Palette.white,
shadowColor: "#000000",
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
}}
>
<RestartSpinnerIcon />
</TouchableOpacity>
</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: size / 2,
}}
>
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={Palette.white}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset}
fill="transparent"
/>
</Svg>
);
};
const RestartSpinnerIcon = ({
size = 30,
color = "#F94697", // couleur unie
background = "transparent",
}) => {
return (
<View
style={{
width: size + 8,
height: size + 8,
borderRadius: size,
alignItems: "center",
justifyContent: "center",
backgroundColor: background,
}}
>
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<G clipPath="url(#clip0)">
{/* Cercle re-dessiné sans opacité */}
<Path
d="
M19.7285 10.9286
C20.4412 13.5975 19.7507 16.5633 17.6569 18.6571
C14.5327 21.7813 9.46734 21.7813 6.34315 18.6571
C3.21895 15.5329 3.21895 10.4676 6.34315 7.34338
C9.46734 4.21918 14.5327 4.21918 17.6569 7.34338
L18.364 8.05048
"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Flèche */}
<Path
d="M14.1214 8.05026H18.364V3.80762"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</G>
<Defs>
<ClipPath id="clip0">
<Rect width="24" height="24" fill="white" />
</ClipPath>
</Defs>
</Svg>
</View>
);
};
export default RecordPlayback;
// Render previous/current/next line to reduce jumpiness
const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : "";
const next =
currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : "";
return (
<View style={{ gap: 4 }}>
{/* {prev ? (
<Text style={{ color: "#FFFFFF99", fontSize: 14, textAlign: "center", fontFamily: FONT_FAMILY.InterMedium }}>
{prev}
</Text>
) : null} */}
<Text
style={{
color: Palette.white,
fontSize: 18,
textAlign: "center",
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
{curr}
</Text>
{next ? (
<Text
style={{
color: "#FFFFFF99",
fontSize: 14,
textAlign: "center",
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{next}
</Text>
) : null}
</View>
);
};
const CameraFacingSelector = ({
value = "front",
onChange,
disabled = false,
}) => {
return (
<View
style={{
flexDirection: "row",
backgroundColor: "rgba(0,0,0,0.35)",
borderRadius: 999,
padding: 4,
gap: 4,
opacity: disabled ? 0.6 : 1,
}}
pointerEvents={disabled ? "none" : "auto"}
>
{CAMERA_FACING_OPTIONS.map((option) => {
const isActive = option.value === value;
return (
<TouchableOpacity
key={option.value}
activeOpacity={0.8}
onPress={() => {
if (typeof onChange === "function") onChange(option.value);
}}
disabled={isActive}
style={{
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: isActive ? Palette.white : "transparent",
}}
>
<Text
style={{
color: isActive ? Palette.black : Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
}}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};