React Native/Firebase/Expo Audio - Cancel loadAsync after download starts and user navigates away from page

1.2k views Asked by At

I am having trouble cancelling loadAsync when a user navigates away from my page. I have tried to use a cleanup function on useEffect but since the soundObject hasn't loaded yet it will give me an error since soundObject equals null. I have also tried to use redux and add soundObject.stopAsync when other pages come into focus but since the soundObject may not be set yet it will not cancel and I will have audio playing and can't be stopped. Here is my Pause/Play button component where I call loadAsync. Any help would be greatly appreciated. Thanks

UPDATE TO MY PLAY PAUSE HANDLER I have found a workaround even though I feel there is a better way. I am now calling Audio.setIsEnabledAsync(false); as a cleanup function.

  //CLEANUP FUNCTION
  useEffect(() => {
    Audio.setIsEnabledAsync(true);
    return function cleanUp() {
      reference.putFile(props.audioFile).cancel();
      Audio.setIsEnabledAsync(false);
    };
  }, []);
import React, { useState, useEffect } from "react";
import { TouchableOpacity } from "react-native";
import { useDispatch, useSelector } from "react-redux";

import storage from "@react-native-firebase/storage";
import { playPause, stopPlay } from "../../../store/actions/playerActions";
import { Audio } from "expo-av";
import SmallIndicator from "../Indicators/SmallIndicator";

import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome";
import { faPlay, faPause } from "@fortawesome/pro-light-svg-icons";
import Colors from "../../../constants/Colors";

const PlayPause = (props) => {
  const dispatch = useDispatch();

  // LOAD FROM FIREBASE VARIABLES
  let audioFile = props.audioFile;
  const reference = storage().ref(audioFile);
  let task = reference.getDownloadURL();

  //HOOKS
  const isPlaying = useSelector((state) => state.player.isPlaying);
  const [iconSwitch, setIconSwitch] = useState(faPlay);
  const [soundObject, setSoundObject] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  // LOAD AUDIO SETTINGS
  useEffect(() => {
    const audioSettings = async () => {
      try {
        await Audio.setAudioModeAsync({
          allowsRecordingIOS: false,
          interruptionModeIOS: Audio.INTERRUPTION_MODE_IOS_DO_NOT_MIX,
          playsInSilentModeIOS: true,
          interruptionModeAndroid: Audio.INTERRUPTION_MODE_ANDROID_DUCK_OTHERS,
          shouldDuckAndroid: true,
          staysActiveInBackground: false,
          playThroughEarpieceAndroid: true,
        });
      } catch (e) {
        console.log(e);
      }
      audioSettings();
    };
  }, []);

  //CLEANUP FUNCTION
  useEffect(() => {
    Audio.setIsEnabledAsync(true);
    return function cleanUp() {
      reference.putFile(props.audioFile).cancel();
      Audio.setIsEnabledAsync(false);
    };
  }, []);

  // STOP PLAY ON PAGE EXIT
  useEffect(() => {
    ifPlaying();
  }, [isPlaying]);

  const ifPlaying = async () => {
    if (isPlaying === false && soundObject != null) {
      await soundObject.stopAsync();
      await soundObject.unloadAsync();
      setSoundObject(null);
      setIconSwitch(faPlay);
    }
  };

  // PLAY PAUSE TOGGLE
  const handlePlayPause = async () => {
    setIsLoading(true);
    let uri = await task;

    //PLAY
    if (isPlaying === false && soundObject === null) {
      const soundObject = new Audio.Sound();
      await soundObject.loadAsync({ uri }, isPlaying, true);
      setSoundObject(soundObject);
      soundObject.playAsync();
      dispatch(playPause(true));
      setIconSwitch(faPause);

      // PAUSE
    } else if (isPlaying === true && soundObject != null) {
      dispatch(playPause(false));
      setIconSwitch(faPlay);

      // STOP AND PLAY
    } else if (isPlaying === true && soundObject === null) {
      dispatch(stopPlay(true));
      dispatch(playPause(true));
      const soundObject = new Audio.Sound();
      const status = { shouldPlay: true };
      await soundObject.loadAsync({ uri }, status, true);
      setSoundObject(soundObject);
      soundObject.playAsync();
      setIconSwitch(faPause);

      // RESUME PLAY
    } else if (isPlaying === false && soundObject != null) {
      dispatch(playPause(true));
      soundObject.playAsync();
      setIconSwitch(faPause);
    }
    setIsLoading(false);
  };

  console.log(isPlaying);

  if (isLoading) {
    return <SmallIndicator />;
  }

  return (
    <TouchableOpacity onPress={handlePlayPause}>
      <FontAwesomeIcon icon={iconSwitch} size={35} color={Colors.primary} />
    </TouchableOpacity>
  );
};

