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/assets/progress.png b/src/assets/progress.png new file mode 100644 index 0000000..11df4ce Binary files /dev/null and b/src/assets/progress.png differ 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/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index 213cf0e..26bf97a 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -1,20 +1,24 @@ import { useRoute } from "@react-navigation/core"; import { useAudioPlayer } from "expo-audio"; +import { Image as ExpoImage } from "expo-image"; import React, { useEffect, useMemo, useState } from "react"; import { Pressable, + Image as RNImage, ScrollView, StyleSheet, Text, View, - Image as RNImage, } from "react-native"; -import { Image as ExpoImage } from "expo-image"; import { SheetManager } from "react-native-actions-sheet"; +import { + responsiveHeight, + responsiveWidth, +} from "react-native-responsive-dimensions"; import { useGlobal } from "reactn"; -import { background, icons, img } from "../../assets"; +import { background, icons } from "../../assets"; import Slider from "../../components/Slider"; -import firebase, { +import { arrayRemove, arrayUnion, increment, @@ -26,10 +30,6 @@ import Page from "../../layouts/Page"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import Style, { gutters, size } from "../../styles/Style"; -import { - responsiveHeight, - responsiveWidth, -} from "react-native-responsive-dimensions"; // 20 secondes const timeBeforeIncrement = 20000; diff --git a/src/screens/NewMusicOptions.js b/src/screens/NewMusicOptions.js index a1654d0..1669d84 100644 --- a/src/screens/NewMusicOptions.js +++ b/src/screens/NewMusicOptions.js @@ -1,16 +1,15 @@ +import FontAwesome from "@expo/vector-icons/FontAwesome"; +import { BlurView } from "expo-blur"; import React, { useMemo } from "react"; import { Image, Platform, Pressable, Text, View } from "react-native"; -import Page from "../layouts/Page"; import { ai, background } from "../assets"; -import { BlurView } from "expo-blur"; -import { Palette } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; +import Page from "../layouts/Page"; import { Routes } from "../navigation"; import { navigate } from "../navigation/NavigationService"; import { useUserData } from "../providers/UserDataProvider"; -import FontAwesome from "@expo/vector-icons/FontAwesome"; +import { Palette } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; import palette from "../styles/Palette"; -import GeneratingSong from "./Studio/GeneratingSong"; const CREATE_DATA = [ { @@ -59,14 +58,27 @@ const NewMusicOptions = ({ route }) => { ? 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) => { @@ -90,7 +102,11 @@ const NewMusicOptions = ({ route }) => { navigate(Routes.PouchReady, { projectId: currentProjet.id }); break; case 3: - navigate(Routes.Playback, { projectId: currentProjet.id }); + console.log("test"); + navigate(Routes.Playback, { + 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..7f1699b 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -1,21 +1,292 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { useAudioPlayer } from "expo-audio"; +import { CameraView, useCameraPermissions } from "expo-camera"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Text, View } from "react-native"; -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 { useSafeAreaInsets } from "react-native-safe-area-context"; -import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; -import { FONT_FAMILY } from "../../styles/Fonts"; +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"; -const RecordPlayback = () => { +/** + * 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 || {}; + + // Permissions + 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 + const songUrl = project?.songUrl || null; + const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + + useEffect(() => { + listenedMsRef.current = 0; + incrementDoneRef.current = false; + }, [songUrl]); + + // Poll player -> progress ring + useEffect(() => { + if (!player) return; + const id = setInterval(() => { + try { + const dur = (player?.duration || 0) * 1000; + const pos = (player?.currentTime || 0) * 1000; + setProgressInfo({ pos, dur }); + } catch (_) {} + }, 250); + return () => clearInterval(id); + }, [player]); + + // 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 + + // 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; + } + + 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?.(); + await player.seekTo?.(0); + } catch (_) {} + } + } catch (_) {} + }, [player]); + + useFocusEffect( + useCallback(() => { + void resetSession(); + return () => {}; + }, [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; + } + try { + if (player?.playing) await player.pause?.(); + } catch (_) {} + if (listenTimerRef.current) { + clearInterval(listenTimerRef.current); + listenTimerRef.current = null; + } + + setIsRecording(false); + setShowProgress(false); + + if (video?.uri) + navigate(Routes.RecordedPlayback, { videoUri: video.uri, project }); + else navigate(Routes.RecordedPlayback, { project }); + } catch (e) { + console.log("RecordPlayback error:", 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; + } + } + }; + + const permissionsGranted = !!cameraPermission?.granted; return ( - + { }} > + { musique, et s'arrêtera à la fin du morceau. + + {/* 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 (_) {} + }} + /> + + )} - navigate(Routes.RecordedPlayback)} - /> + + {/* Start button */} + {permissionsGranted && !isPreparing && !isRecording && ( + + )} + + {/* Progress circulaire */} + {permissionsGranted && (isRecording || showProgress) && ( + + + + + + + )} ); }; +// Progress ring SVG +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 ( + + + + ); +}; + export default RecordPlayback; diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index 999f9c1..cfeed6e 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -1,16 +1,111 @@ -import { View, Text, Image } from "react-native"; -import React from "react"; -import Page from "../../layouts/Page"; -import { background, img } from "../../assets"; +import { useAudioPlayer } from "expo-audio"; +import * as FileSystem from "expo-file-system"; +import { VideoView, useVideoPlayer } from "expo-video"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { 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 } from "../../styles"; -import Slider from "../../components/Slider"; -import GradientButton from "../../components/GradientButton"; -import { Routes } from "../../navigation"; -import BorderGradientButton from "../../components/BorderGradientButton"; +const RecordedPlayback = ({ route }) => { + const { videoUri, project } = route.params || {}; + const songUrl = project?.songUrl || null; + + const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + const videoPlayer = useVideoPlayer(videoUri || null, (p) => { + p.loop = false; + p.muted = true; // recorded video has no audio; keep muted anyway + p.timeUpdateEventInterval = 0.2; + }); + + const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); + const wasPlayingBeforeSeek = useRef(false); + + // Format mm:ss + const fmt = (ms) => { + const total = Math.max(0, Math.floor((ms || 0) / 1000)); + const m = Math.floor(total / 60) + .toString() + .padStart(1, "0"); + const s = (total % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }; + + // Start both players on mount + useEffect(() => { + const start = async () => { + try { + if (audioPlayer && songUrl) await audioPlayer.play?.(); + if (videoPlayer) videoPlayer.play(); + } catch (e) {} + }; + start(); + return () => { + try { + if (audioPlayer?.playing) audioPlayer.pause?.(); + if (videoPlayer?.playing) videoPlayer.pause(); + } catch (e) {} + }; + }, [audioPlayer, videoPlayer, songUrl]); + + // Poll from audio player for progress display; keep video in sync if drifting + useEffect(() => { + const id = global.setInterval(() => { + try { + const dur = (audioPlayer?.duration || 0) * 1000; + const pos = (audioPlayer?.currentTime || 0) * 1000; + setProgressInfo({ pos, dur }); + + // basic drift correction: if desync > 300ms, align video + if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) { + const v = (videoPlayer.currentTime || 0) * 1000; + const drift = Math.abs(v - pos); + if (drift > 350) { + videoPlayer.currentTime = Math.max(0, (pos || 0) / 1000); + } + } + } catch (e) {} + }, 250); + return () => global.clearInterval(id); + }, [audioPlayer, videoPlayer]); + + const onSeek = async (ratio) => { + try { + const dur = progressInfo.dur || 0; + const pos = Math.floor(dur * ratio); + if (audioPlayer && dur > 0) + await audioPlayer.seekTo?.(Math.floor(pos / 1000)); + if (videoPlayer) videoPlayer.currentTime = Math.max(0, pos / 1000); + } catch (e) {} + }; + + const onSeekStart = async () => { + try { + wasPlayingBeforeSeek.current = !!audioPlayer?.playing; + if (audioPlayer?.playing) await audioPlayer.pause?.(); + if (videoPlayer?.playing) videoPlayer.pause(); + } catch (e) {} + }; + const onSeekEnd = async () => { + try { + if (wasPlayingBeforeSeek.current) { + if (audioPlayer) await audioPlayer.play?.(); + if (videoPlayer) videoPlayer.play(); + } + } catch (e) {} + }; + + const sliderProgress = useMemo(() => { + return progressInfo.dur + ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) + : 0; + }, [progressInfo]); -const RecordedPlayback = () => { return ( @@ -18,28 +113,63 @@ const RecordedPlayback = () => { style={{ flex: 1, paddingTop: 12, gap: 14, paddingBottom: gutters * 2 }} > - + )} + - { + navigate(Routes.DownloadSongs, { + action: "playback", + uri: videoUri, + project, + }); + }} /> navigate(Routes.ChooseDecor)} + title="Recommencer" + onPress={async () => { + try { + if (audioPlayer?.playing) await audioPlayer.pause?.(); + if (videoPlayer?.playing) videoPlayer.pause(); + } catch (e) {} + try { + if (videoUri) { + const info = await FileSystem.getInfoAsync(videoUri); + if (info?.exists) + await FileSystem.deleteAsync(videoUri, { + idempotent: true, + }); + } + } catch (e) {} + navigate(Routes.RecordPlayback, { project }); + }} /> - diff --git a/src/screens/Playbacks.js b/src/screens/Playbacks.js index 383cfa4..e9e9b50 100644 --- a/src/screens/Playbacks.js +++ b/src/screens/Playbacks.js @@ -1,106 +1,261 @@ -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, + 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 { size } from "../styles/Style"; -import { BlurView } from "expo-blur"; +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"; -import Carousel from "react-native-reanimated-carousel"; +import { size } from "../styles/Style"; + +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; + return fromSong; + }, [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); + + // 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]); + + // 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(() => { + 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); + 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; + p.muted = true; + 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 */} + + + + { + navigate(Routes.SingerProfile, { userId: item?.userId }); }} > - + ) : ( + + )} + + {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); + } }} > - - - - - - - - Suivre - - - - - - - - - - - - { > - Description chanson. Viverra enim risus enim enim placerat. - Integer pulvinar tristique suscipit risus. Id hendrerit in - odio phasellus interdum + {isFollowing ? "Ne plus suivre" : "Suivre"} - - + + )} + { + 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} + + )} + + + + + + + + + {item?.title || "Description chanson"} + + + + + + ); +}; + +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, + 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 c2fd62a..52e621e 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/DownloadSongs.js @@ -1,29 +1,80 @@ -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 + 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"); + + 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) { + console.log("error upload playback", 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(); 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==