Files
musicland/src/screens/Library/MusicDetails.web.js
T
2025-11-18 11:40:39 +01:00

1347 lines
40 KiB
JavaScript

import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
import { Feather } from "@expo/vector-icons";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Pressable,
Image as RNImage,
Platform,
ScrollView,
StyleSheet,
Text,
View,
useWindowDimensions,
} 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 PressableScale from "../../components/PressableScale";
import Slider from "../../components/Slider";
import {
arrayRemove,
arrayUnion,
increment,
projectsRef,
usersRef,
} from "../../config/firebase";
import { ensureAuthenticated } from "../../utils/authRedirect";
import useDataFromRef from "../../hooks/useDataFromRef";
import useTrackController from "../../hooks/useTrackController";
import usePlayer from "../../hooks/usePlayer";
import Page from "../../layouts/Page";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import Style, { gutters, size } from "../../styles/Style";
import { getArtistDisplayName } from "../../utils/artistName";
import {
formatStructureLabel,
getPromptLabelForStructure,
getSegmentMeta,
normalizeStructureType,
segmentRequiresLyrics,
} from "../../utils/songStructure";
import {
createMusicSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
import { goBack } from "../../navigation/NavigationService";
// 20 secondes
const timeBeforeIncrement = 20000;
const MusicDetails = ({ route }) => {
const { params } = route || {};
const action = params?.action;
const autoPlayRequested = params?.autoPlay;
const projectId = params?.projectId || null;
const [fav, setFav] = useState(false);
const [currentUID] = useGlobal("currentUID");
const [, setTooltip] = useGlobal("_tooltip");
const wasPlayingBeforeSeek = React.useRef(false);
const lastSeekTargetMs = React.useRef(null);
const listenedMsRef = React.useRef(0);
const incrementDoneRef = React.useRef(false);
const timerRef = React.useRef(null);
const hasAutoPlayedRef = React.useRef(false);
const { isLooping, setLooping } = usePlayer() || {};
const isWeb = Platform.OS === "web";
const { width: windowWidth = 0 } = useWindowDimensions();
const isCompactLayout = (windowWidth || 0) < 1200;
const handleBackPress = useCallback(() => {
goBack();
}, []);
const renderWebBackButton = useCallback(() => {
if (!isWeb) return null;
return (
<View style={styles.webBackContainer}>
<Pressable
accessibilityRole="button"
onPress={handleBackPress}
style={styles.webBackButton}
>
<RNImage
source={icons.chevronDown}
style={styles.webBackIcon}
resizeMode="contain"
/>
<Text style={styles.webBackText}>Retour</Text>
</Pressable>
</View>
);
}, [handleBackPress, isWeb]);
const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
});
console.log("project is : ", project);
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 = 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]);
const trackDescriptor = useMemo(() => {
if (!trackId || !songUrl) return null;
return {
id: trackId,
uri: songUrl,
songUrl,
title,
artist,
artwork: coverUrl,
coverUrl,
metadata: { projectId, screen: "MusicDetails" },
context: { projectId, screen: "MusicDetails" },
};
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
const sharePayload = useMemo(
() =>
createMusicSharePayload({
projectId,
title,
artist,
}),
[projectId, title, artist],
);
const handleShare = useCallback(() => {
if (!sharePayload) return;
openShareSheet(sharePayload);
}, [sharePayload]);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const handleDownload = useCallback(async () => {
if (!songUrl || isDownloading) return;
try {
setIsDownloading(true);
setDownloadProgress(0);
const response = await fetch(songUrl);
if (!response.ok) {
throw new Error(`download_failed_${response.status}`);
}
const contentType = response.headers.get("content-type") || "audio/mpeg";
const total = Number(response.headers.get("content-length")) || 0;
const sanitizedTitle = String(title || "musicland-track")
.replace(/[\\/:*?"<>|]+/g, "-")
.trim()
.slice(0, 80);
const fileName = `${sanitizedTitle || "musicland-track"}.mp3`;
if (response.body && typeof response.body.getReader === "function") {
const reader = response.body.getReader();
const chunks = [];
let received = 0;
let pseudoProgress = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
if (total > 0) {
setDownloadProgress(Math.min(1, received / total));
} else {
pseudoProgress = Math.min(0.95, pseudoProgress + 0.05);
setDownloadProgress(pseudoProgress);
}
}
}
const blob = new Blob(chunks, { type: contentType });
setDownloadProgress(1);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
const blob = await response.blob();
setDownloadProgress(1);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
} catch (error) {
console.error("MusicDetails.download", error);
setTooltip({
type: "error",
text: "Téléchargement impossible",
});
} finally {
setIsDownloading(false);
setTimeout(() => setDownloadProgress(0), 400);
}
}, [isDownloading, setTooltip, songUrl, title]);
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 handleOpenOptions = useCallback(() => {
SheetManager.show("MusicOptions", {
payload: {
projectId,
title,
ownerId: project?.userId || owner?.id || null,
onReport: handleReport,
onAddToPlaylist: handleAddToPlaylist,
onDownload: handleDownload,
downloadDisabled: isDownloading || !songUrl,
onShare: sharePayload ? handleShare : null,
sharePayload,
shareDisabled: !sharePayload,
},
});
}, [
handleAddToPlaylist,
handleDownload,
handleReport,
handleShare,
isDownloading,
owner?.id,
project?.userId,
projectId,
sharePayload,
songUrl,
title,
]);
const {
isCurrent: isCurrentTrack,
isPlaying: isTrackPlaying,
positionMs,
durationMs,
ensureLoaded,
pause: pauseTrack,
resume: resumeTrack,
seekTo: seekTrackTo,
seekBy: seekTrackBy,
} = useTrackController(trackDescriptor);
const isPlaying = isTrackPlaying;
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 [];
}
}, [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 sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs;
// Reset counters when the track changes
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 (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 = 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 audio error", e?.message);
}
}, [
trackDescriptor,
isCurrentTrack,
ensureLoaded,
positionMs,
isTrackPlaying,
pauseTrack,
resumeTrack,
]);
const handleSliderSeekStart = useCallback(async () => {
if (!trackDescriptor) return;
try {
console.log("[MusicDetails.web] handleSliderSeekStart", {
isCurrentTrack,
isTrackPlaying,
positionMs,
durationMs,
});
lastSeekTargetMs.current = null;
wasPlayingBeforeSeek.current = isTrackPlaying;
if (!isCurrentTrack) {
await ensureLoaded({
startPositionMs: positionMs,
autoPlay: false,
});
}
if (isTrackPlaying) {
await pauseTrack();
}
} catch (e) {
console.log("MusicDetails seek start error", e?.message);
}
}, [
trackDescriptor,
isTrackPlaying,
isCurrentTrack,
ensureLoaded,
positionMs,
pauseTrack,
lastSeekTargetMs,
durationMs,
]);
const handleSliderSeek = useCallback(
async (ratio) => {
const dur = sliderDurationMs || 0;
if (!trackDescriptor || dur <= 0) return;
const targetMs = Math.max(0, Math.floor(dur * ratio));
lastSeekTargetMs.current = targetMs;
console.log("[MusicDetails.web] handleSliderSeek", {
ratio,
durationMs: dur,
targetMs,
isCurrentTrack,
});
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
} else {
await seekTrackTo(targetMs);
}
} 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 (!isPlaying) {
await resumeTrack();
}
} catch (e) {
console.log("MusicDetails.web loop toggle error", e?.message);
}
}
}, [
isCurrentTrack,
loopEnabled,
setLooping,
seekTrackTo,
isPlaying,
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 (!isPlaying) {
await resumeTrack();
}
hasAutoPlayedRef.current = true;
} catch (e) {
console.log("MusicDetails autoplay error", e?.message);
}
};
run();
}, [
autoPlayRequested,
ensureLoaded,
isCurrentTrack,
isPlaying,
resumeTrack,
songUrl,
trackDescriptor,
]);
const handleSliderSeekEnd = useCallback(async () => {
const targetMs =
typeof lastSeekTargetMs.current === "number"
? Math.max(0, lastSeekTargetMs.current)
: null;
console.log("[MusicDetails.web] handleSliderSeekEnd", {
targetMs,
wasPlayingBeforeSeek: wasPlayingBeforeSeek.current,
isCurrentTrack,
positionMs,
});
try {
if (wasPlayingBeforeSeek.current) {
if (!isCurrentTrack) {
await ensureLoaded({
startPositionMs:
targetMs !== null && Number.isFinite(targetMs)
? targetMs
: positionMs,
autoPlay: true,
});
} else {
if (targetMs !== null && Number.isFinite(targetMs)) {
await seekTrackTo(targetMs);
}
await resumeTrack();
}
}
} catch (e) {
console.log("MusicDetails seek end error", e?.message);
} finally {
wasPlayingBeforeSeek.current = false;
lastSeekTargetMs.current = null;
}
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]);
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)) {
const counts = {};
return project.lyrics
.map((s) => {
const type = normalizeStructureType(s?.type);
const requiresLyrics = segmentRequiresLyrics(type);
const body = (s?.lyrics || "").trim();
if (requiresLyrics && !body) return null;
counts[type] = (counts[type] || 0) + 1;
const label = formatStructureLabel(type, counts[type]);
const displayLabel =
typeof label === "string" && label.length > 0
? label
: getPromptLabelForStructure(type);
if (!requiresLyrics) {
return `[${displayLabel}] — section instrumentale`;
}
return `[${displayLabel}]\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")) {
if (lower.includes("long")) type = "long_intro";
else type = "short_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.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 sectionYRef = useRef({});
const lineRelativeYRef = useRef({});
const lineYRef = useRef({});
const updateLineAbsoluteOffset = useCallback((lineIdx) => {
const entry = lineRelativeYRef.current?.[lineIdx];
if (!entry) return;
const sectionY = sectionYRef.current?.[entry.sectionKey];
if (typeof sectionY !== "number") return;
lineYRef.current[lineIdx] = sectionY + entry.relativeY;
}, []);
const registerLineRelativeOffset = useCallback(
(lineIdx, sectionKey, relativeY) => {
lineRelativeYRef.current[lineIdx] = { sectionKey, relativeY };
updateLineAbsoluteOffset(lineIdx);
},
[updateLineAbsoluteOffset],
);
const registerSectionOffset = useCallback(
(sectionKey, y, lines = []) => {
sectionYRef.current[sectionKey] = y;
lines.forEach((line) => updateLineAbsoluteOffset(line.globalIdx));
},
[updateLineAbsoluteOffset],
);
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"
hideBackButton={isWeb}
topStickyContent={renderWebBackButton}
width={isCompactLayout ? "100%" : undefined}
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
backgroundImg={
action === "userProfile"
? background.profileBG
: background.libraryBG2Web
}
shareBtn={
sharePayload
? {
label: "Partager le morceau",
onPress: handleShare,
}
: false
}
{...(action === "userProfile" && {
containerStyle: {
backgroundColor: "#0000004D",
},
rightComponent: () => (
<Pressable onPress={() => SheetManager.show("DeleteAudio")}>
<RNImage source={icons.trash} />
</Pressable>
),
})}
>
<View
style={{
flex: 1,
paddingBottom: gutters * 2,
flexDirection: isCompactLayout ? "column" : "row",
justifyContent: "center",
alignItems: isCompactLayout ? "stretch" : "center",
height: "auto",
gap: isCompactLayout ? 16 : 24,
width: "100%",
}}
>
{/* Left column: player */}
<BlurView
intensity={40}
style={{
flex: 1,
paddingRight: isCompactLayout ? 0 : 8,
height: isCompactLayout ? undefined : 400,
borderRadius: 12,
padding: 10,
borderWidth: 1,
borderColor: Palette.transparentWhite,
maxWidth: isCompactLayout ? "100%" : "50%",
width: "100%",
}}
>
<View>
<View style={{ gap: 28 }}>
{coverUrl ? (
<ExpoImage
source={{ uri: coverUrl }}
cachePolicy="memory-disk"
priority="high"
contentFit="cover"
transition={150}
style={styles.img}
/>
) : (
// empty container
<View
style={{
...styles.img,
backgroundColor: Palette.transparentWhite,
}}
/>
)}
<View style={{ ...Style.containerSpaceBetween }}>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.name}>{artist}</Text>
</View>
<View style={{ ...Style.containerRow, gap: 12 }}>
<PressableScale
onPress={handleToggleLoop}
disabled={!isCurrentTrack}
hitSlop={10}
style={{
...size({ size: 32 }),
alignItems: "center",
justifyContent: "center",
opacity: isCurrentTrack ? 1 : 0.4,
}}
contentStyle={{
alignItems: "center",
justifyContent: "center",
}}
>
<Feather
name="repeat"
size={24}
color={loopEnabled ? Palette.primary : Palette.white}
/>
</PressableScale>
<Pressable
onPress={async () => {
if (!projectId) return;
if (!ensureAuthenticated(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>
<PressableScale onPress={handleOpenOptions}>
<RNImage
source={icons.more}
style={{ ...size({ size: 24 }) }}
resizeMode="contain"
/>
</PressableScale>
</View>
</View>
</View>
{songUrl && (
<View style={{ paddingTop: 22 }}>
<View
style={{
...Style.containerRow,
gap: 24,
alignSelf: "center",
}}
>
{/* Previous (rewind 10s) */}
<Pressable onPress={() => handleSeekBySeconds(-10)}>
<RNImage
source={icons.forward}
style={{ ...size({ size: 30 }) }}
/>
</Pressable>
{/* Play / Pause */}
<PressableScale
style={{
...size({ size: 40 }),
alignItems: "center",
justifyContent: "center",
}}
onPress={togglePlay}
>
<RNImage
resizeMode="contain"
source={isPlaying ? icons.pause : icons.play}
style={size({ size: 34 })}
/>
</PressableScale>
{/* Next (forward 10s) */}
<PressableScale onPress={() => handleSeekBySeconds(10)}>
<RNImage
source={icons.forward}
style={{
...size({ size: 30 }),
transform: [{ rotate: "180deg" }],
}}
/>
</PressableScale>
</View>
<Slider
value={fmt(positionMs)}
maxValue={fmt(sliderDurationMs)}
progress={
sliderDurationMs ? (positionMs || 0) / sliderDurationMs : 0
}
seekEnabled={!!songUrl}
onSeekStart={handleSliderSeekStart}
onSeek={handleSliderSeek}
onSeekEnd={handleSliderSeekEnd}
/>
</View>
)}
</View>
</BlurView>
{/* Right column: lyrics */}
{(sections.length > 0 || description?.length > 0) && (
<BlurView
intensity={40}
style={{
flex: 1,
paddingLeft: isCompactLayout ? 0 : 8,
padding: 10,
borderWidth: 1,
borderColor: Palette.transparentWhite,
borderRadius: 12,
height: isCompactLayout ? undefined : 400,
maxWidth: isCompactLayout ? "100%" : "50%",
width: "100%",
marginTop: isCompactLayout ? 12 : 0,
}}
>
{sections.length ? (
<ScrollView
ref={lyricsRef}
style={{ flex: 1 }}
contentContainerStyle={{
paddingBottom: 40,
...(isCompactLayout ? {} : { height: 400 }),
}}
>
{sections.map((section, sectionIdx) => (
<View
key={section.key}
onLayout={(e) =>
registerSectionOffset(
section.key,
e.nativeEvent?.layout?.y,
section.lines,
)
}
style={[
styles.sectionContainer,
sectionIdx > 0 ? styles.sectionSpacing : null,
]}
>
<Text style={styles.sectionTitle}>
{section.label}
{"\n"}
</Text>
{section.lines.map((line) => (
<View
key={`line-${line.globalIdx}`}
onLayout={(e) => {
registerLineRelativeOffset(
line.globalIdx,
section.key,
e.nativeEvent?.layout?.y ?? 0,
);
}}
style={{
marginBottom: 8,
flexDirection: "row",
flexWrap: "wrap",
}}
>
{(line.words || []).map((w, j) => (
<Text
key={`w-${line.globalIdx}-${j}`}
style={
visibleTimeS >= (w.startS || 0)
? styles.karaokeWordActive
: styles.karaokeWord
}
onPress={() => handleLyricsSeek(w.startS)}
>
{w.text}
{j < (line.words?.length || 1) - 1 ? " " : ""}
</Text>
))}
</View>
))}
</View>
))}
</ScrollView>
) : description?.length > 0 ? (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingBottom: 40,
...(isCompactLayout ? {} : { height: 400 }),
}}
>
<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({
webBackContainer: {
alignSelf: "flex-start",
marginBottom: 12,
},
webBackButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
alignSelf: "flex-start",
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 999,
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
backgroundColor: Palette.ultraLightWhite,
},
webBackIcon: {
width: 16,
height: 16,
transform: [{ rotate: "90deg" }],
},
webBackText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
img: {
width: 200,
height: 200,
borderRadius: 24,
alignSelf: "center",
},
title: {
fontSize: 20,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
maxWidth: responsiveWidth(70),
flexShrink: 1,
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,
cursor: "pointer",
},
karaokeWordActive: {
color: Palette.primary,
fontFamily: FONT_FAMILY.InterBold,
cursor: "pointer",
},
sectionContainer: {
marginBottom: 16,
},
sectionSpacing: {
marginTop: 12,
},
sectionTitle: {
color: Palette.transparentWhite,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
letterSpacing: 0.4,
textTransform: "uppercase",
marginBottom: 6,
},
});