418 lines
15 KiB
JavaScript
418 lines
15 KiB
JavaScript
import { Feather } from '@expo/vector-icons'
|
|
import { Image as ExpoImage } from 'expo-image'
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
Linking,
|
|
Pressable,
|
|
Image as RNImage,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native'
|
|
import { SheetManager } from 'react-native-actions-sheet'
|
|
import { responsiveHeight, responsiveWidth } from 'react-native-responsive-dimensions'
|
|
import { useGlobal } from 'reactn'
|
|
import { background, icons } from '../../assets'
|
|
import PressableScale from '../../components/PressableScale'
|
|
import ProgressSlider from '../../components/player/ProgressSlider'
|
|
import { arrayRemove, arrayUnion, increment, projectsRef, usersRef } from '../../config/firebase'
|
|
import useDataFromRef from '../../hooks/useDataFromRef'
|
|
import usePlayer from '../../hooks/usePlayer'
|
|
import useTrackController from '../../hooks/useTrackController'
|
|
import Page from '../../layouts/Page'
|
|
import { Palette } from '../../styles'
|
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
|
import Style, { gutters, size } from '../../styles/Style'
|
|
import { getArtistDisplayName } from '../../utils/artistName'
|
|
import { ensureAuthenticated } from '../../utils/authRedirect'
|
|
import { createMusicSharePayload, openShareSheet } from '../../utils/shareSheet'
|
|
// Assure-toi que le fichier lyricsUtils.js contient la fonction processKaraokeSections fournie précédemment
|
|
import { processKaraokeSections } from './utils/lyricsUtils'
|
|
|
|
const timeBeforeIncrement = 20000
|
|
|
|
const MusicDetails = ({ route }) => {
|
|
const { params } = route || {}
|
|
const action = params?.action
|
|
const autoPlayRequested = params?.autoPlay
|
|
const projectId = params?.projectId || null
|
|
|
|
const [fav, setFav] = useState(false)
|
|
const [currentUID] = useGlobal('currentUID')
|
|
|
|
const listenedMsRef = useRef(0)
|
|
const incrementDoneRef = useRef(false)
|
|
const timerRef = useRef(null)
|
|
const hasAutoPlayedRef = useRef(false)
|
|
const lyricsRef = useRef(null)
|
|
const lineYRef = useRef({})
|
|
|
|
const { isLooping, setLooping } = usePlayer() || {}
|
|
|
|
// --- DATA FETCHING ---
|
|
const { data: project } = useDataFromRef({
|
|
ref: projectId ? projectsRef.doc(projectId) : null,
|
|
simpleRef: true,
|
|
listener: true,
|
|
condition: !!projectId,
|
|
})
|
|
|
|
const { data: owner } = useDataFromRef({
|
|
ref: project?.userId ? usersRef.doc(project.userId) : null,
|
|
simpleRef: true,
|
|
listener: true,
|
|
condition: !!project?.userId,
|
|
})
|
|
|
|
// --- METADATA & PLAYER STABILIZATION ---
|
|
const title = project?.title || 'Sans titre'
|
|
const artist = useMemo(() => {
|
|
const ownerName = getArtistDisplayName(owner, '')
|
|
if (ownerName) return ownerName
|
|
return getArtistDisplayName(project, 'MusicLand')
|
|
}, [owner, project])
|
|
|
|
const coverUrl = project?.coverUrl || null
|
|
const songUrl = project?.songUrl || null
|
|
|
|
// On mémoïse le descriptor pour éviter les re-chargements intempestifs sur iOS
|
|
const trackDescriptor = useMemo(() => {
|
|
if (!songUrl) return null
|
|
const id = projectId ? `project-${projectId}` : `song-${songUrl}`
|
|
const desc = {
|
|
id,
|
|
uri: songUrl,
|
|
songUrl,
|
|
title,
|
|
artist,
|
|
artwork: coverUrl,
|
|
coverUrl,
|
|
metadata: { projectId, screen: 'MusicDetails' },
|
|
context: { projectId, screen: 'MusicDetails' },
|
|
}
|
|
console.log('[PLAYER] Nouveau descriptor généré:', id)
|
|
return desc
|
|
}, [projectId, songUrl, title, artist, coverUrl])
|
|
|
|
const {
|
|
isCurrent,
|
|
isPlaying,
|
|
positionMs,
|
|
durationMs,
|
|
ensureLoaded,
|
|
pause,
|
|
resume,
|
|
seekTo,
|
|
seekBy,
|
|
} = useTrackController(trackDescriptor)
|
|
|
|
// --- DURATION CALCULATIONS (Pour le clic sur le slider) ---
|
|
const estimatedDurationMs = useMemo(() => {
|
|
const toMs = (val) => {
|
|
const n = Number(val); return (!n || n <= 0) ? 0 : (n > 1000 ? Math.round(n) : Math.round(n * 1000))
|
|
}
|
|
const idx = Number(project?.songIndex) || 0
|
|
const ts = project?.musicTimestamps?.[idx]
|
|
|
|
// Priorité 1: Metadata
|
|
const fromMeta = toMs(ts?.durationMs || ts?.duration || ts?.audioDuration)
|
|
if (fromMeta > 0) return fromMeta
|
|
|
|
// Priorité 2: Dernier mot synchronisé
|
|
const words = ts?.alignedWords || []
|
|
if (words.length > 0) {
|
|
const last = words[words.length - 1]
|
|
return Math.round((last.endS || last.startS || 0) * 1000)
|
|
}
|
|
return 0
|
|
}, [project])
|
|
|
|
const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs
|
|
|
|
// --- LYRICS LOGIC ---
|
|
const sections = useMemo(() => {
|
|
const idx = Number(project?.songIndex) || 0
|
|
const ts = project?.musicTimestamps?.[idx]
|
|
return processKaraokeSections(ts?.alignedWords || [])
|
|
}, [project?.musicTimestamps, project?.songIndex])
|
|
|
|
const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections])
|
|
const visibleTimeS = (positionMs || 0) / 1000
|
|
|
|
const currentLineIdx = useMemo(() => {
|
|
if (!flatLines.length) return -1
|
|
return flatLines.findIndex(l => visibleTimeS >= l.startS && visibleTimeS <= l.endS)
|
|
}, [flatLines, visibleTimeS])
|
|
|
|
// --- EFFECTS ---
|
|
useEffect(() => {
|
|
if (project && currentUID) {
|
|
setFav(Array.isArray(project.likedBy) ? project.likedBy.includes(currentUID) : false)
|
|
}
|
|
}, [project?.likedBy, currentUID])
|
|
|
|
// Vues
|
|
useEffect(() => {
|
|
const clear = () => timerRef.current && clearInterval(timerRef.current)
|
|
if (isPlaying && projectId && !incrementDoneRef.current) {
|
|
timerRef.current = setInterval(() => {
|
|
listenedMsRef.current += 1000
|
|
if (listenedMsRef.current >= timeBeforeIncrement) {
|
|
projectsRef.doc(projectId).update({ views: increment(1) }).catch(() => {})
|
|
incrementDoneRef.current = true
|
|
clear()
|
|
}
|
|
}, 1000)
|
|
} else { clear() }
|
|
return clear
|
|
}, [isPlaying, projectId])
|
|
|
|
// Auto-scroll
|
|
useEffect(() => {
|
|
const y = lineYRef.current?.[currentLineIdx]
|
|
if (lyricsRef.current && typeof y === 'number') {
|
|
lyricsRef.current.scrollTo({ y: Math.max(0, y - 80), animated: true })
|
|
}
|
|
}, [currentLineIdx])
|
|
|
|
// Autoplay
|
|
useEffect(() => {
|
|
if (autoPlayRequested && !hasAutoPlayedRef.current && trackDescriptor) {
|
|
const run = async () => {
|
|
try {
|
|
if (!isCurrent) await ensureLoaded({ startPositionMs: 0, autoPlay: true })
|
|
else if (!isPlaying) await resume()
|
|
hasAutoPlayedRef.current = true
|
|
} catch (e) { console.log('[PLAYER] Autoplay error:', e.message) }
|
|
}
|
|
run()
|
|
}
|
|
}, [autoPlayRequested, trackDescriptor, isCurrent, isPlaying, ensureLoaded, resume])
|
|
|
|
// --- CALLBACKS ---
|
|
const togglePlay = useCallback(async () => {
|
|
if (!trackDescriptor) return
|
|
try {
|
|
if (!isCurrent) {
|
|
console.log('[PLAYER] Chargement via togglePlay')
|
|
await ensureLoaded({ startPositionMs: positionMs, autoPlay: true })
|
|
} else {
|
|
isPlaying ? await pause() : await resume()
|
|
}
|
|
} catch (e) { console.log('[PLAYER] Toggle error:', e.message) }
|
|
}, [trackDescriptor, isCurrent, isPlaying, ensureLoaded, positionMs, pause, resume])
|
|
|
|
const handleSliderSeek = useCallback(async (targetMs) => {
|
|
if (!trackDescriptor || sliderDurationMs <= 0) return
|
|
const ms = Math.max(0, Math.min(sliderDurationMs, Math.floor(targetMs)))
|
|
console.log(`[PLAYER] Seek demandé vers: ${ms}ms`)
|
|
try {
|
|
if (!isCurrent) {
|
|
await ensureLoaded({ startPositionMs: ms, autoPlay: isPlaying })
|
|
} else {
|
|
await seekTo(ms)
|
|
}
|
|
} catch (e) { console.log('[PLAYER] Seek error:', e.message) }
|
|
}, [trackDescriptor, sliderDurationMs, isCurrent, ensureLoaded, isPlaying, seekTo])
|
|
|
|
const handleLyricsSeek = async (timeS) => {
|
|
const ms = Math.max(0, timeS * 1000)
|
|
try {
|
|
if (!isCurrent) await ensureLoaded({ startPositionMs: ms, autoPlay: true })
|
|
else {
|
|
await seekTo(ms)
|
|
if (!isPlaying) await resume()
|
|
}
|
|
} catch (e) { console.log('[PLAYER] Lyrics Seek error:', e.message) }
|
|
}
|
|
|
|
const handleOpenOptions = () => {
|
|
const sharePayload = createMusicSharePayload({ projectId, title, artist })
|
|
SheetManager.show('MusicOptions', {
|
|
payload: {
|
|
projectId, title, ownerId: project?.userId || owner?.id || null,
|
|
onReport: () => SheetManager.show('Report', { payload: { targetType: 'music', projectId, title, ownerId: project?.userId } }),
|
|
onAddToPlaylist: () => SheetManager.show('Playlist', { payload: { projectId } }),
|
|
onDownload: () => songUrl && Linking.openURL(songUrl).catch(() => {}),
|
|
downloadDisabled: !songUrl,
|
|
onShare: () => sharePayload && openShareSheet(sharePayload),
|
|
sharePayload,
|
|
},
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Page
|
|
headerType="NAVIGATION"
|
|
title={action === 'userProfile' ? 'Mon profil' : 'Détail musique'}
|
|
backgroundImg={action === 'userProfile' ? background.profileBG : background.libraryBG2}
|
|
{...(action === 'userProfile' && {
|
|
containerStyle: { backgroundColor: '#0000004D' },
|
|
rightComponent: () => (
|
|
<Pressable onPress={() => SheetManager.show('DeleteAudio')}>
|
|
<RNImage source={icons.trash} />
|
|
</Pressable>
|
|
),
|
|
})}
|
|
>
|
|
<View style={{ flex: 1, paddingTop: 20, paddingBottom: gutters * 2 }}>
|
|
{/* INFOS MEDIA */}
|
|
<View style={{ gap: 28 }}>
|
|
{coverUrl && (
|
|
<ExpoImage
|
|
source={{ uri: coverUrl }}
|
|
cachePolicy="memory-disk"
|
|
priority="high"
|
|
contentFit="cover"
|
|
transition={150}
|
|
style={styles.img}
|
|
/>
|
|
)}
|
|
<View style={{ gap: 12 }}>
|
|
<View style={{ ...Style.containerRow, justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<View>
|
|
<Text style={styles.title}>{title}</Text>
|
|
<Text style={styles.name}>{artist}</Text>
|
|
</View>
|
|
<View style={{ ...Style.containerRow, gap: 16 }}>
|
|
<PressableScale
|
|
onPress={() => setLooping?.(!isLooping)}
|
|
disabled={!isCurrent}
|
|
style={{ ...size({ size: 32 }), alignItems: 'center', justifyContent: 'center', opacity: isCurrent ? 1 : 0.4 }}
|
|
>
|
|
<Feather name="repeat" size={24} color={isCurrent && isLooping ? Palette.primary : Palette.white} />
|
|
</PressableScale>
|
|
<PressableScale
|
|
onPress={async () => {
|
|
if (!projectId || !ensureAuthenticated(currentUID)) return
|
|
const next = !fav; setFav(next)
|
|
projectsRef.doc(projectId).update({
|
|
likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID),
|
|
}).catch(() => setFav(!next))
|
|
}}
|
|
>
|
|
<RNImage source={fav ? icons.heart : icons.heartOutline} style={{ ...size({ size: 24 }) }} resizeMode="contain" />
|
|
</PressableScale>
|
|
<PressableScale onPress={handleOpenOptions}>
|
|
<RNImage source={icons.more} style={{ ...size({ size: 24 }) }} resizeMode="contain" />
|
|
</PressableScale>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* PLAYER SLIDER & CONTROLS */}
|
|
{songUrl && (
|
|
<View style={{ paddingTop: 22 }}>
|
|
<ProgressSlider
|
|
positionMs={positionMs}
|
|
durationMs={sliderDurationMs}
|
|
isPlaying={isPlaying}
|
|
onSeek={handleSliderSeek}
|
|
onPause={pause}
|
|
onPlay={resume}
|
|
disabled={!songUrl}
|
|
/>
|
|
<View style={{ ...Style.containerRow, gap: 24, alignSelf: 'center' }}>
|
|
<Pressable onPress={() => seekBy(-10000)}>
|
|
<RNImage source={icons.forward} style={{ ...size({ size: 30 }) }} />
|
|
</Pressable>
|
|
<Pressable
|
|
style={{ ...size({ size: 40 }), alignItems: 'center', justifyContent: 'center' }}
|
|
onPress={togglePlay}
|
|
>
|
|
<RNImage resizeMode={'contain'} source={isPlaying ? icons.pause : icons.play} style={size({ size: 34 })} />
|
|
</Pressable>
|
|
<Pressable onPress={() => seekBy(10000)}>
|
|
<RNImage source={icons.forward} style={{ ...size({ size: 30 }), transform: [{ rotate: '180deg' }] }} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* LYRICS SECTION */}
|
|
<View style={{ flex: 1, marginTop: 20 }}>
|
|
{sections.length > 0 ? (
|
|
<ScrollView ref={lyricsRef} style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 40 }}>
|
|
{sections.map((section, sIdx) => (
|
|
<View key={section.key} style={[styles.sectionContainer, sIdx > 0 && styles.sectionSpacing]}>
|
|
<Text style={styles.sectionTitle}>{section.label}{'\n'}</Text>
|
|
{section.lines.map((line) => (
|
|
<View
|
|
key={line.globalIdx}
|
|
onLayout={(e) => { lineYRef.current[line.globalIdx] = e.nativeEvent.layout.y }}
|
|
style={{ marginBottom: 8, flexDirection: 'row', flexWrap: 'wrap' }}
|
|
>
|
|
{line.words.map((w, j) => (
|
|
<Text
|
|
key={j}
|
|
style={visibleTimeS >= w.startS ? styles.karaokeWordActive : styles.karaokeWord}
|
|
onPress={() => handleLyricsSeek(w.startS)}
|
|
>
|
|
{w.word}{' '}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
))}
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
) : (
|
|
<Text style={{ color: 'rgba(255,255,255,0.4)', textAlign: 'center', marginTop: 40, fontFamily: FONT_FAMILY.InterRegular }}>
|
|
{songUrl ? "Chargement des paroles..." : "Aucune parole disponible"}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
export default MusicDetails
|
|
|
|
const styles = StyleSheet.create({
|
|
img: {
|
|
width: 160,
|
|
height: 160,
|
|
borderRadius: 24,
|
|
alignSelf: 'center',
|
|
},
|
|
title: {
|
|
fontSize: 20,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
|
|
width: responsiveWidth(70),
|
|
marginBottom: responsiveHeight(1),
|
|
},
|
|
name: {
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
|
},
|
|
karaokeWord: {
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 16,
|
|
},
|
|
karaokeWordActive: {
|
|
color: Palette.primary,
|
|
fontFamily: FONT_FAMILY.InterBold,
|
|
fontSize: 16,
|
|
},
|
|
sectionContainer: {
|
|
marginBottom: 16,
|
|
},
|
|
sectionSpacing: {
|
|
marginTop: 12,
|
|
},
|
|
sectionTitle: {
|
|
color: Palette.transparentWhite,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 12,
|
|
letterSpacing: 0.4,
|
|
textTransform: 'uppercase',
|
|
marginBottom: 6,
|
|
},
|
|
})
|