Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ConcatenatingAudioSource with lots of children takes a very long time to load #294

Open
smkhalsa opened this issue Feb 1, 2021 · 96 comments
Assignees
Labels
3 testing bug Something isn't working

Comments

@smkhalsa
Copy link
Contributor

smkhalsa commented Feb 1, 2021

Which API doesn't behave as documented, and how does it misbehave?

Creating a ConcatenatingAudioSource with lots (~1000) of children takes a very long time (>20 seconds).

In my application, users can have playlists with an arbitrary number of items.

Minimal reproduction project

To Reproduce (i.e. user steps, not code)

final player = AudioPlayer();
final songs = /// 1000+ sources
await player.setAudioSource(
  ConcatenatingAudioSource(children: songs),
);

Error messages

Expected behavior

I'd expect to be able to set the audio source and start playing the first source within a couple seconds, even with a large number of sources (since only the initial few sources are actually being fetched).

Screenshots

Desktop (please complete the following information):

  • OS: MacOS

Smartphone (please complete the following information):

  • Device: iPhone 12 Pro Max Simulator

Flutter SDK version

[✓] Flutter (Channel beta, 1.25.0-8.3.pre, on Mac OS X 10.15.7 19H114 darwin-x64, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2)
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio (version 4.1)
[✓] VS Code (version 1.52.1)
[✓] Connected device (2 available)

• No issues found!

Additional context

NOTE added by @ryanheise :

Adding a large number of children is problematic at multiple levels due to the single-threaded model. For now, there are three approaches to workaround this issue:

  1. Initialise a ConcatenatingAudioSource with zero children, and then add the children in a loop that yields every now and then to let other coroutines execute on the same thread. This can be done for example by periodically calling await Future.delayed(Duration.zero);.
  2. Initialise a ConcatenatingAudioSource with an initial sublist of children small enough to load without issue, and then write your own application logic to lazily add more children as needed (just-in-time). If you want to create the illusion that the list of children is complete from the beginning, you will need to write your UI around some other more complete list of metadata, separate from ConcatenatingAudioSource's own internal list of items.
  3. There is an experimental branch feature/treadmill (now merged) that implements the logic of (2) above but on the iOS native side (Android already has this implemented on the native side when useLazyPreparation is true). This will stop iOS from trying to load every item in the playlist, and instead only load items that are nearer to the front of the queue. If you test this branch, please share below whether you found it stable enough to release.
@smkhalsa smkhalsa added 1 backlog bug Something isn't working labels Feb 1, 2021
@ryanheise
Copy link
Owner

Hi @smkhalsa unfortunately I will need you to fill in the sections as instructed. In the section called "User steps, not code", you actually copied and pasted code. This should be in the previous section "Minimal reproduction project" which you have left empty, but which should contain a link to a git repository that I can clone and test.

@ryanheise
Copy link
Owner

This issue affects both iOS and macOS. I have a reproduction case on #206 which has helped me to reproduce this issue.

The solution is to rewrite the enqueueFrom method so that it enqueues only the next few items, and whenever the player advances to the next item, we should add another item onto the end. This behaviour should be the default, but it should also be tied to the ConcatenatingAudioSource.useLazyPreparation option.

This is a tricky area of the code to work on since it can easily break things, but it is an important feature to add, and as long as it undergoes sufficient testing, it should be worth doing.

I've marked this issue as "fixing" which means it is on my priority list. That said, there are some other things that were already on my priority list, such as the visualizer, and the null-safe release of audio_service (which will be based on the one-isolate model).

@suragch
Copy link

suragch commented Mar 15, 2021

@ryanheise The enqueFrom you are talking about is here? So that's not something we could handle on plugin user side, right?

I was noticing similar behavior with 43 items in the playlist. My UI freezes while they are loading on macOS and iOS. On the iOS simulator the freeze lasted 30 seconds. Web and Android are fine.

Can you think of any temporary workarounds I could pursue on my side?

@ryanheise
Copy link
Owner

That's correct. The solution involves improving the enqueueFrom so that it can enqueue just a few items ahead instead of the whole list, when the useLazyPreparation option is set to true. That involves modifying the Objective C code in the plugin.

