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"} + + )}