diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 9e0c2dc..5d43d57 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -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, }, }); diff --git a/src/components/modal/PlaybackPickerModal.js b/src/components/modal/PlaybackPickerModal.js new file mode 100644 index 0000000..8c45bd1 --- /dev/null +++ b/src/components/modal/PlaybackPickerModal.js @@ -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 ( + + + Ajouter un playback + + Choisis une musique déjà créée pour y ajouter un playback ou lance un + nouveau projet. + + + {hasProjects ? ( + + {sanitizedProjects.map((project) => { + const coverUri = project?.coverUrl || null; + return ( + handleSelectProject(project)} + style={styles.projectCard} + > + + + + {project?.title || "Sans titre"} + + + {project?.userName || "Moi"} + + + Playback à créer + + + + ); + })} + + ) : ( + + + {hasAnyMusic + ? "Aucune musique n'est prête pour l'étape playback" + : "Tu n'as pas encore de musique prête pour le playback"} + + + {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."} + + + )} + + + + + ); +}; + +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, + }, +}); diff --git a/src/hooks/useUserLikedProjects.js b/src/hooks/useUserLikedProjects.js index 0aa6814..7fe8c5c 100644 --- a/src/hooks/useUserLikedProjects.js +++ b/src/hooks/useUserLikedProjects.js @@ -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 }; } - diff --git a/src/providers/UserDataProvider.js b/src/providers/UserDataProvider.js index fdcced1..be7e7c3 100644 --- a/src/providers/UserDataProvider.js +++ b/src/providers/UserDataProvider.js @@ -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, diff --git a/src/screens/Home/HomeSave.js b/src/screens/Home/HomeSave.js index 86605d8..ed4f78a 100644 --- a/src/screens/Home/HomeSave.js +++ b/src/screens/Home/HomeSave.js @@ -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); diff --git a/src/screens/Library/components/BackTracks.js b/src/screens/Library/components/BackTracks.js index 56cc52c..58f1221 100644 --- a/src/screens/Library/components/BackTracks.js +++ b/src/screens/Library/components/BackTracks.js @@ -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 ( { liked: false, }) } - onPressPlus={() => navigate(Routes.WritingLyrics)} + onPressPlus={openPlaybackPicker} > {items.length > 0 ? ( @@ -85,7 +89,7 @@ const BackTracks = () => { navigate(Routes.WritingLyrics)} + onPress={openPlaybackPicker} title=" Créer un playback" > diff --git a/src/screens/Library/components/MusicCard.js b/src/screens/Library/components/MusicCard.js index 974e2a9..b1fe313 100644 --- a/src/screens/Library/components/MusicCard.js +++ b/src/screens/Library/components/MusicCard.js @@ -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 diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index dd6522d..d094f28 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -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); diff --git a/src/screens/Playbacks/components/PlaybackItem.web.js b/src/screens/Playbacks/components/PlaybackItem.web.js index 5450798..7e16133 100644 --- a/src/screens/Playbacks/components/PlaybackItem.web.js +++ b/src/screens/Playbacks/components/PlaybackItem.web.js @@ -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) => diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js index d9ceedb..2d76515 100644 --- a/src/screens/Profile/Profile.js +++ b/src/screens/Profile/Profile.js @@ -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, + }) => ( {Array.isArray(data) && data.length > 0 ? ( { 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 = () => { )} {selected === "Chansons" && ( diff --git a/src/utils/Sheet.js b/src/utils/Sheet.js index 0782789..4eee952 100644 --- a/src/utils/Sheet.js +++ b/src/utils/Sheet.js @@ -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 {}; diff --git a/src/utils/likes.js b/src/utils/likes.js new file mode 100644 index 0000000..8990b54 --- /dev/null +++ b/src/utils/likes.js @@ -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 } + ); +}; +