As for a workaround, I can think of 2 things to try:

  1. The easiest thing to try (although I don't know whether it will have any effect) is to play with the new buffering options available in the dev branch, which you can pass into the constructor of AudioPlayer. With these options (in particular preferredForwardBufferDuration), you may be able to limit the size of the forward buffer and thus reduce the time spent loading. The problem with this is that iOS isn't guaranteed to follow your preferred buffer parameters, and even if it did, it still may need to load a little bit of each track.
  2. Forego just_audio's playlist management and implement the lazy loading within your own app. So let's say you have a list of 43 items, you could create a ConcatenatingAudioSource with just the first 3 items in it. Then when playback approaches the 3rd item, you can dynamically insert more items onto the end of it. The drawback is that you can't simply use just_audio's state to tell you what's in your entire playlist, because it'll only know about the sublist of items you've added, so you'll need to maintain your own full playlist state outside of just_audio.

@suragch
Copy link

suragch commented Mar 15, 2021

I forgot about the possibility of adding items to an existing playlist. That's a good idea. Thank you!

@YaarPatandarAA
Copy link

YaarPatandarAA commented Mar 30, 2021

Just to add onto this, I have only noticed this issue on MacOS release/debug and only on iOS Simulator. This issue is not present, at least for me, on Physical iOS device whether release or debug. Android is Fine.
I am adding 100 tracks at once.

@FallenChromium
Copy link

As far as I understand, the question that I have is heavily related to this issue (though not exactly relevant, I use Android and it isn't a "bug" per say, it's just that retrieving 40+ URLs is time-costly with the API I use)

I am not sure if I can inject a function to lazy load the track URL in AudioSource objects, so that the URL retrieval (and also caching) will kick in only when the track is about to begin playing. Did I get it correctly that it's now not an option, unless I make an "external" playlist state and control just_audio according to that playlist?

@ryanheise
Copy link
Owner

ConcatenatingAudioSource.useLazyPreparation is already implemented on Android, it just needs to be implemented on iOS/macOS.

@FallenChromium
Copy link

As far as I understood, useLazyPreparation is used to delay caching, but not the retrieval of children in ConcatenatingAudioSource, so, if I use API which, for example, returns download links which are valid only for 1 minute, this is not an option for me. I was initially supposing that after setting this bool, the children themselves will be resolved lazily. So, it's not related to the issue? How can I achieve this kind of effect using just_audio then?

@ryanheise
Copy link
Owner

This issue is about iOS/macOS. If you have found an Android bug or have an Android feature request, it will be appreciated to open a separate issue for that.

@ertgrulll
Copy link

Any progress about the issue? I'm developing an app for windows/mac and player is freezing for 4-5 seconds when i add 60 song.

@ddfreiling
Copy link

This bug should have high priority in my opinion. Would solving it require switching to an AVAudioQueuePlayer and letting the platform handle prefetching?

@ryanheise
Copy link
Owner

The current AVQueuePlayer will work fine, but the enqueue logic should be rewritten to enqueue a configurable number of items, and at the same time, the gapless looping approach should be adapted to this. It's going to take a while to develop and test.

However, you can work around this by doing the same sort of queue management in dart. That is, only add to the concatenating audio source as many children as you want to actually load, then lazily add more children just before they're needed.

@ryanheise
Copy link
Owner

Also I'll quote my earlier comment from above which I think may do a better job of explaining how you could achieve your use case:

  1. Forego just_audio's playlist management and implement the lazy loading within your own app. So let's say you have a list of 43 items, you could create a ConcatenatingAudioSource with just the first 3 items in it. Then when playback approaches the 3rd item, you can dynamically insert more items onto the end of it. The drawback is that you can't simply use just_audio's state to tell you what's in your entire playlist, because it'll only know about the sublist of items you've added, so you'll need to maintain your own full playlist state outside of just_audio.

Incidentally, @ddfreiling , I would also recommend clicking the 👍 on this issue to vote for it. I do try to fix critical bugs quickly regardless of votes, but since this issue is not a critical bug (you can take resource management into your own hands to some extent with the above workaround), and since I'm dealing with a great number of requests and have to prioritise what I work on next based on helping the greatest number of people, the votes do count.

The alternative is that if you can't wait for me personally to work on this, you can consider becoming a contributor, since those who need it will often have the strongest motivation to help work on it. I can't say it's an easy one to work on, though. First, it requires Objective C knowledge, and then second it also requires studying and comprehending what is probably the most complex part of the iOS implementation, and how it fits together with the rest of the implementation.

@ryanheise
Copy link
Owner

For those subscribed to this issue, there is a related issue that can cause the same symptom of blocking the UI thread, but has a simpler solution.

Basically, if you are constructing a very large list of children to pass into ConcatenatingAudioSource in a loop like this:

for (var i = 0; i < 100000; i++) {
  children.add(...);
}

Then that code itself will block the UI thread because it does not yield to co-routines. You can fix that for example by inserting await Future.delayed(Duration.zero); inside the loop.

I mention this just in case anyone experiencing the UI blocking issue actually has this other easier-to-fix issue.

@pro100svitlo
Copy link

Any update on this issue?
For me UI locking even with 100 items (around 7-10 sec).
Quite sad experience...

@ryanheise
Copy link
Owner

This will always be problematic to some degree because of Dart's single threaded model. Fortunately there are ways to deal with this which I have already mentioned (in my edit at the bottom of the very first post).

@mhutshow
Copy link

mhutshow commented Feb 5, 2022

Hi. I have tried a lot to play with your workaround. I was programatically adding the track in the list. But unfortunately it didn't work for me.

It's a humble request to you. Please work for a fix. We love your work, You made it amazing. <3

Hope this will be fixed soon.

@ryanheise
Copy link
Owner

I know that people are using that workaround successfully (lazily adding items to the playlist just-in-time so that you don't try to load them all at once). I suggest you keep trying to implement that workaround because it is going to be much harder to build multi-threaded behaviour into the plugin and you'll be waiting much longer.

@mhutshow
Copy link

mhutshow commented Feb 5, 2022

@ ryanheise : Can you provide us a code example that how can I lazily load items?
I tried this one.

for (var i = 0; i < 100000; i++) {
  children.add(...);
 await Future.delayed(Duration.zero);
}

@pro100svitlo
Copy link

For me lazy loading didn't work.
But i implemented another suggestion: instead adding whole list at once I added 3 items: previous, current and next.
Then if user click next, or previous, i add one new playlist item at the start of at the end of the list.

To be honest - it looks ugly. But when it's covered with the tests - somehow you can trust it

@mhutshow
Copy link

mhutshow commented Feb 5, 2022

I think lazy loading will work. Because if it listen to the list that I am adding new item then it will work.

But in my case the problem is when it loads, it loads with initial items. Then I keep adding the file by a for loop. But it don't see the changes (new files). After playing the initial files it stops.

I think if we get an example code from @ryanheise then it will be easy to implement the feature.

Let's wait with a hope from @ryanheise .

@ryanheise
Copy link
Owner

In the case of .addAll or passing all children into the constructor, there are several points in the code that cause the slowdown. The treadmill branch addresses only one of them, which is to prevent the native player on iOS from trying to load the entire playlist all at once. But there are still 2 other bottlenecks. One is passing a large playlist over the flutter method channel all at once, and the other is purely on the dart side when processing long lists. Dart is not multi-threaded, so any time we do a for loop over a long list, that is potentially going to lock up the UI.

If you do have a very long playlist, yes, you do have to just avoid passing very long lists into ConcatenatingAudioSource and instead use it more dynamically so that you maintain your own Dart list containing your entire playlist, but you use ConcatenatingAudioSource to only ever contain the next 3 items to play. When you reach the end of the current track, you remove it from the front and add a new track onto the back so that you always have 3 items queued up for gapless playback.

The long term future direction of the plugin should be to shift the internal implementation somewhat in this direction. There are complications to that, so there is no short term quick fix, apart from using ConcatenatingAudioSource within your app in a more dynamic way as described above.

@jagpreetsethi
Copy link

@ryanheise Is there any example code available that I may refer that follows your suggested approach to fill ConcatenatingAudioSource dynamically by 3 items only? Thanks in advance!

@hunterwilhelm
Copy link

@ryanheise thanks for that quick response! That makes sense. Someday I'll be knowledgeable enough on the topic to be able to assist with the amazing work you do!

@jagpreetsethi Once I finish my implemention of what Ryan is talking about, I'll post it here. It will include shuffling.

@burhanaksendir
Copy link

Hi @ryanheise

I've been trying to include the just_audio package in my Flutter project using its Git repository as the source. However, I've been encountering an issue when specifying the Git reference in the pubspec.yaml file.

Here's how I'm adding the package to my pubspec.yaml file:

just_audio:
  git:
    url: https://github.com/ryanheise/just_audio.git
    ref: feature/treadmill

However, every time I attempt to fetch the package, I get an error indicating that the pubspec.yaml file cannot be found in the specified Git reference:

Could not find a file named "pubspec.yaml" in https://github.com/ryanheise/just_audio.git 02f7451.

The reference I'm using is: feature/treadmill, and I've verified that it exists in the repository: https://github.com/ryanheise/just_audio/tree/feature/treadmill.

I've tried cleaning the cache, updating Flutter packages, and even cloning the repository locally to test, but the issue persists.

Is there something I might be missing or any suggestions you can provide to help me resolve this? I greatly appreciate your assistance in this matter.

Thank you!

@ryanheise
Copy link
Owner

It's because just_audio isn't in the root directory of this repo, it's in the just_audio subdirectory, so you need to specify that with path: just_audio. So:

just_audio:
  git:
    url: https://github.com/ryanheise/just_audio.git
    ref: feature/treadmill
    path: just_audio

@Eldar2021
Copy link

For me this feature/treadmill works good. I would ask you to merge it. Thank you for the nice package and your support.

@hunterwilhelm
Copy link

@jagpreetsethi I implemented what @ryanheise was suggesting. However, I wasn't able to figure out shuffling while preloading without having any visual glitches or refreshes when switching between shuffle mode and non-shuffle mode. So I left it out. If anyone can modify it to add it, I would be very grateful.

The Implementation:
import 'dart:async';
import 'dart:math' show max, min;

import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import 'package:quiver/iterables.dart' show range;
import 'package:rxdart/rxdart.dart';

Future<AudioHandler> initAudioService() async {
  return await AudioService.init(
    builder: () => MyAudioHandler(),
    config: const AudioServiceConfig(
      androidNotificationChannelId: 'com.example.app.audio',
      androidNotificationChannelName: 'Example App',
      androidNotificationOngoing: true,
      androidStopForegroundOnPause: true,
    ),
  );
}

class PlaylistManager {
  final _player = AudioPlayer();
  final _subPlaylist = ConcatenatingAudioSource(
    children: [],
    useLazyPreparation: false, // I want it to pre-cache everything I put in this playlist.
  );

  /// A copy of the media items so we can access them later
  final List<MediaItem> _subPlaylistItems = [];

  /// The full list of media items to pull from
  final List<MediaItem> _fullPlaylist = [];

  /// Used for keeping track of how many tracks are in front of the current index of the player.
  int _playlistFrontLoadingCount = 0;

  /// Change this if you want the index to not start at the beginning
  static const kDefaultIndex = 0;

  /// Keeps track of the current index of the _fullPlaylist
  int _currentFullPlaylistIndex = kDefaultIndex;

  /// Used for cleaning up the subscriptions when disposed
  final List<StreamSubscription> _streamSubscriptions = [];

  /// Used for preventing race conditions in the _lazyLoadPlaylist
  bool _updatePlayerRunning = false;

  /// Used for preventing race conditions in the _lazyLoadPlaylist
  bool _updatePlayerWasCalledWhileRunning = false;

  /// Used for not skipping too many times if the [_subPlaylist] isn't done loading yet.
  int _directionOfSkipTo = 0;

  // PUBLIC VARS / GETTERS

  /// Notifies the system of the current media item
  final BehaviorSubject<MediaItem> mediaItem = BehaviorSubject();

  /// Use this to get the live data of what the state is of the player
  ///
  /// Don't perform actions like skip on this object because there is extra logic
  /// attached to the functions in this class. For example, use [seekToNext] instead of [player.seekToNext]
  AudioPlayer get player => _player;

  /// The index of the next item to be played
  int get nextIndex => _getRelativeIndex(1);

  /// The index of the previous item in play order
  int get previousIndex => _getRelativeIndex(-1);

  /// The index of the current index.
  int get currentIndex => _currentFullPlaylistIndex;

  /// How many tracks next and previous are loaded into ConcatenatingAudioSource
  /// * Ex. 3 would look like [`prev3`, `prev2`, `prev1`, `current`, `next1`, `next2`, `next3`]
  final int preloadPaddingCount;

  PlaylistManager({this.preloadPaddingCount = 3}) {
    _attachAudioSourceToPlayer();
    _listenForCurrentSongIndexChanges();
  }

  /// Used for loading the tracks. This will reset the current index and position.
  setQueue(List<MediaItem> mediaItems) async {
    _fullPlaylist.clear();
    _fullPlaylist.addAll(mediaItems);
    _subPlaylist.clear();
    _subPlaylistItems.clear();
    _lazyLoadPlaylist();
  }

  /// Use this instead of [player.seekToNext]
  Future<void> seekToNext() async {
    _directionOfSkipTo = 1;
    await _player.seekToNext();
  }

  /// Use this instead of [player.seekToPrevious]
  Future<void> seekToPrevious() async {
    _directionOfSkipTo = -1;
    await _player.seekToPrevious();
  }

  Future<void> _attachAudioSourceToPlayer() async {
    try {
      await _player.setAudioSource(_subPlaylist);
    } catch (e) {
      print("Error: $e");
    }
  }

  void _listenForCurrentSongIndexChanges() {
    int? previousIndex = _player.currentIndex;
    _streamSubscriptions.add(_player.currentIndexStream.listen((index) {
      _updateMediaItem();
      _lazyLoadPlaylist();

      final previousIndex_ = previousIndex;
      previousIndex = index;
      if (previousIndex_ == null || index == null) return;
      final delta = index - previousIndex_;

      if (delta.sign == _directionOfSkipTo.sign) {
        _currentFullPlaylistIndex += delta;
        _playlistFrontLoadingCount += delta;
      }
    }));
  }

  int _getRelativeIndex(int offset) {
    return max(0, min(_currentFullPlaylistIndex + offset, _fullPlaylist.length - 1));
  }

  _updateMediaItem() {
    if (_subPlaylistItems.isEmpty) return;
    final playerIndex = _player.currentIndex;
    if (playerIndex == null) return;

    final newMediaItem = _subPlaylistItems[playerIndex];
    mediaItem.add(newMediaItem);
  }

  _lazyLoadPlaylist() async {
    // prevent race conditions
    if (_updatePlayerRunning) {
      _updatePlayerWasCalledWhileRunning = true;
      return;
    }
    _updatePlayerRunning = true;

    final currentIndex_ = _currentFullPlaylistIndex;
    final playerIndex = _player.currentIndex ?? 0;

    // Pad/pre-cache the ending of the playlist
    final currentNextPadding = max(0, _subPlaylist.length - playerIndex);
    var nextCountToAdd = preloadPaddingCount - currentNextPadding + 1;
    if (nextCountToAdd > 0 && _fullPlaylist.isNotEmpty) {
      for (final iNum in range(nextCountToAdd)) {
        var mediaItem = _fullPlaylist[iNum.toInt() + currentIndex_];
        await _subPlaylist.add(_createAudioSource(mediaItem));
        _subPlaylistItems.add(mediaItem);
        await Future.microtask(() {});
      }
    }

    // Pad/pre-cache the beginning of the playlist
    final currentPreviousPadding = _player.currentIndex ?? 0;
    final previousCountToAdd = preloadPaddingCount - currentPreviousPadding;
    if (previousCountToAdd > 0) {
      for (int i = 1; i <= previousCountToAdd; i++) {
        var index = currentIndex_ - currentPreviousPadding - _playlistFrontLoadingCount - i;
        if (index < 0 || _fullPlaylist.length <= index) continue;
        var mediaItem = _fullPlaylist[index];
        final future = _subPlaylist.insert(0, _createAudioSource(mediaItem));
        _subPlaylistItems.insert(0, mediaItem);
        _playlistFrontLoadingCount++;
        await future;
        await Future.microtask(() {});
      }
    }

    _updateMediaItem();

    // prevent race conditions
    _updatePlayerRunning = false;
    if (_updatePlayerWasCalledWhileRunning) {
      _updatePlayerWasCalledWhileRunning = false;
      Future.microtask(() {
        _lazyLoadPlaylist();
      });
    }
  }

  UriAudioSource _createAudioSource(MediaItem mediaItem) {
    return AudioSource.uri(
      Uri.parse(mediaItem.extras!['url'] as String),
      tag: mediaItem,
    );
  }

  Future<void> dispose() async {
    for (final subscription in _streamSubscriptions) {
      await subscription.cancel();
    }
    _streamSubscriptions.clear();
    return _player.dispose();
  }
}

class MyAudioHandler extends BaseAudioHandler {
  final _playlistManager = PlaylistManager();
  final List<StreamSubscription> _streamSubscriptions = [];

  MyAudioHandler() {
    _notifyAudioHandlerAboutPlaybackEvents();
    _listenForDurationChanges();
    _listenForCurrentSongIndexChanges();
  }

  void _notifyAudioHandlerAboutPlaybackEvents() {
    _streamSubscriptions.add(_playlistManager.player.playbackEventStream.listen((PlaybackEvent event) {
      final playing = _playlistManager.player.playing;
      playbackState.add(playbackState.value.copyWith(
        controls: [
          MediaControl.skipToPrevious,
          if (playing) MediaControl.pause else MediaControl.play,
          MediaControl.stop,
          MediaControl.skipToNext,
        ],
        systemActions: const {
          MediaAction.seek,
        },
        androidCompactActionIndices: const [0, 1, 3],
        processingState: const {
          ProcessingState.idle: AudioProcessingState.idle,
          ProcessingState.loading: AudioProcessingState.loading,
          ProcessingState.buffering: AudioProcessingState.buffering,
          ProcessingState.ready: AudioProcessingState.ready,
          ProcessingState.completed: AudioProcessingState.completed,
        }[_playlistManager.player.processingState]!,
        repeatMode: const {
          LoopMode.off: AudioServiceRepeatMode.none,
          LoopMode.one: AudioServiceRepeatMode.one,
          LoopMode.all: AudioServiceRepeatMode.all,
        }[_playlistManager.player.loopMode]!,
        shuffleMode: (_playlistManager.player.shuffleModeEnabled) ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
        playing: playing,
        updatePosition: _playlistManager.player.position,
        bufferedPosition: _playlistManager.player.bufferedPosition,
        speed: _playlistManager.player.speed,
        queueIndex: event.currentIndex,
      ));
    }));
  }

  void _listenForDurationChanges() {
    _streamSubscriptions.add(_playlistManager.player.durationStream.listen((duration) {
      final oldMediaItem = _playlistManager.mediaItem.valueOrNull;
      if (oldMediaItem == null) return;
      final newMediaItem = oldMediaItem.copyWith(duration: duration);
      mediaItem.add(newMediaItem);
    }));
  }

  void _listenForCurrentSongIndexChanges() {
    _streamSubscriptions.add(_playlistManager.mediaItem.listen((newMediaItem) {
      final newMediaItemWithDuration = newMediaItem.copyWith(duration: _playlistManager.player.duration);
      mediaItem.add(newMediaItemWithDuration);
    }));
  }

  @override
  Future<void> addQueueItems(List<MediaItem> mediaItems) async {
    throw UnimplementedError("addQueueItems");
  }

  @override
  Future<void> addQueueItem(MediaItem mediaItem) async {
    throw UnimplementedError("addQueueItem");
  }

  @override
  Future<void> updateQueue(List<MediaItem> mediaItems) async {
    // notify system
    final newQueue = mediaItems;
    queue.add(newQueue);
    _playlistManager.setQueue(mediaItems);
  }

  @override
  Future<void> removeQueueItemAt(int index) async {
    throw UnimplementedError("removeQueueItemAt");
  }

  @override
  Future<void> play() => _playlistManager.player.play();

  @override
  Future<void> pause() => _playlistManager.player.pause();

  @override
  Future<void> seek(Duration position) => _playlistManager.player.seek(position);

  @override
  Future<void> skipToQueueItem(int index) async {
    throw UnimplementedError("skipToQueueItem");
  }

  @override
  Future<void> skipToNext() async {
    _playlistManager.seekToNext();
  }

  @override
  Future<void> skipToPrevious() async {
    if (_playlistManager.player.position.inSeconds > 5 || _playlistManager.currentIndex == 0) {
      return _playlistManager.player.seek(Duration.zero);
    } else {
      return _playlistManager.seekToPrevious();
    }
  }

  @override
  Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
    throw UnimplementedError("setRepeatMode");
  }

  @override
  Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
    throw UnimplementedError("setShuffleMode");
  }

  @override
  Future<void> customAction(String name, [Map<String, dynamic>? extras]) async {
    if (name == 'dispose') {
      for (final subscription in _streamSubscriptions) {
        await subscription.cancel();
      }
      _streamSubscriptions.clear();

      await _playlistManager.dispose();
      super.stop();
    }
  }

  @override
  Future<void> onTaskRemoved() async {
    stop();
    super.onTaskRemoved();
  }

  @override
  Future<void> stop() async {
    await _playlistManager.player.stop();
    return super.stop();
  }
}

