600 lines
16 KiB
JavaScript
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,
|
|
},
|
|
})
|