From 2a821182c5c4aba3538cc5e553aba3ba05c3b92e Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 13:18:20 +0200 Subject: [PATCH 1/7] playback --- src/screens/Library/MusicDetails.js | 13 +- src/screens/NewMusicOptions.js | 39 +++- src/screens/Playback/Playback.js | 17 +- src/screens/Playback/RecordPlayback.js | 247 +++++++++++++++++++++-- src/screens/Playback/RecordedPlayback.js | 16 +- 5 files changed, 285 insertions(+), 47 deletions(-) diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index ee86413..d257a7a 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -1,8 +1,15 @@ import { useRoute } from "@react-navigation/core"; import { Audio } from "expo-audio"; -import React, { useEffect, useMemo, useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View, Image as RNImage } from "react-native"; import { Image as ExpoImage } from "expo-image"; +import React, { useEffect, useMemo, useState } from "react"; +import { + Pressable, + Image as RNImage, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { useGlobal } from "reactn"; import { background, icons, img } from "../../assets"; @@ -336,7 +343,7 @@ const MusicDetails = () => { /> {/* Previous (rewind 10s) */} - seekBy(-10)}> + seekBy(-10)}> { ? currentProjet.lyrics.length > 0 : !!currentProjet?.lyrics; const hasCover = !!currentProjet?.coverUrl; + console.log("current projet cover", currentProjet?.coverUrl); const isLocked = (index) => { // 0: Songwriter, 1: Beatmaker, 2: Producer, 3: Director - if (index === 3) return true; // Director toujours verrouillé - if (!hasProject) return index !== 0; // seulement Songwriter - if (hasCover) return index !== 2; // seulement Producer - if (hasLyrics) return !(index === 0 || index === 1); // Songwriter + Beatmaker - return index !== 0; // par défaut seulement Songwriter + // Rules: + // - If no project: only Songwriter (0) is available. + // - If project exists: Songwriter (0) is always available. + // - If lyrics exist: Beatmaker (1) becomes available. + // - If cover exists: Producer (2) and Director (3) become available. + if (!hasProject) return index !== 0; + + const allowed = new Set([0]); // songwriter always allowed when project exists + if (hasLyrics) { + allowed.add(1); + } + if (hasCover) { + allowed.add(2); + allowed.add(3); + } + + return !allowed.has(index); }; const onPressOption = (index, item) => { @@ -80,6 +93,12 @@ const NewMusicOptions = ({ route }) => { case 2: navigate(Routes.Production, { action: item.type }); break; + case 3: + console.log("test"); + navigate(Routes.Playback, { + action: item.type, + project: currentProjet, + }); default: break; } diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index b9db567..9790bfd 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -1,15 +1,16 @@ -import { View, Text, StyleSheet, Image } from "react-native"; import React from "react"; +import { Image, StyleSheet, View } from "react-native"; import { ai, background } from "../../assets"; -import MusicLandHeader from "../../components/MusicLandHeader"; -import { goBack, navigate } from "../../navigation/NavigationService"; -import { gutters } from "../../styles"; -import Page from "../../layouts/Page"; import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; +import MusicLandHeader from "../../components/MusicLandHeader"; +import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; +import { goBack, navigate } from "../../navigation/NavigationService"; +import { gutters } from "../../styles"; -const Playback = () => { +const Playback = ({ route }) => { + const { project } = route.params; return ( @@ -23,10 +24,10 @@ const Playback = () => { > - + {/* */} navigate(Routes.RecordPlayback)} + onPress={() => navigate(Routes.RecordPlayback, { project })} /> diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 56941a1..dadd0b7 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -1,21 +1,181 @@ -import { Text, View } from "react-native"; +import { Audio } from "expo-audio"; +import { CameraView, useCameraPermissions } from "expo-camera"; import React from "react"; -import { CameraView } from "expo-camera"; -import { gutters, Palette } from "../../styles"; -import MusicLandHeader from "../../components/MusicLandHeader"; -import { goBack, navigate } from "../../navigation/NavigationService"; +import { Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; -import { FONT_FAMILY } from "../../styles/Fonts"; import GradientButton from "../../components/GradientButton"; +import MusicLandHeader from "../../components/MusicLandHeader"; 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 RecordPlayback = () => { +const RecordPlayback = ({ route }) => { const { top } = useSafeAreaInsets(); + const { project } = route.params || {}; + + // Permissions + const [cameraPermission, requestCameraPermission] = useCameraPermissions(); + + // Refs + const cameraRef = React.useRef(null); + const soundRef = React.useRef(null); + const countdownTimerRef = React.useRef(null); + const stopRequestedRef = React.useRef(false); + + // UI / state + const [isPreparing, setIsPreparing] = React.useState(false); + const [countdown, setCountdown] = React.useState(0); + const [isRecording, setIsRecording] = React.useState(false); + + // Derive song URL robustly + const songUrl = project?.songUrl || null; + + React.useEffect(() => { + // Request permissions on mount if not granted + (async () => { + try { + if (!cameraPermission?.granted) await requestCameraPermission(); + } catch (_) {} + })(); + return () => { + // Cleanup timers and audio on unmount + try { + if (countdownTimerRef.current) { + global.clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + (async () => { + try { + if (soundRef.current) { + soundRef.current.setOnPlaybackStatusUpdate(null); + await soundRef.current.unloadAsync(); + soundRef.current = null; + } + } catch (_) {} + })(); + } catch (_) {} + }; + }, []); + + const startCountdownThenRecord = async () => { + console.log("start countdown"); + if (!songUrl) return; + console.log("songUrl exists"); + setIsPreparing(true); + setCountdown(5); + // Start music + recording immediately when countdown starts + void startRecordingWithMusic(); + // 5 -> 0 countdown display only + countdownTimerRef.current = global.setInterval(() => { + setCountdown((c) => { + const next = (c || 0) - 1; + if (next <= 0) { + global.clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + return Math.max(0, next); + }); + }, 1000); + }; + + const startRecordingWithMusic = async () => { + try { + stopRequestedRef.current = false; + console.log("stop requested ref is : ", soundRef.current); + // Prepare audio + if (soundRef.current) { + console.log("if sound ref current"); + try { + soundRef.current.setOnPlaybackStatusUpdate(null); + await soundRef.current.unloadAsync(); + console.log("unload sound ref"); + } catch (_) {} + soundRef.current = null; + } + + // Ensure audio mode allows playback alongside camera recording + try { + await Audio.setAudioModeAsync({ + playsInSilentMode: true, + interruptionMode: "mixWithOthers", + allowsRecording: true, + shouldPlayInBackground: false, + }); + } catch (_) {} + + const { sound } = await Audio.Sound.createAsync( + { uri: songUrl }, + { shouldPlay: false } + ); + + sound.setOnPlaybackStatusUpdate((status) => { + if (!status || !status.isLoaded) return; + if (status.didJustFinish && !stopRequestedRef.current) { + stopRequestedRef.current = true; + try { + cameraRef.current?.stopRecording?.(); + } catch (_) {} + } + }); + soundRef.current = sound; + + // Start recording + setIsRecording(true); + const recordPromise = cameraRef.current?.recordAsync?.({ + mute: true, + maxDuration: 600, // safety cap (10 min) + }); + + // Start playing + await sound.playAsync(); + + // Wait for recording to stop (either by song end or manual stop) + const video = await recordPromise; + + // Ensure audio stops and cleanup + try { + const s = soundRef.current; + if (s) { + s.setOnPlaybackStatusUpdate(null); + await s.stopAsync().catch(() => {}); + await s.unloadAsync().catch(() => {}); + } + } catch (_) {} + soundRef.current = null; + + setIsRecording(false); + setIsPreparing(false); + + // Navigate to next screen with video uri if available + if (video?.uri) { + navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); + } else { + navigate(Routes.RecordedPlayback, { project }); + } + } catch (e) { + // Fallback on error + setIsRecording(false); + setIsPreparing(false); + try { + soundRef.current?.setOnPlaybackStatusUpdate?.(null); + await soundRef.current?.unloadAsync?.(); + } catch (_) {} + soundRef.current = null; + } + }; + + const permissionsGranted = !!cameraPermission?.granted; return ( - + { musique, et s'arrêtera à la fin du morceau. + + {/* Centered countdown overlay */} + {(isPreparing || isRecording) && countdown > 0 && ( + + + {countdown} + + + )} + + {/* Permission prompt */} + {!permissionsGranted && ( + + { + try { + if (!cameraPermission?.granted) + await requestCameraPermission(); + } catch (_) {} + }} + /> + + )} - navigate(Routes.RecordedPlayback)} - /> + + {/* Start button */} + {permissionsGranted && !isPreparing && !isRecording && ( + + )} diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index 999f9c1..c4e4eb3 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -1,14 +1,14 @@ -import { View, Text, Image } from "react-native"; import React from "react"; -import Page from "../../layouts/Page"; +import { Image, View } from "react-native"; import { background, img } 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 } from "../../styles"; -import Slider from "../../components/Slider"; -import GradientButton from "../../components/GradientButton"; -import { Routes } from "../../navigation"; -import BorderGradientButton from "../../components/BorderGradientButton"; const RecordedPlayback = () => { return ( @@ -32,9 +32,7 @@ const RecordedPlayback = () => { - + navigate(Routes.ChooseDecor)} From 669442570777e5309ab101246c336de8940af837 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 14:43:42 +0200 Subject: [PATCH 2/7] fix timer --- src/screens/Playback/RecordPlayback.js | 238 +++++++++++++------------ 1 file changed, 121 insertions(+), 117 deletions(-) diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 9396382..7f1699b 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -1,20 +1,29 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { useAudioPlayer } from "expo-audio"; import { CameraView, useCameraPermissions } from "expo-camera"; -import React from "react"; +import React, { useCallback, useEffect, 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 GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; +import { increment, projectsRef } from "../../config/firebase"; 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"; -// ADD: Firestore helpers to increment views -import { useFocusEffect } from "@react-navigation/native"; -import { useAudioPlayer } from "expo-audio"; -import { useCallback, useEffect, useRef, useState } from "react"; -import Svg, { Circle } from "react-native-svg"; -import { increment, projectsRef } from "../../config/firebase"; + +/** + * RecordPlayback — refactor robuste du compteur + * + * Correction de l'auto-start : on garde un flag countdownActiveRef pour + * empêcher l'effet de se déclencher tant que le timer n'a pas réellement démarré. + * On affiche 5→1 puis on bascule (pas de 0 visible) pour éviter le "bloqué sur 1". + */ + +const TIME_BEFORE_INCREMENT_MS = 20000; // 20s + const RecordPlayback = ({ route }) => { const { top } = useSafeAreaInsets(); const { project } = route.params || {}; @@ -25,13 +34,15 @@ const RecordPlayback = ({ route }) => { // 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 - // Listen counter refs (inspired by MusicDetails) + // Compteurs vues const listenedMsRef = useRef(0); const incrementDoneRef = useRef(false); - const timerRef = useRef(null); - const timeBeforeIncrement = 20000; // 20 seconds // UI / state const [isPreparing, setIsPreparing] = useState(false); @@ -40,70 +51,74 @@ const RecordPlayback = ({ route }) => { const [showProgress, setShowProgress] = useState(false); const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); - // Derive song URL robustly + // Musique const songUrl = project?.songUrl || null; const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); - // useEffect(() => { - // setVideo(null); - // }, []); + useEffect(() => { listenedMsRef.current = 0; incrementDoneRef.current = false; }, [songUrl]); - // Poll player state to update progress ring + // Poll player -> progress ring useEffect(() => { if (!player) return; - const id = global.setInterval(() => { + const id = setInterval(() => { try { const dur = (player?.duration || 0) * 1000; const pos = (player?.currentTime || 0) * 1000; setProgressInfo({ pos, dur }); } catch (_) {} }, 250); - return () => global.clearInterval(id); + return () => clearInterval(id); }, [player]); + // Permissions au mount + cleanup useEffect(() => { - // Request permissions on mount if not granted (async () => { try { if (!cameraPermission?.granted) await requestCameraPermission(); } catch (_) {} })(); return () => { - // Cleanup timers on unmount try { - if (countdownTimerRef.current) { - global.clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - if (timerRef.current) { - global.clearInterval(timerRef.current); - timerRef.current = null; - } + 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 - // Fully reset audio and recording state (used on focus and before new session) + // Reset complet const resetSession = useCallback(async () => { try { if (countdownTimerRef.current) { - global.clearInterval(countdownTimerRef.current); + clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } - if (timerRef.current) { - global.clearInterval(timerRef.current); - timerRef.current = null; + if (listenTimerRef.current) { + clearInterval(listenTimerRef.current); + listenTimerRef.current = null; } + if (checkSongEndRef.current) { + clearInterval(checkSongEndRef.current); + checkSongEndRef.current = null; + } + + startedRef.current = false; stopRequestedRef.current = false; + countdownActiveRef.current = false; listenedMsRef.current = 0; incrementDoneRef.current = false; + setIsPreparing(false); setIsRecording(false); setShowProgress(false); setCountdown(0); + if (player) { try { if (player.playing) await player.pause?.(); @@ -113,7 +128,6 @@ const RecordPlayback = ({ route }) => { } catch (_) {} }, [player]); - // Reset when screen gains focus (coming from "Recommencer", etc.) useFocusEffect( useCallback(() => { void resetSession(); @@ -121,64 +135,59 @@ const RecordPlayback = ({ route }) => { }, [resetSession]) ); + // Lancer le compte à rebours (le tick décrémente uniquement) const startCountdownThenRecord = async () => { - console.log("start countdown"); if (!songUrl) return; - console.log("songUrl exists"); - // Ensure clean state and audio at t=0 await resetSession(); setIsPreparing(true); - setCountdown(5); setShowProgress(false); + setCountdown(5); - // Start countdown display; start recording+music WHEN countdown reaches 0 + // On démarre l'intervalle puis on active le flag if (countdownTimerRef.current) { - global.clearInterval(countdownTimerRef.current); + clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } - countdownTimerRef.current = global.setInterval(() => { - setCountdown((c) => { - const next = (c || 0) - 1; - if (next <= 0) { - console.log("clear timer"); - // clear timer - global.clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - // Ensure countdown overlay disappears and progress shows immediately - setIsPreparing(false); - setShowProgress(true); - // also set countdown to 0 explicitly in case of frame drop - if (c !== 0) { - // guard against stale value - setCountdown(0); - } - // start recording + music at the same time - void startRecordingWithMusic(); - } - return Math.max(0, next); - }); + 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; - - // Reset listen counters for this track/session listenedMsRef.current = 0; incrementDoneRef.current = false; - // Start recording setIsRecording(true); - setCountdown(0); - setIsPreparing(false); setShowProgress(true); const recordPromise = cameraRef.current?.recordAsync?.({ mute: true, - maxDuration: 600, // safety cap (10 min) + maxDuration: 600, }); - // Start playing with useAudioPlayer (like MusicDetails) if (player && songUrl) { try { await player.seekTo?.(0); @@ -186,15 +195,15 @@ const RecordPlayback = ({ route }) => { await player.play?.(); } - // Start timer to track listening time and increment views (like MusicDetails) - if (!timerRef.current && project?.id) { - timerRef.current = global.setInterval(async () => { + // 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 >= timeBeforeIncrement + listenedMsRef.current >= TIME_BEFORE_INCREMENT_MS ) { incrementDoneRef.current = true; try { @@ -208,61 +217,62 @@ const RecordPlayback = ({ route }) => { }, 500); } - // Monitor when song ends to stop recording - const checkSongEnd = global.setInterval(async () => { - try { - if (player && !player.playing && !stopRequestedRef.current) { + // 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 we're near the end or stopped, stop recording - if (duration > 0 && currentTime >= duration - 1000) { + if ( + (!player.playing && !stopRequestedRef.current) || + (duration > 0 && currentTime >= duration - 600) + ) { stopRequestedRef.current = true; - global.clearInterval(checkSongEnd); + if (checkSongEndRef.current) { + clearInterval(checkSongEndRef.current); + checkSongEndRef.current = null; + } try { cameraRef.current?.stopRecording?.(); } catch (_) {} } - } - } catch (_) {} - }, 1000); + } catch (_) {} + }, 500); + } - // Wait for recording to stop const video = await recordPromise; - global.clearInterval(checkSongEnd); - // Stop audio + if (checkSongEndRef.current) { + clearInterval(checkSongEndRef.current); + checkSongEndRef.current = null; + } try { - if (player?.playing) { - await player.pause?.(); - } + if (player?.playing) await player.pause?.(); } catch (_) {} - - // Clear listen timer after recording ends - if (timerRef.current) { - global.clearInterval(timerRef.current); - timerRef.current = null; + if (listenTimerRef.current) { + clearInterval(listenTimerRef.current); + listenTimerRef.current = null; } setIsRecording(false); - setIsPreparing(false); setShowProgress(false); - // Navigate to next screen with video uri if available - if (video?.uri) { + if (video?.uri) navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); - } else { - navigate(Routes.RecordedPlayback, { project }); - } + else navigate(Routes.RecordedPlayback, { project }); } catch (e) { - // Fallback on error - console.log("error : ", e); + console.log("RecordPlayback error:", e); setIsRecording(false); setIsPreparing(false); setShowProgress(false); - if (timerRef.current) { - global.clearInterval(timerRef.current); - timerRef.current = null; + if (listenTimerRef.current) { + clearInterval(listenTimerRef.current); + listenTimerRef.current = null; + } + if (checkSongEndRef.current) { + clearInterval(checkSongEndRef.current); + checkSongEndRef.current = null; } } }; @@ -286,6 +296,7 @@ const RecordPlayback = ({ route }) => { }} > + { - {/* Centered countdown overlay */} - {isPreparing && countdown > 0 && !showProgress && ( + {/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */} + {isPreparing && countdown >= 1 && !showProgress && ( { {permissionsGranted && !isPreparing && !isRecording && ( )} - {/* Circular progress when countdown finished / recording */} + {/* Progress circulaire */} {permissionsGranted && (isRecording || showProgress) && ( { justifyContent: "center", }} > - {/* Dimmed circular backdrop */} - {/* Progress ring */} { : 0 } /> - {/* Center white button-like circle */} { ); }; -// Simple SVG circular progress ring +// Progress ring SVG const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => { const r = size / 2 - strokeWidth / 2; const c = 2 * Math.PI * r; From 8223b572b90d52bdfa07664430a5d68b50cf1437 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 15:08:59 +0200 Subject: [PATCH 3/7] save playback --- src/screens/Playback/RecordedPlayback.js | 11 ++- src/screens/Production/DownloadSongs.js | 95 ++++++++++++++++++------ src/screens/ProjectSettings.js | 13 ++-- 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index e2076be..cfeed6e 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -141,7 +141,16 @@ const RecordedPlayback = ({ route }) => { - + { + navigate(Routes.DownloadSongs, { + action: "playback", + uri: videoUri, + project, + }); + }} + /> { diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/DownloadSongs.js index c2fd62a..9b96a4a 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/DownloadSongs.js @@ -1,29 +1,76 @@ -import { - View, - Text, - StyleSheet, - Image, - Pressable, - Platform, -} from "react-native"; +import { BlurView } from "expo-blur"; import React from "react"; -import Page from "../../layouts/Page"; +import { + Image, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; +import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, img } from "../../assets"; import MusicLandHeader from "../../components/MusicLandHeader"; +import { projectsRef, serverTimestamp } from "../../config/firebase"; +import { uploadFileToFirebase } from "../../helpers/uploadToFirebase"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; -import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; + import { size } from "../../styles/Style"; -import { BlurView } from "expo-blur"; -import { responsiveHeight } from "react-native-responsive-dimensions"; -import { Routes } from "../../navigation"; -import { useRoute } from "@react-navigation/core"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +const DownloadSongs = ({ route }) => { + // const params = useRoute().params; + const { action, uri, project } = route.params || {}; + console.log("project id", project?.id); + const { setIsLoading, setTooltip } = useMinuit(); -const DownloadSongs = () => { - const params = useRoute().params; - const action = params?.action; + const handleDownloadUri = async () => { + if (action === "playback" && project?.id) { + // Publication du playback + try { + setIsLoading(true); + const { resultURI = null } = await uploadFileToFirebase({ + uri: uri, + path: `musics/${project.id}/playback.mp4`, + }); + if (!resultURI) throw new Error("Téléversement de l'image impossible"); + + if (resultURI) { + await projectsRef.doc(project.id).set( + { + playbackUrl: resultURI, + updatedAt: serverTimestamp(), + }, + { merge: true } + ); + setTooltip({ + type: "success", + text: "Playback publié avec succès", + }); + } else { + setTooltip({ + type: "error", + text: "Erreur lors de la publication du playback", + }); + } + } catch (error) { + setTooltip({ + type: "error", + text: "Erreur lors de la publication du playback", + }); + } finally { + setIsLoading(false); + } + // Handle playback download + } else { + // Handle song download + } + }; return ( { - navigate(Routes.DownloadPrices, { - action, - }) + onPress={ + () => { + console.log("test"); + handleDownloadUri(); + } + + // navigate(Routes.DownloadPrices, { + // action, + // uri, + // }) } > { const { setIsLoading, setTooltip } = useMinuit(); From 1c9974d362b46cba6429f6752b4bc8dabe59d2a9 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 15:51:07 +0200 Subject: [PATCH 4/7] playback --- package.json | 2 +- src/helpers/uploadToFirebase.js | 31 ++- src/screens/Playbacks.js | 338 ++++++++++++++++-------- src/screens/Production/DownloadSongs.js | 4 + yarn.lock | 2 +- 5 files changed, 259 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 042226d..2d9b274 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "react-dom": "18.3.1", "react-native": "0.76.9", "react-native-actions-sheet": "^0.9.7", - "react-native-compressor": "^1.8.24", + "react-native-compressor": "^1.12.0", "react-native-country-picker-modal": "^2.0.0", "react-native-dialog": "^9.3.0", "react-native-figma-squircle": "^0.3.4", diff --git a/src/helpers/uploadToFirebase.js b/src/helpers/uploadToFirebase.js index 1e59b1f..742b4c6 100644 --- a/src/helpers/uploadToFirebase.js +++ b/src/helpers/uploadToFirebase.js @@ -1,13 +1,36 @@ import { Platform } from "react-native"; - +import Compressor from "react-native-compressor"; import firebase from "../config/firebase"; -export function uploadFileToFirebase({ uri, path }) { +export function uploadFileToFirebase({ + uri, + path, + shouldCompress = false, + fileType = "", +}) { return new Promise(async (resolve, reject) => { try { - let resultResize = { uri }; + let workingURI = uri; - const response = await fetch(resultResize.uri); + // Optionally compress before upload + try { + if (shouldCompress && Platform.OS !== "web") { + if (fileType === "VIDEO") { + console.log("Compressing video..."); + workingURI = await Compressor.Video.compress(workingURI); + } else if (fileType === "IMAGE") { + // Basic image compression + workingURI = await Compressor.Image.compress(workingURI, { + compressionMethod: "auto", + }); + } + } + } catch (e) { + console.warn("Compression failed, uploading original file", e?.message); + workingURI = uri; + } + + const response = await fetch(workingURI); const blob = await response.blob(); const uploadTask = firebase.storage().ref(path).put(blob); diff --git a/src/screens/Playbacks.js b/src/screens/Playbacks.js index 383cfa4..400c8a9 100644 --- a/src/screens/Playbacks.js +++ b/src/screens/Playbacks.js @@ -1,126 +1,240 @@ -import { View, Text, Image, Pressable, Platform } from "react-native"; -import React from "react"; +import { useAudioPlayer } from "expo-audio"; +import { BlurView } from "expo-blur"; +import { VideoView, useVideoPlayer } from "expo-video"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Image, Platform, Pressable, Text, View } from "react-native"; +import Carousel from "react-native-reanimated-carousel"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { icons, img } from "../assets"; -import { size } from "../styles/Style"; -import { BlurView } from "expo-blur"; +import { projectsRef } from "../config/firebase"; +import useDataFromRef from "../hooks/useDataFromRef"; import { Palette } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; -import Carousel from "react-native-reanimated-carousel"; +import { size } from "../styles/Style"; + +const PlaybackItem = ({ item, isActive }) => { + const videoUrl = item?.playbackUrl || null; + const audioSource = useMemo(() => { + const fromSong = item?.songUrl ? { uri: item.songUrl } : null; + return fromSong; + }, [item]); + + const hasExternalAudio = !!audioSource; + console.log("has external audio : ", hasExternalAudio); + + const audioPlayer = useAudioPlayer(audioSource || undefined); + const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { + p.loop = false; // vidéo et musique ont la même durée + p.muted = !!hasExternalAudio; // mute seulement si audio externe + p.timeUpdateEventInterval = 0.2; + }); + + useEffect(() => { + const toggle = async () => { + try { + if (isActive) { + try { + if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0); + } catch (e) {} + try { + if (videoPlayer) videoPlayer.currentTime = 0; + } catch (e) {} + + // Lancer quasi simultanément (éviter await pour limiter le décalage) + try { + if (videoPlayer) videoPlayer.play(); + } catch (e) {} + try { + if (audioPlayer && hasExternalAudio) audioPlayer.play?.(); + } catch (e) {} + } else { + if (audioPlayer?.playing) await audioPlayer.pause?.(); + if (videoPlayer?.playing) videoPlayer.pause(); + } + } catch (e) {} + }; + toggle(); + }, [isActive, audioPlayer, videoPlayer, hasExternalAudio]); + + // Micro-correction initiale uniquement pour absorber un léger décalage réseau + useEffect(() => { + if (!isActive || !hasExternalAudio) return; + const t = setTimeout(() => { + try { + const a = audioPlayer?.currentTime || 0; + const v = videoPlayer?.currentTime || 0; + if (Math.abs(a - v) > 0.2 && videoPlayer) { + videoPlayer.currentTime = Math.max(0, a); + } + } catch (e) {} + }, 300); + return () => clearTimeout(t); + }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); + + useEffect(() => { + return () => { + try { + if (audioPlayer?.playing) audioPlayer.pause?.(); + if (videoPlayer?.playing) videoPlayer.pause(); + } catch (e) {} + }; + }, [audioPlayer, videoPlayer]); -const Playbacks = () => { return ( - - ( - + + {!!videoUrl ? ( + + ) : ( + + )} + + {/* Right side actions */} + + + + + + + + + + Suivre + + + + + - + + + + + + + - - - - - - - - - Suivre - - - - - - - - - - - - - - - Description chanson. Viverra enim risus enim enim placerat. - Integer pulvinar tristique suscipit risus. Id hendrerit in - odio phasellus interdum - - - - - + {item?.title || "Description chanson"} + + + + + + ); +}; + +const Playbacks = () => { + const [activeIndex, setActiveIndex] = useState(0); + const { data: playbacks = [], loadMore } = useDataFromRef({ + ref: projectsRef.where("playbackUrl", "!=", null), + simpleRef: false, + listener: false, + usePagination: true, + batchSize: 2, + }); + + const onSnap = useCallback( + (index) => { + setActiveIndex(index); + // Pré-charge la suite 2 par 2 + if (index >= (playbacks?.length || 0) - 2) { + loadMore?.(); + } + }, + [playbacks?.length, loadMore] + ); + + return ( + + ( + )} /> diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/DownloadSongs.js index 9b96a4a..52e621e 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/DownloadSongs.js @@ -31,11 +31,14 @@ const DownloadSongs = ({ route }) => { const handleDownloadUri = async () => { if (action === "playback" && project?.id) { // Publication du playback + console.log("project id : ", project.id); try { setIsLoading(true); const { resultURI = null } = await uploadFileToFirebase({ uri: uri, path: `musics/${project.id}/playback.mp4`, + shouldCompress: true, + fileType: "VIDEO", }); if (!resultURI) throw new Error("Téléversement de l'image impossible"); @@ -59,6 +62,7 @@ const DownloadSongs = ({ route }) => { }); } } catch (error) { + console.log("error upload playback", error); setTooltip({ type: "error", text: "Erreur lors de la publication du playback", diff --git a/yarn.lock b/yarn.lock index 5983b04..49c6f75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8665,7 +8665,7 @@ react-native-calendars@^1.1300.0: optionalDependencies: moment "^2.29.4" -react-native-compressor@^1.8.24: +react-native-compressor@^1.12.0: version "1.12.0" resolved "https://registry.yarnpkg.com/react-native-compressor/-/react-native-compressor-1.12.0.tgz#4c387f100ec6d98adbee10c22496d0e42397d8bf" integrity sha512-NMAYpXnTLwx/KecwlLF+9Dnrn/tKV5UVfta8Lk9eROsb05JYnUoKLprRzSSpHcyLjIdQAVaTtx2lPYsappMezw== From 6383b84ad912c0218c2ca2966fb3e53733b702f1 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 16:05:31 +0200 Subject: [PATCH 5/7] like playbacks --- src/screens/Playbacks.js | 62 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/screens/Playbacks.js b/src/screens/Playbacks.js index 400c8a9..7bb90a5 100644 --- a/src/screens/Playbacks.js +++ b/src/screens/Playbacks.js @@ -6,13 +6,15 @@ import { Image, Platform, Pressable, Text, View } from "react-native"; import Carousel from "react-native-reanimated-carousel"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { icons, img } from "../assets"; -import { projectsRef } from "../config/firebase"; +import { arrayRemove, arrayUnion, projectsRef } from "../config/firebase"; import useDataFromRef from "../hooks/useDataFromRef"; +import { useUser } from "../providers/UserDataProvider"; import { Palette } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; import { size } from "../styles/Style"; const PlaybackItem = ({ item, isActive }) => { + const { currentUID } = useUser() || {}; const videoUrl = item?.playbackUrl || null; const audioSource = useMemo(() => { const fromSong = item?.songUrl ? { uri: item.songUrl } : null; @@ -20,12 +22,24 @@ const PlaybackItem = ({ item, isActive }) => { }, [item]); const hasExternalAudio = !!audioSource; + + const initialLikedBy = Array.isArray(item?.likedBy) ? item.likedBy : []; + const [isLiked, setIsLiked] = useState( + currentUID ? initialLikedBy.includes(currentUID) : false + ); + const [likesCount, setLikesCount] = useState(initialLikedBy.length); + + useEffect(() => { + const lb = Array.isArray(item?.likedBy) ? item.likedBy : []; + setLikesCount(lb.length); + setIsLiked(currentUID ? lb.includes(currentUID) : false); + }, [item?.likedBy, currentUID]); console.log("has external audio : ", hasExternalAudio); const audioPlayer = useAudioPlayer(audioSource || undefined); const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { - p.loop = false; // vidéo et musique ont la même durée - p.muted = !!hasExternalAudio; // mute seulement si audio externe + p.loop = false; + p.muted = true; p.timeUpdateEventInterval = 0.2; }); @@ -157,12 +171,50 @@ const PlaybackItem = ({ item, isActive }) => { - + { + try { + if (!currentUID || !item?.id) return; + const nextLiked = !isLiked; + setIsLiked(nextLiked); + setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1))); + + const ref = projectsRef.doc(item.id); + await ref.set( + { + likedBy: nextLiked + ? arrayUnion(currentUID) + : arrayRemove(currentUID), + // Optionally: updatedAt could be set if needed + }, + { merge: true } + ); + } catch (e) { + // rollback on failure + setIsLiked((v) => !v); + setLikesCount((c) => (isLiked ? c + 1 : Math.max(0, c - 1))); + } + }} + style={{ alignItems: "center" }} + > + {!!likesCount && ( + + {likesCount} + + )} From 9d1f7c7cdac1374ef7bf4252b0ab36cefd31d3e8 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 16:09:51 +0200 Subject: [PATCH 6/7] follow from playback --- src/screens/Playbacks.js | 146 +++++++++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 35 deletions(-) diff --git a/src/screens/Playbacks.js b/src/screens/Playbacks.js index 7bb90a5..7c6629c 100644 --- a/src/screens/Playbacks.js +++ b/src/screens/Playbacks.js @@ -1,7 +1,7 @@ import { useAudioPlayer } from "expo-audio"; import { BlurView } from "expo-blur"; import { VideoView, useVideoPlayer } from "expo-video"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Image, Platform, Pressable, Text, View } from "react-native"; import Carousel from "react-native-reanimated-carousel"; import { responsiveHeight } from "react-native-responsive-dimensions"; @@ -13,8 +13,8 @@ import { Palette } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; import { size } from "../styles/Style"; -const PlaybackItem = ({ item, isActive }) => { - const { currentUID } = useUser() || {}; +const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { + const { currentUID, followUser, unfollowUser } = useUser() || {}; const videoUrl = item?.playbackUrl || null; const audioSource = useMemo(() => { const fromSong = item?.songUrl ? { uri: item.songUrl } : null; @@ -29,6 +29,43 @@ const PlaybackItem = ({ item, isActive }) => { ); const [likesCount, setLikesCount] = useState(initialLikedBy.length); + // Fetch owner profile (cached) + const [owner, setOwner] = useState( + item?.userId && userCache?.current?.get(item.userId) + ? userCache.current.get(item.userId) + : null, + ); + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const uid = item?.userId; + if (!uid || !userCache) return; + const cached = userCache.current.get(uid); + if (cached) { + if (!cancelled) setOwner(cached); + return; + } + const user = await getUserByUid?.(uid); + if (!cancelled && user) { + userCache.current.set(uid, user); + setOwner(user); + } + } catch (e) {} + }; + run(); + return () => { + cancelled = true; + }; + }, [item?.userId, userCache, getUserByUid]); + + // Follow state derived from owner.followedBy + const [isFollowing, setIsFollowing] = useState(false); + useEffect(() => { + const list = Array.isArray(owner?.followedBy) ? owner.followedBy : []; + setIsFollowing(currentUID ? list.includes(currentUID) : false); + }, [owner?.followedBy, currentUID]); + useEffect(() => { const lb = Array.isArray(item?.likedBy) ? item.likedBy : []; setLikesCount(lb.length); @@ -135,41 +172,76 @@ const PlaybackItem = ({ item, isActive }) => { > - - - - - - Suivre - - + /> + ) : ( + + )} + {owner?.id && currentUID && owner.id !== currentUID && ( + { + try { + const next = !isFollowing; + setIsFollowing(next); + // Optimistic update of local owner.followedBy + setOwner((prev) => { + const fb = Array.isArray(prev?.followedBy) + ? prev.followedBy + : []; + const newFb = next + ? Array.from(new Set([...fb, currentUID])) + : fb.filter((x) => x !== currentUID); + return prev ? { ...prev, followedBy: newFb } : prev; + }); + if (next) await followUser?.(owner.id); + else await unfollowUser?.(owner.id); + } catch (e) { + // rollback on failure + setIsFollowing((v) => !v); + } + }} + > + + + {isFollowing ? "Suivi" : "Suivre"} + + + + )} { @@ -253,6 +325,8 @@ const PlaybackItem = ({ item, isActive }) => { const Playbacks = () => { const [activeIndex, setActiveIndex] = useState(0); + const userCache = useRef(new Map()); + const { getUserByUid } = useUser() || {}; const { data: playbacks = [], loadMore } = useDataFromRef({ ref: projectsRef.where("playbackUrl", "!=", null), simpleRef: false, @@ -285,6 +359,8 @@ const Playbacks = () => { )} From ac2c2036fc2b2a174f699c3fede0a9558c704571 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 2 Sep 2025 16:15:12 +0200 Subject: [PATCH 7/7] follow from reel --- src/screens/Playbacks.js | 41 +++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/screens/Playbacks.js b/src/screens/Playbacks.js index 7c6629c..e9e9b50 100644 --- a/src/screens/Playbacks.js +++ b/src/screens/Playbacks.js @@ -1,13 +1,21 @@ import { useAudioPlayer } from "expo-audio"; import { BlurView } from "expo-blur"; import { VideoView, useVideoPlayer } from "expo-video"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Image, Platform, Pressable, Text, View } from "react-native"; import Carousel from "react-native-reanimated-carousel"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { icons, img } from "../assets"; -import { arrayRemove, arrayUnion, projectsRef } from "../config/firebase"; +import { arrayRemove, arrayUnion, projectsRef, usersRef } from "../config/firebase"; import useDataFromRef from "../hooks/useDataFromRef"; +import { Routes } from "../navigation"; +import { navigate } from "../navigation/NavigationService"; import { useUser } from "../providers/UserDataProvider"; import { Palette } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; @@ -33,7 +41,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const [owner, setOwner] = useState( item?.userId && userCache?.current?.get(item.userId) ? userCache.current.get(item.userId) - : null, + : null ); useEffect(() => { let cancelled = false; @@ -59,6 +67,25 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { }; }, [item?.userId, userCache, getUserByUid]); + // Live sync owner from Firestore to reflect follow changes elsewhere + useEffect(() => { + const uid = item?.userId; + if (!uid) return; + const unsub = usersRef.doc(uid).onSnapshot( + (doc) => { + if (doc?.exists) { + const data = { id: doc.id, ...doc.data() }; + setOwner(data); + try { + userCache?.current?.set(uid, data); + } catch (e) {} + } + }, + () => {}, + ); + return () => unsub?.(); + }, [item?.userId, userCache]); + // Follow state derived from owner.followedBy const [isFollowing, setIsFollowing] = useState(false); useEffect(() => { @@ -171,7 +198,11 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { }} > - + { + navigate(Routes.SingerProfile, { userId: item?.userId }); + }} + > {owner?.profilePictureURL ? ( { fontFamily: FONT_FAMILY.InterMedium, }} > - {isFollowing ? "Suivi" : "Suivre"} + {isFollowing ? "Ne plus suivre" : "Suivre"}