Files
musicland/src/screens/Playbacks/Playbacks.web.js
T
2026-09-03 09:30:31 +02:00

600 lines
16 KiB
JavaScript

import { useIsFocused, useRoute } from '@react-navigation/native'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
FlatList,
Image,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
} from 'react-native'
import { icons } from '../../assets'
import { projectsRef } from '../../config/firebase'
import useDataFromRef from '../../hooks/useDataFromRef'
import usePlayer from '../../hooks/usePlayer'
import { useUser } from '../../providers/UserDataProvider'
import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import PlaybackItem from './components/PlaybackItem.web'
import PlaybackCharts from './components/PlaybackCharts'
const Playbacks = () => {
const { height: windowHeight } = useWindowDimensions()
const [activeIndex, setActiveIndex] = useState(0)
const route = useRoute()
const { stop } = usePlayer() || {}
const wasFocusedRef = useRef(false)
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
const userCache = useRef(new Map())
const { getUserByUid } = useUser() || {}
const isFocused = useIsFocused()
const {
data: rawPlaybacks = [],
loadMore,
hasMore,
loading,
} = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
listener: false,
usePagination: true,
batchSize: 6,
})
const focusIndex = useMemo(() => {
if (!focusProjectId) return -1
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId)
}, [focusProjectId, rawPlaybacks])
const playbacks = useMemo(() => {
if (focusIndex < 0) return rawPlaybacks
const target = rawPlaybacks[focusIndex]
const before = rawPlaybacks.slice(0, focusIndex)
const after = rawPlaybacks.slice(focusIndex + 1)
return [target, ...after, ...before]
}, [focusIndex, rawPlaybacks])
const activePlayback = playbacks?.[activeIndex] || null
const activeVideoUrl = activePlayback?.playbackUrl || null
const backgroundVideoRef = useRef(null)
const lastActiveIndexRef = useRef(0)
const backgroundSeekPendingRef = useRef(false)
const listRef = useRef(null)
useEffect(() => {
if (isFocused && !wasFocusedRef.current) {
stop?.().catch(() => {})
}
wasFocusedRef.current = isFocused
}, [isFocused, stop])
useEffect(() => {
const total = playbacks?.length || 0
if (total <= 0) {
if (activeIndex !== 0) {
lastActiveIndexRef.current = 0
setActiveIndex(0)
}
return
}
const maxIndex = total - 1
if (activeIndex > maxIndex) {
lastActiveIndexRef.current = maxIndex
setActiveIndex(maxIndex)
}
}, [playbacks?.length, activeIndex])
const handleActiveIndexChange = useCallback(
(rawIndex) => {
if (Number.isNaN(rawIndex)) return
const total = playbacks?.length || 0
const maxIndex = Math.max(0, total - 1)
const clamped = Math.max(0, Math.min(rawIndex, maxIndex))
if (clamped === lastActiveIndexRef.current) return
lastActiveIndexRef.current = clamped
setActiveIndex((prev) => (prev === clamped ? prev : clamped))
if (total > 0 && clamped >= Math.max(0, total - 6)) {
loadMore?.()
}
},
[playbacks?.length, loadMore]
)
const syncBackgroundVideo = useCallback(({ currentTime, isPlaying }) => {
const bg = backgroundVideoRef.current
if (!bg) return
if (typeof currentTime === 'number' && Number.isFinite(currentTime)) {
const applyTime = () => {
backgroundSeekPendingRef.current = false
const diff = Math.abs((bg.currentTime || 0) - currentTime)
if (diff > 0.25) {
try {
bg.currentTime = currentTime
} catch (_e) {}
}
}
if (bg.readyState >= 1) applyTime()
else if (!backgroundSeekPendingRef.current) {
backgroundSeekPendingRef.current = true
const handler = () => applyTime()
bg.addEventListener('loadeddata', handler, { once: true })
}
}
if (isPlaying === true) {
if (bg.paused) {
bg.play().catch(() => {})
}
} else if (isPlaying === false) {
try {
if (!bg.paused) bg.pause()
} catch (_e) {}
}
}, [])
useEffect(() => {
const bg = backgroundVideoRef.current
if (!bg) return
if (!activeVideoUrl) {
backgroundSeekPendingRef.current = false
try {
bg.pause()
} catch (_e) {}
bg.removeAttribute?.('src')
bg.load?.()
return
}
backgroundSeekPendingRef.current = false
const setSrcIfNeeded = () => {
const attrSrc = bg.getAttribute('src')
if (attrSrc !== activeVideoUrl) {
bg.setAttribute('src', activeVideoUrl)
try {
bg.load()
} catch (_e) {}
}
}
const startPlayback = () => {
bg.play().catch(() => {})
}
setSrcIfNeeded()
if (bg.readyState >= 2) {
startPlayback()
return
}
const handleLoaded = () => {
startPlayback()
}
bg.addEventListener('loadeddata', handleLoaded, { once: true })
return () => {
bg.removeEventListener('loadeddata', handleLoaded)
}
}, [activeVideoUrl])
const hasAppliedFocusRef = useRef(false)
const focusLoadAttemptsRef = useRef(0)
useEffect(() => {
hasAppliedFocusRef.current = false
focusLoadAttemptsRef.current = 0
}, [focusProjectId])
useEffect(() => {
if (!focusProjectId) return
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
hasAppliedFocusRef.current = true
lastActiveIndexRef.current = 0
setActiveIndex(0)
requestAnimationFrame(() => {
try {
listRef.current?.scrollToOffset?.({ offset: 0, animated: false })
} catch (_e) {}
})
return
}
if (loadMore && focusLoadAttemptsRef.current < 6) {
focusLoadAttemptsRef.current += 1
loadMore()
}
}, [focusIndex, focusProjectId, loadMore, playbacks])
useEffect(() => {
const nextFocusId = route?.params?.projectId || route?.params?.focusId || null
setFocusProjectId(nextFocusId)
if (nextFocusId) {
setViewMode('feed')
}
}, [route?.params?.focusId, route?.params?.projectId])
useEffect(() => {
if (viewMode !== 'feed') {
return
}
lastActiveIndexRef.current = 0
setActiveIndex(0)
requestAnimationFrame(() => {
try {
listRef.current?.scrollToOffset?.({ offset: 0, animated: false })
} catch (_e) {}
})
}, [viewMode])
// === FlatList (one real page per item) ===
// 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)
handleActiveIndexChange(idx)
} catch (_e) {}
},
[windowHeight, handleActiveIndexChange]
)
const onScroll = useCallback(
(e) => {
try {
const y = e?.nativeEvent?.contentOffset?.y || 0
const idx = Math.round(y / windowHeight)
handleActiveIndexChange(idx)
} catch (_e) {}
},
[windowHeight, handleActiveIndexChange]
)
const getItemLayout = useCallback(
(_data, index) => ({
length: windowHeight,
offset: windowHeight * index,
index,
}),
[windowHeight]
)
const total = playbacks.length
const indicatorItems = useMemo(() => {
if (total <= 0) return []
const maxVisible = 7
let start = 0
let end = total - 1
if (total > maxVisible) {
const half = Math.floor(maxVisible / 2)
start = activeIndex - half
end = start + maxVisible - 1
if (start < 0) {
end += -start
start = 0
}
if (end >= total) {
const overshoot = end - (total - 1)
start = Math.max(0, start - overshoot)
end = total - 1
}
}
const items = []
for (let i = start; i <= end; i += 1) {
items.push({
key: `indicator-${i}`,
isActive: i === activeIndex,
isPast: i < activeIndex,
})
}
return items
}, [total, activeIndex])
const canScrollUp = total > 0 && activeIndex > 0
const atLastLoaded = total > 0 && activeIndex >= total - 1
const canScrollDown = total > 0 && (!atLastLoaded || hasMore)
const showIndicators = total > 0
const indicatorLabel = useMemo(() => {
if (total <= 0) return null
if (hasMore || loading) return `${activeIndex + 1}+`
return `${activeIndex + 1}/${total}`
}, [total, activeIndex, hasMore, loading])
const scrollToPlayback = useCallback(
(direction) => {
if (!direction || !listRef.current) return
const totalItems = playbacks.length
if (totalItems <= 0) return
const targetIndex = Math.max(0, Math.min(activeIndex + direction, totalItems - 1))
if (targetIndex === activeIndex) {
if (direction > 0 && hasMore) {
loadMore?.()
}
return
}
const targetOffset = targetIndex * windowHeight
try {
listRef.current.scrollToOffset({
offset: targetOffset,
animated: true,
})
} catch (_e) {
try {
listRef.current.scrollToIndex({
index: targetIndex,
animated: true,
})
} catch (__e) {}
}
handleActiveIndexChange(targetIndex)
},
[activeIndex, handleActiveIndexChange, hasMore, loadMore, playbacks.length, windowHeight]
)
const showFeed = viewMode === 'feed'
const openFeedForPlayback = useCallback((project) => {
const nextId =
project && typeof project === 'object'
? project?.id || project?.projectId || null
: project || null
if (!nextId) {
return
}
setFocusProjectId(nextId)
setViewMode('feed')
}, [])
if (!showFeed) {
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} />
}
return (
<View style={styles.screen}>
{activeVideoUrl ? (
<View pointerEvents="none" style={styles.backgroundVideoWrapper}>
<video
ref={backgroundVideoRef}
key={activeVideoUrl}
src={activeVideoUrl}
autoPlay
muted
loop
playsInline
preload="auto"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
filter: 'blur(28px) saturate(120%) brightness(55%)',
transform: 'scale(1.1)',
}}
/>
<View style={styles.backgroundOverlay} />
</View>
) : null}
<FlatList
ref={listRef}
data={playbacks}
keyExtractor={(item, index) => String(item?.id || index)}
renderItem={({ item, index }) => (
<View style={{ width: '100%', height: windowHeight }}>
<PlaybackItem
item={item}
userCache={userCache}
getUserByUid={getUserByUid}
isActive={isFocused && index === activeIndex}
onBackgroundSync={syncBackgroundVideo}
/>
</View>
)}
pagingEnabled
showsVerticalScrollIndicator={false}
onMomentumScrollEnd={onMomentumScrollEnd}
getItemLayout={getItemLayout}
initialNumToRender={3}
windowSize={5}
removeClippedSubviews
onScroll={onScroll}
scrollEventThrottle={16}
/>
{showIndicators && (
<View style={styles.indicatorContainer} pointerEvents="box-none">
<Pressable
onPress={() => scrollToPlayback(-1)}
hitSlop={12}
disabled={!canScrollUp}
style={styles.indicatorArrowTouch}
>
<Image
source={icons.chevronDown}
resizeMode="contain"
style={[
styles.indicatorArrow,
styles.indicatorArrowUp,
!canScrollUp && styles.indicatorArrowDisabled,
]}
/>
</Pressable>
<View style={styles.indicatorDotsWrapper}>
{indicatorItems.map((item, index) => (
<View
key={item.key}
style={[
styles.indicatorDot,
item.isActive && styles.indicatorDotActive,
item.isPast && styles.indicatorDotPast,
index < indicatorItems.length - 1 ? styles.indicatorDotSpacing : null,
]}
/>
))}
{canScrollDown && hasMore && (
<View style={[styles.indicatorMoreDot, styles.indicatorDotSpacing]} />
)}
</View>
{/* {indicatorLabel ? (
<Text style={styles.indicatorLabel}>{indicatorLabel}</Text>
) : null} */}
<Pressable
onPress={() => scrollToPlayback(1)}
hitSlop={12}
disabled={!canScrollDown}
style={styles.indicatorArrowTouch}
>
<Image
source={icons.chevronDown}
resizeMode="contain"
style={[styles.indicatorArrow, !canScrollDown && styles.indicatorArrowDisabled]}
/>
</Pressable>
</View>
)}
<Pressable
style={styles.backToCharts}
hitSlop={12}
onPress={() => setViewMode('charts')}
accessibilityLabel="Revenir au classement des playbacks"
>
<Image source={icons.sort} resizeMode="contain" style={styles.backToChartsIcon} />
<Text style={styles.backToChartsText}>Retour au classement</Text>
</Pressable>
</View>
// </Page>
)
}
export default Playbacks
const styles = StyleSheet.create({
screen: {
flex: 1,
},
backgroundVideoWrapper: {
height: '100%',
width: '100%',
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
overflow: 'hidden',
zIndex: -1,
backgroundColor: '#060606',
},
backgroundOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(2, 2, 2, 0.35)',
},
topOverlayContainer: {
position: 'absolute',
top: 32,
left: 24,
right: 24,
zIndex: 10,
},
topOverlayContent: {
alignSelf: 'flex-start',
paddingVertical: 12,
paddingHorizontal: 18,
borderRadius: 18,
backgroundColor: 'rgba(0,0,0,0.45)',
gap: 4,
},
topOverlayTitle: {
color: Palette.white,
fontSize: 20,
fontFamily: FONT_FAMILY.InterSemiBold,
},
topOverlaySubtitle: {
color: Palette.white,
fontSize: 15,
fontFamily: FONT_FAMILY.InterMedium,
opacity: 0.9,
},
indicatorContainer: {
position: 'absolute',
left: 20,
top: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
gap: 16,
},
indicatorArrow: {
width: 20,
height: 20,
tintColor: Palette.white,
opacity: 0.8,
},
indicatorArrowTouch: {
paddingVertical: 6,
paddingHorizontal: 8,
},
indicatorArrowUp: {
transform: [{ rotate: '180deg' }],
},
indicatorArrowDisabled: {
opacity: 0.25,
},
indicatorDotsWrapper: {
alignItems: 'center',
},
indicatorDot: {
width: 6,
height: 12,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.3)',
},
indicatorDotPast: {
backgroundColor: 'rgba(255,255,255,0.55)',
},
indicatorDotActive: {
backgroundColor: Palette.white,
height: 20,
},
indicatorDotSpacing: {
marginVertical: 5,
},
indicatorMoreDot: {
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: Palette.white,
opacity: 0.7,
},
indicatorLabel: {
color: Palette.white,
fontSize: 12,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: 'center',
},
backToCharts: {
position: 'absolute',
top: 28,
right: 24,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: 'rgba(255,255,255,0.92)',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.12)',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
zIndex: 20,
},
backToChartsIcon: {
width: 16,
height: 16,
tintColor: '#0F0C14',
},
backToChartsText: {
color: '#0F0C14',
fontSize: 13,
fontFamily: FONT_FAMILY.InterSemiBold,
},
})