export default PlayPause;

The PlayPause Component is located in my SongItem Component, I wont add the code that isn't applicable.

const SongItem = (props) => {
  return (
    <View>
      <PurchaseModal
        visible={modalToggle}
        purchaseSelector={purchaseSelector}
        radio_props={LicenseData}
        onPress={modalToggleHandler}
      />
      <View>
        <Card>
          <BodyText>{props.items.name}</BodyText>
          <View style={styles.innerContainer}>
            <PlayPause audioFile={props.items.audio} />
            <TouchableOpacity onPress={cartPress}>
              <FontAwesomeIcon
                icon={iconSwitch}
                size={35}
                color={Colors.primary}
              />
            </TouchableOpacity>
          </View>
          <TouchableOpacity onPress={modalToggleHandler} style={toggleStyle}>
            <FontAwesomeIcon
              icon={faFileInvoice}
              size={35}
              color={Colors.primary}
            />
          </TouchableOpacity>
        </Card>
      </View>
    </View>
  );
};

The SongItem is located on my SongScreen. when I call dispatch(stopPlay) I am switching isPlaying to false;

const SongScreen = (props) => {
  const filteredSongs = useSelector((state) => state.filter.filteredSongs);
  const { goBack } = props.navigation;
  const dispatch = useDispatch();
  const backPress = () => {
    dispatch(stopPlay());
    goBack();
  };

  useEffect(() => {
    props.navigation.addListener("didBlur", () => {
      dispatch(stopPlay());
    });
  });

    return (
      <Gradient>
        <FlatList
          removeClippedSubviews={false}
          windowSize={2}
          maxToRenderPerBatch={6}
          data={filteredSongs}
          keyExtractor={(item) => item.id.toString()}
          renderItem={(itemData) => <SongItem items={itemData.item} />}
        />
        <MainButton name={"Back"} onPress={backPress} />
      </Gradient>
    );
  }
};
2

There are 2 answers

1
user2803039 On BEST ANSWER

I have struggled with the same challenge even asked expo directly and checked their source code, to find out there is no way to cancel and already loading Audio.

What i did was solve it with the help of setOnPlaybackStatusUpdate callback.

Explaining: if i want to cancel a song first i have to wait until it has loaded and with the help of setOnPlaybackStatusUpdate it's possible to immediately stop and unload audio after it finishes loading.

so your stopPlay function for me would look like this:

 try{
        await audio.stopAsync();
        audio.setOnPlaybackStatusUpdate(null);
    }catch(e){
        //Error thrown if audio is still loading
        //Wait until it has finished loading and stop it
        const stopListener = new StopListener();
        audio.setOnPlaybackStatusUpdate(stopListener.getListener())
    }

The StopListener would look like this

class StopListener {

    getListener = () => async (status) => {
        const audio = ... //Get the loading audio object here

        audio.setOnPlaybackStatusUpdate(null);
        await audio.stopAsync();
    }
}

Please share if you have found any other solution.

1
ulou On

Looks like you are using react life cycles not properly.

  // STOP PLAY ON PAGE EXIT
  useEffect(() => {
    ifPlaying();
  }, [isPlaying]);

this is not correct (at least it does't do what comment says). Instead it says: when isPlaying flag changed, then execute ifPlaying.

You need something like this to make your async code stop:

  // STOP PLAY ON PAGE EXIT
  useEffect(() => {
    return cleanUp() {
       ifPlaying();
    }
  });

Anything what will you return in useEffect will be executed before your component will be destroyed.