Files
musicland/src/screens/Playbacks/Playbacks.js
T
2025-11-05 10:15:50 +01:00

516 lines
16 KiB
JavaScript

import { useIsFocused, useRoute } from "@react-navigation/native";
import { BlurView } from "expo-blur";
import { VideoView, useVideoPlayer } from "expo-video";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Image, Platform, Pressable, 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 ProfilePicture from "../../components/ProfilePicture";
import { 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";
import {
getProjectLikes,
LIKE_TARGET,
toggleProjectLike,
} from "../../utils/likes";
import {
createPlaybackSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
import { SheetManager } from "react-native-actions-sheet";
import { Feather } from "@expo/vector-icons";
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const { currentUID, followUser, unfollowUser } = useUser() || {};
const videoUrl = item?.playbackUrl || null;
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
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);
const [isFollowActionPending, setIsFollowActionPending] = useState(false);
useEffect(() => {
const list = Array.isArray(owner?.followedBy) ? owner.followedBy : [];
setIsFollowing(currentUID ? list.includes(currentUID) : false);
}, [owner?.followedBy, currentUID]);
useEffect(() => {
const lb = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
setLikesCount(lb.length);
setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likes?.playback, currentUID]);
const projectTitle = typeof item?.title === "string" ? item.title.trim() : "";
const creatorName = useMemo(() => {
if (owner?.displayName) return owner.displayName;
if (owner?.artistName) return owner.artistName;
if (owner?.userName) return owner.userName;
if (typeof item?.userName === "string") return item.userName;
return "";
}, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]);
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
p.loop = false;
p.muted = false;
p.timeUpdateEventInterval = 0.2;
});
useEffect(() => {
const toggle = async () => {
try {
if (isActive) {
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) {}
} else {
if (videoPlayer?.playing) videoPlayer.pause();
}
} catch (e) {}
};
toggle();
}, [isActive, videoPlayer]);
useEffect(() => {
return () => {
try {
if (videoPlayer?.playing) videoPlayer.pause();
} catch (e) {}
};
}, [videoPlayer]);
const handleReport = useCallback(() => {
if (!item?.id) return;
SheetManager.show("Report", {
payload: {
targetType: "playback",
projectId: item?.id || null,
playbackId: item?.id || null,
title: item?.title || "",
ownerId: item?.userId || null,
},
});
}, [item?.id, item?.title, item?.userId]);
// 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 = global.setInterval(() => {
try {
setCurrentTimeS(Number(videoPlayer?.currentTime || 0));
} catch (e) {}
}, 250);
return () => global.clearInterval(id);
}, [isActive, videoPlayer]);
const sharePayload = useMemo(() => {
if (!item?.id) return null;
return createPlaybackSharePayload({
projectId: item.id,
title: projectTitle || undefined,
artist: creatorName || undefined,
playbackUrl: videoUrl || undefined,
});
}, [creatorName, item?.id, projectTitle, videoUrl]);
const handleShare = useCallback(() => {
if (sharePayload) {
openShareSheet(sharePayload);
}
}, [sharePayload]);
// partage: via feuille unifiée (prend en charge QR)
return (
<View
style={{
height: responsiveHeight(100),
position: "relative",
backgroundColor: "black",
}}
>
{!!videoUrl ? (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="cover"
style={{ width: "100%", height: "100%" }}
/>
) : (
<Image
source={img.placeholder3}
style={{ width: "100%", height: "100%" }}
/>
)}
{/* Right side actions */}
<View
style={{
position: "absolute",
width: "100%",
bottom: 130,
gap: 18,
}}
>
<View
style={{
alignSelf: "flex-end",
alignItems: "center",
gap: 20,
paddingHorizontal: 13,
}}
>
<View style={{ gap: 6, alignItems: "center" }}>
<Pressable
onPress={() => {
navigate(Routes.SingerProfile, { userId: item?.userId });
}}
>
<ProfilePicture
uri={owner?.profilePictureURL || null}
size={45}
imageProps={{ priority: "high" }}
/>
</Pressable>
{owner?.id && currentUID && owner.id !== currentUID && (
<Pressable
disabled={isFollowActionPending}
onPress={async () => {
if (isFollowActionPending) return;
try {
setIsFollowActionPending(true);
const next = !isFollowing;
console.log("follwin");
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);
// Timeout de 2 secondes avant de pouvoir recliquer
global.setTimeout(() => {
setIsFollowActionPending(false);
}, 1000);
} catch (e) {
// rollback on failure
setIsFollowing((v) => !v);
setIsFollowActionPending(false);
}
}}
>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={{
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: Palette.white,
overflow: "hidden",
backgroundColor: isFollowing ? "#FFFFFF1A" : undefined,
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
>
<Text
style={{
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{isFollowing ? "Suivi(e)" : "Suivre"}
</Text>
</BlurView>
</Pressable>
)}
</View>
<Pressable
onPress={async () => {
try {
if (!currentUID || !item?.id) return;
const nextLiked = !isLiked;
setIsLiked(nextLiked);
setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
await toggleProjectLike({
projectId: item.id,
currentUID,
target: LIKE_TARGET.PLAYBACK,
next: nextLiked,
});
} catch (e) {
// rollback on failure
setIsLiked((v) => !v);
setLikesCount((c) => (isLiked ? c + 1 : Math.max(0, c - 1)));
}
}}
style={{ alignItems: "center" }}
>
<Image
source={isLiked ? icons.heart : icons.heartOutline}
style={size({ size: 26 })}
resizeMode="contain"
/>
{!!likesCount && (
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
{likesCount}
</Text>
)}
</Pressable>
<Pressable
onPress={() => item?.id && openComments(item.id)}
style={{ alignItems: "center" }}
>
<Image source={icons.chatBubble} style={size({ size: 26 })} />
{!!commentsCount && (
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
{commentsCount}
</Text>
)}
</Pressable>
<Pressable onPress={handleShare}>
<Image source={icons.share} style={size({ size: 26 })} />
</Pressable>
<Pressable onPress={handleReport} style={{ alignItems: "center" }}>
<Feather name="flag" size={26} color={Palette.white} />
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
Signaler
</Text>
</Pressable>
</View>
<View style={{ paddingHorizontal: 28 }}>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={{
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: Palette.glass,
overflow: "hidden",
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
>
{alignedWords?.length > 0 ? (
<KaraokeLyrics
alignedWords={alignedWords}
currentTimeS={currentTimeS}
/>
) : (
<Text
style={{
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{item?.title || "Description chanson"}
</Text>
)}
</BlurView>
</View>
</View>
</View>
);
};
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
global.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 (
<View style={{ flex: 1, backgroundColor: "black" }}>
<Carousel
ref={carouselRef}
data={playbacks}
vertical
height={responsiveHeight(100)}
pagingEnabled
windowSize={5}
onSnapToItem={onSnap}
renderItem={({ item, index }) => (
<PlaybackItem
key={item?.id || index}
item={item}
userCache={userCache}
getUserByUid={getUserByUid}
isActive={isFocused && index === activeIndex}
/>
)}
/>
</View>
);
};
export default Playbacks;