From 0b9bbcd8b0d89e2392d19f3a51092840834d7284 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Wed, 3 Sep 2025 15:48:44 +0200 Subject: [PATCH] automatic scroll --- src/screens/Library/MusicDetails.js | 206 +++++++++++++++++++--------- 1 file changed, 144 insertions(+), 62 deletions(-) diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index 430f4cf..9123dfb 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -1,7 +1,7 @@ import { useRoute } from "@react-navigation/core"; import { useAudioPlayer } from "expo-audio"; import { Image as ExpoImage } from "expo-image"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Pressable, Image as RNImage, @@ -217,53 +217,105 @@ const MusicDetails = () => { () => Math.max(0, (progressInfo.pos || 0) / 1000), [progressInfo.pos] ); + // Group words into timed lines (similar to KaraokeLyrics) + const groupAlignedWordsToLines = (words = [], { removeTags = true } = {}) => { + const out = []; + let buf = []; + let start = null; + const clean = (txt) => + String(txt || "") + .replace(/\s+/g, " ") + .trim(); + const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim()); + const isSectionTag = (txt) => + /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim()); - // Render full lyrics with the currently sung word highlighted - const renderKaraokeFullLyrics = () => { - if (!alignedWords?.length) return null; - const spans = []; - for (let i = 0; i < alignedWords.length; i++) { - const w = alignedWords[i]; - const text = String(w.word || ""); - // Highlight all words that already started (and keep them lit) - const isHighlighted = currentTimeS >= (w.startS || 0); + for (let i = 0; i < words.length; i++) { + const w = words[i] || {}; + const original = String(w.word || ""); + const textNoNewline = original.replace(/\n/g, " "); + const textNoTag = removeTags + ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "") + : textNoNewline; + const next = words[i + 1] || null; + const gapToNext = next + ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) + : 0; - // split by newline to preserve line breaks - const parts = text.split(/(\n+)/); - parts.forEach((p, j) => { - if (/^\n+$/.test(p)) { - spans.push({"\n"}); - } else if (p.length > 0) { - spans.push( - - {p} - - ); + if (buf.length === 0) start = Number(w.startS || 0); + + if (isSectionTag(textNoNewline)) { + if (!removeTags) { + const joined = clean(textNoNewline); + if (joined) + out.push({ + text: joined, + startS: start ?? Number(w.startS || 0), + endS: Number(w.endS || 0), + }); } + buf = []; + start = null; + continue; + } + + if (!clean(textNoTag)) continue; + + buf.push({ + text: textNoTag, + startS: Number(w.startS || 0), + endS: Number(w.endS || 0), }); - // Add space between words when no newline - if (!/\n/.test(text) && i < alignedWords.length - 1) { - spans.push( - - {" "} - - ); + const eolByNewline = /\n/.test(original); + const eolByPause = gapToNext >= 0.6; // logical break + const eolByPunct = isSentenceEnd(textNoTag); + const isLast = i === words.length - 1; + + if (eolByNewline || eolByPause || eolByPunct || isLast) { + const joined = clean(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 ( - - {spans} - - ); + return out; }; + const lines = useMemo( + () => groupAlignedWordsToLines(alignedWords, { removeTags: true }), + [alignedWords] + ); + const currentLineIdx = useMemo(() => { + if (!lines || lines.length === 0) return -1; + for (let i = 0; i < lines.length; i++) { + const L = lines[i]; + if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) + return i; + } + if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) + return lines.length - 1; + return -1; + }, [lines, currentTimeS]); + + // 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 ( { ), })} > - + {coverUrl && ( { )} - - {alignedWords?.length ? ( - renderKaraokeFullLyrics() - ) : description?.length > 0 ? ( - + {lines?.length ? ( + - {description} - + {lines.map((L, i) => ( + { + lineYRef.current[i] = e.nativeEvent.layout.y; + }} + style={{ + marginBottom: 8, + flexDirection: "row", + flexWrap: "wrap", + // justifyContent: "", + }} + > + {(L.words || []).map((w, j) => ( + = (w.startS || 0) + ? styles.karaokeWordActive + : styles.karaokeWord + } + > + {w.text} + {j < (L.words?.length || 1) - 1 ? " " : ""} + + ))} + + ))} + + ) : description?.length > 0 ? ( + + + {description} + + ) : null} - + ); }; @@ -469,9 +553,7 @@ const styles = StyleSheet.create({ fontFamily: FONT_FAMILY.InterRegular, }, karaokeWordActive: { - color: Palette.white, + color: Palette.primary, fontFamily: FONT_FAMILY.InterBold, - fontStyle: "italic", - fontWeight: "bold", }, });