516 lines
16 KiB
JavaScript
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;
|