diff --git a/functions/src/music.js b/functions/src/music.js
index 178b407..6ed78f0 100644
--- a/functions/src/music.js
+++ b/functions/src/music.js
@@ -411,7 +411,9 @@ exports.getSunoTimestamps = onCall(async ({ data = {} }) => {
.doc(projectId)
.set(
{
- [`musicTimestamps.${index}`]: dataToReturn,
+ musicTimestamps: {
+ [musicIndex]: dataToReturn,
+ },
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
diff --git a/src/components/KaraokeLyrics.js b/src/components/KaraokeLyrics.js
new file mode 100644
index 0000000..4259444
--- /dev/null
+++ b/src/components/KaraokeLyrics.js
@@ -0,0 +1,144 @@
+import React, { useMemo } from "react";
+import { Text, View } from "react-native";
+import { Palette } from "../styles";
+import { FONT_FAMILY } from "../styles/Fonts";
+
+export function groupAlignedWordsToLines(alignedWords = [], { 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 < alignedWords.length; i++) {
+ const w = alignedWords[i] || {};
+ const original = String(w.word || "");
+ const textNoNewline = original.replace(/\n/g, " ");
+ const textNoTag = removeTags
+ ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "")
+ : textNoNewline;
+ const next = alignedWords[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 (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),
+ });
+ }
+ buf = [];
+ start = null;
+ continue;
+ }
+
+ if (!clean(textNoTag)) {
+ continue;
+ }
+
+ buf.push(textNoTag);
+
+ const eolByNewline = /\n/.test(original);
+ const eolByPause = gapToNext >= 0.6; // threshold for a logical break
+ const eolByPunct = isSentenceEnd(textNoTag);
+ const isLast = i === alignedWords.length - 1;
+
+ if (eolByNewline || eolByPause || eolByPunct || isLast) {
+ const joined = clean(buf.join(" "));
+ if (joined)
+ out.push({
+ text: joined,
+ startS: start ?? Number(w.startS || 0),
+ endS: Number(w.endS || 0),
+ });
+ buf = [];
+ start = null;
+ }
+ }
+ return out;
+}
+
+export default function KaraokeLyrics({
+ alignedWords = [],
+ currentTimeS = 0,
+ showContext = true,
+ removeTags = true,
+}) {
+ const lines = useMemo(
+ () => groupAlignedWordsToLines(alignedWords, { removeTags }),
+ [alignedWords, removeTags]
+ );
+
+ 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]);
+
+ if (!lines.length) return null;
+
+ const prev =
+ showContext && currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
+ const curr =
+ currentLineIdx >= 0 ? lines[currentLineIdx]?.text : lines[0]?.text;
+ const next =
+ showContext && currentLineIdx + 1 < lines.length
+ ? lines[currentLineIdx + 1]?.text
+ : "";
+
+ return (
+
+ {/* {prev ? (
+
+ {prev}
+
+ ) : null} */}
+
+ {curr}
+
+ {next ? (
+
+ {next}
+
+ ) : null}
+
+ );
+}
diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js
index 75368f8..21638df 100644
--- a/src/screens/Playback/Playback.js
+++ b/src/screens/Playback/Playback.js
@@ -24,7 +24,7 @@ const Playback = ({ route }) => {
return;
// return navigate(Routes.RecordPlayback, { project });
}
- if (project?.[`musicTimestamps.${songIndex}`]) {
+ if (project?.musicTimestamps?.[songIndex]) {
console.log("exists");
return navigate(Routes.RecordPlayback, { project });
}
diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js
index 3745d99..e5d0c64 100644
--- a/src/screens/Playback/RecordPlayback.js
+++ b/src/screens/Playback/RecordPlayback.js
@@ -12,6 +12,7 @@ import { Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle } from "react-native-svg";
import GradientButton from "../../components/GradientButton";
+import KaraokeLyrics from "../../components/KaraokeLyrics";
import MusicLandHeader from "../../components/MusicLandHeader";
import { increment, projectsRef } from "../../config/firebase";
import { Routes } from "../../navigation";
@@ -60,15 +61,67 @@ const RecordPlayback = ({ route }) => {
}, [songIndex]);
const alignedWords = useMemo(() => {
- const ts = project?.[`musicTimestamps.${songIndex}`];
- null;
+ const ts = project?.musicTimestamps?.[musicIndex];
+
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
return arr.map((w) => ({
- word: String(w?.word ?? "").replace(/\n/g, " "),
+ word: String(w?.word ?? ""),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
}));
}, [project?.musicTimestamps, musicIndex]);
+
+ // Build line groups to display line-by-line
+ const lines = useMemo(() => {
+ 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 < alignedWords.length; i++) {
+ const w = alignedWords[i];
+ const original = String(w.word || "");
+ const textNoNewline = original.replace(/\n/g, " ");
+ const gapToNext =
+ i < alignedWords.length - 1
+ ? Math.max(0, alignedWords[i + 1].startS - w.endS)
+ : 0;
+
+ // If buffer empty, mark start
+ if (buf.length === 0) start = w.startS;
+
+ // Section tag becomes its own line
+ if (isSectionTag(textNoNewline)) {
+ const joined = clean(textNoNewline);
+ if (joined)
+ out.push({ text: joined, startS: start ?? w.startS, endS: w.endS });
+ buf = [];
+ start = null;
+ continue;
+ }
+
+ buf.push(textNoNewline);
+
+ const eolByNewline = /\n/.test(original);
+ const eolByPause = gapToNext >= 0.6;
+ const eolByPunct = isSentenceEnd(textNoNewline);
+ const isLast = i === alignedWords.length - 1;
+
+ if (eolByNewline || eolByPause || eolByPunct || isLast) {
+ const joined = clean(buf.join(" "));
+ if (joined)
+ out.push({ text: joined, startS: start ?? w.startS, endS: w.endS });
+ buf = [];
+ start = null;
+ }
+ }
+ return out;
+ }, [alignedWords]);
// console.log("alignedWords:", alignedWords);
useEffect(() => {
listenedMsRef.current = 0;
@@ -88,18 +141,18 @@ const RecordPlayback = ({ route }) => {
return () => clearInterval(id);
}, [player]);
- // Compute current word index from playback time
+ // Compute current line index from playback time
const currentTimeS = (progressInfo?.pos || 0) / 1000;
- const currentIdx = useMemo(() => {
- if (!alignedWords || alignedWords.length === 0) return -1;
- for (let i = 0; i < alignedWords.length; i++) {
- const w = alignedWords[i];
- if (currentTimeS >= w.startS && currentTimeS <= w.endS) return i;
+ 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 && currentTimeS <= L.endS) return i;
}
- if (currentTimeS > (alignedWords[alignedWords.length - 1]?.endS || 0))
- return alignedWords.length - 1;
+ if (currentTimeS > (lines[lines.length - 1]?.endS || 0))
+ return lines.length - 1;
return -1;
- }, [alignedWords, currentTimeS]);
+ }, [lines, currentTimeS]);
// Permissions au mount + cleanup
useEffect(() => {
@@ -442,7 +495,10 @@ const RecordPlayback = ({ route }) => {
>
{(isRecording || showProgress) && alignedWords?.length > 0 ? (
-
+
) : (
{
export default RecordPlayback;
-// Simple karaoke line renderer
-const KaraokeLine = ({ words = [], currentIdx = -1 }) => {
- const windowSize = 8;
- const start = Math.max(0, currentIdx - 2);
- const end = Math.min(
- words.length,
- currentIdx >= 0 ? currentIdx + windowSize : windowSize
- );
- const slice = words.slice(start, end);
+// Render previous/current/next line to reduce jumpiness
+const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
+ const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : "";
+ const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : "";
+ const next =
+ currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : "";
return (
-
- {slice.map((w, i) => {
- const idx = start + i;
- const text = String(w.word || "").replace(/\s+/g, " ");
- const isCurrent = idx === currentIdx;
- return (
-
- {text + " "}
-
- );
- })}
-
+
+ {/* {prev ? (
+
+ {prev}
+
+ ) : null} */}
+
+ {curr}
+
+ {next ? (
+
+ {next}
+
+ ) : null}
+
);
};
diff --git a/src/screens/Playbacks.js b/src/screens/Playbacks.js
index e34ba0d..8f6f323 100644
--- a/src/screens/Playbacks.js
+++ b/src/screens/Playbacks.js
@@ -13,6 +13,7 @@ import { Image, Platform, Pressable, Text, View } from "react-native";
import Carousel from "react-native-reanimated-carousel";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { icons, img } from "../assets";
+import KaraokeLyrics from "../components/KaraokeLyrics";
import {
arrayRemove,
arrayUnion,
@@ -163,6 +164,34 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
};
}, [audioPlayer, videoPlayer]);
+ // Build alignedWords from item musicTimestamps
+ const alignedWords = useMemo(() => {
+ const idx = Number(item?.songIndex) || 0;
+ const ts = item?.musicTimestamps?.[idx];
+
+ const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
+ return arr.map((w) => ({
+ word: String(w?.word ?? ""),
+ startS: Number(w?.startS ?? 0),
+ endS: Number(w?.endS ?? 0),
+ }));
+ }, [item?.musicTimestamps, item?.songIndex]);
+
+ // Track current time for lyrics sync
+ const [currentTimeS, setCurrentTimeS] = useState(0);
+ useEffect(() => {
+ if (!isActive) return;
+ const id = setInterval(() => {
+ try {
+ const t = hasExternalAudio
+ ? Number(audioPlayer?.currentTime || 0)
+ : Number(videoPlayer?.currentTime || 0);
+ setCurrentTimeS(t);
+ } catch (e) {}
+ }, 250);
+ return () => clearInterval(id);
+ }, [isActive, hasExternalAudio, audioPlayer, videoPlayer]);
+
return (
{
Platform.OS !== "ios" ? "dimezisBlurView" : "none"
}
>
-
- {item?.title || "Description chanson"}
-
+ {alignedWords?.length > 0 ? (
+
+ ) : (
+
+ {item?.title || "Description chanson"}
+
+ )}