Audio trying to update unmounted Component on Android

#1

Hello!

I’m building first ReactNative app and am super grateful to only use Javascript with Expo – Thank you!

We’re moving towards a Standalone app eventually, but are still wrapping up basic functionality and styling.

Our app features an audio player (using Expo’s Audio api) that will allow for playing one (meditation) track at a time.

Using the example app + Stream’s audio-only version, I put together a player that appears to work fine in iOS but in Android (device) I get the error: “Warning: Can only update a mounted or mounting component. This usually means you called setState…”

Here’s when it happens:
MediationPlayerScreen opens
Press play (track plays and then finishes)
Hit back button (GET ABOVE ERROR)

And the error keeps logging new instances that, to me, implies that _onPlaybackStatusUpdate is continuing to fire even though:
if (status.didJustFinish) {
this.playbackInstance.unloadAsync()
}
(again, in iOS, no error and the didJustFinish seems to unload instance.)

My debugging console.logs aren’t showing up when running on the Android.

Summary – the Component works, but error message indicates not working properly in Android.

Can you help? Code for offending Screen below ( I removed timestamp code to simplify). Many thanks!

Shawn

/**
 * @flow
 */

import React, { Component } from 'react';
import {
  Dimensions,
  Image,
  Platform,
  Slider,
  StyleSheet,
  Text,
  TouchableHighlight,
  View
} from 'react-native';
import { Icon } from 'react-native-elements';
import { MaterialIcons } from '@expo/vector-icons';

import { Asset, Audio } from 'expo';

const ICON_TRACK = require('../assets/images/line-white-thin.png');
const ICON_THUMB = require('../assets/images/dot-sm.png');

const { width: DEVICE_WIDTH, height: DEVICE_HEIGHT } = Dimensions.get('window');
const BACKGROUND_COLOR = '#000';
const DISABLED_OPACITY = 0.5;
const FONT_SIZE = 14;
const LOADING_STRING = '... loading ...';


class MeditationPlayerScreen extends Component {
  static navigationOptions = ({ navigation }) => ({
		title: 'iSleep',
		headerLeft:
			<Icon
				name='navigate-before'
				size={32}
				onPress={ () => navigation.goBack() }
			/>,
		headerStyle: { marginTop: Platform.OS === 'android' ? 24: 0 }
	});

  constructor(props) {
    super(props);
    this.playbackInstance = null;
    this.isSeeking = false;
    this.shouldPlayAtEndOfSeek = false;
    this.state = {
      meditationTrack: this.props.navigation.state.params.meditation,
      playbackInstanceName: LOADING_STRING,
      playbackInstancePosition: null,
      playbackInstanceDuration: null,
      shouldPlay: false,
      isPlaying: false,
      isLoading: true
    };
  }

  componentDidMount() {
    Audio.setAudioModeAsync({
      allowsRecordingIOS: false,
      interruptionModeIOS: Audio.INTERRUPTION_MODE_IOS_DO_NOT_MIX,
      playsInSilentModeIOS: true,
      shouldDuckAndroid: false,
      interruptionModeAndroid: Audio.INTERRUPTION_MODE_ANDROID_DO_NOT_MIX
    });

    this._loadNewPlaybackInstance(false);
  }

  async _loadNewPlaybackInstance(playing) {
    if (this.playbackInstance != null) {
      await this.playbackInstance.unloadAsync();
      this.playbackInstance.setOnPlaybackStatusUpdate(null);
      this.playbackInstance = null;
    }

    const initialStatus = {
      shouldPlay: playing
    };

    const source = this.state.meditationTrack.id == 1 ? require('../assets/sounds/test-audio.mp3') : require('../assets/sounds/test-audio-2.mp3');
    
    try  {
      const { sound, status } = await Audio.Sound.create(
        source,
        initialStatus,
        this._onPlaybackStatusUpdate
      )
      this.playbackInstance = sound;
      this._updateScreenForLoading(false);
    } catch(e) {
      console.log("Problem creating sound object: ", e)
    }
  } 

 _updateScreenForLoading(isLoading) {
    if (isLoading) {
      this.setState({
        isPlaying: false,
        playbackInstanceName: LOADING_STRING,
        playbackInstanceDuration: null,
        playbackInstancePosition: null,
        isLoading: true
      });
    } else {
      this.setState({
        playbackInstanceName: this.state.meditationTrack.title,
        isLoading: false
      });
    }
  }

