diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js new file mode 100644 index 0000000..398397e --- /dev/null +++ b/src/screens/Library/MusicDetails.web.js @@ -0,0 +1,606 @@ +import { useAudioPlayer } from "expo-audio"; +import { BlurView } from "expo-blur"; +import { Image as ExpoImage } from "expo-image"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Pressable, + Image as RNImage, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { SheetManager } from "react-native-actions-sheet"; +import { + responsiveHeight, + responsiveWidth, +} from "react-native-responsive-dimensions"; +import { useGlobal } from "reactn"; +import { background, icons } from "../../assets"; +import Slider from "../../components/Slider"; +import { + arrayRemove, + arrayUnion, + increment, + projectsRef, + usersRef, +} from "../../config/firebase"; +import useDataFromRef from "../../hooks/useDataFromRef"; +import Page from "../../layouts/Page"; +import { Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; +import Style, { gutters, size } from "../../styles/Style"; + +// 20 secondes +const timeBeforeIncrement = 20000; + +const MusicDetails = ({ route }) => { + const { params } = route || {}; + const action = params?.action; + const projectId = params?.projectId || null; + const [fav, setFav] = useState(false); + const [currentUID] = useGlobal("currentUID"); + const [isPlaying, setIsPlaying] = useState(false); + const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); + const wasPlayingBeforeSeek = React.useRef(false); + const listenedMsRef = React.useRef(0); + const incrementDoneRef = React.useRef(false); + const timerRef = React.useRef(null); + + const { data: project } = useDataFromRef({ + ref: projectId ? projectsRef.doc(projectId) : null, + simpleRef: true, + listener: true, + condition: !!projectId, + }); + + const { data: owner } = useDataFromRef({ + ref: project?.userId ? usersRef.doc(project.userId) : null, + simpleRef: true, + listener: true, + condition: !!project?.userId, + }); + + useEffect(() => { + if (project && currentUID) { + const liked = Array.isArray(project?.likedBy) + ? project.likedBy.includes(currentUID) + : false; + setFav(liked); + } + }, [project?.likedBy, currentUID]); + + const title = project?.title || "Sans titre"; + const artist = owner?.userName || "MusicLand"; + const coverUrl = project?.coverUrl || null; + const songUrl = project?.songUrl || null; + + const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + + // 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 []; + } + }, [project?.musicTimestamps, project?.songIndex]); + + // Reset counters when the track changes + useEffect(() => { + listenedMsRef.current = 0; + incrementDoneRef.current = false; + }, [songUrl]); + + // Poll player state to update progress and play state + useEffect(() => { + const id = setInterval(() => { + const dur = (player?.duration || 0) * 1000; + const pos = (player?.currentTime || 0) * 1000; + setProgressInfo({ pos, dur }); + setIsPlaying(!!player?.playing); + }, 300); + return () => clearInterval(id); + }, [player]); + + // Start/stop a timer to accumulate listened milliseconds while playing + useEffect(() => { + const clearTimer = () => { + if (timerRef.current) { + global.clearInterval(timerRef.current); + timerRef.current = null; + } + }; + if (isPlaying && 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); + } + } else { + clearTimer(); + } + return clearTimer; + }, [isPlaying, projectId]); + + const fmt = (ms) => { + const total = Math.max(0, Math.floor((ms || 0) / 1000)); + const m = Math.floor(total / 60) + .toString() + .padStart(1, "0"); + const s = (total % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }; + + const togglePlay = async () => { + if (!player || !songUrl) return; + try { + if (player.playing) { + await player.pause?.(); + setIsPlaying(false); + } else { + await player.play?.(); + setIsPlaying(true); + } + } catch (e) { + console.log("MusicDetails audio error", e?.message); + } + }; + + const onSeek = async (ratio) => { + try { + const dur = progressInfo.dur || 0; + const pos = Math.floor(dur * ratio); + if (player && dur > 0) { + await player.seekTo?.(Math.floor((pos || 0) / 1000)); + } + } catch (e) { + console.log("MusicDetails seek error", e?.message); + } + }; + + const seekBy = async (deltaSeconds) => { + try { + const cur = Math.floor((progressInfo.pos || 0) / 1000); + const next = Math.max(0, cur + deltaSeconds); + if (player) await player.seekTo?.(next); + } catch (e) { + console.log("MusicDetails seekBy error", e?.message); + } + }; + const description = useMemo(() => { + // Build a readable text from lyrics with section labels + if (Array.isArray(project?.lyrics)) { + return project.lyrics + .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, (progressInfo.pos || 0) / 1000), + [progressInfo.pos] + ); + // Preview lead: show words 0.5s earlier + const visibleTimeS = useMemo( + () => Math.max(0, currentTimeS + 0.5), + [currentTimeS] + ); + // 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()); + + for (let i = 0; i < words.length; i++) { + const w = words[i] || {}; + const original = String(w.word || ""); + const textNoNewline = original.replace(/\n/g, " "); + const textNoTag = removeTags + ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, "") + : textNoNewline; + const next = words[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({ + text: textNoTag, + startS: Number(w.startS || 0), + endS: Number(w.endS || 0), + }); + + const eolByNewline = /\n/.test(original); + const eolByPause = gapToNext >= 0.6; // logical break + const eolByPunct = isSentenceEnd(textNoTag); + const isLast = i === words.length - 1; + + 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; + }; + + 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 (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0)) + return i; + } + if (visibleTimeS > (lines[lines.length - 1]?.endS || 0)) + return lines.length - 1; + return -1; + }, [lines, visibleTimeS]); + + // 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 ( + ( + SheetManager.show("DeleteAudio")}> + + + ), + })} + > + + {/* Left column: player */} + + + + {coverUrl && ( + + )} + + + {title} + {artist} + + + { + if (!projectId || !currentUID) return; + const next = !fav; + setFav(next); + try { + await projectsRef.doc(projectId).update({ + likedBy: next + ? arrayUnion(currentUID) + : arrayRemove(currentUID), + }); + } catch (e) { + setFav(!next); + } + }} + > + + + + SheetManager.show("Playlist", { payload: { projectId } }) + } + > + + + + + + + {songUrl && ( + + { + try { + wasPlayingBeforeSeek.current = !!player?.playing; + if (player?.playing) { + await player.pause?.(); + setIsPlaying(false); + } + } catch (e) { + console.log("Pause on seek start error", e?.message); + } + }} + onSeek={onSeek} + onSeekEnd={async () => { + try { + if (player && wasPlayingBeforeSeek.current) { + await player.play?.(); + setIsPlaying(true); + } + wasPlayingBeforeSeek.current = false; + } catch (e) { + console.log("Resume after seek error", e?.message); + } + }} + /> + + {/* Previous (rewind 10s) */} + seekBy(-10)}> + + + {/* Play / Pause */} + + + + {/* Next (forward 10s) */} + seekBy(10)}> + + + + + )} + + + {/* Right column: lyrics */} + + {lines?.length ? ( + + {lines.map((L, i) => ( + { + lineYRef.current[i] = e.nativeEvent.layout.y; + }} + style={{ + marginBottom: 8, + flexDirection: "row", + flexWrap: "wrap", + }} + > + {(L.words || []).map((w, j) => ( + = (w.startS || 0) + ? styles.karaokeWordActive + : styles.karaokeWord + } + > + {w.text} + {j < (L.words?.length || 1) - 1 ? " " : ""} + + ))} + + ))} + + ) : description?.length > 0 ? ( + + + {description} + + + ) : null} + + + + ); +}; + +export default MusicDetails; + +const styles = StyleSheet.create({ + img: { + width: 160, + height: 160, + borderRadius: 24, + alignSelf: "center", + }, + title: { + fontSize: 20, + color: Palette.white, + fontFamily: FONT_FAMILY.HelveticaNeueMedium, + width: responsiveWidth(70), + marginBottom: responsiveHeight(1), + }, + name: { + fontSize: 16, + 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, + }, + karaokeWordActive: { + color: Palette.primary, + fontFamily: FONT_FAMILY.InterBold, + }, +});