automatic scroll

This commit is contained in:
2025-09-03 15:48:44 +02:00
parent 8f1c062b33
commit 0b9bbcd8b0
+144 -62
View File
@@ -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 });
}
>
{p}
</Text>
);
} }
buf = [];
start = null;
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> if (eolByNewline || eolByPause || eolByPunct || isLast) {
); const joined = clean(buf.map((b) => b.text).join(" "));
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
words: buf,
});
buf = [];
start = null;
} }
} }
return out;
return (
<Text style={styles.karaokeContainer} selectable>
{spans}
</Text>
);
}; };
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,22 +463,60 @@ const MusicDetails = () => {
</View> </View>
</View> </View>
)} )}
<View style={{ marginTop: 30, gap: 20 }}> <View style={{ flex: 1, marginTop: 20 }}>
{alignedWords?.length ? ( {lines?.length ? (
renderKaraokeFullLyrics() <ScrollView
) : description?.length > 0 ? ( ref={lyricsRef}
<Text style={{ flex: 1 }}
style={{ contentContainerStyle={{ paddingBottom: 40 }}
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
> >
{description} {lines.map((L, i) => (
</Text> <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 ? (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40 }}
>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{description}
</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",
}, },
}); });