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