music details web
This commit is contained in:
@@ -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 (
|
||||
<Page
|
||||
headerType="NAVIGATION"
|
||||
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
|
||||
backgroundImg={
|
||||
action === "userProfile" ? background.profileBG : background.libraryBG2
|
||||
}
|
||||
{...(action === "userProfile" && {
|
||||
containerStyle: {
|
||||
backgroundColor: "#0000004D",
|
||||
},
|
||||
rightComponent: () => (
|
||||
<Pressable onPress={() => SheetManager.show("DeleteAudio")}>
|
||||
<RNImage source={icons.trash} />
|
||||
</Pressable>
|
||||
),
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingBottom: gutters * 2,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "auto",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{/* Left column: player */}
|
||||
<BlurView
|
||||
intensity={10}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingRight: 8,
|
||||
height: 400,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.transparentWhite,
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<View style={{ gap: 28 }}>
|
||||
{coverUrl && (
|
||||
<ExpoImage
|
||||
source={{ uri: coverUrl }}
|
||||
cachePolicy="memory-disk"
|
||||
priority="high"
|
||||
contentFit="cover"
|
||||
transition={150}
|
||||
style={styles.img}
|
||||
/>
|
||||
)}
|
||||
<View style={{ ...Style.containerSpaceBetween }}>
|
||||
<View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.name}>{artist}</Text>
|
||||
</View>
|
||||
<View style={{ ...Style.containerRow, gap: 12 }}>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RNImage
|
||||
source={fav ? icons.heart : icons.heartOutline}
|
||||
style={{ ...size({ size: 24 }) }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
SheetManager.show("Playlist", { payload: { projectId } })
|
||||
}
|
||||
>
|
||||
<RNImage
|
||||
source={icons.more}
|
||||
style={{ ...size({ size: 24 }) }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{songUrl && (
|
||||
<View style={{ paddingTop: 22 }}>
|
||||
<Slider
|
||||
value={fmt(progressInfo.pos)}
|
||||
maxValue={fmt(progressInfo.dur)}
|
||||
progress={
|
||||
progressInfo.dur
|
||||
? (progressInfo.pos || 0) / progressInfo.dur
|
||||
: 0
|
||||
}
|
||||
seekEnabled={!!songUrl}
|
||||
onSeekStart={async () => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
...Style.containerRow,
|
||||
gap: 24,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
{/* Previous (rewind 10s) */}
|
||||
<Pressable onPress={() => seekBy(-10)}>
|
||||
<RNImage
|
||||
source={icons.forward}
|
||||
style={{
|
||||
...size({ size: 30 }),
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
{/* Play / Pause */}
|
||||
<Pressable
|
||||
style={{
|
||||
...size({ size: 40 }),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onPress={togglePlay}
|
||||
>
|
||||
<RNImage
|
||||
resizeMode={"contain"}
|
||||
source={isPlaying ? icons.pause : icons.play}
|
||||
style={size({ size: 34 })}
|
||||
/>
|
||||
</Pressable>
|
||||
{/* Next (forward 10s) */}
|
||||
<Pressable onPress={() => seekBy(10)}>
|
||||
<RNImage
|
||||
source={icons.forward}
|
||||
style={{
|
||||
...size({ size: 30 }),
|
||||
transform: [{ rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</BlurView>
|
||||
{/* Right column: lyrics */}
|
||||
<BlurView
|
||||
intensity={10}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: 8,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.transparentWhite,
|
||||
borderRadius: 12,
|
||||
height: 400,
|
||||
}}
|
||||
>
|
||||
{lines?.length ? (
|
||||
<ScrollView
|
||||
ref={lyricsRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 40, height: 400 }}
|
||||
>
|
||||
{lines.map((L, i) => (
|
||||
<View
|
||||
key={`line-${i}`}
|
||||
onLayout={(e) => {
|
||||
lineYRef.current[i] = e.nativeEvent.layout.y;
|
||||
}}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{(L.words || []).map((w, j) => (
|
||||
<Text
|
||||
key={`w-${i}-${j}`}
|
||||
style={
|
||||
visibleTimeS >= (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}
|
||||
</BlurView>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user