diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js
index 6b6dad7..703919b 100644
--- a/src/screens/Library/MusicDetails.web.js
+++ b/src/screens/Library/MusicDetails.web.js
@@ -361,6 +361,7 @@ const MusicDetails = ({ route }) => {
padding: 10,
borderWidth: 1,
borderColor: Palette.transparentWhite,
+ maxWidth: "50%",
}}
>
@@ -500,69 +501,73 @@ const MusicDetails = ({ route }) => {
{/* Right column: lyrics */}
-
- {lines?.length ? (
-
- {lines.map((L, i) => (
- {
- lineYRef.current[i] = e.nativeEvent.layout.y;
- }}
+
+ {lines.length > 0 && (
+
+ {lines?.length ? (
+
+ {lines.map((L, i) => (
+ {
+ lineYRef.current[i] = e.nativeEvent.layout.y;
+ }}
+ style={{
+ marginBottom: 8,
+ flexDirection: "row",
+ flexWrap: "wrap",
+ }}
+ >
+ {(L.words || []).map((w, j) => (
+ = (w.startS || 0)
+ ? styles.karaokeWordActive
+ : styles.karaokeWord
+ }
+ >
+ {w.text}
+ {j < (L.words?.length || 1) - 1 ? " " : ""}
+
+ ))}
+
+ ))}
+
+ ) : description?.length > 0 ? (
+
+
- {(L.words || []).map((w, j) => (
- = (w.startS || 0)
- ? styles.karaokeWordActive
- : styles.karaokeWord
- }
- >
- {w.text}
- {j < (L.words?.length || 1) - 1 ? " " : ""}
-
- ))}
-
- ))}
-
- ) : description?.length > 0 ? (
-
-
- {description}
-
-
- ) : null}
-
+ {description}
+
+
+ ) : null}
+
+ )}
);
diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js
index 8e2ea22..dcdd74a 100644
--- a/src/screens/Playback/RecordPlayback.web.js
+++ b/src/screens/Playback/RecordPlayback.web.js
@@ -1,6 +1,6 @@
import { useFocusEffect } from "@react-navigation/native";
import { useAudioPlayer } from "expo-audio";
-import { CameraView, useCameraPermissions } from "expo-camera";
+import { useCameraPermissions } from "expo-camera";
import React, {
useCallback,
useEffect,
@@ -23,193 +23,302 @@ import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
-const TIME_BEFORE_INCREMENT_MS = 20000; // 20s
+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 projectId = project?.id;
- const songIndex = project?.songIndex;
- // Permissions
+ const songIndex = Number(project?.songIndex ?? 0) || 0;
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
- // Refs
- const cameraRef = useRef(null);
- const countdownTimerRef = useRef(null);
- const listenTimerRef = useRef(null);
- const checkSongEndRef = useRef(null);
- const stopRequestedRef = useRef(false);
- const startedRef = useRef(false); // empêche les doubles démarrages
- const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick
-
- // Compteurs vues
- const listenedMsRef = useRef(0);
- const incrementDoneRef = useRef(false);
-
- // UI / state
- const [isPreparing, setIsPreparing] = useState(false);
- const [countdown, setCountdown] = useState(0);
- const [isRecording, setIsRecording] = useState(false);
- const [showProgress, setShowProgress] = useState(false);
- const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
-
- // Musique
+ // Player
const songUrl = project?.songUrl || null;
const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined);
- const musicIndex = useMemo(() => {
- const i = Number(songIndex);
- return Number.isFinite(i) && i >= 0 ? i : 0;
- }, [songIndex]);
+ // 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?.[musicIndex];
-
+ 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, musicIndex]);
+ }, [project?.musicTimestamps, songIndex]);
- // Build line groups to display line-by-line
- const lines = useMemo(() => {
- const out = [];
- let buf = [];
- let start = null;
- const clean = (txt) =>
- String(txt || "")
- .replace(/\s+/g, " ")
- .trim();
- const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim());
- const isSectionTag = (txt) =>
- /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim());
- for (let i = 0; i < alignedWords.length; i++) {
- const w = alignedWords[i];
- const original = String(w.word || "");
- const textNoNewline = original.replace(/\n/g, " ");
- const gapToNext =
- i < alignedWords.length - 1
- ? Math.max(0, alignedWords[i + 1].startS - w.endS)
- : 0;
+ 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;
+ };
- // If buffer empty, mark start
- if (buf.length === 0) start = w.startS;
-
- // Section tag becomes its own line
- if (isSectionTag(textNoNewline)) {
- const joined = clean(textNoNewline);
- if (joined)
- out.push({ text: joined, startS: start ?? w.startS, endS: w.endS });
- buf = [];
- start = null;
- continue;
- }
-
- buf.push(textNoNewline);
-
- const eolByNewline = /\n/.test(original);
- const eolByPause = gapToNext >= 0.6;
- const eolByPunct = isSentenceEnd(textNoNewline);
- const isLast = i === alignedWords.length - 1;
-
- if (eolByNewline || eolByPause || eolByPunct || isLast) {
- const joined = clean(buf.join(" "));
- if (joined)
- out.push({ text: joined, startS: start ?? w.startS, endS: w.endS });
- buf = [];
- start = null;
- }
- }
- return out;
- }, [alignedWords]);
- // console.log("alignedWords:", alignedWords);
- useEffect(() => {
+ const resetUI = () => {
+ console.log(LOG_PREFIX, "resetUI");
+ setIsPreparing(false);
+ setIsRecording(false);
+ setCountdown(0);
+ setPos(0);
+ setDur(0);
listenedMsRef.current = 0;
- incrementDoneRef.current = false;
- }, [songUrl]);
+ viewsIncrementedRef.current = false;
+ perfStartRef.current = null;
+ correctedInitialJumpRef.current = false;
+ };
- // Poll player -> progress ring
- useEffect(() => {
- if (!player) return;
- const id = setInterval(() => {
+ const releaseRecordingUrl = useCallback(() => {
+ if (recordedUrlRef.current) {
try {
- const dur = (player?.duration || 0) * 1000;
- const pos = (player?.currentTime || 0) * 1000;
- setProgressInfo({ pos, dur });
- } catch (_) {}
- }, 250);
- return () => clearInterval(id);
- }, [player]);
-
- // Compute current line index from playback time
- const currentTimeS = (progressInfo?.pos || 0) / 1000;
- const currentLineIdx = useMemo(() => {
- if (!lines || lines.length === 0) return -1;
- for (let i = 0; i < lines.length; i++) {
- const L = lines[i];
- if (currentTimeS >= L.startS && currentTimeS <= L.endS) return i;
+ URL.revokeObjectURL(recordedUrlRef.current);
+ } catch {}
+ recordedUrlRef.current = null;
}
- if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
- return lines.length - 1;
- return -1;
- }, [lines, currentTimeS]);
+ }, []);
- // Permissions au mount + cleanup
- useEffect(() => {
- (async () => {
- try {
- if (!cameraPermission?.granted) await requestCameraPermission();
- } catch (_) {}
- })();
- return () => {
- try {
- if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
- if (listenTimerRef.current) clearInterval(listenTimerRef.current);
- if (checkSongEndRef.current) clearInterval(checkSongEndRef.current);
- countdownTimerRef.current = null;
- listenTimerRef.current = null;
- checkSongEndRef.current = null;
- } catch (_) {}
- };
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ const startRecorder = useCallback(() => {
+ if (!mediaStreamRef.current) {
+ console.log(LOG_PREFIX, "mediaRecorder:start:noStream");
+ return false;
+ }
- // 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;
- }
- if (checkSongEndRef.current) {
- clearInterval(checkSongEndRef.current);
- checkSongEndRef.current = null;
+ if (
+ mediaRecorderRef.current &&
+ mediaRecorderRef.current.state !== "inactive"
+ ) {
+ mediaRecorderRef.current.stop();
}
+ } catch (e) {
+ console.log(
+ LOG_PREFIX,
+ "mediaRecorder:stopPrevious:error",
+ String(e?.message || e || "")
+ );
+ }
- startedRef.current = false;
- stopRequestedRef.current = false;
- countdownActiveRef.current = false;
- listenedMsRef.current = 0;
- incrementDoneRef.current = false;
+ recordedChunksRef.current = [];
+ releaseRecordingUrl();
- setIsPreparing(false);
- setIsRecording(false);
- setShowProgress(false);
- setCountdown(0);
+ 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;
+ }
- if (player) {
- try {
- if (player.playing) await player.pause?.();
- await player.seekTo?.(0);
- } catch (_) {}
+ 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);
}
- } catch (_) {}
- }, [player]);
+ };
+
+ 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(() => {
@@ -218,146 +327,324 @@ const RecordPlayback = ({ route }) => {
}, [resetSession])
);
- // Lancer le compte à rebours (le tick décrémente uniquement)
- const startCountdownThenRecord = async () => {
- if (!songUrl) return;
- await resetSession();
- setIsPreparing(true);
- setShowProgress(false);
- setCountdown(5);
-
- // On démarre l'intervalle puis on active le flag
- if (countdownTimerRef.current) {
- clearInterval(countdownTimerRef.current);
- countdownTimerRef.current = null;
- }
- countdownTimerRef.current = setInterval(() => {
- setCountdown((c) => Math.max(0, c - 1));
- }, 1000);
- countdownActiveRef.current = true;
- };
-
- // Quand le compteur a réellement démarré ET atteint 0, on démarre
useEffect(() => {
- if (!isPreparing) return;
- if (!countdownActiveRef.current) return; // évite l'auto-start
-
- if (countdown === 0 && !startedRef.current) {
- startedRef.current = true;
- if (countdownTimerRef.current) {
- clearInterval(countdownTimerRef.current);
- countdownTimerRef.current = null;
- }
- countdownActiveRef.current = false;
- // Bascule après rendu de la frame courante
- requestAnimationFrame(() => {
- setIsPreparing(false);
- setShowProgress(true);
- void startRecordingWithMusic();
- });
- }
- }, [countdown, isPreparing]);
-
- const startRecordingWithMusic = async () => {
- try {
- stopRequestedRef.current = false;
- listenedMsRef.current = 0;
- incrementDoneRef.current = false;
-
- setIsRecording(true);
- setShowProgress(true);
- const recordPromise = cameraRef.current?.recordAsync?.({
- mute: true,
- maxDuration: 600,
- });
-
- if (player && songUrl) {
- try {
- await player.seekTo?.(0);
- } catch (_) {}
- await player.play?.();
- }
-
- // Incrément des vues
- if (!listenTimerRef.current && project?.id) {
- listenTimerRef.current = setInterval(async () => {
- try {
- if (player?.playing) {
- listenedMsRef.current += 500;
- if (
- !incrementDoneRef.current &&
- listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS
- ) {
- incrementDoneRef.current = true;
- try {
- await projectsRef
- .doc(project.id)
- .set({ views: increment(1) }, { merge: true });
- } catch (_) {}
- }
- }
- } catch (_) {}
- }, 500);
- }
-
- // Fin du morceau -> stop recording
- if (!checkSongEndRef.current) {
- checkSongEndRef.current = setInterval(() => {
- try {
- if (!player) return;
- const duration = (player?.duration || 0) * 1000;
- const currentTime = (player?.currentTime || 0) * 1000;
- if (
- (!player.playing && !stopRequestedRef.current) ||
- (duration > 0 && currentTime >= duration - 600)
- ) {
- stopRequestedRef.current = true;
- if (checkSongEndRef.current) {
- clearInterval(checkSongEndRef.current);
- checkSongEndRef.current = null;
- }
- try {
- cameraRef.current?.stopRecording?.();
- } catch (_) {}
- }
- } catch (_) {}
- }, 500);
- }
-
- const video = await recordPromise;
-
- if (checkSongEndRef.current) {
- clearInterval(checkSongEndRef.current);
- checkSongEndRef.current = null;
- }
+ (async () => {
try {
- if (player?.playing) await player.pause?.();
- } catch (_) {}
- if (listenTimerRef.current) {
- clearInterval(listenTimerRef.current);
- listenTimerRef.current = null;
+ 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 || "")
+ );
+ }
}
- setIsRecording(false);
- setShowProgress(false);
+ const timeSinceStart =
+ perfStartRef.current != null
+ ? performance.now() - perfStartRef.current
+ : null;
- if (video?.uri)
- navigate(Routes.RecordedPlayback, { videoUri: video.uri, project });
- else navigate(Routes.RecordedPlayback, { project });
+ 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("RecordPlayback error:", e);
+ console.log(
+ LOG_PREFIX,
+ "startPlayback:error",
+ String(e?.message || e || "")
+ );
setIsRecording(false);
setIsPreparing(false);
- setShowProgress(false);
- if (listenTimerRef.current) {
- clearInterval(listenTimerRef.current);
- listenTimerRef.current = null;
- }
- if (checkSongEndRef.current) {
- clearInterval(checkSongEndRef.current);
- checkSongEndRef.current = null;
- }
- // Inform the user when using a simulator where recording isn't supported
const msg = String(e?.message || e || "");
if (/not supported on the simulator/i.test(msg)) {
Alert.alert(
@@ -369,7 +656,7 @@ const RecordPlayback = ({ route }) => {
onPress: () => {
try {
goBack();
- } catch (_) {}
+ } catch {}
},
},
],
@@ -377,9 +664,35 @@ const RecordPlayback = ({ route }) => {
);
}
}
- };
+ }, [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 (
{
headerType="NONE"
>
+
-
+
+ {/* Overlay */}
+
-
-
- {/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
- {isPreparing && countdown >= 1 && !showProgress && (
-
-
- {countdown}
-
-
- )}
-
- {/* Permission prompt */}
- {!permissionsGranted && (
-
- {
- try {
- if (!cameraPermission?.granted)
- await requestCameraPermission();
- } catch (_) {}
- }}
- />
-
- )}
-
-
- {/* Start button */}
- {permissionsGranted && !isPreparing && !isRecording && (
-
- )}
-
- {/* Progress circulaire */}
- {permissionsGranted && (isRecording || showProgress) && (
+
+ {mediaError && (
+
+ {"Caméra indisponible : "}
+ {String(mediaError?.message || "").trim() ||
+ "vérifie les permissions"}
+
+
+ )}
+ {!mediaError && !mediaReady && (
+
-
-
-
-
+ Initialisation de la caméra…
+
+
+ )}
+ {isPreparing && countdown >= 1 && !isRecording && (
+
+
+ {countdown}
+
)}
-
- {/* CreateLyricsHeader overlay outside of CameraView */}
+
+ {/* Start */}
+ {permissionsGranted && !isPreparing && !isRecording && (
+
+ )}
+
+ {/* Progress circulaire */}
+ {permissionsGranted && (isRecording || isPreparing) && (
+
+
+
+
+
+
+ )}
+
+ {/* Permission prompt */}
+ {!permissionsGranted && (
+
+ {
+ try {
+ if (!cameraPermission?.granted)
+ await requestCameraPermission();
+ } catch {}
+ }}
+ />
+
+ )}
+
+
+ {/* Lyrics */}
{
}}
>
- {(isRecording || showProgress) && alignedWords?.length > 0 ? (
-
+ {isRecording && alignedWords.length > 0 ? (
+
) : (
{
);
};
-// Progress ring SVG
const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
const r = size / 2 - strokeWidth / 2;
const c = 2 * Math.PI * r;
@@ -589,42 +954,3 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
};
export default RecordPlayback;
-
-// Render previous/current/next line to reduce jumpiness
-const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
- const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
- const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : "";
- const next =
- currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : "";
- return (
-
- {/* {prev ? (
-
- {prev}
-
- ) : null} */}
-
- {curr}
-
- {next ? (
-
- {next}
-
- ) : null}
-
- );
-};
diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js
index cfeed6e..a45988c 100644
--- a/src/screens/Playback/RecordedPlayback.js
+++ b/src/screens/Playback/RecordedPlayback.js
@@ -15,7 +15,7 @@ import { gutters } from "../../styles";
const RecordedPlayback = ({ route }) => {
const { videoUri, project } = route.params || {};
const songUrl = project?.songUrl || null;
-
+ console.log("video uri is : ", videoUri);
const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined);
const videoPlayer = useVideoPlayer(videoUri || null, (p) => {
p.loop = false;
diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js
new file mode 100644
index 0000000..521043d
--- /dev/null
+++ b/src/screens/Playback/RecordedPlayback.web.js
@@ -0,0 +1,235 @@
+import { useAudioPlayer } from "expo-audio";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { Text, View } from "react-native";
+import { background } from "../../assets";
+import BorderGradientButton from "../../components/BorderGradientButton";
+import GradientButton from "../../components/GradientButton";
+import MusicLandHeader from "../../components/MusicLandHeader";
+import Slider from "../../components/Slider";
+import Page from "../../layouts/Page";
+import { Routes } from "../../navigation";
+import { goBack, navigate } from "../../navigation/NavigationService";
+import { gutters, Palette } from "../../styles";
+
+const fmtSeconds = (s) => {
+ const total = Math.max(0, Math.floor(Number(s || 0)));
+ const m = Math.floor(total / 60).toString();
+ const sec = (total % 60).toString().padStart(2, "0");
+ return `${m}:${sec}`;
+};
+
+const toSeconds = (value) => {
+ const n = Number(value ?? 0);
+ if (!Number.isFinite(n) || n < 0) return 0;
+ return n > 10000 ? n / 1000 : n; // heuristique ms → s
+};
+
+const RecordedPlayback = ({ route }) => {
+ const { videoUri, project } = route.params || {};
+ const songUrl = project?.songUrl || null;
+ console.log("video uri is : ", videoUri);
+ // AUDIO PLAYER (expo-audio → seconds)
+ const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined);
+
+ // Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
+ const videoElRef = useRef(null);
+
+ const [progress, setProgress] = useState({
+ posS: 0, // secondes
+ durS: 0, // secondes
+ playing: false,
+ });
+
+ // Démarrage / arrêt
+ useEffect(() => {
+ let mounted = true;
+ const start = async () => {
+ try {
+ if (audioPlayer && songUrl) {
+ await audioPlayer.play?.();
+ }
+ if (videoElRef.current && videoUri) {
+ // Lecture vidéo HTML5 (muet pour éviter les policies)
+ videoElRef.current.muted = true;
+ videoElRef.current.play().catch(() => {});
+ }
+ } catch {}
+ };
+ start();
+
+ return () => {
+ mounted = false;
+ try {
+ if (audioPlayer?.playing) audioPlayer.pause?.();
+ } catch {}
+ try {
+ if (videoElRef.current) {
+ videoElRef.current.pause();
+ }
+ } catch {}
+ };
+ }, [audioPlayer, songUrl, videoUri]);
+
+ // Boucle de progression + éventuelle sync de la vidéo si fournie
+ useEffect(() => {
+ const id = setInterval(() => {
+ try {
+ const durS = toSeconds(audioPlayer?.duration); // secondes
+ const posS = toSeconds(audioPlayer?.currentTime); // secondes
+
+ setProgress({
+ posS,
+ durS,
+ playing: !!audioPlayer?.playing,
+ });
+
+ // Sync vidéo si on a une source vidéo
+ if (
+ videoElRef.current &&
+ videoUri &&
+ !Number.isNaN(videoElRef.current.currentTime)
+ ) {
+ const v = Number(videoElRef.current.currentTime || 0);
+ const drift = Math.abs(v - posS);
+ if (drift > 0.35) {
+ videoElRef.current.currentTime = Math.max(0, posS);
+ }
+ }
+ } catch {}
+ }, 250);
+ return () => clearInterval(id);
+ }, [audioPlayer, videoUri]);
+
+ // Slider: ratio 0..1
+ const sliderProgress = useMemo(() => {
+ return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0;
+ }, [progress]);
+
+ const onSeek = async (ratio) => {
+ try {
+ const durS = Number(progress.durS || 0);
+ const target = durS * ratio; // secondes
+ if (audioPlayer && durS > 0) {
+ await audioPlayer.seekTo?.(Math.floor(target)); // seek en secondes
+ }
+ if (videoElRef.current && videoUri) {
+ videoElRef.current.currentTime = Math.max(0, target);
+ }
+ } catch {}
+ };
+
+ const wasPlayingRef = useRef(false);
+ const onSeekStart = async () => {
+ try {
+ wasPlayingRef.current = !!audioPlayer?.playing;
+ if (audioPlayer?.playing) await audioPlayer.pause?.();
+ if (videoElRef.current && !videoElRef.current.paused) {
+ videoElRef.current.pause();
+ }
+ } catch {}
+ };
+ const onSeekEnd = async () => {
+ try {
+ if (wasPlayingRef.current) {
+ if (audioPlayer) await audioPlayer.play?.();
+ if (videoElRef.current && videoUri)
+ videoElRef.current.play().catch(() => {});
+ }
+ } catch {}
+ };
+
+ return (
+
+
+
+
+ {/* Bloc vidéo optionnel si jamais tu as un videoUri sur web */}
+ {videoUri ? (
+
+
+
+ ) : (
+ // Placeholder quand pas de vidéo sur web
+
+
+ Aperçu vidéo non disponible sur le web
+
+
+ )}
+
+
+
+
+
+ {
+ navigate(Routes.DownloadSongs, {
+ action: "playback",
+ uri: videoUri || null, // peut être null sur web
+ project,
+ });
+ }}
+ />
+ {
+ try {
+ if (audioPlayer?.playing) audioPlayer.pause?.();
+ } catch {}
+ try {
+ if (videoElRef.current && !videoElRef.current.paused)
+ videoElRef.current.pause();
+ } catch {}
+ navigate(Routes.RecordPlayback, { project });
+ }}
+ />
+
+
+
+ );
+};
+
+export default RecordedPlayback;
diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/DownloadSongs.js
index fcf103f..a519da1 100644
--- a/src/screens/Production/DownloadSongs.js
+++ b/src/screens/Production/DownloadSongs.js
@@ -17,11 +17,11 @@ import { uploadFileToFirebase } from "../../helpers/uploadToFirebase";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
+import { useUserData } from "../../providers/UserDataProvider";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { size } from "../../styles/Style";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
-import { useUserData } from "../../providers/UserDataProvider";
const DownloadSongs = ({ route }) => {
const { currentUID } = useUserData();
@@ -50,11 +50,11 @@ const DownloadSongs = ({ route }) => {
playbackUrl: resultURI,
updatedAt: serverTimestamp(),
},
- { merge: true },
+ { merge: true }
);
setTooltip({
type: "success",
- text: "Vidéo uploadée, conversion HLS en cours…",
+ text: "Vidéo uploadée",
});
} else {
setTooltip({