stripe embedded and other fixes
This commit is contained in:
+19
-1
@@ -27,6 +27,21 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
cancelOption?.onPress();
|
||||
};
|
||||
|
||||
const renderDescription = () => {
|
||||
if (
|
||||
typeof description === "string" ||
|
||||
typeof description === "number"
|
||||
) {
|
||||
return <Text style={styles.description}>{description}</Text>;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <View style={styles.customDescription}>{description}</View>;
|
||||
};
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Overlay
|
||||
@@ -42,7 +57,7 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
>
|
||||
<BlurView intensity={100} tint="dark" style={styles.modalContainer}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
{renderDescription()}
|
||||
<View style={styles.divider} />
|
||||
<View
|
||||
style={hasSecondaryAction ? styles.actionsRow : styles.actions}
|
||||
@@ -152,6 +167,9 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
marginVertical: 0,
|
||||
},
|
||||
customDescription: {
|
||||
width: "100%",
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
gap: gutters,
|
||||
|
||||
+101
-52
@@ -25,10 +25,23 @@ const STRIPE_SUCCESS_URL =
|
||||
"https://dashboard.stripe.com/test/billing/starter-guide/checkout-success";
|
||||
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL;
|
||||
|
||||
const isStripeTesting = false;
|
||||
const stripePromise = loadStripe(
|
||||
isStripeTesting ? STRIPE_PUBLISHABLE_KEY_TEST : STRIPE_PUBLISHABLE_KEY_LIVE,
|
||||
);
|
||||
const isStripeTesting = true;
|
||||
const rawStripePublishableKey = isStripeTesting
|
||||
? STRIPE_PUBLISHABLE_KEY_TEST
|
||||
: STRIPE_PUBLISHABLE_KEY_LIVE;
|
||||
const stripePublishableKey =
|
||||
typeof rawStripePublishableKey === "string"
|
||||
? rawStripePublishableKey.trim()
|
||||
: "";
|
||||
const stripePromise = stripePublishableKey
|
||||
? loadStripe(stripePublishableKey)
|
||||
: null;
|
||||
|
||||
if (!stripePublishableKey) {
|
||||
console.warn(
|
||||
"[StripeProvider] Aucune clé publique Stripe fournie, le checkout intégré sera désactivé.",
|
||||
);
|
||||
}
|
||||
|
||||
const isSafariBrowser = () => {
|
||||
if (
|
||||
@@ -86,6 +99,7 @@ export const useStripe = () => {
|
||||
const StripeProvider = ({ children }) => {
|
||||
const { isMobile } = useLayoutType();
|
||||
const { currentUserData } = useUserData() || {};
|
||||
const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise);
|
||||
|
||||
const [clientSecret, setClientSecret] = React.useState(null);
|
||||
const [subscriptions, setSubscriptions] = React.useState({
|
||||
@@ -118,33 +132,12 @@ const StripeProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
if (safariWindow && !safariWindow.closed) {
|
||||
try {
|
||||
safariWindow.location.replace(checkoutUrl);
|
||||
} catch (navigationError) {
|
||||
safariWindow.location.href = checkoutUrl;
|
||||
}
|
||||
safariWindow.focus?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const openedTab = window.open(
|
||||
checkoutUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
if (openedTab) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Linking.openURL(checkoutUrl);
|
||||
return;
|
||||
throw new Error(
|
||||
"Impossible d'ouvrir le paiement Stripe sans checkout intégré.",
|
||||
);
|
||||
}
|
||||
|
||||
const isMobileApp =
|
||||
Platform.OS === "ios" || Platform.OS === "android";
|
||||
const isMobileApp = Platform.OS === "ios" || Platform.OS === "android";
|
||||
|
||||
if (isMobileApp) {
|
||||
try {
|
||||
@@ -173,17 +166,37 @@ const StripeProvider = ({ children }) => {
|
||||
);
|
||||
|
||||
const runCheckoutSession = React.useCallback(
|
||||
async ({ callableName, payload, logTag }) => {
|
||||
const safariWindow = openSafariCheckoutWindow();
|
||||
try {
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||
callableName,
|
||||
async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
|
||||
if (useEmbeddedFlow && !canUseEmbeddedCheckout) {
|
||||
throw new Error(
|
||||
"Le paiement intégré Stripe est indisponible sur cette plateforme.",
|
||||
);
|
||||
}
|
||||
|
||||
const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout;
|
||||
const safariWindow = wantsEmbeddedCheckout
|
||||
? null
|
||||
: openSafariCheckoutWindow();
|
||||
try {
|
||||
const callable =
|
||||
getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName);
|
||||
const { data } = await callable(payload);
|
||||
const checkoutUrl = data?.url;
|
||||
const clientSecret = data?.client_secret || data?.clientSecret;
|
||||
|
||||
if (wantsEmbeddedCheckout) {
|
||||
if (!clientSecret) {
|
||||
throw new Error("Session Stripe introuvable.");
|
||||
}
|
||||
setClientSecret(clientSecret);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkoutUrl) {
|
||||
throw new Error("Session Stripe introuvable.");
|
||||
}
|
||||
|
||||
console.log("[StripeProvider] Stripe checkout URL", checkoutUrl);
|
||||
await redirectToCheckout(checkoutUrl, { safariWindow });
|
||||
} catch (error) {
|
||||
if (safariWindow && !safariWindow.closed) {
|
||||
@@ -196,7 +209,7 @@ const StripeProvider = ({ children }) => {
|
||||
);
|
||||
}
|
||||
},
|
||||
[redirectToCheckout],
|
||||
[canUseEmbeddedCheckout, redirectToCheckout, setClientSecret],
|
||||
);
|
||||
|
||||
const createSubscriptionCheckout = React.useCallback(
|
||||
@@ -204,6 +217,19 @@ const StripeProvider = ({ children }) => {
|
||||
if (!priceId) {
|
||||
throw new Error("Aucun abonnement sélectionné.");
|
||||
}
|
||||
if (isWeb && !canUseEmbeddedCheckout) {
|
||||
console.error(
|
||||
"[StripeProvider] checkout blocked (missing embedded support)",
|
||||
{
|
||||
hasStripeKey: Boolean(stripePublishableKey),
|
||||
isClientSecretReady: false,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
"Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).",
|
||||
);
|
||||
}
|
||||
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout;
|
||||
await runCheckoutSession({
|
||||
callableName: "subscription-createSubscriptionCheckoutSession",
|
||||
payload: {
|
||||
@@ -212,11 +238,13 @@ const StripeProvider = ({ children }) => {
|
||||
successUrl: STRIPE_SUCCESS_URL,
|
||||
cancelUrl: STRIPE_CANCEL_URL,
|
||||
},
|
||||
uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
|
||||
},
|
||||
logTag: "subscription checkout error",
|
||||
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
||||
});
|
||||
},
|
||||
[runCheckoutSession],
|
||||
[canUseEmbeddedCheckout, runCheckoutSession],
|
||||
);
|
||||
|
||||
const createCoinPackCheckout = React.useCallback(
|
||||
@@ -224,6 +252,19 @@ const StripeProvider = ({ children }) => {
|
||||
if (!productId) {
|
||||
throw new Error("Aucun pack sélectionné.");
|
||||
}
|
||||
if (isWeb && !canUseEmbeddedCheckout) {
|
||||
console.error(
|
||||
"[StripeProvider] checkout blocked (missing embedded support)",
|
||||
{
|
||||
hasStripeKey: Boolean(stripePublishableKey),
|
||||
isClientSecretReady: false,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
"Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).",
|
||||
);
|
||||
}
|
||||
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout;
|
||||
await runCheckoutSession({
|
||||
callableName: "subscription-createCoinPackCheckoutSession",
|
||||
payload: {
|
||||
@@ -232,11 +273,13 @@ const StripeProvider = ({ children }) => {
|
||||
successUrl: STRIPE_SUCCESS_URL,
|
||||
cancelUrl: STRIPE_CANCEL_URL,
|
||||
},
|
||||
uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
|
||||
},
|
||||
logTag: "coin pack checkout error",
|
||||
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
||||
});
|
||||
},
|
||||
[runCheckoutSession],
|
||||
[canUseEmbeddedCheckout, runCheckoutSession],
|
||||
);
|
||||
|
||||
const fetchActiveSubscription = React.useCallback(async () => {
|
||||
@@ -252,9 +295,9 @@ const StripeProvider = ({ children }) => {
|
||||
setIsActiveSubscriptionLoading(true);
|
||||
setActiveSubscriptionError(null);
|
||||
try {
|
||||
const callable = getFunctionsClient(
|
||||
FUNCTIONS_REGION,
|
||||
).httpsCallable("subscription-getActiveSubscription");
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||
"subscription-getActiveSubscription",
|
||||
);
|
||||
const { data } = await callable();
|
||||
setActiveSubscription(data?.subscription || null);
|
||||
} catch (error) {
|
||||
@@ -276,8 +319,9 @@ const StripeProvider = ({ children }) => {
|
||||
let lastError = null;
|
||||
|
||||
try {
|
||||
const callable =
|
||||
functionsClient.httpsCallable("subscription-listSubscriptionPlans");
|
||||
const callable = functionsClient.httpsCallable(
|
||||
"subscription-listSubscriptionPlans",
|
||||
);
|
||||
const { data } = await callable();
|
||||
const nextPlans = data?.plans || {};
|
||||
setSubscriptions({
|
||||
@@ -290,8 +334,9 @@ const StripeProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const callable =
|
||||
functionsClient.httpsCallable("subscription-listCoinPacks");
|
||||
const callable = functionsClient.httpsCallable(
|
||||
"subscription-listCoinPacks",
|
||||
);
|
||||
const { data } = await callable();
|
||||
setCoinPacks(Array.isArray(data?.packs) ? data.packs : []);
|
||||
} catch (error) {
|
||||
@@ -382,23 +427,28 @@ const StripeProvider = ({ children }) => {
|
||||
isVisible={clientSecret !== null}
|
||||
setIsVisible={closeEmbeddedCheckout}
|
||||
>
|
||||
<View
|
||||
style={[StyleSheet.absoluteFillObject, styles.overlayContent]}
|
||||
>
|
||||
<View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
|
||||
<View
|
||||
style={[
|
||||
styles.embeddedWrapper,
|
||||
{
|
||||
width: isMobile ? "90%" : "80%",
|
||||
width: isMobile ? "99%" : "95%",
|
||||
height: isMobile ? "92vh" : "96vh",
|
||||
maxWidth: isMobile ? "100%" : "1400px",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{clientSecret ? (
|
||||
{clientSecret && stripePromise ? (
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{ clientSecret }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
<EmbeddedCheckout
|
||||
onComplete={() => {
|
||||
closeEmbeddedCheckout();
|
||||
fetchActiveSubscription();
|
||||
}}
|
||||
/>
|
||||
</EmbeddedCheckoutProvider>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -422,9 +472,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
embeddedWrapper: {
|
||||
alignSelf: "center",
|
||||
height: "70vh",
|
||||
borderRadius: mainBorderRadius,
|
||||
overflow: "scroll",
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import {
|
||||
@@ -74,6 +75,8 @@ const MusicDetails = ({ route }) => {
|
||||
const hasAutoPlayedRef = React.useRef(false);
|
||||
const { isLooping, setLooping } = usePlayer() || {};
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { width: windowWidth = 0 } = useWindowDimensions();
|
||||
const isCompactLayout = (windowWidth || 0) < 1200;
|
||||
const handleBackPress = useCallback(() => {
|
||||
goBack();
|
||||
}, []);
|
||||
@@ -964,6 +967,7 @@ const MusicDetails = ({ route }) => {
|
||||
headerType="NAVIGATION"
|
||||
hideBackButton={isWeb}
|
||||
topStickyContent={renderWebBackButton}
|
||||
width={isCompactLayout ? "100%" : undefined}
|
||||
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
|
||||
backgroundImg={
|
||||
action === "userProfile"
|
||||
@@ -993,11 +997,12 @@ const MusicDetails = ({ route }) => {
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingBottom: gutters * 2,
|
||||
flexDirection: "row",
|
||||
flexDirection: isCompactLayout ? "column" : "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
alignItems: isCompactLayout ? "stretch" : "center",
|
||||
height: "auto",
|
||||
gap: 24,
|
||||
gap: isCompactLayout ? 16 : 24,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Left column: player */}
|
||||
@@ -1005,13 +1010,14 @@ const MusicDetails = ({ route }) => {
|
||||
intensity={40}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingRight: 8,
|
||||
height: 400,
|
||||
paddingRight: isCompactLayout ? 0 : 8,
|
||||
height: isCompactLayout ? undefined : 400,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.transparentWhite,
|
||||
maxWidth: "50%",
|
||||
maxWidth: isCompactLayout ? "100%" : "50%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
@@ -1159,20 +1165,25 @@ const MusicDetails = ({ route }) => {
|
||||
intensity={40}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: 8,
|
||||
paddingLeft: isCompactLayout ? 0 : 8,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.transparentWhite,
|
||||
borderRadius: 12,
|
||||
height: 400,
|
||||
maxWidth: "50%",
|
||||
height: isCompactLayout ? undefined : 400,
|
||||
maxWidth: isCompactLayout ? "100%" : "50%",
|
||||
width: "100%",
|
||||
marginTop: isCompactLayout ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{sections.length ? (
|
||||
<ScrollView
|
||||
ref={lyricsRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 40, height: 400 }}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 40,
|
||||
...(isCompactLayout ? {} : { height: 400 }),
|
||||
}}
|
||||
>
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<View
|
||||
@@ -1231,7 +1242,10 @@ const MusicDetails = ({ route }) => {
|
||||
) : description?.length > 0 ? (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 40,
|
||||
...(isCompactLayout ? {} : { height: 400 }),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { BackHandler, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import alert from "../../components/Alert";
|
||||
@@ -54,6 +54,10 @@ const RecordPlayback = ({ route }) => {
|
||||
const checkSongEndRef = useRef(null);
|
||||
const stopRequestedRef = useRef(false);
|
||||
const restartRequestedRef = useRef(false);
|
||||
const manualRestartInFlightRef = useRef(false);
|
||||
const stopRequestedAtRef = useRef(0);
|
||||
const activeRecordingPromiseRef = useRef(null);
|
||||
const exitRequestedRef = 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
|
||||
@@ -259,45 +263,58 @@ const RecordPlayback = ({ route }) => {
|
||||
}, []); // 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");
|
||||
}
|
||||
const resetSession = useCallback(
|
||||
async ({
|
||||
preserveSongEndWatcher = false,
|
||||
preserveStopRequest = false,
|
||||
} = {}) => {
|
||||
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) {
|
||||
if (preserveSongEndWatcher) {
|
||||
log("checkSongEndRef preserved");
|
||||
} else {
|
||||
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");
|
||||
startedRef.current = false;
|
||||
if (!preserveStopRequest) {
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
}
|
||||
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);
|
||||
setIsPreparing(false);
|
||||
setIsRecording(false);
|
||||
setShowProgress(false);
|
||||
setCountdown(0);
|
||||
|
||||
if (player) {
|
||||
try {
|
||||
if (player.playing) await player.pause?.();
|
||||
await player.seekTo?.(0);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, [player]);
|
||||
if (player) {
|
||||
try {
|
||||
if (player.playing) await player.pause?.();
|
||||
await player.seekTo?.(0);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
const resetSessionRef = useRef(resetSession);
|
||||
useEffect(() => {
|
||||
@@ -307,6 +324,7 @@ const RecordPlayback = ({ route }) => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
log("Screen focused, resetting session");
|
||||
exitRequestedRef.current = false;
|
||||
void resetSessionRef.current?.();
|
||||
return () => {
|
||||
log("Screen blurred, stopping playback");
|
||||
@@ -337,15 +355,47 @@ const RecordPlayback = ({ route }) => {
|
||||
}, [setLooping])
|
||||
);
|
||||
|
||||
const discardRecordingFile = useCallback(async (uri, reason = "") => {
|
||||
if (!uri) return;
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
if (info?.exists) {
|
||||
await FileSystem.deleteAsync(uri, { idempotent: true });
|
||||
log("Discarded recording file", { reason: reason || "cleanup" });
|
||||
}
|
||||
} catch (error) {
|
||||
log("Failed to discard recording", {
|
||||
reason: reason || "cleanup",
|
||||
message: error?.message || String(error || ""),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Lancer le compte à rebours (le tick décrémente uniquement)
|
||||
const startCountdownThenRecord = async () => {
|
||||
const startCountdownThenRecord = async ({
|
||||
preserveRestartFlag = false,
|
||||
preserveSongEndWatcher = false,
|
||||
preserveStopRequest = false,
|
||||
} = {}) => {
|
||||
if (!songUrl) {
|
||||
log("startCountdownThenRecord aborted: missing song URL");
|
||||
return;
|
||||
}
|
||||
log("startCountdownThenRecord invoked", { projectId, songUrl });
|
||||
await resetSession();
|
||||
restartRequestedRef.current = false;
|
||||
log("startCountdownThenRecord invoked", {
|
||||
projectId,
|
||||
songUrl,
|
||||
preserveRestartFlag,
|
||||
preserveSongEndWatcher,
|
||||
preserveStopRequest,
|
||||
});
|
||||
await resetSession({
|
||||
preserveSongEndWatcher,
|
||||
preserveStopRequest,
|
||||
});
|
||||
if (!preserveRestartFlag) {
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
}
|
||||
setIsPreparing(true);
|
||||
setShowProgress(false);
|
||||
setCountdown(5);
|
||||
@@ -387,6 +437,7 @@ const RecordPlayback = ({ route }) => {
|
||||
const startRecordingWithMusic = async () => {
|
||||
try {
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
incrementDoneRef.current = false;
|
||||
|
||||
@@ -399,6 +450,18 @@ const RecordPlayback = ({ route }) => {
|
||||
songUrl,
|
||||
hasCamera: !!cameraRef.current,
|
||||
});
|
||||
|
||||
if (activeRecordingPromiseRef.current) {
|
||||
log("Waiting for previous recording to finish before starting a new one");
|
||||
try {
|
||||
await activeRecordingPromiseRef.current;
|
||||
} catch (error) {
|
||||
log("Previous recording promise rejected", {
|
||||
message: error?.message || String(error || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recordPromise = (() => {
|
||||
const camera = cameraRef.current;
|
||||
if (!camera) throw new Error("Caméra indisponible");
|
||||
@@ -451,6 +514,7 @@ const RecordPlayback = ({ route }) => {
|
||||
"L'enregistrement vidéo n'est pas supporté sur cet appareil."
|
||||
);
|
||||
})();
|
||||
activeRecordingPromiseRef.current = recordPromise;
|
||||
|
||||
if (player && songUrl) {
|
||||
try {
|
||||
@@ -501,7 +565,18 @@ const RecordPlayback = ({ route }) => {
|
||||
log("Song end watcher armed");
|
||||
checkSongEndRef.current = setInterval(() => {
|
||||
try {
|
||||
if (!player || stopRequestedRef.current) return;
|
||||
if (!player) return;
|
||||
if (stopRequestedRef.current) {
|
||||
const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0);
|
||||
if (sinceLastRequest >= 1200) {
|
||||
stopRequestedAtRef.current = Date.now();
|
||||
try {
|
||||
cameraRef.current?.stopRecording?.();
|
||||
log("stopRecording retried while awaiting stop");
|
||||
} catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const duration = (player?.duration || 0) * 1000;
|
||||
const currentTime = (player?.currentTime || 0) * 1000;
|
||||
if (
|
||||
@@ -524,10 +599,7 @@ const RecordPlayback = ({ route }) => {
|
||||
playing: player?.playing,
|
||||
});
|
||||
stopRequestedRef.current = true;
|
||||
if (checkSongEndRef.current) {
|
||||
clearInterval(checkSongEndRef.current);
|
||||
checkSongEndRef.current = null;
|
||||
}
|
||||
stopRequestedAtRef.current = Date.now();
|
||||
try {
|
||||
cameraRef.current?.stopRecording?.();
|
||||
log("stopRecording triggered");
|
||||
@@ -539,6 +611,9 @@ const RecordPlayback = ({ route }) => {
|
||||
|
||||
const video = await recordPromise;
|
||||
log("Recording promise resolved", { hasVideo: !!video?.uri });
|
||||
activeRecordingPromiseRef.current = null;
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
|
||||
if (checkSongEndRef.current) {
|
||||
clearInterval(checkSongEndRef.current);
|
||||
@@ -559,30 +634,31 @@ const RecordPlayback = ({ route }) => {
|
||||
log("Recording flow completed", { hasVideo: !!video?.uri });
|
||||
playbackStartedRef.current = false;
|
||||
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
|
||||
const exitRequested = exitRequestedRef.current;
|
||||
const shouldRestart = restartRequestedRef.current;
|
||||
|
||||
if (exitRequested) {
|
||||
exitRequestedRef.current = false;
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
await discardRecordingFile(video?.uri, "exit");
|
||||
log("Recording aborted before completion, skipping navigation");
|
||||
return;
|
||||
}
|
||||
|
||||
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 || ""),
|
||||
await discardRecordingFile(video?.uri, "restart");
|
||||
const manualRestartPending = manualRestartInFlightRef.current;
|
||||
manualRestartInFlightRef.current = false;
|
||||
if (manualRestartPending) {
|
||||
log("Manual restart already scheduled, waiting for countdown");
|
||||
} else {
|
||||
log("Restart requested, relaunching countdown");
|
||||
requestAnimationFrame(() => {
|
||||
void startCountdownThenRecord();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRestart) {
|
||||
log("Restart requested, relaunching countdown");
|
||||
requestAnimationFrame(() => {
|
||||
void startCountdownThenRecord();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,6 +678,9 @@ const RecordPlayback = ({ route }) => {
|
||||
message: e?.message || String(e || ""),
|
||||
});
|
||||
console.log("RecordPlayback error:", e);
|
||||
activeRecordingPromiseRef.current = null;
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
setIsRecording(false);
|
||||
setIsPreparing(false);
|
||||
setShowProgress(false);
|
||||
@@ -617,13 +696,27 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
playbackStartedRef.current = false;
|
||||
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
|
||||
const exitRequested = exitRequestedRef.current;
|
||||
const shouldRestart = restartRequestedRef.current;
|
||||
if (exitRequested) {
|
||||
exitRequestedRef.current = false;
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
log("Recording aborted, skipping error handling");
|
||||
return;
|
||||
}
|
||||
if (shouldRestart) {
|
||||
restartRequestedRef.current = false;
|
||||
log("Restart requested despite error, restarting flow");
|
||||
requestAnimationFrame(() => {
|
||||
void startCountdownThenRecord();
|
||||
});
|
||||
const manualRestartPending = manualRestartInFlightRef.current;
|
||||
manualRestartInFlightRef.current = false;
|
||||
if (manualRestartPending) {
|
||||
log("Manual restart already scheduled after error");
|
||||
} else {
|
||||
log("Restart requested despite error, restarting flow");
|
||||
requestAnimationFrame(() => {
|
||||
void startCountdownThenRecord();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Inform the user when using a simulator where recording isn't supported
|
||||
@@ -648,25 +741,94 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackPress = useCallback(() => {
|
||||
const busy = isPreparing || isRecording;
|
||||
log("Back button pressed", { isPreparing, isRecording, busy });
|
||||
if (busy) {
|
||||
exitRequestedRef.current = true;
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
stopRequestedRef.current = true;
|
||||
stopRequestedAtRef.current = Date.now();
|
||||
if (isPreparing && countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
countdownTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
if (player?.playing) {
|
||||
const maybePromise = player.pause?.();
|
||||
if (maybePromise && typeof maybePromise.catch === "function") {
|
||||
maybePromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (isRecording) {
|
||||
cameraRef.current?.stopRecording?.();
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
exitRequestedRef.current = false;
|
||||
}
|
||||
try {
|
||||
goBack();
|
||||
} catch (_) {}
|
||||
return true;
|
||||
}, [goBack, isPreparing, isRecording, player]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
() => handleBackPress()
|
||||
);
|
||||
return () => subscription.remove();
|
||||
}, [handleBackPress])
|
||||
);
|
||||
|
||||
const handleRestartRecording = async () => {
|
||||
try {
|
||||
const hasRecordingPending = !!activeRecordingPromiseRef.current;
|
||||
log("Restart button pressed", {
|
||||
isPreparing,
|
||||
isRecording,
|
||||
hasRecordingPending,
|
||||
});
|
||||
if (isPreparing || !isRecording) {
|
||||
if (isPreparing) {
|
||||
if (hasRecordingPending) {
|
||||
await startCountdownThenRecord({
|
||||
preserveRestartFlag: true,
|
||||
preserveSongEndWatcher: true,
|
||||
preserveStopRequest: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
await startCountdownThenRecord();
|
||||
return;
|
||||
}
|
||||
if (!isRecording) {
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
await startCountdownThenRecord();
|
||||
return;
|
||||
}
|
||||
restartRequestedRef.current = true;
|
||||
manualRestartInFlightRef.current = true;
|
||||
stopRequestedRef.current = true;
|
||||
stopRequestedAtRef.current = Date.now();
|
||||
try {
|
||||
if (player?.playing) await player.pause?.();
|
||||
} catch (_) {}
|
||||
try {
|
||||
cameraRef.current?.stopRecording?.();
|
||||
} catch (_) {}
|
||||
await startCountdownThenRecord({
|
||||
preserveRestartFlag: true,
|
||||
preserveSongEndWatcher: true,
|
||||
preserveStopRequest: true,
|
||||
});
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
@@ -693,7 +855,7 @@ const RecordPlayback = ({ route }) => {
|
||||
paddingBottom: gutters * 2,
|
||||
}}
|
||||
>
|
||||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||||
<MusicLandHeader progress={9} onPressBack={handleBackPress} />
|
||||
|
||||
<View style={{ marginTop: 12, alignItems: "flex-end" }}>
|
||||
<CameraFacingSelector
|
||||
@@ -869,7 +1031,6 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
|
||||
height={size}
|
||||
style={{
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
backgroundColor: "#ffffff3d",
|
||||
borderRadius: size / 2,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -202,9 +202,51 @@ const SongReady = () => {
|
||||
|
||||
const handleRegeneratePress = () => {
|
||||
if (isWeb) {
|
||||
const descriptionTextStyle = {
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
};
|
||||
const amountTextStyle = {
|
||||
...descriptionTextStyle,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
};
|
||||
|
||||
alert(
|
||||
"Re-générer le morceau",
|
||||
`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux pour ${MUSIC_GENERATION_COIN_COST} crédits.\n\nLes crédits seront utilisés lors de l'étape de génération.`,
|
||||
(
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={descriptionTextStyle}>
|
||||
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={descriptionTextStyle}>Cette action coûte</Text>
|
||||
<CreditAmount
|
||||
value={MUSIC_GENERATION_COIN_COST}
|
||||
iconSize={18}
|
||||
textStyle={amountTextStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={descriptionTextStyle}>
|
||||
Les crédits seront utilisés lors de l'étape de génération.
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
{
|
||||
text: "Annuler",
|
||||
|
||||
Reference in New Issue
Block a user