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 useDataFromRef from "../../hooks/useDataFromRef"; import { Routes } from "../../navigation"; import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { 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} /> ); }; const Playbacks = () => { const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const [activeIndex, setActiveIndex] = useState(0); const carouselRef = useRef(null); const route = useRoute(); const focusProjectId = route?.params?.projectId || route?.params?.focusId || null; const userCache = useRef(new Map()); const { getUserByUid } = useUser() || {}; const isFocused = useIsFocused(); const { data: playbacks = [], loadMore } = useDataFromRef({ ref: projectsRef.where("playbackUrl", "!=", null), simpleRef: false, listener: false, usePagination: true, 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; const idx = playbacks.findIndex((p) => p?.id === focusProjectId); if (idx >= 0) { setActiveIndex(idx); setTimeout(() => { try { carouselRef.current?.scrollTo?.({ index: idx, animated: false }); } catch (_e) {} }, 50); } else if (loadMore && triedLoadMoreRef.current < 6) { triedLoadMoreRef.current += 1; loadMore(); } }, [focusProjectId, playbacks, loadMore]); // === FlatList (one real page per item) === const listRef = useRef(null); // keep active index in sync with scroll const onMomentumScrollEnd = useCallback( (e) => { try { const y = e?.nativeEvent?.contentOffset?.y || 0; const idx = Math.round(y / windowHeight); if (!Number.isNaN(idx)) setActiveIndex( Math.max(0, Math.min(idx, (playbacks?.length || 1) - 1)) ); if (idx >= (playbacks?.length || 0) - 6) loadMore?.(); } catch (_e) {} }, [windowHeight, playbacks?.length, loadMore] ); // programmatic jump to focus item useEffect(() => { if (!focusProjectId) return; const idx = playbacks.findIndex((p) => p?.id === focusProjectId); if (idx >= 0) { setActiveIndex(idx); setTimeout(() => { try { listRef.current?.scrollToIndex?.({ index: idx, animated: false }); } catch (_e) {} }, 50); } }, [focusProjectId, playbacks]); const getItemLayout = useCallback( (_data, index) => ({ length: windowHeight, offset: windowHeight * index, index, }), [windowHeight] ); return ( 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, }, });