@hunterwilhelm
Copy link

@ryanheise Is there a way to disable preloading/pre-caching entirely for ConcatenatingAudioSource? I don't see an option to turn it off on iOS.

@ryanheise
Copy link
Owner

No, that is in fact why you are working around it by only adding a limited number of items to the playlist. You need to have at least the current and the next item in the playlist in order to get gapless playback. If you want to get more sophisticated, you could listen to the current position, and when it reaches 15-20 seconds before reaching the end of the track, that's when you can add the next item to the playlist, so that it then starts buffering it just in time.

@adamhaqiem
Copy link

No, that is in fact why you are working around it by only adding a limited number of items to the playlist. You need to have at least the current and the next item in the playlist in order to get gapless playback. If you want to get more sophisticated, you could listen to the current position, and when it reaches 15-20 seconds before reaching the end of the track, that's when you can add the next item to the playlist, so that it then starts buffering it just in time.

Would it be a problem if I don't remove the previous item and just keep adding for as much as I want?

@ryanheise
Copy link
Owner

If you keep adding without ever removing, you will eventually end up with a very large list that could again slow down communication between Dart and iOS.

@Eldar2021
Copy link

Can you please update branch(feature/treadmill)?

@ryanheise
Copy link
Owner

What specifically would you like me to update about it?

