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' import { formatStructureLabel, getPromptLabelForStructure, getSegmentMeta, normalizeStructureType, } from '../../utils/songStructure' // 20 secondes 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 { isLooping, setLooping } = usePlayer() || {} 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, }) useEffect(() => { if (project && currentUID) { const liked = Array.isArray(project?.likedBy) ? project.likedBy.includes(currentUID) : false setFav(liked) } }, [project?.likedBy, currentUID]) 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 const trackId = useMemo(() => { if (projectId) return `project-${projectId}` if (songUrl) return `song-${songUrl}` return null }, [projectId, songUrl]) const trackDescriptor = useMemo(() => { if (!trackId || !songUrl) return null return { id: trackId, uri: songUrl, songUrl, title, artist, artwork: coverUrl, coverUrl, metadata: { projectId, screen: 'MusicDetails' }, context: { projectId, screen: 'MusicDetails' }, } }, [trackId, songUrl, title, artist, coverUrl, projectId]) const sharePayload = useMemo(() => { return createMusicSharePayload({ projectId, title, artist, }) }, [artist, projectId, title]) const handleShare = useCallback(() => { if (!sharePayload) return openShareSheet(sharePayload) }, [sharePayload]) const handleReport = useCallback(() => { if (!projectId) return SheetManager.show('Report', { payload: { targetType: 'music', projectId, title, ownerId: project?.userId || owner?.id || null, }, }) }, [owner?.id, project?.userId, projectId, title]) const handleAddToPlaylist = useCallback(() => { if (projectId) { SheetManager.show('Playlist', { payload: { projectId } }) } else { SheetManager.show('Playlist') } }, [projectId]) const handleDownload = useCallback(async () => { if (!songUrl) return try { await Linking.openURL(songUrl) } catch (error) { console.log('MusicDetails download error', error?.message) } }, [songUrl]) const handleOpenOptions = useCallback(() => { SheetManager.show('MusicOptions', { payload: { projectId, title, ownerId: project?.userId || owner?.id || null, onReport: handleReport, onAddToPlaylist: handleAddToPlaylist, onDownload: handleDownload, downloadDisabled: !songUrl, onShare: sharePayload ? handleShare : null, sharePayload, shareDisabled: !sharePayload, }, }) }, [ handleAddToPlaylist, handleDownload, handleReport, handleShare, owner?.id, project?.userId, projectId, sharePayload, songUrl, title, ]) const { isCurrent: isCurrentTrack, isPlaying: isTrackPlaying, positionMs, durationMs, ensureLoaded, pause: pauseTrack, resume: resumeTrack, seekTo: seekTrackTo, seekBy: seekTrackBy, } = useTrackController(trackDescriptor) const loopEnabled = isCurrentTrack && !!isLooping // Karaoke aligned words (from timestamps) const alignedWords = useMemo(() => { try { const idx = Number(project?.songIndex) || 0 const ts = project?.musicTimestamps?.[idx] const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [] return arr.map((w) => ({ word: String(w?.word ?? ''), startS: Number(w?.startS ?? 0), endS: Number(w?.endS ?? 0), })) } catch (e) { return [] } }, [project?.musicTimestamps, project?.songIndex]) const estimatedDurationMs = useMemo(() => { const toMs = (value) => { const num = Number(value) if (!Number.isFinite(num) || num <= 0) return 0 return num > 1000 ? Math.round(num) : Math.round(num * 1000) } const idx = Number(project?.songIndex) const normalizedIdx = Number.isFinite(idx) && idx >= 0 ? idx : 0 const tsEntry = project?.musicTimestamps?.[normalizedIdx] if (tsEntry && typeof tsEntry === 'object') { const fromMetadata = toMs(tsEntry.durationMs) || toMs(tsEntry.duration) || toMs(tsEntry.durationS) || toMs(tsEntry.durationSeconds) || toMs(tsEntry.audioDuration) || toMs(tsEntry.audioLength) if (fromMetadata > 0) return fromMetadata } let maxEndS = 0 for (let i = 0; i < alignedWords.length; i++) { const word = alignedWords[i] const end = Number(word?.endS ?? word?.startS ?? 0) if (Number.isFinite(end) && end > maxEndS) { maxEndS = end } } return maxEndS > 0 ? Math.round(maxEndS * 1000) : 0 }, [alignedWords, project?.musicTimestamps, project?.songIndex]) const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs // Reset counters when the track changes useEffect(() => { listenedMsRef.current = 0 incrementDoneRef.current = false hasAutoPlayedRef.current = false }, [trackId]) // Start/stop a timer to accumulate listened milliseconds while playing useEffect(() => { const clearTimer = () => { if (timerRef.current) { global.clearInterval(timerRef.current) timerRef.current = null } } if (isTrackPlaying && projectId) { if (!timerRef.current) { timerRef.current = global.setInterval(async () => { try { listenedMsRef.current += 500 if ( !incrementDoneRef.current && listenedMsRef.current >= timeBeforeIncrement && projectId ) { incrementDoneRef.current = true await projectsRef.doc(projectId).set({ views: increment(1) }, { merge: true }) } } catch (e) { // Silent fail for counter } }, 500) } } else { clearTimer() } return clearTimer }, [isTrackPlaying, projectId]) const togglePlay = useCallback(async () => { if (!trackDescriptor) return try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: positionMs, autoPlay: true }) return } if (isTrackPlaying) { await pauseTrack() } else { await resumeTrack() } } catch (e) { console.log('MusicDetails toggle error', e?.message) } }, [ trackDescriptor, isCurrentTrack, ensureLoaded, positionMs, isTrackPlaying, pauseTrack, resumeTrack, ]) const handleSliderSeekStart = useCallback(() => { if (!trackDescriptor) return }, [trackDescriptor]) const handleSliderSeek = useCallback( async (targetMs) => { const dur = sliderDurationMs || 0 if (!trackDescriptor || dur <= 0) return const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs))) try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: bounded, autoPlay: false }) } else { await seekTrackTo(bounded) } } catch (e) { console.log('MusicDetails seek error', e?.message) } }, [trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo] ) const handleToggleLoop = useCallback(async () => { if (!isCurrentTrack) return const next = !loopEnabled if (typeof setLooping === 'function') { setLooping(next) } if (next) { try { await seekTrackTo(0) if (!isTrackPlaying) { await resumeTrack() } } catch (e) { console.log('MusicDetails loop toggle error', e?.message) } } }, [isCurrentTrack, loopEnabled, setLooping, seekTrackTo, isTrackPlaying, resumeTrack]) useEffect(() => { if (!autoPlayRequested || hasAutoPlayedRef.current) return if (!trackDescriptor || !songUrl) return const run = async () => { try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: 0, autoPlay: true }) } else if (!isTrackPlaying) { await resumeTrack() } hasAutoPlayedRef.current = true } catch (e) { console.log('MusicDetails autoplay error', e?.message) } } run() }, [ autoPlayRequested, trackDescriptor, songUrl, isCurrentTrack, ensureLoaded, isTrackPlaying, resumeTrack, ]) const handleSeekBySeconds = useCallback( async (deltaSeconds) => { if (!trackDescriptor) return const deltaMs = Number(deltaSeconds || 0) * 1000 const target = Math.max(0, (positionMs || 0) + deltaMs) try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: target, autoPlay: true }) } else { await seekTrackBy(deltaMs) } } catch (e) { console.log('MusicDetails seekBy error', e?.message) } }, [trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy] ) const handleLyricsSeek = useCallback( async (timestampS) => { if (!trackDescriptor || typeof timestampS !== 'number' || Number.isNaN(timestampS)) return const targetMs = Math.max(0, Number(timestampS) * 1000) try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: targetMs, autoPlay: true }) return } await seekTrackTo(targetMs) if (!isTrackPlaying) { await resumeTrack() } } catch (e) { console.log('MusicDetails lyrics seek error', e?.message) } }, [trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo, isTrackPlaying, resumeTrack] ) const description = useMemo(() => { // Build a readable text from lyrics with section labels if (Array.isArray(project?.lyrics)) { return project.lyrics .filter((s) => { const t = (s?.type || '').toLowerCase() return ['couplet', 'refrain'].includes(t) }) .map((s) => { const body = (s?.lyrics || '').trim() if (!body) return null const t = (s?.type || '').toLowerCase() const label = t === 'refrain' ? 'Refrain' : 'Couplet' return `[${label}]\n${body}` }) .filter(Boolean) .join('\n\n') } const c = project?.lyrics?.couplet const r = project?.lyrics?.refrain const parts = [c ? `[Couplet]\n${c}` : null, r ? `[Refrain]\n${r}` : null].filter(Boolean) return parts.length ? parts.join('\n\n') : '' }, [project]) // Current time in seconds for highlighting const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs]) // Preview lead: show words 0.5s earlier const visibleTimeS = useMemo(() => Math.max(0, currentTimeS + 0.5), [currentTimeS]) // Helpers to group aligned words into sections and timed lines const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i const cleanText = (txt) => String(txt || '') .replace(/\s+/g, ' ') .trim() const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()) const stripSectionTag = (txt) => String(txt || '').replace(SECTION_TAG_REGEX, '') const parseSectionTag = (txt) => { const match = String(txt || '').match(SECTION_TAG_REGEX) if (!match) return null const label = match[1]?.trim() || '' const lower = label.toLowerCase() let type = 'section' if (lower.includes('refrain') || lower.includes('chorus')) type = 'refrain' else if (lower.includes('couplet') || lower.includes('verse')) type = 'couplet' else if ( lower.includes('pré') || lower.includes('prechorus') || lower.includes('pre-chorus') || lower.includes('pre chorus') || lower.includes('pre-refrain') ) type = 'pre_refrain_instrumental' else if (lower.includes('pont') || lower.includes('bridge')) type = 'pont' else if (lower.includes('intro')) type = 'intro' const indexMatch = label.match(/(\d+)/) const index = indexMatch ? Number(indexMatch[1]) : undefined return { label, type, index } } const formatSectionLabel = (type, index, fallback) => { if (fallback) return fallback const normalized = normalizeStructureType(type) const meta = getSegmentMeta(normalized) if (meta) { const label = formatStructureLabel(normalized, index) if (label) return label } const prompt = getPromptLabelForStructure(normalized) if (prompt) return prompt return index > 1 ? `Section ${index}` : 'Section' } const groupAlignedWordsToLines = (words = [], { removeTags = true } = {}) => { const out = [] let buf = [] let start = null for (let i = 0; i < words.length; i++) { const w = words[i] || {} const original = String(w.word || '') const textNoNewline = original.replace(/\n/g, ' ') let working = textNoNewline if (removeTags) { while (true) { const match = working.match(SECTION_TAG_LEADING_REGEX) if (!match) break working = working.slice(match[0].length) } working = stripSectionTag(working) } else if (SECTION_TAG_REGEX.test(textNoNewline)) { const joined = cleanText(textNoNewline) if (joined) out.push({ text: joined, startS: start ?? Number(w.startS || 0), endS: Number(w.endS || 0), }) buf = [] start = null continue } const cleaned = cleanText(removeTags ? working : textNoNewline) if (!cleaned) continue if (buf.length === 0) start = Number(w.startS || 0) buf.push({ text: cleaned, startS: Number(w.startS || 0), endS: Number(w.endS || 0), }) const next = words[i + 1] || null const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0 const eolByNewline = /\n/.test(original) const eolByPause = gapToNext >= 0.6 // logical break const eolByPunct = isSentenceEnd(cleaned) const isLast = i === words.length - 1 if (eolByNewline || eolByPause || eolByPunct || isLast) { const joined = cleanText(buf.map((b) => b.text).join(' ')) if (joined) out.push({ text: joined, startS: start ?? Number(w.startS || 0), endS: Number(w.endS || 0), words: buf, }) buf = [] start = null } } return out } const sections = useMemo(() => { const counts = {} const grouped = [] let current = null const startSection = (tagMeta) => { const type = tagMeta?.type || 'section' let index if (tagMeta?.index !== undefined && Number.isFinite(tagMeta.index) && tagMeta.index > 0) { index = tagMeta.index counts[type] = Math.max(counts[type] || 0, index) } else { counts[type] = (counts[type] || 0) + 1 index = counts[type] } const label = formatSectionLabel(type, index, tagMeta?.label) current = { key: `${type}-${index}-${grouped.length}`, type, index, label, words: [], } } const pushCurrent = () => { if (!current || current.words.length === 0) { current = null return } const lines = groupAlignedWordsToLines(current.words, { removeTags: true, }) if (!lines.length) { current = null return } const startS = lines.reduce( (acc, line) => Math.min(acc, Number.isFinite(line.startS) ? line.startS : acc), Number.POSITIVE_INFINITY ) const endS = lines.reduce((acc, line) => Math.max(acc, Number(line.endS || 0)), 0) grouped.push({ key: current.key, type: current.type, index: current.index, label: current.label, startS: Number.isFinite(startS) ? startS : 0, endS, lines, }) current = null } for (let i = 0; i < alignedWords.length; i++) { const word = alignedWords[i] || {} const raw = String(word.word || '') let working = raw.replace(/\n/g, ' ') let consumedTag = false while (true) { const match = working.match(SECTION_TAG_LEADING_REGEX) if (!match) break const tagMeta = parseSectionTag(`[${match[1]}]`) if (tagMeta) { pushCurrent() startSection(tagMeta) } working = working.slice(match[0].length) consumedTag = true } if (consumedTag && !working.trim()) { continue } const cleanedWord = cleanText(working) if (!cleanedWord) continue if (!current) startSection(null) current.words.push({ ...word, word: cleanedWord, startS: Number(word.startS || 0), endS: Number(word.endS || 0), }) } pushCurrent() let lineIdx = 0 return grouped .filter((s) => ['couplet', 'refrain'].includes(s.type)) .map((section) => ({ ...section, lines: section.lines.map((line) => ({ ...line, globalIdx: lineIdx++, })), })) }, [alignedWords]) const flatLines = useMemo(() => sections.flatMap((section) => section.lines), [sections]) const currentLineIdx = useMemo(() => { if (!flatLines || flatLines.length === 0) return -1 for (let i = 0; i < flatLines.length; i++) { const L = flatLines[i] if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0)) return i } if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0)) return flatLines.length - 1 return -1 }, [flatLines, visibleTimeS]) // Auto-scroll lyrics to keep the current line visible const lyricsRef = useRef(null) const lineYRef = useRef({}) useEffect(() => { const y = lineYRef.current?.[currentLineIdx] if (lyricsRef.current && typeof y === 'number') { try { lyricsRef.current.scrollTo({ y: Math.max(0, y - 80), animated: true }) } catch (e) {} } }, [currentLineIdx]) return ( ( SheetManager.show('DeleteAudio')}> ), })} > {coverUrl && ( )} {title} {artist} { if (!projectId) return if (!ensureAuthenticated(currentUID)) return const next = !fav setFav(next) try { await projectsRef.doc(projectId).update({ likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), }) } catch (e) { setFav(!next) } }} > {songUrl && ( {/* Previous (rewind 10s) */} handleSeekBySeconds(-10)}> {/* Play / Pause */} {/* Next (forward 10s) */} handleSeekBySeconds(10)}> )} {sections.length ? ( {sections.map((section, sectionIdx) => ( 0 ? styles.sectionSpacing : null]} > {section.label} {'\n'} {section.lines.map((line) => ( { lineYRef.current[line.globalIdx] = e.nativeEvent.layout.y }} style={{ marginBottom: 8, flexDirection: 'row', flexWrap: 'wrap', }} > {(line.words || []).map((w, j) => ( = (w.startS || 0) ? styles.karaokeWordActive : styles.karaokeWord } onPress={() => handleLyricsSeek(w.startS)} > {w.text} {j < (line.words?.length || 1) - 1 ? ' ' : ''} ))} ))} ))} ) : description?.length > 0 ? ( {description} ) : null} ) } 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, }, karaokeContainer: { fontSize: 16, color: Palette.white, fontFamily: FONT_FAMILY.InterRegular, lineHeight: 24, }, karaokeWord: { color: Palette.white, fontFamily: FONT_FAMILY.InterRegular, }, karaokeWordActive: { color: Palette.primary, fontFamily: FONT_FAMILY.InterBold, }, sectionContainer: { marginBottom: 16, }, sectionSpacing: { marginTop: 12, }, sectionTitle: { color: Palette.transparentWhite, fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 12, letterSpacing: 0.4, textTransform: 'uppercase', marginBottom: 6, }, })