new project modal & seperate playback and music likes

This commit is contained in:
2025-10-24 16:12:07 +02:00
parent 47d5b92a57
commit 2599a25d77
12 changed files with 415 additions and 63 deletions
+34 -9
View File
@@ -339,15 +339,37 @@ exports.createProjectLikeNotification = onDocumentWritten(
return null;
}
const beforeLikes = Array.isArray(before?.likedBy) ? before.likedBy : [];
const afterLikes = Array.isArray(after?.likedBy) ? after.likedBy : [];
const beforeSongLikes = Array.isArray(before?.likes?.song)
? before.likes.song
: [];
const afterSongLikes = Array.isArray(after?.likes?.song)
? after.likes.song
: [];
const beforePlaybackLikes = Array.isArray(before?.likes?.playback)
? before.likes.playback
: [];
const afterPlaybackLikes = Array.isArray(after?.likes?.playback)
? after.likes.playback
: [];
if (afterLikes.length <= beforeLikes.length) {
return null;
}
const beforeSongSet = new Set(beforeSongLikes);
const beforePlaybackSet = new Set(beforePlaybackLikes);
const beforeSet = new Set(beforeLikes);
const newLikers = afterLikes.filter((uid) => !beforeSet.has(uid));
const newSongLikers = afterSongLikes.filter(
(uid) => uid && !beforeSongSet.has(uid)
);
const newPlaybackLikers = afterPlaybackLikes.filter(
(uid) => uid && !beforePlaybackSet.has(uid)
);
const newLikers = [];
newSongLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "song" });
});
newPlaybackLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "playback" });
});
if (!newLikers.length) {
return null;
@@ -359,7 +381,7 @@ exports.createProjectLikeNotification = onDocumentWritten(
: "ton projet";
await Promise.all(
newLikers.map(async (likerId) => {
newLikers.map(async ({ likerId, likeType }) => {
if (!likerId || likerId === ownerId) {
return null;
}
@@ -371,7 +393,9 @@ exports.createProjectLikeNotification = onDocumentWritten(
? liker.userName.trim()
: "Un utilisateur";
const message = `${likerName} a aimé ton projet "${projectTitle}"`;
const isPlaybackLike = likeType === "playback";
const assetLabel = isPlaybackLike ? "ton playback" : "ta musique";
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`;
await sendNotification({
sender: likerId,
@@ -384,6 +408,7 @@ exports.createProjectLikeNotification = onDocumentWritten(
projectId,
likerId,
likerName,
likeType,
},
});
+244
View File
@@ -0,0 +1,244 @@
import React, { useCallback, useMemo, useState } from "react";
import {
Image,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import AppActionSheet from "../AppActionSheet";
import GradientButton from "../GradientButton";
import { img } from "../../assets";
import { Routes } from "../../navigation";
import { navigate } from "../../navigation/NavigationService";
import { useUserData } from "../../providers/UserDataProvider";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import Style, { size } from "../../styles/Style";
import { isWeb } from "../../hooks/useLayoutType";
const SHEET_ID = "PlaybackPicker";
const PlaybackPickerModal = () => {
const { userProjects = [], createNewProject } = useUserData();
const [isCreating, setIsCreating] = useState(false);
const [webVisible, setWebVisible] = useState(true);
const sanitizedProjects = useMemo(() => {
if (!Array.isArray(userProjects)) {
return [];
}
return userProjects.filter((project) => {
if (!project || project.playbackUrl) {
return false;
}
return !!project?.songUrl;
});
}, [userProjects]);
const hasAnyMusic = Array.isArray(userProjects) && userProjects.length > 0;
const hideSheet = useCallback(() => {
if (isWeb) {
setWebVisible(false);
}
try {
const maybePromise = SheetManager.hide(SHEET_ID);
return Promise.resolve(maybePromise);
} catch (error) {
console.error("[PlaybackPicker] hide error", error?.message || error);
return Promise.resolve();
}
}, [setWebVisible]);
const handleSelectProject = useCallback(
async (project) => {
if (!project?.id) return;
const hidePromise = hideSheet();
await Promise.race([
Promise.resolve(hidePromise),
new Promise((resolve) => setTimeout(resolve, 200)),
]).catch(() => {});
navigate(Routes.Playback, { project });
},
[hideSheet]
);
const handleCreateNew = useCallback(async () => {
if (isCreating) {
return;
}
setIsCreating(true);
try {
let projectId = null;
if (typeof createNewProject === "function") {
projectId = await createNewProject({ hasLyrics: false });
}
const hidePromise = hideSheet();
await Promise.race([
Promise.resolve(hidePromise),
new Promise((resolve) => setTimeout(resolve, 200)),
]);
if (projectId) {
navigate(Routes.WritingLyrics, { projectId });
} else {
navigate(Routes.WritingLyrics);
}
} catch (error) {
console.error("[PlaybackPicker] create project failed", error);
} finally {
setIsCreating(false);
}
}, [createNewProject, hideSheet, isCreating]);
const hasProjects = sanitizedProjects.length > 0;
if (isWeb && !webVisible) {
return null;
}
return (
<AppActionSheet id={SHEET_ID} webModal onClose={hideSheet}>
<View style={{ gap: 18 }}>
<Text style={styles.title}>Ajouter un playback</Text>
<Text style={styles.description}>
Choisis une musique déjà créée pour y ajouter un playback ou lance un
nouveau projet.
</Text>
{hasProjects ? (
<ScrollView
style={{ maxHeight: 320 }}
contentContainerStyle={{ gap: 12, paddingVertical: 4 }}
showsVerticalScrollIndicator={false}
>
{sanitizedProjects.map((project) => {
const coverUri = project?.coverUrl || null;
return (
<Pressable
key={project.id}
onPress={() => handleSelectProject(project)}
style={styles.projectCard}
>
<Image
source={coverUri ? { uri: coverUri } : img.placeholder}
style={styles.cover}
resizeMode="cover"
/>
<View style={{ flex: 1, gap: 4 }}>
<Text numberOfLines={1} style={styles.projectTitle}>
{project?.title || "Sans titre"}
</Text>
<Text numberOfLines={1} style={styles.projectSubtitle}>
{project?.userName || "Moi"}
</Text>
<Text style={styles.projectHint}>
Playback à créer
</Text>
</View>
</Pressable>
);
})}
</ScrollView>
) : (
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>
{hasAnyMusic
? "Aucune musique n'est prête pour l'étape playback"
: "Tu n'as pas encore de musique prête pour le playback"}
</Text>
<Text style={styles.emptyDescription}>
{hasAnyMusic
? "Complète d'abord la création de ta chanson, puis reviens ici pour enregistrer le playback."
: "Crée un nouveau projet pour composer ta musique et lancer ton playback."}
</Text>
</View>
)}
<GradientButton
title="Créer un nouveau projet"
onPress={handleCreateNew}
containerStyle={{ alignSelf: "center", minWidth: 220 }}
disabled={isCreating}
/>
</View>
</AppActionSheet>
);
};
export default PlaybackPickerModal;
const styles = StyleSheet.create({
title: {
fontSize: 22,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
},
description: {
fontSize: 14,
color: Palette.white,
opacity: 0.85,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
lineHeight: 20,
},
projectCard: {
...Style.containerRow,
gap: 12,
padding: 12,
borderRadius: 16,
backgroundColor: Palette.glass,
},
cover: {
...size({ size: 60 }),
borderRadius: 12,
backgroundColor: Palette.black,
},
projectTitle: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
projectSubtitle: {
fontSize: 12,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
},
projectHint: {
fontSize: 12,
color: Palette.white,
opacity: 0.7,
fontFamily: FONT_FAMILY.InterRegular,
},
emptyState: {
alignItems: "center",
gap: 8,
paddingVertical: 40,
paddingHorizontal: 20,
borderRadius: 20,
backgroundColor: Palette.glass,
},
emptyTitle: {
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
},
emptyDescription: {
fontSize: 14,
color: Palette.white,
opacity: 0.8,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
lineHeight: 20,
},
});
+6 -2
View File
@@ -1,5 +1,6 @@
import { useGlobal } from "reactn";
import firebase from "../config/firebase";
import { getLikeFieldPath, LIKE_TARGET } from "../utils/likes";
import useDataFromRef from "./useDataFromRef";
export default function useUserLikedProjects() {
@@ -11,7 +12,11 @@ export default function useUserLikedProjects() {
? firebase
.firestore()
.collection("projects")
.where("likedBy", "array-contains", currentUID)
.where(
getLikeFieldPath(LIKE_TARGET.SONG),
"array-contains",
currentUID
)
: null,
simpleRef: false,
listener: true,
@@ -21,4 +26,3 @@ export default function useUserLikedProjects() {
return { projects: Array.isArray(data) ? data : [], loading };
}
+20 -5
View File
@@ -14,6 +14,10 @@ import firebase, {
usersRef,
} from "../config/firebase";
import { getUserPreferredArtistName } from "../utils/artistName";
import { getLikeFieldPath, LIKE_TARGET } from "../utils/likes";
const SONG_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.SONG);
const PLAYBACK_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.PLAYBACK);
export const UserDataContext = createContext();
@@ -63,7 +67,21 @@ export default ({ children }) => {
} = useDataFromRef({
ref: currentUID
? projectsRef
.where("likedBy", "array-contains", currentUID)
.where(SONG_LIKES_FIELD, "array-contains", currentUID)
.orderBy("updatedAt", "desc")
: null,
simpleRef: false,
listener: true,
condition: !!currentUID,
refreshArray: [currentUID],
});
const {
data: userLikedPlaybacks = [],
loading: userLikedPlaybacksLoading = true,
} = useDataFromRef({
ref: currentUID
? projectsRef
.where(PLAYBACK_LIKES_FIELD, "array-contains", currentUID)
.orderBy("updatedAt", "desc")
: null,
simpleRef: false,
@@ -71,9 +89,6 @@ export default ({ children }) => {
condition: !!currentUID,
refreshArray: [currentUID],
});
const userLikedPlaybacks = Array.isArray(userLikedProjects)
? userLikedProjects.filter((project) => project.playbackUrl)
: [];
// Subscribe to user's playlists
const { data: userPlaylists = [] } = useDataFromRef({
ref: currentUID ? playlistsRef.where("createdBy", "==", currentUID) : null,
@@ -406,7 +421,7 @@ export default ({ children }) => {
userLikedProjects,
userLikedProjectsLoading,
userLikedPlaybacks,
userLikedPlaybacksLoading: userLikedProjectsLoading,
userLikedPlaybacksLoading,
userPlaylists,
selectedProjectId,
selectedProject,
+2 -1
View File
@@ -13,6 +13,7 @@ import { Routes } from "../../navigation";
import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { getArtistDisplayName } from "../../utils/artistName";
import { getProjectLikes, LIKE_TARGET } from "../../utils/likes";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import MusicCard from "../Library/components/MusicCard";
@@ -86,7 +87,7 @@ const HomeSave = () => {
subtitle={item?.userName || ownerDisplayName}
imageUri={item?.coverUrl || null}
projectId={item?.id}
likedBy={item?.likedBy || []}
likedBy={getProjectLikes(item, LIKE_TARGET.SONG)}
onPress={() => {
selectProject(item.id);
navigate(Routes.Home);
+8 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import {
FlatList,
Image,
@@ -7,6 +7,7 @@ import {
View,
useWindowDimensions,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import GradientButton from "../../../components/GradientButton";
import useLayoutType from "../../../hooks/useLayoutType";
import { Routes } from "../../../navigation";
@@ -29,7 +30,10 @@ const BackTracks = () => {
0,
Math.floor((containerWidth - gap * (numColumns - 1)) / numColumns)
);
console.log("BackTracks items:", items);
const openPlaybackPicker = useCallback(() => {
SheetManager.show("PlaybackPicker");
}, []);
return (
<CardContainer
@@ -41,7 +45,7 @@ const BackTracks = () => {
liked: false,
})
}
onPressPlus={() => navigate(Routes.WritingLyrics)}
onPressPlus={openPlaybackPicker}
>
<View>
{items.length > 0 ? (
@@ -85,7 +89,7 @@ const BackTracks = () => {
</Text>
<GradientButton
containerStyle={{ padding: 2 }}
onPress={() => navigate(Routes.WritingLyrics)}
onPress={openPlaybackPicker}
title=" Créer un playback"
></GradientButton>
</View>
+7 -3
View File
@@ -13,7 +13,7 @@ import { responsiveWidth } from "react-native-responsive-dimensions";
import { useGlobal } from "reactn";
import { icons, img } from "../../../assets";
import PressableScale from "../../../components/PressableScale";
import { arrayRemove, arrayUnion, projectsRef } from "../../../config/firebase";
import { LIKE_TARGET, toggleProjectLike } from "../../../utils/likes";
import { Palette, Style } from "../../../styles";
import { FONT_FAMILY } from "../../../styles/Fonts";
import { size } from "../../../styles/Style";
@@ -26,6 +26,7 @@ const MusicCard = ({
imageUri = null,
projectId = null,
likedBy = [],
likeTarget = LIKE_TARGET.SONG,
}) => {
const [currentUID] = useGlobal("currentUID");
const [selected, setSelected] = useState(false);
@@ -58,8 +59,11 @@ const MusicCard = ({
const next = !selected;
setSelected(next);
try {
await projectsRef.doc(projectId).update({
likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID),
await toggleProjectLike({
projectId,
target: likeTarget,
currentUID,
next,
});
} catch (e) {
// rollback on failure
+15 -19
View File
@@ -15,12 +15,7 @@ import { icons, img } from "../../assets";
import { openComments } from "../../components/bottomsheets/CommentsBottomSheet";
import KaraokeLyrics from "../../components/KaraokeLyrics";
import ProfilePicture from "../../components/ProfilePicture";
import {
arrayRemove,
arrayUnion,
projectsRef,
usersRef,
} from "../../config/firebase";
import { projectsRef, usersRef } from "../../config/firebase";
import useDataFromRef from "../../hooks/useDataFromRef";
import { Routes } from "../../navigation";
import { navigate } from "../../navigation/NavigationService";
@@ -28,11 +23,16 @@ import { useUser } from "../../providers/UserDataProvider";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { size } from "../../styles/Style";
import {
getProjectLikes,
LIKE_TARGET,
toggleProjectLike,
} from "../../utils/likes";
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const { currentUID, followUser, unfollowUser } = useUser() || {};
const videoUrl = item?.playbackUrl || null;
const initialLikedBy = Array.isArray(item?.likedBy) ? item.likedBy : [];
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
const [isLiked, setIsLiked] = useState(
currentUID ? initialLikedBy.includes(currentUID) : false
);
@@ -100,10 +100,10 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
}, [owner?.followedBy, currentUID]);
useEffect(() => {
const lb = Array.isArray(item?.likedBy) ? item.likedBy : [];
const lb = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
setLikesCount(lb.length);
setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likedBy, currentUID]);
}, [item?.likes?.playback, currentUID]);
const projectTitle = typeof item?.title === "string" ? item.title.trim() : "";
@@ -292,16 +292,12 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
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 }
);
await toggleProjectLike({
projectId: item.id,
currentUID,
target: LIKE_TARGET.PLAYBACK,
next: nextLiked,
});
} catch (e) {
// rollback on failure
setIsLiked((v) => !v);
@@ -17,12 +17,7 @@ import {
} from "react-native";
import { icons, img } from "../../../assets";
import KaraokeLyrics from "../../../components/KaraokeLyrics";
import {
arrayRemove,
arrayUnion,
projectsRef,
usersRef,
} from "../../../config/firebase";
import { usersRef } from "../../../config/firebase";
import { Routes } from "../../../navigation";
import { navigate } from "../../../navigation/NavigationService";
import { useUser } from "../../../providers/UserDataProvider";
@@ -30,6 +25,11 @@ import { Palette } from "../../../styles";
import { FONT_FAMILY } from "../../../styles/Fonts";
import { size } from "../../../styles/Style";
import CommentsPanel from "./CommentsPanel.web";
import {
getProjectLikes,
LIKE_TARGET,
toggleProjectLike,
} from "../../../utils/likes";
// Debug logging toggle for web playback
const DEBUG_PLAYBACK_WEB = true;
@@ -61,7 +61,7 @@ const PlaybackItem = ({
const wasActiveRef = useRef(false);
const startedRef = useRef(false);
const initialLikedBy = Array.isArray(item?.likedBy) ? item.likedBy : [];
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
const [isLiked, setIsLiked] = useState(
currentUID ? initialLikedBy.includes(currentUID) : false
);
@@ -130,10 +130,10 @@ const PlaybackItem = ({
}, [owner?.followedBy, currentUID]);
useEffect(() => {
const lb = Array.isArray(item?.likedBy) ? item.likedBy : [];
const lb = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
setLikesCount(lb.length);
setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likedBy, currentUID]);
}, [item?.likes?.playback, currentUID]);
useEffect(() => {
startedRef.current = false;
@@ -428,15 +428,12 @@ const PlaybackItem = ({
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),
},
{ merge: true }
);
await toggleProjectLike({
projectId: item.id,
currentUID,
target: LIKE_TARGET.PLAYBACK,
next: nextLiked,
});
} catch (_e) {
setIsLiked((v) => !v);
setLikesCount((c) =>
+17 -2
View File
@@ -35,6 +35,7 @@ import { Routes } from "../../navigation";
import { push } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { getArtistDisplayName } from "../../utils/artistName";
import { getProjectLikes, LIKE_TARGET } from "../../utils/likes";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import Style, { gutters, size } from "../../styles/Style";
@@ -191,7 +192,11 @@ const Profile = () => {
};
// Composant factorisé pour afficher la liste de musiques
const MusicListSection = ({ data, emptyText }) => (
const MusicListSection = ({
data,
emptyText,
likeTarget = LIKE_TARGET.SONG,
}) => (
<ScrollView style={{ flex: 1, marginBottom: 70 }}>
{Array.isArray(data) && data.length > 0 ? (
<FlatList
@@ -207,12 +212,21 @@ const Profile = () => {
subtitle={isSelf ? selfDisplayName : targetDisplayName}
imageUri={item?.coverUrl || null}
projectId={item?.id}
likedBy={item?.likedBy || []}
likedBy={getProjectLikes(item, likeTarget)}
likeTarget={likeTarget}
onPress={() =>
navigateToMusicDetails({
projectId: item.id,
songUrl: item?.songUrl,
project: item,
queueProjects: data,
queueSource: {
id: isSelf ? "profile-self" : "profile-other",
type: "collection",
name: isSelf
? "Mes musiques"
: targetDisplayName || "Profil",
},
})
}
onPressMore={(posTop) => {
@@ -528,6 +542,7 @@ const Profile = () => {
<MusicListSection
data={displayedPlaybacks}
emptyText="Aucun playback pour le moment"
likeTarget={LIKE_TARGET.PLAYBACK}
/>
)}
{selected === "Chansons" && (
+2
View File
@@ -5,6 +5,7 @@ import DeleteAccountModal from "../components/modal/DeleteAccountModal";
import DeletePlaybackModal from "../components/modal/DeletePlaybackModal";
import DeleteAudioModal from "../components/modal/DeleteAudioModal";
import PlaylistModal from "../components/modal/PlaylistModal";
import PlaybackPickerModal from "../components/modal/PlaybackPickerModal";
import ShareModal from "../components/modal/ShareModal";
registerSheet("Delete", DeleteModal);
@@ -13,6 +14,7 @@ registerSheet("DeleteAccount", DeleteAccountModal);
registerSheet("DeletePlayback", DeletePlaybackModal);
registerSheet("DeleteAudio", DeleteAudioModal);
registerSheet("Playlist", PlaylistModal);
registerSheet("PlaybackPicker", PlaybackPickerModal);
registerSheet("Share", ShareModal);
export {};
+45
View File
@@ -0,0 +1,45 @@
import { arrayRemove, arrayUnion, projectsRef } from "../config/firebase";
export const LIKE_TARGET = {
SONG: "song",
PLAYBACK: "playback",
};
export const getLikeFieldPath = (target = LIKE_TARGET.SONG) => {
return target === LIKE_TARGET.PLAYBACK ? "likes.playback" : "likes.song";
};
export const getProjectLikes = (project, target = LIKE_TARGET.SONG) => {
const path = target === LIKE_TARGET.PLAYBACK ? "playback" : "song";
const likes = project?.likes;
const list = likes ? likes[path] : null;
return Array.isArray(list) ? list : [];
};
export const isProjectLikedByUser = (
project,
target,
currentUID
) => {
if (!currentUID) return false;
return getProjectLikes(project, target).includes(currentUID);
};
export const toggleProjectLike = async ({
projectId,
target = LIKE_TARGET.SONG,
currentUID,
next,
}) => {
if (!projectId || !currentUID) return;
const fieldPath = getLikeFieldPath(target);
await projectsRef.doc(projectId).set(
{
[fieldPath]: next
? arrayUnion(currentUID)
: arrayRemove(currentUID),
},
{ merge: true }
);
};