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),
[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 }) => {
</View>
)}
<View style={{ flex: 1, marginTop: 20 }}>
{lines?.length ? (
{sections.length ? (
<ScrollView
ref={lyricsRef}
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40 }}
>
{lines.map((L, i) => (
{sections.map((section, sectionIdx) => (
<View
key={`line-${i}`}
onLayout={(e) => {
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) => (
<Text
key={`w-${i}-${j}`}
style={
visibleTimeS >= (w.startS || 0)
? styles.karaokeWordActive
: styles.karaokeWord
}
onPress={() => handleLyricsSeek(w.startS)}
<Text style={styles.sectionTitle}>
{section.label}
{"\n"}
</Text>
{section.lines.map((line) => (
<View
key={`line-${line.globalIdx}`}
onLayout={(e) => {
lineYRef.current[line.globalIdx] =
e.nativeEvent.layout.y;
}}
style={{
marginBottom: 8,
flexDirection: "row",
flexWrap: "wrap",
}}
>
{w.text}
{j < (L.words?.length || 1) - 1 ? " " : ""}
</Text>
{(line.words || []).map((w, j) => (
<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>
))}
@@ -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,
},
});