import { useIsFocused, useRoute } from '@react-navigation/native'; import { useAudioPlayer } from 'expo-audio'; import { BlurView } from 'expo-blur'; import { VideoView, useVideoPlayer } from 'expo-video'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Image, Platform, Pressable, Share, Text, View } from 'react-native'; import Carousel from 'react-native-reanimated-carousel'; import { responsiveHeight } from 'react-native-responsive-dimensions'; import { icons, img } from '../assets'; import { openComments } from '../components/bottomsheets/CommentsBottomSheet'; import KaraokeLyrics from '../components/KaraokeLyrics'; import { arrayRemove, arrayUnion, projectsRef, 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'; const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const { currentUID, followUser, unfollowUser } = useUser() || {}; const videoUrl = item?.playbackUrl || null; 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 commentsCount = useMemo( () => Number(item?.commentsCount || 0), [item?.commentsCount] ); 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]); // Live sync owner from Firestore to reflect follow changes elsewhere 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]); // Follow state derived from owner.followedBy 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]); const audioPlayer = useAudioPlayer(audioSource || undefined); const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { p.loop = false; p.muted = true; p.timeUpdateEventInterval = 0.2; }); useEffect(() => { const toggle = async () => { try { if (isActive) { try { if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0); } catch (e) {} try { if (videoPlayer) videoPlayer.currentTime = 0; } catch (e) {} // Lancer quasi simultanément (éviter await pour limiter le décalage) try { if (videoPlayer) videoPlayer.play(); } catch (e) {} try { if (audioPlayer && hasExternalAudio) audioPlayer.play?.(); } catch (e) {} } else { if (audioPlayer?.playing) await audioPlayer.pause?.(); if (videoPlayer?.playing) videoPlayer.pause(); } } catch (e) {} }; toggle(); }, [isActive, audioPlayer, videoPlayer, hasExternalAudio]); // Micro-correction initiale uniquement pour absorber un léger décalage réseau useEffect(() => { if (!isActive || !hasExternalAudio) return; const t = setTimeout(() => { try { const a = audioPlayer?.currentTime || 0; const v = videoPlayer?.currentTime || 0; if (Math.abs(a - v) > 0.2 && videoPlayer) { videoPlayer.currentTime = Math.max(0, a); } } catch (e) {} }, 300); return () => clearTimeout(t); }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); useEffect(() => { return () => { try { if (audioPlayer?.playing) audioPlayer.pause?.(); if (videoPlayer?.playing) videoPlayer.pause(); } catch (e) {} }; }, [audioPlayer, videoPlayer]); // Build alignedWords from item musicTimestamps 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]); // Track current time for lyrics sync const [currentTimeS, setCurrentTimeS] = useState(0); useEffect(() => { if (!isActive) return; const id = setInterval(() => { try { const t = hasExternalAudio ? Number(audioPlayer?.currentTime || 0) : Number(videoPlayer?.currentTime || 0); setCurrentTimeS(t); } catch (e) {} }, 250); return () => clearInterval(id); }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); // partage: géré via l'API native Share directement dans l'UI return ( {!!videoUrl ? ( ) : ( )} {/* Right side actions */} { navigate(Routes.SingerProfile, { userId: item?.userId }); }}> {owner?.profilePictureURL ? ( ) : ( )} {owner?.id && currentUID && owner.id !== currentUID && ( { try { const next = !isFollowing; setIsFollowing(next); // Optimistic update of local owner.followedBy 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) { // rollback on failure 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), // Optionally: updatedAt could be set if needed }, { merge: true } ); } catch (e) { // rollback on failure setIsLiked((v) => !v); setLikesCount((c) => (isLiked ? c + 1 : Math.max(0, c - 1))); } }} style={{ alignItems: 'center' }}> {!!likesCount && ( {likesCount} )} item?.id && openComments(item.id)} style={{ alignItems: 'center' }}> {!!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) {} }}> {alignedWords?.length > 0 ? ( ) : ( {item?.title || 'Description chanson'} )} ); }; const Playbacks = () => { 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, }); const onSnap = useCallback( (index) => { setActiveIndex(index); // Pré-charge la suite 6 par 6 if (index >= (playbacks?.length || 0) - 6) { loadMore?.(); } }, [playbacks?.length, loadMore] ); // When a specific playback id is provided via navigation params, // try to focus it by scrolling the carousel to its index. const triedLoadMoreRef = useRef(0); useEffect(() => { if (!focusProjectId) return; const idx = playbacks.findIndex((p) => p?.id === focusProjectId); if (idx >= 0) { setActiveIndex(idx); // give time for first render before scrolling 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]); return ( ( )} /> ); }; export default Playbacks;