ryanheise / just_audio

Audio Player
1.03k stars 652 forks source link

Add new loop mode that plays mediaItem only once and then pauses in a ConcatenatingAudioSource #380

Open arshrahman opened 3 years ago

arshrahman commented 3 years ago

Is your feature request related to a problem? Please describe. ConcatenatingAudioSource plays all mediaItem in it. There's no option to play a particular MediaItem only once and then pause the Audioplayer once the mediaItem completes playing.

Listening to ProcessingState also doesn't seem to solve this problem since ProcessingState.completed is only called when the last MediaItem completes playing.

Describe the solution you'd like A solution would be add a new looping option LoopMode.once which plays the mediaItem exactly once and then pauses the Audioplayer.

Describe alternatives you've considered I have tried the following code snippet but it doesn't work.

 await audioPlayer.play();
 if (audioPlayer.loopMode == LoopMode.off) onPause();

If there's any other way to achieve this, that would be great.

Additional context None

ryanheise commented 3 years ago

This is an important feature. I'm not sure on the best API, so I'd like to think about alternative API designs before choosing one.

arshrahman commented 3 years ago

@ryanheise thanks for the swift response. Are there any workaround approaches that I can implement now?

ryanheise commented 3 years ago

The alternatives would be to not use a ConcatenatingAudioSource. You could just define your own list of items:

final List<AudioSource> items = ...;

And to play one, do something like this:

Future<void> playItem(int index) async {
  await _player.setAudioSource(items[index]);
  await _player.play();
  await _player.pause();
}

This may or may not be efficient in terms of audio buffer loading, depending on your requirements, but you can test it and see. If it's not efficient enough and you want to preload all items, you may need to create items.length different players, each one loading just one item and holding it for when you need it. But this would use a lot more resources so you'd also need to test whether you're running into OS limits, depending on how many items you're simultaneously loading into different players.

arshrahman commented 3 years ago

I managed to do a workaround based on your suggestion with some tweaks. I repurposed LoopMode.off to behave like LoopMode.once.

So, when setting AudioSource, I check LoopMode.

int nowPlayingIndex;

Future<void> setAudioSourceWithInitialPosition({int initialIndex, Duration initialPosition = Duration.zero}) async {
    nowPlayingIndex = initialIndex;

    if (audioPlayer.loopMode == LoopMode.off)
      audioPlayer.setAudioSource(queue[initialIndex], initialPosition: initialPosition);
    else
      audioPlayer.setAudioSource(
        ConcatenatingAudioSource(children: queue),
        preload: false,
        initialIndex: initialIndex,
        initialPosition: initialPosition,
      );
}

Then, I keep track of the nowPlayingIndex so that when LoopMode changes, there will be a smooth transition.

audioPlayer.durationStream.listen((duration) {
      if (audioPlayer.loopMode == LoopMode.off) return;

      final index = audioPlayer.currentIndex;
      if (index != null && queue != null && queue.length > index) {
        queue[index] = queue[index].copyWith(duration: duration);
        nowPlayingIndex = index;
        }));
      }
    });

I reset AudioSource again when LoopMode changes.

    await audioPlayer.setLoopMode(loopMode);
    if (nowPlayingIndex != null)
      setAudioSourceWithInitialPosition(initialIndex: nowPlayingDuaIndex, initialPosition: audioPlayer.position);

Lastly, I disabled skipToPrevious and skipToNext when LoopMode is off

This works though I need to do more testing and this really is a workaround.

arshrahman commented 3 years ago

forgot to include this,

audioPlayer.processingStateStream.listen((state) {
      switch (state) {
        case ProcessingState.completed:
          if (audioPlayer.loopMode == LoopMode.off) onPause();
          break;
        default:
          break;
      }
    });

this handles the playback state and calls pause when playing is completed

xxjonesriderxx commented 3 years ago

If your only working with LoopMode.all this should be an easy way to achive your goal:

_handleAndroidAutoPause(){ Rx.combineLatest2<Duration, Duration, PositionData>(androidPlayer.positionStream, androidPlayer.durationStream, (position, duration) => PositionData(position: position, duration: duration)).listen((positionData) { if(positionData.position.inSeconds>=positionData.duration.inSeconds && androidPlayer.playing) { androidPlayer.seekToNext().then((value) => androidPlayer.pause()); } }); }

just call that method after player init

ryanheise commented 3 years ago

