-
Notifications
You must be signed in to change notification settings - Fork 42
Open
Description
import {
Forward02Icon,
PauseIcon,
PlayIcon,
RepeatIcon,
ShuffleSquareIcon,
VolumeHighIcon,
VolumeMute02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react-native";
import {
useAudioPlayer,
Waveform,
type IWaveformRef,
} from "@simform_solutions/react-native-audio-waveform";
import * as FileSystem from "expo-file-system";
import React, { useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
type AudioPlayerProps = {
url: string;
};
const formatTime = (sec: number) => {
const min = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${min}:${s < 10 ? "0" : ""}${s}`;
};
const AudioPlayer: React.FC<AudioPlayerProps> = ({ url }) => {
const [isMuted, setIsMuted] = useState(false);
const [isShuffled, setIsShuffled] = useState(false);
const [localPath, setLocalPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [waveformLoaded, setWaveformLoaded] = useState(false);
const [waveformError, setWaveformError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentPosition, setCurrentPosition] = useState(0);
const [playerReady, setPlayerReady] = useState(false);
const [waveformRetryCount, setWaveformRetryCount] = useState(0);
const waveformRef = useRef<IWaveformRef>(null);
const { seekToPlayer, setVolume, stopAllWaveFormExtractors, stopAllPlayers } =
useAudioPlayer();
// Download file to local cache, overwrite if exists, and verify it's valid audio
useEffect(() => {
let isMounted = true;
setLoading(true);
setWaveformLoaded(false);
setWaveformError(null);
setPlayerReady(false);
setWaveformRetryCount(0);
const setupAudio = async () => {
try {
// Use a stable filename based on the URL to improve caching
const filename = `audio-${url.split("/").pop() || Date.now()}.mp3`;
const fileUri = `${FileSystem.cacheDirectory}${filename}`;
// Check if file already exists
const fileInfo = await FileSystem.getInfoAsync(fileUri);
// Only download if file doesn't exist or has no size
if (!fileInfo.exists || fileInfo.size === 0) {
console.log("Downloading audio file...");
const downloadResult = await FileSystem.downloadAsync(url, fileUri);
console.log("Download complete:", downloadResult);
// Verify download success
const downloadedFileInfo = await FileSystem.getInfoAsync(fileUri);
if (!downloadedFileInfo.exists || downloadedFileInfo.size === 0) {
throw new Error("Audio file couldn't be downloaded or is empty.");
}
} else {
console.log("Using cached audio file:", fileInfo);
}
// Log first bytes to ensure it's not HTML
try {
const bytes = await FileSystem.readAsStringAsync(fileUri, {
encoding: FileSystem.EncodingType.Base64,
length: 100, // Read just first 100 bytes
});
console.log("First file bytes (base64):", bytes.slice(0, 40));
// Simple validation that it looks like an audio file (MP3 typically starts with ID3)
if (!bytes.startsWith("SUQz") && !bytes.startsWith("AAAA")) {
console.warn("File doesn't appear to be a valid audio file");
}
} catch (e) {
console.warn("Could not log file bytes", e);
}
// Wait longer to ensure file is properly flushed to disk
setTimeout(() => {
if (isMounted) {
setLocalPath(fileUri);
setLoading(false);
}
}, 500);
} catch (e: any) {
console.error("Error setting up audio:", e);
if (isMounted) {
setLoading(false);
setLocalPath(null);
setWaveformError("Failed to set up audio. " + (e?.message || ""));
}
}
};
setupAudio();
return () => {
isMounted = false;
};
}, [url]);
// Retry waveform creation if it fails
useEffect(() => {
if (waveformError && waveformRetryCount < 3 && localPath) {
console.log(
`Retrying waveform extraction (attempt ${waveformRetryCount + 1})...`
);
const timer = setTimeout(() => {
setWaveformError(null);
setWaveformRetryCount((prev) => prev + 1);
// Force remount of waveform component by temporarily clearing localPath
setLocalPath(null);
setTimeout(() => setLocalPath(localPath), 100);
}, 1000);
return () => clearTimeout(timer);
}
}, [waveformError, waveformRetryCount, localPath]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopAllWaveFormExtractors().catch((e) =>
console.warn("Error stopping extractors:", e)
);
stopAllPlayers().catch((e) => console.warn("Error stopping players:", e));
};
}, [stopAllPlayers, stopAllWaveFormExtractors]);
// Watch for player readiness: either waveform is loaded, or we have a playerKey
useEffect(() => {
if (
waveformLoaded ||
(waveformRef.current && waveformRef.current.playerKey)
) {
setPlayerReady(true);
} else {
setPlayerReady(false);
}
}, [waveformLoaded, waveformRef.current?.playerKey]);
// If waveform extraction fails but we have a valid path, switch to simpler player
const useFallbackPlayer =
waveformRetryCount >= 3 && localPath && !waveformLoaded;
// Play/Pause logic via ref
const onPlayPause = async () => {
try {
if (!playerReady || !waveformRef.current) return;
if (isPlaying) {
await waveformRef.current.pausePlayer();
setIsPlaying(false);
} else {
await waveformRef.current.startPlayer();
setIsPlaying(true);
}
} catch (error) {
console.error("Error during play/pause:", error);
}
};
// Mute/Unmute (volume)
const onMute = async () => {
try {
if (
!playerReady ||
!waveformRef.current ||
!waveformRef.current.playerKey
)
return;
const key = waveformRef.current.playerKey;
const newMuteState = !isMuted;
setIsMuted(newMuteState);
await setVolume({
playerKey: key,
volume: newMuteState ? 0 : 1,
});
} catch (error) {
console.error("Error toggling mute:", error);
}
};
// Seek
const onSeek = async (seconds: number) => {
try {
if (
!playerReady ||
!waveformRef.current ||
!waveformRef.current.playerKey
)
return;
let newPos = currentPosition + seconds;
if (newPos < 0) newPos = 0;
if (newPos > duration) newPos = duration;
await seekToPlayer({
playerKey: waveformRef.current.playerKey,
progress: newPos * 1000, // ms
});
} catch (error) {
console.error("Error seeking:", error);
}
};
const handleWaveformLoad = (loaded: boolean) => {
console.log("Waveform loaded:", loaded);
setWaveformLoaded(loaded);
};
const handleWaveformError = (error: Error) => {
console.error("Waveform error:", error);
setWaveformError(error.message);
};
if (loading || !localPath) {
return (
<View
style={[styles.container, { justifyContent: "center", minHeight: 150 }]}
>
<ActivityIndicator color="#fff" size="large" />
<Text style={{ color: "#fff", marginTop: 12 }}>Loading audio...</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Top controls */}
<View style={styles.topRow}>
<TouchableOpacity onPress={onMute} disabled={!playerReady}>
<HugeiconsIcon
icon={isMuted ? VolumeMute02Icon : VolumeHighIcon}
color={playerReady ? "#fff" : "#666"}
size={28}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => onSeek(-10)} disabled={!playerReady}>
<HugeiconsIcon
icon={RepeatIcon}
color={playerReady ? "#fff" : "#666"}
size={28}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onPlayPause}
style={[styles.playButton, !playerReady && styles.playButtonDisabled]}
disabled={!playerReady}
>
<HugeiconsIcon
icon={isPlaying ? PauseIcon : PlayIcon}
color={playerReady ? "#fff" : "#666"}
size={48}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => onSeek(10)} disabled={!playerReady}>
<HugeiconsIcon
icon={Forward02Icon}
color={playerReady ? "#fff" : "#666"}
size={28}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setIsShuffled((s) => !s)}
disabled={!playerReady}
>
<HugeiconsIcon
icon={ShuffleSquareIcon}
color={isShuffled ? "#4fd1c5" : playerReady ? "#fff" : "#666"}
size={28}
/>
</TouchableOpacity>
</View>
{/* Waveform & Time */}
<View style={styles.waveformRow}>
<Text style={styles.timeText}>{formatTime(currentPosition)}</Text>
<View style={styles.waveformContainer}>
{waveformError && !useFallbackPlayer ? (
<Text style={styles.errorText}>
{waveformError}
{waveformRetryCount < 3
? `\nRetrying... (${waveformRetryCount + 1}/3)`
: "\nSwitching to simplified player..."}
</Text>
) : (
<Waveform
// Use key with retryCount to force re-mount component
key={`waveform-${waveformRetryCount}`}
mode={useFallbackPlayer ? "none" : "static"}
path={localPath}
ref={waveformRef}
waveColor="#fff"
scrubColor="#4fd1c5"
candleSpace={3}
candleWidth={3}
candleHeightScale={6}
onChangeWaveformLoadState={handleWaveformLoad}
onError={handleWaveformError}
containerStyle={styles.waveformInner}
onCurrentProgressChange={(currentProgress, songDuration) => {
setCurrentPosition(currentProgress / 1000);
if (songDuration > 0) {
setDuration(songDuration / 1000);
}
}}
onPlayerStateChange={(state) => {
setIsPlaying(state === "playing");
console.log(`Waveform player state: ${state}`);
}}
// Add fallback options
playbackOptions={{
// Try to avoid issues with challenging files
iosPreferWaveformExtraction: !useFallbackPlayer,
androidExtractWaveform: !useFallbackPlayer,
}}
/>
)}
{/* Overlay progress bar for fallback mode */}
{useFallbackPlayer && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{
width: `${
(currentPosition / Math.max(duration, 1)) * 100
}%`,
},
]}
/>
</View>
)}
</View>
<Text style={styles.timeText}>{formatTime(duration)}</Text>
</View>
{!playerReady && !waveformError && (
<Text style={styles.generatingText}>Preparing player...</Text>
)}
{useFallbackPlayer && playerReady && (
<Text style={styles.fallbackText}>Using simplified player mode</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: "#20160c",
padding: 18,
borderRadius: 20,
alignItems: "center",
},
topRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
},
playButton: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: "#181008",
alignItems: "center",
justifyContent: "center",
},
playButtonDisabled: {
backgroundColor: "#1a1610",
},
waveformRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 24,
width: "100%",
justifyContent: "center",
},
timeText: {
color: "#fff",
width: 50,
textAlign: "center",
fontSize: 16,
},
waveformContainer: {
flex: 1,
height: 100,
position: "relative",
justifyContent: "center",
alignItems: "center",
marginHorizontal: 8,
},
waveformInner: {
width: "100%",
height: 80,
backgroundColor: "transparent",
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(32, 22, 12, 0.5)",
},
generatingText: {
color: "#aaa",
marginTop: 8,
fontSize: 12,
},
errorText: {
color: "#ff8080",
fontSize: 12,
textAlign: "center",
},
fallbackText: {
color: "#aaa",
marginTop: 8,
fontSize: 12,
},
progressBarContainer: {
position: "absolute",
bottom: 15,
left: 0,
right: 0,
height: 4,
backgroundColor: "rgba(255, 255, 255, 0.2)",
borderRadius: 2,
},
progressBar: {
height: "100%",
backgroundColor: "#4fd1c5",
borderRadius: 2,
},
});
export default AudioPlayer;
Error is
Error during play/pause: [Error: Can not pause player, Player key is null]
on play pasue
Metadata
Metadata
Assignees
Labels
No labels