feat: restart

This commit is contained in:
2025-11-17 10:47:36 +01:00
parent 705fda0ce3
commit e0309c24b9
4 changed files with 288 additions and 56 deletions
+171 -36
View File
@@ -1,5 +1,6 @@
import { useFocusEffect } from "@react-navigation/native";
import { CameraView, useCameraPermissions } from "expo-camera";
import * as FileSystem from "expo-file-system";
import React, {
useCallback,
useEffect,
@@ -9,7 +10,7 @@ import React, {
} from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle } from "react-native-svg";
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";
@@ -51,6 +52,7 @@ const RecordPlayback = ({ route }) => {
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
@@ -161,7 +163,7 @@ const RecordPlayback = ({ route }) => {
}
return out;
}, [alignedWords]);
// console.log("alignedWords:", alignedWords);
useEffect(() => {
listenedMsRef.current = 0;
incrementDoneRef.current = false;
@@ -228,6 +230,8 @@ const RecordPlayback = ({ route }) => {
return -1;
}, [lines, currentTimeS]);
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
// Permissions au mount + cleanup
useEffect(() => {
(async () => {
@@ -340,6 +344,7 @@ const RecordPlayback = ({ route }) => {
}
log("startCountdownThenRecord invoked", { projectId, songUrl });
await resetSession();
restartRequestedRef.current = false;
setIsPreparing(true);
setShowProgress(false);
setCountdown(5);
@@ -553,6 +558,32 @@ const RecordPlayback = ({ route }) => {
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", {
@@ -585,6 +616,15 @@ const RecordPlayback = ({ route }) => {
}
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)) {
@@ -607,6 +647,28 @@ const RecordPlayback = ({ route }) => {
}
};
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(() => {
@@ -700,48 +762,63 @@ const RecordPlayback = ({ route }) => {
/>
)}
{/* Progress circulaire */}
{permissionsGranted && (isRecording || showProgress) && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
alignItems: "center",
justifyContent: "center",
}}
>
{/* Progress circulaire + restart */}
{permissionsGranted &&
(isRecording || showProgress || isPreparing) && (
<View
style={{
width: 100,
height: 100,
borderRadius: 105,
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
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,
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>
)}
)}
</View>
</CameraView>
{/* CreateLyricsHeader overlay outside of CameraView */}
@@ -792,7 +869,7 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
style={{
transform: [{ rotate: "-90deg" }],
backgroundColor: "#ffffff3d",
borderRadius: 55,
borderRadius: size / 2,
}}
>
<Circle
@@ -810,6 +887,60 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
);
};
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
@@ -851,7 +982,11 @@ const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
);
};
const CameraFacingSelector = ({ value = "front", onChange, disabled = false }) => {
const CameraFacingSelector = ({
value = "front",
onChange,
disabled = false,
}) => {
return (
<View
style={{
@@ -150,7 +150,7 @@ const reorder = (list, from, to) => {
return next;
};
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const clamp = (value, min, max) => Math.max(mn, Math.min(max, value));
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const [, setTooltip] = useGlobal("_tooltip");
+115 -18
View File
@@ -3,10 +3,12 @@ import { ScrollView, StyleSheet, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, icons } from "../../assets";
import alert from "../../components/Alert";
import AppCheckbox from "../../components/AppCheckbox";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import MusicLandHeader from "../../components/MusicLandHeader";
import Overlay from "../../components/Overlay";
import firebase, { projectsRef } from "../../config/firebase";
import { strings } from "../../constants/strings";
import { isWeb } from "../../hooks/useLayoutType";
@@ -27,6 +29,9 @@ import {
import { getStageAction } from "../../utils/projectStages";
import CustomInput from "./components/CustomInput";
const RESPONSIBILITY_CHECKBOX_LABEL =
"Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.";
const Lyrics = ({ navigation }) => {
const scrollRef = useRef(null);
const [containerLayout, setContainerLayout] = useState(null);
@@ -34,6 +39,23 @@ const Lyrics = ({ navigation }) => {
const { selectedProjectId, selectedProject } = useUser();
const [isFocus, setIsFocus] = useState(null);
const [itemsContainerLayout, setItemsContainerLayout] = useState([]);
const [sensitiveContentModal, setSensitiveContentModal] = useState({
visible: false,
title: "",
message: "",
});
const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] =
useState(false);
const sensitiveContentResolverRef = useRef(null);
const closeSensitiveContentModal = useCallback((result) => {
setSensitiveContentModal((prev) => ({ ...prev, visible: false }));
setIsSensitiveContentAcknowledged(false);
if (sensitiveContentResolverRef.current) {
sensitiveContentResolverRef.current(result);
sensitiveContentResolverRef.current = null;
}
}, []);
// Effective sources from provider only
const projectTitle = selectedProject?.title || "";
@@ -129,24 +151,26 @@ const Lyrics = ({ navigation }) => {
alert(title, message);
}, []);
const confirmSensitiveContent = useCallback(
(title, message) =>
new Promise((resolve) => {
alert(title, message, [
{
text: "Annuler",
style: "cancel",
onPress: () => resolve(false),
},
{
text: "Continuer",
style: "destructive",
onPress: () => resolve(true),
},
]);
}),
[]
);
const confirmSensitiveContent = useCallback((title, message) => {
return new Promise((resolve) => {
sensitiveContentResolverRef.current = resolve;
setSensitiveContentModal({
visible: true,
title,
message,
});
setIsSensitiveContentAcknowledged(false);
});
}, []);
const handleSensitiveCancel = useCallback(() => {
closeSensitiveContentModal(false);
}, [closeSensitiveContentModal]);
const handleSensitiveConfirm = useCallback(() => {
if (!isSensitiveContentAcknowledged) return;
closeSensitiveContentModal(true);
}, [closeSensitiveContentModal, isSensitiveContentAcknowledged]);
const onValidate = useCallback(async () => {
try {
@@ -461,6 +485,49 @@ const Lyrics = ({ navigation }) => {
)}
<GradientButton title="Valider" onPress={onValidate} />
</View>
<Overlay
isVisible={sensitiveContentModal.visible}
setIsVisible={(visible) => {
if (visible === false) {
handleSensitiveCancel();
}
}}
contentContainerStyle={{
alignItems: "center",
justifyContent: "center",
}}
>
<View style={styles.sensitiveModal}>
<Text style={styles.sensitiveModalTitle}>
{sensitiveContentModal.title}
</Text>
<Text style={styles.sensitiveModalMessage}>
{sensitiveContentModal.message}
</Text>
<View style={styles.sensitiveCheckboxWrapper}>
<AppCheckbox
selected={isSensitiveContentAcknowledged}
onPress={() =>
setIsSensitiveContentAcknowledged((prev) => !prev)
}
label={RESPONSIBILITY_CHECKBOX_LABEL}
/>
</View>
<View style={styles.sensitiveModalActions}>
<BorderGradientButton
title="Annuler"
onPress={handleSensitiveCancel}
containerStyle={{ flex: 1 }}
/>
<GradientButton
title="Continuer"
onPress={handleSensitiveConfirm}
disabled={!isSensitiveContentAcknowledged}
containerStyle={{ flex: 1 }}
/>
</View>
</View>
</Overlay>
</Page>
);
};
@@ -513,4 +580,34 @@ const styles = StyleSheet.create({
color: Palette.white,
opacity: 0.8,
},
sensitiveModal: {
width: "90%",
maxWidth: 460,
backgroundColor: Palette.lightPurple,
borderRadius: 18,
paddingVertical: 24,
paddingHorizontal: 20,
gap: 16,
},
sensitiveModalTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 20,
color: Palette.white,
textAlign: "center",
},
sensitiveModalMessage: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 15,
color: Palette.white,
lineHeight: 20,
textAlign: "left",
opacity: 0.9,
},
sensitiveCheckboxWrapper: {
paddingVertical: 4,
},
sensitiveModalActions: {
flexDirection: "row",
gap: 12,
},
});
+1 -1
View File
@@ -11,7 +11,7 @@ const SpecificityContext = ({ context, setContext }) => {
subTitle="Dis-nous en un peu plus pour quon puisse mieux taider."
/>
<CustomInput
placeholder="Écris à qui s'adresse ta chanson"
placeholder="cris le contexte de ta chanson"
height={283}
value={context}
setValue={setContext}