@burhanaksendir

This comment has been minimized.

@ryanheise

This comment has been minimized.

@ulutashus
Copy link

@ryanheise Is there any reason, known issue to not merge feature/treadmill branch? First impression, it works just fine for me. I am planning to publish it to our production if there is no known issue. Btw Is this branch changes affecting Android too?

@ryanheise
Copy link
Owner

I think one more thing I would like to do is make this behaviour sensitive to the useLazyPreparation parameter, or even add a separate parameter to specifically control the iOS/macOS specific behaviour. In this case, perhaps the parameter would control the size of the treadmill. I welcome any feedback on this.

@ryanheise
Copy link
Owner

or even add a separate parameter to specifically control the iOS/macOS specific behaviour. In this case, perhaps the parameter would control the size of the treadmill. I welcome any feedback on this.

The only reason to want to specify the size of the treadmill is for short queue items where items are so short that having two items on the treadmill is not enough of a buffer to ensure that we will not hit the end of the buffer and stall playback. However, I think a boolean option such as useLazyPreparation might suffice after all. Apps that are using "really" short items where this is a consideration probably aren't the intended use case for lazy loading anyway, and they have two options:

  1. Switch off the option. The entire playlist may load fast enough anyway if the items are really short.
  2. Implement their own treadmill in Dart using the mutating methods in ConcatenatingAudioSource.

