playbacks web
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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 (
|
||||
<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={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 ? "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
|
||||
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={async () => {
|
||||
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) {}
|
||||
}}
|
||||
>
|
||||
<Image source={icons.share} style={size({ size: 26 })} />
|
||||
</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
|
||||
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;
|
||||
Reference in New Issue
Block a user