972 lines
29 KiB
JavaScript
972 lines
29 KiB
JavaScript
import { useFocusEffect } from "@react-navigation/native";
|
||
import { useCameraPermissions } from "expo-camera";
|
||
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 } 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 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";
|
||
import { background } from "../../assets";
|
||
import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon";
|
||
import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache";
|
||
|
||
const TIME_BEFORE_INCREMENT_MS = 20000;
|
||
const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10;
|
||
const WEB_PREVIEW_WIDTH = 360;
|
||
|
||
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();
|
||
const { setLooping, isLooping } = usePlayer() || {};
|
||
|
||
// 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 preserveRecordingForNextScreenRef = useRef(false);
|
||
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
|
||
const latestLoopingValueRef = useRef(isLooping ?? false);
|
||
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 [isPaused, setIsPaused] = useState(false);
|
||
const [pos, setPos] = useState(0);
|
||
const [dur, setDur] = useState(0);
|
||
|
||
// timers / refs
|
||
const isPausedRef = useRef(false);
|
||
const progressTimerRef = useRef(null);
|
||
const listenTimerRef = useRef(null);
|
||
const countdownTimerRef = useRef(null);
|
||
const perfStartRef = useRef(null);
|
||
const pausedAtRef = useRef(null);
|
||
const pausedMsRef = useRef(0);
|
||
const listenedMsRef = useRef(0);
|
||
const viewsIncrementedRef = useRef(false);
|
||
const correctedInitialJumpRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
latestLoopingValueRef.current = isLooping ?? false;
|
||
}, [isLooping]);
|
||
useEffect(() => {
|
||
isPausedRef.current = isPaused;
|
||
}, [isPaused]);
|
||
|
||
// 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 = () => {
|
||
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 = () => {
|
||
setIsPreparing(false);
|
||
setIsRecording(false);
|
||
setIsPaused(false);
|
||
isPausedRef.current = false;
|
||
pausedAtRef.current = null;
|
||
pausedMsRef.current = 0;
|
||
setCountdown(0);
|
||
setPos(0);
|
||
setDur(0);
|
||
listenedMsRef.current = 0;
|
||
viewsIncrementedRef.current = false;
|
||
perfStartRef.current = null;
|
||
correctedInitialJumpRef.current = false;
|
||
};
|
||
|
||
const releaseRecordingUrl = useCallback(() => {
|
||
if (!recordedUrlRef.current) {
|
||
preserveRecordingForNextScreenRef.current = false;
|
||
return;
|
||
}
|
||
releaseBlobUrl(recordedUrlRef.current);
|
||
recordedUrlRef.current = null;
|
||
preserveRecordingForNextScreenRef.current = false;
|
||
}, []);
|
||
|
||
const startRecorder = useCallback(() => {
|
||
if (!mediaStreamRef.current) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
if (
|
||
mediaRecorderRef.current &&
|
||
mediaRecorderRef.current.state !== "inactive"
|
||
) {
|
||
mediaRecorderRef.current.stop();
|
||
}
|
||
} catch (e) { }
|
||
|
||
recordedChunksRef.current = [];
|
||
releaseRecordingUrl();
|
||
|
||
const mimeType = pickRecorderMimeType();
|
||
let recorder;
|
||
try {
|
||
recorder =
|
||
mimeType != null
|
||
? new MediaRecorder(mediaStreamRef.current, { mimeType })
|
||
: new MediaRecorder(mediaStreamRef.current);
|
||
} catch (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;
|
||
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;
|
||
registerBlobUrl(url, blob);
|
||
} else {
|
||
releaseRecordingUrl();
|
||
}
|
||
} catch (e) {
|
||
releaseRecordingUrl();
|
||
}
|
||
if (stopRecordingResolveRef.current) {
|
||
stopRecordingResolveRef.current(url);
|
||
stopRecordingResolveRef.current = null;
|
||
}
|
||
recordedChunksRef.current = [];
|
||
mediaRecorderRef.current = null;
|
||
stopRecordingPromiseRef.current = null;
|
||
};
|
||
|
||
try {
|
||
recorder.start(1000);
|
||
return true;
|
||
} catch (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) { }
|
||
|
||
if (!waitPromise) {
|
||
return recordedUrlRef.current || null;
|
||
}
|
||
|
||
let url = null;
|
||
try {
|
||
url = await waitPromise;
|
||
} catch (e) {
|
||
} finally {
|
||
stopRecordingPromiseRef.current = null;
|
||
stopRecordingResolveRef.current = null;
|
||
}
|
||
|
||
return url || recordedUrlRef.current || null;
|
||
}, []);
|
||
|
||
const resetSession = useCallback(async () => {
|
||
clearAllTimers();
|
||
resetUI();
|
||
try {
|
||
await player?.pause?.();
|
||
await player?.seekTo?.(0);
|
||
} catch (e) { }
|
||
try {
|
||
await stopRecorderAndGetUrl();
|
||
} catch (e) { }
|
||
if (!preserveRecordingForNextScreenRef.current) {
|
||
releaseRecordingUrl();
|
||
}
|
||
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
|
||
|
||
const resetSessionRef = useRef(resetSession);
|
||
useEffect(() => {
|
||
resetSessionRef.current = resetSession;
|
||
}, [resetSession]);
|
||
|
||
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]),
|
||
);
|
||
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
void resetSessionRef.current?.();
|
||
return () => {
|
||
void resetSessionRef.current?.();
|
||
};
|
||
}, []),
|
||
);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
if (!cameraPermission?.granted) {
|
||
await requestCameraPermission();
|
||
}
|
||
} catch (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: false,
|
||
});
|
||
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) { }
|
||
} 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;
|
||
}
|
||
if (!preserveRecordingForNextScreenRef.current) {
|
||
releaseRecordingUrl();
|
||
}
|
||
};
|
||
}, [releaseRecordingUrl, preserveRecordingForNextScreenRef]);
|
||
|
||
const stopAndNavigate = useCallback(async () => {
|
||
preserveRecordingForNextScreenRef.current = true;
|
||
clearAllTimers();
|
||
try {
|
||
await player?.pause?.();
|
||
} catch (e) { }
|
||
|
||
let videoUrl = null;
|
||
try {
|
||
videoUrl = await stopRecorderAndGetUrl();
|
||
if (!videoUrl) {
|
||
videoUrl = recordedUrlRef.current || null;
|
||
}
|
||
} catch (e) { }
|
||
|
||
setIsRecording(false);
|
||
setIsPaused(false);
|
||
isPausedRef.current = false;
|
||
pausedAtRef.current = null;
|
||
pausedMsRef.current = 0;
|
||
navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null });
|
||
}, [dur, player, pos, project, stopRecorderAndGetUrl]);
|
||
|
||
const startProgressLoop = useCallback(() => {
|
||
if (progressTimerRef.current) clearInterval(progressTimerRef.current);
|
||
|
||
progressTimerRef.current = setInterval(async () => {
|
||
const rawDur = toSeconds(player?.duration);
|
||
const rawPos = toSeconds(player?.currentTime);
|
||
const pausedSince =
|
||
pausedAtRef.current != null
|
||
? Math.max(0, performance.now() - pausedAtRef.current)
|
||
: 0;
|
||
const pausedTotal = pausedMsRef.current + pausedSince;
|
||
|
||
// fallback monotone si le player ne donne rien
|
||
const fallback =
|
||
perfStartRef.current != null
|
||
? Math.max(
|
||
0,
|
||
(performance.now() - perfStartRef.current - pausedTotal) / 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;
|
||
} catch (e) { }
|
||
}
|
||
|
||
const timeSinceStart =
|
||
perfStartRef.current != null
|
||
? Math.max(0, performance.now() - perfStartRef.current - pausedTotal)
|
||
: null;
|
||
|
||
const shouldTrustFallback =
|
||
fallback > 0 &&
|
||
((nextDur > 0 && nextPos > nextDur + 0.5) ||
|
||
nextPos - fallback > 1.5 ||
|
||
(timeSinceStart != null &&
|
||
timeSinceStart < 5000 &&
|
||
nextPos > fallback + 1));
|
||
|
||
if (shouldTrustFallback) {
|
||
nextPos = fallback;
|
||
}
|
||
|
||
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur;
|
||
|
||
setDur(nextDur);
|
||
setPos(nextPos);
|
||
|
||
if (isPausedRef.current) {
|
||
return;
|
||
}
|
||
|
||
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
|
||
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;
|
||
if (project?.id) {
|
||
try {
|
||
await projectsRef
|
||
.doc(project.id)
|
||
.set({ views: increment(1) }, { merge: true });
|
||
} catch (e) { }
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { }
|
||
}, 500);
|
||
}, [player, project?.id]);
|
||
|
||
const startPlayback = useCallback(async () => {
|
||
try {
|
||
setIsPaused(false);
|
||
isPausedRef.current = false;
|
||
pausedAtRef.current = null;
|
||
pausedMsRef.current = 0;
|
||
await player?.pause?.(); // s'assure qu'on repart propre
|
||
await player?.seekTo?.(0); // tente un seek d'amorçage
|
||
const recorderStarted = startRecorder();
|
||
if (!recorderStarted) {
|
||
}
|
||
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);
|
||
startProgressLoop();
|
||
startListenLoop();
|
||
} catch (e) {
|
||
setIsRecording(false);
|
||
setIsPreparing(false);
|
||
setIsPaused(false);
|
||
isPausedRef.current = false;
|
||
pausedAtRef.current = null;
|
||
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;
|
||
await resetSession();
|
||
setIsPaused(false);
|
||
isPausedRef.current = false;
|
||
setIsPreparing(true);
|
||
setCountdown(COUNTDOWN_SECONDS);
|
||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
|
||
countdownTimerRef.current = setInterval(() => {
|
||
setCountdown((c) => {
|
||
if (c <= 1) {
|
||
clearAllTimers();
|
||
setIsPreparing(false);
|
||
requestAnimationFrame(() => {
|
||
void startPlayback();
|
||
});
|
||
return 0;
|
||
}
|
||
return c - 1;
|
||
});
|
||
}, 1000);
|
||
}, [songUrl, canRecord, resetSession, startPlayback]);
|
||
|
||
const handleTogglePause = useCallback(async () => {
|
||
try {
|
||
if (!isRecording) return;
|
||
if (!isPausedRef.current) {
|
||
isPausedRef.current = true;
|
||
setIsPaused(true);
|
||
pausedAtRef.current = performance.now();
|
||
try {
|
||
await player?.pause?.();
|
||
} catch (e) { }
|
||
try {
|
||
const recorder = mediaRecorderRef.current;
|
||
if (recorder && recorder.state === "recording") {
|
||
recorder.pause?.();
|
||
}
|
||
} catch (e) { }
|
||
return;
|
||
}
|
||
const pausedAt = pausedAtRef.current;
|
||
if (pausedAt != null) {
|
||
pausedMsRef.current += performance.now() - pausedAt;
|
||
pausedAtRef.current = null;
|
||
}
|
||
isPausedRef.current = false;
|
||
setIsPaused(false);
|
||
try {
|
||
const recorder = mediaRecorderRef.current;
|
||
if (recorder && recorder.state === "paused") {
|
||
recorder.resume?.();
|
||
}
|
||
} catch (e) { }
|
||
try {
|
||
await player?.play?.();
|
||
} catch (e) { }
|
||
} catch (e) { }
|
||
}, [isRecording, player]);
|
||
|
||
const handleRestartRecording = useCallback(async () => {
|
||
try {
|
||
await startCountdownThenRecord();
|
||
} catch (e) { }
|
||
}, [startCountdownThenRecord]);
|
||
|
||
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0;
|
||
|
||
return (
|
||
<Page
|
||
style={{ flex: 1 }}
|
||
// containerStyle={{ backgroundColor: Palette.gray }}
|
||
headerType="NONE"
|
||
backgroundColor={Palette.grayMid}
|
||
// backgroundImg={background.playbackBG2}
|
||
>
|
||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||
|
||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
||
<View
|
||
style={{
|
||
width: WEB_PREVIEW_WIDTH,
|
||
maxWidth: "100%",
|
||
aspectRatio: 9 / 16,
|
||
overflow: "hidden",
|
||
borderRadius: 12,
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<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",
|
||
left: 0,
|
||
right: 0,
|
||
// bottom: gutters,
|
||
bottom: 0,
|
||
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}
|
||
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>
|
||
{isRecording && (
|
||
<TouchableOpacity
|
||
activeOpacity={0.9}
|
||
onPress={handleTogglePause}
|
||
style={{
|
||
marginTop: 14,
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 18,
|
||
borderRadius: 999,
|
||
backgroundColor: Palette.white,
|
||
shadowColor: "#000000",
|
||
shadowOpacity: 0.12,
|
||
shadowRadius: 10,
|
||
shadowOffset: { width: 0, height: 4 },
|
||
elevation: 4,
|
||
}}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: Palette.black,
|
||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
{isPaused ? "Reprendre" : "Mettre en pause"}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</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 + 180,
|
||
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>
|
||
</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: 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>
|
||
);
|
||
};
|
||
|
||
export default RecordPlayback;
|