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