Search code examples
react-nativeexpoexpo-av

Why is playground music not stopping when I change screens?


This is my first time working with React Native together with Expo and I have been learning a lot, but this is something I am still unable to fix.

I am using expo-av and for short sounds I am not having any problem. They play and then I call unloadAsync() and no issues there. But when it comes to the background music, I am stopping, unloadind, making sure the useEffect is returning but for some reason even after the game is over and I am sent to the game over or victory screen, the music keeps playing and if for some reason I try to start another game, it will be one audio on top of the other.

Inside the class responsible for seting up playback:

 class AudioController {

    constructor() {

        this.bgMusic = new Audio.Sound()
        this.flipSound = new Audio.Sound()
        this.matchSound = new Audio.Sound()
        this.victorySound = new Audio.Sound()
        this.gameOverSound = new Audio.Sound()
    }

    
    loadAndPlay = async (audioObject, audioFile, loop = false, volume = 1.0) => {

        try {
            await audioObject.loadAsync(audioFile);
            audioObject.setIsLoopingAsync(loop);
            audioObject.setVolumeAsync(volume);
            await audioObject
                .playAsync()
                .then(async playbackStatus => {

                    if (!loop) {
                        setTimeout(() => {
                            audioObject.unloadAsync()
                        }, playbackStatus.playableDurationMillis)
                    }
                })
                .catch(error => {
                    console.log(error)
                })

        } catch (error) {
            console.log(error);
        }
    }


    playBgMusic = async () => {
        await this.loadAndPlay(this.bgMusic, bgMusic, true, 0.5); // Loop and set the volume to 50%
    }


    stopBgMusic = async () => {
        try {

            await this.bgMusic.stopAsync();
            await this.bgMusic.unloadAsync();

        } catch (error) {
            console.log(error);
        }
    }

    playFlipSound = async () => {
        await this.loadAndPlay(this.flipSound, flipSound);
    }

    playMatchSound = async () => {
        await this.loadAndPlay(this.matchSound, matchSound);
    }

    playVictorySound = async () => {
        await this.loadAndPlay(this.victorySound, victorySound);
    }

    playGameOverSound = async () => {
        await this.loadAndPlay(this.gameOverSound, gameOverSound);
    }
}


export default AudioController``



Inside the component where I want to use it.

`useEffect(() => {

    audioController.playBgMusic()

    resetTurn()
    setTimer(level)
    shuffleCards()

    return () => {

       console.log(' Start game cleanup executed')

audioController.stopBgMusic()
}
}, []);`

Of course I am properly instancing the audioController (or the other sounds would not be working) and I am triggering the game over and victory functions. So the problem is either with the way I am trying to stop the music or the component is not unmounting. I have also tried to stop the music inside the fuction that handles the game over right before it sends me to a different screen and still the same. Also tried using React Context and the result is the same, the music will not stop and new instances will start if I try to start a new game.


Solution

  • react native navigation lifecycle doesnt work like react you need to listen to focus and blur event to detect if the component is hidded or shown just like this :

      useEffect(() => {
        const subscribe = navigation.addListener('focus', () => {
          // Screen was focused
          // Do something
          console.log('play sound');
    
          controller.playBackgroundMusic();
        });
    
        const unsubscribe = navigation.addListener('blur', () => {
          // Screen was blurred
          // Do something
          console.log(' Start game cleanup executed');
          controller.stopBackgroundMusic();
        });
    
        return unsubscribe;
      }, []);
    

    you can learn more at :

    https://reactnavigation.org/docs/navigation-lifecycle/
    

    This is a small demo how to play and stop background music using native react :

    SandBox : https://snack.expo.dev/EjVXFzojr

    App.js

    import {NavigationContainer} from '@react-navigation/native';
    
    // or any files within the Snack
    import GameScene from './components/GameScene';
    import IntroScene from './components/IntroScene';
    import {createNativeStackNavigator} from '@react-navigation/native-stack';
    
    const Stack = createNativeStackNavigator();
    
    export default function App() {
      return (
        <NavigationContainer>
          <Stack.Navigator>
            <Stack.Screen
              name="Intro"
              component={IntroScene}
              options={{title: 'Intro'}}
            />
            <Stack.Screen name="Game" component={GameScene} options={{title: 'Game'}} />
          </Stack.Navigator>
    
        </NavigationContainer>
      );
    }
    

    ./controllers/AudioController.js

    import {Audio} from "expo-av"
    
    class AudioController {
      constructor() {
        this.backgroundMusic = new Audio.Sound();
      }
    
      loadAndPlay = async (audioObject, audioFile, loop = false, volume = 1.0) => {
        try {
          await audioObject.loadAsync(audioFile);
          audioObject.setIsLoopingAsync(loop);
          audioObject.setVolumeAsync(volume);
          await audioObject
            .playAsync()
            .then(async (playbackStatus) => {
              if (!loop) {
                setTimeout(() => {
                  audioObject.unloadAsync();
                }, playbackStatus.playableDurationMillis);
              }
            })
            .catch((error) => {
              console.log(error);
            });
        } catch (error) {
          console.log(error);
        }
      };
    
      playBackgroundMusic = async () => {
    
        await this.loadAndPlay(
          this.backgroundMusic,
          require('../assets/FitGirl-Repacks.mp3'),
          true,
          1
        ); // Loop and set the volume to 50%
      };
    
      stopBackgroundMusic = async () => {
        try {
          await this.backgroundMusic.stopAsync();
          await this.backgroundMusic.unloadAsync();
        } catch (error) {
          console.log(error);
        }
      };
    }
    export function audioController(){
      return new AudioController()
    }
    

    ./components/IntroScene.js

    import React, { useState, useEffect } from 'react';
    import { Text, View, StyleSheet, Image, Button } from 'react-native';
    import { audioController } from '../controllers/AudioController';
    
    export default function IntroScene({ navigation }) {
      const startHandler = (event) => {
        navigation.navigate('Game');
      };
    
      const controller = audioController();
    
      useEffect(() => {
        const subscribe = navigation.addListener('focus', () => {
          // Screen was focused
          // Do something
          console.log('play sound');
    
          controller.playBackgroundMusic();
        });
    
        const unsubscribe = navigation.addListener('blur', () => {
          // Screen was focused
          // Do something
          console.log(' Start game cleanup executed');
          controller.stopBackgroundMusic();
        });
    
        return unsubscribe;
      }, []);
    
      return (
        <View style={styles.container}>
          <Text style={styles.paragraph}>Intro Game</Text>
          <Button title="start" onPress={(event) => startHandler(event)} />
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        alignItems: 'center',
        justifyContent: 'center',
        padding: 24,
      },
      paragraph: {
        margin: 24,
        marginTop: 0,
        fontSize: 14,
        fontWeight: 'bold',
        textAlign: 'center',
      },
    });
    

    ./components/GameScene.js

    import { Text, View, StyleSheet, Image } from 'react-native';
    
    export default function GameScene() {
      return (
        <View style={styles.container}>
          <Text style={styles.paragraph}>
            Play The Game
          </Text>
          <Image style={styles.logo} source={require('../assets/snack-icon.png')} />
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        alignItems: 'center',
        justifyContent: 'center',
        padding: 24,
      },
      paragraph: {
        margin: 24,
        marginTop: 0,
        fontSize: 14,
        fontWeight: 'bold',
        textAlign: 'center',
      },
      logo: {
        height: 128,
        width: 128,
      }
    });