Files
musicland/src/screens/Library/MusicDetails.js
T
2026-09-03 11:06:08 +02:00

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,
},
})