This commit is contained in:
2025-09-30 13:32:02 +02:00
parent 81ddbb2cd4
commit eacd9c08e2
+153 -138
View File
@@ -11,6 +11,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import {
FlatList,
Image, Image,
Platform, Platform,
Pressable, Pressable,
@@ -22,7 +23,8 @@ import {
View, View,
useWindowDimensions, useWindowDimensions,
} from "react-native"; } from "react-native";
import Carousel from "react-native-reanimated-carousel"; // Carousel removed; using FlatList for real scrollable content
import { setGlobal } from "reactn"; import { setGlobal } from "reactn";
import { icons, img } from "../../assets"; import { icons, img } from "../../assets";
import KaraokeLyrics from "../../components/KaraokeLyrics"; import KaraokeLyrics from "../../components/KaraokeLyrics";
@@ -42,6 +44,7 @@ import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import { size } from "../../styles/Style"; import { size } from "../../styles/Style";
// === Helpers ===
const formatRelativeTime = (date) => { const formatRelativeTime = (date) => {
try { try {
const d = date instanceof Date ? date : date?.toDate?.() || null; const d = date instanceof Date ? date : date?.toDate?.() || null;
@@ -60,6 +63,7 @@ const formatRelativeTime = (date) => {
} }
}; };
// === Comments Panel ===
const CommentsPanel = ({ const CommentsPanel = ({
projectId, projectId,
description, description,
@@ -277,6 +281,7 @@ const CommentsPanel = ({
); );
}; };
// === Playback Item (WEB-ONLY, side-by-side, sound on) ===
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const { width: viewportWidth, height: viewportHeight } = const { width: viewportWidth, height: viewportHeight } =
useWindowDimensions(); useWindowDimensions();
@@ -287,6 +292,8 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const commentInputRef = useRef(null); const commentInputRef = useRef(null);
const { currentUID, followUser, unfollowUser } = useUser() || {}; const { currentUID, followUser, unfollowUser } = useUser() || {};
const videoUrl = item?.playbackUrl || null; const videoUrl = item?.playbackUrl || null;
// External audio source (if present)
const audioSource = useMemo(() => { const audioSource = useMemo(() => {
const fromSong = item?.songUrl ? { uri: item.songUrl } : null; const fromSong = item?.songUrl ? { uri: item.songUrl } : null;
return fromSong; return fromSong;
@@ -368,53 +375,81 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
setIsLiked(currentUID ? lb.includes(currentUID) : false); setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likedBy, currentUID]); }, [item?.likedBy, currentUID]);
// Players
const audioPlayer = useAudioPlayer(audioSource || undefined); const audioPlayer = useAudioPlayer(audioSource || undefined);
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
p.loop = false; p.loop = true;
p.muted = true; p.muted = false; // 🔊 Sound must be on
p.timeUpdateEventInterval = 0.2; p.timeUpdateEventInterval = 0.2;
}); });
// Always attempt to start both audio & video together when active
useEffect(() => { useEffect(() => {
const toggle = async () => { const startBoth = async () => {
try { try {
if (isActive) { if (!isActive) return;
try { // reset positions
if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0); try {
} catch (_e) {} if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0);
try { } catch (_e) {}
if (videoPlayer) videoPlayer.currentTime = 0; try {
} catch (_e) {} if (videoPlayer) videoPlayer.currentTime = 0;
} catch (_e) {}
try { // play both
if (videoPlayer) videoPlayer.play(); try {
} catch (_e) {} if (videoPlayer && !videoPlayer.playing) await videoPlayer.play();
try { } catch (_e) {}
if (audioPlayer && hasExternalAudio) audioPlayer.play?.(); try {
} catch (_e) {} if (hasExternalAudio && audioPlayer && !audioPlayer.playing)
} else { await audioPlayer.play?.();
if (audioPlayer?.playing) await audioPlayer.pause?.(); } catch (_e) {}
if (videoPlayer?.playing) videoPlayer.pause();
}
} catch (_e) {} } catch (_e) {}
}; };
toggle(); 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]); }, [isActive, audioPlayer, videoPlayer, hasExternalAudio]);
// Keep A/V roughly aligned
useEffect(() => { useEffect(() => {
if (!isActive || !hasExternalAudio) return; if (!isActive || !hasExternalAudio) return;
const t = setTimeout(() => { const t = setInterval(() => {
try { try {
const a = audioPlayer?.currentTime || 0; const a = Number(audioPlayer?.currentTime || 0);
const v = videoPlayer?.currentTime || 0; const v = Number(videoPlayer?.currentTime || 0);
if (Math.abs(a - v) > 0.2 && videoPlayer) { if (Math.abs(a - v) > 0.2 && videoPlayer) {
videoPlayer.currentTime = Math.max(0, a); videoPlayer.currentTime = Math.max(0, a);
} }
} catch (_e) {} } catch (_e) {}
}, 300); }, 300);
return () => clearTimeout(t); return () => clearInterval(t);
}, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]);
// Pause on unmount / when not active
useEffect(() => { useEffect(() => {
return () => { return () => {
try { try {
@@ -427,7 +462,6 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const alignedWords = useMemo(() => { const alignedWords = useMemo(() => {
const idx = Number(item?.songIndex) || 0; const idx = Number(item?.songIndex) || 0;
const ts = item?.musicTimestamps?.[idx]; const ts = item?.musicTimestamps?.[idx];
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []; const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
return arr.map((w) => ({ return arr.map((w) => ({
word: String(w?.word ?? ""), word: String(w?.word ?? ""),
@@ -441,7 +475,8 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
if (!isActive) return; if (!isActive) return;
const id = setInterval(() => { const id = setInterval(() => {
try { try {
const t = hasExternalAudio const useAudioTime = hasExternalAudio && !!audioPlayer?.playing;
const t = useAudioTime
? Number(audioPlayer?.currentTime || 0) ? Number(audioPlayer?.currentTime || 0)
: Number(videoPlayer?.currentTime || 0); : Number(videoPlayer?.currentTime || 0);
setCurrentTimeS(t); setCurrentTimeS(t);
@@ -453,6 +488,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const descriptionText = const descriptionText =
item?.description || item?.title || "Description chanson"; item?.description || item?.title || "Description chanson";
// Layout: always side-by-side on web
const handleLayout = useCallback((event) => { const handleLayout = useCallback((event) => {
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {}; const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
setLayoutSize((prev) => ({ setLayoutSize((prev) => ({
@@ -464,111 +500,51 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const layoutWidth = layoutSize.width || viewportWidth; const layoutWidth = layoutSize.width || viewportWidth;
const layoutHeight = layoutSize.height || viewportHeight; const layoutHeight = layoutSize.height || viewportHeight;
const widePaddingThreshold = 900; // Force side-by-side
const horizontalPadding = layoutWidth >= widePaddingThreshold ? 64 : 32; const horizontalPadding = 64;
const innerWidth = Math.max(layoutWidth - horizontalPadding * 2, 0); const innerWidth = Math.max(layoutWidth - horizontalPadding * 2, 0);
const gapBetweenColumns = 40; const gapBetweenColumns = 40;
const minVideoWidth = 340; const minVideoWidth = 420;
const maxVideoWidth = 620; const maxVideoWidth = 720;
const minCommentsWidth = 260; const minCommentsWidth = 300;
const maxCommentsWidth = 420; const maxCommentsWidth = 420;
const baseInnerWidth = innerWidth > 0 ? innerWidth : viewportWidth; let desiredHeight = Math.max(420, layoutHeight * 0.8);
let desiredHeight = Math.max(360, layoutHeight * 0.8); let videoWidth = Math.min(
let desiredWidth = desiredHeight * (9 / 16); maxVideoWidth,
Math.max(minVideoWidth, innerWidth * 0.58)
if (desiredWidth > maxVideoWidth) { );
desiredWidth = maxVideoWidth;
desiredHeight = desiredWidth * (16 / 9);
}
if (desiredWidth < minVideoWidth) {
desiredWidth = minVideoWidth;
desiredHeight = desiredWidth * (16 / 9);
}
let sideBySide =
innerWidth >= desiredWidth + minCommentsWidth + gapBetweenColumns;
let videoWidth = sideBySide
? Math.min(maxVideoWidth, Math.max(desiredWidth, minVideoWidth))
: Math.min(Math.max(baseInnerWidth * 0.88, minVideoWidth), maxVideoWidth);
let videoHeight = videoWidth * (16 / 9); let videoHeight = videoWidth * (16 / 9);
if (videoHeight > desiredHeight) { if (videoHeight > desiredHeight) {
videoHeight = desiredHeight; videoHeight = desiredHeight;
videoWidth = videoHeight * (9 / 16); videoWidth = videoHeight * (9 / 16);
} }
let commentsWidth = Math.min(
let commentsWidth = sideBySide maxCommentsWidth,
? innerWidth - videoWidth - gapBetweenColumns Math.max(minCommentsWidth, innerWidth - videoWidth - gapBetweenColumns)
: baseInnerWidth; );
if (sideBySide && commentsWidth > maxCommentsWidth) {
const excess = commentsWidth - maxCommentsWidth;
commentsWidth = maxCommentsWidth;
videoWidth = Math.min(maxVideoWidth, videoWidth + excess);
videoHeight = videoWidth * (16 / 9);
}
if (sideBySide && commentsWidth < minCommentsWidth) {
const deficit = minCommentsWidth - commentsWidth;
if (videoWidth - deficit >= minVideoWidth) {
videoWidth -= deficit;
commentsWidth = minCommentsWidth;
videoHeight = videoWidth * (16 / 9);
} else {
sideBySide = false;
videoWidth = Math.min(baseInnerWidth, maxVideoWidth);
commentsWidth = baseInnerWidth;
videoHeight = videoWidth * (16 / 9);
}
}
if (!sideBySide) {
videoHeight = Math.min(videoHeight, desiredHeight);
videoWidth = videoHeight * (9 / 16);
commentsWidth = videoWidth;
}
const panelHeight = Math.min(videoHeight, desiredHeight); const panelHeight = Math.min(videoHeight, desiredHeight);
const containerHeight = viewportHeight;
const verticalPadding = Math.max((layoutHeight - panelHeight) / 2, 24);
const safeCommentsWidth = sideBySide
? Math.max(minCommentsWidth, Math.min(commentsWidth, maxCommentsWidth))
: undefined;
return ( return (
<View <View
onLayout={handleLayout} onLayout={handleLayout}
style={[ style={[
styles.itemContainerBase, styles.itemContainerBase,
sideBySide ? styles.itemContainerRow : styles.itemContainerCompact, styles.itemContainerRow,
{ {
height: "90%", height: "90%",
paddingHorizontal: sideBySide ? horizontalPadding : 24, paddingHorizontal: horizontalPadding,
paddingVertical: verticalPadding, paddingVertical: Math.max((layoutHeight - panelHeight) / 2, 24),
}, },
]} ]}
> >
<View <View
style={[ style={[
styles.videoColumn, styles.videoColumn,
sideBySide { width: videoWidth, marginRight: gapBetweenColumns },
? { width: videoWidth, marginRight: gapBetweenColumns }
: { width: "100%" },
]} ]}
> >
<View <View style={[styles.videoSurface, { height: panelHeight }]}>
style={[
styles.videoSurface,
{
// width: videoWidth,
height: panelHeight,
},
]}
>
<View style={styles.videoContainer}> <View style={styles.videoContainer}>
{!!videoUrl ? ( {!!videoUrl ? (
<VideoView <VideoView
@@ -730,12 +706,12 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
</View> </View>
</View> </View>
</View> </View>
{/* Comments always visible side-by-side */}
<View <View
style={[ style={[
styles.commentsColumn, styles.commentsColumn,
sideBySide { width: commentsWidth, height: panelHeight },
? { width: safeCommentsWidth, height: panelHeight }
: [styles.commentsColumnCompact, { height: panelHeight }],
]} ]}
> >
<CommentsPanel <CommentsPanel
@@ -771,6 +747,7 @@ const Playbacks = () => {
batchSize: 6, batchSize: 6,
}); });
// Web-only layout hint
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return undefined; if (Platform.OS !== "web") return undefined;
setGlobal({ webLayoutMode: isFocused ? "playbacks-wide" : "default" }); setGlobal({ webLayoutMode: isFocused ? "playbacks-wide" : "default" });
@@ -804,26 +781,72 @@ const Playbacks = () => {
} }
}, [focusProjectId, playbacks, 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 ( return (
<View style={styles.screen}> <View style={styles.screen}>
<Carousel <FlatList
ref={carouselRef} ref={listRef}
data={playbacks} data={playbacks}
vertical keyExtractor={(item, index) => String(item?.id || index)}
width={windowWidth}
height={windowHeight}
pagingEnabled
windowSize={5}
onSnapToItem={onSnap}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<PlaybackItem <View style={{ width: windowWidth, height: windowHeight }}>
key={item?.id || index} <PlaybackItem
item={item} item={item}
userCache={userCache} userCache={userCache}
getUserByUid={getUserByUid} getUserByUid={getUserByUid}
isActive={isFocused && index === activeIndex} isActive={isFocused && index === activeIndex}
/> />
</View>
)} )}
pagingEnabled
showsVerticalScrollIndicator={false}
onMomentumScrollEnd={onMomentumScrollEnd}
getItemLayout={getItemLayout}
initialNumToRender={3}
windowSize={5}
removeClippedSubviews
scrollEventThrottle={32}
/> />
</View> </View>
); );
@@ -831,9 +854,13 @@ const Playbacks = () => {
export default Playbacks; export default Playbacks;
// === Styles ===
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { screen: {
flex: 1, flex: 1,
// prevent page scroll bounce and ensure the wheel maps to paging
// (RN Web supports these CSS props on View)
overscrollBehavior: "none",
}, },
itemContainerBase: { itemContainerBase: {
width: "100%", width: "100%",
@@ -845,19 +872,12 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
itemContainerCompact: {
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
},
videoColumn: { videoColumn: {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
videoSurface: { videoSurface: {
alignSelf: "center", alignSelf: "center",
// width: 100,
// maxWidth: 400,
aspectRatio: 9 / 16, aspectRatio: 9 / 16,
borderRadius: 32, borderRadius: 32,
overflow: "hidden", overflow: "hidden",
@@ -944,11 +964,6 @@ const styles = StyleSheet.create({
commentsColumn: { commentsColumn: {
alignSelf: "stretch", alignSelf: "stretch",
}, },
commentsColumnCompact: {
width: "100%",
maxWidth: 640,
marginTop: 32,
},
commentsWrapper: { commentsWrapper: {
flex: 1, flex: 1,
borderRadius: 28, borderRadius: 28,