From eacd9c08e2a38cd4578ad9150ee4c9e30fd52c62 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 30 Sep 2025 13:32:02 +0200 Subject: [PATCH] scroll --- src/screens/Playbacks/Playbacks.web.js | 291 +++++++++++++------------ 1 file changed, 153 insertions(+), 138 deletions(-) diff --git a/src/screens/Playbacks/Playbacks.web.js b/src/screens/Playbacks/Playbacks.web.js index 28d6e2e..3edaf19 100644 --- a/src/screens/Playbacks/Playbacks.web.js +++ b/src/screens/Playbacks/Playbacks.web.js @@ -11,6 +11,7 @@ import React, { useState, } from "react"; import { + FlatList, Image, Platform, Pressable, @@ -22,7 +23,8 @@ import { View, useWindowDimensions, } from "react-native"; -import Carousel from "react-native-reanimated-carousel"; +// Carousel removed; using FlatList for real scrollable content + import { setGlobal } from "reactn"; import { icons, img } from "../../assets"; import KaraokeLyrics from "../../components/KaraokeLyrics"; @@ -42,6 +44,7 @@ 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; @@ -60,6 +63,7 @@ const formatRelativeTime = (date) => { } }; +// === Comments Panel === const CommentsPanel = ({ projectId, description, @@ -277,6 +281,7 @@ const CommentsPanel = ({ ); }; +// === Playback Item (WEB-ONLY, side-by-side, sound on) === const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const { width: viewportWidth, height: viewportHeight } = useWindowDimensions(); @@ -287,6 +292,8 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { 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; @@ -368,53 +375,81 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { setIsLiked(currentUID ? lb.includes(currentUID) : false); }, [item?.likedBy, currentUID]); + // Players const audioPlayer = useAudioPlayer(audioSource || undefined); const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { - p.loop = false; - p.muted = true; + 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 toggle = async () => { + const startBoth = async () => { try { - if (isActive) { - try { - if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0); - } catch (_e) {} - try { - if (videoPlayer) videoPlayer.currentTime = 0; - } catch (_e) {} - - 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(); - } + 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) {} }; - 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]); + // Keep A/V roughly aligned useEffect(() => { if (!isActive || !hasExternalAudio) return; - const t = setTimeout(() => { + const t = setInterval(() => { try { - const a = audioPlayer?.currentTime || 0; - const v = videoPlayer?.currentTime || 0; + 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 () => clearTimeout(t); + return () => clearInterval(t); }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]); + // Pause on unmount / when not active useEffect(() => { return () => { try { @@ -427,7 +462,6 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { 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 ?? ""), @@ -441,7 +475,8 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { if (!isActive) return; const id = setInterval(() => { try { - const t = hasExternalAudio + const useAudioTime = hasExternalAudio && !!audioPlayer?.playing; + const t = useAudioTime ? Number(audioPlayer?.currentTime || 0) : Number(videoPlayer?.currentTime || 0); setCurrentTimeS(t); @@ -453,6 +488,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { 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) => ({ @@ -464,111 +500,51 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const layoutWidth = layoutSize.width || viewportWidth; const layoutHeight = layoutSize.height || viewportHeight; - const widePaddingThreshold = 900; - const horizontalPadding = layoutWidth >= widePaddingThreshold ? 64 : 32; + // Force side-by-side + const horizontalPadding = 64; const innerWidth = Math.max(layoutWidth - horizontalPadding * 2, 0); const gapBetweenColumns = 40; - const minVideoWidth = 340; - const maxVideoWidth = 620; - const minCommentsWidth = 260; + const minVideoWidth = 420; + const maxVideoWidth = 720; + const minCommentsWidth = 300; const maxCommentsWidth = 420; - const baseInnerWidth = innerWidth > 0 ? innerWidth : viewportWidth; - let desiredHeight = Math.max(360, layoutHeight * 0.8); - let desiredWidth = desiredHeight * (9 / 16); - - 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 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 = sideBySide - ? 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; - } - + let commentsWidth = Math.min( + maxCommentsWidth, + Math.max(minCommentsWidth, innerWidth - videoWidth - gapBetweenColumns) + ); 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 ( - + {!!videoUrl ? ( { + + {/* Comments always visible side-by-side */} { batchSize: 6, }); + // Web-only layout hint useEffect(() => { if (Platform.OS !== "web") return undefined; setGlobal({ webLayoutMode: isFocused ? "playbacks-wide" : "default" }); @@ -804,26 +781,72 @@ const Playbacks = () => { } }, [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 ( - String(item?.id || index)} renderItem={({ item, index }) => ( - + + + )} + pagingEnabled + showsVerticalScrollIndicator={false} + onMomentumScrollEnd={onMomentumScrollEnd} + getItemLayout={getItemLayout} + initialNumToRender={3} + windowSize={5} + removeClippedSubviews + scrollEventThrottle={32} /> ); @@ -831,9 +854,13 @@ const Playbacks = () => { 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%", @@ -845,19 +872,12 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, - itemContainerCompact: { - flexDirection: "column", - alignItems: "center", - justifyContent: "flex-start", - }, videoColumn: { alignItems: "center", justifyContent: "center", }, videoSurface: { alignSelf: "center", - // width: 100, - // maxWidth: 400, aspectRatio: 9 / 16, borderRadius: 32, overflow: "hidden", @@ -944,11 +964,6 @@ const styles = StyleSheet.create({ commentsColumn: { alignSelf: "stretch", }, - commentsColumnCompact: { - width: "100%", - maxWidth: 640, - marginTop: 32, - }, commentsWrapper: { flex: 1, borderRadius: 28,