display by couplet
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user