automatic scroll
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useRoute } from "@react-navigation/core";
|
import { useRoute } from "@react-navigation/core";
|
||||||
import { useAudioPlayer } from "expo-audio";
|
import { useAudioPlayer } from "expo-audio";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
@@ -217,53 +217,105 @@ const MusicDetails = () => {
|
|||||||
() => Math.max(0, (progressInfo.pos || 0) / 1000),
|
() => Math.max(0, (progressInfo.pos || 0) / 1000),
|
||||||
[progressInfo.pos]
|
[progressInfo.pos]
|
||||||
);
|
);
|
||||||
|
// Group words into timed lines (similar to KaraokeLyrics)
|
||||||
|
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());
|
||||||
|
|
||||||
// Render full lyrics with the currently sung word highlighted
|
for (let i = 0; i < words.length; i++) {
|
||||||
const renderKaraokeFullLyrics = () => {
|
const w = words[i] || {};
|
||||||
if (!alignedWords?.length) return null;
|
const original = String(w.word || "");
|
||||||
const spans = [];
|
const textNoNewline = original.replace(/\n/g, " ");
|
||||||
for (let i = 0; i < alignedWords.length; i++) {
|
const textNoTag = removeTags
|
||||||
const w = alignedWords[i];
|
? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "")
|
||||||
const text = String(w.word || "");
|
: textNoNewline;
|
||||||
// Highlight all words that already started (and keep them lit)
|
const next = words[i + 1] || null;
|
||||||
const isHighlighted = currentTimeS >= (w.startS || 0);
|
const gapToNext = next
|
||||||
|
? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0))
|
||||||
|
: 0;
|
||||||
|
|
||||||
// split by newline to preserve line breaks
|
if (buf.length === 0) start = Number(w.startS || 0);
|
||||||
const parts = text.split(/(\n+)/);
|
|
||||||
parts.forEach((p, j) => {
|
if (isSectionTag(textNoNewline)) {
|
||||||
if (/^\n+$/.test(p)) {
|
if (!removeTags) {
|
||||||
spans.push(<Text key={`br-${i}-${j}`}>{"\n"}</Text>);
|
const joined = clean(textNoNewline);
|
||||||
} else if (p.length > 0) {
|
if (joined)
|
||||||
spans.push(
|
out.push({
|
||||||
<Text
|
text: joined,
|
||||||
key={`w-${i}-${j}`}
|
startS: start ?? Number(w.startS || 0),
|
||||||
style={
|
endS: Number(w.endS || 0),
|
||||||
isHighlighted ? styles.karaokeWordActive : styles.karaokeWord
|
});
|
||||||
}
|
}
|
||||||
>
|
buf = [];
|
||||||
{p}
|
start = null;
|
||||||
</Text>
|
continue;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!clean(textNoTag)) continue;
|
||||||
|
|
||||||
|
buf.push({
|
||||||
|
text: textNoTag,
|
||||||
|
startS: Number(w.startS || 0),
|
||||||
|
endS: Number(w.endS || 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add space between words when no newline
|
const eolByNewline = /\n/.test(original);
|
||||||
if (!/\n/.test(text) && i < alignedWords.length - 1) {
|
const eolByPause = gapToNext >= 0.6; // logical break
|
||||||
spans.push(
|
const eolByPunct = isSentenceEnd(textNoTag);
|
||||||
<Text key={`sp-${i}`} style={styles.karaokeWord}>
|
const isLast = i === words.length - 1;
|
||||||
{" "}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (eolByNewline || eolByPause || eolByPunct || isLast) {
|
||||||
<Text style={styles.karaokeContainer} selectable>
|
const joined = clean(buf.map((b) => b.text).join(" "));
|
||||||
{spans}
|
if (joined)
|
||||||
</Text>
|
out.push({
|
||||||
);
|
text: joined,
|
||||||
|
startS: start ?? Number(w.startS || 0),
|
||||||
|
endS: Number(w.endS || 0),
|
||||||
|
words: buf,
|
||||||
|
});
|
||||||
|
buf = [];
|
||||||
|
start = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lines = useMemo(
|
||||||
|
() => groupAlignedWordsToLines(alignedWords, { removeTags: true }),
|
||||||
|
[alignedWords]
|
||||||
|
);
|
||||||
|
const currentLineIdx = useMemo(() => {
|
||||||
|
if (!lines || lines.length === 0) return -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const L = lines[i];
|
||||||
|
if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0))
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
|
||||||
|
return lines.length - 1;
|
||||||
|
return -1;
|
||||||
|
}, [lines, currentTimeS]);
|
||||||
|
|
||||||
|
// Auto-scroll lyrics to keep the current line visible
|
||||||
|
const lyricsRef = useRef(null);
|
||||||
|
const lineYRef = useRef({});
|
||||||
|
useEffect(() => {
|
||||||
|
const y = lineYRef.current?.[currentLineIdx];
|
||||||
|
if (lyricsRef.current && typeof y === "number") {
|
||||||
|
try {
|
||||||
|
lyricsRef.current.scrollTo({ y: Math.max(0, y - 80), animated: true });
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}, [currentLineIdx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
headerType="NAVIGATION"
|
headerType="NAVIGATION"
|
||||||
@@ -282,13 +334,7 @@ const MusicDetails = () => {
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<View style={{ flex: 1, paddingTop: 20, paddingBottom: gutters * 2 }}>
|
||||||
contentContainerStyle={{
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingTop: 20,
|
|
||||||
paddingBottom: gutters * 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ gap: 28 }}>
|
<View style={{ gap: 28 }}>
|
||||||
{coverUrl && (
|
{coverUrl && (
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
@@ -417,10 +463,47 @@ const MusicDetails = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={{ marginTop: 30, gap: 20 }}>
|
<View style={{ flex: 1, marginTop: 20 }}>
|
||||||
{alignedWords?.length ? (
|
{lines?.length ? (
|
||||||
renderKaraokeFullLyrics()
|
<ScrollView
|
||||||
|
ref={lyricsRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
|
{lines.map((L, i) => (
|
||||||
|
<View
|
||||||
|
key={`line-${i}`}
|
||||||
|
onLayout={(e) => {
|
||||||
|
lineYRef.current[i] = e.nativeEvent.layout.y;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
// justifyContent: "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(L.words || []).map((w, j) => (
|
||||||
|
<Text
|
||||||
|
key={`w-${i}-${j}`}
|
||||||
|
style={
|
||||||
|
currentTimeS >= (w.startS || 0)
|
||||||
|
? styles.karaokeWordActive
|
||||||
|
: styles.karaokeWord
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{w.text}
|
||||||
|
{j < (L.words?.length || 1) - 1 ? " " : ""}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
) : description?.length > 0 ? (
|
) : description?.length > 0 ? (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -430,9 +513,10 @@ const MusicDetails = () => {
|
|||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -469,9 +553,7 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
},
|
},
|
||||||
karaokeWordActive: {
|
karaokeWordActive: {
|
||||||
color: Palette.white,
|
color: Palette.primary,
|
||||||
fontFamily: FONT_FAMILY.InterBold,
|
fontFamily: FONT_FAMILY.InterBold,
|
||||||
fontStyle: "italic",
|
|
||||||
fontWeight: "bold",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user