audio player

This commit is contained in:
2025-10-07 10:14:31 +02:00
parent c51176d498
commit a5a5806c7c
2 changed files with 203 additions and 147 deletions
+19 -38
View File
@@ -1,13 +1,6 @@
import { BlurView } from "expo-blur";
import React, { useMemo } from "react";
import {
Image,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { Image, Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { icons, img } from "../../assets";
@@ -60,14 +53,12 @@ const GlobalAudioPlayer = () => {
return (
<View pointerEvents="box-none" style={styles.host}>
<BlurView
intensity={Platform.OS === "ios" ? 40 : 15}
tint="dark"
style={[
styles.container,
{
paddingBottom: Math.max(insets.bottom, 12),
},
]}
intensity={40}
// tint="dark"
blurReductionFactor={2}
// tint="dark"
style={[styles.container]}
experimentalBlurMethod="dimezisBlurView"
>
<Image source={artworkSource} style={styles.cover} />
<View style={styles.details}>
@@ -79,17 +70,6 @@ const GlobalAudioPlayer = () => {
{artist}
</Text>
)}
<View style={styles.progressContainer}>
<View style={styles.progressTrack}>
<View
style={[styles.progressFill, { width: `${progress * 100}%` }]}
/>
</View>
<View style={styles.timeContainer}>
<Text style={styles.time}>{formatDuration(positionMs)}</Text>
<Text style={styles.time}>{formatDuration(durationMs)}</Text>
</View>
</View>
</View>
<View style={styles.actions}>
<Pressable
@@ -118,26 +98,27 @@ const styles = StyleSheet.create({
},
container: {
marginHorizontal: 16,
marginBottom: 12,
borderRadius: 20,
marginBottom: 80,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingHorizontal: 16,
paddingTop: 12,
backgroundColor: "rgba(18, 11, 29, 0.85)",
borderWidth: 1,
borderColor: "#372152",
overflow: "hidden",
paddingHorizontal: 4,
paddingVertical: 4,
// backgroundColor: "rgba(18, 11, 29, 0.85)",
// borderWidth: 1,
// borderColor: "#372152",
},
cover: {
width: 48,
height: 48,
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: "#1f1829",
},
details: {
flex: 1,
gap: 6,
gap: 3,
},
title: {
color: Palette.white,
@@ -181,7 +162,7 @@ const styles = StyleSheet.create({
borderRadius: 22,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.1)",
// backgroundColor: "rgba(255, 255, 255, 0.1)",
},
toggleIcon: {
width: 24,
+160 -85
View File
@@ -1,6 +1,6 @@
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
import useTrackController from "../../hooks/useTrackController";
import { Image as ExpoImage } from "expo-image";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Pressable,
Image as RNImage,
@@ -40,12 +40,10 @@ const MusicDetails = ({ route }) => {
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 wasPlayingBeforeSeek = useRef(false);
const listenedMsRef = useRef(0);
const incrementDoneRef = useRef(false);
const timerRef = useRef(null);
const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
@@ -75,14 +73,43 @@ const MusicDetails = ({ route }) => {
const coverUrl = project?.coverUrl || null;
const songUrl = project?.songUrl || null;
const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: projectId ? `project-${projectId}` : songUrl ? `song-${songUrl}` : undefined,
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 {
isCurrent: isCurrentTrack,
isPlaying: isTrackPlaying,
positionMs,
durationMs,
ensureLoaded,
pause: pauseTrack,
resume: resumeTrack,
seekTo: seekTrackTo,
seekBy: seekTrackBy,
} = useTrackController(trackDescriptor);
const progressInfo = useMemo(
() => ({ pos: positionMs, dur: durationMs }),
[positionMs, durationMs]
);
// Karaoke aligned words (from timestamps)
const alignedWords = useMemo(() => {
@@ -104,18 +131,7 @@ const MusicDetails = ({ route }) => {
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]);
}, [trackId]);
// Start/stop a timer to accumulate listened milliseconds while playing
useEffect(() => {
@@ -125,7 +141,7 @@ const MusicDetails = ({ route }) => {
timerRef.current = null;
}
};
if (isPlaying && projectId) {
if (isTrackPlaying && projectId) {
if (!timerRef.current) {
timerRef.current = global.setInterval(async () => {
try {
@@ -149,7 +165,7 @@ const MusicDetails = ({ route }) => {
clearTimer();
}
return clearTimer;
}, [isPlaying, projectId]);
}, [isTrackPlaying, projectId]);
const fmt = (ms) => {
const total = Math.max(0, Math.floor((ms || 0) / 1000));
@@ -160,66 +176,145 @@ const MusicDetails = ({ route }) => {
return `${m}:${s}`;
};
const togglePlay = async () => {
if (!player || !songUrl) return;
const handleTogglePlay = useCallback(async () => {
if (!trackDescriptor) return;
try {
if (player.playing) {
await player.pause?.();
setIsPlaying(false);
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: progressInfo.pos, autoPlay: true });
return;
}
if (isTrackPlaying) {
await pauseTrack();
} else {
await player.play?.();
setIsPlaying(true);
await resumeTrack();
}
} catch (e) {
console.log("MusicDetails audio error", e?.message);
console.log("MusicDetails toggle error", e?.message);
}
};
}, [
trackDescriptor,
isCurrentTrack,
ensureLoaded,
progressInfo.pos,
isTrackPlaying,
pauseTrack,
resumeTrack,
]);
const onSeek = async (ratio) => {
const handleSliderSeekStart = useCallback(async () => {
if (!trackDescriptor) return;
try {
wasPlayingBeforeSeek.current = isTrackPlaying;
if (!isCurrentTrack) {
await ensureLoaded({
startPositionMs: progressInfo.pos,
autoPlay: false,
});
}
if (isTrackPlaying) {
await pauseTrack();
}
} catch (e) {
console.log("MusicDetails seek start error", e?.message);
}
}, [
trackDescriptor,
isTrackPlaying,
isCurrentTrack,
ensureLoaded,
progressInfo.pos,
pauseTrack,
]);
const handleSliderSeek = useCallback(
async (ratio) => {
const dur = progressInfo.dur || 0;
const pos = Math.floor(dur * ratio);
if (player && dur > 0) {
await player.seekTo?.(Math.floor((pos || 0) / 1000));
if (!trackDescriptor || dur <= 0) return;
const targetMs = Math.max(0, Math.floor(dur * ratio));
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
} else {
await seekTrackTo(targetMs);
}
} catch (e) {
console.log("MusicDetails seek error", e?.message);
}
};
},
[progressInfo.dur, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo]
);
const seekBy = async (deltaSeconds) => {
const handleSliderSeekEnd = useCallback(async () => {
try {
const cur = Math.floor((progressInfo.pos || 0) / 1000);
const next = Math.max(0, cur + deltaSeconds);
if (player) await player.seekTo?.(next);
if (wasPlayingBeforeSeek.current) {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: progressInfo.pos, autoPlay: true });
} else {
await resumeTrack();
}
}
} catch (e) {
console.log("MusicDetails seek end error", e?.message);
} finally {
wasPlayingBeforeSeek.current = false;
}
}, [ensureLoaded, isCurrentTrack, progressInfo.pos, resumeTrack]);
const handleSeekBySeconds = useCallback(
async (deltaSeconds) => {
if (!trackDescriptor) return;
const deltaMs = Number(deltaSeconds || 0) * 1000;
const target = Math.max(0, (progressInfo.pos || 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,
progressInfo.pos,
isCurrentTrack,
ensureLoaded,
seekTrackBy,
]
);
const handleLyricsSeek = React.useCallback(
const handleLyricsSeek = useCallback(
async (timestampS) => {
if (!player || typeof timestampS !== "number" || Number.isNaN(timestampS))
if (
!trackDescriptor ||
typeof timestampS !== "number" ||
Number.isNaN(timestampS)
)
return;
const seconds = Math.max(0, Number(timestampS) || 0);
const duration = Number(player?.duration || 0);
const bounded = duration > 0 ? Math.min(seconds, duration) : seconds;
const targetMs = Math.max(0, Number(timestampS) * 1000);
try {
const wasPlaying = !!player?.playing;
await player.seekTo?.(bounded);
setProgressInfo((prev) => ({ ...prev, pos: bounded * 1000 }));
if (wasPlaying) {
if (!player?.playing) {
await player.play?.();
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: targetMs, autoPlay: true });
return;
}
setIsPlaying(true);
await seekTrackTo(targetMs);
if (!isTrackPlaying) {
await resumeTrack();
}
} catch (e) {
console.log("MusicDetails lyrics seek error", e?.message);
}
},
[player]
[
trackDescriptor,
isCurrentTrack,
ensureLoaded,
seekTrackTo,
isTrackPlaying,
resumeTrack,
]
);
const description = useMemo(() => {
// Build a readable text from lyrics with section labels
@@ -566,39 +661,19 @@ const MusicDetails = ({ route }) => {
maxValue={fmt(progressInfo.dur)}
progress={
progressInfo.dur
? (progressInfo.pos || 0) / progressInfo.dur
? Math.min(1, Math.max(0, (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);
}
}}
onSeekStart={handleSliderSeekStart}
onSeek={handleSliderSeek}
onSeekEnd={handleSliderSeekEnd}
/>
<View
style={{ ...Style.containerRow, gap: 24, alignSelf: "center" }}
>
{/* Previous (rewind 10s) */}
<Pressable onPress={() => seekBy(-10)}>
<Pressable onPress={() => handleSeekBySeconds(-10)}>
<RNImage
source={icons.forward}
style={{
@@ -613,16 +688,16 @@ const MusicDetails = ({ route }) => {
alignItems: "center",
justifyContent: "center",
}}
onPress={togglePlay}
onPress={handleTogglePlay}
>
<RNImage
resizeMode={"contain"}
source={isPlaying ? icons.pause : icons.play}
source={isTrackPlaying ? icons.pause : icons.play}
style={size({ size: 34 })}
/>
</Pressable>
{/* Next (forward 10s) */}
<Pressable onPress={() => seekBy(10)}>
<Pressable onPress={() => handleSeekBySeconds(10)}>
<RNImage
source={icons.forward}
style={{