  _onPlaybackStatusUpdate = status => { 
    if (!status.isLoaded) {
      // Update your UI for the unloaded state
      if (status.error) {
        console.log(`Encountered a fatal error during playback: ${status.error}`);
        // Send Expo team the error on Slack or the forums so we can help you debug!
      }
    } else {
      if (status.isLoaded) {
        this.setState({
          playbackInstancePosition: status.positionMillis,
          playbackInstanceDuration: status.durationMillis,
          shouldPlay: status.shouldPlay,
          isPlaying: status.isPlaying,
        });
        if (status.didJustFinish) {
          this.playbackInstance.unloadAsync()
        }
      } else {
        if (status.error) {
          console.log(`FATAL PLAYER ERROR: ${status.error}`);
        }
      }
    }
  };

  _onPlayPausePressed = () => {
    if (this.playbackInstance != null) {
      if (this.state.isPlaying) {
        this.playbackInstance.pauseAsync();
      } else {
        this.playbackInstance.playAsync();
      }
    }
  };

  render() {
    return(
      <View style={styles.container}>
        
        <Image
          source={ require('../assets/images/meditation-on-beach.jpg') }
          style={styles.image}
          resizeMode='contain'
        />
        <Text style={styles.title}>
          {this.state.playbackInstanceName}
        </Text>
        <Text style={styles.title}>
          YOGA NIDRA
        </Text>
        <View style={styles.round}>
          <TouchableHighlight
              underlayColor={BACKGROUND_COLOR}
              onPress={this._onPlayPausePressed}
              disabled={this.state.isLoading}
            >
            <View>
              {this.state.isPlaying ? (
                <MaterialIcons
                  name="pause"
                  size={50}
                  color="#56D5FA"
                />
              ) : (
                <MaterialIcons
                  name="play-arrow"
                  size={50}
                  color="#56D5FA"
                />
              )}
            </View>
          </TouchableHighlight>
        </View>
      </View>
    )
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    paddingBottom: '10%',
    backgroundColor: BACKGROUND_COLOR
  },
  image: {
    flex: 1, 
    height: DEVICE_WIDTH * .5,
    width: DEVICE_WIDTH,
  },
  title: {
    color: '#fff',
    fontWeight: 'bold',
    fontSize: 20,
    paddingBottom: 10
  },
  timestampRow: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    alignSelf: 'stretch',
    maxHeight: FONT_SIZE * 2
  },
  text: {
    color: '#fff',
    fontSize: 12
  },
  playbackSlider: {
    width: DEVICE_WIDTH * .6
  },
  round: {
    height: 70,
    width: 70,
    borderRadius: 35,
    justifyContent: 'center',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#fff',
    backgroundColor: BACKGROUND_COLOR
  }
});

export default MeditationPlayerScreen;

1 Like
Audio plays but limited count
#2

Minor update: console.logs from Android showed! WooHoo!

They verified that _onPlaybackStatusUpdate is continuing to run and the “isLoaded” condition is being met.

For some reason, (status.didJustFinish) is never true, yet it IS hit in iOS.

Ideas? Thanks!

#3

hi @shawnte-- i think this is maybe just a bug in the expo audio code that there is a difference between ios and android.

i’ll try to find someone to look at it but right now we don’t have anyone looking at audio so it might take some time to get to.

it seems like you were able to work around the issue here? is that correct?

#4

Thanks for the heads up @ccheever, much appreciated.
I don’t currently have a work around. I need the behavior called for in didJustFinish.

The odd thing is that it did work one time (earlier today!!)… Sadly I can’t recreate it.

#5

maybe you could try setting a flag in componentWillUnmount and then skipping whatever is triggering the problem when that flag is set?

#6

Newbie Q here. What do you mean ‘setting’ a flag? I’m unclear as to what I have access to inspecting in componentWillUnmount. Thank you for your patience!!

#7

@shawnte – basically in componentWillUnmount if you set something like this.unmounted = true; then in _onPlaybackStatusUpdate don’t call this.setState({ ... }) if this.unmounted is true. This way, once the component is unmounted, you won’t do this.setState({ ... }).

