From 5cd6741ae661a72dd56b9ae891ab3f3ffb36f41e Mon Sep 17 00:00:00 2001 From: leon-morival Date: Mon, 6 Oct 2025 16:48:14 +0200 Subject: [PATCH] display by couplet --- src/screens/Library/MusicDetails.js | 288 ++++++++++++++++++----- src/screens/Library/MusicDetails.web.js | 293 +++++++++++++++++++----- 2 files changed, 455 insertions(+), 126 deletions(-) diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index a289355..b5ed6a0 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -247,63 +247,91 @@ const MusicDetails = ({ route }) => { () => Math.max(0, currentTimeS + 0.5), [currentTimeS] ); - // Group words into timed lines (similar to KaraokeLyrics) + // 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; - const clean = (txt) => - String(txt || "") - .replace(/\s+/g, " ") - .trim(); - const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim()); - const isSectionTag = (txt) => - /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim()); 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; + let working = textNoNewline; - 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), - }); + 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; } - if (!clean(textNoTag)) continue; + const cleaned = cleanText(removeTags ? working : textNoNewline); + if (!cleaned) continue; + + if (buf.length === 0) start = Number(w.startS || 0); buf.push({ - text: textNoTag, + 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(textNoTag); + const eolByPunct = isSentenceEnd(cleaned); const isLast = i === words.length - 1; if (eolByNewline || eolByPause || eolByPunct || isLast) { - const joined = clean(buf.map((b) => b.text).join(" ")); + const joined = cleanText(buf.map((b) => b.text).join(" ")); if (joined) out.push({ text: joined, @@ -318,21 +346,127 @@ const MusicDetails = ({ route }) => { return out; }; - const lines = useMemo( - () => groupAlignedWordsToLines(alignedWords, { removeTags: true }), - [alignedWords] + 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 (!lines || lines.length === 0) return -1; - for (let i = 0; i < lines.length; i++) { - const L = lines[i]; + 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 > (lines[lines.length - 1]?.endS || 0)) - return lines.length - 1; + if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0)) + return flatLines.length - 1; return -1; - }, [lines, visibleTimeS]); + }, [flatLines, visibleTimeS]); // Auto-scroll lyrics to keep the current line visible const lyricsRef = useRef(null); @@ -494,38 +628,52 @@ const MusicDetails = ({ route }) => { )} - {lines?.length ? ( + {sections.length ? ( - {lines.map((L, i) => ( + {sections.map((section, sectionIdx) => ( { - lineYRef.current[i] = e.nativeEvent.layout.y; - }} - style={{ - marginBottom: 8, - flexDirection: "row", - flexWrap: "wrap", - // justifyContent: "", - }} + key={section.key} + style={[ + styles.sectionContainer, + sectionIdx > 0 ? styles.sectionSpacing : null, + ]} > - {(L.words || []).map((w, j) => ( - = (w.startS || 0) - ? styles.karaokeWordActive - : styles.karaokeWord - } - onPress={() => handleLyricsSeek(w.startS)} + + {section.label} + {"\n"} + + {section.lines.map((line) => ( + { + lineYRef.current[line.globalIdx] = + e.nativeEvent.layout.y; + }} + style={{ + marginBottom: 8, + flexDirection: "row", + flexWrap: "wrap", + }} > - {w.text} - {j < (L.words?.length || 1) - 1 ? " " : ""} - + {(line.words || []).map((w, j) => ( + = (w.startS || 0) + ? styles.karaokeWordActive + : styles.karaokeWord + } + onPress={() => handleLyricsSeek(w.startS)} + > + {w.text} + {j < (line.words?.length || 1) - 1 ? " " : ""} + + ))} + ))} ))} @@ -587,4 +735,18 @@ const styles = StyleSheet.create({ 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, + }, }); diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index e9044c7..9fc3820 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -344,63 +344,93 @@ const MusicDetails = ({ route }) => { () => Math.max(0, currentTimeS + 0.5), [currentTimeS] ); - // Group words into timed lines (similar to KaraokeLyrics) + // 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; - const clean = (txt) => - String(txt || "") - .replace(/\s+/g, " ") - .trim(); - const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim()); - const isSectionTag = (txt) => - /^\s*\[[^\]]+\]\s*$/i.test((txt || "").trim()); 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; + let working = textNoNewline; - 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), - }); + 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; } - if (!clean(textNoTag)) continue; + const cleaned = cleanText(removeTags ? working : textNoNewline); + if (!cleaned) continue; + + if (buf.length === 0) start = Number(w.startS || 0); buf.push({ - text: textNoTag, + 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(textNoTag); + const eolByPunct = isSentenceEnd(cleaned); const isLast = i === words.length - 1; if (eolByNewline || eolByPause || eolByPunct || isLast) { - const joined = clean(buf.map((b) => b.text).join(" ")); + const joined = cleanText(buf.map((b) => b.text).join(" ")); if (joined) out.push({ text: joined, @@ -415,21 +445,129 @@ const MusicDetails = ({ route }) => { return out; }; - const lines = useMemo( - () => groupAlignedWordsToLines(alignedWords, { removeTags: true }), - [alignedWords] + 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 (!lines || lines.length === 0) return -1; - for (let i = 0; i < lines.length; i++) { - const L = lines[i]; + 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 > (lines[lines.length - 1]?.endS || 0)) - return lines.length - 1; + if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0)) + return flatLines.length - 1; return -1; - }, [lines, visibleTimeS]); + }, [flatLines, visibleTimeS]); // Auto-scroll lyrics to keep the current line visible const lyricsRef = useRef(null); @@ -626,7 +764,7 @@ const MusicDetails = ({ route }) => { {/* Right column: lyrics */} - {lines.length > 0 && ( + {(sections.length > 0 || description?.length > 0) && ( { maxWidth: "50%", }} > - {lines?.length ? ( + {sections.length ? ( - {lines.map((L, i) => ( + {sections.map((section, sectionIdx) => ( { - lineYRef.current[i] = e.nativeEvent.layout.y; - }} - style={{ - marginBottom: 8, - flexDirection: "row", - flexWrap: "wrap", - }} + key={section.key} + style={[ + styles.sectionContainer, + sectionIdx > 0 ? styles.sectionSpacing : null, + ]} > - {(L.words || []).map((w, j) => ( - = (w.startS || 0) - ? styles.karaokeWordActive - : styles.karaokeWord - } - onPress={() => handleLyricsSeek(w.startS)} + + {section.label} + {"\n"} + + {section.lines.map((line) => ( + { + lineYRef.current[line.globalIdx] = + e.nativeEvent.layout.y; + }} + style={{ + marginBottom: 8, + flexDirection: "row", + flexWrap: "wrap", + }} > - {w.text} - {j < (L.words?.length || 1) - 1 ? " " : ""} - + {(line.words || []).map((w, j) => ( + = (w.startS || 0) + ? styles.karaokeWordActive + : styles.karaokeWord + } + onPress={() => handleLyricsSeek(w.startS)} + > + {w.text} + {j < (line.words?.length || 1) - 1 ? " " : ""} + + ))} + ))} ))} @@ -736,4 +889,18 @@ const styles = StyleSheet.create({ 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, + }, });