1095 lines
31 KiB
JavaScript
1095 lines
31 KiB
JavaScript
import { useIsFocused, useRoute } from "@react-navigation/native";
|
|
import { useAudioPlayer } from "expo-audio";
|
|
import { BlurView } from "expo-blur";
|
|
import { Image as ExpoImage } from "expo-image";
|
|
import { VideoView, useVideoPlayer } from "expo-video";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
FlatList,
|
|
Image,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
Share,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
// Carousel removed; using FlatList for real scrollable content
|
|
|
|
import { setGlobal } from "reactn";
|
|
import { icons, img } from "../../assets";
|
|
import KaraokeLyrics from "../../components/KaraokeLyrics";
|
|
import {
|
|
arrayRemove,
|
|
arrayUnion,
|
|
increment,
|
|
projectsRef,
|
|
serverTimestamp,
|
|
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";
|
|
|
|
// === Helpers ===
|
|
const formatRelativeTime = (date) => {
|
|
try {
|
|
const d = date instanceof Date ? date : date?.toDate?.() || null;
|
|
if (!d) return "";
|
|
const diff = Math.max(0, Date.now() - d.getTime());
|
|
const sec = Math.floor(diff / 1000);
|
|
if (sec < 60) return `${sec}s`;
|
|
const min = Math.floor(sec / 60);
|
|
if (min < 60) return `${min}min`;
|
|
const h = Math.floor(min / 60);
|
|
if (h < 24) return `${h}h`;
|
|
const dA = Math.floor(h / 24);
|
|
return `${dA}j`;
|
|
} catch (_e) {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
// === Comments Panel ===
|
|
const CommentsPanel = ({
|
|
projectId,
|
|
description,
|
|
commentsCount,
|
|
onCommentAdded,
|
|
inputRef,
|
|
panelHeight,
|
|
}) => {
|
|
const { currentUID, currentUserData } = useUser() || {};
|
|
const [text, setText] = useState("");
|
|
const scrollRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
setText("");
|
|
try {
|
|
scrollRef.current?.scrollTo({ y: 0, animated: false });
|
|
} catch (_e) {}
|
|
}, [projectId]);
|
|
|
|
const commentsRef = useMemo(() => {
|
|
try {
|
|
return projectId
|
|
? projectsRef
|
|
.doc(projectId)
|
|
.collection("comments")
|
|
.orderBy("createdAt", "desc")
|
|
: null;
|
|
} catch (_e) {
|
|
return null;
|
|
}
|
|
}, [projectId]);
|
|
|
|
const {
|
|
data: comments = [],
|
|
setData: setComments,
|
|
loadMore,
|
|
} = useDataFromRef({
|
|
ref: commentsRef,
|
|
simpleRef: false,
|
|
listener: false,
|
|
condition: !!commentsRef,
|
|
refreshArray: [projectId],
|
|
usePagination: true,
|
|
batchSize: 20,
|
|
});
|
|
|
|
const handleScroll = useCallback(
|
|
({ nativeEvent }) => {
|
|
try {
|
|
const { layoutMeasurement, contentOffset, contentSize } =
|
|
nativeEvent || {};
|
|
if (
|
|
layoutMeasurement?.height + contentOffset?.y >=
|
|
(contentSize?.height || 0) - 120
|
|
) {
|
|
loadMore?.();
|
|
}
|
|
} catch (_e) {}
|
|
},
|
|
[loadMore]
|
|
);
|
|
|
|
const onSend = useCallback(async () => {
|
|
const value = (text || "").trim();
|
|
if (!value || !projectId || !currentUID) return;
|
|
try {
|
|
setText("");
|
|
const docRef = await projectsRef
|
|
.doc(projectId)
|
|
.collection("comments")
|
|
.add({
|
|
userId: currentUID,
|
|
userName: currentUserData?.userName || "",
|
|
profilePicture: currentUserData?.profilePictureURL || "",
|
|
text: value,
|
|
createdAt: serverTimestamp(),
|
|
});
|
|
try {
|
|
await projectsRef
|
|
.doc(projectId)
|
|
.set({ commentsCount: increment(1) }, { merge: true });
|
|
} catch (_e) {}
|
|
const optimistic = {
|
|
id: docRef?.id || Math.random().toString(36).slice(2),
|
|
userId: currentUID,
|
|
userName: currentUserData?.userName || "",
|
|
profilePicture: currentUserData?.profilePictureURL || "",
|
|
text: value,
|
|
createdAt: new Date(),
|
|
};
|
|
setComments((prev = []) => [
|
|
optimistic,
|
|
...prev.filter((x) => x?.id !== optimistic.id),
|
|
]);
|
|
onCommentAdded?.();
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
scrollRef.current?.scrollTo({ y: 0, animated: true });
|
|
} catch (_e) {}
|
|
});
|
|
} catch (_e) {}
|
|
}, [
|
|
currentUID,
|
|
currentUserData,
|
|
onCommentAdded,
|
|
projectId,
|
|
setComments,
|
|
text,
|
|
]);
|
|
|
|
const canComment = !!currentUID;
|
|
const descriptionText = (description || "").trim();
|
|
|
|
return (
|
|
<BlurView
|
|
intensity={Platform.OS !== "ios" ? 10 : 20}
|
|
tint="dark"
|
|
style={[
|
|
styles.commentsWrapper,
|
|
panelHeight ? { height: panelHeight } : null,
|
|
]}
|
|
>
|
|
<View style={styles.commentsInner}>
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
style={styles.commentsScroll}
|
|
contentContainerStyle={styles.commentsContent}
|
|
keyboardDismissMode="interactive"
|
|
keyboardShouldPersistTaps="handled"
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={120}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<Text style={styles.sectionTitle}>Description</Text>
|
|
<View style={styles.descriptionCard}>
|
|
<Text style={styles.descriptionText}>
|
|
{descriptionText || "Description chanson..."}
|
|
</Text>
|
|
</View>
|
|
<Text style={[styles.sectionTitle, styles.sectionSpacing]}>
|
|
{`Commentaires${commentsCount ? ` (${commentsCount})` : ""}`}
|
|
</Text>
|
|
{Array.isArray(comments) && comments.length > 0 ? (
|
|
comments.map((c, index) => (
|
|
<View
|
|
key={c.id}
|
|
style={[
|
|
styles.commentRow,
|
|
index > 0 && styles.commentRowSpacing,
|
|
]}
|
|
>
|
|
{c?.profilePicture ? (
|
|
<ExpoImage
|
|
source={{ uri: c.profilePicture }}
|
|
cachePolicy="memory-disk"
|
|
priority="high"
|
|
contentFit="cover"
|
|
transition={100}
|
|
style={styles.commentAvatar}
|
|
/>
|
|
) : (
|
|
<View style={styles.commentAvatarFallback} />
|
|
)}
|
|
<View style={styles.commentBubble}>
|
|
<View style={styles.commentHeader}>
|
|
<Text style={styles.commentAuthor}>
|
|
{c?.userId === currentUID ? "vous" : c?.userName || ""}
|
|
</Text>
|
|
<Text style={styles.commentMeta}>
|
|
• {formatRelativeTime(c?.createdAt)}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.commentBody}>{c?.text}</Text>
|
|
</View>
|
|
</View>
|
|
))
|
|
) : (
|
|
<Text style={styles.emptyState}>
|
|
Aucun commentaire pour le moment
|
|
</Text>
|
|
)}
|
|
</ScrollView>
|
|
<View style={styles.commentInputContainer}>
|
|
<TextInput
|
|
ref={inputRef}
|
|
value={text}
|
|
onChangeText={setText}
|
|
placeholder={
|
|
canComment
|
|
? "Commente"
|
|
: "Connecte-toi pour laisser un commentaire"
|
|
}
|
|
placeholderTextColor="#FFFFFF90"
|
|
editable={canComment}
|
|
style={[styles.commentInput, !canComment && { opacity: 0.6 }]}
|
|
/>
|
|
<Pressable
|
|
onPress={onSend}
|
|
disabled={!canComment || !text.trim()}
|
|
style={({ pressed }) => [
|
|
styles.sendButton,
|
|
(!canComment || !text.trim()) && styles.sendButtonDisabled,
|
|
pressed && canComment && styles.sendButtonPressed,
|
|
]}
|
|
>
|
|
<Image
|
|
source={icons.send}
|
|
style={styles.sendIcon}
|
|
resizeMode="contain"
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</BlurView>
|
|
);
|
|
};
|
|
|
|
// === Playback Item (WEB-ONLY, side-by-side, sound on) ===
|
|
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
|
const { width: viewportWidth, height: viewportHeight } =
|
|
useWindowDimensions();
|
|
const [layoutSize, setLayoutSize] = useState({
|
|
width: viewportWidth,
|
|
height: viewportHeight,
|
|
});
|
|
const commentInputRef = useRef(null);
|
|
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
|
const videoUrl = item?.playbackUrl || null;
|
|
|
|
// External audio source (if present)
|
|
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 computedCommentsCount = useMemo(
|
|
() => Number(item?.commentsCount || 0),
|
|
[item?.commentsCount]
|
|
);
|
|
const [commentsCount, setCommentsCount] = useState(computedCommentsCount);
|
|
useEffect(() => {
|
|
setCommentsCount(computedCommentsCount);
|
|
}, [computedCommentsCount]);
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
// Players
|
|
const audioPlayer = useAudioPlayer(audioSource || undefined);
|
|
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
|
|
p.loop = true;
|
|
p.muted = false; // 🔊 Sound must be on
|
|
p.timeUpdateEventInterval = 0.2;
|
|
});
|
|
|
|
// Always attempt to start both audio & video together when active
|
|
useEffect(() => {
|
|
const startBoth = async () => {
|
|
try {
|
|
if (!isActive) return;
|
|
// reset positions
|
|
try {
|
|
if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0);
|
|
} catch (_e) {}
|
|
try {
|
|
if (videoPlayer) videoPlayer.currentTime = 0;
|
|
} catch (_e) {}
|
|
// play both
|
|
try {
|
|
if (videoPlayer && !videoPlayer.playing) await videoPlayer.play();
|
|
} catch (_e) {}
|
|
try {
|
|
if (hasExternalAudio && audioPlayer && !audioPlayer.playing)
|
|
await audioPlayer.play?.();
|
|
} catch (_e) {}
|
|
} catch (_e) {}
|
|
};
|
|
startBoth();
|
|
|
|
// If autoplay with sound is blocked, attach once-only global listeners
|
|
// to resume without any extra UI/button.
|
|
if (isActive) {
|
|
const resume = async () => {
|
|
try {
|
|
if (videoPlayer && !videoPlayer.playing) await videoPlayer.play();
|
|
if (hasExternalAudio && audioPlayer && !audioPlayer.playing)
|
|
await audioPlayer.play?.();
|
|
} catch (_e) {}
|
|
};
|
|
const events = [
|
|
"pointerdown",
|
|
"click",
|
|
"touchstart",
|
|
"keydown",
|
|
"wheel",
|
|
"scroll",
|
|
];
|
|
events.forEach((ev) =>
|
|
document.addEventListener(ev, resume, { once: true, passive: true })
|
|
);
|
|
return () => {
|
|
events.forEach((ev) => document.removeEventListener(ev, resume));
|
|
};
|
|
}
|
|
}, [isActive, audioPlayer, videoPlayer, hasExternalAudio]);
|
|
|
|
// Keep A/V roughly aligned
|
|
useEffect(() => {
|
|
if (!isActive || !hasExternalAudio) return;
|
|
const t = setInterval(() => {
|
|
try {
|
|
const a = Number(audioPlayer?.currentTime || 0);
|
|
const v = Number(videoPlayer?.currentTime || 0);
|
|
if (Math.abs(a - v) > 0.2 && videoPlayer) {
|
|
videoPlayer.currentTime = Math.max(0, a);
|
|
}
|
|
} catch (_e) {}
|
|
}, 300);
|
|
return () => clearInterval(t);
|
|
}, [isActive, hasExternalAudio, audioPlayer, videoPlayer]);
|
|
|
|
// Pause on unmount / when not active
|
|
useEffect(() => {
|
|
return () => {
|
|
try {
|
|
if (audioPlayer?.playing) audioPlayer.pause?.();
|
|
if (videoPlayer?.playing) videoPlayer.pause();
|
|
} catch (_e) {}
|
|
};
|
|
}, [audioPlayer, videoPlayer]);
|
|
|
|
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]);
|
|
|
|
const [currentTimeS, setCurrentTimeS] = useState(0);
|
|
useEffect(() => {
|
|
if (!isActive) return;
|
|
const id = setInterval(() => {
|
|
try {
|
|
const useAudioTime = hasExternalAudio && !!audioPlayer?.playing;
|
|
const t = useAudioTime
|
|
? Number(audioPlayer?.currentTime || 0)
|
|
: Number(videoPlayer?.currentTime || 0);
|
|
setCurrentTimeS(t);
|
|
} catch (_e) {}
|
|
}, 250);
|
|
return () => clearInterval(id);
|
|
}, [isActive, hasExternalAudio, audioPlayer, videoPlayer]);
|
|
|
|
const descriptionText =
|
|
item?.description || item?.title || "Description chanson";
|
|
|
|
// Layout: always side-by-side on web
|
|
const handleLayout = useCallback((event) => {
|
|
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
|
|
setLayoutSize((prev) => ({
|
|
width: width > 0 ? width : prev.width,
|
|
height: height > 0 ? height : prev.height,
|
|
}));
|
|
}, []);
|
|
|
|
const layoutWidth = layoutSize.width || viewportWidth;
|
|
const layoutHeight = layoutSize.height || viewportHeight;
|
|
|
|
// Force side-by-side
|
|
const horizontalPadding = 64;
|
|
const innerWidth = Math.max(layoutWidth - horizontalPadding * 2, 0);
|
|
const gapBetweenColumns = 40;
|
|
const minVideoWidth = 420;
|
|
const maxVideoWidth = 720;
|
|
const minCommentsWidth = 300;
|
|
const maxCommentsWidth = 420;
|
|
|
|
let desiredHeight = Math.max(420, layoutHeight * 0.8);
|
|
let videoWidth = Math.min(
|
|
maxVideoWidth,
|
|
Math.max(minVideoWidth, innerWidth * 0.58)
|
|
);
|
|
let videoHeight = videoWidth * (16 / 9);
|
|
if (videoHeight > desiredHeight) {
|
|
videoHeight = desiredHeight;
|
|
videoWidth = videoHeight * (9 / 16);
|
|
}
|
|
let commentsWidth = Math.min(
|
|
maxCommentsWidth,
|
|
Math.max(minCommentsWidth, innerWidth - videoWidth - gapBetweenColumns)
|
|
);
|
|
const panelHeight = Math.min(videoHeight, desiredHeight);
|
|
|
|
return (
|
|
<View
|
|
onLayout={handleLayout}
|
|
style={[
|
|
styles.itemContainerBase,
|
|
styles.itemContainerRow,
|
|
{
|
|
height: "90%",
|
|
paddingHorizontal: horizontalPadding,
|
|
paddingVertical: Math.max((layoutHeight - panelHeight) / 2, 24),
|
|
},
|
|
]}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.videoColumn,
|
|
{ width: videoWidth, marginRight: gapBetweenColumns },
|
|
]}
|
|
>
|
|
<View style={[styles.videoSurface, { height: panelHeight }]}>
|
|
<View style={styles.videoContainer}>
|
|
{!!videoUrl ? (
|
|
<VideoView
|
|
player={videoPlayer}
|
|
nativeControls={false}
|
|
contentFit="cover"
|
|
style={StyleSheet.absoluteFillObject}
|
|
/>
|
|
) : (
|
|
<Image
|
|
source={img.placeholder3}
|
|
style={StyleSheet.absoluteFillObject}
|
|
resizeMode="cover"
|
|
/>
|
|
)}
|
|
<View style={styles.actionStack}>
|
|
<View style={styles.ownerBlock}>
|
|
<Pressable
|
|
style={styles.ownerAvatarButton}
|
|
onPress={() => {
|
|
navigate(Routes.SingerProfile, { userId: item?.userId });
|
|
}}
|
|
>
|
|
{owner?.profilePictureURL ? (
|
|
<Image
|
|
source={{ uri: owner.profilePictureURL }}
|
|
style={styles.ownerAvatar}
|
|
/>
|
|
) : (
|
|
<Image source={img.profile} style={styles.ownerAvatar} />
|
|
)}
|
|
</Pressable>
|
|
{owner?.id && currentUID && owner.id !== currentUID && (
|
|
<Pressable
|
|
style={styles.followPressable}
|
|
onPress={async () => {
|
|
try {
|
|
const next = !isFollowing;
|
|
setIsFollowing(next);
|
|
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) {
|
|
setIsFollowing((v) => !v);
|
|
}
|
|
}}
|
|
>
|
|
<BlurView
|
|
tint="dark"
|
|
intensity={Platform.OS !== "ios" ? 10 : 20}
|
|
style={styles.followButton}
|
|
>
|
|
<Text style={styles.followButtonText}>
|
|
{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),
|
|
},
|
|
{ merge: true }
|
|
);
|
|
} catch (_e) {
|
|
setIsLiked((v) => !v);
|
|
setLikesCount((c) =>
|
|
isLiked ? c + 1 : Math.max(0, c - 1)
|
|
);
|
|
}
|
|
}}
|
|
style={[styles.actionButton, styles.actionSpacing]}
|
|
>
|
|
<Image
|
|
source={isLiked ? icons.heart : icons.heartOutline}
|
|
style={styles.actionIcon}
|
|
resizeMode="contain"
|
|
/>
|
|
{!!likesCount && (
|
|
<Text style={styles.actionLabel}>{likesCount}</Text>
|
|
)}
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => commentInputRef.current?.focus?.()}
|
|
style={[styles.actionButton, styles.actionSpacing]}
|
|
>
|
|
<Image
|
|
source={icons.chatBubble}
|
|
style={styles.actionIcon}
|
|
resizeMode="contain"
|
|
/>
|
|
{!!commentsCount && (
|
|
<Text style={styles.actionLabel}>{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) {}
|
|
}}
|
|
style={[styles.actionButton, styles.actionSpacing]}
|
|
>
|
|
<Image
|
|
source={icons.share}
|
|
style={styles.actionIcon}
|
|
resizeMode="contain"
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
<View style={styles.lyricsContainer}>
|
|
<BlurView
|
|
tint="dark"
|
|
intensity={Platform.OS !== "ios" ? 10 : 20}
|
|
style={styles.lyricsCard}
|
|
>
|
|
{alignedWords?.length > 0 ? (
|
|
<KaraokeLyrics
|
|
alignedWords={alignedWords}
|
|
currentTimeS={currentTimeS}
|
|
/>
|
|
) : (
|
|
<Text style={styles.lyricsText}>{descriptionText}</Text>
|
|
)}
|
|
</BlurView>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Comments always visible side-by-side */}
|
|
<View
|
|
style={[
|
|
styles.commentsColumn,
|
|
{ width: commentsWidth, height: panelHeight },
|
|
]}
|
|
>
|
|
<CommentsPanel
|
|
projectId={item?.id}
|
|
description={descriptionText}
|
|
commentsCount={commentsCount}
|
|
onCommentAdded={() =>
|
|
setCommentsCount((c) => Math.max(0, Number(c || 0) + 1))
|
|
}
|
|
inputRef={commentInputRef}
|
|
panelHeight={panelHeight}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const Playbacks = () => {
|
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
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,
|
|
});
|
|
|
|
// Web-only layout hint
|
|
useEffect(() => {
|
|
if (Platform.OS !== "web") return undefined;
|
|
setGlobal({ webLayoutMode: isFocused ? "playbacks-wide" : "default" });
|
|
return () => setGlobal({ webLayoutMode: "default" });
|
|
}, [isFocused]);
|
|
|
|
const onSnap = useCallback(
|
|
(index) => {
|
|
setActiveIndex(index);
|
|
if (index >= (playbacks?.length || 0) - 6) {
|
|
loadMore?.();
|
|
}
|
|
},
|
|
[playbacks?.length, loadMore]
|
|
);
|
|
|
|
const triedLoadMoreRef = useRef(0);
|
|
useEffect(() => {
|
|
if (!focusProjectId) return;
|
|
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
|
|
if (idx >= 0) {
|
|
setActiveIndex(idx);
|
|
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]);
|
|
|
|
// === FlatList (one real page per item) ===
|
|
const listRef = useRef(null);
|
|
|
|
// keep active index in sync with scroll
|
|
const onMomentumScrollEnd = useCallback(
|
|
(e) => {
|
|
try {
|
|
const y = e?.nativeEvent?.contentOffset?.y || 0;
|
|
const idx = Math.round(y / windowHeight);
|
|
if (!Number.isNaN(idx))
|
|
setActiveIndex(
|
|
Math.max(0, Math.min(idx, (playbacks?.length || 1) - 1))
|
|
);
|
|
if (idx >= (playbacks?.length || 0) - 6) loadMore?.();
|
|
} catch (_e) {}
|
|
},
|
|
[windowHeight, playbacks?.length, loadMore]
|
|
);
|
|
|
|
// programmatic jump to focus item
|
|
useEffect(() => {
|
|
if (!focusProjectId) return;
|
|
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
|
|
if (idx >= 0) {
|
|
setActiveIndex(idx);
|
|
setTimeout(() => {
|
|
try {
|
|
listRef.current?.scrollToIndex?.({ index: idx, animated: false });
|
|
} catch (_e) {}
|
|
}, 50);
|
|
}
|
|
}, [focusProjectId, playbacks]);
|
|
|
|
const getItemLayout = useCallback(
|
|
(_data, index) => ({
|
|
length: windowHeight,
|
|
offset: windowHeight * index,
|
|
index,
|
|
}),
|
|
[windowHeight]
|
|
);
|
|
|
|
return (
|
|
<View style={styles.screen}>
|
|
<FlatList
|
|
ref={listRef}
|
|
data={playbacks}
|
|
keyExtractor={(item, index) => String(item?.id || index)}
|
|
renderItem={({ item, index }) => (
|
|
<View style={{ width: windowWidth, height: windowHeight }}>
|
|
<PlaybackItem
|
|
item={item}
|
|
userCache={userCache}
|
|
getUserByUid={getUserByUid}
|
|
isActive={isFocused && index === activeIndex}
|
|
/>
|
|
</View>
|
|
)}
|
|
pagingEnabled
|
|
showsVerticalScrollIndicator={false}
|
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
getItemLayout={getItemLayout}
|
|
initialNumToRender={3}
|
|
windowSize={5}
|
|
removeClippedSubviews
|
|
scrollEventThrottle={32}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default Playbacks;
|
|
|
|
// === Styles ===
|
|
const styles = StyleSheet.create({
|
|
screen: {
|
|
flex: 1,
|
|
// prevent page scroll bounce and ensure the wheel maps to paging
|
|
// (RN Web supports these CSS props on View)
|
|
overscrollBehavior: "none",
|
|
},
|
|
itemContainerBase: {
|
|
width: "100%",
|
|
paddingVertical: 36,
|
|
alignItems: "center",
|
|
},
|
|
itemContainerRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
videoColumn: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
videoSurface: {
|
|
alignSelf: "center",
|
|
aspectRatio: 9 / 16,
|
|
borderRadius: 32,
|
|
overflow: "hidden",
|
|
backgroundColor: "#07040D",
|
|
shadowColor: "#000",
|
|
shadowOpacity: 0.45,
|
|
shadowRadius: 30,
|
|
shadowOffset: { width: 0, height: 20 },
|
|
elevation: 12,
|
|
},
|
|
videoContainer: {
|
|
flex: 1,
|
|
position: "relative",
|
|
},
|
|
actionStack: {
|
|
position: "absolute",
|
|
right: 18,
|
|
bottom: 120,
|
|
alignItems: "center",
|
|
},
|
|
ownerBlock: {
|
|
alignItems: "center",
|
|
marginBottom: 26,
|
|
},
|
|
ownerAvatarButton: {
|
|
marginBottom: 16,
|
|
},
|
|
ownerAvatar: {
|
|
...size({ size: 56 }),
|
|
borderRadius: 100,
|
|
},
|
|
followPressable: {
|
|
alignSelf: "center",
|
|
},
|
|
followButton: {
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 18,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
borderColor: Palette.white,
|
|
backgroundColor: "#FFFFFF20",
|
|
overflow: "hidden",
|
|
},
|
|
followButtonText: {
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
},
|
|
actionButton: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
actionSpacing: {
|
|
marginTop: 24,
|
|
},
|
|
actionIcon: {
|
|
...size({ size: 30 }),
|
|
},
|
|
actionLabel: {
|
|
marginTop: 6,
|
|
fontSize: 13,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
textAlign: "center",
|
|
},
|
|
lyricsContainer: {
|
|
position: "absolute",
|
|
left: 24,
|
|
right: 24,
|
|
bottom: 32,
|
|
},
|
|
lyricsCard: {
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 12,
|
|
borderRadius: 22,
|
|
backgroundColor: Palette.glass,
|
|
overflow: "hidden",
|
|
},
|
|
lyricsText: {
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
},
|
|
commentsColumn: {
|
|
alignSelf: "stretch",
|
|
},
|
|
commentsWrapper: {
|
|
flex: 1,
|
|
borderRadius: 28,
|
|
overflow: "hidden",
|
|
padding: 24,
|
|
backgroundColor: Palette.glass,
|
|
minHeight: 0,
|
|
},
|
|
commentsInner: {
|
|
flex: 1,
|
|
justifyContent: "space-between",
|
|
},
|
|
commentsScroll: {
|
|
flex: 1,
|
|
},
|
|
commentsContent: {
|
|
paddingBottom: 24,
|
|
flexGrow: 1,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 18,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
marginBottom: 12,
|
|
},
|
|
sectionSpacing: {
|
|
marginTop: 24,
|
|
},
|
|
descriptionCard: {
|
|
borderRadius: 18,
|
|
backgroundColor: "#FFFFFF14",
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 14,
|
|
},
|
|
descriptionText: {
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
lineHeight: 20,
|
|
},
|
|
commentRow: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
},
|
|
commentRowSpacing: {
|
|
marginTop: 16,
|
|
},
|
|
commentAvatar: {
|
|
...size({ size: 48 }),
|
|
borderRadius: 100,
|
|
marginRight: 12,
|
|
},
|
|
commentAvatarFallback: {
|
|
...size({ size: 48 }),
|
|
borderRadius: 100,
|
|
backgroundColor: "#FFFFFF33",
|
|
marginRight: 12,
|
|
},
|
|
commentBubble: {
|
|
flex: 1,
|
|
backgroundColor: "#FFFFFF18",
|
|
borderRadius: 20,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
},
|
|
commentHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
marginBottom: 4,
|
|
},
|
|
commentAuthor: {
|
|
fontSize: 13,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterMedium,
|
|
},
|
|
commentMeta: {
|
|
fontSize: 12,
|
|
color: "#FFFFFFA0",
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
marginLeft: 8,
|
|
},
|
|
commentBody: {
|
|
fontSize: 14,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
lineHeight: 20,
|
|
},
|
|
emptyState: {
|
|
fontSize: 14,
|
|
color: "#FFFFFFBB",
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
},
|
|
commentInputContainer: {
|
|
marginTop: 20,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
backgroundColor: "#FFFFFF14",
|
|
borderRadius: 18,
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 10,
|
|
},
|
|
commentInput: {
|
|
flex: 1,
|
|
color: Palette.white,
|
|
fontSize: 14,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
minHeight: 36,
|
|
marginRight: 12,
|
|
},
|
|
sendButton: {
|
|
...size({ size: 38 }),
|
|
borderRadius: 19,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: Palette.primary,
|
|
},
|
|
sendButtonPressed: {
|
|
transform: [{ scale: 0.95 }],
|
|
},
|
|
sendButtonDisabled: {
|
|
backgroundColor: "#FFFFFF33",
|
|
},
|
|
sendIcon: {
|
|
width: 22,
|
|
height: 22,
|
|
tintColor: Palette.white,
|
|
},
|
|
});
|