So unless there are any objections, I may just tie this to useLazyPreparation with a treadmill size of 2.

@ryanheise
Copy link
Owner

Update: I have made substantial changes to the feature/treadmill branch in order to support the useLazyPreparation option, along with a couple of other bug fixes/enhancements.

Please let me know if this update breaks anything in your apps before I officially merge it into the next release.

@MohammadElKhatib
Copy link

Update: I have made substantial changes to the feature/treadmill branch in order to support the useLazyPreparation option, along with a couple of other bug fixes/enhancements.

Please let me know if this update breaks anything in your apps before I officially merge it into the next release.

Working great, I am using it to load 114 files with large files such as 2 hours length with no issues

@bekchan
Copy link

bekchan commented Mar 18, 2024

Update: I have made substantial changes to the feature/treadmill branch in order to support the useLazyPreparation option, along with a couple of other bug fixes/enhancements.

Please let me know if this update breaks anything in your apps before I officially merge it into the next release.

Works awesome with 1000+ ConcatenatingAudioSources in playlist.

@ryanheise
Copy link
Owner

Thanks for the testing results @MohammadElKhatib and @bekchan .

Have either of you had any experience with how this interacts with shuffle mode, loop modes, or edge cases such as where there are only 1 or 0 items in the playlist?

@MohammadElKhatib
Copy link

