import { BlurView } from "expo-blur"; import { Image as ExpoImage } from "expo-image"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { 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 Slider from "../../components/Slider"; import { arrayRemove, arrayUnion, increment, projectsRef, usersRef, } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; 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"; // 20 secondes const timeBeforeIncrement = 20000; const MusicDetails = ({ route }) => { const { params } = route || {}; const action = params?.action; const projectId = params?.projectId || null; const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); const wasPlayingBeforeSeek = React.useRef(false); const listenedMsRef = React.useRef(0); const incrementDoneRef = React.useRef(false); const timerRef = React.useRef(null); const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, simpleRef: true, listener: true, condition: !!projectId, }); console.log("project is : ", project); 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 = owner?.userName || "MusicLand"; 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 { isCurrent: isCurrentTrack, isPlaying: isTrackPlaying, positionMs, durationMs, ensureLoaded, pause: pauseTrack, resume: resumeTrack, seekTo: seekTrackTo, seekBy: seekTrackBy, } = useTrackController(trackDescriptor); const isPlaying = isTrackPlaying; // 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]); // Reset counters when the track changes useEffect(() => { listenedMsRef.current = 0; incrementDoneRef.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 (isPlaying && 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; }, [isPlaying, projectId]); const fmt = (ms) => { const total = Math.max(0, Math.floor((ms || 0) / 1000)); const m = Math.floor(total / 60) .toString() .padStart(1, "0"); const s = (total % 60).toString().padStart(2, "0"); return `${m}:${s}`; }; 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 audio error", e?.message); } }, [ trackDescriptor, isCurrentTrack, ensureLoaded, positionMs, isTrackPlaying, pauseTrack, resumeTrack, ]); const handleSliderSeekStart = useCallback(async () => { if (!trackDescriptor) return; try { wasPlayingBeforeSeek.current = isTrackPlaying; if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: positionMs, autoPlay: false, }); } if (isTrackPlaying) { await pauseTrack(); } } catch (e) { console.log("MusicDetails seek start error", e?.message); } }, [ trackDescriptor, isTrackPlaying, isCurrentTrack, ensureLoaded, positionMs, pauseTrack, ]); const handleSliderSeek = useCallback( async (ratio) => { const dur = durationMs || 0; if (!trackDescriptor || dur <= 0) return; const targetMs = Math.max(0, Math.floor(dur * ratio)); try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }); } else { await seekTrackTo(targetMs); } } catch (e) { console.log("MusicDetails seek error", e?.message); } }, [ trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo, ] ); const handleSliderSeekEnd = useCallback(async () => { try { if (wasPlayingBeforeSeek.current) { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: positionMs, autoPlay: true, }); } else { await resumeTrack(); } } } catch (e) { console.log("MusicDetails seek end error", e?.message); } finally { wasPlayingBeforeSeek.current = false; } }, [ensureLoaded, isCurrentTrack, positionMs, 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 .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("bridge")) type = "bridge"; 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; if (type === "refrain") return index > 1 ? `Refrain ${index}` : "Refrain"; if (type === "couplet") return index > 1 ? `Couplet ${index}` : "Couplet"; if (type === "bridge") return index > 1 ? `Pont ${index}` : "Pont"; if (type === "intro") return "Intro"; 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.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")}> ), })} > {/* Left column: player */} {coverUrl ? ( ) : ( // empty container )} {title} {artist} { if (!projectId || !currentUID) return; const next = !fav; setFav(next); try { await projectsRef.doc(projectId).update({ likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), }); } catch (e) { setFav(!next); } }} > SheetManager.show("Playlist", { payload: { projectId } }) } > {songUrl && ( {/* Previous (rewind 10s) */} handleSeekBySeconds(-10)}> {/* Play / Pause */} {/* Next (forward 10s) */} handleSeekBySeconds(10)}> )} {/* Right column: lyrics */} {(sections.length > 0 || description?.length > 0) && ( {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: 200, height: 200, borderRadius: 24, alignSelf: "center", }, title: { fontSize: 20, color: Palette.white, fontFamily: FONT_FAMILY.HelveticaNeueMedium, maxWidth: responsiveWidth(70), flexShrink: 1, 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, cursor: "pointer", }, karaokeWordActive: { color: Palette.primary, fontFamily: FONT_FAMILY.InterBold, cursor: "pointer", }, sectionContainer: { marginBottom: 16, }, sectionSpacing: { marginTop: 12, }, sectionTitle: { color: Palette.transparentWhite, fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 12, letterSpacing: 0.4, textTransform: "uppercase", marginBottom: 6, }, });