audio player
This commit is contained in:
@@ -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
@@ -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={{
|
||||||
|
|||||||
Reference in New Issue
Block a user