From 6bb657628173de8a26eeb7da07ff5bdde56e7d5d Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 30 Sep 2025 14:43:39 +0200 Subject: [PATCH] playbacks --- src/layouts/Page.js | 2 - src/screens/Playbacks/Playbacks.web.js | 1035 +---------------- .../Playbacks/components/CommentsPanel.web.js | 392 +++++++ .../Playbacks/components/PlaybackItem.web.js | 625 ++++++++++ 4 files changed, 1050 insertions(+), 1004 deletions(-) create mode 100644 src/screens/Playbacks/components/CommentsPanel.web.js create mode 100644 src/screens/Playbacks/components/PlaybackItem.web.js diff --git a/src/layouts/Page.js b/src/layouts/Page.js index 2f4d032..9eefe40 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -44,8 +44,6 @@ export default ({ const ContentContainer = scrollEnabled ? KeyboardAwareScrollView : View; - const handleScroll = () => {}; - const computedWidth = width ?? (isWeb ? "60%" : "100%"); return ( diff --git a/src/screens/Playbacks/Playbacks.web.js b/src/screens/Playbacks/Playbacks.web.js index 3edaf19..2c4abd1 100644 --- a/src/screens/Playbacks/Playbacks.web.js +++ b/src/screens/Playbacks/Playbacks.web.js @@ -1,736 +1,14 @@ import { useIsFocused, useRoute } from "@react-navigation/native"; -import { useAudioPlayer } from "expo-audio"; -import { BlurView } from "expo-blur"; -import { Image as ExpoImage } from "expo-image"; -import { VideoView, useVideoPlayer } from "expo-video"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - FlatList, - Image, - Platform, - Pressable, - ScrollView, - Share, - StyleSheet, - Text, - TextInput, - View, - useWindowDimensions, -} from "react-native"; -// Carousel removed; using FlatList for real scrollable content - -import { setGlobal } from "reactn"; -import { icons, img } from "../../assets"; -import KaraokeLyrics from "../../components/KaraokeLyrics"; -import { - arrayRemove, - arrayUnion, - increment, - projectsRef, - serverTimestamp, - usersRef, -} from "../../config/firebase"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { FlatList, StyleSheet, View, useWindowDimensions } from "react-native"; +import { projectsRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; -import { Routes } from "../../navigation"; -import { navigate } from "../../navigation/NavigationService"; +import Page from "../../layouts/Page"; import { useUser } from "../../providers/UserDataProvider"; -import { Palette } from "../../styles"; -import { FONT_FAMILY } from "../../styles/Fonts"; -import { size } from "../../styles/Style"; - -// === Helpers === -const formatRelativeTime = (date) => { - try { - const d = date instanceof Date ? date : date?.toDate?.() || null; - if (!d) return ""; - const diff = Math.max(0, Date.now() - d.getTime()); - const sec = Math.floor(diff / 1000); - if (sec < 60) return `${sec}s`; - const min = Math.floor(sec / 60); - if (min < 60) return `${min}min`; - const h = Math.floor(min / 60); - if (h < 24) return `${h}h`; - const dA = Math.floor(h / 24); - return `${dA}j`; - } catch (_e) { - return ""; - } -}; - -// === Comments Panel === -const CommentsPanel = ({ - projectId, - description, - commentsCount, - onCommentAdded, - inputRef, - panelHeight, -}) => { - const { currentUID, currentUserData } = useUser() || {}; - const [text, setText] = useState(""); - const scrollRef = useRef(null); - - useEffect(() => { - setText(""); - try { - scrollRef.current?.scrollTo({ y: 0, animated: false }); - } catch (_e) {} - }, [projectId]); - - const commentsRef = useMemo(() => { - try { - return projectId - ? projectsRef - .doc(projectId) - .collection("comments") - .orderBy("createdAt", "desc") - : null; - } catch (_e) { - return null; - } - }, [projectId]); - - const { - data: comments = [], - setData: setComments, - loadMore, - } = useDataFromRef({ - ref: commentsRef, - simpleRef: false, - listener: false, - condition: !!commentsRef, - refreshArray: [projectId], - usePagination: true, - batchSize: 20, - }); - - const handleScroll = useCallback( - ({ nativeEvent }) => { - try { - const { layoutMeasurement, contentOffset, contentSize } = - nativeEvent || {}; - if ( - layoutMeasurement?.height + contentOffset?.y >= - (contentSize?.height || 0) - 120 - ) { - loadMore?.(); - } - } catch (_e) {} - }, - [loadMore] - ); - - const onSend = useCallback(async () => { - const value = (text || "").trim(); - if (!value || !projectId || !currentUID) return; - try { - setText(""); - const docRef = await projectsRef - .doc(projectId) - .collection("comments") - .add({ - userId: currentUID, - userName: currentUserData?.userName || "", - profilePicture: currentUserData?.profilePictureURL || "", - text: value, - createdAt: serverTimestamp(), - }); - try { - await projectsRef - .doc(projectId) - .set({ commentsCount: increment(1) }, { merge: true }); - } catch (_e) {} - const optimistic = { - id: docRef?.id || Math.random().toString(36).slice(2), - userId: currentUID, - userName: currentUserData?.userName || "", - profilePicture: currentUserData?.profilePictureURL || "", - text: value, - createdAt: new Date(), - }; - setComments((prev = []) => [ - optimistic, - ...prev.filter((x) => x?.id !== optimistic.id), - ]); - onCommentAdded?.(); - requestAnimationFrame(() => { - try { - scrollRef.current?.scrollTo({ y: 0, animated: true }); - } catch (_e) {} - }); - } catch (_e) {} - }, [ - currentUID, - currentUserData, - onCommentAdded, - projectId, - setComments, - text, - ]); - - const canComment = !!currentUID; - const descriptionText = (description || "").trim(); - - return ( - - - - Description - - - {descriptionText || "Description chanson..."} - - - - {`Commentaires${commentsCount ? ` (${commentsCount})` : ""}`} - - {Array.isArray(comments) && comments.length > 0 ? ( - comments.map((c, index) => ( - 0 && styles.commentRowSpacing, - ]} - > - {c?.profilePicture ? ( - - ) : ( - - )} - - - - {c?.userId === currentUID ? "vous" : c?.userName || ""} - - - • {formatRelativeTime(c?.createdAt)} - - - {c?.text} - - - )) - ) : ( - - Aucun commentaire pour le moment - - )} - - - - [ - styles.sendButton, - (!canComment || !text.trim()) && styles.sendButtonDisabled, - pressed && canComment && styles.sendButtonPressed, - ]} - > - - - - - - ); -}; - -// === Playback Item (WEB-ONLY, side-by-side, sound on) === -const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { - const { width: viewportWidth, height: viewportHeight } = - useWindowDimensions(); - const [layoutSize, setLayoutSize] = useState({ - width: viewportWidth, - height: viewportHeight, - }); - const commentInputRef = useRef(null); - const { currentUID, followUser, unfollowUser } = useUser() || {}; - const videoUrl = item?.playbackUrl || null; - - // External audio source (if present) - 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); - - const computedCommentsCount = useMemo( - () => Number(item?.commentsCount || 0), - [item?.commentsCount] - ); - const [commentsCount, setCommentsCount] = useState(computedCommentsCount); - useEffect(() => { - setCommentsCount(computedCommentsCount); - }, [computedCommentsCount]); - - 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]); - - 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]); - - 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]); - - // Players - const audioPlayer = useAudioPlayer(audioSource || undefined); - const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { - p.loop = true; - p.muted = false; // 🔊 Sound must be on - p.timeUpdateEventInterval = 0.2; - }); - - // Always attempt to start both audio & video together when active - useEffect(() => { - const startBoth = async () => { - try { - if (!isActive) return; - // reset positions - try { - if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0); - } catch (_e) {} - try { - if (videoPlayer) videoPlayer.currentTime = 0; - } catch (_e) {} - // play both - try { - if (videoPlayer && !videoPlayer.playing) await videoPlayer.play(); - } catch (_e) {} - try { - if (hasExternalAudio && audioPlayer && !audioPlayer.playing) - await audioPlayer.play?.(); - } catch (_e) {} - } catch (_e) {} - }; - startBoth(); - - // If autoplay with sound is blocked, attach once-only global listeners - // to resume without any extra UI/button. - if (isActive) { - const resume = async () => { - try { - if (videoPlayer && !videoPlayer.playing) await videoPlayer.play(); - if (hasExternalAudio && audioPlayer && !audioPlayer.playing) - await audioPlayer.play?.(); - } catch (_e) {} - }; - const events = [ - "pointerdown", - "click", - "touchstart", - "keydown", - "wheel", - "scroll", - ]; - events.forEach((ev) => - document.addEventListener(ev, resume, { once: true, passive: true }) - ); - return () => { - events.forEach((ev) => document.removeEventListener(ev, resume)); - }; - } - }, [isActive, audioPlayer, videoPlayer, hasExternalAudio]); - - // Keep A/V roughly aligned - useEffect(() => { - if (!isActive || !hasExternalAudio) return; - const t = setInterval(() => { - try { - const a = Number(audioPlayer?.currentTime || 0); - const v = Number(videoPlayer?.currentTime || 0); - if (Math.abs(a - v) > 0.2 && videoPlayer) { - videoPlayer.currentTime = Math.max(0, a); - } - } catch (_e) {} - }, 300); - return () => clearInterval(t); - }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); - - // Pause on unmount / when not active - useEffect(() => { - return () => { - try { - if (audioPlayer?.playing) audioPlayer.pause?.(); - if (videoPlayer?.playing) videoPlayer.pause(); - } catch (_e) {} - }; - }, [audioPlayer, videoPlayer]); - - const alignedWords = useMemo(() => { - const idx = Number(item?.songIndex) || 0; - const ts = item?.musicTimestamps?.[idx]; - const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []; - return arr.map((w) => ({ - word: String(w?.word ?? ""), - startS: Number(w?.startS ?? 0), - endS: Number(w?.endS ?? 0), - })); - }, [item?.musicTimestamps, item?.songIndex]); - - const [currentTimeS, setCurrentTimeS] = useState(0); - useEffect(() => { - if (!isActive) return; - const id = setInterval(() => { - try { - const useAudioTime = hasExternalAudio && !!audioPlayer?.playing; - const t = useAudioTime - ? Number(audioPlayer?.currentTime || 0) - : Number(videoPlayer?.currentTime || 0); - setCurrentTimeS(t); - } catch (_e) {} - }, 250); - return () => clearInterval(id); - }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); - - const descriptionText = - item?.description || item?.title || "Description chanson"; - - // Layout: always side-by-side on web - const handleLayout = useCallback((event) => { - const { width = 0, height = 0 } = event?.nativeEvent?.layout || {}; - setLayoutSize((prev) => ({ - width: width > 0 ? width : prev.width, - height: height > 0 ? height : prev.height, - })); - }, []); - - const layoutWidth = layoutSize.width || viewportWidth; - const layoutHeight = layoutSize.height || viewportHeight; - - // Force side-by-side - const horizontalPadding = 64; - const innerWidth = Math.max(layoutWidth - horizontalPadding * 2, 0); - const gapBetweenColumns = 40; - const minVideoWidth = 420; - const maxVideoWidth = 720; - const minCommentsWidth = 300; - const maxCommentsWidth = 420; - - let desiredHeight = Math.max(420, layoutHeight * 0.8); - let videoWidth = Math.min( - maxVideoWidth, - Math.max(minVideoWidth, innerWidth * 0.58) - ); - let videoHeight = videoWidth * (16 / 9); - if (videoHeight > desiredHeight) { - videoHeight = desiredHeight; - videoWidth = videoHeight * (9 / 16); - } - let commentsWidth = Math.min( - maxCommentsWidth, - Math.max(minCommentsWidth, innerWidth - videoWidth - gapBetweenColumns) - ); - const panelHeight = Math.min(videoHeight, desiredHeight); - - return ( - - - - - {!!videoUrl ? ( - - ) : ( - - )} - - - { - navigate(Routes.SingerProfile, { userId: item?.userId }); - }} - > - {owner?.profilePictureURL ? ( - - ) : ( - - )} - - {owner?.id && currentUID && owner.id !== currentUID && ( - { - try { - const next = !isFollowing; - setIsFollowing(next); - 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) { - setIsFollowing((v) => !v); - } - }} - > - - - {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), - }, - { merge: true } - ); - } catch (_e) { - setIsLiked((v) => !v); - setLikesCount((c) => - isLiked ? c + 1 : Math.max(0, c - 1) - ); - } - }} - style={[styles.actionButton, styles.actionSpacing]} - > - - {!!likesCount && ( - {likesCount} - )} - - commentInputRef.current?.focus?.()} - style={[styles.actionButton, styles.actionSpacing]} - > - - {!!commentsCount && ( - {commentsCount} - )} - - { - try { - const url = item?.playbackUrl || ""; - const title = item?.title || "Partager"; - const base = item?.title - ? `Découvre « ${item.title} » sur MusicLand` - : `Découvre ce playback sur MusicLand`; - const message = url ? `${base}\n${url}` : base; - - await Share.share( - Platform.select({ - ios: url ? { url, message, title } : { message, title }, - default: { message, title }, - }) - ); - } catch (_e) {} - }} - style={[styles.actionButton, styles.actionSpacing]} - > - - - - - - {alignedWords?.length > 0 ? ( - - ) : ( - {descriptionText} - )} - - - - - - - {/* Comments always visible side-by-side */} - - - setCommentsCount((c) => Math.max(0, Number(c || 0) + 1)) - } - inputRef={commentInputRef} - panelHeight={panelHeight} - /> - - - ); -}; +import PlaybackItem from "./components/PlaybackItem.web"; const Playbacks = () => { - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const { height: windowHeight } = useWindowDimensions(); const [activeIndex, setActiveIndex] = useState(0); const carouselRef = useRef(null); const route = useRoute(); @@ -747,23 +25,6 @@ const Playbacks = () => { batchSize: 6, }); - // Web-only layout hint - useEffect(() => { - if (Platform.OS !== "web") return undefined; - setGlobal({ webLayoutMode: isFocused ? "playbacks-wide" : "default" }); - return () => setGlobal({ webLayoutMode: "default" }); - }, [isFocused]); - - const onSnap = useCallback( - (index) => { - setActiveIndex(index); - if (index >= (playbacks?.length || 0) - 6) { - loadMore?.(); - } - }, - [playbacks?.length, loadMore] - ); - const triedLoadMoreRef = useRef(0); useEffect(() => { if (!focusProjectId) return; @@ -824,271 +85,41 @@ const Playbacks = () => { ); return ( - - String(item?.id || index)} - renderItem={({ item, index }) => ( - - - - )} - pagingEnabled - showsVerticalScrollIndicator={false} - onMomentumScrollEnd={onMomentumScrollEnd} - getItemLayout={getItemLayout} - initialNumToRender={3} - windowSize={5} - removeClippedSubviews - scrollEventThrottle={32} - /> - + + + String(item?.id || index)} + renderItem={({ item, index }) => ( + + + + )} + pagingEnabled + showsVerticalScrollIndicator={false} + onMomentumScrollEnd={onMomentumScrollEnd} + getItemLayout={getItemLayout} + initialNumToRender={3} + windowSize={5} + removeClippedSubviews + scrollEventThrottle={32} + /> + + ); }; export default Playbacks; -// === Styles === const styles = StyleSheet.create({ screen: { flex: 1, - // prevent page scroll bounce and ensure the wheel maps to paging - // (RN Web supports these CSS props on View) overscrollBehavior: "none", }, - itemContainerBase: { - width: "100%", - paddingVertical: 36, - alignItems: "center", - }, - itemContainerRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - }, - videoColumn: { - alignItems: "center", - justifyContent: "center", - }, - videoSurface: { - alignSelf: "center", - aspectRatio: 9 / 16, - borderRadius: 32, - overflow: "hidden", - backgroundColor: "#07040D", - shadowColor: "#000", - shadowOpacity: 0.45, - shadowRadius: 30, - shadowOffset: { width: 0, height: 20 }, - elevation: 12, - }, - videoContainer: { - flex: 1, - position: "relative", - }, - actionStack: { - position: "absolute", - right: 18, - bottom: 120, - alignItems: "center", - }, - ownerBlock: { - alignItems: "center", - marginBottom: 26, - }, - ownerAvatarButton: { - marginBottom: 16, - }, - ownerAvatar: { - ...size({ size: 56 }), - borderRadius: 100, - }, - followPressable: { - alignSelf: "center", - }, - followButton: { - paddingVertical: 8, - paddingHorizontal: 18, - borderRadius: 14, - borderWidth: 1, - borderColor: Palette.white, - backgroundColor: "#FFFFFF20", - overflow: "hidden", - }, - followButtonText: { - fontSize: 14, - color: Palette.white, - fontFamily: FONT_FAMILY.InterMedium, - }, - actionButton: { - alignItems: "center", - justifyContent: "center", - }, - actionSpacing: { - marginTop: 24, - }, - actionIcon: { - ...size({ size: 30 }), - }, - actionLabel: { - marginTop: 6, - fontSize: 13, - color: Palette.white, - fontFamily: FONT_FAMILY.InterMedium, - textAlign: "center", - }, - lyricsContainer: { - position: "absolute", - left: 24, - right: 24, - bottom: 32, - }, - lyricsCard: { - paddingHorizontal: 18, - paddingVertical: 12, - borderRadius: 22, - backgroundColor: Palette.glass, - overflow: "hidden", - }, - lyricsText: { - fontSize: 14, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, - }, - commentsColumn: { - alignSelf: "stretch", - }, - commentsWrapper: { - flex: 1, - borderRadius: 28, - overflow: "hidden", - padding: 24, - backgroundColor: Palette.glass, - minHeight: 0, - }, - commentsInner: { - flex: 1, - justifyContent: "space-between", - }, - commentsScroll: { - flex: 1, - }, - commentsContent: { - paddingBottom: 24, - flexGrow: 1, - }, - sectionTitle: { - fontSize: 18, - color: Palette.white, - fontFamily: FONT_FAMILY.InterSemiBold, - marginBottom: 12, - }, - sectionSpacing: { - marginTop: 24, - }, - descriptionCard: { - borderRadius: 18, - backgroundColor: "#FFFFFF14", - paddingHorizontal: 18, - paddingVertical: 14, - }, - descriptionText: { - fontSize: 14, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, - lineHeight: 20, - }, - commentRow: { - flexDirection: "row", - alignItems: "flex-start", - }, - commentRowSpacing: { - marginTop: 16, - }, - commentAvatar: { - ...size({ size: 48 }), - borderRadius: 100, - marginRight: 12, - }, - commentAvatarFallback: { - ...size({ size: 48 }), - borderRadius: 100, - backgroundColor: "#FFFFFF33", - marginRight: 12, - }, - commentBubble: { - flex: 1, - backgroundColor: "#FFFFFF18", - borderRadius: 20, - paddingHorizontal: 16, - paddingVertical: 12, - }, - commentHeader: { - flexDirection: "row", - alignItems: "center", - marginBottom: 4, - }, - commentAuthor: { - fontSize: 13, - color: Palette.white, - fontFamily: FONT_FAMILY.InterMedium, - }, - commentMeta: { - fontSize: 12, - color: "#FFFFFFA0", - fontFamily: FONT_FAMILY.InterRegular, - marginLeft: 8, - }, - commentBody: { - fontSize: 14, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, - lineHeight: 20, - }, - emptyState: { - fontSize: 14, - color: "#FFFFFFBB", - fontFamily: FONT_FAMILY.InterRegular, - }, - commentInputContainer: { - marginTop: 20, - flexDirection: "row", - alignItems: "center", - backgroundColor: "#FFFFFF14", - borderRadius: 18, - paddingHorizontal: 18, - paddingVertical: 10, - }, - commentInput: { - flex: 1, - color: Palette.white, - fontSize: 14, - fontFamily: FONT_FAMILY.InterRegular, - minHeight: 36, - marginRight: 12, - }, - sendButton: { - ...size({ size: 38 }), - borderRadius: 19, - alignItems: "center", - justifyContent: "center", - backgroundColor: Palette.primary, - }, - sendButtonPressed: { - transform: [{ scale: 0.95 }], - }, - sendButtonDisabled: { - backgroundColor: "#FFFFFF33", - }, - sendIcon: { - width: 22, - height: 22, - tintColor: Palette.white, - }, }); diff --git a/src/screens/Playbacks/components/CommentsPanel.web.js b/src/screens/Playbacks/components/CommentsPanel.web.js new file mode 100644 index 0000000..0ee5897 --- /dev/null +++ b/src/screens/Playbacks/components/CommentsPanel.web.js @@ -0,0 +1,392 @@ +import { BlurView } from "expo-blur"; +import { Image as ExpoImage } from "expo-image"; +import moment from "moment"; +import "moment/locale/fr"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Image, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { icons } from "../../../assets"; +import { + increment, + projectsRef, + serverTimestamp, +} from "../../../config/firebase"; +import useDataFromRef from "../../../hooks/useDataFromRef"; +import { useUser } from "../../../providers/UserDataProvider"; +import { Palette } from "../../../styles"; +import { FONT_FAMILY } from "../../../styles/Fonts"; +import { size } from "../../../styles/Style"; + +moment.locale("fr"); + +const formatRelativeTime = (date) => { + try { + const d = date instanceof Date ? date : date?.toDate?.() || null; + return d ? moment(d).fromNow() : ""; + } catch (_e) { + return ""; + } +}; + +const CommentsPanel = ({ + projectId, + description, + commentsCount, + onCommentAdded, + inputRef, + panelHeight, +}) => { + const { currentUID, currentUserData } = useUser() || {}; + const [text, setText] = useState(""); + const scrollRef = useRef(null); + + useEffect(() => { + setText(""); + try { + scrollRef.current?.scrollTo({ y: 0, animated: false }); + } catch (_e) {} + }, [projectId]); + + const commentsRef = useMemo(() => { + try { + return projectId + ? projectsRef + .doc(projectId) + .collection("comments") + .orderBy("createdAt", "desc") + : null; + } catch (_e) { + return null; + } + }, [projectId]); + + const { + data: comments = [], + setData: setComments, + loadMore, + } = useDataFromRef({ + ref: commentsRef, + simpleRef: false, + listener: false, + condition: !!commentsRef, + refreshArray: [projectId], + usePagination: true, + batchSize: 20, + }); + + const handleScroll = useCallback( + ({ nativeEvent }) => { + try { + const { layoutMeasurement, contentOffset, contentSize } = + nativeEvent || {}; + if ( + layoutMeasurement?.height + contentOffset?.y >= + (contentSize?.height || 0) - 120 + ) { + loadMore?.(); + } + } catch (_e) {} + }, + [loadMore] + ); + + const onSend = useCallback(async () => { + const value = (text || "").trim(); + if (!value || !projectId || !currentUID) return; + try { + setText(""); + const docRef = await projectsRef + .doc(projectId) + .collection("comments") + .add({ + userId: currentUID, + userName: currentUserData?.userName || "", + profilePicture: currentUserData?.profilePictureURL || "", + text: value, + createdAt: serverTimestamp(), + }); + try { + await projectsRef + .doc(projectId) + .set({ commentsCount: increment(1) }, { merge: true }); + } catch (_e) {} + const optimistic = { + id: docRef?.id || Math.random().toString(36).slice(2), + userId: currentUID, + userName: currentUserData?.userName || "", + profilePicture: currentUserData?.profilePictureURL || "", + text: value, + createdAt: new Date(), + }; + setComments((prev = []) => [ + optimistic, + ...prev.filter((x) => x?.id !== optimistic.id), + ]); + onCommentAdded?.(); + requestAnimationFrame(() => { + try { + scrollRef.current?.scrollTo({ y: 0, animated: true }); + } catch (_e) {} + }); + } catch (_e) {} + }, [ + currentUID, + currentUserData, + onCommentAdded, + projectId, + setComments, + text, + ]); + + const canComment = !!currentUID; + const descriptionText = (description || "").trim(); + + return ( + + + + Description + + + {descriptionText || "Description chanson..."} + + + + {`Commentaires${commentsCount ? ` (${commentsCount})` : ""}`} + + {Array.isArray(comments) && comments.length > 0 ? ( + comments.map((c, index) => ( + 0 && styles.commentRowSpacing, + ]} + > + {c?.profilePicture ? ( + + ) : ( + + )} + + + + {c?.userId === currentUID ? "vous" : c?.userName || ""} + + + • {formatRelativeTime(c?.createdAt)} + + + {c?.text} + + + )) + ) : ( + + Aucun commentaire pour le moment + + )} + + + + [ + styles.sendButton, + (!canComment || !text.trim()) && styles.sendButtonDisabled, + pressed && canComment && styles.sendButtonPressed, + ]} + > + + + + + + ); +}; + +export default CommentsPanel; + +const styles = StyleSheet.create({ + commentsWrapper: { + flex: 1, + borderRadius: 28, + overflow: "hidden", + padding: 24, + backgroundColor: Palette.glass, + minHeight: 0, + }, + commentsInner: { + flex: 1, + justifyContent: "space-between", + }, + commentsScroll: { + flex: 1, + }, + commentsContent: { + paddingBottom: 24, + flexGrow: 1, + }, + sectionTitle: { + fontSize: 18, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + marginBottom: 12, + }, + sectionSpacing: { + marginTop: 24, + }, + descriptionCard: { + borderRadius: 18, + backgroundColor: "#FFFFFF14", + paddingHorizontal: 18, + paddingVertical: 14, + }, + descriptionText: { + fontSize: 14, + color: Palette.white, + fontFamily: FONT_FAMILY.InterRegular, + lineHeight: 20, + }, + commentRow: { + flexDirection: "row", + alignItems: "flex-start", + }, + commentRowSpacing: { + marginTop: 16, + }, + commentAvatar: { + ...size({ size: 48 }), + borderRadius: 100, + marginRight: 12, + }, + commentAvatarFallback: { + ...size({ size: 48 }), + borderRadius: 100, + backgroundColor: "#FFFFFF33", + marginRight: 12, + }, + commentBubble: { + flex: 1, + backgroundColor: "#FFFFFF18", + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 12, + }, + commentHeader: { + flexDirection: "row", + alignItems: "center", + marginBottom: 4, + }, + commentAuthor: { + fontSize: 13, + color: Palette.white, + fontFamily: FONT_FAMILY.InterMedium, + }, + commentMeta: { + fontSize: 12, + color: "#FFFFFFA0", + fontFamily: FONT_FAMILY.InterRegular, + marginLeft: 8, + }, + commentBody: { + fontSize: 14, + color: Palette.white, + fontFamily: FONT_FAMILY.InterRegular, + lineHeight: 20, + }, + emptyState: { + fontSize: 14, + color: "#FFFFFFBB", + fontFamily: FONT_FAMILY.InterRegular, + }, + commentInputContainer: { + marginTop: 20, + flexDirection: "row", + alignItems: "center", + backgroundColor: "#FFFFFF14", + borderRadius: 18, + paddingHorizontal: 18, + paddingVertical: 10, + }, + commentInput: { + flex: 1, + color: Palette.white, + fontSize: 14, + fontFamily: FONT_FAMILY.InterRegular, + minHeight: 36, + marginRight: 12, + }, + sendButton: { + ...size({ size: 38 }), + borderRadius: 19, + alignItems: "center", + justifyContent: "center", + backgroundColor: Palette.primary, + }, + sendButtonPressed: { + transform: [{ scale: 0.95 }], + }, + sendButtonDisabled: { + backgroundColor: "#FFFFFF33", + }, + sendIcon: { + width: 22, + height: 22, + tintColor: Palette.white, + }, +}); diff --git a/src/screens/Playbacks/components/PlaybackItem.web.js b/src/screens/Playbacks/components/PlaybackItem.web.js new file mode 100644 index 0000000..fddb2d9 --- /dev/null +++ b/src/screens/Playbacks/components/PlaybackItem.web.js @@ -0,0 +1,625 @@ +import { useAudioPlayer } from "expo-audio"; +import { BlurView } from "expo-blur"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Image, + Pressable, + Share, + StyleSheet, + Text, + View, + useWindowDimensions, +} from "react-native"; +import { icons, img } from "../../../assets"; +import KaraokeLyrics from "../../../components/KaraokeLyrics"; +import { + arrayRemove, + arrayUnion, + projectsRef, + usersRef, +} from "../../../config/firebase"; +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 { size } from "../../../styles/Style"; +import CommentsPanel from "./CommentsPanel.web"; + +// NOTE: Web-only implementation that uses a native