feat: clean playback web

This commit is contained in:
2025-11-17 11:13:26 +01:00
parent add6a707a2
commit d2094b453e
+212 -382
View File
@@ -15,7 +15,6 @@ 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 usePlayer from "../../hooks/usePlayer";
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import Page from "../../layouts/Page";
@@ -25,11 +24,9 @@ import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { background } from "../../assets";
import playbackBG2 from "../../assets/UI/playbackBG2.png";
import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon";
const TIME_BEFORE_INCREMENT_MS = 20000;
const LOG_PREFIX = "[RecordPlayback.web]";
const WEB_PREVIEW_WIDTH = 360;
const toSeconds = (v) => {
@@ -105,7 +102,6 @@ const RecordPlayback = ({ route }) => {
const listenedMsRef = useRef(0);
const viewsIncrementedRef = useRef(false);
const correctedInitialJumpRef = useRef(false);
const progressDebugCounterRef = useRef(0);
useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false;
@@ -123,7 +119,6 @@ const RecordPlayback = ({ route }) => {
}, [project?.musicTimestamps, songIndex]);
const clearAllTimers = () => {
console.log(LOG_PREFIX, "clearAllTimers");
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
if (listenTimerRef.current) clearInterval(listenTimerRef.current);
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
@@ -134,7 +129,6 @@ const RecordPlayback = ({ route }) => {
};
const resetUI = () => {
console.log(LOG_PREFIX, "resetUI");
setIsPreparing(false);
setIsRecording(false);
setCountdown(0);
@@ -157,7 +151,6 @@ const RecordPlayback = ({ route }) => {
const startRecorder = useCallback(() => {
if (!mediaStreamRef.current) {
console.log(LOG_PREFIX, "mediaRecorder:start:noStream");
return false;
}
@@ -168,13 +161,7 @@ const RecordPlayback = ({ route }) => {
) {
mediaRecorderRef.current.stop();
}
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:stopPrevious:error",
String(e?.message || e || ""),
);
}
} catch (e) {}
recordedChunksRef.current = [];
releaseRecordingUrl();
@@ -187,11 +174,6 @@ const RecordPlayback = ({ route }) => {
? new MediaRecorder(mediaStreamRef.current, { mimeType })
: new MediaRecorder(mediaStreamRef.current);
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:createError",
String(e?.message || e || ""),
);
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
return false;
}
@@ -209,11 +191,6 @@ const RecordPlayback = ({ route }) => {
recorder.onerror = (event) => {
const err = event?.error || event;
console.log(
LOG_PREFIX,
"mediaRecorder:error",
String(err?.message || err || ""),
);
setMediaError(err instanceof Error ? err : new Error(String(err || "")));
};
@@ -230,11 +207,6 @@ const RecordPlayback = ({ route }) => {
releaseRecordingUrl();
}
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:onstop:createBlobError",
String(e?.message || e || ""),
);
releaseRecordingUrl();
}
if (stopRecordingResolveRef.current) {
@@ -248,16 +220,8 @@ const RecordPlayback = ({ route }) => {
try {
recorder.start(1000);
console.log(LOG_PREFIX, "mediaRecorder:start", {
mimeType: recorder.mimeType,
});
return true;
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:startError",
String(e?.message || e || ""),
);
if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(null);
stopRecordingResolveRef.current = null;
@@ -282,13 +246,7 @@ const RecordPlayback = ({ route }) => {
}
recorder.stop();
}
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:stopError",
String(e?.message || e || ""),
);
}
} catch (e) {}
if (!waitPromise) {
return recordedUrlRef.current || null;
@@ -298,11 +256,6 @@ const RecordPlayback = ({ route }) => {
try {
url = await waitPromise;
} catch (e) {
console.log(
LOG_PREFIX,
"mediaRecorder:waitStopError",
String(e?.message || e || ""),
);
} finally {
stopRecordingPromiseRef.current = null;
stopRecordingResolveRef.current = null;
@@ -312,31 +265,16 @@ const RecordPlayback = ({ route }) => {
}, []);
const resetSession = useCallback(async () => {
console.log(LOG_PREFIX, "resetSession:start");
clearAllTimers();
resetUI();
try {
await player?.pause?.();
await player?.seekTo?.(0);
console.log(LOG_PREFIX, "resetSession:playerReset");
} catch (e) {
console.log(
LOG_PREFIX,
"resetSession:error",
String(e?.message || e || ""),
);
}
} catch (e) {}
try {
await stopRecorderAndGetUrl();
releaseRecordingUrl();
} catch (e) {
console.log(
LOG_PREFIX,
"resetSession:recorderError",
String(e?.message || e || ""),
);
}
console.log(LOG_PREFIX, "resetSession:end");
} catch (e) {}
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
const resetSessionRef = useRef(resetSession);
@@ -363,34 +301,25 @@ const RecordPlayback = ({ route }) => {
setLooping(!!originalLoopingValueRef.current.value);
}
};
}, [setLooping]),
}, [setLooping])
);
useFocusEffect(
useCallback(() => {
void resetSessionRef.current?.();
return () => {
console.log(LOG_PREFIX, "focusEffect:cleanup");
void resetSessionRef.current?.();
};
}, []),
}, [])
);
useEffect(() => {
(async () => {
try {
if (!cameraPermission?.granted) {
console.log(LOG_PREFIX, "requestingCameraPermission");
await requestCameraPermission();
console.log(LOG_PREFIX, "requestingCameraPermission:done");
}
} catch (e) {
console.log(
LOG_PREFIX,
"requestingCameraPermission:error",
String(e?.message || e || ""),
);
}
} catch (e) {}
})();
return () => {
clearAllTimers();
@@ -413,7 +342,7 @@ const RecordPlayback = ({ route }) => {
!navigator.mediaDevices?.getUserMedia
) {
setMediaError(
new Error("La capture vidéo n'est pas supportée sur ce navigateur"),
new Error("La capture vidéo n'est pas supportée sur ce navigateur")
);
setMediaReady(false);
return;
@@ -459,13 +388,7 @@ const RecordPlayback = ({ route }) => {
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch(() => {});
}
} catch (e) {
console.log(
LOG_PREFIX,
"previewVideo:attachError",
String(e?.message || e || ""),
);
}
} catch (e) {}
} else {
try {
video.srcObject = null;
@@ -492,22 +415,10 @@ const RecordPlayback = ({ route }) => {
}, [releaseRecordingUrl]);
const stopAndNavigate = useCallback(async () => {
console.log(LOG_PREFIX, "stopAndNavigate:start", {
pos,
dur,
listenedMs: listenedMsRef.current,
});
clearAllTimers();
try {
await player?.pause?.();
console.log(LOG_PREFIX, "stopAndNavigate:playerPaused");
} catch (e) {
console.log(
LOG_PREFIX,
"stopAndNavigate:pauseError",
String(e?.message || e || ""),
);
}
} catch (e) {}
let videoUrl = null;
try {
@@ -515,26 +426,14 @@ const RecordPlayback = ({ route }) => {
if (!videoUrl) {
videoUrl = recordedUrlRef.current || null;
}
} catch (e) {
console.log(
LOG_PREFIX,
"stopAndNavigate:recorderError",
String(e?.message || e || ""),
);
}
} catch (e) {}
setIsRecording(false);
console.log(LOG_PREFIX, "stopAndNavigate:navigate", {
route: Routes.RecordedPlayback,
hasVideo: !!videoUrl,
});
navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null });
}, [dur, player, pos, project, stopRecorderAndGetUrl]);
const startProgressLoop = useCallback(() => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
progressDebugCounterRef.current = 0;
console.log(LOG_PREFIX, "startProgressLoop");
progressTimerRef.current = setInterval(async () => {
const rawDur = toSeconds(player?.duration);
@@ -560,14 +459,7 @@ const RecordPlayback = ({ route }) => {
await player?.seekTo?.(0);
correctedInitialJumpRef.current = true;
nextPos = 0;
console.log(LOG_PREFIX, "progressLoop:correctedInitialJump");
} catch (e) {
console.log(
LOG_PREFIX,
"progressLoop:correctInitialJumpError",
String(e?.message || e || ""),
);
}
} catch (e) {}
}
const timeSinceStart =
@@ -584,12 +476,6 @@ const RecordPlayback = ({ route }) => {
nextPos > fallback + 1));
if (shouldTrustFallback) {
console.log(LOG_PREFIX, "progressLoop:useFallback", {
rawPos,
fallback,
nextPosBeforeFallback: nextPos,
timeSinceStart,
});
nextPos = fallback;
}
@@ -598,28 +484,7 @@ const RecordPlayback = ({ route }) => {
setDur(nextDur);
setPos(nextPos);
progressDebugCounterRef.current += 1;
if (
progressDebugCounterRef.current <= 12 ||
nextPos >= nextDur - 0.5 ||
progressDebugCounterRef.current % 20 === 0
) {
console.log(LOG_PREFIX, "progressLoop:tick", {
tick: progressDebugCounterRef.current,
rawDur,
rawPos,
fallback,
nextDur,
nextPos,
playing: !!player?.playing,
});
}
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
console.log(LOG_PREFIX, "progressLoop:willStop", {
nextDur,
nextPos,
});
void stopAndNavigate();
}
}, 250);
@@ -636,58 +501,33 @@ const RecordPlayback = ({ route }) => {
listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS
) {
viewsIncrementedRef.current = true;
console.log(LOG_PREFIX, "listenLoop:incrementViews");
if (project?.id) {
try {
await projectsRef
.doc(project.id)
.set({ views: increment(1) }, { merge: true });
console.log(LOG_PREFIX, "listenLoop:incrementViews:done");
} catch (e) {
console.log(
LOG_PREFIX,
"listenLoop:incrementViews:error",
String(e?.message || e || ""),
);
}
} catch (e) {}
}
}
}
} catch (e) {
console.log(
LOG_PREFIX,
"listenLoop:error",
String(e?.message || e || ""),
);
}
} catch (e) {}
}, 500);
}, [player, project?.id]);
const startPlayback = useCallback(async () => {
console.log(LOG_PREFIX, "startPlayback:start");
try {
await player?.pause?.(); // s'assure qu'on repart propre
await player?.seekTo?.(0); // tente un seek d'amorçage
const recorderStarted = startRecorder();
if (!recorderStarted) {
console.log(LOG_PREFIX, "startPlayback:recorderNotStarted");
}
await player?.play?.(); // attend la promesse → l'élément est prêt
perfStartRef.current = perfStartRef.current ?? performance.now();
correctedInitialJumpRef.current = false; // autorise la correction 1-shot
setIsRecording(true);
console.log(LOG_PREFIX, "startPlayback:playing", {
duration: toSeconds(player?.duration),
currentTime: toSeconds(player?.currentTime),
});
startProgressLoop();
startListenLoop();
} catch (e) {
console.log(
LOG_PREFIX,
"startPlayback:error",
String(e?.message || e || ""),
);
setIsRecording(false);
setIsPreparing(false);
const msg = String(e?.message || e || "");
@@ -705,7 +545,7 @@ const RecordPlayback = ({ route }) => {
},
},
],
{ cancelable: false },
{ cancelable: false }
);
}
}
@@ -716,7 +556,6 @@ const RecordPlayback = ({ route }) => {
const startCountdownThenRecord = useCallback(async () => {
if (!songUrl || !canRecord) return;
console.log(LOG_PREFIX, "startCountdownThenRecord");
await resetSession();
setIsPreparing(true);
setCountdown(5);
@@ -724,7 +563,6 @@ const RecordPlayback = ({ route }) => {
countdownTimerRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
console.log(LOG_PREFIX, "countdown:launchRecording");
clearAllTimers();
setIsPreparing(false);
requestAnimationFrame(() => {
@@ -732,27 +570,16 @@ const RecordPlayback = ({ route }) => {
});
return 0;
}
console.log(LOG_PREFIX, "countdown:tick", c - 1);
return c - 1;
});
}, 1000);
}, [songUrl, canRecord, resetSession, startPlayback]);
const handleRestartRecording = useCallback(async () => {
console.log(LOG_PREFIX, "restartButton:pressed", {
isPreparing,
isRecording,
});
try {
await startCountdownThenRecord();
} catch (e) {
console.log(
LOG_PREFIX,
"restartButton:error",
String(e?.message || e || ""),
);
}
}, [isPreparing, isRecording, startCountdownThenRecord]);
} catch (e) {}
}, [startCountdownThenRecord]);
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0;
@@ -761,239 +588,242 @@ const RecordPlayback = ({ route }) => {
style={{ flex: 1 }}
width={"100%"}
maxWidth={800}
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.tran }}
headerType="NONE"
backgroundImg={background.playbackBG2}
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<View
style={{
width: isWeb ? WEB_PREVIEW_WIDTH : "100%",
maxWidth: "100%",
aspectRatio: 9 / 16,
overflow: "hidden",
borderRadius: 12,
alignSelf: "center",
position: "relative",
}}
>
<video
ref={previewVideoRef}
autoPlay
playsInline
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: "scaleX(-1)",
backgroundColor: "transparent",
}}
/>
{/* Overlay */}
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: gutters,
paddingTop: top,
paddingBottom: gutters * 2,
width: WEB_PREVIEW_WIDTH,
maxWidth: "100%",
aspectRatio: 9 / 16,
overflow: "hidden",
borderRadius: 12,
position: "relative",
}}
>
<View style={{ flex: 1, marginTop: 11 }}>
{mediaError && (
<video
ref={previewVideoRef}
autoPlay
playsInline
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: "scaleX(-1)",
backgroundColor: "transparent",
}}
/>
{/* Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: gutters,
paddingTop: top,
paddingBottom: gutters * 2,
}}
>
<View style={{ flex: 1, marginTop: 11 }}>
{mediaError && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: gutters,
}}
>
<Text
style={{
textAlign: "center",
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{"Caméra indisponible : "}
{String(mediaError?.message || "").trim() ||
"vérifie les permissions"}
</Text>
</View>
)}
{!mediaError && !mediaReady && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
Initialisation de la caméra
</Text>
</View>
)}
{isPreparing && countdown >= 1 && !isRecording && (
<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>
)}
</View>
{/* Start */}
{permissionsGranted && !isPreparing && !isRecording && (
<GradientButton
title="Lancer ma musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
disabled={!songUrl || !canRecord}
onPress={startCountdownThenRecord}
/>
)}
{/* Progress circulaire + restart */}
{permissionsGranted && (isRecording || isPreparing) && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
// bottom: gutters,
bottom: 0,
backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: gutters,
}}
>
<Text
<View
style={{
textAlign: "center",
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
width: 120,
height: 120,
borderRadius: 120,
alignItems: "center",
justifyContent: "center",
}}
>
{"Caméra indisponible : "}
{String(mediaError?.message || "").trim() ||
"vérifie les permissions"}
</Text>
<ProgressRing
size={120}
strokeWidth={8}
progress={progressRatio}
/>
<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>
)}
{!mediaError && !mediaReady && (
{/* Permission prompt */}
{!permissionsGranted && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "transparent",
alignItems: "center",
justifyContent: "center",
bottom: gutters,
padding: gutters,
}}
>
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
<GradientButton
title="Autoriser la caméra"
onPress={async () => {
try {
if (!cameraPermission?.granted)
await requestCameraPermission();
} catch {}
}}
>
Initialisation de la caméra
</Text>
</View>
)}
{isPreparing && countdown >= 1 && !isRecording && (
<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>
)}
</View>
{/* Start */}
{permissionsGranted && !isPreparing && !isRecording && (
<GradientButton
title="Lancer ma musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
disabled={!songUrl || !canRecord}
onPress={startCountdownThenRecord}
/>
)}
{/* Progress circulaire + restart */}
{permissionsGranted && (isRecording || 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={progressRatio}
/>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleRestartRecording}
{/* Lyrics */}
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
right: 0,
bottom: bottom + 130,
paddingHorizontal: gutters,
}}
>
<CreateLyricsHeader>
{isRecording && alignedWords.length > 0 ? (
<KaraokeLyrics alignedWords={alignedWords} currentTimeS={pos} />
) : (
<Text
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,
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
<RestartSpinnerIcon />
</TouchableOpacity>
</View>
</View>
)}
{/* Permission prompt */}
{!permissionsGranted && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
padding: gutters,
}}
>
<GradientButton
title="Autoriser la caméra"
onPress={async () => {
try {
if (!cameraPermission?.granted)
await requestCameraPermission();
} catch {}
}}
/>
</View>
)}
</View>
{/* Lyrics */}
<View
pointerEvents="box-none"
style={{
position: "absolute",
left: 0,
right: 0,
bottom: bottom + 130,
paddingHorizontal: gutters,
}}
>
<CreateLyricsHeader>
{isRecording && alignedWords.length > 0 ? (
<KaraokeLyrics alignedWords={alignedWords} currentTimeS={pos} />
) : (
<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>
L'enregistrement de ta vidéo commencera lorsque tu lanceras ta
musique, et s'arrêtera à la fin du morceau.
</Text>
)}
</CreateLyricsHeader>
</View>
</View>
</View>
{/* </View> */}
</Page>
);
};