This commit is contained in:
2025-09-02 16:18:11 +02:00
12 changed files with 1091 additions and 194 deletions
+1 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+27 -4
View File
@@ -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);
+8 -8
View File
@@ -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;
+28 -12
View File
@@ -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;
}
};
+9 -8
View File
@@ -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 (
<Page headerType="NONE" backgroundImg={background.playbackBG2}>
<Image source={ai.john} style={styles.img} resizeMode="contain" />
@@ -23,10 +24,10 @@ const Playback = () => {
>
<View style={{ width: "80%", alignSelf: "center", gap: 12 }}>
<BorderGradientButton title="Guide du Playbacker" />
<BorderGradientButton title="Importer une vidéo" />
{/* <BorderGradientButton title="Importer une vidéo" /> */}
<GradientButton
title="Enregistrer mon Playback"
onPress={() => navigate(Routes.RecordPlayback)}
onPress={() => navigate(Routes.RecordPlayback, { project })}
/>
</View>
</View>
+413 -17
View File
@@ -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 (
<View style={{ flex: 1 }}>
<CameraView style={{ flex: 1 }} facing="front">
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing="front"
mode="video"
>
<View
style={{
paddingHorizontal: gutters,
@@ -25,6 +296,7 @@ const RecordPlayback = () => {
}}
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<View style={{ flex: 1, marginTop: 11 }}>
<CreateLyricsHeader>
<Text
@@ -38,19 +310,143 @@ const RecordPlayback = () => {
musique, et s'arrêtera à la fin du morceau.
</Text>
</CreateLyricsHeader>
{/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
{isPreparing && countdown >= 1 && !showProgress && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 72,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}
>
{countdown}
</Text>
</View>
)}
{/* Permission prompt */}
{!permissionsGranted && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: gutters,
}}
>
<GradientButton
title="Autoriser la caméra"
onPress={async () => {
try {
if (!cameraPermission?.granted)
await requestCameraPermission();
} catch (_) {}
}}
/>
</View>
)}
</View>
<GradientButton
title="Lancer ma musique"
containerStyle={{
width: "80%",
alignSelf: "center",
}}
onPress={() => navigate(Routes.RecordedPlayback)}
/>
{/* Start button */}
{permissionsGranted && !isPreparing && !isRecording && (
<GradientButton
title="Lancer ma musique"
containerStyle={{ width: "80%", alignSelf: "center" }}
disabled={!songUrl}
onPress={startCountdownThenRecord}
/>
)}
{/* Progress circulaire */}
{permissionsGranted && (isRecording || showProgress) && (
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: gutters,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: 100,
height: 100,
borderRadius: 105,
alignItems: "center",
justifyContent: "center",
}}
>
<ProgressRing
size={100}
strokeWidth={8}
progress={
progressInfo.dur
? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur)
: 0
}
/>
<View
style={{
position: "absolute",
width: 50,
height: 50,
borderRadius: 55,
backgroundColor: Palette.white,
}}
/>
</View>
</View>
)}
</View>
</CameraView>
</View>
);
};
// 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 (
<Svg
width={size}
height={size}
style={{
transform: [{ rotate: "-90deg" }],
backgroundColor: "#ffffff3d",
borderRadius: 55,
}}
>
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={Palette.white}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${c} ${c}`}
strokeDashoffset={offset}
fill="transparent"
/>
</Svg>
);
};
export default RecordPlayback;
+151 -21
View File
@@ -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 (
<Page backgroundImg={background.playbackBG2} headerType="NONE">
<MusicLandHeader progress={19} onPressBack={goBack} />
@@ -18,28 +113,63 @@ const RecordedPlayback = () => {
style={{ flex: 1, paddingTop: 12, gap: 14, paddingBottom: gutters * 2 }}
>
<View style={{ flex: 1, gap: 22 }}>
<Image
source={img.placeholder3}
style={{
width: "80%",
flex: 1,
alignSelf: "center",
borderRadius: 16,
}}
{!!videoUri && (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="contain"
style={{
width: "80%",
flex: 1,
alignSelf: "center",
borderRadius: 16,
backgroundColor: "#00000066",
overflow: "hidden",
}}
/>
)}
<Slider
value={fmt(progressInfo.pos)}
maxValue={fmt(progressInfo.dur)}
progress={sliderProgress}
seekEnabled={!!songUrl}
onSeek={onSeek}
onSeekStart={onSeekStart}
onSeekEnd={onSeekEnd}
/>
<Slider value="0:00" maxValue="2:00" />
</View>
<View
style={{ width: "80%", alignSelf: "center", marginTop: 4, gap: 12 }}
>
<GradientButton
title="Je valide"
onPress={() => {
navigate(Routes.DownloadSongs, {
action: "playback",
uri: videoUri,
project,
});
}}
/>
<BorderGradientButton
title="Je change de décor"
onPress={() => 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 });
}}
/>
<BorderGradientButton title="Recommencer" />
</View>
</View>
</Page>
+368 -95
View File
@@ -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 (
<View style={{ flex: 1 }}>
<Carousel
data={Array.from({ length: 5 })}
vertical
height={responsiveHeight(100)}
renderItem={() => (
<View
style={{
height: responsiveHeight(100),
position: "relative",
}}
>
<Image
source={img.placeholder3}
style={{ width: "100%", height: "100%" }}
/>
<View
style={{
position: "absolute",
width: "100%",
bottom: 130,
gap: 18,
<View
style={{
height: responsiveHeight(100),
position: "relative",
backgroundColor: "black",
}}
>
{!!videoUrl ? (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="cover"
style={{ width: "100%", height: "100%" }}
/>
) : (
<Image
source={img.placeholder3}
style={{ width: "100%", height: "100%" }}
/>
)}
{/* Right side actions */}
<View
style={{
position: "absolute",
width: "100%",
bottom: 130,
gap: 18,
}}
>
<View
style={{
alignSelf: "flex-end",
alignItems: "center",
gap: 20,
paddingHorizontal: 13,
}}
>
<View style={{ gap: 6, alignItems: "center" }}>
<Pressable
onPress={() => {
navigate(Routes.SingerProfile, { userId: item?.userId });
}}
>
<View
style={{
alignSelf: "flex-end",
alignItems: "center",
gap: 20,
paddingHorizontal: 13,
{owner?.profilePictureURL ? (
<Image
source={{ uri: owner.profilePictureURL }}
style={{
...size({ size: 45 }),
borderRadius: 100,
}}
/>
) : (
<Image
source={img.profile}
style={{
...size({ size: 45 }),
borderRadius: 100,
}}
/>
)}
</Pressable>
{owner?.id && currentUID && owner.id !== currentUID && (
<Pressable
onPress={async () => {
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);
}
}}
>
<View style={{ gap: 6, alignItems: "center" }}>
<Pressable>
<Image
source={img.profile}
style={{
...size({ size: 45 }),
borderRadius: 100,
}}
/>
</Pressable>
<Pressable>
<BlurView
tint="dark"
intensity={20}
style={{
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: Palette.white,
overflow: "hidden",
}}
experimentalBlurMethod={
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
}
>
<Text
style={{
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
Suivre
</Text>
</BlurView>
</Pressable>
</View>
<Pressable>
<Image
source={icons.heartOutline}
style={size({ size: 26 })}
resizeMode="contain"
/>
</Pressable>
<Pressable>
<Image source={icons.share} style={size({ size: 26 })} />
</Pressable>
</View>
<View style={{ paddingHorizontal: 28 }}>
<BlurView
tint="dark"
intensity={20}
style={{
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: Palette.glass,
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: Palette.white,
overflow: "hidden",
backgroundColor: isFollowing ? "#FFFFFF1A" : undefined,
}}
experimentalBlurMethod={
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
@@ -108,19 +263,137 @@ const Playbacks = () => {
>
<Text
style={{
fontSize: 12,
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
Description chanson. Viverra enim risus enim enim placerat.
Integer pulvinar tristique suscipit risus. Id hendrerit in
odio phasellus interdum
{isFollowing ? "Ne plus suivre" : "Suivre"}
</Text>
</BlurView>
</View>
</View>
</Pressable>
)}
</View>
<Pressable
onPress={async () => {
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" }}
>
<Image
source={isLiked ? icons.heart : icons.heartOutline}
style={size({ size: 26 })}
resizeMode="contain"
/>
{!!likesCount && (
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
{likesCount}
</Text>
)}
</Pressable>
<Pressable>
<Image source={icons.share} style={size({ size: 26 })} />
</Pressable>
</View>
<View style={{ paddingHorizontal: 28 }}>
<BlurView
tint="dark"
intensity={20}
style={{
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: Palette.glass,
overflow: "hidden",
}}
experimentalBlurMethod={
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
}
>
<Text
style={{
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{item?.title || "Description chanson"}
</Text>
</BlurView>
</View>
</View>
</View>
);
};
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 (
<View style={{ flex: 1, backgroundColor: "black" }}>
<Carousel
data={playbacks}
vertical
height={responsiveHeight(100)}
pagingEnabled
windowSize={5}
onSnapToItem={onSnap}
renderItem={({ item, index }) => (
<PlaybackItem
key={item?.id || index}
item={item}
userCache={userCache}
getUserByUid={getUserByUid}
isActive={index === activeIndex}
/>
)}
/>
</View>
+78 -21
View File
@@ -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 (
<Page
backgroundImg={
@@ -57,10 +108,16 @@ const DownloadSongs = () => {
<View style={{ gap: 10 }}>
<Pressable
style={styles.itemContainer}
onPress={() =>
navigate(Routes.DownloadPrices, {
action,
})
onPress={
() => {
console.log("test");
handleDownloadUri();
}
// navigate(Routes.DownloadPrices, {
// action,
// uri,
// })
}
>
<BlurView
+7 -6
View File
@@ -1,26 +1,27 @@
import React, { useState } from "reactn";
import { Text, Pressable } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { Pressable, Text } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import React, { useState } from "reactn";
import Page from "../layouts/Page";
import { responsiveWidth } from "../actions/responsiveSizes.js";
import Page from "../layouts/Page";
import alert from "../components/Alert.js";
import Avatar from "../components/Avatar";
import InputRow from "../components/InputRow.js";
import ItemRowList from "../components/ItemRowList.js";
import alert from "../components/Alert.js";
import { Fonts, gutters, Palette, Style } from "../styles";
import { projectsRef } from "../config/firebase";
import { uploadFileToFirebase } from "../helpers/uploadToFirebase";
import { getValueFromKeyState } from "../helpers/index.js";
import { uploadFileToFirebase } from "../helpers/uploadToFirebase";
import { useUserData } from "../providers/UserDataProvider.js";
import { Routes } from "../navigation/Routes.js";
import { useUserData } from "../providers/UserDataProvider.js";
// eslint-disable-next-line react/display-name
export default ({ navigation }) => {
const { setIsLoading, setTooltip } = useMinuit();
+1 -1
View File
@@ -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==