display by couplet

This commit is contained in:
2025-10-06 16:48:14 +02:00
parent 8537673144
commit 5cd6741ae6
2 changed files with 455 additions and 126 deletions
+225 -63
View File
@@ -247,63 +247,91 @@ const MusicDetails = ({ route }) => {
() => Math.max(0, currentTimeS + 0.5), () => Math.max(0, currentTimeS + 0.5),
[currentTimeS] [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 groupAlignedWordsToLines = (words = [], { removeTags = true } = {}) => {
const out = []; const out = [];
let buf = []; let buf = [];
let start = null; 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++) { for (let i = 0; i < words.length; i++) {
const w = words[i] || {}; const w = words[i] || {};
const original = String(w.word || ""); const original = String(w.word || "");
const textNoNewline = original.replace(/\n/g, " "); const textNoNewline = original.replace(/\n/g, " ");
const textNoTag = removeTags let working = textNoNewline;
? 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;
if (buf.length === 0) start = Number(w.startS || 0); if (removeTags) {
while (true) {
if (isSectionTag(textNoNewline)) { const match = working.match(SECTION_TAG_LEADING_REGEX);
if (!removeTags) { if (!match) break;
const joined = clean(textNoNewline); working = working.slice(match[0].length);
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
});
} }
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 = []; buf = [];
start = null; start = null;
continue; 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({ buf.push({
text: textNoTag, text: cleaned,
startS: Number(w.startS || 0), startS: Number(w.startS || 0),
endS: Number(w.endS || 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 eolByNewline = /\n/.test(original);
const eolByPause = gapToNext >= 0.6; // logical break const eolByPause = gapToNext >= 0.6; // logical break
const eolByPunct = isSentenceEnd(textNoTag); const eolByPunct = isSentenceEnd(cleaned);
const isLast = i === words.length - 1; const isLast = i === words.length - 1;
if (eolByNewline || eolByPause || eolByPunct || isLast) { 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) if (joined)
out.push({ out.push({
text: joined, text: joined,
@@ -318,21 +346,127 @@ const MusicDetails = ({ route }) => {
return out; return out;
}; };
const lines = useMemo( const sections = useMemo(() => {
() => groupAlignedWordsToLines(alignedWords, { removeTags: true }), const counts = {};
[alignedWords] 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(() => { const currentLineIdx = useMemo(() => {
if (!lines || lines.length === 0) return -1; if (!flatLines || flatLines.length === 0) return -1;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < flatLines.length; i++) {
const L = lines[i]; const L = flatLines[i];
if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0)) if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0))
return i; return i;
} }
if (visibleTimeS > (lines[lines.length - 1]?.endS || 0)) if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0))
return lines.length - 1; return flatLines.length - 1;
return -1; return -1;
}, [lines, visibleTimeS]); }, [flatLines, visibleTimeS]);
// Auto-scroll lyrics to keep the current line visible // Auto-scroll lyrics to keep the current line visible
const lyricsRef = useRef(null); const lyricsRef = useRef(null);
@@ -494,38 +628,52 @@ const MusicDetails = ({ route }) => {
</View> </View>
)} )}
<View style={{ flex: 1, marginTop: 20 }}> <View style={{ flex: 1, marginTop: 20 }}>
{lines?.length ? ( {sections.length ? (
<ScrollView <ScrollView
ref={lyricsRef} ref={lyricsRef}
style={{ flex: 1 }} style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40 }} contentContainerStyle={{ paddingBottom: 40 }}
> >
{lines.map((L, i) => ( {sections.map((section, sectionIdx) => (
<View <View
key={`line-${i}`} key={section.key}
onLayout={(e) => { style={[
lineYRef.current[i] = e.nativeEvent.layout.y; styles.sectionContainer,
}} sectionIdx > 0 ? styles.sectionSpacing : null,
style={{ ]}
marginBottom: 8,
flexDirection: "row",
flexWrap: "wrap",
// justifyContent: "",
}}
> >
{(L.words || []).map((w, j) => ( <Text style={styles.sectionTitle}>
<Text {section.label}
key={`w-${i}-${j}`} {"\n"}
style={ </Text>
visibleTimeS >= (w.startS || 0) {section.lines.map((line) => (
? styles.karaokeWordActive <View
: styles.karaokeWord key={`line-${line.globalIdx}`}
} onLayout={(e) => {
onPress={() => handleLyricsSeek(w.startS)} lineYRef.current[line.globalIdx] =
e.nativeEvent.layout.y;
}}
style={{
marginBottom: 8,
flexDirection: "row",
flexWrap: "wrap",
}}
> >
{w.text} {(line.words || []).map((w, j) => (
{j < (L.words?.length || 1) - 1 ? " " : ""} <Text
</Text> key={`w-${line.globalIdx}-${j}`}
style={
visibleTimeS >= (w.startS || 0)
? styles.karaokeWordActive
: styles.karaokeWord
}
onPress={() => handleLyricsSeek(w.startS)}
>
{w.text}
{j < (line.words?.length || 1) - 1 ? " " : ""}
</Text>
))}
</View>
))} ))}
</View> </View>
))} ))}
@@ -587,4 +735,18 @@ const styles = StyleSheet.create({
color: Palette.primary, color: Palette.primary,
fontFamily: FONT_FAMILY.InterBold, 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,
},
}); });
+230 -63
View File
@@ -344,63 +344,93 @@ const MusicDetails = ({ route }) => {
() => Math.max(0, currentTimeS + 0.5), () => Math.max(0, currentTimeS + 0.5),
[currentTimeS] [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 groupAlignedWordsToLines = (words = [], { removeTags = true } = {}) => {
const out = []; const out = [];
let buf = []; let buf = [];
let start = null; 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++) { for (let i = 0; i < words.length; i++) {
const w = words[i] || {}; const w = words[i] || {};
const original = String(w.word || ""); const original = String(w.word || "");
const textNoNewline = original.replace(/\n/g, " "); const textNoNewline = original.replace(/\n/g, " ");
const textNoTag = removeTags let working = textNoNewline;
? 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;
if (buf.length === 0) start = Number(w.startS || 0); if (removeTags) {
while (true) {
if (isSectionTag(textNoNewline)) { const match = working.match(SECTION_TAG_LEADING_REGEX);
if (!removeTags) { if (!match) break;
const joined = clean(textNoNewline); working = working.slice(match[0].length);
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
});
} }
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 = []; buf = [];
start = null; start = null;
continue; 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({ buf.push({
text: textNoTag, text: cleaned,
startS: Number(w.startS || 0), startS: Number(w.startS || 0),
endS: Number(w.endS || 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 eolByNewline = /\n/.test(original);
const eolByPause = gapToNext >= 0.6; // logical break const eolByPause = gapToNext >= 0.6; // logical break
const eolByPunct = isSentenceEnd(textNoTag); const eolByPunct = isSentenceEnd(cleaned);
const isLast = i === words.length - 1; const isLast = i === words.length - 1;
if (eolByNewline || eolByPause || eolByPunct || isLast) { 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) if (joined)
out.push({ out.push({
text: joined, text: joined,
@@ -415,21 +445,129 @@ const MusicDetails = ({ route }) => {
return out; return out;
}; };
const lines = useMemo( const sections = useMemo(() => {
() => groupAlignedWordsToLines(alignedWords, { removeTags: true }), const counts = {};
[alignedWords] 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(() => { const currentLineIdx = useMemo(() => {
if (!lines || lines.length === 0) return -1; if (!flatLines || flatLines.length === 0) return -1;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < flatLines.length; i++) {
const L = lines[i]; const L = flatLines[i];
if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0)) if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0))
return i; return i;
} }
if (visibleTimeS > (lines[lines.length - 1]?.endS || 0)) if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0))
return lines.length - 1; return flatLines.length - 1;
return -1; return -1;
}, [lines, visibleTimeS]); }, [flatLines, visibleTimeS]);
// Auto-scroll lyrics to keep the current line visible // Auto-scroll lyrics to keep the current line visible
const lyricsRef = useRef(null); const lyricsRef = useRef(null);
@@ -626,7 +764,7 @@ const MusicDetails = ({ route }) => {
</BlurView> </BlurView>
{/* Right column: lyrics */} {/* Right column: lyrics */}
{lines.length > 0 && ( {(sections.length > 0 || description?.length > 0) && (
<BlurView <BlurView
intensity={40} intensity={40}
style={{ style={{
@@ -640,37 +778,52 @@ const MusicDetails = ({ route }) => {
maxWidth: "50%", maxWidth: "50%",
}} }}
> >
{lines?.length ? ( {sections.length ? (
<ScrollView <ScrollView
ref={lyricsRef} ref={lyricsRef}
style={{ flex: 1 }} style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40, height: 400 }} contentContainerStyle={{ paddingBottom: 40, height: 400 }}
> >
{lines.map((L, i) => ( {sections.map((section, sectionIdx) => (
<View <View
key={`line-${i}`} key={section.key}
onLayout={(e) => { style={[
lineYRef.current[i] = e.nativeEvent.layout.y; styles.sectionContainer,
}} sectionIdx > 0 ? styles.sectionSpacing : null,
style={{ ]}
marginBottom: 8,
flexDirection: "row",
flexWrap: "wrap",
}}
> >
{(L.words || []).map((w, j) => ( <Text style={styles.sectionTitle}>
<Text {section.label}
key={`w-${i}-${j}`} {"\n"}
style={ </Text>
visibleTimeS >= (w.startS || 0) {section.lines.map((line) => (
? styles.karaokeWordActive <View
: styles.karaokeWord key={`line-${line.globalIdx}`}
} onLayout={(e) => {
onPress={() => handleLyricsSeek(w.startS)} lineYRef.current[line.globalIdx] =
e.nativeEvent.layout.y;
}}
style={{
marginBottom: 8,
flexDirection: "row",
flexWrap: "wrap",
}}
> >
{w.text} {(line.words || []).map((w, j) => (
{j < (L.words?.length || 1) - 1 ? " " : ""} <Text
</Text> key={`w-${line.globalIdx}-${j}`}
style={
visibleTimeS >= (w.startS || 0)
? styles.karaokeWordActive
: styles.karaokeWord
}
onPress={() => handleLyricsSeek(w.startS)}
>
{w.text}
{j < (line.words?.length || 1) - 1 ? " " : ""}
</Text>
))}
</View>
))} ))}
</View> </View>
))} ))}
@@ -736,4 +889,18 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterBold, fontFamily: FONT_FAMILY.InterBold,
cursor: "pointer", 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,
},
}); });