Thanks for the testing results @MohammadElKhatib and @bekchan .

Have either of you had any experience with how this interacts with shuffle mode, loop modes, or edge cases such as where there are only 1 or 0 items in the playlist?

Well I believe it is working so well as I mentioned previously, first it did not work well but maybe it was caching the default branch and once I cleared cache and re-run the application, it is working awesome

I am using it for Quran application where there is multiple reciters so basically 114 files with around 15 reciters and what i am doing is loading all the files and replace it once changing the reciters also I am listening to the sound progress updates in order to highlight text in parallel with sound.

thanks for your efforts waiting the merge with main branch :-)

@ryanheise
Copy link
Owner

Thanks, @MohammadElKhatib . Are you using either/both of shuffle and loop modes, or is that not a pertinent feature in your app?

@MohammadElKhatib
Copy link

Thanks, @MohammadElKhatib . Are you using either/both of shuffle and loop modes, or is that not a pertinent feature in your app?

I used the loop options but I faced one issue I am not sure if this is the default behavior, what happened is that I used a button to toggle the loop and repeat. I have 3 options repeat one, repeat all and no repeat basically repeat one is repeating the current sound normally, repeat all is going through all the files and repeat from first index after reaching the last index while the no repeat is keep going forward till the last sound and stop there.

while what I wanted is to only play one sound and stop so I am not sure if this is a bug or default behavior or I am missing something here.

