This commit is contained in:
2025-09-02 14:28:01 +02:00
11 changed files with 527 additions and 261 deletions
+102 -137
View File
@@ -1,5 +1,5 @@
import { useRoute } from "@react-navigation/core";
import { Audio } from "expo-audio";
import { useAudioPlayer } from "expo-audio";
import { Image as ExpoImage } from "expo-image";
import React, { useEffect, useMemo, useState } from "react";
import {
@@ -11,10 +11,14 @@ import {
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, img } from "../../assets";
import { background, icons } from "../../assets";
import Slider from "../../components/Slider";
import firebase, {
import {
arrayRemove,
arrayUnion,
increment,
@@ -44,9 +48,7 @@ const MusicDetails = () => {
const timerRef = React.useRef(null);
const { data: project } = useDataFromRef({
ref: projectId
? firebase.firestore().collection("projects").doc(projectId)
: null,
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
@@ -71,61 +73,27 @@ const MusicDetails = () => {
const title = project?.title || "Sans titre";
const artist = owner?.userName || "MusicLand";
const coverUrl = project?.coverUrl || null;
const songUrl = useMemo(() => {
if (project?.song?.url) return project.song.url;
const arr = Array.isArray(project?.musicUrls) ? project.musicUrls : [];
return arr[0] || null;
}, [project]);
const songUrl = project?.songUrl || null;
const soundRef = React.useRef(null);
const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined);
// Load/unload audio with expo-audio for reliable status updates on iOS/Android
// Reset counters when the track changes
useEffect(() => {
let isMounted = true;
const load = async () => {
try {
// Unload previous
if (soundRef.current) {
await soundRef.current.unloadAsync();
soundRef.current.setOnPlaybackStatusUpdate(null);
soundRef.current = null;
}
// Reset listen tracking per song load
listenedMsRef.current = 0;
incrementDoneRef.current = false;
if (!songUrl) return;
const { sound } = await Audio.Sound.createAsync(
{ uri: songUrl },
{ shouldPlay: false }
);
sound.setOnPlaybackStatusUpdate((status) => {
if (!isMounted) return;
if (!status || !status.isLoaded) return;
const pos = status.positionMillis || 0;
const dur = status.durationMillis || 0;
setProgressInfo({ pos, dur });
setIsPlaying(!!status.isPlaying);
});
soundRef.current = sound;
} catch (e) {
console.log("Audio load error", e?.message);
}
};
load();
return () => {
isMounted = false;
(async () => {
try {
if (soundRef.current) {
await soundRef.current.unloadAsync();
soundRef.current.setOnPlaybackStatusUpdate(null);
soundRef.current = null;
}
} catch (_) {}
})();
};
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 = () => {
@@ -170,15 +138,13 @@ const MusicDetails = () => {
};
const togglePlay = async () => {
const sound = soundRef.current;
if (!sound || !songUrl) return;
if (!player || !songUrl) return;
try {
const status = await sound.getStatusAsync();
if (status?.isLoaded && status.isPlaying) {
await sound.pauseAsync();
if (player.playing) {
await player.pause?.();
setIsPlaying(false);
} else {
await sound.playAsync();
await player.play?.();
setIsPlaying(true);
}
} catch (e) {
@@ -190,9 +156,8 @@ const MusicDetails = () => {
try {
const dur = progressInfo.dur || 0;
const pos = Math.floor(dur * ratio);
const sound = soundRef.current;
if (sound && dur > 0) {
await sound.setPositionAsync(pos);
if (player && dur > 0) {
await player.seekTo?.(Math.floor((pos || 0) / 1000));
}
} catch (e) {
console.log("MusicDetails seek error", e?.message);
@@ -203,8 +168,7 @@ const MusicDetails = () => {
try {
const cur = Math.floor((progressInfo.pos || 0) / 1000);
const next = Math.max(0, cur + deltaSeconds);
const sound = soundRef.current;
if (sound) await sound.setPositionAsync(next * 1000);
if (player) await player.seekTo?.(next);
} catch (e) {
console.log("MusicDetails seekBy error", e?.message);
}
@@ -248,10 +212,9 @@ const MusicDetails = () => {
paddingTop: 20,
paddingBottom: gutters * 2,
}}
// stickyHeaderIndices={[1]}
>
<View style={{ gap: 28 }}>
{coverUrl ? (
{coverUrl && (
<ExpoImage
source={{ uri: coverUrl }}
cachePolicy="memory-disk"
@@ -260,8 +223,6 @@ const MusicDetails = () => {
transition={150}
style={styles.img}
/>
) : (
<RNImage source={img.placeholder4} style={styles.img} />
)}
<View style={{ ...Style.containerSpaceBetween }}>
<View>
@@ -305,79 +266,81 @@ const MusicDetails = () => {
</View>
</View>
</View>
<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 {
const sound = soundRef.current;
const status = await sound?.getStatusAsync?.();
wasPlayingBeforeSeek.current =
!!status?.isLoaded && !!status?.isPlaying;
if (status?.isLoaded && status?.isPlaying) {
await sound.pauseAsync();
setIsPlaying(false);
}
} catch (e) {
console.log("Pause on seek start error", e?.message);
{songUrl && (
<View style={{ paddingTop: 22 }}>
<Slider
value={fmt(progressInfo.pos)}
maxValue={fmt(progressInfo.dur)}
progress={
progressInfo.dur
? (progressInfo.pos || 0) / progressInfo.dur
: 0
}
}}
onSeek={onSeek}
onSeekEnd={async () => {
try {
const sound = soundRef.current;
if (sound && wasPlayingBeforeSeek.current) {
await sound.playAsync();
setIsPlaying(true);
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);
}
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}
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" }}
>
<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}
{/* Previous (rewind 10s) */}
<Pressable onPress={() => seekBy(-10)}>
<RNImage
source={icons.forward}
style={{
...size({ size: 30 }),
}}
/>
</Pressable>
{/* Play / Pause */}
<Pressable
style={{
...size({ size: 30 }),
transform: [{ rotate: "180deg" }],
...size({ size: 40 }),
alignItems: "center",
justifyContent: "center",
}}
/>
</Pressable>
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>
)}
{description?.length > 0 && (
<View style={{ marginTop: 30, gap: 20 }}>
<Text
@@ -409,6 +372,8 @@ const styles = StyleSheet.create({
fontSize: 20,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
width: responsiveWidth(70),
marginBottom: responsiveHeight(1),
},
name: {
fontSize: 16,