import { BlurView } from "expo-blur"; import { Image as ExpoImage } from "expo-image"; import { Feather } from "@expo/vector-icons"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Pressable, Image as RNImage, Platform, ScrollView, StyleSheet, Text, View, useWindowDimensions, } 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 { ensureAuthenticated } from "../../utils/authRedirect"; import useDataFromRef from "../../hooks/useDataFromRef"; import useTrackController from "../../hooks/useTrackController"; import usePlayer from "../../hooks/usePlayer"; 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 { formatStructureLabel, getPromptLabelForStructure, getSegmentMeta, normalizeStructureType, segmentRequiresLyrics, } from "../../utils/songStructure"; import { createMusicSharePayload, openShareSheet, } from "../../utils/shareSheet"; import { goBack } from "../../navigation/NavigationService"; // 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 [, setTooltip] = useGlobal("_tooltip"); const wasPlayingBeforeSeek = React.useRef(false); const lastSeekTargetMs = React.useRef(null); const listenedMsRef = React.useRef(0); const incrementDoneRef = React.useRef(false); const timerRef = React.useRef(null); const hasAutoPlayedRef = React.useRef(false); const { isLooping, setLooping } = usePlayer() || {}; const isWeb = Platform.OS === "web"; const { width: windowWidth = 0 } = useWindowDimensions(); const isCompactLayout = (windowWidth || 0) < 1200; const handleBackPress = useCallback(() => { goBack(); }, []); const renderWebBackButton = useCallback(() => { if (!isWeb) return null; return ( Retour ); }, [handleBackPress, isWeb]); 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 = 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( () => createMusicSharePayload({ projectId, title, artist, }), [projectId, title, artist], ); const handleShare = useCallback(() => { if (!sharePayload) return; openShareSheet(sharePayload); }, [sharePayload]); const [isDownloading, setIsDownloading] = useState(false); const [downloadProgress, setDownloadProgress] = useState(0); const handleDownload = useCallback(async () => { if (!songUrl || isDownloading) return; try { setIsDownloading(true); setDownloadProgress(0); const response = await fetch(songUrl); if (!response.ok) { throw new Error(`download_failed_${response.status}`); } const contentType = response.headers.get("content-type") || "audio/mpeg"; const total = Number(response.headers.get("content-length")) || 0; const sanitizedTitle = String(title || "musicland-track") .replace(/[\\/:*?"<>|]+/g, "-") .trim() .slice(0, 80); const fileName = `${sanitizedTitle || "musicland-track"}.mp3`; if (response.body && typeof response.body.getReader === "function") { const reader = response.body.getReader(); const chunks = []; let received = 0; let pseudoProgress = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); received += value.length; if (total > 0) { setDownloadProgress(Math.min(1, received / total)); } else { pseudoProgress = Math.min(0.95, pseudoProgress + 0.05); setDownloadProgress(pseudoProgress); } } } const blob = new Blob(chunks, { type: contentType }); setDownloadProgress(1); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } else { const blob = await response.blob(); setDownloadProgress(1); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } } catch (error) { console.error("MusicDetails.download", error); setTooltip({ type: "error", text: "Téléchargement impossible", }); } finally { setIsDownloading(false); setTimeout(() => setDownloadProgress(0), 400); } }, [isDownloading, setTooltip, songUrl, title]); 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 handleOpenOptions = useCallback(() => { SheetManager.show("MusicOptions", { payload: { projectId, title, ownerId: project?.userId || owner?.id || null, onReport: handleReport, onAddToPlaylist: handleAddToPlaylist, onDownload: handleDownload, downloadDisabled: isDownloading || !songUrl, onShare: sharePayload ? handleShare : null, sharePayload, shareDisabled: !sharePayload, }, }); }, [ handleAddToPlaylist, handleDownload, handleReport, handleShare, isDownloading, 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 isPlaying = isTrackPlaying; 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 (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 { console.log("[MusicDetails.web] handleSliderSeekStart", { isCurrentTrack, isTrackPlaying, positionMs, durationMs, }); lastSeekTargetMs.current = null; 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, lastSeekTargetMs, durationMs, ]); const handleSliderSeek = useCallback( async (ratio) => { const dur = sliderDurationMs || 0; if (!trackDescriptor || dur <= 0) return; const targetMs = Math.max(0, Math.floor(dur * ratio)); lastSeekTargetMs.current = targetMs; console.log("[MusicDetails.web] handleSliderSeek", { ratio, durationMs: dur, targetMs, isCurrentTrack, }); try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }); } else { await seekTrackTo(targetMs); } } 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 (!isPlaying) { await resumeTrack(); } } catch (e) { console.log("MusicDetails.web loop toggle error", e?.message); } } }, [ isCurrentTrack, loopEnabled, setLooping, seekTrackTo, isPlaying, 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 (!isPlaying) { await resumeTrack(); } hasAutoPlayedRef.current = true; } catch (e) { console.log("MusicDetails autoplay error", e?.message); } }; run(); }, [ autoPlayRequested, ensureLoaded, isCurrentTrack, isPlaying, resumeTrack, songUrl, trackDescriptor, ]); const handleSliderSeekEnd = useCallback(async () => { const targetMs = typeof lastSeekTargetMs.current === "number" ? Math.max(0, lastSeekTargetMs.current) : null; console.log("[MusicDetails.web] handleSliderSeekEnd", { targetMs, wasPlayingBeforeSeek: wasPlayingBeforeSeek.current, isCurrentTrack, positionMs, }); try { if (wasPlayingBeforeSeek.current) { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: targetMs !== null && Number.isFinite(targetMs) ? targetMs : positionMs, autoPlay: true, }); } else { if (targetMs !== null && Number.isFinite(targetMs)) { await seekTrackTo(targetMs); } await resumeTrack(); } } } catch (e) { console.log("MusicDetails seek end error", e?.message); } finally { wasPlayingBeforeSeek.current = false; lastSeekTargetMs.current = null; } }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]); 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)) { const counts = {}; return project.lyrics .map((s) => { const type = normalizeStructureType(s?.type); const requiresLyrics = segmentRequiresLyrics(type); const body = (s?.lyrics || "").trim(); if (requiresLyrics && !body) return null; counts[type] = (counts[type] || 0) + 1; const label = formatStructureLabel(type, counts[type]); const displayLabel = typeof label === "string" && label.length > 0 ? label : getPromptLabelForStructure(type); if (!requiresLyrics) { return `[${displayLabel}] — section instrumentale`; } return `[${displayLabel}]\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")) { if (lower.includes("long")) type = "long_intro"; else type = "short_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.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 sectionYRef = useRef({}); const lineRelativeYRef = useRef({}); const lineYRef = useRef({}); const updateLineAbsoluteOffset = useCallback((lineIdx) => { const entry = lineRelativeYRef.current?.[lineIdx]; if (!entry) return; const sectionY = sectionYRef.current?.[entry.sectionKey]; if (typeof sectionY !== "number") return; lineYRef.current[lineIdx] = sectionY + entry.relativeY; }, []); const registerLineRelativeOffset = useCallback( (lineIdx, sectionKey, relativeY) => { lineRelativeYRef.current[lineIdx] = { sectionKey, relativeY }; updateLineAbsoluteOffset(lineIdx); }, [updateLineAbsoluteOffset], ); const registerSectionOffset = useCallback( (sectionKey, y, lines = []) => { sectionYRef.current[sectionKey] = y; lines.forEach((line) => updateLineAbsoluteOffset(line.globalIdx)); }, [updateLineAbsoluteOffset], ); 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) 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)}> )} {/* Right column: lyrics */} {(sections.length > 0 || description?.length > 0) && ( {sections.length ? ( {sections.map((section, sectionIdx) => ( registerSectionOffset( section.key, e.nativeEvent?.layout?.y, section.lines, ) } style={[ styles.sectionContainer, sectionIdx > 0 ? styles.sectionSpacing : null, ]} > {section.label} {"\n"} {section.lines.map((line) => ( { registerLineRelativeOffset( line.globalIdx, section.key, e.nativeEvent?.layout?.y ?? 0, ); }} 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({ webBackContainer: { alignSelf: "flex-start", marginBottom: 12, }, webBackButton: { flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", paddingHorizontal: 14, paddingVertical: 7, borderRadius: 999, borderWidth: 1, borderColor: Palette.ultraLightWhite, backgroundColor: Palette.ultraLightWhite, }, webBackIcon: { width: 16, height: 16, transform: [{ rotate: "90deg" }], }, webBackText: { fontSize: 14, color: Palette.white, fontFamily: FONT_FAMILY.InterSemiBold, }, 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, }, });