@xxjonesriderxx unfortunately you can't rely on pause to happen quickly enough to definitely prevent a bit of sound coming out from the next playlist item. Maybe that is sufficient for some use cases though, but if not, the workaround would be to take the ConcatenatingAudioSource into your own hands and position the current item as the last item temporarily using the move method.

zeyus commented 2 years ago

just adding my two cents into the convo, this feature would be a lifesaver for me, I've implemented this in a very fragile way but it seems to work for now (and as long as it's stable enough for me to gather data at the moment, then it's good enough, but not for future plans.

I've done it as @ryanheise suggested using ConcatenatingAudioSource and a listener for playbackEventStream

Here's a generic version of what I've done:


enum PlaybackState {
  playing,
  completed,
  stopped,
}

class MyPlayerWrapper {
  final player = AudioPlayer(handleInterruptions: false);
  static ConcatenatingAudioSource? _experimentPlaylist;
  bool _playlistLoaded = false;
  VoidCallback? _onPlayComplete;
  int _currentIndex = -1;

  MyPlayerWrapper({VoidCallback? onPlayComplete}) {
    if (onPlayComplete != null) {
      this.onPlayComplete = onPlayComplete;
    }
  }

  Future<void> loadExperiment() async {
    // prepare playlist here
    if (!_playlistLoaded) {
      await player.setAudioSource(_experimentPlaylist!);
      _playlistLoaded = true;
      player.playbackEventStream.listen((event) async {
        if (_playerState == PlaybackState.playing &&
            event.processingState == ProcessingState.completed) {
          if (event.updatePosition >= event.duration!) {
            stateChangeListener(PlaybackState.completed);
            Future.delayed(Duration(milliseconds: 100), () async {
              debugPrint("Pausing playback...");
              await player.pause();
              debugPrint("Moving item ${event.currentIndex} to $_currentIndex");
              await _experimentPlaylist!
                  .move(event.currentIndex!, _currentIndex);
            });
          }
        }
      });
    }
  // handle state manually
  PlaybackState _playerState = PlaybackState.stopped;
  Future<void> play() {
    int lastIndex = _experimentPlaylist!.length - 1;
    await _experimentPlaylist!.move(_currentIndex, lastIndex).then((_) async {
      debugPrint(
          "moved target (index: $_currentIndex) to end of playlist (index: $lastIndex)");
      if (player.currentIndex != lastIndex) {
        await player.seek(Duration.zero, index: lastIndex);
        debugPrint("Player seek to $lastIndex");
      }
      await player.play();
    });
  }

  void stateChangeListener(PlaybackState state) {
    debugPrint('StateChangeListener: $state');
    if (_playerState == state) {
      debugPrint('no state change, returning');
      return;
    }
    _playerState = state;

    if (state == PlaybackState.completed) {
      _onPlayComplete?.call();
    }
  }

  // for externally setting index/step
  set currentIndex(int index) {
    _currentIndex = index;
  }

}

Obviously the above is untested as I ripped out all the project-specific code, but it works so far using this method.

I do have one question about this implementation though, why does it fail with a Bad state: Cannot fire new event. Controller is already firing an event exception when I don't include the Future.delayed in the playbackEventStream.listen callback?

I forgot to say huge thanks to @ryanheise for all the hard work! It's definitely appreciated and the fact that I could even workaround like this means I can have consistent timing for my experiment trial pages, which is absolutely critical (I tried every way with audioplayers and nothing could stop some audio clips, even repeated ones, taking ages to load intermittently)...so thank you!

Edit: while I'm happy I switched to just_audio I realised I may have thrown unnecessary shade at audioplayers (at least not confirmed) because I found a bug (?) in the flutter framework causing async events to not fire for really long periods of time (5+ seconds) when I had a placeholder StatelessWidget waiting on a ChangeNotifierProvider, to force event firing I had to put a dirty while loop with a notifyListeners every 25ms to force event firing (and unfortunately some widget rebuilding)...

audioPrompt.onPlayStart = () async {
        while (!promptComplete) {
          await Future.delayed(const Duration(milliseconds: 25), () {
            notifyListeners();
          });
        }
      };
ryanheise commented 2 years ago

Hi @zeyus , thanks for sharing your implementation. It should come in handy to anyone else who is trying to solve the same use case while waiting for a feature explicitly for this.

Regarding the error, it might be related to (or the same as) this:

https://stackoverflow.com/questions/66513675/silence-between-tracks-in-just-audio/66514017#66514017

zeyus commented 2 years ago

@ryanheise that's great, thanks just checked it out and it makes sense to me now! :)