1347 lines
40 KiB
JavaScript
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,
|
|
},
|
|
});
|