Note: I am also using background service and syncing text and progress alltogether.

below screenshot of my app

IMG_8010

@ryanheise
Copy link
Owner

while what I wanted is to only play one sound and stop so I am not sure if this is a bug or default behavior or I am missing something here.

If you have a ConcatenatingAudioSource, it is the normal behaviour to auto-advance to the next child after completing the current one. There is another feature request to make that configurable, but until then you could listen to the PositionDiscontinuity event type and when it is autoAdvance, you can handle that event by pausing. The playlist example contains some code that shows how to listen for this event, since it uses it to show a toast whenever auto-advancing. Since this is a workaround until the aforementioned feature is implemented, it is not going to be perfect, though, because the time between when you receive the event and when you invoke pause() and wait for it to actually pause won't be zero, and so there is a chance that if the next audio doesn't have any silent padding at the start, you might hear a few milliseconds of the next audio before it pauses.

@MohammadElKhatib
Copy link

while what I wanted is to only play one sound and stop so I am not sure if this is a bug or default behavior or I am missing something here.

If you have a ConcatenatingAudioSource, it is the normal behaviour to auto-advance to the next child after completing the current one. There is another feature request to make that configurable, but until then you could listen to the PositionDiscontinuity event type and when it is autoAdvance, you can handle that event by pausing. The playlist example contains some code that shows how to listen for this event, since it uses it to show a toast whenever auto-advancing. Since this is a workaround until the aforementioned feature is implemented, it is not going to be perfect, though, because the time between when you receive the event and when you invoke pause() and wait for it to actually pause won't be zero, and so there is a chance that if the next audio doesn't have any silent padding at the start, you might hear a few milliseconds of the next audio before it pauses.

Honestly, the current situation is acceptable in my app, I will keep following your updates.

Thanks again for your great work.

@bekchan
Copy link

bekchan commented Mar 26, 2024

Have either of you had any experience with how this interacts with shuffle mode, loop modes, or edge cases such as where there are only 1 or 0 items in the playlist?

Shuffle and loop modes works great. Also tested reorderable playlist with +1000 audio, still good.
Thank you for this great package.

@ryanheise
Copy link
Owner

I have just merged and published the feature/treadmill branch. Thank you very much to those who helped to test it. This issue will remain open since there are 2 other sources of lag that can be introduced by long lists. One is that the Dart iterators don't yield, which may be an issue on very long lists, and the other is (or was?) possibly the lag from passing a huge message over the method channels all at once. I have to test whether this is still an issue. But of course in the meantime, apps can work around this by creating an empty ConcatenatingAudioSource and incrementally building it up rather than initialising it all in one go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3 testing bug Something isn't working
Projects
None yet
Development

No branches or pull requests