From dbd52fa28569857874dccc8f0452d3fcdb8c1a0e Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 3 Mar 2026 14:19:06 +0100 Subject: [PATCH] feat: fix lecteur --- src/screens/Library/MusicDetails.js | 868 +++++------------------ src/screens/Library/utils/lyricsUtils.js | 127 ++++ 2 files changed, 298 insertions(+), 697 deletions(-) create mode 100644 src/screens/Library/utils/lyricsUtils.js diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index 661fcc0..7c7bc68 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -27,14 +27,9 @@ import Style, { gutters, size } from '../../styles/Style' import { getArtistDisplayName } from '../../utils/artistName' import { ensureAuthenticated } from '../../utils/authRedirect' import { createMusicSharePayload, openShareSheet } from '../../utils/shareSheet' -import { - formatStructureLabel, - getPromptLabelForStructure, - getSegmentMeta, - normalizeStructureType, -} from '../../utils/songStructure' +// Import de la fonction utilitaire (à créer dans src/utils/lyricsUtils.js) +import { processKaraokeSections } from './utils/lyricsUtils' -// 20 secondes const timeBeforeIncrement = 20000 const MusicDetails = ({ route }) => { @@ -42,14 +37,20 @@ const MusicDetails = ({ route }) => { const action = params?.action const autoPlayRequested = params?.autoPlay const projectId = params?.projectId || null + const [fav, setFav] = useState(false) const [currentUID] = useGlobal('currentUID') + const listenedMsRef = useRef(0) const incrementDoneRef = useRef(false) const timerRef = useRef(null) const hasAutoPlayedRef = useRef(false) + const lyricsRef = useRef(null) + const lineYRef = useRef({}) + const { isLooping, setLooping } = usePlayer() || {} + // --- DATA --- const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, simpleRef: true, @@ -64,32 +65,23 @@ const MusicDetails = ({ route }) => { condition: !!project?.userId, }) - useEffect(() => { - if (project && currentUID) { - const liked = Array.isArray(project?.likedBy) ? project.likedBy.includes(currentUID) : false - setFav(liked) - } - }, [project?.likedBy, currentUID]) - + // --- MEMOS & STABILISATION --- const title = project?.title || 'Sans titre' const artist = useMemo(() => { const ownerName = getArtistDisplayName(owner, '') if (ownerName) return ownerName return getArtistDisplayName(project, 'MusicLand') }, [owner, project]) + const coverUrl = project?.coverUrl || null const songUrl = project?.songUrl || null - const trackId = useMemo(() => { - if (projectId) return `project-${projectId}` - if (songUrl) return `song-${songUrl}` - return null - }, [projectId, songUrl]) - + // IMPORTANT: Stabiliser cet objet pour éviter les sauts iOS const trackDescriptor = useMemo(() => { - if (!trackId || !songUrl) return null - return { - id: trackId, + if (!songUrl) return null + const id = projectId ? `project-${projectId}` : `song-${songUrl}` + const desc = { + id, uri: songUrl, songUrl, title, @@ -99,580 +91,148 @@ const MusicDetails = ({ route }) => { metadata: { projectId, screen: 'MusicDetails' }, context: { projectId, screen: 'MusicDetails' }, } - }, [trackId, songUrl, title, artist, coverUrl, projectId]) - - const sharePayload = useMemo(() => { - return createMusicSharePayload({ - projectId, - title, - artist, - }) - }, [artist, projectId, title]) - - const handleShare = useCallback(() => { - if (!sharePayload) return - openShareSheet(sharePayload) - }, [sharePayload]) - - const handleReport = useCallback(() => { - if (!projectId) return - SheetManager.show('Report', { - payload: { - targetType: 'music', - projectId, - title, - ownerId: project?.userId || owner?.id || null, - }, - }) - }, [owner?.id, project?.userId, projectId, title]) - - const handleAddToPlaylist = useCallback(() => { - if (projectId) { - SheetManager.show('Playlist', { payload: { projectId } }) - } else { - SheetManager.show('Playlist') - } - }, [projectId]) - - const handleDownload = useCallback(async () => { - if (!songUrl) return - try { - await Linking.openURL(songUrl) - } catch (error) { - console.log('MusicDetails download error', error?.message) - } - }, [songUrl]) - - const handleOpenOptions = useCallback(() => { - SheetManager.show('MusicOptions', { - payload: { - projectId, - title, - ownerId: project?.userId || owner?.id || null, - onReport: handleReport, - onAddToPlaylist: handleAddToPlaylist, - onDownload: handleDownload, - downloadDisabled: !songUrl, - onShare: sharePayload ? handleShare : null, - sharePayload, - shareDisabled: !sharePayload, - }, - }) - }, [ - handleAddToPlaylist, - handleDownload, - handleReport, - handleShare, - owner?.id, - project?.userId, - projectId, - sharePayload, - songUrl, - title, - ]) + console.log('[DEBUG PLAYER] Nouveau Track Descriptor:', desc.id) + return desc + }, [projectId, songUrl, title, artist, coverUrl]) + // --- CONTROLLER --- const { - isCurrent: isCurrentTrack, - isPlaying: isTrackPlaying, + isCurrent, + isPlaying, positionMs, durationMs, ensureLoaded, - pause: pauseTrack, - resume: resumeTrack, - seekTo: seekTrackTo, - seekBy: seekTrackBy, + pause, + resume, + seekTo, + seekBy, } = useTrackController(trackDescriptor) - const loopEnabled = isCurrentTrack && !!isLooping - // Karaoke aligned words (from timestamps) - const alignedWords = useMemo(() => { - try { - const idx = Number(project?.songIndex) || 0 - const ts = project?.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), - })) - } catch (e) { - return [] - } + const loopEnabled = isCurrent && !!isLooping + + // --- LOGIQUE LYRICS --- + const sections = useMemo(() => { + const idx = Number(project?.songIndex) || 0 + const ts = project?.musicTimestamps?.[idx] + return processKaraokeSections(ts?.alignedWords || []) }, [project?.musicTimestamps, project?.songIndex]) - const estimatedDurationMs = useMemo(() => { - const toMs = (value) => { - const num = Number(value) - if (!Number.isFinite(num) || num <= 0) return 0 - return num > 1000 ? Math.round(num) : Math.round(num * 1000) - } - const idx = Number(project?.songIndex) - const normalizedIdx = Number.isFinite(idx) && idx >= 0 ? idx : 0 - const tsEntry = project?.musicTimestamps?.[normalizedIdx] - if (tsEntry && typeof tsEntry === 'object') { - const fromMetadata = - toMs(tsEntry.durationMs) || - toMs(tsEntry.duration) || - toMs(tsEntry.durationS) || - toMs(tsEntry.durationSeconds) || - toMs(tsEntry.audioDuration) || - toMs(tsEntry.audioLength) - if (fromMetadata > 0) return fromMetadata - } - let maxEndS = 0 - for (let i = 0; i < alignedWords.length; i++) { - const word = alignedWords[i] - const end = Number(word?.endS ?? word?.startS ?? 0) - if (Number.isFinite(end) && end > maxEndS) { - maxEndS = end - } - } - return maxEndS > 0 ? Math.round(maxEndS * 1000) : 0 - }, [alignedWords, project?.musicTimestamps, project?.songIndex]) + const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections]) - const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs + const currentTimeS = (positionMs || 0) / 1000 + const visibleTimeS = currentTimeS + 0.3 // Offset pour fluidité iOS - // Reset counters when the track changes + const currentLineIdx = useMemo(() => { + if (!flatLines.length) return -1 + return flatLines.findIndex(l => visibleTimeS >= l.startS && visibleTimeS <= l.endS) + }, [flatLines, visibleTimeS]) + + // --- EFFECTS --- + + // Sync favoris useEffect(() => { - listenedMsRef.current = 0 - incrementDoneRef.current = false - hasAutoPlayedRef.current = false - }, [trackId]) - - // Start/stop a timer to accumulate listened milliseconds while playing - useEffect(() => { - const clearTimer = () => { - if (timerRef.current) { - global.clearInterval(timerRef.current) - timerRef.current = null - } + if (project && currentUID) { + setFav(Array.isArray(project.likedBy) ? project.likedBy.includes(currentUID) : false) } - if (isTrackPlaying && projectId) { - if (!timerRef.current) { - timerRef.current = global.setInterval(async () => { - try { - listenedMsRef.current += 500 - if ( - !incrementDoneRef.current && - listenedMsRef.current >= timeBeforeIncrement && - projectId - ) { - incrementDoneRef.current = true - await projectsRef.doc(projectId).set({ views: increment(1) }, { merge: true }) - } - } catch (e) { - // Silent fail for counter - } - }, 500) - } + }, [project?.likedBy, currentUID]) + + // Timer pour les vues + useEffect(() => { + const clearTimer = () => { if (timerRef.current) clearInterval(timerRef.current) } + if (isPlaying && projectId && !incrementDoneRef.current) { + timerRef.current = setInterval(async () => { + listenedMsRef.current += 1000 + if (listenedMsRef.current >= timeBeforeIncrement) { + console.log('[DEBUG] Incrémentation vue pour:', projectId) + incrementDoneRef.current = true + projectsRef.doc(projectId).set({ views: increment(1) }, { merge: true }).catch(() => {}) + clearTimer() + } + }, 1000) } else { clearTimer() } return clearTimer - }, [isTrackPlaying, projectId]) + }, [isPlaying, projectId]) - const togglePlay = useCallback(async () => { - if (!trackDescriptor) return - try { - if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: positionMs, autoPlay: true }) - return - } - if (isTrackPlaying) { - await pauseTrack() - } else { - await resumeTrack() - } - } catch (e) { - console.log('MusicDetails toggle error', e?.message) - } - }, [ - trackDescriptor, - isCurrentTrack, - ensureLoaded, - positionMs, - isTrackPlaying, - pauseTrack, - resumeTrack, - ]) - - const handleSliderSeekStart = useCallback(() => { - if (!trackDescriptor) return - }, [trackDescriptor]) - - const handleSliderSeek = useCallback( - async (targetMs) => { - const dur = sliderDurationMs || 0 - if (!trackDescriptor || dur <= 0) return - const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs))) - try { - if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: bounded, autoPlay: false }) - } else { - await seekTrackTo(bounded) - } - } catch (e) { - console.log('MusicDetails seek error', e?.message) - } - }, - [trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo] - ) - - const handleToggleLoop = useCallback(async () => { - if (!isCurrentTrack) return - const next = !loopEnabled - if (typeof setLooping === 'function') { - setLooping(next) - } - if (next) { - try { - await seekTrackTo(0) - if (!isTrackPlaying) { - await resumeTrack() - } - } catch (e) { - console.log('MusicDetails loop toggle error', e?.message) - } - } - }, [isCurrentTrack, loopEnabled, setLooping, seekTrackTo, isTrackPlaying, resumeTrack]) - - useEffect(() => { - if (!autoPlayRequested || hasAutoPlayedRef.current) return - if (!trackDescriptor || !songUrl) return - - const run = async () => { - try { - if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: 0, autoPlay: true }) - } else if (!isTrackPlaying) { - await resumeTrack() - } - hasAutoPlayedRef.current = true - } catch (e) { - console.log('MusicDetails autoplay error', e?.message) - } - } - - run() - }, [ - autoPlayRequested, - trackDescriptor, - songUrl, - isCurrentTrack, - ensureLoaded, - isTrackPlaying, - resumeTrack, - ]) - - const handleSeekBySeconds = useCallback( - async (deltaSeconds) => { - if (!trackDescriptor) return - const deltaMs = Number(deltaSeconds || 0) * 1000 - const target = Math.max(0, (positionMs || 0) + deltaMs) - try { - if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: target, autoPlay: true }) - } else { - await seekTrackBy(deltaMs) - } - } catch (e) { - console.log('MusicDetails seekBy error', e?.message) - } - }, - [trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy] - ) - - const handleLyricsSeek = useCallback( - async (timestampS) => { - if (!trackDescriptor || typeof timestampS !== 'number' || Number.isNaN(timestampS)) return - const targetMs = Math.max(0, Number(timestampS) * 1000) - - try { - if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: targetMs, autoPlay: true }) - return - } - await seekTrackTo(targetMs) - if (!isTrackPlaying) { - await resumeTrack() - } - } catch (e) { - console.log('MusicDetails lyrics seek error', e?.message) - } - }, - [trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo, isTrackPlaying, resumeTrack] - ) - const description = useMemo(() => { - // Build a readable text from lyrics with section labels - if (Array.isArray(project?.lyrics)) { - return project.lyrics - .filter((s) => { - const t = (s?.type || '').toLowerCase() - return ['couplet', 'refrain'].includes(t) - }) - .map((s) => { - const body = (s?.lyrics || '').trim() - if (!body) return null - const t = (s?.type || '').toLowerCase() - const label = t === 'refrain' ? 'Refrain' : 'Couplet' - return `[${label}]\n${body}` - }) - .filter(Boolean) - .join('\n\n') - } - const c = project?.lyrics?.couplet - const r = project?.lyrics?.refrain - const parts = [c ? `[Couplet]\n${c}` : null, r ? `[Refrain]\n${r}` : null].filter(Boolean) - return parts.length ? parts.join('\n\n') : '' - }, [project]) - - // Current time in seconds for highlighting - const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs]) - // Preview lead: show words 0.5s earlier - const visibleTimeS = useMemo(() => Math.max(0, currentTimeS + 0.5), [currentTimeS]) - // 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('pré') || - lower.includes('prechorus') || - lower.includes('pre-chorus') || - lower.includes('pre chorus') || - lower.includes('pre-refrain') - ) - type = 'pre_refrain_instrumental' - else if (lower.includes('pont') || lower.includes('bridge')) type = 'pont' - 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 - const normalized = normalizeStructureType(type) - const meta = getSegmentMeta(normalized) - if (meta) { - const label = formatStructureLabel(normalized, index) - if (label) return label - } - const prompt = getPromptLabelForStructure(normalized) - if (prompt) return prompt - return index > 1 ? `Section ${index}` : 'Section' - } - - const groupAlignedWordsToLines = (words = [], { removeTags = true } = {}) => { - const out = [] - let buf = [] - let start = null - - for (let i = 0; i < words.length; i++) { - const w = words[i] || {} - const original = String(w.word || '') - const textNoNewline = original.replace(/\n/g, ' ') - let working = textNoNewline - - if (removeTags) { - while (true) { - const match = working.match(SECTION_TAG_LEADING_REGEX) - if (!match) break - working = working.slice(match[0].length) - } - 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 = [] - start = null - continue - } - - const cleaned = cleanText(removeTags ? working : textNoNewline) - if (!cleaned) continue - - if (buf.length === 0) start = Number(w.startS || 0) - - buf.push({ - text: cleaned, - startS: Number(w.startS || 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 eolByPause = gapToNext >= 0.6 // logical break - const eolByPunct = isSentenceEnd(cleaned) - const isLast = i === words.length - 1 - - if (eolByNewline || eolByPause || eolByPunct || isLast) { - const joined = cleanText(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 - } - - const sections = useMemo(() => { - const counts = {} - 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 - .filter((s) => ['couplet', 'refrain'].includes(s.type)) - .map((section) => ({ - ...section, - lines: section.lines.map((line) => ({ - ...line, - globalIdx: lineIdx++, - })), - })) - }, [alignedWords]) - - const flatLines = useMemo(() => sections.flatMap((section) => section.lines), [sections]) - const currentLineIdx = useMemo(() => { - if (!flatLines || flatLines.length === 0) return -1 - for (let i = 0; i < flatLines.length; i++) { - const L = flatLines[i] - if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0)) return i - } - if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0)) return flatLines.length - 1 - return -1 - }, [flatLines, visibleTimeS]) - - // Auto-scroll lyrics to keep the current line visible - const lyricsRef = useRef(null) - const lineYRef = useRef({}) + // Auto-scroll 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) {} + lyricsRef.current.scrollTo({ y: Math.max(0, y - 80), animated: true }) } }, [currentLineIdx]) + // Autoplay requested + useEffect(() => { + if (autoPlayRequested && !hasAutoPlayedRef.current && trackDescriptor) { + const start = async () => { + try { + if (!isCurrent) await ensureLoaded({ startPositionMs: 0, autoPlay: true }) + else if (!isPlaying) await resume() + hasAutoPlayedRef.current = true + } catch (e) { console.log('[PLAYER ERROR] Autoplay:', e.message) } + } + start() + } + }, [autoPlayRequested, trackDescriptor, isCurrent, isPlaying, ensureLoaded, resume]) + + // --- ACTIONS --- + const togglePlay = useCallback(async () => { + if (!trackDescriptor) return + try { + if (!isCurrent) { + console.log('[PLAYER] Chargement nouveau media:', trackDescriptor.title) + await ensureLoaded({ startPositionMs: positionMs, autoPlay: true }) + } else { + isPlaying ? await pause() : await resume() + } + } catch (e) { console.log('[PLAYER ERROR] Toggle:', e.message) } + }, [trackDescriptor, isCurrent, isPlaying, ensureLoaded, positionMs, pause, resume]) + + const handleSliderSeek = useCallback(async (ms) => { + if (!trackDescriptor) return + try { + if (!isCurrent) await ensureLoaded({ startPositionMs: ms, autoPlay: false }) + else await seekTo(Math.floor(ms)) + } catch (e) { console.log('[PLAYER ERROR] Seek:', e.message) } + }, [trackDescriptor, isCurrent, ensureLoaded, seekTo]) + + const handleLyricsSeek = async (timeS) => { + const ms = timeS * 1000 + try { + if (!isCurrent) await ensureLoaded({ startPositionMs: ms, autoPlay: true }) + else { + await seekTo(ms) + if (!isPlaying) await resume() + } + } catch (e) { console.log('[PLAYER ERROR] LyricsSeek:', e.message) } + } + + // --- UI PARTS (Options / Share) --- + const sharePayload = useMemo(() => createMusicSharePayload({ projectId, title, artist }), [artist, projectId, title]) + + const handleOpenOptions = () => { + SheetManager.show('MusicOptions', { + payload: { + projectId, title, ownerId: project?.userId || owner?.id || null, + onReport: () => SheetManager.show('Report', { payload: { targetType: 'music', projectId, title, ownerId: project?.userId } }), + onAddToPlaylist: () => SheetManager.show('Playlist', { payload: { projectId } }), + onDownload: () => songUrl && Linking.openURL(songUrl).catch(() => {}), + downloadDisabled: !songUrl, + onShare: () => sharePayload && openShareSheet(sharePayload), + sharePayload, + }, + }) + } + return ( ( SheetManager.show('DeleteAudio')}> @@ -681,6 +241,7 @@ const MusicDetails = ({ route }) => { })} > + {/* HEADER SECTION */} {coverUrl && ( { /> )} - + {title} {artist} setLooping?.(!isLooping)} + disabled={!isCurrent} + style={{ ...size({ size: 32 }), alignItems: 'center', justifyContent: 'center', opacity: isCurrent ? 1 : 0.4 }} > - + { - if (!projectId) return - if (!ensureAuthenticated(currentUID)) return + if (!projectId || !ensureAuthenticated(currentUID)) return const next = !fav setFav(next) - try { - await projectsRef.doc(projectId).update({ - likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), - }) - } catch (e) { - setFav(!next) - } + projectsRef.doc(projectId).update({ + likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), + }).catch(() => setFav(!next)) }} > - + - + + + {/* PLAYER SLIDER & CONTROLS */} {songUrl && ( - {/* Previous (rewind 10s) */} - handleSeekBySeconds(-10)}> - + seekBy(-10000)}> + - {/* Play / Pause */} - + - {/* Next (forward 10s) */} - handleSeekBySeconds(10)}> - + seekBy(10000)}> + )} + + {/* LYRICS SECTION */} - {sections.length ? ( - - {sections.map((section, sectionIdx) => ( - 0 ? styles.sectionSpacing : null]} - > - - {section.label} - {'\n'} - + {sections.length > 0 ? ( + + {sections.map((section, sIdx) => ( + 0 && styles.sectionSpacing]}> + {section.label}{'\n'} {section.lines.map((line) => ( { - lineYRef.current[line.globalIdx] = e.nativeEvent.layout.y - }} - style={{ - marginBottom: 8, - flexDirection: 'row', - flexWrap: 'wrap', - }} + key={line.globalIdx} + onLayout={(e) => { lineYRef.current[line.globalIdx] = e.nativeEvent.layout.y }} + style={{ marginBottom: 8, flexDirection: 'row', flexWrap: 'wrap' }} > - {(line.words || []).map((w, j) => ( + {line.words.map((w, j) => ( = (w.startS || 0) - ? styles.karaokeWordActive - : styles.karaokeWord - } + key={j} + style={visibleTimeS >= w.startS ? styles.karaokeWordActive : styles.karaokeWord} onPress={() => handleLyricsSeek(w.startS)} > - {w.text} - {j < (line.words?.length || 1) - 1 ? ' ' : ''} + {w.word}{' '} ))} @@ -855,19 +343,9 @@ const MusicDetails = ({ route }) => { ))} - ) : description?.length > 0 ? ( - - - {description} - - - ) : null} + ) : ( + Chargement des paroles... + )} @@ -895,19 +373,15 @@ const styles = StyleSheet.create({ color: Palette.white, fontFamily: FONT_FAMILY.HelveticaNeueRegular, }, - karaokeContainer: { - fontSize: 16, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, - lineHeight: 24, - }, karaokeWord: { color: Palette.white, fontFamily: FONT_FAMILY.InterRegular, + fontSize: 16, }, karaokeWordActive: { color: Palette.primary, fontFamily: FONT_FAMILY.InterBold, + fontSize: 16, }, sectionContainer: { marginBottom: 16, @@ -923,4 +397,4 @@ const styles = StyleSheet.create({ textTransform: 'uppercase', marginBottom: 6, }, -}) +}) \ No newline at end of file diff --git a/src/screens/Library/utils/lyricsUtils.js b/src/screens/Library/utils/lyricsUtils.js new file mode 100644 index 0000000..0a1f34c --- /dev/null +++ b/src/screens/Library/utils/lyricsUtils.js @@ -0,0 +1,127 @@ +/** + * lyricsUtils.js + * Logique de parsing des paroles synchronisées (Karaoké) + */ + +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()) + +/** + * Normalise les noms de sections (Chorus -> Refrain, etc.) + */ +const getSectionLabel = (rawLabel, type, index) => { + const lower = rawLabel.toLowerCase() + if (lower.includes('refrain') || lower.includes('chorus')) + return `Refrain ${index > 1 ? index : ''}` + if (lower.includes('couplet') || lower.includes('verse')) + return `Couplet ${index > 1 ? index : ''}` + if (lower.includes('intro')) return 'Intro' + if (lower.includes('pont') || lower.includes('bridge')) return 'Pont' + return rawLabel || `Section ${index}` +} + +/** + * Détecte le type de section pour le filtrage + */ +const getSectionType = (label) => { + const l = label.toLowerCase() + if (l.includes('refrain') || l.includes('chorus')) return 'refrain' + if (l.includes('couplet') || l.includes('verse')) return 'couplet' + return 'other' +} + +/** + * Transforme une liste de mots synchronisés en structure Sections > Lignes > Mots + */ +export const processKaraokeSections = (alignedWords = []) => { + if (!Array.isArray(alignedWords) || alignedWords.length === 0) return [] + + const sections = [] + let currentSection = null + let currentLineWords = [] + let sectionCounts = {} + let globalLineIdx = 0 + + const flushSection = () => { + if (currentSection) { + // On finit la dernière ligne si nécessaire + if (currentLineWords.length > 0) { + currentSection.lines.push({ + words: [...currentLineWords], + startS: currentLineWords[0].startS, + endS: currentLineWords[currentLineWords.length - 1].endS, + globalIdx: globalLineIdx++, + }) + currentLineWords = [] + } + if (currentSection.lines.length > 0) { + sections.push(currentSection) + } + } + } + + const startNewSection = (rawTag) => { + flushSection() + const type = getSectionType(rawTag) + sectionCounts[type] = (sectionCounts[type] || 0) + 1 + + currentSection = { + key: `section-${sections.length}`, + label: getSectionLabel(rawTag, type, sectionCounts[type]), + type: type, + lines: [], + } + } + + for (let i = 0; i < alignedWords.length; i++) { + const w = alignedWords[i] + let wordText = String(w.word || '') + + // 1. Détection de tag de section [Couplet] + const tagMatch = wordText.match(SECTION_TAG_LEADING_REGEX) + if (tagMatch) { + startNewSection(tagMatch[1]) + wordText = wordText.replace(SECTION_TAG_LEADING_REGEX, '') + } + + if (!currentSection) startNewSection('Musique') + + const cleaned = cleanText(wordText) + if (!cleaned) continue + + const wordObj = { + word: cleaned, + startS: Number(w.startS || 0), + endS: Number(w.endS || 0), + } + + currentLineWords.push(wordObj) + + // 2. Détection de fin de ligne (Ponctuation, saut de ligne ou pause > 0.8s) + const nextWord = alignedWords[i + 1] + const hasPause = nextWord && nextWord.startS - w.endS > 0.8 + const hasNewline = String(w.word).includes('\n') + const hasPunctuation = isSentenceEnd(cleaned) + + if (hasPause || hasNewline || hasPunctuation || i === alignedWords.length - 1) { + currentSection.lines.push({ + words: [...currentLineWords], + startS: currentLineWords[0].startS, + endS: currentLineWords[currentLineWords.length - 1].endS, + globalIdx: globalLineIdx++, + }) + currentLineWords = [] + } + } + + flushSection() + + // On ne garde que les couplets et refrains pour l'affichage propre + return sections.filter((s) => s.type !== 'other' || s.label === 'Musique') +}