The issue was that this.setState({ ... }) is being called after unmounting, as highlighted in the error message. Ideally this.playbackInstance.unloadAsync() should actually immediately invalidate the triggering of _onPlaybackStatusUpdate, but since it isn’t doing that, for now this could be a way to work around that.

1 Like
#8

I think my android device is possessed. Or my code is off.

Android never hits the unmounted = true conditional and the _onPlaybackStatusUpdate function continues to log after navigating away from the component.

Would you mind looking at what I did?

1. set initial state (unmounted = false)

constructor(props) {
    super(props);
    this.playbackInstance = null;
    this.isSeeking = false;
    this.shouldPlayAtEndOfSeek = false;
    this.state = {
      meditationTrack: this.props.navigation.state.params.meditation,
      playbackInstanceName: LOADING_STRING,
      playbackInstancePosition: null,
      playbackInstanceDuration: null,
      shouldPlay: false,
      isPlaying: false,
      isLoading: true,
      modalVisible: false,
      closingMessage: '',
      unmounted: false
    };
  }

2. Only setState when unmounted is not true

_onPlaybackStatusUpdate = status => { 
    if (!status.isLoaded) {
      // Update your UI for the unloaded state
      if (status.error) {
        console.log(`Encountered a fatal error during playback: ${status.error}`);
        // Send Expo team the error on Slack or the forums so we can help you debug!
      }
    } else {
      if (status.isLoaded) {
      console.log("status.isLoaded")

        if (this.unmounted) {
            console.log("IF THIS.UNMOUNTED IS TRUE")
        } else {
          console.log("in this.unmounted conditional")
          this.setState({
            playbackInstancePosition: status.positionMillis,
            playbackInstanceDuration: status.durationMillis,
            shouldPlay: status.shouldPlay,
            isPlaying: status.isPlaying,
          });
        }

        if (status.didJustFinish) {
          console.log(
            `AUDIO UPDATE : Finished meditation`
            );
          this.playbackInstance.unloadAsync()
          this._getRandomClosingMessage();
          this._setModalVisible(!this.state.modalVisible);
        }
      } else {
        if (status.error) {
          console.log(`FATAL PLAYER ERROR: ${status.error}`);
        }
      }
    }
  };

3. change unmounted to true

  componentWillUnmount() {
    this.setState({ unmounted: true })
  }
#9

Woot! Nevermind on the unmounted state.

Digging around some more, t’would almost seem that the functions being called in the slider are Not being called (this would be a React Native issue, I believe). I.e. no console.logs appear from either:
onValueChange={this._onSeekSliderValueChange}
onSlidingComplete={this._onSeekSliderSlidingComplete}

Not sure what that’s about but it led to the Slider Value…

Value begins at 0 and goes to 1, so I put in a conditional that checks for 1 and the player stops as expected!! Yay!

 _getSeekSliderPosition() {
    if (
      this.playbackInstance != null &&
      this.state.playbackInstancePosition != null &&
      this.state.playbackInstanceDuration != null
    ) {
      if (this.state.playbackInstancePosition / this.state.playbackInstanceDuration == 1) {
        this.playbackInstance.unloadAsync()
      }
      else {
        return (
          this.state.playbackInstancePosition /
          this.state.playbackInstanceDuration
        );
      }
    }
    return 0;
  };
2 Likes
#10

Be careful about setting an unmounted flag via setState. This problem is an example of React attempting to set component state even after the component is unmounted. Similarly, the ‘setState’ call to change the unmounted flag in ‘componentWillUnmount’ could itself get resolve after the component has unmounted. One technique is to move the flag outside of state and make it part of the component itself so it can be accessed using 'this.unmounted'. This technique has worked for me so far.

I think a better solution would be the ability to stop onPlaybackStatusUpdate in componentWillUnmount. You wouldn’t have happened to find that yet, have you?

This article discusses the related issue more in depth: https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html

Edit:
Found a way to stop the status updates so that an unmounted flag is not required.

await this.sound.unloadAsync().then(() => {
        console.log('******** sound unloaded ********');
        this.props.onComplete(this.state.soundFileInfo);
      });

allows you to wait until the unloadAsync is completed before doing something that unmounts the component.

2 Likes
#11

Can someone here look at my post and help me with it ? Expo Audio plays only one song and one time only!
I’ll thank you very much !!