react-native-audio-waveform icon indicating copy to clipboard operation
react-native-audio-waveform copied to clipboard

`playerKey` is not defined error

Open dishant0406 opened this issue 6 months ago • 2 comments
trafficstars

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

dishant0406 avatar May 11 '25 17:05 dishant0406