diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js index 5998845..9900094 100644 --- a/src/screens/HitParade/HitParade.js +++ b/src/screens/HitParade/HitParade.js @@ -64,6 +64,14 @@ const HitParade = () => { condition: true, }) + const { data: allTimeSongs } = useDataFromRef({ + ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20), + format: (docs) => docs.filter((item) => item?.hasPlayback !== true), + simpleRef: false, + listener: false, + condition: true, + }) + const songsList = useMemo(() => (Array.isArray(allSongs) ? allSongs : []), [allSongs]) const topMonthList = useMemo( @@ -71,6 +79,11 @@ const HitParade = () => { [topMonthSongs] ) + const allTimeList = useMemo( + () => (Array.isArray(allTimeSongs) ? allTimeSongs : []), + [allTimeSongs] + ) + // === RENDUS PAR PLATEFORME === const renderSongsMobile = () => ( { /> ) + const renderAllTimeMobile = () => ( + item.id} + renderItem={({ item, index }) => ( + + navigateToMusicDetails({ + projectId: item?.id, + songUrl: item?.songUrl, + }) + } + /> + )} + /> + ) + const renderSongList = () => ( {songsList.map((item) => ( @@ -168,6 +207,27 @@ const HitParade = () => { ) + const renderAllTimeColumns = () => ( + + {allTimeList.map((item, index) => ( + + navigateToMusicDetails({ + projectId: item?.id, + songUrl: item?.songUrl, + }) + } + /> + ))} + + ) + const isWeb = Platform.OS === 'web' const { search, @@ -205,8 +265,9 @@ const HitParade = () => { } songsList.forEach(collectUserId) topMonthList.forEach(collectUserId) + allTimeList.forEach(collectUserId) return Array.from(collected) - }, [songsList, topMonthList]) + }, [songsList, topMonthList, allTimeList]) const fetchCreatorsByIds = useCallback( async (userIds) => { @@ -455,6 +516,32 @@ const HitParade = () => { {renderTopMonthColumns()} + + + {/* */} + + All Time + + {/* */} + + {renderAllTimeColumns()} + ) @@ -470,7 +557,7 @@ const HitParade = () => { gap: 12, }} > - {['Chansons', 'Top du mois'].map((item, index) => ( + {['Chansons', 'Top du mois', 'All Time'].map((item, index) => ( { {selectedCategory === 'Chansons' && renderSongsMobile()} {selectedCategory === 'Top du mois' && renderTopMonthMobile()} + {selectedCategory === 'All Time' && renderAllTimeMobile()} {/* "Clips" pourra reprendre la même logique si tu le réactives */} ) diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index 3a636d3..95cd6a7 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -1,18 +1,23 @@ import { useIsFocused, useRoute } from '@react-navigation/native' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import Carousel from 'react-native-reanimated-carousel' import { responsiveHeight } from 'react-native-responsive-dimensions' import { projectsRef } from '../../config/firebase' import useDataFromRef from '../../hooks/useDataFromRef' import PlaybackItem from './components/PlaybackItem' +import PlaybackCharts from './components/PlaybackCharts' +import { Palette } from '../../styles' +import { FONT_FAMILY } from '../../styles/Fonts' const Playbacks = () => { const [activeIndex, setActiveIndex] = useState(0) const userCache = useRef(new Map()) const isFocused = useIsFocused() const route = useRoute() - const focusProjectId = route?.params?.projectId || route?.params?.focusId || null + const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null + const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId) + const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts') const { data: rawPlaybacks = [], loadMore } = useDataFromRef({ ref: projectsRef.where('playbackUrl', '!=', null), simpleRef: false, @@ -75,6 +80,39 @@ const Playbacks = () => { } }, [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 + } + setActiveIndex(0) + }, [viewMode]) + + 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 + } + return ( { /> )} /> + setViewMode('charts')} + accessibilityLabel="Revenir au classement des playbacks" + > + Classement + ) } export default Playbacks + +const styles = StyleSheet.create({ + backToCharts: { + position: 'absolute', + top: 32, + right: 20, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.55)', + zIndex: 20, + }, + backToChartsText: { + color: Palette.white, + fontSize: 12, + fontFamily: FONT_FAMILY.InterSemiBold, + }, +}) diff --git a/src/screens/Playbacks/Playbacks.web.js b/src/screens/Playbacks/Playbacks.web.js index 86812f1..b3da8ce 100644 --- a/src/screens/Playbacks/Playbacks.web.js +++ b/src/screens/Playbacks/Playbacks.web.js @@ -1,6 +1,14 @@ import { useIsFocused, useRoute } from '@react-navigation/native' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { FlatList, Image, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native' +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' @@ -8,12 +16,15 @@ 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 focusProjectId = route?.params?.projectId || route?.params?.focusId || null + 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() @@ -44,9 +55,6 @@ const Playbacks = () => { const activePlayback = playbacks?.[activeIndex] || null const activeVideoUrl = activePlayback?.playbackUrl || null - const activeTitle = typeof activePlayback?.title === 'string' ? activePlayback.title.trim() : '' - - const activeCreatorName = activePlayback?.userName const backgroundVideoRef = useRef(null) const lastActiveIndexRef = useRef(0) const backgroundSeekPendingRef = useRef(false) @@ -161,7 +169,6 @@ const Playbacks = () => { } }, [activeVideoUrl]) - const triedLoadMoreRef = useRef(0) const hasAppliedFocusRef = useRef(false) const focusLoadAttemptsRef = useRef(0) @@ -189,6 +196,27 @@ const Playbacks = () => { } }, [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( @@ -294,6 +322,24 @@ const Playbacks = () => { [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 + } + return ( {activeVideoUrl ? ( @@ -396,6 +442,14 @@ const Playbacks = () => { )} + setViewMode('charts')} + accessibilityLabel="Revenir au classement des playbacks" + > + Classement + // ) @@ -505,4 +559,19 @@ const styles = StyleSheet.create({ fontFamily: FONT_FAMILY.InterMedium, textAlign: 'center', }, + backToCharts: { + position: 'absolute', + top: 32, + right: 24, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.55)', + zIndex: 20, + }, + backToChartsText: { + color: Palette.white, + fontSize: 12, + fontFamily: FONT_FAMILY.InterSemiBold, + }, }) diff --git a/src/screens/Playbacks/components/PlaybackCharts.js b/src/screens/Playbacks/components/PlaybackCharts.js new file mode 100644 index 0000000..5d3cbc8 --- /dev/null +++ b/src/screens/Playbacks/components/PlaybackCharts.js @@ -0,0 +1,650 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + FlatList, + Image, + Platform, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' +import { BlurView } from 'expo-blur' +import { responsiveHeight } from 'react-native-responsive-dimensions' +import { background, icons } from '../../../assets' +import BorderGradientButton from '../../../components/BorderGradientButton' +import MobileCoinBadge from '../../../components/MobileCoinBadge' +import SearchBar from '../../../components/SearchBar' +import ShareBtn from '../../../components/ShareBtn/ShareBtn' +import firebase, { projectsRef, usersRef } from '../../../config/firebase' +import useDataFromRef from '../../../hooks/useDataFromRef' +import useSearch from '../../../hooks/useSearch' +import Page from '../../../layouts/Page' +import ResearchHeader from '../../Library/components/ResearchHeader' +import SearchResultsList from '../../Library/components/SearchResultsList' +import CreateLyricsHeader from '../../Writing/components/CreateLyricsHeader' +import SongCard from '../../HitParade/components/SongCard' +import { Palette, Style } from '../../../styles' +import { FONT_FAMILY } from '../../../styles/Fonts' + +const allowedPremiumLevels = new Set(['starter', 'pro', 'premium']) + +const normalizePremiumLevel = (value) => { + if (typeof value !== 'string') { + return null + } + const normalized = value.trim().toLowerCase() + return allowedPremiumLevels.has(normalized) ? normalized : null +} + +const chunkArray = (items = [], size = 10) => { + const chunks = [] + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)) + } + return chunks +} + +const PlaybackCharts = ({ onPlaybackPress }) => { + const isWeb = Platform.OS === 'web' + const [selectedCategory, setSelectedCategory] = useState('Playbacks') + const { + data: allPlaybacks, + loading: allPlaybacksLoading, + } = useDataFromRef({ + ref: projectsRef.where('playbackUrl', '!=', null), + format: (docs) => docs.filter((item) => !!item?.playbackUrl), + simpleRef: false, + listener: true, + condition: true, + }) + + const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({ + ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20), + format: (docs) => docs.filter((item) => !!item?.playbackUrl), + simpleRef: false, + listener: false, + condition: true, + }) + + const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({ + ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20), + format: (docs) => docs.filter((item) => !!item?.playbackUrl), + simpleRef: false, + listener: false, + condition: true, + }) + + const playbacksList = useMemo( + () => (Array.isArray(allPlaybacks) ? allPlaybacks : []), + [allPlaybacks] + ) + + const topMonthList = useMemo( + () => (Array.isArray(topMonthPlaybacks) ? topMonthPlaybacks : []), + [topMonthPlaybacks] + ) + + const allTimeList = useMemo( + () => (Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []), + [allTimePlaybacks] + ) + + const { + search, + setSearch, + selected: searchSelected, + setSelected: setSearchSelected, + users = [], + musics = [], + playbacks = [], + loading: searchLoading, + } = useSearch() + + const [dropdownVisible, setDropdownVisible] = useState(false) + const searchWrapperRef = useRef(null) + + const toggleSearchSelected = useCallback( + (value) => { + setSearchSelected((previous) => (previous === value ? null : value)) + }, + [setSearchSelected] + ) + + const musicResults = useMemo(() => (Array.isArray(musics) ? musics : []), [musics]) + const playbackResults = useMemo(() => (Array.isArray(playbacks) ? playbacks : []), [playbacks]) + const userResults = useMemo(() => (Array.isArray(users) ? users : []), [users]) + + const musicsLoading = searchLoading + const playbacksLoading = searchLoading + const usersLoading = searchLoading + + const [creatorsById, setCreatorsById] = useState({}) + + const requiredUserIds = useMemo(() => { + const collected = new Set() + const collectUserId = (project) => { + const uid = project && typeof project.userId === 'string' ? project.userId.trim() : null + if (uid) { + collected.add(uid) + } + } + playbacksList.forEach(collectUserId) + topMonthList.forEach(collectUserId) + allTimeList.forEach(collectUserId) + return Array.from(collected) + }, [allTimeList, playbacksList, topMonthList]) + + const fetchCreatorsByIds = useCallback( + async (userIds) => { + if (!Array.isArray(userIds) || userIds.length === 0) { + return + } + + const normalizedIds = userIds + .map((id) => (typeof id === 'string' ? id.trim() : null)) + .filter(Boolean) + + if (normalizedIds.length === 0) { + return + } + + const pendingIds = new Set(normalizedIds) + const nextCreators = {} + + const idChunks = chunkArray(normalizedIds, 10) + + await Promise.all( + idChunks.map(async (chunk) => { + try { + const snapshot = await usersRef + .where(firebase.firestore.FieldPath.documentId(), 'in', chunk) + .get() + + snapshot.docs.forEach((doc) => { + const data = doc.data() || {} + nextCreators[doc.id] = { + premiumLevel: normalizePremiumLevel(data.premiumLevel), + } + pendingIds.delete(doc.id) + }) + } catch (error) { + console.log('PlaybackCharts: unable to fetch creators', error?.message || error) + } + }) + ) + + pendingIds.forEach((userId) => { + nextCreators[userId] = { premiumLevel: null } + }) + + if (Object.keys(nextCreators).length > 0) { + setCreatorsById((previous) => ({ ...previous, ...nextCreators })) + } + }, + [setCreatorsById] + ) + + useEffect(() => { + const missingIds = requiredUserIds.filter((id) => !creatorsById[id]) + if (missingIds.length === 0) { + return + } + fetchCreatorsByIds(missingIds) + }, [creatorsById, fetchCreatorsByIds, requiredUserIds]) + + const getCreatorLevel = useCallback( + (project) => { + const uid = project && typeof project.userId === 'string' ? project.userId.trim() : null + if (!uid) { + return null + } + return creatorsById?.[uid]?.premiumLevel || null + }, + [creatorsById] + ) + + const closeDropdown = useCallback(() => { + setDropdownVisible(false) + setSearchSelected(null) + setSearch('') + }, [setSearch, setSearchSelected]) + + const handleSearchFocus = useCallback(() => { + if (isWeb) { + setDropdownVisible(true) + } + }, [isWeb]) + + const handleSearchChange = useCallback( + (value) => { + setSearch(value) + if (isWeb) { + setDropdownVisible(true) + } + }, + [isWeb, setSearch] + ) + + useEffect(() => { + if (!isWeb || !dropdownVisible) { + return undefined + } + + const handleClickOutside = (event) => { + if (searchWrapperRef.current?.contains?.(event.target)) { + return + } + closeDropdown() + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('touchstart', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('touchstart', handleClickOutside) + } + }, [closeDropdown, dropdownVisible, isWeb]) + + const shouldShowResults = isWeb && dropdownVisible + const shouldBlurContent = shouldShowResults + + const handlePlaybackSelected = useCallback( + (project) => { + if (!project?.id) { + return + } + onPlaybackPress?.(project) + }, + [onPlaybackPress] + ) + + const heroTitle = isWeb ? 'Playbacks coups de coeur' : 'Classements des playbacks' + const heroSubtitle = 'Découvre les playbacks les plus écoutés et lance la lecture verticale.' + + const renderHero = () => ( + + + + {heroTitle} + + + {heroSubtitle} + + + + ) + + const renderPlaybacksMobile = () => ( + item.id} + refreshing={allPlaybacksLoading} + renderItem={({ item }) => ( + handlePlaybackSelected(item)} + /> + )} + /> + ) + + const renderTopMonthMobile = () => ( + item.id} + refreshing={topMonthLoading} + renderItem={({ item, index }) => ( + handlePlaybackSelected(item)} + /> + )} + /> + ) + + const renderAllTimeMobile = () => ( + item.id} + refreshing={allTimeLoading} + renderItem={({ item, index }) => ( + handlePlaybackSelected(item)} + /> + )} + /> + ) + + const renderPlaybacksList = () => ( + + {playbacksList.map((item) => ( + handlePlaybackSelected(item)} + /> + ))} + + ) + + const renderTopMonthColumns = () => ( + + {topMonthList.map((item, index) => ( + handlePlaybackSelected(item)} + /> + ))} + + ) + + const renderAllTimeColumns = () => ( + + {allTimeList.map((item, index) => ( + handlePlaybackSelected(item)} + /> + ))} + + ) + + const renderWebContent = () => ( + + {renderHero()} + + + + + Playbacks + + + {renderPlaybacksList()} + + + + + Top du mois + + + {renderTopMonthColumns()} + + + + + All Time + + + {renderAllTimeColumns()} + + + + ) + + const renderMobileContent = () => ( + + {renderHero()} + + {['Playbacks', 'Top du mois', 'All Time'].map((item, index) => ( + setSelectedCategory(item)} + tint={selectedCategory === item ? 'light' : 'dark'} + containerStyle={{ flex: 1 }} + titleStyle={{ + fontSize: 16, + fontFamily: FONT_FAMILY.HelveticaNeueBold, + }} + /> + ))} + + + {selectedCategory === 'Playbacks' && renderPlaybacksMobile()} + {selectedCategory === 'Top du mois' && renderTopMonthMobile()} + {selectedCategory === 'All Time' && renderAllTimeMobile()} + + ) + + return ( + + {!isWeb ? ( + + + + + ) : null} + + + + {isWeb && ( + + + {shouldShowResults && ( + + + + + + + )} + + )} + + + {shouldBlurContent && ( + + )} + + {isWeb ? renderWebContent() : renderMobileContent()} + + + + ) +} + +export default PlaybackCharts