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,
+ },
});