687 lines
20 KiB
JavaScript
687 lines
20 KiB
JavaScript
import { BlurView } from "expo-blur";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
Image,
|
|
Pressable,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
import { icons, img } from "../../../assets";
|
|
import KaraokeLyrics from "../../../components/KaraokeLyrics";
|
|
import { usersRef } from "../../../config/firebase";
|
|
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 CommentsPanel from "./CommentsPanel.web";
|
|
import {
|
|
getProjectLikes,
|
|
LIKE_TARGET,
|
|
toggleProjectLike,
|
|
} from "../../../utils/likes";
|
|
import { ensureAuthenticated } from "../../../utils/authRedirect";
|
|
import {
|
|
createPlaybackSharePayload,
|
|
openShareSheet,
|
|
} from "../../../utils/shareSheet";
|
|
import { SheetManager } from "react-native-actions-sheet";
|
|
import { Feather } from "@expo/vector-icons";
|
|
|
|
// Debug logging toggle for web playback
|
|
const DEBUG_PLAYBACK_WEB = true;
|
|
|
|
// NOTE: Web-only implementation that uses a native <video> element instead of expo-video
|
|
// - No Platform checks
|
|
// - Audio now comes directly from the MP4 playbackUrl
|
|
|
|
const PlaybackItem = ({
|
|
item,
|
|
isActive,
|
|
userCache,
|
|
getUserByUid,
|
|
onBackgroundSync,
|
|
}) => {
|
|
const { width: viewportWidth, height: viewportHeight } =
|
|
useWindowDimensions();
|
|
|
|
const [layoutSize, setLayoutSize] = useState({
|
|
width: viewportWidth,
|
|
height: viewportHeight,
|
|
});
|
|
const [openComments] = useState(true);
|
|
const commentInputRef = useRef(null);
|
|
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
|
|
|
const videoUrl = item?.playbackUrl || null;
|
|
const videoRef = useRef(null); // HTMLVideoElement
|
|
const wasActiveRef = useRef(false);
|
|
const startedRef = useRef(false);
|
|
|
|
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
|
|
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 = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
|
|
setLikesCount(lb.length);
|
|
setIsLiked(currentUID ? lb.includes(currentUID) : false);
|
|
}, [item?.likes?.playback, currentUID]);
|
|
|
|
useEffect(() => {
|
|
startedRef.current = false;
|
|
}, [videoUrl]);
|
|
|
|
// Start/reset when active, using the video's own audio
|
|
useEffect(() => {
|
|
const el = videoRef.current;
|
|
if (!el) return;
|
|
|
|
if (!isActive) {
|
|
try {
|
|
if (!el.paused) el.pause();
|
|
} catch (_e) {}
|
|
try {
|
|
el.muted = true;
|
|
} catch (_e) {}
|
|
startedRef.current = false;
|
|
onBackgroundSync?.({ isPlaying: false });
|
|
return;
|
|
}
|
|
|
|
const start = async () => {
|
|
try {
|
|
if (!startedRef.current) {
|
|
startedRef.current = true;
|
|
const resetToStart = () => {
|
|
try {
|
|
el.currentTime = 0;
|
|
} catch (_e) {}
|
|
};
|
|
if (el.readyState >= 1) {
|
|
resetToStart();
|
|
} else {
|
|
const handleLoaded = () => {
|
|
resetToStart();
|
|
};
|
|
el.addEventListener("loadeddata", handleLoaded, { once: true });
|
|
}
|
|
}
|
|
el.muted = false;
|
|
await el.play().catch((error) => {
|
|
if (DEBUG_PLAYBACK_WEB) {
|
|
console.log("[PlaybackItem.web] play() rejected", error);
|
|
}
|
|
});
|
|
onBackgroundSync?.({
|
|
currentTime: Number(el.currentTime || 0),
|
|
isPlaying: !el.paused,
|
|
});
|
|
} catch (_e) {
|
|
if (DEBUG_PLAYBACK_WEB) {
|
|
console.log("[PlaybackItem.web] start error", _e);
|
|
}
|
|
}
|
|
};
|
|
|
|
start();
|
|
}, [isActive, videoUrl, onBackgroundSync]);
|
|
|
|
// Pause all on unmount
|
|
useEffect(() => {
|
|
const el = videoRef.current;
|
|
return () => {
|
|
try {
|
|
if (el && !el.paused) el.pause();
|
|
} catch (_e) {}
|
|
onBackgroundSync?.({ isPlaying: false });
|
|
};
|
|
}, [onBackgroundSync]);
|
|
|
|
// Lyrics timing based on video clock
|
|
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 el = videoRef.current;
|
|
const videoTime = Number(el?.currentTime || 0);
|
|
const safeTime = Number.isFinite(videoTime) ? videoTime : 0;
|
|
setCurrentTimeS(safeTime);
|
|
|
|
onBackgroundSync?.({
|
|
currentTime: safeTime,
|
|
isPlaying: !!el && !el.paused,
|
|
});
|
|
} catch (_e) {}
|
|
}, 250);
|
|
return () => clearInterval(id);
|
|
}, [isActive, onBackgroundSync]);
|
|
|
|
useEffect(() => {
|
|
if (!onBackgroundSync) return undefined;
|
|
if (isActive) {
|
|
wasActiveRef.current = true;
|
|
return undefined;
|
|
}
|
|
if (wasActiveRef.current) {
|
|
onBackgroundSync({ isPlaying: false });
|
|
wasActiveRef.current = false;
|
|
}
|
|
return undefined;
|
|
}, [isActive, onBackgroundSync]);
|
|
|
|
const descriptionText =
|
|
item?.description || item?.title || "Description chanson";
|
|
|
|
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 sharePayload = useMemo(() => {
|
|
if (!item?.id) return null;
|
|
return createPlaybackSharePayload({
|
|
projectId: item.id,
|
|
title: typeof item?.title === "string" ? item.title.trim() : undefined,
|
|
artist: creatorName || undefined,
|
|
playbackUrl: videoUrl || undefined,
|
|
});
|
|
}, [creatorName, item?.id, item?.title, videoUrl]);
|
|
|
|
const handleShare = useCallback(() => {
|
|
if (sharePayload) {
|
|
openShareSheet(sharePayload);
|
|
}
|
|
}, [sharePayload]);
|
|
|
|
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]);
|
|
|
|
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;
|
|
|
|
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);
|
|
|
|
// Attach verbose event listeners on the HTML video element
|
|
useEffect(() => {
|
|
const el = videoRef.current;
|
|
if (!el || !DEBUG_PLAYBACK_WEB) return undefined;
|
|
const handler = (e) => {
|
|
// Avoid heavy logs: only show key events and brief state
|
|
console.log("[PlaybackItem.web] video:", e.type, {
|
|
t: Number(el.currentTime || 0).toFixed(2),
|
|
paused: el.paused,
|
|
rs: el.readyState,
|
|
});
|
|
};
|
|
const events = [
|
|
"loadedmetadata",
|
|
"loadeddata",
|
|
"play",
|
|
"playing",
|
|
"pause",
|
|
"seeking",
|
|
"seeked",
|
|
"stalled",
|
|
"waiting",
|
|
"ended",
|
|
"error",
|
|
];
|
|
events.forEach((ev) => el.addEventListener(ev, handler));
|
|
return () => {
|
|
events.forEach((ev) => el.removeEventListener(ev, handler));
|
|
};
|
|
}, []);
|
|
|
|
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 ? (
|
|
// Native HTML video for web
|
|
<video
|
|
ref={videoRef}
|
|
src={videoUrl}
|
|
playsInline
|
|
muted={!isActive}
|
|
preload="auto"
|
|
// Using web CSS properties here on purpose
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
display: "block",
|
|
}}
|
|
/>
|
|
) : (
|
|
<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,
|
|
owner && !owner.profilePictureURL
|
|
? { backgroundColor: Palette.gray }
|
|
: null,
|
|
]}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
{owner?.id && owner.id !== currentUID && (
|
|
<Pressable
|
|
style={styles.followPressable}
|
|
onPress={async () => {
|
|
if (
|
|
!ensureAuthenticated(currentUID, {
|
|
onIntercept: () => {
|
|
setIsFollowing(false);
|
|
},
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
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={20}
|
|
style={styles.followButton}
|
|
>
|
|
<Text style={styles.followButtonText}>
|
|
{isFollowing ? "Suivi(e)" : "Suivre"}
|
|
</Text>
|
|
</BlurView>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
<Pressable
|
|
onPress={async () => {
|
|
try {
|
|
if (!item?.id) return;
|
|
if (!ensureAuthenticated(currentUID)) {
|
|
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) {
|
|
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={() => {}}
|
|
style={[styles.actionButton, styles.actionSpacing]}
|
|
>
|
|
<Image
|
|
source={icons.chatBubble}
|
|
style={styles.actionIcon}
|
|
resizeMode="contain"
|
|
/>
|
|
{!!commentsCount && (
|
|
<Text style={styles.actionLabel}>{commentsCount}</Text>
|
|
)}
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={handleShare}
|
|
style={[styles.actionButton, styles.actionSpacing]}
|
|
>
|
|
<Image
|
|
source={icons.share}
|
|
style={styles.actionIcon}
|
|
resizeMode="contain"
|
|
/>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={handleReport}
|
|
style={[styles.actionButton, styles.actionSpacing]}
|
|
>
|
|
<Feather name="flag" size={20} color={Palette.white} />
|
|
<Text style={styles.actionLabel}>Signaler</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={styles.lyricsContainer}>
|
|
<BlurView tint="dark" intensity={20} style={styles.lyricsCard}>
|
|
{alignedWords?.length > 0 ? (
|
|
<KaraokeLyrics
|
|
alignedWords={alignedWords}
|
|
currentTimeS={currentTimeS}
|
|
/>
|
|
) : (
|
|
<Text style={styles.lyricsText}>{descriptionText}</Text>
|
|
)}
|
|
</BlurView>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{openComments && (
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default PlaybackItem;
|
|
|
|
const styles = StyleSheet.create({
|
|
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,
|
|
top: 150,
|
|
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",
|
|
},
|
|
});
|