404 lines
12 KiB
JavaScript
404 lines
12 KiB
JavaScript
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, 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 { 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);
|
|
|
|
// Fetch owner profile (cached)
|
|
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]);
|
|
console.log("has external audio : ", hasExternalAudio);
|
|
|
|
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]);
|
|
|
|
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 });
|
|
}}
|
|
>
|
|
{owner?.profilePictureURL ? (
|
|
<Image
|
|
source={{ uri: owner.profilePictureURL }}
|
|
style={{
|
|
...size({ size: 45 }),
|
|
borderRadius: 100,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Image
|
|
source={img.profile}
|
|
style={{
|
|
...size({ size: 45 }),
|
|
borderRadius: 100,
|
|
}}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
{owner?.id && currentUID && owner.id !== currentUID && (
|
|
<Pressable
|
|
onPress={async () => {
|
|
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);
|
|
}
|
|
}}
|
|
>
|
|
<BlurView
|
|
tint="dark"
|
|
intensity={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 ? "Ne plus suivre" : "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)));
|
|
|
|
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" }}
|
|
>
|
|
<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>
|
|
<Image source={icons.share} style={size({ size: 26 })} />
|
|
</Pressable>
|
|
</View>
|
|
<View style={{ paddingHorizontal: 28 }}>
|
|
<BlurView
|
|
tint="dark"
|
|
intensity={20}
|
|
style={{
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: 20,
|
|
backgroundColor: Palette.glass,
|
|
overflow: "hidden",
|
|
}}
|
|
experimentalBlurMethod={
|
|
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
|
}
|
|
>
|
|
<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 userCache = useRef(new Map());
|
|
const { getUserByUid } = useUser() || {};
|
|
const { data: playbacks = [], loadMore } = useDataFromRef({
|
|
ref: projectsRef.where("playbackUrl", "!=", null),
|
|
simpleRef: false,
|
|
listener: false,
|
|
usePagination: true,
|
|
batchSize: 2,
|
|
});
|
|
|
|
const onSnap = useCallback(
|
|
(index) => {
|
|
setActiveIndex(index);
|
|
// Pré-charge la suite 2 par 2
|
|
if (index >= (playbacks?.length || 0) - 2) {
|
|
loadMore?.();
|
|
}
|
|
},
|
|
[playbacks?.length, loadMore]
|
|
);
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
|
<Carousel
|
|
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={index === activeIndex}
|
|
/>
|
|
)}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default Playbacks;
|