960 lines
27 KiB
JavaScript
960 lines
27 KiB
JavaScript
import { useFocusEffect } from "@react-navigation/native";
|
||
import { useCameraPermissions } from "expo-camera";
|
||
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { Text, View } from "react-native";
|
||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
import Svg, { Circle } 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 { isWeb } from "../../hooks/useLayoutType";
|
||
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
||
import Page from "../../layouts/Page";
|
||
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;
|
||
const LOG_PREFIX = "[RecordPlayback.web]";
|
||
|
||
const toSeconds = (v) => {
|
||
const n = Number(v ?? 0);
|
||
if (!Number.isFinite(n) || n < 0) return 0;
|
||
return n > 10000 ? n / 1000 : n; // heuristique ms → s
|
||
};
|
||
|
||
const pickRecorderMimeType = () => {
|
||
if (typeof window === "undefined" || typeof MediaRecorder === "undefined")
|
||
return undefined;
|
||
|
||
const candidates = [
|
||
"video/webm;codecs=vp9,opus",
|
||
"video/webm;codecs=vp8,opus",
|
||
"video/webm;codecs=vp8",
|
||
"video/webm",
|
||
];
|
||
|
||
for (const mimeType of candidates) {
|
||
try {
|
||
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType;
|
||
} catch {}
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
const RecordPlayback = ({ route }) => {
|
||
const { top, bottom } = useSafeAreaInsets();
|
||
const { project } = route.params || {};
|
||
const songIndex = Number(project?.songIndex ?? 0) || 0;
|
||
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||
|
||
// Player
|
||
const songUrl = project?.songUrl || null;
|
||
const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
||
id: project?.id,
|
||
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: project?.id, screen: "RecordPlayback" },
|
||
});
|
||
|
||
// MediaStream / Recorder (web only)
|
||
const previewVideoRef = useRef(null);
|
||
const mediaStreamRef = useRef(null);
|
||
const mediaRecorderRef = useRef(null);
|
||
const recordedChunksRef = useRef([]);
|
||
const stopRecordingPromiseRef = useRef(null);
|
||
const stopRecordingResolveRef = useRef(null);
|
||
const recordedUrlRef = useRef(null);
|
||
const [mediaReady, setMediaReady] = useState(false);
|
||
const [mediaError, setMediaError] = useState(null);
|
||
|
||
// UI state
|
||
const [countdown, setCountdown] = useState(0);
|
||
const [isPreparing, setIsPreparing] = useState(false);
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [pos, setPos] = useState(0);
|
||
const [dur, setDur] = useState(0);
|
||
|
||
// timers / refs
|
||
const progressTimerRef = useRef(null);
|
||
const listenTimerRef = useRef(null);
|
||
const countdownTimerRef = useRef(null);
|
||
const perfStartRef = useRef(null);
|
||
const listenedMsRef = useRef(0);
|
||
const viewsIncrementedRef = useRef(false);
|
||
const correctedInitialJumpRef = useRef(false);
|
||
const progressDebugCounterRef = useRef(0);
|
||
|
||
// Lyrics
|
||
const alignedWords = useMemo(() => {
|
||
const ts = project?.musicTimestamps?.[songIndex];
|
||
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, 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);
|
||
progressTimerRef.current =
|
||
listenTimerRef.current =
|
||
countdownTimerRef.current =
|
||
null;
|
||
};
|
||
|
||
const resetUI = () => {
|
||
console.log(LOG_PREFIX, "resetUI");
|
||
setIsPreparing(false);
|
||
setIsRecording(false);
|
||
setCountdown(0);
|
||
setPos(0);
|
||
setDur(0);
|
||
listenedMsRef.current = 0;
|
||
viewsIncrementedRef.current = false;
|
||
perfStartRef.current = null;
|
||
correctedInitialJumpRef.current = false;
|
||
};
|
||
|
||
const releaseRecordingUrl = useCallback(() => {
|
||
if (recordedUrlRef.current) {
|
||
try {
|
||
URL.revokeObjectURL(recordedUrlRef.current);
|
||
} catch {}
|
||
recordedUrlRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
const startRecorder = useCallback(() => {
|
||
if (!mediaStreamRef.current) {
|
||
console.log(LOG_PREFIX, "mediaRecorder:start:noStream");
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
if (
|
||
mediaRecorderRef.current &&
|
||
mediaRecorderRef.current.state !== "inactive"
|
||
) {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"mediaRecorder:stopPrevious:error",
|
||
String(e?.message || e || "")
|
||
);
|
||
}
|
||
|
||
recordedChunksRef.current = [];
|
||
releaseRecordingUrl();
|
||
|
||
const mimeType = pickRecorderMimeType();
|
||
let recorder;
|
||
try {
|
||
recorder =
|
||
mimeType != null
|
||
? 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;
|
||
}
|
||
|
||
mediaRecorderRef.current = recorder;
|
||
stopRecordingPromiseRef.current = new Promise((resolve) => {
|
||
stopRecordingResolveRef.current = resolve;
|
||
});
|
||
|
||
recorder.ondataavailable = (event) => {
|
||
if (event?.data && event.data.size > 0) {
|
||
recordedChunksRef.current.push(event.data);
|
||
}
|
||
};
|
||
|
||
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 || "")));
|
||
};
|
||
|
||
recorder.onstop = () => {
|
||
let url = null;
|
||
try {
|
||
if (recordedChunksRef.current.length > 0) {
|
||
const blob = new Blob(recordedChunksRef.current, {
|
||
type: recorder.mimeType || mimeType || "video/webm",
|
||
});
|
||
url = URL.createObjectURL(blob);
|
||
recordedUrlRef.current = url;
|
||
} else {
|
||
releaseRecordingUrl();
|
||
}
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"mediaRecorder:onstop:createBlobError",
|
||
String(e?.message || e || "")
|
||
);
|
||
releaseRecordingUrl();
|
||
}
|
||
if (stopRecordingResolveRef.current) {
|
||
stopRecordingResolveRef.current(url);
|
||
stopRecordingResolveRef.current = null;
|
||
}
|
||
recordedChunksRef.current = [];
|
||
mediaRecorderRef.current = null;
|
||
stopRecordingPromiseRef.current = null;
|
||
};
|
||
|
||
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;
|
||
}
|
||
stopRecordingPromiseRef.current = null;
|
||
mediaRecorderRef.current = null;
|
||
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
|
||
return false;
|
||
}
|
||
}, [releaseRecordingUrl, setMediaError]);
|
||
|
||
const stopRecorderAndGetUrl = useCallback(async () => {
|
||
let waitPromise = stopRecordingPromiseRef.current;
|
||
try {
|
||
const recorder = mediaRecorderRef.current;
|
||
if (recorder && recorder.state !== "inactive") {
|
||
if (!waitPromise) {
|
||
waitPromise = new Promise((resolve) => {
|
||
stopRecordingResolveRef.current = resolve;
|
||
});
|
||
stopRecordingPromiseRef.current = waitPromise;
|
||
}
|
||
recorder.stop();
|
||
}
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"mediaRecorder:stopError",
|
||
String(e?.message || e || "")
|
||
);
|
||
}
|
||
|
||
if (!waitPromise) {
|
||
return recordedUrlRef.current || null;
|
||
}
|
||
|
||
let url = null;
|
||
try {
|
||
url = await waitPromise;
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"mediaRecorder:waitStopError",
|
||
String(e?.message || e || "")
|
||
);
|
||
} finally {
|
||
stopRecordingPromiseRef.current = null;
|
||
stopRecordingResolveRef.current = null;
|
||
}
|
||
|
||
return url || recordedUrlRef.current || null;
|
||
}, []);
|
||
|
||
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 || "")
|
||
);
|
||
}
|
||
try {
|
||
await stopRecorderAndGetUrl();
|
||
releaseRecordingUrl();
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"resetSession:recorderError",
|
||
String(e?.message || e || "")
|
||
);
|
||
}
|
||
console.log(LOG_PREFIX, "resetSession:end");
|
||
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
|
||
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
void resetSession();
|
||
return () => {};
|
||
}, [resetSession])
|
||
);
|
||
|
||
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 || "")
|
||
);
|
||
}
|
||
})();
|
||
return () => {
|
||
clearAllTimers();
|
||
};
|
||
}, []); // eslint-disable-line
|
||
|
||
useEffect(() => {
|
||
if (!cameraPermission?.granted) {
|
||
setMediaReady(false);
|
||
return;
|
||
}
|
||
|
||
if (mediaStreamRef.current) {
|
||
setMediaReady(true);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
typeof navigator === "undefined" ||
|
||
!navigator.mediaDevices?.getUserMedia
|
||
) {
|
||
setMediaError(
|
||
new Error("La capture vidéo n'est pas supportée sur ce navigateur")
|
||
);
|
||
setMediaReady(false);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: "user" },
|
||
audio: true,
|
||
});
|
||
if (cancelled) {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
return;
|
||
}
|
||
mediaStreamRef.current = stream;
|
||
setMediaReady(true);
|
||
setMediaError(null);
|
||
} catch (e) {
|
||
if (cancelled) return;
|
||
setMediaError(e instanceof Error ? e : new Error(String(e || "")));
|
||
setMediaReady(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [cameraPermission?.granted]);
|
||
|
||
useEffect(() => {
|
||
const video = previewVideoRef.current;
|
||
const stream = mediaStreamRef.current;
|
||
if (!video) return;
|
||
|
||
if (stream) {
|
||
try {
|
||
if (video.srcObject !== stream) {
|
||
video.srcObject = stream;
|
||
}
|
||
const playPromise = video.play?.();
|
||
if (playPromise && typeof playPromise.catch === "function") {
|
||
playPromise.catch(() => {});
|
||
}
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"previewVideo:attachError",
|
||
String(e?.message || e || "")
|
||
);
|
||
}
|
||
} else {
|
||
try {
|
||
video.srcObject = null;
|
||
} catch {}
|
||
}
|
||
}, [mediaReady, mediaError]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
try {
|
||
if (
|
||
mediaRecorderRef.current &&
|
||
mediaRecorderRef.current.state !== "inactive"
|
||
) {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
} catch {}
|
||
if (mediaStreamRef.current) {
|
||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||
mediaStreamRef.current = null;
|
||
}
|
||
releaseRecordingUrl();
|
||
};
|
||
}, [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 || "")
|
||
);
|
||
}
|
||
|
||
let videoUrl = null;
|
||
try {
|
||
videoUrl = await stopRecorderAndGetUrl();
|
||
if (!videoUrl) {
|
||
videoUrl = recordedUrlRef.current || null;
|
||
}
|
||
} catch (e) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"stopAndNavigate:recorderError",
|
||
String(e?.message || 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);
|
||
const rawPos = toSeconds(player?.currentTime);
|
||
|
||
// fallback monotone si le player ne donne rien
|
||
const fallback =
|
||
perfStartRef.current != null
|
||
? Math.max(0, (performance.now() - perfStartRef.current) / 1000)
|
||
: 0;
|
||
|
||
let nextDur = rawDur > 0 ? rawDur : dur || 0;
|
||
let nextPos =
|
||
Number.isFinite(rawPos) && rawPos >= 0
|
||
? rawPos
|
||
: fallback > 0
|
||
? fallback
|
||
: 0;
|
||
|
||
// 🔧 Correction 1-shot du "jump initial" (ex: 0 → 93s au 1er tick)
|
||
if (!correctedInitialJumpRef.current && nextPos > 1 && fallback < 1.5) {
|
||
try {
|
||
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 || "")
|
||
);
|
||
}
|
||
}
|
||
|
||
const timeSinceStart =
|
||
perfStartRef.current != null
|
||
? performance.now() - perfStartRef.current
|
||
: null;
|
||
|
||
const shouldTrustFallback =
|
||
fallback > 0 &&
|
||
((nextDur > 0 && nextPos > nextDur + 0.5) ||
|
||
nextPos - fallback > 1.5 ||
|
||
(timeSinceStart != null &&
|
||
timeSinceStart < 5000 &&
|
||
nextPos > fallback + 1));
|
||
|
||
if (shouldTrustFallback) {
|
||
console.log(LOG_PREFIX, "progressLoop:useFallback", {
|
||
rawPos,
|
||
fallback,
|
||
nextPosBeforeFallback: nextPos,
|
||
timeSinceStart,
|
||
});
|
||
nextPos = fallback;
|
||
}
|
||
|
||
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur;
|
||
|
||
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);
|
||
}, [player, stopAndNavigate]);
|
||
|
||
const startListenLoop = useCallback(() => {
|
||
if (listenTimerRef.current) clearInterval(listenTimerRef.current);
|
||
listenTimerRef.current = setInterval(async () => {
|
||
try {
|
||
if (player?.playing) {
|
||
listenedMsRef.current += 500;
|
||
if (
|
||
!viewsIncrementedRef.current &&
|
||
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) {
|
||
console.log(
|
||
LOG_PREFIX,
|
||
"listenLoop:error",
|
||
String(e?.message || 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 || "");
|
||
if (/not supported on the simulator/i.test(msg)) {
|
||
alert(
|
||
"Indisponible sur simulateur",
|
||
"L’enregistrement vidéo n’est pas disponible sur le simulateur. Merci d’utiliser un appareil réel.",
|
||
[
|
||
{
|
||
text: "OK",
|
||
onPress: () => {
|
||
try {
|
||
goBack();
|
||
} catch {}
|
||
},
|
||
},
|
||
],
|
||
{ cancelable: false }
|
||
);
|
||
}
|
||
}
|
||
}, [player, startRecorder, startProgressLoop, startListenLoop]);
|
||
|
||
const permissionsGranted = !!cameraPermission?.granted;
|
||
const canRecord = permissionsGranted && mediaReady && !mediaError;
|
||
|
||
const startCountdownThenRecord = useCallback(async () => {
|
||
if (!songUrl || !canRecord) return;
|
||
console.log(LOG_PREFIX, "startCountdownThenRecord");
|
||
await resetSession();
|
||
setIsPreparing(true);
|
||
setCountdown(5);
|
||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
|
||
countdownTimerRef.current = setInterval(() => {
|
||
setCountdown((c) => {
|
||
if (c <= 1) {
|
||
console.log(LOG_PREFIX, "countdown:launchRecording");
|
||
clearAllTimers();
|
||
setIsPreparing(false);
|
||
requestAnimationFrame(() => {
|
||
void startPlayback();
|
||
});
|
||
return 0;
|
||
}
|
||
console.log(LOG_PREFIX, "countdown:tick", c - 1);
|
||
return c - 1;
|
||
});
|
||
}, 1000);
|
||
}, [songUrl, canRecord, resetSession, startPlayback]);
|
||
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0;
|
||
|
||
return (
|
||
<Page
|
||
style={{ flex: 1 }}
|
||
width={"100%"}
|
||
maxWidth={2000}
|
||
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.black }}
|
||
headerType="NONE"
|
||
>
|
||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||
|
||
<View
|
||
style={{
|
||
width: isWeb ? 420 : "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: "#000000",
|
||
}}
|
||
/>
|
||
|
||
{/* 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: "#00000099",
|
||
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: "#00000066",
|
||
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 */}
|
||
{permissionsGranted && (isRecording || isPreparing) && (
|
||
<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={progressRatio}
|
||
/>
|
||
<View
|
||
style={{
|
||
position: "absolute",
|
||
width: 50,
|
||
height: 50,
|
||
borderRadius: 55,
|
||
backgroundColor: Palette.white,
|
||
}}
|
||
/>
|
||
</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>
|
||
</View>
|
||
</View>
|
||
</Page>
|
||
);
|
||
};
|
||
|
||
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;
|