Repository: ryanheise/just_audio

Issue:
Errors not reported + stuck in playing when `preload: false`
<!--    ALL SECTIONS BELOW MUST BE COMPLETED   -->
**Which API doesn't behave as documented, and how does it misbehave?**
`player.setAudioSource` with `preload: false, initialIndex: ...`. When trying to play an invalid URL in this way, errors are never reported to the application and the player does not automatically jump to the next item.

**Minimal reproduction project**
https://github.com/chengyuhui/just_audio/tree/error-flood
Replaced URLs to invalid ones with additional change on L81, disabling preload with `initialIndex`.

**To Reproduce (i.e. user steps, not code)**
Steps to reproduce the behavior:
1. Launch the example
2. Tap play
3. See that the player is stuck in playing state
4. Tap the third item (which is also invalid)
5. See that the player also stucks in playing state, with nothing actually playing

**Error messages**
```
Launching lib/main.dart on MIX 2S in debug mode...
✓ Built build/app/outputs/flutter-apk/app-debug.apk.
Connecting to VM Service at ws://127.0.0.1:34255/k5grn-W0nnY=/ws
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->getUnsafe()Lsun/misc/Unsafe; (greylist,core-platform-api, linking, allowed)
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->objectFieldOffset(Ljava/lang/reflect/Field;)J (greylist,core-platform-api, linking, allowed)
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->compareAndSwapObject(Ljava/lang/Object;JLjava/lang/Object;Ljava/lang/Object;)Z (greylist, linking, allowed)
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->putObject(Ljava/lang/Object;JLjava/lang/Object;)V (greylist, linking, allowed)
I/t_audio_exampl(19603): ProcessProfilingInfo new_methods=523 is saved saved_to_disk=1 resolve_classes_delay=8000
I/flutter (19372): core onAudioFocusChanged
W/t_audio_exampl(19603): Accessing hidden method Landroid/media/AudioTrack;->getLatency()I (greylist, reflection, allowed)
I/ExoPlayerImpl(19603): Init 9e73da9 [ExoPlayerLib/2.13.1] [polaris, MIX 2S, Xiaomi, 29]
D/AudioManager(19603): getStreamVolume isRestricted mode = 0
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->compareAndSwapObject(Ljava/lang/Object;JLjava/lang/Object;Ljava/lang/Object;)Z (greylist, linking, allowed)
E/ExoPlayerImplInternal(19603): Playback error
E/ExoPlayerImplInternal(19603):   com.google.android.exoplayer2.ExoPlaybackException: Source error
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:579)
E/ExoPlayerImplInternal(19603):       at android.os.Handler.dispatchMessage(Handler.java:103)
E/ExoPlayerImplInternal(19603):       at android.os.Looper.loop(Looper.java:228)
E/ExoPlayerImplInternal(19603):       at android.os.HandlerThread.run(HandlerThread.java:67)
E/ExoPlayerImplInternal(19603):   Caused by: com.google.android.exoplayer2.upstream.HttpDataSource$HttpDataSourceException: Unable to connect
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:356)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultDataSource.open(DefaultDataSource.java:201)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.StatsDataSource.open(StatsDataSource.java:84)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.source.ProgressiveMediaPeriod$ExtractingLoadable.load(ProgressiveMediaPeriod.java:1015)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.Loader$LoadTask.run(Loader.java:415)
E/ExoPlayerImplInternal(19603):       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
E/ExoPlayerImplInternal(19603):       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
E/ExoPlayerImplInternal(19603):       at java.lang.Thread.run(Thread.java:919)
E/ExoPlayerImplInternal(19603):   Caused by: java.net.ConnectException: Failed to connect to /127.0.0.1:443
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.io.RealConnection.connectSocket(RealConnection.java:147)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.io.RealConnection.connect(RealConnection.java:116)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.StreamAllocation.findConnection(StreamAllocation.java:186)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.StreamAllocation.findHealthyConnection(StreamAllocation.java:128)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.StreamAllocation.newStream(StreamAllocation.java:97)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:289)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:232)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:465)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect(HttpURLConnectionImpl.java:131)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.DelegatingHttpsURLConnection.connect(DelegatingHttpsURLConnection.java:90)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:30)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.makeConnection(DefaultHttpDataSource.java:641)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.makeConnection(DefaultHttpDataSource.java:543)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:349)
E/ExoPlayerImplInternal(19603):       ... 7 more
E/AudioPlayer(19603): TYPE_SOURCE: Unable to connect
I/ExoPlayerImpl(19603): Release 9e73da9 [ExoPlayerLib/2.13.1] [polaris, MIX 2S, Xiaomi, 29] [goog.exo.core]
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->compareAndSwapObject(Ljava/lang/Object;JLjava/lang/Object;Ljava/lang/Object;)Z (greylist, linking, allowed)
Reloaded 1 of 802 libraries in 986ms.
I/flutter (19372): ### Stop
I/ExoPlayerImpl(19603): Init c9886bb [ExoPlayerLib/2.13.1] [polaris, MIX 2S, Xiaomi, 29]
D/AudioManager(19603): getStreamVolume isRestricted mode = 0
E/ExoPlayerImplInternal(19603): Playback error
E/ExoPlayerImplInternal(19603):   com.google.android.exoplayer2.ExoPlaybackException: Source error
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:579)
E/ExoPlayerImplInternal(19603):       at android.os.Handler.dispatchMessage(Handler.java:103)
E/ExoPlayerImplInternal(19603):       at android.os.Looper.loop(Looper.java:228)
E/ExoPlayerImplInternal(19603):       at android.os.HandlerThread.run(HandlerThread.java:67)
E/ExoPlayerImplInternal(19603):   Caused by: com.google.android.exoplayer2.upstream.HttpDataSource$HttpDataSourceException: Unable to connect
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:356)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultDataSource.open(DefaultDataSource.java:201)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.StatsDataSource.open(StatsDataSource.java:84)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.source.ProgressiveMediaPeriod$ExtractingLoadable.load(ProgressiveMediaPeriod.java:1015)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.Loader$LoadTask.run(Loader.java:415)
E/ExoPlayerImplInternal(19603):       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
E/ExoPlayerImplInternal(19603):       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
E/ExoPlayerImplInternal(19603):       at java.lang.Thread.run(Thread.java:919)
E/ExoPlayerImplInternal(19603):   Caused by: java.net.ConnectException: Failed to connect to /127.0.0.1:443
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.io.RealConnection.connectSocket(RealConnection.java:147)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.io.RealConnection.connect(RealConnection.java:116)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.StreamAllocation.findConnection(StreamAllocation.java:186)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.StreamAllocation.findHealthyConnection(StreamAllocation.java:128)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.StreamAllocation.newStream(StreamAllocation.java:97)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:289)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:232)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.HttpURLConnectionImpl.execute(HttpURLConnectionImpl.java:465)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.HttpURLConnectionImpl.connect(HttpURLConnectionImpl.java:131)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.DelegatingHttpsURLConnection.connect(DelegatingHttpsURLConnection.java:90)
E/ExoPlayerImplInternal(19603):       at com.android.okhttp.internal.huc.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:30)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.makeConnection(DefaultHttpDataSource.java:641)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.makeConnection(DefaultHttpDataSource.java:543)
E/ExoPlayerImplInternal(19603):       at com.google.android.exoplayer2.upstream.DefaultHttpDataSource.open(DefaultHttpDataSource.java:349)
E/ExoPlayerImplInternal(19603):       ... 7 more
E/AudioPlayer(19603): TYPE_SOURCE: Unable to connect
I/ExoPlayerImpl(19603): Release c9886bb [ExoPlayerLib/2.13.1] [polaris, MIX 2S, Xiaomi, 29] [goog.exo.core]
W/t_audio_exampl(19603): Accessing hidden method Lsun/misc/Unsafe;->compareAndSwapObject(Ljava/lang/Object;JLjava/lang/Object;Ljava/lang/Object;)Z (greylist, linking, allowed)

```
Notice that neither `A stream error occurred` nor `Error loading playlist` is here.

**Expected behavior**
The player should advance to the next item, and this error should be added to `playbackEventStream` or thrown.


**Smartphone (please complete the following information):**
 - Device: MI MIX 2S
 - OS: Android 10

**Flutter SDK version**
```
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 2.0.4, on Linux, locale zh_CN.UTF-8)
[✓] Android toolchain - develop for Android devices (Android SDK version 30.0.3)
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition (version 2020.3)
[✓] Connected device (2 available)
```

**Additional context**
The whole error handling situation seems to be hit-or-miss, maybe we need some general way to improve it?

Stop playing in the event of an error
<!--

        PLEASE READ CAREFULLY!





        FOR YOUR FEATURE REQUEST TO BE PROCESSED, YOU WILL NEED
        TO FILL IN ALL SECTIONS BELOW. DON'T DELETE THE HEADINGS.


        THANK YOU :-D


-->

**Is your feature request related to a problem? Please describe.**
The current queue implementation seems to continue to the next item when an error is encountered (at least on Android) even if I have `LoopMode.one`. This is sometimes not ideal.

**Describe the solution you'd like**
I would like to (at least have an option) stop playing in the event of an error.

**Describe alternatives you've considered**
I have tried to call `player.pause()` in `playbackEventStream`'s `onError`, but it seems to fight with the player's decision to play the next item, and the queue jumps around.

**Additional context**
None for now.

New playlist API
**Is your feature request related to a problem? Please describe.**

Currently `ConcatenatingAudioSource`s and `LoopingAudioSource`s can be nested resulting in a data structure that doesn't easily map onto audio_service's flat playlist structure (which in turn is determined by the way Android media sessions and the iOS equivalent are structured). These two classes were originally inspired by ExoPlayer's original API, but that has since been deprecated in favour of simplifying it down to a flat structure.

**Describe the solution you'd like**

A new top-level API on `AudioPlayer`:

```dart
class AudioPlayer {
  Future<void> addAudioSource(AudioSource audioSource)
  Future<void> insertAudioSource(int index, AudioSource audioSource)
  Future<void> addAudioSources(List<AudioSource> audioSources)
  Future<void> insertAudioSources(int index, List<AudioSource> audioSources)
  Future<void> removeAudioSourceAt(int index)
  Future<void> removeAudioSourceRange(int start, int end)
  Future<void> moveAudioSource(int currentIndex, int newIndex)
  Future<void> clearAudioSources()
}

@Deprecated('Use AudioPlayer.setAudioSources instead')
class ConcatenatingAudioSource extends AudioSource {}

@Deprecated('Use List.filled(N, audioSource) instead')
class LoopingAudioSource extends AudioSource {}
```

The classes `ConcatenatingAudioSource` and `LoopingAudioSource` can be deprecated, with audio sources being sequenced directly at the top level. The latter can be replaced by adding the same audio source N times in sequence.

**Describe alternatives you've considered**

Keep `ConcatenatingAudioSource` but disallow nested children.

(Although if this is the only audio source that behaves as a sequence and cannot have nested sequence, then it is justified to treat it as another type of thing other than an audio source.)

**Additional context**

N/A



Discussion: I think it's reasonable that errors aren't reported in the initial call if you requested it not to load, but you should still be able to catch the error either by a later explicit call to load or by listening to errors on the playbackState stream.

Is is unexpected that it doesn't automatically skip to the next item though.
The fact that the error is never reported to `playbackStateStream` and automatic skip does not work suggests that the Java side of the plugin might also missed the exception.
Sorry I realised I should have said the playbackEventStream.
That one does not work either.
https://github.com/ryanheise/just_audio/blob/e37b50d6cd6fcca8108cf083ff3bddd400c37acc/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java#L250-L265
Since we have `E/AudioPlayer(19603): TYPE_SOURCE: Unable to connect`, it is sure that we have reached there, and the stream listener has been registered on the Dart side (verified with breakpoints). So the error message somehow got lost in the channel?

After these lost errors, the player is unable to play anything when seeked to, even the valid files. It do start working on valid files if I pause and play again, and errors are reported normally from now on. This bug seems only happen if the initial file is unplayable?
sendError only sends the error if the event sink is not null. It's unlikely it would be null though, it would mean it tries to send an error too soon, before the event sink is initialised. On the other end, the error won't be received unless you're already listening to it before the error happens. Again, probably unlikely for this to go wrong either but there are the first two things I would check. Otherwise, maybe there's an unlogged exception happening somewhere within the plugin where it's crashing silently added failing to deliver the sent error. In that case the code needs extra try/catch logging.
`sendError` is very likely actually called, but I'm unsure if the event sink is fine.
In that case I'll need to debug the Android side then. Is this possible in Android Studio? I usually use VSCode for all my Flutter works.
They're all just text files after all. I use plain old Vim to edit everything so any other editor that can edit files should work.
Further debugging shows that `sendError` is indeed called and the `eventSink` is not null. So for some reason the error did not get through?

Putting breakpoint on the `onError` callback of `platform.playbackEventMessageStream` yields nothing, indicating that it is not called.

The player only returns to normal if I:
* Seek to a valid item
* Pause playback
* Start it again
If I tell Android Studio to pause on all exceptions, the connection error caused by connecting to the invalid URL keeps popping up, which means that the player looks never stopped (but it is released with `Release c9886bb [ExoPlayerLib/2.13.1] [polaris, MIX 2S, Xiaomi, 29] [goog.exo.core]`?)
The Android implementation was made to mirror iOS's behaviour, although if I can make iOS not automatically skip to the next track, I would agree your feature request sounds like more desirable behaviour.
We sometimes get errors on our CDN, and a JSON is returned in the place of the audio file, which gets cached and needs to be deleted.
Continue playing can sometimes cause confusion about which one is the current file that should be deleted (we can have multiple errors in a row, and since the platform channel does not guarantee an order, we may en up deleting the wrong file).
That is fair enough. I agree the proposed change would be helpful, or at least it should be configurable.

It is not my priority, but if someone reads this and wants to have a go at it, take a look at the `AVQueuePlayer.actionAtItemEnd` property. My memory of the iOS problem is that it would automatically advance to the next track on error, but you could perhaps temporarily change this property to prevent it advancing just while processing the error. In the code, the value of this property is set here in `AudioPlayer.m`:

```objc
- (void)updateEndAction {
    // Should be called in the following situations:
    // - when the audio source changes
    // - when _index changes
    // - when the loop mode changes.
    // - when the shuffle order changes. (TODO)
    // - when the shuffle mode changes.
    if (!_player) return;
    if (_audioSource && (_loopMode != loopOff || ([_order count] > 0 && [_orderInv[_index] intValue] + 1 < [_order count]))) {
        _player.actionAtItemEnd = AVPlayerActionAtItemEndAdvance;
    } else {
        _player.actionAtItemEnd = AVPlayerActionAtItemEndPause; // AVPlayerActionAtItemEndNone
    }
}
```
Based on my observation (I'm not an iOS developer), it seems that looping is actually implemented manually?

If that's the case, do we really need to do this, which is as hacky as how I delete invalid cache files?
Gapless looping was implemented in accordance with official guidance from Apple. It is a feature people want and there is no other way to do it. Your feature request doesn't present a problem for the current implementation of gapless looping. It would also be a lot of unnecessary work to change the whole gapless looping strategy, so let's leave it as is.
As per https://github.com/ryanheise/just_audio/issues/360#issuecomment-809185943 , I agree that we should try to prevent possible infinite loop in errors. However, the errors are not completely under the developer's control.

I accidentally discovered the behavior in #360 when my internet dropped during playing (and we are currently in the progress of migrating storage and CDN solution, other errors can happen), and I was unable to stop the playing because the errors came after the decision to jump. I don't think it is a good idea to leave the player in a bad state in this case (internet disconnection), from a user experience point of view.

In my case I might be able to ditch the built-in queue management and roll my own, but this does not apply to other users.
I think we need some careful combination of code to prevent infinite error loops (e.g. loop mode turned on, and every item errors out and skips to the next track), and some user-definable policy on whether an error either stops the player or skips to the next track.

Alternatively, I could try to make the iOS implementation more like ExoPlayer's default implementation which I think puts the player into the idle state after an error, after which the app must manually reload the audio to get it back into the ready state. I don't think ExoPlayer's behaviour is ideal either, though, because it is a shame to have to prepare the audio again for a whole playlist if only one of them errorred out.
Is there any way to know that the player has been "disabled" due to too many errors? I want to reset it but have no way to tell if it has died.
Possibly, you can listen to all of these errors on the `playbackEventStream` and count them. Once the count reaches 5, you can call `await stop()` and then `await load()`.
This should work fairly well, but we still need to figure out #390 where the error is somehow not reported.
I see that there is an error count in the Java code (I can locate when the counter is set, but not when it is used), does it reset upon successful plays?
The counter is used on the line after it's set.

```java
        errorCount++;
        if (player.hasNext() && currentIndex != null && errorCount <= 5) {
```

It does not reset, however, you can effectively reset it using the technique in my comment above:

> Possibly, you can listen to all of these errors on the `playbackEventStream` and count them. Once the count reaches 5, you can call `await stop()` and then `await load()`.

This has the effect of disposing of the native player and creating a new one, so it gets a new error counter starting at zero.


Fix patch:
diff --git a/.gitignore b/.gitignore
index 5d2e84b73..a6a57200c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,5 @@ build/
 coverage
 pubspec.lock
 Podfile.lock
+
+**/.cxx/
diff --git a/just_audio/CHANGELOG.md b/just_audio/CHANGELOG.md
index 2e6884520..d9f6a2ff8 100644
--- a/just_audio/CHANGELOG.md
+++ b/just_audio/CHANGELOG.md
@@ -1,6 +1,13 @@
-## 0.10.1
-
+## 0.10.0
+
+* New playlist API.
+* Deprecate ConcatenatingAudioSource - use playlist API instead.
+* Deprecate LoopingAudioSource - Use List.filled(N, source) instead.
+* Replace playbackEventStream.onError by errorStream.
+* Add errorCode/errorMessage to PlaybackEvent.
+* Add maxSkipsOnError constructor parameter.
 * Fix conversion between milliBel and deciBel (@Chaphasilor).
+* Bump min flutter version to 3.27.0, AGP to 8.5.2.
 
 ## 0.9.46
 
diff --git a/just_audio/README.md b/just_audio/README.md
index a141c401a..b7b7ce938 100644
--- a/just_audio/README.md
+++ b/just_audio/README.md
@@ -23,6 +23,14 @@ await player.setVolume(0.5);                    // Half as loud
 await player.stop();                            // Stop and free resources
 ```
 
+### Migrating to 0.10.x
+
+* iOS: You may remove the compile flag `AUDIO_SESSION_MICROPHONE=0` since this is now the default.
+* Instead of `player.setAudioSource(ConcatenatingAudioSource(children: sources))` use `player.setAudioSources(sources)`.
+* Instead of `LoopingAudioSource(child: source, count: N)` use `...List.filled(N, source)`.
+* Instead of listening to `player.playbackEventStream.onError`, listen to `player.errorStream`.
+* If you would like to emulate the previous skip-on-error setting, use constructor parameter `maxSkipsOnError: 6`.
+
 ### Working with multiple players
 
 ```dart
@@ -65,21 +73,16 @@ await player.setClip(); // Clear clip region
 
 ```dart
 // Define the playlist
-final playlist = ConcatenatingAudioSource(
-  // Start loading next item just before reaching it
-  useLazyPreparation: true,
-  // Customise the shuffle algorithm
-  shuffleOrder: DefaultShuffleOrder(),
-  // Specify the playlist items
-  children: [
-    AudioSource.uri(Uri.parse('https://example.com/track1.mp3')),
-    AudioSource.uri(Uri.parse('https://example.com/track2.mp3')),
-    AudioSource.uri(Uri.parse('https://example.com/track3.mp3')),
-  ],
+final playlist = <AudioSource>[
+  AudioSource.uri(Uri.parse('https://example.com/track1.mp3')),
+  AudioSource.uri(Uri.parse('https://example.com/track2.mp3')),
+  AudioSource.uri(Uri.parse('https://example.com/track3.mp3')),
+];
+// Load the playlist
+await player.setAudioSources(playlist, initialIndex: 0, initialPosition: Duration.zero,
+  useLazyPreparation: true,                    // Load each item just in time
+  shuffleOrder: DefaultShuffleOrder(),         // Customise the shuffle algorithm
 );
-
-// Load and play the playlist
-await player.setAudioSource(playlist, initialIndex: 0, initialPosition: Duration.zero);
 await player.seekToNext();                     // Skip to the next item
 await player.seekToPrevious();                 // Skip to the previous item
 await player.seek(Duration.zero, index: 2);    // Skip to the start of track3.mp3
@@ -87,9 +90,10 @@ await player.setLoopMode(LoopMode.all);        // Set playlist to loop (off|all|
 await player.setShuffleModeEnabled(true);      // Shuffle playlist order (true|false)
 
 // Update the playlist
-await playlist.add(newChild1);
-await playlist.insert(3, newChild2);
-await playlist.removeAt(3);
+await player.addAudioSource(newChild1);
+await player.insertAudioSource(3, newChild2);
+await player.removeAudioSourceAt(3);
+await player.moveAudioSource(2, 1);
 ```
 
 ### Working with headers
@@ -181,20 +185,14 @@ try {
   print('An error occured: $e');
 }
 
-// Catching errors during playback (e.g. lost network connection)
-player.playbackEventStream.listen((event) {}, onError: (Object e, StackTrace st) {
-  if (e is PlatformException) {
-    print('Error code: ${e.code}');
-    print('Error message: ${e.message}');
-    print('AudioSource index: ${e.details?["index"]}');
-  } else {
-    print('An error occurred: $e');
-  }
+// Listening to errors during playback (e.g. lost network connection)
+player.errorStream.listen((PlayerException e) {
+  print('Error code: ${e.code}');
+  print('Error message: ${e.message}');
+  print('AudioSource index: ${e.index}');
 });
 ```
 
-Note: In a future release, the exception type on `playbackEventStream` will change from `PlatformException` to `PlayerException`.
-
 ### Working with state streams
 
 See [The state model](#the-state-model) for details.
@@ -291,52 +289,6 @@ If you wish to connect to non-HTTPS URLs, or if you use a feature that depends o
 </dict>
 ```
 
-Using the default configuration, the App Store will detect that your app uses the AVAudioSession API which includes a microphone API, and for privacy reasons it will ask you to describe your app's usage of the microphone. If your app does indeed use the microphone, you can describe your usage by editing the `Info.plist` file as follows:
-
-```xml
-<key>NSMicrophoneUsageDescription</key>
-<string>... explain why the app uses the microphone here ...</string>
-```
-
-But if your app doesn't use the microphone, you can pass a build option to "compile out" any microphone code so that the App Store won't ask for the above usage description. This can be done with either CocoaPods or SwiftPM builds.
-
-#### CocoaPods
-
-Edit your `ios/Podfile` as follows:
-
-```ruby
-post_install do |installer|
-  installer.pods_project.targets.each do |target|
-    flutter_additional_ios_build_settings(target)
-    
-    # ADD THE NEXT SECTION
-    target.build_configurations.each do |config|
-      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
-        '$(inherited)',
-        'AUDIO_SESSION_MICROPHONE=0'
-      ]
-    end
-    
-  end
-end
-```
-
-#### SwiftPM
-
-Run `flutter clean` to force SwiftPM to pick up your new settings:
-
-```
-flutter clean
-```
-
-Export the environment variable `AUDIO_SESSION_MICROPHONE=0` before running your build command. E.g. Using the bash command line:
-
-```
-AUDIO_SESSION_MICROPHONE=0 flutter run
-```
-
-Note: `flutter clean` is needed whenever the value of `AUDIO_SESSION_MICROPHONE` changes, or whenever you switch between different projects that use audio_session with different `AUDIO_SESSION_MICROPHONE` values.
-
 ### macOS
 
 To allow your macOS application to access audio files on the Internet, add the following to your `DebugProfile.entitlements` and `Release.entitlements` files:
@@ -362,6 +314,7 @@ The macOS player relies on server headers (e.g. `Content-Type`, `Content-Length`
 
 Windows support is enabled by adding an additional dependency to your `pubspec.yaml` alongside `just_audio`. There are a number of alternative options:
 
+* [just_audio_media_kit](https://pub.dev/packages/just_audio_media_kit)
 * [just_audio_windows](https://pub.dev/packages/just_audio_windows)
 * [just_audio_libwinmedia](https://pub.dev/packages/just_audio_libwinmedia)
 
@@ -370,7 +323,8 @@ Example:
 ```yaml
 dependencies:
   just_audio: any # substitute version number
-  just_audio_windows: any # substitute version number
+  just_audio_media_kit: any # substitute version number
+  media_kit_libs_windows_audio: any # substitute version number
 ```
 
 For issues with the Windows implementation, please open an issue on the respective implementation's GitHub issues page.
@@ -379,13 +333,14 @@ For issues with the Windows implementation, please open an issue on the respecti
 
 Linux support is enabled by adding an additional dependency to your `pubspec.yaml` alongside `just_audio`. There are a number of alternative options:
 
-* [just_audio_mpv](https://pub.dev/packages/just_audio_mpv)
+* [just_audio_media_kit](https://pub.dev/packages/just_audio_media_kit)
 * [just_audio_libwinmedia](https://pub.dev/packages/just_audio_libwinmedia) (untested)
 
 ```yaml
 dependencies:
   just_audio: any # substitute version number
-  just_audio_mpv: any # substitute version number
+  just_audio_media_kit: any # substitute version number
+  media_kit_libs_linux: any # substitute version number
 ```
 
 For issues with the Linux implementation, please open an issue on the respective implementation's GitHub issues page.
@@ -461,18 +416,17 @@ Please also consider pressing the thumbs up button at the top of [this page](htt
 | buffer status/position         | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
 | play/pause/seek                | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
 | set volume/speed               | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
-| clip audio                     | ✅      | ✅  | ✅    | ✅  |         | ✅    |
+| clip audio                     | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
 | playlists                      | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
 | looping/shuffling              | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
-| compose audio                  | ✅      | ✅  | ✅    | ✅  |         | ✅    |
 | gapless playback               | ✅      | ✅  | ✅    |     | ✅      | ✅    |
 | report player errors           | ✅      | ✅  | ✅    | ✅  | ✅      | ✅    |
 | handle phonecall interruptions | ✅      | ✅  |       |     |         |       |
-| buffering/loading options      | ✅      | ✅  | ✅    |     |         |       |
+| buffering/loading options      | ✅      | ✅  | ✅    |     | ✅      | ✅    |
 | set pitch                      | ✅      |     |       |     |         |       |
 | skip silence                   | ✅      |     |       |     |         |       |
-| equalizer                      | ✅      |     |       |     |         | ✅    |
-| volume boost                   | ✅      |     |       |     |         | ✅    |
+| equalizer                      | ✅      |     |       |     |         |       |
+| volume boost                   | ✅      |     |       |     |         |       |
 
 (*): While request headers cannot be set directly on Web, cookies can be used to send information in the [Cookie header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie). See also `AudioPlayer.setWebCrossOrigin` to allow sending cookies when loading audio files from the same origin or a different origin.
 
@@ -492,7 +446,7 @@ Please consider reporting any bugs you encounter [here](https://github.com/ryanh
 
 The state of the player consists of two orthogonal states: `playing` and `processingState`. The `playing` state typically maps to the app's play/pause button and only ever changes in response to direct method calls by the app. By contrast, `processingState` reflects the state of the underlying audio decoder and can change both in response to method calls by the app and also in response to events occurring asynchronously within the audio processing pipeline. The following diagram depicts the valid state transitions:
 
-![just_audio_states](https://user-images.githubusercontent.com/19899190/103147563-e6601100-47aa-11eb-8baf-dee00d8e2cd4.png)
+![just_audio_states](https://github.com/user-attachments/assets/177a80fd-29e2-493f-a29d-2a3c34e42835)
 
 This state model provides a flexible way to capture different combinations of states such as playing+buffering vs paused+buffering, and this allows state to be more accurately represented in an app's UI. It is important to understand that even when `playing == true`, no sound will actually be audible unless `processingState == ready` which indicates that the buffers are filled and ready to play. This makes intuitive sense when imagining the `playing` state as mapping onto an app's play/pause button:
 
@@ -502,6 +456,14 @@ This state model provides a flexible way to capture different combinations of st
 
 Apps that wish to react to both orthogonal states through a single combined stream may listen to `playerStateStream`. This stream will emit events that contain the latest value of both `playing` and `processingState`.
 
+### Exceptions to the rule
+
+The `playing` state normally changes only according to direct method calls by the app. However, there are some exceptions:
+
+* When the `hundleInterruptions` constructor parameter is `true`, just_audio will automatically pause the player whenever there is an interruption to the audio session (such as a phone call), and will in some cases resume playback when the interruption ends.
+* When just_audio_background is used and the user interacts with the media notification's play/pause buttons, these will automatically call the `play` and `pause` methods on the player.
+* When the `maxSkipsOnError` parameter is set to a positive number `N`, just_audio will automatically pause the player after encountering `N` consecutive errors in the playlist.
+
 ## Configuring the audio session
 
 If your app uses audio, you should tell the operating system what kind of usage scenario your app has and how your app will interact with other audio apps on the device. Different audio apps often have unique requirements. For example, when a navigator app speaks driving instructions, a music player should duck its audio while a podcast player should pause its audio. Depending on which one of these three apps you are building, you will need to configure your app's audio settings and callbacks to appropriately handle these interactions.
diff --git a/just_audio/android/build.gradle b/just_audio/android/build.gradle
index ac46ef64f..cbca23961 100644
--- a/just_audio/android/build.gradle
+++ b/just_audio/android/build.gradle
@@ -1,15 +1,18 @@
-group 'com.ryanheise.just_audio'
-version '1.0'
+group "com.ryanheise.just_audio"
+version "1.0"
 def args = ["-Xlint:deprecation","-Xlint:unchecked"]
 
 buildscript {
+    ext {
+        agp_version = '8.5.2'
+    }
     repositories {
         google()
         mavenCentral()
     }
 
     dependencies {
-        classpath 'com.android.tools.build:gradle:8.5.0'
+        classpath "com.android.tools.build:gradle:$agp_version"
     }
 }
 
@@ -24,34 +27,30 @@ project.getTasks().withType(JavaCompile) {
     options.compilerArgs.addAll(args)
 }
 
-apply plugin: 'com.android.library'
+apply plugin: "com.android.library"
 
 android {
-    // Conditional for compatibility with AGP <4.2.
-    if (project.android.hasProperty("namespace")) {
-        namespace 'com.ryanheise.just_audio'
+    namespace "com.ryanheise.just_audio"
+
+    compileSdk = 35
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
     }
-    compileSdk 34
 
     defaultConfig {
-        minSdk 16
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        minSdk = 16
     }
 
     lintOptions {
         disable 'AndroidGradlePluginVersion', 'InvalidPackage', 'GradleDependency', 'NewerVersionAvailable'
     }
-
-    compileOptions {
-        sourceCompatibility 1.8
-        targetCompatibility 1.8
-    }
-
-    dependencies {
-        def exoplayer_version = "1.4.1"
-        implementation "androidx.media3:media3-exoplayer:$exoplayer_version"
-        implementation "androidx.media3:media3-exoplayer-dash:$exoplayer_version"
-        implementation "androidx.media3:media3-exoplayer-hls:$exoplayer_version"
-        implementation "androidx.media3:media3-exoplayer-smoothstreaming:$exoplayer_version"
-    }
+}
+dependencies {
+    def exoplayer_version = "1.4.1"
+    implementation "androidx.media3:media3-exoplayer:$exoplayer_version"
+    implementation "androidx.media3:media3-exoplayer-dash:$exoplayer_version"
+    implementation "androidx.media3:media3-exoplayer-hls:$exoplayer_version"
+    implementation "androidx.media3:media3-exoplayer-smoothstreaming:$exoplayer_version"
 }
diff --git a/just_audio/android/src/main/AndroidManifest.xml b/just_audio/android/src/main/AndroidManifest.xml
index e83e84164..a2f47b605 100644
--- a/just_audio/android/src/main/AndroidManifest.xml
+++ b/just_audio/android/src/main/AndroidManifest.xml
@@ -1,3 +1,2 @@
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-  package="com.ryanheise.just_audio">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 </manifest>
diff --git a/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java b/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java
index dadb2db77..e69cc6bd5 100644
--- a/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java
+++ b/just_audio/android/src/main/java/com/ryanheise/just_audio/AudioPlayer.java
@@ -58,6 +58,7 @@
 import io.flutter.plugin.common.MethodChannel.Result;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -66,6 +67,7 @@
 import java.util.Random;
 
 public class AudioPlayer implements MethodCallHandler, Player.Listener, MetadataOutput {
+    public static final int ERROR_ABORT = 10000000;
 
     static final String TAG = "AudioPlayer";
 
@@ -81,18 +83,16 @@ public class AudioPlayer implements MethodCallHandler, Player.Listener, Metadata
     private long updateTime;
     private long bufferedPosition;
     private Long seekPos;
-    private long initialPos;
-    private Integer initialIndex;
     private Result prepareResult;
     private Result playResult;
     private Result seekResult;
     private Map<String, MediaSource> mediaSources = new HashMap<String, MediaSource>();
     private IcyInfo icyInfo;
     private IcyHeaders icyHeaders;
-    private int errorCount;
     private AudioAttributes pendingAudioAttributes;
     private LoadControl loadControl;
     private boolean offloadSchedulingEnabled;
+    private boolean useLazyPreparation;
     private LivePlaybackSpeedControl livePlaybackSpeedControl;
     private List<Object> rawAudioEffects;
     private List<AudioEffect> audioEffects = new ArrayList<AudioEffect>();
@@ -102,7 +102,8 @@ public class AudioPlayer implements MethodCallHandler, Player.Listener, Metadata
 
     private ExoPlayer player;
     private Integer audioSessionId;
-    private MediaSource mediaSource;
+    private Integer errorCode;
+    private String errorMessage;
     private Integer currentIndex;
     private final Handler handler = new Handler(Looper.getMainLooper());
     private final Runnable bufferWatcher = new Runnable() {
@@ -140,16 +141,18 @@ public AudioPlayer(
         final String id,
         Map<?, ?> audioLoadConfiguration,
         List<Object> rawAudioEffects,
-        Boolean offloadSchedulingEnabled
+        Boolean offloadSchedulingEnabled,
+        boolean useLazyPreparation
     ) {
         this.context = applicationContext;
         this.rawAudioEffects = rawAudioEffects;
         this.offloadSchedulingEnabled = offloadSchedulingEnabled != null ? offloadSchedulingEnabled : false;
+        this.useLazyPreparation = useLazyPreparation;
         methodChannel = new MethodChannel(messenger, "com.ryanheise.just_audio.methods." + id);
         methodChannel.setMethodCallHandler(this);
         eventChannel = new BetterEventChannel(messenger, "com.ryanheise.just_audio.events." + id);
         dataEventChannel = new BetterEventChannel(messenger, "com.ryanheise.just_audio.data." + id);
-        processingState = ProcessingState.none;
+        processingState = ProcessingState.idle;
         if (audioLoadConfiguration != null) {
             Map<?, ?> loadControlMap = (Map<?, ?>)audioLoadConfiguration.get("androidLoadControl");
             if (loadControlMap != null) {
@@ -272,12 +275,6 @@ public void onPositionDiscontinuity(PositionInfo oldPosition, PositionInfo newPo
 
     @Override
     public void onTimelineChanged(Timeline timeline, int reason) {
-        if (initialPos != C.TIME_UNSET || initialIndex != null) {
-            int windowIndex = initialIndex != null ? initialIndex : 0;
-            player.seekTo(windowIndex, initialPos);
-            initialIndex = null;
-            initialPos = C.TIME_UNSET;
-        }
         if (updateCurrentIndex()) {
             broadcastImmediatePlaybackEvent();
         }
@@ -319,6 +316,8 @@ public void onPlaybackStateChanged(int playbackState) {
             if (player.getPlayWhenReady())
                 updatePosition();
             processingState = ProcessingState.ready;
+            errorCode = null;
+            errorMessage = null;
             broadcastImmediatePlaybackEvent();
             if (prepareResult != null) {
                 Map<String, Object> response = new HashMap<>();
@@ -338,6 +337,8 @@ public void onPlaybackStateChanged(int playbackState) {
             updatePositionIfChanged();
             if (processingState != ProcessingState.buffering && processingState != ProcessingState.loading) {
                 processingState = ProcessingState.buffering;
+                errorCode = null;
+                errorMessage = null;
                 broadcastImmediatePlaybackEvent();
             }
             startWatchingBuffer();
@@ -346,10 +347,13 @@ public void onPlaybackStateChanged(int playbackState) {
             if (processingState != ProcessingState.completed) {
                 updatePosition();
                 processingState = ProcessingState.completed;
+                errorCode = null;
+                errorMessage = null;
                 broadcastImmediatePlaybackEvent();
             }
             if (prepareResult != null) {
                 Map<String, Object> response = new HashMap<>();
+                response.put("duration", getDuration() == C.TIME_UNSET ? null : (1000 * getDuration()));
                 prepareResult.success(response);
                 prepareResult = null;
                 if (pendingAudioAttributes != null) {
@@ -386,22 +390,10 @@ public void onPlayerError(PlaybackException error) {
                 Log.e(TAG, "default ExoPlaybackException: " + exoError.getUnexpectedException().getMessage());
             }
             // TODO: send both errorCode and type
-            sendError(String.valueOf(exoError.type), exoError.getMessage(), mapOf("index", currentIndex));
+            sendError(exoError.type, exoError.getMessage(), mapOf("index", currentIndex));
         } else {
             Log.e(TAG, "default PlaybackException: " + error.getMessage());
-            sendError(String.valueOf(error.errorCode), error.getMessage(), mapOf("index", currentIndex));
-        }
-        errorCount++;
-        if (player.hasNextMediaItem() && currentIndex != null && errorCount <= 5) {
-            int nextIndex = currentIndex + 1;
-            Timeline timeline = player.getCurrentTimeline();
-            // This condition is due to: https://github.com/ryanheise/just_audio/pull/310
-            if (nextIndex < timeline.getWindowCount()) {
-                // TODO: pass in initial position here.
-                player.setMediaSource(mediaSource);
-                player.prepare();
-                player.seekTo(nextIndex, 0);
-            }
+            sendError(error.errorCode, error.getMessage(), mapOf("index", currentIndex));
         }
     }
 
@@ -420,7 +412,10 @@ public void onMethodCall(final MethodCall call, final Result result) {
             case "load":
                 Long initialPosition = getLong(call.argument("initialPosition"));
                 Integer initialIndex = call.argument("initialIndex");
-                load(getAudioSource(call.argument("audioSource")),
+                Map<?, ?> audioSourceMap = call.argument("audioSource");
+                MediaSource[] children = getAudioSourcesArray(audioSourceMap.get("children"));
+                ShuffleOrder shuffleOrder = decodeShuffleOrder(mapGet(audioSourceMap, "shuffleOrder"));
+                load(Arrays.asList(children), shuffleOrder,
                         initialPosition == null ? C.TIME_UNSET : initialPosition / 1000,
                         initialIndex, result);
                 break;
@@ -474,22 +469,40 @@ public void onMethodCall(final MethodCall call, final Result result) {
                 seek(position == null ? C.TIME_UNSET : position / 1000, index, result);
                 break;
             case "concatenatingInsertAll":
-                concatenating(call.argument("id"))
+                if (((String)call.argument("id")).length() == 0) {
+                    player.addMediaSources(call.argument("index"), getAudioSources(call.argument("children"))); 
+                    player.setShuffleOrder(decodeShuffleOrder(call.argument("shuffleOrder")));
+                    result.success(new HashMap<String, Object>());
+                } else {
+                    concatenating(call.argument("id"))
                         .addMediaSources(call.argument("index"), getAudioSources(call.argument("children")), handler, () -> result.success(new HashMap<String, Object>()));
-                concatenating(call.argument("id"))
+                    concatenating(call.argument("id"))
                         .setShuffleOrder(decodeShuffleOrder(call.argument("shuffleOrder")));
+                }
                 break;
             case "concatenatingRemoveRange":
-                concatenating(call.argument("id"))
+                if (((String)call.argument("id")).length() == 0) {
+                    player.removeMediaItems(call.argument("startIndex"), call.argument("endIndex"));
+                    player.setShuffleOrder(decodeShuffleOrder(call.argument("shuffleOrder")));
+                    result.success(new HashMap<String, Object>());
+                } else {
+                    concatenating(call.argument("id"))
                         .removeMediaSourceRange(call.argument("startIndex"), call.argument("endIndex"), handler, () -> result.success(new HashMap<String, Object>()));
-                concatenating(call.argument("id"))
+                    concatenating(call.argument("id"))
                         .setShuffleOrder(decodeShuffleOrder(call.argument("shuffleOrder")));
+                }
                 break;
             case "concatenatingMove":
-                concatenating(call.argument("id"))
+                if (((String)call.argument("id")).length() == 0) {
+                    player.moveMediaItem(call.argument("currentIndex"), call.argument("newIndex"));
+                    player.setShuffleOrder(decodeShuffleOrder(call.argument("shuffleOrder")));
+                    result.success(new HashMap<String, Object>());
+                } else {
+                    concatenating(call.argument("id"))
                         .moveMediaSource(call.argument("currentIndex"), call.argument("newIndex"), handler, () -> result.success(new HashMap<String, Object>()));
-                concatenating(call.argument("id"))
+                    concatenating(call.argument("id"))
                         .setShuffleOrder(decodeShuffleOrder(call.argument("shuffleOrder")));
+                }
                 break;
             case "setAndroidAudioAttributes":
                 setAudioAttributes(call.argument("contentType"), call.argument("flags"), call.argument("usage"));
@@ -533,33 +546,6 @@ private ShuffleOrder decodeShuffleOrder(List<Integer> indexList) {
         return new DefaultShuffleOrder(shuffleIndices, random.nextLong());
     }
 
-    private static int[] shuffle(int length, Integer firstIndex) {
-        final int[] shuffleOrder = new int[length];
-        for (int i = 0; i < length; i++) {
-            final int j = random.nextInt(i + 1);
-            shuffleOrder[i] = shuffleOrder[j];
-            shuffleOrder[j] = i;
-        }
-        if (firstIndex != null) {
-            for (int i = 1; i < length; i++) {
-                if (shuffleOrder[i] == firstIndex) {
-                    final int v = shuffleOrder[0];
-                    shuffleOrder[0] = shuffleOrder[i];
-                    shuffleOrder[i] = v;
-                    break;
-                }
-            }
-        }
-        return shuffleOrder;
-    }
-
-    // Create a shuffle order optionally fixing the first index.
-    private ShuffleOrder createShuffleOrder(int length, Integer firstIndex) {
-        int[] shuffleIndices = shuffle(length, firstIndex);
-        return new DefaultShuffleOrder(shuffleIndices, random.nextLong());
-    }
-
-
     @SuppressWarnings("deprecation")
     private androidx.media3.exoplayer.source.ConcatenatingMediaSource concatenating(final Object index) {
         return (androidx.media3.exoplayer.source.ConcatenatingMediaSource)mediaSources.get((String)index);
@@ -646,12 +632,11 @@ private MediaSource decodeAudioSource(final Object json) {
                     .setTag(id)
                     .createMediaSource();
         case "concatenating":
-            MediaSource[] mediaSources = getAudioSourcesArray(map.get("children"));
             return new androidx.media3.exoplayer.source.ConcatenatingMediaSource(
                     false, // isAtomic
                     (Boolean)map.get("useLazyPreparation"),
                     decodeShuffleOrder(mapGet(map, "shuffleOrder")),
-                    mediaSources);
+                    getAudioSourcesArray(map.get("children")));
         case "clipping":
             Long start = getLong(map.get("start"));
             Long end = getLong(map.get("end"));
@@ -737,12 +722,10 @@ private DataSource.Factory buildDataSourceFactory(Map<?, ?> headers) {
         return new DefaultDataSource.Factory(context, httpDataSourceFactory);
     }
 
-    private void load(final MediaSource mediaSource, final long initialPosition, final Integer initialIndex, final Result result) {
-        this.initialPos = initialPosition;
-        this.initialIndex = initialIndex;
+    private void load(final List<MediaSource> mediaSources, ShuffleOrder shuffleOrder, final long initialPosition, final Integer initialIndex, final Result result) {
         currentIndex = initialIndex != null ? initialIndex : 0;
         switch (processingState) {
-        case none:
+        case idle:
             break;
         case loading:
             abortExistingConnection();
@@ -752,20 +735,22 @@ private void load(final MediaSource mediaSource, final long initialPosition, fin
             player.stop();
             break;
         }
-        errorCount = 0;
         prepareResult = result;
         updatePosition();
         processingState = ProcessingState.loading;
+        errorCode = null;
+        errorMessage = null;
         enqueuePlaybackEvent();
-        this.mediaSource = mediaSource;
-        // TODO: pass in initial position here.
-        player.setMediaSource(mediaSource);
+        int windowIndex = initialIndex != null ? initialIndex : 0;
+        player.setMediaSources(mediaSources, windowIndex, initialPosition);
+        player.setShuffleOrder(shuffleOrder);
         player.prepare();
     }
 
     private void ensurePlayerInitialized() {
         if (player == null) {
             ExoPlayer.Builder builder = new ExoPlayer.Builder(context);
+            builder.setUseLazyPreparation(useLazyPreparation);
             if (loadControl != null) {
                 builder.setLoadControl(loadControl);
             }
@@ -864,6 +849,8 @@ private Map<String, Object> createPlaybackEvent() {
         event.put("duration", duration);
         event.put("currentIndex", currentIndex);
         event.put("androidAudioSessionId", audioSessionId);
+        event.put("errorCode", errorCode);
+        event.put("errorMessage", errorMessage);
         return event;
     }
 
@@ -886,7 +873,6 @@ private void broadcastPendingPlaybackEvent() {
     // broadcastPendingPlaybackEvent, only the last event is
     // broadcast.
     private void enqueuePlaybackEvent() {
-        final Map<String, Object> event = new HashMap<String, Object>();
         pendingPlaybackEvent = createPlaybackEvent();
     }
 
@@ -918,9 +904,7 @@ private Map<String, Object> collectIcyMetadata() {
     }
 
     private long getCurrentPosition() {
-        if (initialPos != C.TIME_UNSET) {
-            return initialPos;
-        } else if (processingState == ProcessingState.none || processingState == ProcessingState.loading) {
+        if (processingState == ProcessingState.idle || processingState == ProcessingState.loading) {
             long pos = player.getCurrentPosition();
             if (pos < 0) pos = 0;
             return pos;
@@ -932,24 +916,27 @@ private long getCurrentPosition() {
     }
 
     private long getDuration() {
-        if (processingState == ProcessingState.none || processingState == ProcessingState.loading || player == null) {
+        if (processingState == ProcessingState.idle || processingState == ProcessingState.loading || player == null) {
             return C.TIME_UNSET;
         } else {
             return player.getDuration();
         }
     }
 
-    private void sendError(String errorCode, String errorMsg) {
+    private void sendError(int errorCode, String errorMsg) {
         sendError(errorCode, errorMsg, null);
     }
 
-    private void sendError(String errorCode, String errorMsg, Object details) {
+    private void sendError(int errorCode, String errorMsg, Object details) {
+        eventChannel.error(String.valueOf(errorCode), errorMsg, details);
+        this.errorCode = errorCode;
+        this.errorMessage = errorMsg;
+        processingState = ProcessingState.idle;
+        broadcastImmediatePlaybackEvent();
         if (prepareResult != null) {
-            prepareResult.error(errorCode, errorMsg, details);
+            prepareResult.error(String.valueOf(errorCode), errorMsg, details);
             prepareResult = null;
         }
-
-        eventChannel.error(errorCode, errorMsg, details);
     }
 
     private String getLowerCaseExtension(Uri uri) {
@@ -1022,7 +1009,7 @@ public void setShuffleModeEnabled(final boolean enabled) {
     }
 
     public void seek(final long position, final Integer index, final Result result) {
-        if (processingState == ProcessingState.none || processingState == ProcessingState.loading) {
+        if (processingState == ProcessingState.idle || processingState == ProcessingState.loading) {
             result.success(new HashMap<String, Object>());
             return;
         }
@@ -1048,12 +1035,11 @@ public void dispose() {
             playResult = null;
         }
         mediaSources.clear();
-        mediaSource = null;
         clearAudioEffects();
         if (player != null) {
             player.release();
             player = null;
-            processingState = ProcessingState.none;
+            processingState = ProcessingState.idle;
             broadcastImmediatePlaybackEvent();
         }
         eventChannel.endOfStream();
@@ -1073,7 +1059,7 @@ private void abortSeek() {
     }
 
     private void abortExistingConnection() {
-        sendError("abort", "Connection aborted");
+        sendError(ERROR_ABORT, "Connection aborted");
     }
 
     // Dart can't distinguish between int sizes so
@@ -1111,7 +1097,7 @@ static Map<String, String> castToStringMap(Map<?, ?> map) {
     }
 
     enum ProcessingState {
-        none,
+        idle,
         loading,
         buffering,
         ready,
diff --git a/just_audio/android/src/main/java/com/ryanheise/just_audio/MainMethodCallHandler.java b/just_audio/android/src/main/java/com/ryanheise/just_audio/MainMethodCallHandler.java
index 916423e97..c36301488 100644
--- a/just_audio/android/src/main/java/com/ryanheise/just_audio/MainMethodCallHandler.java
+++ b/just_audio/android/src/main/java/com/ryanheise/just_audio/MainMethodCallHandler.java
@@ -42,7 +42,8 @@ public void onMethodCall(MethodCall call, @NonNull Result result) {
                     id,
                     call.argument("audioLoadConfiguration"),
                     rawAudioEffects,
-                    call.argument("androidOffloadSchedulingEnabled")
+                    call.argument("androidOffloadSchedulingEnabled"),
+		    call.argument("useLazyPreparation")
                 )
             );
             result.success(null);
diff --git a/just_audio/darwin/just_audio/Sources/just_audio/AudioPlayer.m b/just_audio/darwin/just_audio/Sources/just_audio/AudioPlayer.m
index f359ea67d..5427a2f63 100644
--- a/just_audio/darwin/just_audio/Sources/just_audio/AudioPlayer.m
+++ b/just_audio/darwin/just_audio/Sources/just_audio/AudioPlayer.m
@@ -12,6 +12,7 @@
 #include <TargetConditionals.h>
 
 #define TREADMILL_SIZE 2
+#define ERROR_ABORT 10000000
 
 // TODO: Check for and report invalid state transitions.
 // TODO: Apply Apple's guidance on seeking: https://developer.apple.com/library/archive/qa/qa1820/_index.html
@@ -43,19 +44,23 @@ @implementation AudioPlayer {
     BOOL _automaticallyWaitsToMinimizeStalling;
     BOOL _allowsExternalPlayback;
     LoadControl *_loadControl;
+    BOOL _useLazyPreparation;
     BOOL _playing;
     float _speed;
     float _volume;
     BOOL _justAdvanced;
     BOOL _enqueuedAll;
     NSDictionary<NSString *, NSObject *> *_icyMetadata;
+    NSNumber *_errorCode;
+    NSString *_errorMessage;
 }
 
-- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar playerId:(NSString*)idParam loadConfiguration:(NSDictionary *)loadConfiguration {
+- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar playerId:(NSString*)idParam loadConfiguration:(NSDictionary *)loadConfiguration useLazyPreparation:(BOOL)useLazyPreparation {
     self = [super init];
     NSAssert(self, @"super init cannot be nil");
     _registrar = registrar;
     _playerId = idParam;
+    _useLazyPreparation = useLazyPreparation;
     _methodChannel =
         [FlutterMethodChannel methodChannelWithName:[NSMutableString stringWithFormat:@"com.ryanheise.just_audio.methods.%@", _playerId]
                                     binaryMessenger:[registrar messenger]];
@@ -66,8 +71,8 @@ - (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar
         initWithName:[NSMutableString stringWithFormat:@"com.ryanheise.just_audio.data.%@", _playerId]
            messenger:[registrar messenger]];
     _index = 0;
-    _processingState = none;
-    _loopMode = loopOff;
+    _processingState = psIdle;
+    _loopMode = lmLoopOff;
     _shuffleModeEnabled = NO;
     _player = nil;
     _audioSource = nil;
@@ -108,6 +113,8 @@ - (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar
     _justAdvanced = NO;
     _enqueuedAll = NO;
     _icyMetadata = @{};
+    _errorCode = (NSNumber *)[NSNull null];
+    _errorMessage = (NSString *)[NSNull null];
     __weak __typeof__(self) weakSelf = self;
     [_methodChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
         [weakSelf handleMethodCall:call result:result];
@@ -117,6 +124,7 @@ - (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar
 
 - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
     @try {
+        //NSLog(@"method: %@ index=%d", call.method, _index);
         NSDictionary *request = (NSDictionary *)call.arguments;
         if ([@"load" isEqualToString:call.method]) {
             CMTime initialPosition = request[@"initialPosition"] == (id)[NSNull null] ? kCMTimeInvalid : CMTimeMake([request[@"initialPosition"] longLongValue], 1000000);
@@ -290,9 +298,9 @@ - (void)concatenatingMove:(NSString *)catId currentIndex:(int)currentIndex newIn
 }
 
 - (void)checkForDiscontinuity {
-    if (!_playing || CMTIME_IS_VALID(_seekPos) || _processingState == completed) return;
+    if (!_playing || CMTIME_IS_VALID(_seekPos) || _processingState == psCompleted) return;
     int position = [self getCurrentPosition];
-    if (_processingState == buffering) {
+    if (_processingState == psBuffering) {
         if (position > _lastPosition) {
             [self leaveBuffering:@"stall ended"];
             [self updatePosition];
@@ -318,13 +326,17 @@ - (void)checkForDiscontinuity {
 }
 
 - (void)enterBuffering:(NSString *)reason {
-    //NSLog(@"ENTER BUFFERING: %@", reason);
-    _processingState = buffering;
+    if (_processingState != psIdle) {
+        //NSLog(@"ENTER BUFFERING: %@", reason);
+        _processingState = psBuffering;
+    }
 }
 
 - (void)leaveBuffering:(NSString *)reason {
-    //NSLog(@"LEAVE BUFFERING: %@", reason);
-    _processingState = ready;
+    if (_processingState == psBuffering || _processingState == psLoading) {
+        //NSLog(@"LEAVE BUFFERING: %@", reason);
+        _processingState = psReady;
+    }
 }
 
 - (void)broadcastPlaybackEvent {
@@ -336,6 +348,8 @@ - (void)broadcastPlaybackEvent {
             @"icyMetadata": _icyMetadata,
             @"duration": @([self getDurationMicroseconds]),
             @"currentIndex": @(_index),
+            @"errorCode": _errorCode,
+            @"errorMessage": _errorMessage,
     }];
 }
 
@@ -354,7 +368,7 @@ - (int)getCurrentPosition {
 }
 
 - (int)getBufferedPosition {
-    if (_processingState == none || _processingState == loading) {
+    if (_processingState == psIdle || _processingState == psLoading) {
         return 0;
     } else if (_indexedAudioSources && _indexedAudioSources.count > 0) {
         int ms = (int)(1000 * CMTimeGetSeconds(_indexedAudioSources[_index].bufferedPosition));
@@ -366,7 +380,7 @@ - (int)getBufferedPosition {
 }
 
 - (int)getDuration {
-    if (_processingState == none || _processingState == loading) {
+    if (_processingState == psIdle || _processingState == psLoading) {
         return -1;
     } else if (_indexedAudioSources && _indexedAudioSources.count > 0) {
         int v = (int)(1000 * CMTimeGetSeconds(_indexedAudioSources[_index].duration));
@@ -499,6 +513,7 @@ - (void)enqueueFrom:(int)index {
     IndexedPlayerItem *newItem = _indexedAudioSources.count > 0 ? _indexedAudioSources[_index].playerItem : nil;
     NSArray *oldPlayerItems = [NSArray arrayWithArray:_player.items];
     // In the first pass, preserve the old and new items.
+    // TODO: preserve as much of the treadmill as possible
     for (int i = 0; i < oldPlayerItems.count; i++) {
         if (oldPlayerItems[i] == newItem) {
             // Preserve and tag new item if it is already in the queue.
@@ -525,7 +540,7 @@ - (void)enqueueFrom:(int)index {
 
     // Regenerate queue
     _enqueuedAll = NO;
-    if (!existingItem || _loopMode != loopOne) {
+    if (!existingItem || _loopMode != lmLoopOne) {
         _enqueuedAll = YES;
         BOOL include = NO;
         for (int i = 0; i < [_order count]; i++) {
@@ -539,7 +554,7 @@ - (void)enqueueFrom:(int)index {
                 }
                 //NSLog(@"inserting item %d", si);
                 [_player insertItem:_indexedAudioSources[si].playerItem afterItem:nil];
-                if (_loopMode == loopOne) {
+                if (_loopMode == lmLoopOne) {
                     // We only want one item in the queue.
                     break;
                 }
@@ -549,7 +564,7 @@ - (void)enqueueFrom:(int)index {
 
     // Add next loop item if we're looping
     if (_order.count > 0) {
-        if (_loopMode == loopAll && _enqueuedAll) {
+        if (_loopMode == lmLoopAll && _enqueuedAll) {
             int si = [_order[0] intValue];
             //NSLog(@"### add loop item:%d", si);
             if (!_indexedAudioSources[si].playerItem2) {
@@ -557,7 +572,7 @@ - (void)enqueueFrom:(int)index {
                 [self addItemObservers:_indexedAudioSources[si].playerItem2];
             }
             [_player insertItem:_indexedAudioSources[si].playerItem2 afterItem:nil];
-        } else if (_loopMode == loopOne) {
+        } else if (_loopMode == lmLoopOne) {
             //NSLog(@"### add loop item:%d", _index);
             if (!_indexedAudioSources[_index].playerItem2) {
                 [_indexedAudioSources[_index] preparePlayerItem2];
@@ -570,7 +585,7 @@ - (void)enqueueFrom:(int)index {
     /* NSLog(@"after reorder: _player.items.count: ", _player.items.count); */
     /* [self dumpQueue]; */
 
-    if (_processingState != loading && oldItem != newItem) {
+    if (_processingState != psLoading && oldItem != newItem) {
         // || !_player.currentItem.playbackLikelyToKeepUp;
         if (_player.currentItem.playbackBufferEmpty) {
             [self enterBuffering:@"enqueueFrom playbackBufferEmpty"];
@@ -592,12 +607,14 @@ - (void)load:(NSDictionary *)source initialPosition:(CMTime)initialPosition init
     if (_playing) {
         [_player pause];
     }
-    if (_processingState == loading) {
+    if (_processingState == psLoading) {
         [self abortExistingConnection];
     }
     _loadResult = result;
-    _processingState = loading;
+    _processingState = psLoading;
     _index = (initialIndex != (id)[NSNull null]) ? [initialIndex intValue] : 0;
+    _errorCode = (NSNumber *)[NSNull null];
+    _errorMessage = (NSString *)[NSNull null];
     // Remove previous observers
     if (_indexedAudioSources) {
         for (int i = 0; i < [_indexedAudioSources count]; i++) {
@@ -687,19 +704,20 @@ - (void)load:(NSDictionary *)source initialPosition:(CMTime)initialPosition init
         [_indexedAudioSources[i] attach:_player initialPos:(i == _index ? initialPosition : kCMTimeInvalid)];
     }
 
+    if (_playing) {
+        _player.rate = _speed;
+    }
+    [_player setVolume:_volume];
+    [self broadcastPlaybackEvent];
+
     if (_loadResult && (_indexedAudioSources.count == 0 || !_player.currentItem ||
             _player.currentItem.status == AVPlayerItemStatusReadyToPlay)) {
-        _processingState = ready;
+        _processingState = psReady;
         _loadResult(@{@"duration": @([self getDurationMicroseconds])});
         _loadResult = nil;
     } else {
         // We send result after the playerItem is ready in observeValueForKeyPath.
     }
-    if (_playing) {
-        _player.rate = _speed;
-    }
-    [_player setVolume:_volume];
-    [self broadcastPlaybackEvent];
     /* NSLog(@"load:"); */
     /* for (int i = 0; i < [_indexedAudioSources count]; i++) { */
     /*     NSLog(@"- %@", _indexedAudioSources[i].sourceId); */
@@ -741,10 +759,10 @@ - (void)onComplete:(NSNotification *)notification {
     IndexedPlayerItem *endedPlayerItem = (IndexedPlayerItem *)notification.object;
     IndexedAudioSource *endedSource = endedPlayerItem.audioSource;
 
-    if (_loopMode == loopOne) {
+    if (_loopMode == lmLoopOne) {
         [endedSource seek:kCMTimeZero];
         _justAdvanced = YES;
-    } else if (_loopMode == loopAll) {
+    } else if (_loopMode == lmLoopAll) {
         [endedSource seek:kCMTimeZero];
         _index = [_order[([_orderInv[_index] intValue] + 1) % _order.count] intValue];
         [self broadcastPlaybackEvent];
@@ -818,8 +836,10 @@ - (void)observeValueForKeyPath:(NSString *)keyPath
                 break;
             }
             case AVPlayerItemStatusFailed: {
-                //NSLog(@"AVPlayerItemStatusFailed");
-                [self sendErrorForItem:playerItem];
+                //NSLog(@"AVPlayerItemStatusFailed on item [%d]", [self indexForItem:playerItem]);
+                if (playerItem == _player.currentItem) {
+                    [self sendErrorForItem:playerItem];
+                }
                 break;
             }
             case AVPlayerItemStatusUnknown:
@@ -850,7 +870,6 @@ - (void)observeValueForKeyPath:(NSString *)keyPath
                 [self updatePosition];
                 [self broadcastPlaybackEvent];
             } else if (!playerItem.playbackBufferEmpty || playerItem.playbackBufferFull) {
-                _processingState = ready;
                 [self leaveBuffering:@"!playing, !playbackBufferEmpty || playbackBufferFull"];
                 [self updatePosition];
                 [self broadcastPlaybackEvent];
@@ -870,7 +889,7 @@ - (void)observeValueForKeyPath:(NSString *)keyPath
                     break;
                 case AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate:
                     //NSLog(@"AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate");
-                    if (_processingState != completed) {
+                    if (_processingState != psCompleted) {
                         [self enterBuffering:@"timeControlStatus"];
                         [self updatePosition];
                         [self broadcastPlaybackEvent];
@@ -886,18 +905,11 @@ - (void)observeValueForKeyPath:(NSString *)keyPath
             }
         }
     } else if ([keyPath isEqualToString:@"currentItem"] && _player.currentItem) {
+        //NSLog(@"currentItem -> [%d]", [self indexForItem:_player.currentItem]);
         IndexedPlayerItem *playerItem = (IndexedPlayerItem *)change[NSKeyValueChangeNewKey];
         //IndexedPlayerItem *oldPlayerItem = (IndexedPlayerItem *)change[NSKeyValueChangeOldKey];
         if (playerItem.status == AVPlayerItemStatusFailed) {
-            if ([_orderInv[_index] intValue] + 1 < [_order count]) {
-                // account for automatic move to next item
-                _index = [_order[[_orderInv[_index] intValue] + 1] intValue];
-                //NSLog(@"advance to next on error: index = %d", _index);
-                [self updateEndAction];
-                [self broadcastPlaybackEvent];
-            } else {
-                //NSLog(@"error on last item");
-            }
+            [self sendErrorForItem:playerItem];
             return;
         } else {
             int expectedIndex = [self indexForItem:playerItem];
@@ -951,10 +963,10 @@ - (void)observeValueForKeyPath:(NSString *)keyPath
 
         if (_justAdvanced) {
             IndexedAudioSource *audioSource = playerItem.audioSource;
-            if (_loopMode == loopOne) {
+            if (_loopMode == lmLoopOne) {
                 [audioSource flip];
                 [self enqueueFrom:_index];
-            } else if (_loopMode == loopAll) {
+            } else if (_loopMode == lmLoopAll) {
                 if (_index == [_order[0] intValue] && playerItem == audioSource.playerItem2) {
                     [audioSource flip];
                     [self enqueueFrom:_index];
@@ -980,27 +992,28 @@ - (void)observeValueForKeyPath:(NSString *)keyPath
 }
 
 - (void)sendErrorForItem:(IndexedPlayerItem *)playerItem {
-    FlutterError *flutterError = [FlutterError errorWithCode:[NSString stringWithFormat:@"%d", (int)playerItem.error.code]
-                                                     message:playerItem.error.localizedDescription
-                                                     details:@{@"index": @([self indexForItem:playerItem])}];
-    [self sendError:flutterError playerItem:playerItem];
+    [self sendError:@((int)playerItem.error.code) errorMessage:playerItem.error.localizedDescription playerItem:playerItem];
+    [_player removeAllItems];
 }
 
-- (void)sendError:(FlutterError *)flutterError playerItem:(IndexedPlayerItem *)playerItem {
-    //NSLog(@"sendError");
+- (void)sendError:(NSNumber *)errorCode errorMessage:(NSString *)errorMessage playerItem:(IndexedPlayerItem *)playerItem {
+    //NSLog(@"sendError (%@) %@", errorCode, errorMessage);
+    FlutterError *flutterError = [FlutterError errorWithCode:[NSString stringWithFormat:@"%@", errorCode]
+                                                     message:errorMessage
+                                                     details:playerItem != nil ? @{@"index": @([self indexForItem:playerItem])} : nil];
+    [_eventChannel sendEvent:flutterError];
+    _errorCode = errorCode;
+    _errorMessage = errorMessage;
+    _processingState = psIdle;
+    [self broadcastPlaybackEvent];
     if (_loadResult && playerItem == _player.currentItem) {
         _loadResult(flutterError);
         _loadResult = nil;
     }
-    // Broadcast all errors even if they aren't on the current item.
-    [_eventChannel sendEvent:flutterError];
 }
 
 - (void)abortExistingConnection {
-    FlutterError *flutterError = [FlutterError errorWithCode:@"abort"
-                                                     message:@"Connection aborted"
-                                                     details:nil];
-    [self sendError:flutterError playerItem:nil];
+    [self sendError:@(ERROR_ABORT) errorMessage:@"Connection aborted" playerItem:nil];
 }
 
 - (int)indexForItem:(IndexedPlayerItem *)playerItem {
@@ -1057,7 +1070,7 @@ - (void)pause {
 
 - (void)complete {
     [self updatePosition];
-    _processingState = completed;
+    _processingState = psCompleted;
     [self broadcastPlaybackEvent];
     if (_playResult) {
         //NSLog(@"PLAY FINISHED DUE TO COMPLETE");
@@ -1121,7 +1134,7 @@ - (void)updateEndAction {
     // - when the shuffle order changes. (TODO)
     // - when the shuffle mode changes.
     if (!_player) return;
-    if (_audioSource && (_loopMode != loopOff || ([_order count] > 0 && [_orderInv[_index] intValue] + 1 < [_order count]))) {
+    if (_audioSource && (_loopMode != lmLoopOff || ([_order count] > 0 && [_orderInv[_index] intValue] + 1 < [_order count]))) {
         _player.actionAtItemEnd = AVPlayerActionAtItemEndAdvance;
     } else {
         _player.actionAtItemEnd = AVPlayerActionAtItemEndPause; // AVPlayerActionAtItemEndNone
@@ -1191,7 +1204,7 @@ - (void)setAllowsExternalPlayback:(BOOL)allowsExternalPlayback {
 }
 
 - (void)seek:(CMTime)position index:(NSNumber *)newIndex completionHandler:(void (^)(BOOL))completionHandler {
-    if (_processingState == none || _processingState == loading) {
+    if (_processingState == psIdle || _processingState == psLoading) {
         if (completionHandler) {
             completionHandler(NO);
         }
@@ -1313,7 +1326,7 @@ - (void)seek:(CMTime)position index:(NSNumber *)newIndex completionHandler:(void
                     [self leaveBuffering:@"seek finished, !playbackBufferEmpty"];
                 }
                 [self updatePosition];
-                if (self->_processingState != buffering) {
+                if (self->_processingState != psBuffering) {
                     [self broadcastPlaybackEvent];
                 }
             }
@@ -1328,7 +1341,7 @@ - (void)seek:(CMTime)position index:(NSNumber *)newIndex completionHandler:(void
 
 - (void)dispose:(BOOL)calledFromDealloc {
     if (!_player) return;
-    if (_processingState != none) {
+    if (_processingState != psIdle) {
         [_player pause];
 
         [self updatePosition];
@@ -1341,7 +1354,7 @@ - (void)dispose:(BOOL)calledFromDealloc {
             _playResult = nil;
         }
 
-        _processingState = none;
+        _processingState = psIdle;
         // If used just before destroying the current FlutterEngine, this will result in:
         // NSInternalInconsistencyException: 'Sending a message before the FlutterEngine has been run.'
         //[self broadcastPlaybackEvent];
diff --git a/just_audio/darwin/just_audio/Sources/just_audio/BetterEventChannel.m b/just_audio/darwin/just_audio/Sources/just_audio/BetterEventChannel.m
index ad52bb2d3..d74a0ddc7 100644
--- a/just_audio/darwin/just_audio/Sources/just_audio/BetterEventChannel.m
+++ b/just_audio/darwin/just_audio/Sources/just_audio/BetterEventChannel.m
@@ -31,6 +31,9 @@ - (void)sendEvent:(id)event {
 }
 
 - (void)dispose {
+    if (_eventSink) {
+        _eventSink(FlutterEndOfEventStream);
+    }
     [_eventChannel setStreamHandler:nil];
 }
 
diff --git a/just_audio/darwin/just_audio/Sources/just_audio/JustAudioPlugin.m b/just_audio/darwin/just_audio/Sources/just_audio/JustAudioPlugin.m
index 7aac91695..e43a41ad5 100644
--- a/just_audio/darwin/just_audio/Sources/just_audio/JustAudioPlugin.m
+++ b/just_audio/darwin/just_audio/Sources/just_audio/JustAudioPlugin.m
@@ -25,15 +25,17 @@ - (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar
 }
 
 - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
+    //NSLog(@"plugin method: %@", call.method);
     if ([@"init" isEqualToString:call.method]) {
         NSDictionary *request = (NSDictionary *)call.arguments;
         NSString *playerId = (NSString *)request[@"id"];
         NSDictionary *loadConfiguration = (NSDictionary *)request[@"audioLoadConfiguration"];
+        BOOL useLazyPreparation = [((NSNumber *)request[@"useLazyPreparation"]) boolValue];
         if ([_players objectForKey:playerId] != nil) {
             FlutterError *flutterError = [FlutterError errorWithCode:@"error" message:@"Platform player already exists" details:nil];
             result(flutterError);
         } else {
-            AudioPlayer* player = [[AudioPlayer alloc] initWithRegistrar:_registrar playerId:playerId loadConfiguration:loadConfiguration];
+            AudioPlayer* player = [[AudioPlayer alloc] initWithRegistrar:_registrar playerId:playerId loadConfiguration:loadConfiguration useLazyPreparation:useLazyPreparation];
             [_players setValue:player forKey:playerId];
             result(nil);
         }
diff --git a/just_audio/darwin/just_audio/Sources/just_audio/include/just_audio/AudioPlayer.h b/just_audio/darwin/just_audio/Sources/just_audio/include/just_audio/AudioPlayer.h
index ae402b4b1..6d9e0f96c 100644
--- a/just_audio/darwin/just_audio/Sources/just_audio/include/just_audio/AudioPlayer.h
+++ b/just_audio/darwin/just_audio/Sources/just_audio/include/just_audio/AudioPlayer.h
@@ -10,21 +10,21 @@
 @property (readonly, nonatomic) AVQueuePlayer *player;
 @property (readonly, nonatomic) float speed;
 
-- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar playerId:(NSString*)idParam loadConfiguration:(NSDictionary *)loadConfiguration;
+- (instancetype)initWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar playerId:(NSString*)idParam loadConfiguration:(NSDictionary *)loadConfiguration useLazyPreparation:(BOOL)useLazyPreparation;
 - (void)dispose:(BOOL)calledFromDealloc;
 
 @end
 
 enum ProcessingState {
-    none,
-    loading,
-    buffering,
-    ready,
-    completed
+    psIdle,
+    psLoading,
+    psBuffering,
+    psReady,
+    psCompleted
 };
 
 enum LoopMode {
-    loopOff,
-    loopOne,
-    loopAll
+    lmLoopOff,
+    lmLoopOne,
+    lmLoopAll
 };
diff --git a/just_audio/example/android/app/build.gradle b/just_audio/example/android/app/build.gradle
index 73918b483..d5d020c25 100644
--- a/just_audio/example/android/app/build.gradle
+++ b/just_audio/example/android/app/build.gradle
@@ -1,45 +1,26 @@
-def localProperties = new Properties()
-def localPropertiesFile = rootProject.file('local.properties')
-if (localPropertiesFile.exists()) {
-    localPropertiesFile.withReader('UTF-8') { reader ->
-        localProperties.load(reader)
-    }
-}
-
-def flutterRoot = localProperties.getProperty('flutter.sdk')
-if (flutterRoot == null) {
-    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
-}
-
-def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
-if (flutterVersionCode == null) {
-    flutterVersionCode = '1'
-}
-
-def flutterVersionName = localProperties.getProperty('flutter.versionName')
-if (flutterVersionName == null) {
-    flutterVersionName = '1.0'
+plugins {
+    id "com.android.application"
+    id "kotlin-android"
+    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+    id "dev.flutter.flutter-gradle-plugin"
 }
 
-apply plugin: 'com.android.application'
-apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
-
 android {
     namespace 'com.ryanheise.just_audio_example'
-    compileSdk 34
+    compileSdk flutter.compileSdkVersion
 
-    lintOptions {
-        disable 'InvalidPackage'
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
     }
 
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "com.ryanheise.just_audio_example"
         minSdk flutter.minSdkVersion
-        targetSdk 34
-        versionCode flutterVersionCode.toInteger()
-        versionName flutterVersionName
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        targetSdk flutter.targetSdkVersion
+        versionCode flutter.versionCode
+        versionName flutter.versionName
     }
 
     buildTypes {
@@ -49,6 +30,10 @@ android {
             signingConfig signingConfigs.debug
         }
     }
+
+    lint {
+        disable 'InvalidPackage'
+    }
 }
 
 flutter {
diff --git a/just_audio/example/android/app/src/debug/AndroidManifest.xml b/just_audio/example/android/app/src/debug/AndroidManifest.xml
index df636f40d..f880684a6 100644
--- a/just_audio/example/android/app/src/debug/AndroidManifest.xml
+++ b/just_audio/example/android/app/src/debug/AndroidManifest.xml
@@ -1,5 +1,4 @@
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.ryanheise.just_audio_example">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
     <!-- Flutter needs it to communicate with the running application
          to allow setting breakpoints, to provide hot reload, etc.
     -->
diff --git a/just_audio/example/android/app/src/main/AndroidManifest.xml b/just_audio/example/android/app/src/main/AndroidManifest.xml
index 27c1136db..e495332d4 100644
--- a/just_audio/example/android/app/src/main/AndroidManifest.xml
+++ b/just_audio/example/android/app/src/main/AndroidManifest.xml
@@ -1,5 +1,4 @@
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.ryanheise.just_audio_example">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
     <uses-permission android:name="android.permission.INTERNET"/>
 
diff --git a/just_audio/example/android/app/src/profile/AndroidManifest.xml b/just_audio/example/android/app/src/profile/AndroidManifest.xml
index df636f40d..f880684a6 100644
--- a/just_audio/example/android/app/src/profile/AndroidManifest.xml
+++ b/just_audio/example/android/app/src/profile/AndroidManifest.xml
@@ -1,5 +1,4 @@
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.ryanheise.just_audio_example">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
     <!-- Flutter needs it to communicate with the running application
          to allow setting breakpoints, to provide hot reload, etc.
     -->
diff --git a/just_audio/example/android/build.gradle b/just_audio/example/android/build.gradle
index 6bb781444..b3b39e589 100644
--- a/just_audio/example/android/build.gradle
+++ b/just_audio/example/android/build.gradle
@@ -1,14 +1,3 @@
-buildscript {
-    repositories {
-        google()
-        mavenCentral()
-    }
-
-    dependencies {
-        classpath 'com.android.tools.build:gradle:7.3.0'
-    }
-}
-
 allprojects {
     repositories {
         google()
@@ -16,12 +5,12 @@ allprojects {
     }
 }
 
-rootProject.buildDir = '../build'
+rootProject.buildDir = "../build"
 subprojects {
     project.buildDir = "${rootProject.buildDir}/${project.name}"
 }
 subprojects {
-    project.evaluationDependsOn(':app')
+    project.evaluationDependsOn(":app")
 }
 
 tasks.register("clean", Delete) {
diff --git a/just_audio/example/android/gradle.properties b/just_audio/example/android/gradle.properties
index 38c8d4544..c32a4ef08 100644
--- a/just_audio/example/android/gradle.properties
+++ b/just_audio/example/android/gradle.properties
@@ -2,3 +2,6 @@ org.gradle.jvmargs=-Xmx1536M
 android.enableR8=true
 android.useAndroidX=true
 android.enableJetifier=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonTransitiveRClass=false
+android.nonFinalResIds=false
diff --git a/just_audio/example/android/settings.gradle b/just_audio/example/android/settings.gradle
index 5a2f14fb1..c5ab4b5c0 100644
--- a/just_audio/example/android/settings.gradle
+++ b/just_audio/example/android/settings.gradle
@@ -1,15 +1,25 @@
-include ':app'
+pluginManagement {
+    def flutterSdkPath = {
+        def properties = new Properties()
+        file("local.properties").withInputStream { properties.load(it) }
+        def flutterSdkPath = properties.getProperty("flutter.sdk")
+        assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+        return flutterSdkPath
+    }()
 
-def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
+    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
 
-def plugins = new Properties()
-def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
-if (pluginsFile.exists()) {
-    pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
 }
 
-plugins.each { name, path ->
-    def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
-    include ":$name"
-    project(":$name").projectDir = pluginDirectory
+plugins {
+    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+    id "com.android.application" version "8.5.2" apply false
+    id "org.jetbrains.kotlin.android" version "1.8.22" apply false
 }
+
+include ":app"
diff --git a/just_audio/example/lib/example_caching.dart b/just_audio/example/lib/example_caching.dart
index 33732fc69..c4b7df194 100644
--- a/just_audio/example/lib/example_caching.dart
+++ b/just_audio/example/lib/example_caching.dart
@@ -5,6 +5,7 @@
 //
 // flutter run -t lib/example_caching.dart
 
+import 'media_kit_stub.dart' if (dart.library.io) 'media_kit_impl.dart';
 import 'package:audio_session/audio_session.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -12,7 +13,10 @@ import 'package:just_audio/just_audio.dart';
 import 'package:just_audio_example/common.dart';
 import 'package:rxdart/rxdart.dart';
 
-void main() => runApp(const MyApp());
+void main() {
+  initMediaKit(); // Initialise just_audio_media_kit for Linux/Windows.
+  runApp(const MyApp());
+}
 
 class MyApp extends StatefulWidget {
   const MyApp({Key? key}) : super(key: key);
@@ -43,8 +47,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
   Future<void> _init() async {
     final session = await AudioSession.instance;
     await session.configure(const AudioSessionConfiguration.speech());
-    _player.playbackEventStream.listen((event) {},
-        onError: (Object e, StackTrace stackTrace) {
+    _player.errorStream.listen((e) {
       print('A stream error occurred: $e');
     });
     try {
diff --git a/just_audio/example/lib/example_playlist.dart b/just_audio/example/lib/example_playlist.dart
index c0bb9fec6..ac357d617 100644
--- a/just_audio/example/lib/example_playlist.dart
+++ b/just_audio/example/lib/example_playlist.dart
@@ -5,15 +5,22 @@
 //
 // flutter run -t lib/example_playlist.dart
 
+import 'dart:async';
+
+import 'media_kit_stub.dart' if (dart.library.io) 'media_kit_impl.dart';
 import 'package:audio_session/audio_session.dart';
-import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:just_audio/just_audio.dart';
 import 'package:just_audio_example/common.dart';
 import 'package:rxdart/rxdart.dart';
 
-void main() => runApp(const MyApp());
+void main() {
+  initMediaKit(); // Initialise just_audio_media_kit for Linux/Windows.
+  // Enable gapless playback on Linux/Windows (experimental):
+  // JustAudioMediaKit.prefetchPlaylist = true;
+  runApp(const MyApp());
+}
 
 class MyApp extends StatefulWidget {
   const MyApp({Key? key}) : super(key: key);
@@ -24,23 +31,19 @@ class MyApp extends StatefulWidget {
 
 class MyAppState extends State<MyApp> with WidgetsBindingObserver {
   late AudioPlayer _player;
-  final _playlist = ConcatenatingAudioSource(children: [
-    // Remove this audio source from the Windows and Linux version because it's not supported yet
-    if (kIsWeb ||
-        ![TargetPlatform.windows, TargetPlatform.linux]
-            .contains(defaultTargetPlatform))
-      ClippingAudioSource(
-        start: const Duration(seconds: 60),
-        end: const Duration(seconds: 90),
-        child: AudioSource.uri(Uri.parse(
-            "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3")),
-        tag: AudioMetadata(
-          album: "Science Friday",
-          title: "A Salute To Head-Scratching Science (30 seconds)",
-          artwork:
-              "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg",
-        ),
+  static final _playlist = [
+    ClippingAudioSource(
+      start: const Duration(seconds: 60),
+      end: const Duration(seconds: 90),
+      child: AudioSource.uri(Uri.parse(
+          "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3")),
+      tag: AudioMetadata(
+        album: "Science Friday",
+        title: "A Salute To Head-Scratching Science (30 seconds)",
+        artwork:
+            "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg",
       ),
+    ),
     AudioSource.uri(
       Uri.parse(
           "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3"),
@@ -69,7 +72,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
             "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg",
       ),
     ),
-  ]);
+  ];
   int _addedCount = 0;
   final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
 
@@ -77,7 +80,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
   void initState() {
     super.initState();
     ambiguate(WidgetsBinding.instance)!.addObserver(this);
-    _player = AudioPlayer();
+    _player = AudioPlayer(maxSkipsOnError: 3);
     SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
       statusBarColor: Colors.black,
     ));
@@ -88,17 +91,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
     final session = await AudioSession.instance;
     await session.configure(const AudioSessionConfiguration.speech());
     // Listen to errors during playback.
-    _player.playbackEventStream.listen((event) {},
-        onError: (Object e, StackTrace stackTrace) {
+    _player.errorStream.listen((e) {
       print('A stream error occurred: $e');
     });
     try {
-      // Preloading audio is not currently supported on Linux.
-      await _player.setAudioSource(_playlist,
-          preload: kIsWeb || defaultTargetPlatform != TargetPlatform.linux);
+      await _player.setAudioSources(_playlist);
     } on PlayerException catch (e) {
       // Catch load errors: 404, invalid url...
-      print("Error loading audio source: $e");
+      print("Error loading playlist: $e");
     }
     // Show a snackbar whenever reaching the end of an item in the playlist.
     _player.positionDiscontinuityStream.listen((discontinuity) {
@@ -116,7 +116,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
   void _showItemFinished(int? index) {
     if (index == null) return;
     final sequence = _player.sequence;
-    if (sequence == null) return;
+    if (index >= sequence.length) return;
     final source = sequence[index];
     final metadata = source.tag as AudioMetadata;
     _scaffoldMessengerKey.currentState?.showSnackBar(SnackBar(
@@ -269,7 +269,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
                     return ReorderableListView(
                       onReorder: (int oldIndex, int newIndex) {
                         if (oldIndex < newIndex) newIndex--;
-                        _playlist.move(oldIndex, newIndex);
+                        _player.moveAudioSource(oldIndex, newIndex);
                       },
                       children: [
                         for (var i = 0; i < sequence.length; i++)
@@ -283,18 +283,17 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
                                 child: Icon(Icons.delete, color: Colors.white),
                               ),
                             ),
-                            onDismissed: (dismissDirection) {
-                              _playlist.removeAt(i);
-                            },
+                            onDismissed: (dismissDirection) =>
+                                _player.removeAudioSourceAt(i),
                             child: Material(
                               color: i == state!.currentIndex
                                   ? Colors.grey.shade300
                                   : null,
                               child: ListTile(
                                 title: Text(sequence[i].tag.title as String),
-                                onTap: () {
-                                  _player.seek(Duration.zero, index: i);
-                                },
+                                onTap: () => _player
+                                    .seek(Duration.zero, index: i)
+                                    .catchError((e, st) {}),
                               ),
                             ),
                           ),
@@ -309,7 +308,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
         floatingActionButton: FloatingActionButton(
           child: const Icon(Icons.add),
           onPressed: () {
-            _playlist.add(AudioSource.uri(
+            _player.addAudioSource(AudioSource.uri(
               Uri.parse("asset:///audio/nature.mp3"),
               tag: AudioMetadata(
                 album: "Public Domain",
@@ -357,12 +356,16 @@ class ControlButtons extends StatelessWidget {
             onPressed: player.hasPrevious ? player.seekToPrevious : null,
           ),
         ),
-        StreamBuilder<PlayerState>(
-          stream: player.playerStateStream,
+        StreamBuilder<(bool, ProcessingState, int)>(
+          stream: Rx.combineLatest3(
+              player.playingStream,
+              player.playbackEventStream,
+              player.sequenceStream,
+              (playing, event, sequence) =>
+                  (playing, event.processingState, sequence.length)),
           builder: (context, snapshot) {
-            final playerState = snapshot.data;
-            final processingState = playerState?.processingState;
-            final playing = playerState?.playing;
+            final (playing, processingState, sequenceLength) =
+                snapshot.data ?? (false, null, 0);
             if (processingState == ProcessingState.loading ||
                 processingState == ProcessingState.buffering) {
               return Container(
@@ -371,11 +374,11 @@ class ControlButtons extends StatelessWidget {
                 height: 64.0,
                 child: const CircularProgressIndicator(),
               );
-            } else if (playing != true) {
+            } else if (!playing) {
               return IconButton(
                 icon: const Icon(Icons.play_arrow),
                 iconSize: 64.0,
-                onPressed: player.play,
+                onPressed: sequenceLength > 0 ? player.play : null,
               );
             } else if (processingState != ProcessingState.completed) {
               return IconButton(
@@ -387,8 +390,10 @@ class ControlButtons extends StatelessWidget {
               return IconButton(
                 icon: const Icon(Icons.replay),
                 iconSize: 64.0,
-                onPressed: () => player.seek(Duration.zero,
-                    index: player.effectiveIndices!.first),
+                onPressed: sequenceLength > 0
+                    ? () => player.seek(Duration.zero,
+                        index: player.effectiveIndices.first)
+                    : null,
               );
             }
           },
diff --git a/just_audio/example/lib/example_radio.dart b/just_audio/example/lib/example_radio.dart
index 40456cd15..7ad7b03a7 100644
--- a/just_audio/example/lib/example_radio.dart
+++ b/just_audio/example/lib/example_radio.dart
@@ -4,13 +4,17 @@
 //
 // flutter run -t lib/example_radio.dart
 
+import 'media_kit_stub.dart' if (dart.library.io) 'media_kit_impl.dart';
 import 'package:audio_session/audio_session.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:just_audio/just_audio.dart';
 import 'package:just_audio_example/common.dart';
 
-void main() => runApp(const MyApp());
+void main() {
+  initMediaKit(); // Initialise just_audio_media_kit for Linux/Windows.
+  runApp(const MyApp());
+}
 
 class MyApp extends StatefulWidget {
   const MyApp({Key? key}) : super(key: key);
@@ -38,8 +42,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
     final session = await AudioSession.instance;
     await session.configure(const AudioSessionConfiguration.speech());
     // Listen to errors during playback.
-    _player.playbackEventStream.listen((event) {},
-        onError: (Object e, StackTrace stackTrace) {
+    _player.errorStream.listen((e) {
       print('A stream error occurred: $e');
     });
     // Try to load audio from a source and catch any errors.
diff --git a/just_audio/example/lib/main.dart b/just_audio/example/lib/main.dart
index 2b44956eb..c92ff1e02 100644
--- a/just_audio/example/lib/main.dart
+++ b/just_audio/example/lib/main.dart
@@ -2,6 +2,7 @@
 // More advanced examples demonstrating other features can be found in the same
 // directory as this example in the GitHub repository.
 
+import 'media_kit_stub.dart' if (dart.library.io) 'media_kit_impl.dart';
 import 'package:audio_session/audio_session.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -9,7 +10,10 @@ import 'package:just_audio/just_audio.dart';
 import 'package:just_audio_example/common.dart';
 import 'package:rxdart/rxdart.dart';
 
-void main() => runApp(const MyApp());
+void main() {
+  initMediaKit(); // Initialise just_audio_media_kit for Linux/Windows.
+  runApp(const MyApp());
+}
 
 class MyApp extends StatefulWidget {
   const MyApp({Key? key}) : super(key: key);
@@ -37,8 +41,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
     final session = await AudioSession.instance;
     await session.configure(const AudioSessionConfiguration.speech());
     // Listen to errors during playback.
-    _player.playbackEventStream.listen((event) {},
-        onError: (Object e, StackTrace stackTrace) {
+    _player.errorStream.listen((e) {
       print('A stream error occurred: $e');
     });
     // Try to load audio from a source and catch any errors.
diff --git a/just_audio/example/lib/media_kit_impl.dart b/just_audio/example/lib/media_kit_impl.dart
new file mode 100644
index 000000000..bb08e51dd
--- /dev/null
+++ b/just_audio/example/lib/media_kit_impl.dart
@@ -0,0 +1,5 @@
+import 'package:just_audio_media_kit/just_audio_media_kit.dart';
+
+void initMediaKit() {
+  JustAudioMediaKit.ensureInitialized(linux: true, windows: true);
+}
diff --git a/just_audio/example/lib/media_kit_stub.dart b/just_audio/example/lib/media_kit_stub.dart
new file mode 100644
index 000000000..86239769b
--- /dev/null
+++ b/just_audio/example/lib/media_kit_stub.dart
@@ -0,0 +1,1 @@
+void initMediaKit() {}
diff --git a/just_audio/example/linux/flutter/generated_plugin_registrant.cc b/just_audio/example/linux/flutter/generated_plugin_registrant.cc
index e71a16d23..d2d64057d 100644
--- a/just_audio/example/linux/flutter/generated_plugin_registrant.cc
+++ b/just_audio/example/linux/flutter/generated_plugin_registrant.cc
@@ -6,6 +6,10 @@
 
 #include "generated_plugin_registrant.h"
 
+#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
 
 void fl_register_plugins(FlPluginRegistry* registry) {
+  g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
+  media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
 }
diff --git a/just_audio/example/linux/flutter/generated_plugins.cmake b/just_audio/example/linux/flutter/generated_plugins.cmake
index 2e1de87a7..23338851f 100644
--- a/just_audio/example/linux/flutter/generated_plugins.cmake
+++ b/just_audio/example/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
+  media_kit_libs_linux
 )
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/just_audio/example/pubspec.yaml b/just_audio/example/pubspec.yaml
index 2840dd7e2..655bc2c9c 100644
--- a/just_audio/example/pubspec.yaml
+++ b/just_audio/example/pubspec.yaml
@@ -8,11 +8,13 @@ environment:
 dependencies:
   flutter:
     sdk: flutter
-  audio_session: ^0.1.25
+  audio_session: ^0.2.0
   rxdart: ^0.28.0
-  just_audio_mpv: ^0.1.6
-  just_audio_windows: ^0.2.0
+  just_audio_media_kit: ^2.1.0
+  media_kit_libs_linux: any
+  media_kit_libs_windows_audio: any
   # Other platform implementations below:
+  # just_audio_windows: ^0.2.0
   # just_audio_libwinmedia: ^0.0.4
   just_audio:
     path: ../
diff --git a/just_audio/example/windows/flutter/generated_plugin_registrant.cc b/just_audio/example/windows/flutter/generated_plugin_registrant.cc
index eebf9bf42..ca7c0dc86 100644
--- a/just_audio/example/windows/flutter/generated_plugin_registrant.cc
+++ b/just_audio/example/windows/flutter/generated_plugin_registrant.cc
@@ -6,9 +6,9 @@
 
 #include "generated_plugin_registrant.h"
 
-#include <just_audio_windows/just_audio_windows_plugin.h>
+#include <media_kit_libs_windows_audio/media_kit_libs_windows_audio_plugin_c_api.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
-  JustAudioWindowsPluginRegisterWithRegistrar(
-      registry->GetRegistrarForPlugin("JustAudioWindowsPlugin"));
+  MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi"));
 }
diff --git a/just_audio/example/windows/flutter/generated_plugins.cmake b/just_audio/example/windows/flutter/generated_plugins.cmake
index 591a05243..26da86f36 100644
--- a/just_audio/example/windows/flutter/generated_plugins.cmake
+++ b/just_audio/example/windows/flutter/generated_plugins.cmake
@@ -3,7 +3,7 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
-  just_audio_windows
+  media_kit_libs_windows_audio
 )
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST
diff --git a/just_audio/lib/just_audio.dart b/just_audio/lib/just_audio.dart
index 84688a2b9..754e7c699 100644
--- a/just_audio/lib/just_audio.dart
+++ b/just_audio/lib/just_audio.dart
@@ -36,7 +36,7 @@ JustAudioPlatform get _pluginPlatform {
   return pluginPlatform;
 }
 
-/// An object to manage playing audio from a URL, a locale file or an asset.
+/// An audio player that plays a gapless playlist of [AudioSource]s.
 ///
 /// ```
 /// final player = AudioPlayer();
@@ -52,9 +52,11 @@ JustAudioPlatform get _pluginPlatform {
 /// await player.dispose();
 /// ```
 ///
-/// You must call [dispose] to release the resources used by this player,
-/// including any temporary files created to cache assets.
+/// You must call [stop] or [dispose] to release the resources used by this
+/// player, including any temporary files created to cache assets.
 class AudioPlayer {
+  static String _generateId() => _uuid.v4();
+
   /// The user agent to set on all HTTP requests.
   final String? _userAgent;
 
@@ -101,38 +103,66 @@ class AudioPlayer {
       _androidAudioAttributesSubscription;
   StreamSubscription<void>? _becomingNoisyEventSubscription;
   StreamSubscription<AudioInterruptionEvent>? _interruptionEventSubscription;
+  StreamSubscription<void>? _positionDiscontinuitySubscription;
+  StreamSubscription<void>? _currentIndexSubscription;
+  StreamSubscription<void>? _errorsSubscription;
+  StreamSubscription<void>? _errorsResetSubscription;
 
-  final String _id;
+  String? _id;
   final _proxy = _ProxyHttpServer();
-  AudioSource? _audioSource;
+  // ignore: deprecated_member_use_from_same_package
+  final ConcatenatingAudioSource _playlist;
   final Map<String, AudioSource> _audioSources = {};
   bool _disposed = false;
   _InitialSeekValues? _initialSeekValues;
   final AudioPipeline _audioPipeline;
 
-  PlaybackEvent _playbackEvent = PlaybackEvent();
-  final _playbackEventSubject = BehaviorSubject<PlaybackEvent>(sync: true);
-  Future<Duration?>? _durationFuture;
-  final _durationSubject = BehaviorSubject<Duration?>();
-  final _processingStateSubject = BehaviorSubject<ProcessingState>();
+  Future<Duration?>? _loadFuture;
+  final _shuffleIndicesInv = <int>[];
+
+  final _playbackEventSubject =
+      BehaviorSubject<PlaybackEvent>.seeded(PlaybackEvent(), sync: true);
+
+  // derived from playbackEventStream
+  final _processingStateSubject =
+      BehaviorSubject<ProcessingState>.seeded(ProcessingState.idle);
+  final _durationSubject = BehaviorSubject<Duration?>.seeded(null);
+  final _bufferedPositionSubject =
+      BehaviorSubject<Duration>.seeded(Duration.zero);
+  final _icyMetadataSubject = BehaviorSubject<IcyMetadata?>.seeded(null);
+  final _androidAudioSessionIdSubject = BehaviorSubject<int?>.seeded(null);
+  final _errorSubject = PublishSubject<PlayerException>();
+
+  // independent streams
   final _playingSubject = BehaviorSubject.seeded(false);
   final _volumeSubject = BehaviorSubject.seeded(1.0);
   final _speedSubject = BehaviorSubject.seeded(1.0);
   final _pitchSubject = BehaviorSubject.seeded(1.0);
   final _skipSilenceEnabledSubject = BehaviorSubject.seeded(false);
-  final _bufferedPositionSubject = BehaviorSubject<Duration>();
-  final _icyMetadataSubject = BehaviorSubject<IcyMetadata?>();
-  final _playerStateSubject = BehaviorSubject<PlayerState>();
-  final _sequenceSubject = BehaviorSubject<List<IndexedAudioSource>?>();
-  final _shuffleIndicesSubject = BehaviorSubject<List<int>?>();
-  final _shuffleIndicesInv = <int>[];
-  final _currentIndexSubject = BehaviorSubject<int?>(sync: true);
-  final _sequenceStateSubject = BehaviorSubject<SequenceState?>();
-  final _loopModeSubject = BehaviorSubject.seeded(LoopMode.off);
-  final _shuffleModeEnabledSubject = BehaviorSubject.seeded(false);
-  final _androidAudioSessionIdSubject = BehaviorSubject<int?>();
+
   final _positionDiscontinuitySubject =
       PublishSubject<PositionDiscontinuity>(sync: true);
+
+  final _sequenceStateSubject = BehaviorSubject<SequenceState>.seeded(
+      SequenceState(
+        sequence: [],
+        currentIndex: null,
+        shuffleIndices: [],
+        shuffleModeEnabled: false,
+        loopMode: LoopMode.off,
+      ),
+      sync: true);
+
+  // derived from sequenceStateStream
+  final _sequenceSubject = BehaviorSubject.seeded(<IndexedAudioSource>[]);
+  final _shuffleIndicesSubject = BehaviorSubject.seeded(<int>[]);
+  final _currentIndexSubject = BehaviorSubject<int?>.seeded(null);
+  final _loopModeSubject = BehaviorSubject.seeded(LoopMode.off);
+  final _shuffleModeEnabledSubject = BehaviorSubject.seeded(false);
+
+  final _playerStateSubject = BehaviorSubject<PlayerState>.seeded(
+      PlayerState(false, ProcessingState.idle));
+
   var _seeking = false;
   // ignore: close_sinks
   BehaviorSubject<Duration>? _positionSubject;
@@ -183,6 +213,18 @@ class AudioPlayer {
   ///
   /// The default audio loading and buffering behaviour can be configured via
   /// the [audioLoadConfiguration] parameter.
+  ///
+  /// [useLazyPreparation] specifies whether audio sources will be loaded lazily
+  /// in preparation to be played. Set this to `false` to make all audio
+  /// sources load eagerly in advance.
+  ///
+  /// [shuffleOrder] determines the playback order (defaulting to
+  /// [DefaultShuffleOrder]) when [shuffleModeEnabled] is `true`,
+  ///
+  /// When [maxSkipsOnError] is set, the player will automatically skip to the
+  /// next audio source on load errors, and will give up after [maxSkipsOnError]
+  /// attempts. This is supported on Android, iOS and web. For other platforms,
+  /// check the documentation of the respective platform implementation.
   AudioPlayer({
     String? userAgent,
     bool handleInterruptions = true,
@@ -192,7 +234,10 @@ class AudioPlayer {
     AudioPipeline? audioPipeline,
     bool androidOffloadSchedulingEnabled = false,
     bool useProxyForRequestHeaders = true,
-  })  : _id = _uuid.v4(),
+    bool useLazyPreparation = true,
+    ShuffleOrder? shuffleOrder,
+    int maxSkipsOnError = 0,
+  })  : _id = _generateId(),
         _userAgent = userAgent,
         _androidApplyAudioAttributes =
             androidApplyAudioAttributes && _isAndroid(),
@@ -200,41 +245,52 @@ class AudioPlayer {
         _audioLoadConfiguration = audioLoadConfiguration,
         _audioPipeline = audioPipeline ?? AudioPipeline(),
         _androidOffloadSchedulingEnabled = androidOffloadSchedulingEnabled,
-        _useProxyForRequestHeaders = useProxyForRequestHeaders {
+        _useProxyForRequestHeaders = useProxyForRequestHeaders,
+        // ignore: deprecated_member_use_from_same_package
+        _playlist = ConcatenatingAudioSource._playlist(
+          children: [],
+          useLazyPreparation: useLazyPreparation,
+          shuffleOrder: shuffleOrder,
+        ) {
     _audioPipeline._setup(this);
+    _playlist._onAttach(this);
     if (_audioLoadConfiguration?.darwinLoadControl != null) {
       _automaticallyWaitsToMinimizeStalling = _audioLoadConfiguration!
           .darwinLoadControl!.automaticallyWaitsToMinimizeStalling;
     }
-    _playbackEventSubject.add(_playbackEvent);
-    _processingStateSubject.addStream(playbackEventStream
-        .map((event) => event.processingState)
-        .distinct()
-        .handleError((Object err, StackTrace stackTrace) {/* noop */}));
-    _bufferedPositionSubject.addStream(playbackEventStream
-        .map((event) => event.bufferedPosition)
-        .distinct()
-        .handleError((Object err, StackTrace stackTrace) {/* noop */}));
-    _icyMetadataSubject.addStream(playbackEventStream
-        .map((event) => event.icyMetadata)
-        .distinct()
-        .handleError((Object err, StackTrace stackTrace) {/* noop */}));
-    playbackEventStream.pairwise().listen((pair) {
-      final prev = pair.first;
-      final curr = pair.last;
-      // Detect auto-advance
+    _durationSubject.addStream(
+        playbackEventStream.map((event) => event.duration).distinct());
+    _processingStateSubject.addStream(
+        playbackEventStream.map((event) => event.processingState).distinct());
+    _bufferedPositionSubject.addStream(
+        playbackEventStream.map((event) => event.bufferedPosition).distinct());
+    _icyMetadataSubject.addStream(
+        playbackEventStream.map((event) => event.icyMetadata).distinct());
+    _positionDiscontinuitySubscription = playbackEventStream
+        .map((event) => (
+              event,
+              (sequence.isNotEmpty &&
+                      event.currentIndex != null &&
+                      event.currentIndex! < sequence.length)
+                  ? sequence[event.currentIndex!]
+                  : null
+            ))
+        .pairwise()
+        .listen((rec) {
       if (_seeking) return;
-      if (prev.currentIndex == null || curr.currentIndex == null) return;
-      if (curr.currentIndex != prev.currentIndex) {
+      final [(prevEvent, prevSource), (currEvent, currSource)] = rec;
+      if (prevSource == null || currSource == null) return;
+      if (currSource._id != prevSource._id) {
         // If we've changed item without seeking, it must be an autoAdvance.
         _positionDiscontinuitySubject.add(PositionDiscontinuity(
-            PositionDiscontinuityReason.autoAdvance, prev, curr));
+            PositionDiscontinuityReason.autoAdvance, prevEvent, currEvent));
       } else {
         // If the item is the same, try to determine whether we have looped
         // back.
-        final prevPos = _getPositionFor(prev);
-        final currPos = _getPositionFor(curr);
-        if (loopMode != LoopMode.one) return;
+        if (loopMode == LoopMode.off) return;
+        final prevPos = _getPositionFor(prevEvent);
+        final currPos = _getPositionFor(currEvent);
+        // if (loopMode != LoopMode.one) return;
         if (currPos >= prevPos) return;
         if (currPos >= const Duration(milliseconds: 300)) return;
         final duration = this.duration;
@@ -244,50 +300,46 @@ class AudioPlayer {
           return;
         }
         _positionDiscontinuitySubject.add(PositionDiscontinuity(
-            PositionDiscontinuityReason.autoAdvance, prev, curr));
+            PositionDiscontinuityReason.autoAdvance, prevEvent, currEvent));
       }
-    }, onError: (Object e, StackTrace st) {});
-    _currentIndexSubject.addStream(playbackEventStream
-        .map((event) => event.currentIndex)
-        .distinct()
-        .handleError((Object err, StackTrace stackTrace) {/* noop */}));
+    });
+    _currentIndexSubscription = playbackEventStream.listen(
+      (event) => _sequenceStateSubject
+          .add(sequenceState.copyWith(currentIndex: event.currentIndex)),
+    );
+    _currentIndexSubject.addStream(
+        sequenceStateStream.map((sequenceState) => sequenceState.currentIndex));
+    _sequenceSubject.addStream(
+        sequenceStateStream.map((sequenceState) => sequenceState.sequence));
+    _shuffleIndicesSubject.addStream(sequenceStateStream
+        .map((sequenceState) => sequenceState.shuffleIndices));
+    _shuffleModeEnabledSubject.addStream(sequenceStateStream
+        .map((sequenceState) => sequenceState.shuffleModeEnabled));
+    _loopModeSubject.addStream(
+        sequenceStateStream.map((sequenceState) => sequenceState.loopMode));
+
     _androidAudioSessionIdSubject.addStream(playbackEventStream
         .map((event) => event.androidAudioSessionId)
+        .distinct());
+
+    _errorSubject.addStream(playbackEventStream
+        .map((event) => (
+              code: event.errorCode,
+              message: event.errorMessage,
+              index: event.currentIndex,
+            ))
         .distinct()
-        .handleError((Object err, StackTrace stackTrace) {/* noop */}));
-    _sequenceStateSubject.addStream(Rx.combineLatest5<List<IndexedAudioSource>?,
-        List<int>?, int?, bool, LoopMode, SequenceState?>(
-      sequenceStream,
-      shuffleIndicesStream,
-      currentIndexStream,
-      shuffleModeEnabledStream,
-      loopModeStream,
-      (sequence, shuffleIndices, currentIndex, shuffleModeEnabled, loopMode) {
-        if (sequence == null) return null;
-        if (shuffleIndices == null) return null;
-        currentIndex ??= 0;
-        currentIndex = max(min(sequence.length - 1, max(0, currentIndex)), 0);
-        return SequenceState(
-          sequence,
-          currentIndex,
-          shuffleIndices,
-          shuffleModeEnabled,
-          loopMode,
-        );
-      },
-    ).distinct().handleError((Object err, StackTrace stackTrace) {/* noop */}));
+        .where((error) => error.code != null)
+        .map((error) =>
+            PlayerException(error.code!, error.message, error.index)));
     _playerStateSubject.addStream(
         Rx.combineLatest2<bool, PlaybackEvent, PlayerState>(
                 playingStream,
                 playbackEventStream,
                 (playing, event) => PlayerState(playing, event.processingState))
-            .distinct()
-            .handleError((Object err, StackTrace stackTrace) {/* noop */}));
-    _shuffleModeEnabledSubject.add(false);
-    _loopModeSubject.add(LoopMode.off);
+            .distinct());
     _setPlatformActive(false, force: true)
         ?.catchError((dynamic e) async => null);
-    _sequenceSubject.add(null);
     // Respond to changes to AndroidAudioAttributes configuration.
     if (androidApplyAudioAttributes && _isAndroid()) {
       AudioSession.instance.then((audioSession) {
@@ -346,6 +398,24 @@ class AudioPlayer {
         });
       });
     }
+    if (maxSkipsOnError > 0) {
+      var consecutiveErrorCount = 0;
+      _errorsSubscription = errorStream.listen((error) async {
+        if (audioSources.length > 1 &&
+            consecutiveErrorCount < maxSkipsOnError &&
+            hasNext) {
+          consecutiveErrorCount++;
+          scheduleMicrotask(() => seekToNext().catchError((e, st) {}));
+        } else {
+          scheduleMicrotask(() => pause().catchError((e, st) {}));
+        }
+      });
+      _errorsResetSubscription = processingStateStream.listen((state) {
+        if (state == ProcessingState.ready && consecutiveErrorCount > 0) {
+          consecutiveErrorCount = 0;
+        }
+      });
+    }
     _removeOldAssetCacheDir();
   }
 
@@ -372,26 +442,23 @@ class AudioPlayer {
     }
   }
 
-  /// The previously set [AudioSource], if any.
-  AudioSource? get audioSource => _audioSource;
+  /// The first [AudioSource] in the playlist, if any.
+  AudioSource? get audioSource => _playlist.children.firstOrNull;
 
   /// The latest [PlaybackEvent].
-  PlaybackEvent get playbackEvent => _playbackEvent;
+  PlaybackEvent get playbackEvent => _playbackEventSubject.value;
 
   /// A stream of [PlaybackEvent]s.
   Stream<PlaybackEvent> get playbackEventStream => _playbackEventSubject.stream;
 
   /// The duration of the current audio or `null` if unknown.
-  Duration? get duration => _playbackEvent.duration;
-
-  /// The duration of the current audio or `null` if unknown.
-  Future<Duration?>? get durationFuture => _durationFuture;
+  Duration? get duration => playbackEvent.duration;
 
   /// The duration of the current audio.
   Stream<Duration?> get durationStream => _durationSubject.stream;
 
   /// The current [ProcessingState].
-  ProcessingState get processingState => _playbackEvent.processingState;
+  ProcessingState get processingState => playbackEvent.processingState;
 
   /// A stream of [ProcessingState]s.
   Stream<ProcessingState> get processingStateStream =>
@@ -438,7 +505,7 @@ class AudioPlayer {
 
   /// The latest ICY metadata received through the audio source, or `null` if no
   /// metadata is available.
-  IcyMetadata? get icyMetadata => _playbackEvent.icyMetadata;
+  IcyMetadata? get icyMetadata => playbackEvent.icyMetadata;
 
   /// A stream of ICY metadata received through the audio source.
   Stream<IcyMetadata?> get icyMetadataStream => _icyMetadataSubject.stream;
@@ -451,39 +518,32 @@ class AudioPlayer {
   /// A stream of [PlayerState]s.
   Stream<PlayerState> get playerStateStream => _playerStateSubject.stream;
 
-  /// The current sequence of indexed audio sources, or `null` if no audio
-  /// source is set.
-  List<IndexedAudioSource>? get sequence => _sequenceSubject.nvalue;
+  /// The current sequence of indexed audio sources.
+  List<IndexedAudioSource> get sequence => _sequenceSubject.value;
 
   /// A stream broadcasting the current sequence of indexed audio sources.
-  Stream<List<IndexedAudioSource>?> get sequenceStream =>
+  Stream<List<IndexedAudioSource>> get sequenceStream =>
       _sequenceSubject.stream;
 
-  /// The current shuffled sequence of indexed audio sources, or `null` if no
-  /// audio source is set.
-  List<int>? get shuffleIndices => _shuffleIndicesSubject.nvalue;
+  /// The current shuffled sequence of indexed audio sources.
+  List<int> get shuffleIndices => _shuffleIndicesSubject.value;
 
   /// A stream broadcasting the current shuffled sequence of indexed audio
   /// sources.
-  Stream<List<int>?> get shuffleIndicesStream => _shuffleIndicesSubject.stream;
-
-  //List<IndexedAudioSource> get _effectiveSequence =>
-  //    shuffleModeEnabled ? shuffleIndices : sequence;
+  Stream<List<int>> get shuffleIndicesStream => _shuffleIndicesSubject.stream;
 
-  /// The index of the current item, or `null` if either no audio source is set,
-  /// or the current audio source has an empty sequence.
+  /// The index of the current [IndexedAudioSource] in [sequence], or `null` if
+  /// [sequence] is empty.
   int? get currentIndex => _currentIndexSubject.nvalue;
 
-  /// A stream broadcasting the current item.
+  /// A stream broadcasting the current index.
   Stream<int?> get currentIndexStream => _currentIndexSubject.stream;
 
-  /// The current [SequenceState], or `null` if either [sequence]] or
-  /// [currentIndex] is `null`.
-  SequenceState? get sequenceState => _sequenceStateSubject.nvalue;
+  /// The current [SequenceState].
+  SequenceState get sequenceState => _sequenceStateSubject.value;
 
   /// A stream broadcasting the current [SequenceState].
-  Stream<SequenceState?> get sequenceStateStream =>
-      _sequenceStateSubject.stream;
+  Stream<SequenceState> get sequenceStateStream => _sequenceStateSubject.stream;
 
   /// Whether there is another item after the current index.
   bool get hasNext => nextIndex != null;
@@ -492,20 +552,17 @@ class AudioPlayer {
   bool get hasPrevious => previousIndex != null;
 
   /// Returns [shuffleIndices] if [shuffleModeEnabled] is `true`, otherwise
-  /// returns the unshuffled indices. When no current audio source is set, this
-  /// returns `null`.
-  List<int>? get effectiveIndices {
-    if (shuffleIndices == null || sequence == null) return null;
+  /// returns the unshuffled indices.
+  List<int> get effectiveIndices {
     return shuffleModeEnabled
         ? shuffleIndices
-        : List.generate(sequence!.length, (i) => i);
+        : List.generate(sequence.length, (i) => i);
   }
 
-  List<int>? get _effectiveIndicesInv {
-    if (shuffleIndices == null || sequence == null) return null;
+  List<int> get _effectiveIndicesInv {
     return shuffleModeEnabled
         ? _shuffleIndicesInv
-        : List.generate(sequence!.length, (i) => i);
+        : List.generate(sequence.length, (i) => i);
   }
 
   /// The index of the next item to be played, or `null` if there is no next
@@ -517,11 +574,11 @@ class AudioPlayer {
   int? get previousIndex => _getRelativeIndex(-1);
 
   int? _getRelativeIndex(int offset) {
-    if (_audioSource == null || currentIndex == null) return null;
+    if (_playlist.children.isEmpty || currentIndex == null) return null;
     if (loopMode == LoopMode.one) return currentIndex;
     final effectiveIndices = this.effectiveIndices;
-    if (effectiveIndices == null || effectiveIndices.isEmpty) return null;
-    final effectiveIndicesInv = _effectiveIndicesInv!;
+    if (effectiveIndices.isEmpty) return null;
+    final effectiveIndicesInv = _effectiveIndicesInv;
     if (currentIndex! >= effectiveIndicesInv.length) return null;
     final invPos = effectiveIndicesInv[currentIndex!];
     var newInvPos = invPos + offset;
@@ -550,12 +607,15 @@ class AudioPlayer {
       _shuffleModeEnabledSubject.stream;
 
   /// The current Android AudioSession ID or `null` if not set.
-  int? get androidAudioSessionId => _playbackEvent.androidAudioSessionId;
+  int? get androidAudioSessionId => playbackEvent.androidAudioSessionId;
 
   /// Broadcasts the current Android AudioSession ID or `null` if not set.
   Stream<int?> get androidAudioSessionIdStream =>
       _androidAudioSessionIdSubject.stream;
 
+  /// A stream of errors broadcast by the player.
+  Stream<PlayerException> get errorStream => _errorSubject.stream;
+
   /// A stream broadcasting every position discontinuity.
   Stream<PositionDiscontinuity> get positionDiscontinuityStream =>
       _positionDiscontinuitySubject.stream;
@@ -585,7 +645,7 @@ class AudioPlayer {
   String get webSinkId => _webSinkId;
 
   /// The current position of the player.
-  Duration get position => _getPositionFor(_playbackEvent);
+  Duration get position => _getPositionFor(playbackEvent);
 
   Duration _getPositionFor(PlaybackEvent playbackEvent) {
     if (playing && processingState == ProcessingState.ready) {
@@ -673,7 +733,7 @@ class AudioPlayer {
     }, onError: (Object e, StackTrace stackTrace) {});
     playbackEventSubscription = playbackEventStream.listen((event) {
       controller.add(position);
-    }, onError: (Object e, StackTrace stackTrace) {});
+    });
     return controller.stream.distinct();
   }
 
@@ -689,7 +749,7 @@ class AudioPlayer {
   ///     initialPosition: Duration.zero, preload: true);
   /// ```
   ///
-  /// See [setAudioSource] for a detailed explanation of the options.
+  /// See [setAudioSources] for a detailed explanation of the options.
   Future<Duration?> setUrl(
     String url, {
     Map<String, String>? headers,
@@ -712,7 +772,7 @@ class AudioPlayer {
   ///     initialPosition: Duration.zero, preload: true);
   /// ```
   ///
-  /// See [setAudioSource] for a detailed explanation of the options.
+  /// See [setAudioSources] for a detailed explanation of the options.
   Future<Duration?> setFilePath(
     String filePath, {
     Duration? initialPosition,
@@ -735,7 +795,7 @@ class AudioPlayer {
   /// If the asset is to be loaded from a different package, the [package]
   /// parameter must be given to specify the package name.
   ///
-  /// See [setAudioSource] for a detailed explanation of the options.
+  /// See [setAudioSources] for a detailed explanation of the options.
   Future<Duration?> setAsset(
     String assetPath, {
     String? package,
@@ -749,15 +809,39 @@ class AudioPlayer {
         preload: preload,
       );
 
-  /// Sets the source from which this audio player should fetch audio.
+  /// Clears the playlist and adds the given [audioSource].
+  ///
+  /// This is equivalent to:
   ///
-  /// By default, this method will immediately start loading audio and return
-  /// its duration as soon as it is known, or `null` if that information is
-  /// unavailable. Set [preload] to `false` if you would prefer to delay loading
-  /// until some later point, either via an explicit call to [load] or via a
-  /// call to [play] which implicitly loads the audio. If [preload] is `false`,
-  /// a `null` duration will be returned. Note that the [preload] option will
-  /// automatically be assumed as `true` if `playing` is currently `true`.
+  /// ```
+  /// setAudioSources([source], initialIndex: 0, initialPosition: Duration.zero,
+  ///     preload: true);
+  /// ```
+  ///
+  /// See [setAudioSources] for a detailed explanation of the options.
+  Future<Duration?> setAudioSource(
+    AudioSource audioSource, {
+    bool preload = true,
+    int? initialIndex,
+    Duration? initialPosition,
+  }) =>
+      setAudioSources(
+        [audioSource],
+        preload: preload,
+        initialIndex: initialIndex,
+        initialPosition: initialPosition,
+      );
+
+  /// Clears the playlist and adds the given [audioSources].
+  ///
+  /// By default, this method will immediately start loading the initial
+  /// [AudioSource] and return its duration as soon as it is known, or `null` if
+  /// that information is unavailable. Set [preload] to `false` if you would
+  /// prefer to delay loading until some later point, either via an explicit
+  /// call to [load] or via a call to [play] which implicitly loads the audio.
+  /// If [preload] is `false`, a `null` duration will be returned. Note that the
+  /// [preload] option will automatically be assumed as `true` if `playing` is
+  /// currently `true`.
   ///
   /// Optionally specify [initialPosition] and [initialIndex] to seek to an
   /// initial position within a particular item (defaulting to position zero of
@@ -765,29 +849,29 @@ class AudioPlayer {
   ///
   /// When [preload] is `true`, this method may throw:
   ///
-  /// * [Exception] if no audio source has been previously set.
-  /// * [PlayerException] if the audio source was unable to be loaded.
-  /// * [PlayerInterruptedException] if another audio source was loaded before
-  /// this call completed or the player was stopped or disposed of before the
-  /// call completed.
-  Future<Duration?> setAudioSource(
-    AudioSource source, {
+  /// * [PlayerException] if the initial audio source was unable to be loaded.
+  /// * [PlayerInterruptedException] if another invocation of this method
+  /// interrupted this invocation.
+  Future<Duration?> setAudioSources(
+    List<AudioSource> audioSources, {
     bool preload = true,
     int? initialIndex,
     Duration? initialPosition,
+    ShuffleOrder? shuffleOrder,
   }) async {
     if (_disposed) return null;
-    _audioSource = null;
+    await _playlist.clear();
     _initialSeekValues =
         _InitialSeekValues(position: initialPosition, index: initialIndex);
-    _playbackEventSubject.add(_playbackEvent = PlaybackEvent(
+    _playbackEventSubject.add(PlaybackEvent(
         currentIndex: initialIndex ?? 0,
         updatePosition: initialPosition ?? Duration.zero));
-    _audioSource = source;
-    _broadcastSequence();
+    _playlist._shuffleOrder = shuffleOrder ?? DefaultShuffleOrder();
+    await _playlist.addAll(audioSources);
+    await _broadcastSequence();
     Duration? duration;
     if (playing) preload = true;
-    if (preload) {
+    if (preload && _playlist.children.isNotEmpty) {
       duration = await load();
     } else {
       await _setPlatformActive(false)?.catchError((dynamic e) async => null);
@@ -795,25 +879,22 @@ class AudioPlayer {
     return duration;
   }
 
-  /// Starts loading the current audio source and returns the audio duration as
-  /// soon as it is known, or `null` if unavailable.
+  /// Starts loading the playlist and returns the audio duration of the initial
+  /// [AudioSource] in the playlist as soon as it is known, or `null` if
+  /// unavailable. This method does nothing if the playlist is empty.
   ///
   /// This method throws:
   ///
-  /// * [Exception] if no audio source has been previously set.
   /// * [PlayerException] if the audio source was unable to be loaded.
-  /// * [PlayerInterruptedException] if another call to [load] happened before
-  /// this call completed or the player was stopped or disposed of before the
-  /// call could complete.
+  /// * [PlayerInterruptedException] if another invocation of [setAudioSources]
+  /// interrupted this invocation.
   Future<Duration?> load() async {
     if (_disposed) return null;
-    if (_audioSource == null) {
-      throw Exception('Must set AudioSource before loading');
-    }
+    if (_playlist.children.isEmpty) return null;
     if (_active) {
       final initialSeekValues = _initialSeekValues;
       _initialSeekValues = null;
-      return await _load(await _platform, _audioSource!,
+      return await _load(await _platform, _playlist,
           initialSeekValues: initialSeekValues);
     } else {
       // This will implicitly load the current audio source.
@@ -821,16 +902,45 @@ class AudioPlayer {
     }
   }
 
-  void _broadcastSequence() {
-    // TODO: update currentIndex first if it's out of range as a result of
-    // removing items from the playlist.
-    _sequenceSubject.add(_audioSource?.sequence);
-    _updateShuffleIndices();
-  }
+  /// Adds [audioSource] to the end of the playlist.
+  Future<void> addAudioSource(AudioSource audioSource) =>
+      _playlist.add(audioSource);
 
-  void _updateShuffleIndices() {
-    _shuffleIndicesSubject.add(_audioSource?.shuffleIndices);
-    final shuffleIndicesLength = shuffleIndices?.length ?? 0;
+  /// Inserts [audioSource] into the playlist at [index].
+  Future<void> insertAudioSource(int index, AudioSource audioSource) =>
+      _playlist.insert(index, audioSource);
+
+  /// Adds [audioSources] to the end of the playlist.
+  Future<void> addAudioSources(List<AudioSource> audioSources) =>
+      _playlist.addAll(audioSources);
+
+  /// Inserts [audioSources] into the playlist at [index].
+  Future<void> insertAudioSources(int index, List<AudioSource> audioSources) =>
+      _playlist.insertAll(index, audioSources);
+
+  /// Removes the [AudioSource] at [index] from the playlist.
+  Future<void> removeAudioSourceAt(int index) => _playlist.removeAt(index);
+
+  /// Removes the [AudioSource]s from index [start] to [end] from the playlist.
+  Future<void> removeAudioSourceRange(int start, int end) =>
+      _playlist.removeRange(start, end);
+
+  /// Moves the [AudioSource] in the playlist at [currentIndex] to [newIndex].
+  Future<void> moveAudioSource(int currentIndex, int newIndex) =>
+      _playlist.move(currentIndex, newIndex);
+
+  /// Clears the playlist.
+  Future<void> clearAudioSources() => _playlist.clear();
+
+  /// The playlist.
+  List<AudioSource> get audioSources => _playlist.children;
+
+  Future<void> _broadcastSequence({bool sequenceChanged = true}) async {
+    _sequenceStateSubject.add(sequenceState.copyWith(
+      sequence: sequenceChanged ? _playlist.sequence : sequenceState.sequence,
+      shuffleIndices: _playlist.shuffleIndices,
+    ));
+    final shuffleIndicesLength = shuffleIndices.length;
     if (_shuffleIndicesInv.length > shuffleIndicesLength) {
       _shuffleIndicesInv.removeRange(
           shuffleIndicesLength, _shuffleIndicesInv.length);
@@ -839,16 +949,22 @@ class AudioPlayer {
           List.filled(shuffleIndicesLength - _shuffleIndicesInv.length, 0));
     }
     for (var i = 0; i < shuffleIndicesLength; i++) {
-      _shuffleIndicesInv[shuffleIndices![i]] = i;
+      _shuffleIndicesInv[shuffleIndices[i]] = i;
     }
+    // Allow this event to propagate to derived streams.
+    await Future<void>.delayed(Duration.zero);
   }
 
   void _registerAudioSource(AudioSource source) {
     _audioSources[source._id] = source;
   }
 
-  Future<Duration?> _load(AudioPlayerPlatform platform, AudioSource source,
-      {_InitialSeekValues? initialSeekValues}) async {
+  Future<Duration?> _load(
+    AudioPlayerPlatform platform,
+    // ignore: deprecated_member_use_from_same_package
+    ConcatenatingAudioSource source, {
+    _InitialSeekValues? initialSeekValues,
+  }) async {
     final activationNumber = _activationCount;
     void checkInterruption() {
       if (_activationCount != activationNumber) {
@@ -858,20 +974,22 @@ class AudioPlayer {
     }
 
     try {
-      await source._setup(this);
-      checkInterruption();
+      if (_active) {
+        await source._onLoad();
+        checkInterruption();
+      }
       source._shuffle(initialIndex: initialSeekValues?.index ?? 0);
-      _broadcastSequence();
-      _durationFuture = platform
+      await _broadcastSequence();
+      checkInterruption();
+      _loadFuture = platform
           .load(LoadRequest(
             audioSourceMessage: source._toMessage(),
             initialPosition: initialSeekValues?.position,
             initialIndex: initialSeekValues?.index,
           ))
           .then((response) => response.duration);
-      final duration = await _durationFuture;
+      final duration = await _loadFuture;
       checkInterruption();
-      _durationSubject.add(duration);
       if (platform != _platformValue) {
         // the platform has changed since we started loading, so abort.
         throw PlatformException(code: 'abort', message: 'Loading interrupted');
@@ -882,16 +1000,7 @@ class AudioPlayer {
       checkInterruption();
       return duration;
     } on PlatformException catch (e) {
-      try {
-        throw PlayerException(int.parse(e.code), e.message,
-            (e.details as Map<dynamic, dynamic>?)?.cast<String, dynamic>());
-      } on FormatException catch (_) {
-        if (e.code == 'abort') {
-          throw PlayerInterruptedException(e.message);
-        } else {
-          throw PlayerException(9999999, e.message);
-        }
-      }
+      throw _convertException(e);
     }
   }
 
@@ -903,17 +1012,24 @@ class AudioPlayer {
   Future<Duration?> setClip(
       {Duration? start, Duration? end, dynamic tag}) async {
     if (_disposed) return null;
+    final audioSource = _playlist.children.firstOrNull;
+    if (_playlist.children.length != 1 || audioSource is! UriAudioSource) {
+      throw Exception('Playlist must contain 1 UriAudioSource');
+    }
     _setPlatformActive(true)?.catchError((dynamic e) async => null);
     final duration = await _load(
         await _platform,
         start == null && end == null
-            ? _audioSource!
-            : ClippingAudioSource(
-                child: _audioSource as UriAudioSource,
-                start: start,
-                end: end,
-                tag: tag,
-              ));
+            ? _playlist
+            // ignore: deprecated_member_use_from_same_package
+            : ConcatenatingAudioSource._playlist(children: [
+                ClippingAudioSource(
+                  child: audioSource,
+                  start: start,
+                  end: end,
+                  tag: tag,
+                )
+              ]));
     return duration;
   }
 
@@ -941,19 +1057,18 @@ class AudioPlayer {
     // Broadcast to clients immediately, but revert to false if we fail to
     // activate the audio session. This allows setAudioSource to be aware of a
     // prior play request.
-    _playbackEvent = _playbackEvent.copyWith(
+    _playbackEventSubject.add(playbackEvent.copyWith(
       updatePosition: position,
       updateTime: DateTime.now(),
-    );
+    ));
     _playingSubject.add(true);
-    _playbackEventSubject.add(_playbackEvent);
     final playCompleter = Completer<dynamic>();
     final audioSession = await AudioSession.instance;
     if (!_handleAudioSessionActivation || await audioSession.setActive(true)) {
       if (!playing) return;
       // TODO: rewrite this to more cleanly handle simultaneous load/play
       // requests which each may result in platform play requests.
-      final requireActive = _audioSource != null;
+      final requireActive = _playlist.children.isNotEmpty;
       if (requireActive) {
         if (_active) {
           // If the native platform is already active, send it a play request.
@@ -983,12 +1098,11 @@ class AudioPlayer {
     //_setPlatformActive(true);
     _playInterrupted = false;
     // Update local state immediately so that queries aren't surprised.
-    _playbackEvent = _playbackEvent.copyWith(
+    _playbackEventSubject.add(playbackEvent.copyWith(
       updatePosition: position,
       updateTime: DateTime.now(),
-    );
+    ));
     _playingSubject.add(false);
-    _playbackEventSubject.add(_playbackEvent);
     // TODO: perhaps modify platform side to ensure new state is broadcast
     // before this method returns.
     await (await _platform).pause(PauseRequest());
@@ -1053,11 +1167,10 @@ class AudioPlayer {
   /// audio.
   Future<void> setSpeed(final double speed) async {
     if (_disposed) return;
-    _playbackEvent = _playbackEvent.copyWith(
+    _playbackEventSubject.add(playbackEvent.copyWith(
       updatePosition: position,
       updateTime: DateTime.now(),
-    );
-    _playbackEventSubject.add(_playbackEvent);
+    ));
     _speedSubject.add(speed);
     await (await _platform).setSpeed(SetSpeedRequest(speed: speed));
   }
@@ -1065,11 +1178,10 @@ class AudioPlayer {
   /// Sets the factor by which pitch will be shifted.
   Future<void> setPitch(final double pitch) async {
     if (_disposed) return;
-    _playbackEvent = _playbackEvent.copyWith(
+    _playbackEventSubject.add(playbackEvent.copyWith(
       updatePosition: position,
       updateTime: DateTime.now(),
-    );
-    _playbackEventSubject.add(_playbackEvent);
+    ));
     _pitchSubject.add(pitch);
     await (await _platform).setPitch(SetPitchRequest(pitch: pitch));
   }
@@ -1078,7 +1190,7 @@ class AudioPlayer {
   /// web, there will be a slight gap at the loop point.
   Future<void> setLoopMode(LoopMode mode) async {
     if (_disposed) return;
-    _loopModeSubject.add(mode);
+    _sequenceStateSubject.add(sequenceState.copyWith(loopMode: mode));
     await (await _platform).setLoopMode(
         SetLoopModeRequest(loopMode: LoopModeMessage.values[mode.index]));
   }
@@ -1086,22 +1198,23 @@ class AudioPlayer {
   /// Sets whether shuffle mode is enabled.
   Future<void> setShuffleModeEnabled(bool enabled) async {
     if (_disposed) return;
-    _shuffleModeEnabledSubject.add(enabled);
+    _sequenceStateSubject
+        .add(sequenceState.copyWith(shuffleModeEnabled: enabled));
     await (await _platform).setShuffleMode(SetShuffleModeRequest(
         shuffleMode:
             enabled ? ShuffleModeMessage.all : ShuffleModeMessage.none));
   }
 
-  /// Recursively shuffles the children of the currently loaded [AudioSource].
-  /// Each [ConcatenatingAudioSource] will be shuffled according to its
-  /// configured [ShuffleOrder].
+  /// Shuffles the playlist using the [ShuffleOrder] passed into the
+  /// constructor, and recursively shuffles each nested [AudioSource]
+  /// according to its own [ShuffleOrder].
   Future<void> shuffle() async {
     if (_disposed) return;
-    if (_audioSource == null) return;
-    _audioSource!._shuffle(initialIndex: currentIndex);
-    _updateShuffleIndices();
+    if (_playlist.children.isEmpty) return;
+    _playlist._shuffle(initialIndex: currentIndex);
+    await _broadcastSequence(sequenceChanged: false);
     await (await _platform).setShuffleOrder(
-        SetShuffleOrderRequest(audioSourceMessage: _audioSource!._toMessage()));
+        SetShuffleOrderRequest(audioSourceMessage: _playlist._toMessage()));
   }
 
   /// Sets automaticallyWaitsToMinimizeStalling for AVPlayer in iOS 10.0 or later, defaults to true.
@@ -1148,10 +1261,8 @@ class AudioPlayer {
             allowsExternalPlayback: allowsExternalPlayback));
   }
 
-  /// Seeks to a particular [position]. If a composition of multiple
-  /// [AudioSource]s has been loaded, you may also specify [index] to seek to a
-  /// particular item within that sequence. This method has no effect unless
-  /// an audio source has been loaded.
+  /// Seeks to a particular [position], and optionally to a particular [index]
+  /// within [sequence].
   ///
   /// A `null` [position] seeks to the head of a live stream.
   Future<void> seek(final Duration? position, {int? index}) async {
@@ -1163,39 +1274,41 @@ class AudioPlayer {
       default:
         try {
           _seeking = true;
-          final prevPlaybackEvent = _playbackEvent;
-          _playbackEvent = prevPlaybackEvent.copyWith(
+          final prevPlaybackEvent = playbackEvent;
+          _playbackEventSubject.add(prevPlaybackEvent.copyWith(
             updatePosition: position,
             updateTime: DateTime.now(),
-          );
-          _playbackEventSubject.add(_playbackEvent);
+          ));
           _positionDiscontinuitySubject.add(PositionDiscontinuity(
               PositionDiscontinuityReason.seek,
               prevPlaybackEvent,
-              _playbackEvent));
+              playbackEvent));
           await (await _platform)
               .seek(SeekRequest(position: position, index: index));
+          if (playing && !_active) {
+            _setPlatformActive(true)?.catchError((dynamic e) async => null);
+          }
         } finally {
           _seeking = false;
         }
     }
   }
 
-  /// Seek to the next item, or does nothing if there is no next item.
+  /// Seeks to the next item, or does nothing if there is no next item.
   Future<void> seekToNext() async {
     if (hasNext) {
       await seek(Duration.zero, index: nextIndex);
     }
   }
 
-  /// Seek to the previous item, or does nothing if there is no previous item.
+  /// Seeks to the previous item, or does nothing if there is no previous item.
   Future<void> seekToPrevious() async {
     if (hasPrevious) {
       await seek(Duration.zero, index: previousIndex);
     }
   }
 
-  /// Set the Android audio attributes for this player. Has no effect on other
+  /// Sets the Android audio attributes for this player. Has no effect on other
   /// platforms. This will cause a new Android AudioSession ID to be generated.
   Future<void> setAndroidAudioAttributes(
       AndroidAudioAttributes audioAttributes) async {
@@ -1215,7 +1328,7 @@ class AudioPlayer {
         usage: audioAttributes.usage.value));
   }
 
-  /// Set the `crossorigin` attribute on the `<audio>` element backing this
+  /// Sets the `crossorigin` attribute on the `<audio>` element backing this
   /// player instance on web (see
   /// [HTMLMediaElement crossorigin](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/crossOrigin) ).
   ///
@@ -1248,7 +1361,7 @@ class AudioPlayer {
     _webSinkId = webSinkId;
   }
 
-  /// Release all resources associated with this player. You must invoke this
+  /// Releases all resources associated with this player. You must invoke this
   /// after you are done with the player.
   Future<void> dispose() async {
     if (_disposed) return;
@@ -1261,27 +1374,53 @@ class AudioPlayer {
       await _disposePlatform(_idlePlatform!);
       _idlePlatform = null;
     }
-    _audioSource = null;
+    _playlist.children.clear();
     for (var s in _audioSources.values) {
       s._dispose();
     }
     _audioSources.clear();
     _proxy.stop();
-    await _durationSubject.close();
-    await _loopModeSubject.close();
-    await _shuffleModeEnabledSubject.close();
+    await _playerDataSubscription?.cancel();
+    await _playbackEventSubscription?.cancel();
+    await _androidAudioAttributesSubscription?.cancel();
+    await _becomingNoisyEventSubscription?.cancel();
+    await _interruptionEventSubscription?.cancel();
+    await _positionDiscontinuitySubscription?.cancel();
+    await _currentIndexSubscription?.cancel();
+    await _errorsSubscription?.cancel();
+    await _errorsResetSubscription?.cancel();
+
+    await _playbackEventSubject.close();
+    await _sequenceStateSubject.close();
     await _playingSubject.close();
     await _volumeSubject.close();
     await _speedSubject.close();
     await _pitchSubject.close();
+
+    await Future<void>.delayed(Duration.zero);
+    await _durationSubject.close();
+    await _processingStateSubject.close();
+    await _bufferedPositionSubject.close();
+    await _icyMetadataSubject.close();
+    await _androidAudioSessionIdSubject.close();
+    await _errorSubject.close();
+    await _playerStateSubject.close();
+    await _skipSilenceEnabledSubject.close();
+    await _positionDiscontinuitySubject.close();
     await _sequenceSubject.close();
     await _shuffleIndicesSubject.close();
-    await _androidAudioAttributesSubscription?.cancel();
-    await _becomingNoisyEventSubscription?.cancel();
-    await _interruptionEventSubscription?.cancel();
+    await _currentIndexSubject.close();
+    await _loopModeSubject.close();
+    await _shuffleModeEnabledSubject.close();
+    await _shuffleModeEnabledSubject.close();
+
+    if (playbackEvent.processingState != ProcessingState.idle) {
+      _playbackEventSubject
+          .add(playbackEvent.copyWith(processingState: ProcessingState.idle));
+    }
   }
 
-  /// Switch to using the native platform when [active] is `true` and using the
+  /// Switches to using the native platform when [active] is `true` and using the
   /// idle platform when [active] is `false`. If an audio source has been set,
   /// the returned future completes with its duration if known, or `null`
   /// otherwise.
@@ -1291,7 +1430,7 @@ class AudioPlayer {
   Future<Duration?>? _setPlatformActive(bool active,
       {Completer<void>? playCompleter, bool force = false}) {
     if (_disposed) return null;
-    if (!force && (active == _active)) return _durationFuture;
+    if (!force && (active == _active)) return _loadFuture;
     _platformLoading = active;
 
     // Warning! Tricky async code lies ahead.
@@ -1336,7 +1475,7 @@ class AudioPlayer {
     _active = active;
     final position = this.position;
     final currentIndex = this.currentIndex;
-    final audioSource = _audioSource;
+    final playlist = _playlist;
 
     void subscribeToEvents(AudioPlayerPlatform platform) {
       _playerDataSubscription =
@@ -1354,29 +1493,33 @@ class AudioPlayer {
           _pitchSubject.add(message.pitch!);
         }
         if (message.loopMode != null) {
-          _loopModeSubject.add(LoopMode.values[message.loopMode!.index]);
+          _sequenceStateSubject.add(sequenceState.copyWith(
+              loopMode: LoopMode.values[message.loopMode!.index]));
         }
         if (message.shuffleMode != null) {
-          _shuffleModeEnabledSubject
-              .add(message.shuffleMode != ShuffleModeMessage.none);
+          _sequenceStateSubject.add(sequenceState.copyWith(
+              shuffleModeEnabled:
+                  message.shuffleMode != ShuffleModeMessage.none));
         }
+      }, onDone: () {
+        _playerDataSubscription = null;
       });
       _playbackEventSubscription =
           platform.playbackEventMessageStream.listen((message) {
         var duration = message.duration;
         var index = message.currentIndex ?? currentIndex;
-        if (index != null && sequence != null && index < sequence!.length) {
+        if (index != null && index < sequence.length) {
           if (duration == null) {
-            duration = sequence![index].duration;
+            duration = sequence[index].duration;
           } else {
-            sequence![index].duration = duration;
+            sequence[index].duration = duration;
           }
         }
         if (_platformLoading &&
             message.processingState != ProcessingStateMessage.idle) {
           _platformLoading = false;
         }
-        final playbackEvent = PlaybackEvent(
+        final newPlaybackEvent = PlaybackEvent(
           // The platform may emit an idle state while it's starting up which we
           // override here.
           processingState: _platformLoading
@@ -1391,27 +1534,31 @@ class AudioPlayer {
               : IcyMetadata._fromMessage(message.icyMetadata!),
           currentIndex: index,
           androidAudioSessionId: message.androidAudioSessionId,
+          errorCode: message.errorCode,
+          errorMessage: message.errorMessage,
         );
-        _durationFuture = Future.value(playbackEvent.duration);
-        if (playbackEvent == _playbackEvent) {
+        _loadFuture = Future.value(newPlaybackEvent.duration);
+        if (newPlaybackEvent == playbackEvent) {
           return;
         }
-        if (playbackEvent.duration != _playbackEvent.duration) {
-          _durationSubject.add(playbackEvent.duration);
-        }
-        final oldPlaybackEvent = _playbackEvent;
-        _playbackEventSubject.add(_playbackEvent = playbackEvent);
-        if (_playbackEvent.processingState !=
-                oldPlaybackEvent.processingState &&
-            _playbackEvent.processingState == ProcessingState.idle) {
+        final oldPlaybackEvent = playbackEvent;
+        _playbackEventSubject.add(newPlaybackEvent);
+        if (playbackEvent.processingState != oldPlaybackEvent.processingState &&
+            playbackEvent.processingState == ProcessingState.idle) {
           _setPlatformActive(false)?.catchError((dynamic e) async => null);
         }
-      }, onError: _playbackEventSubject.addError);
+      }, onError: (Object e, [StackTrace? st]) {});
     }
 
     Future<AudioPlayerPlatform> setPlatform() async {
-      _playbackEventSubscription?.cancel();
-      _playerDataSubscription?.cancel();
+      // We need to cancel before calling _disposePlatform in order to prevent a
+      // MissingPluginException. This also means there will potentially be
+      // unconsumed events which will unfortunately appear when spinning up a
+      // new platform player. For now, we change the player ID to avoid wires
+      // getting crossed, although it would be better to find a way to flush the
+      // event channel and keep the same ID.
+      await _playbackEventSubscription?.cancel();
+      await _playerDataSubscription?.cancel();
       if (!force) {
         final oldPlatform = _platformValue!;
         if (oldPlatform is! _IdleAudioPlayer) {
@@ -1423,7 +1570,7 @@ class AudioPlayer {
       // _platform is updated again during initialisation.
       final platform = active
           ? await (_nativePlatform = _pluginPlatform.init(InitRequest(
-              id: _id,
+              id: _id = _generateId(),
               audioLoadConfiguration: _audioLoadConfiguration?._toMessage(),
               androidAudioEffects: (_isAndroid() || _isUnitTest())
                   ? _audioPipeline.androidAudioEffects
@@ -1436,16 +1583,21 @@ class AudioPlayer {
                       .toList()
                   : [],
               androidOffloadSchedulingEnabled: _androidOffloadSchedulingEnabled,
+              useLazyPreparation: _playlist.useLazyPreparation,
             )))
-          : (_idlePlatform =
-              _IdleAudioPlayer(id: _id, sequenceStream: sequenceStream));
+          : (_idlePlatform = _IdleAudioPlayer(
+              id: _id = _generateId(),
+              sequenceStream: sequenceStream,
+              errorCode: playbackEvent.errorCode,
+              errorMessage: playbackEvent.errorMessage,
+            ));
       if (checkInterruption()) return platform;
 
       _platformValue = platform;
 
       if (active) {
-        if (audioSource != null) {
-          _playbackEventSubject.add(_playbackEvent = _playbackEvent.copyWith(
+        if (playlist.children.isNotEmpty) {
+          _playbackEventSubject.add(playbackEvent.copyWith(
             updatePosition: position,
             processingState: ProcessingState.loading,
           ));
@@ -1506,11 +1658,13 @@ class AudioPlayer {
             await platform.setWebCrossOrigin(SetWebCrossOriginRequest(
               crossOrigin: WebCrossOriginMessage.values[_webCrossOrigin!.index],
             ));
+            if (checkInterruption()) return platform;
           }
           if (_webSinkId != '') {
             await platform.setWebSinkId(SetWebSinkIdRequest(
               sinkId: _webSinkId,
             ));
+            if (checkInterruption()) return platform;
           }
         }
         for (var audioEffect in _audioPipeline._audioEffects) {
@@ -1524,18 +1678,16 @@ class AudioPlayer {
 
       subscribeToEvents(platform);
 
-      if (audioSource != null) {
+      if (playlist.children.isNotEmpty) {
         try {
           final initialSeekValues = _initialSeekValues ??
               _InitialSeekValues(position: position, index: currentIndex);
           _initialSeekValues = null;
-          final duration = await _load(platform, _audioSource!,
+          final duration = await _load(platform, _playlist,
               initialSeekValues: initialSeekValues);
           if (checkInterruption()) return platform;
           durationCompleter.complete(duration);
         } catch (e, stackTrace) {
-          await _setPlatformActive(false)
-              ?.catchError((dynamic e) async => null);
           durationCompleter.completeError(e, stackTrace);
         }
       } else {
@@ -1549,17 +1701,19 @@ class AudioPlayer {
     return durationCompleter.future;
   }
 
-  /// Dispose of the given platform.
+  /// Disposes of the given platform.
   Future<void> _disposePlatform(AudioPlayerPlatform platform) async {
     if (platform is _IdleAudioPlayer) {
       await platform.dispose(DisposeRequest());
     } else {
       _nativePlatform = null;
       try {
-        await _pluginPlatform.disposePlayer(DisposePlayerRequest(id: _id));
+        await _pluginPlatform.disposePlayer(DisposePlayerRequest(id: _id!));
       } catch (e) {
         // Fallback if disposePlayer hasn't been implemented.
         await platform.dispose(DisposeRequest());
+      } finally {
+        _id = null;
       }
     }
   }
@@ -1573,6 +1727,26 @@ class AudioPlayer {
       await file.delete(recursive: true);
     }
   }
+
+  Exception _convertException(PlatformException e) {
+    const kUnknownErrorCode = 9999999;
+    const kInterruptedErrorCode = 10000000;
+    final details =
+        (e.details as Map<dynamic, dynamic>?)?.cast<String, dynamic>();
+    final index = details?['index'] as int? ?? currentIndex;
+    final code = int.tryParse(e.code);
+    if (code == null) {
+      if (e.code == 'abort') {
+        return PlayerInterruptedException(e.message);
+      } else {
+        return PlayerException(kUnknownErrorCode, e.message, index);
+      }
+    } else if (code == kInterruptedErrorCode) {
+      return PlayerInterruptedException(e.message);
+    } else {
+      return PlayerException(code, e.message, index);
+    }
+  }
 }
 
 /// Captures the details of any error accessing, loading or playing an audio
@@ -1588,13 +1762,10 @@ class PlayerException implements Exception {
   /// is provided.
   final String? message;
 
-  /// On Android/iOS/macOS, contains details of the error. For errors associated
-  /// with a particular audio source, the `"index"` key maps to the index of the
-  /// audio source in the sequence.
-  final Map<String, dynamic> details;
+  /// The index of the audio source associated with this error.
+  final int? index;
 
-  PlayerException(this.code, this.message, [Map<String, dynamic>? details])
-      : details = details ?? <String, dynamic>{};
+  PlayerException(this.code, this.message, this.index);
 
   @override
   String toString() => "($code) $message";
@@ -1633,11 +1804,24 @@ class PlaybackEvent {
   final IcyMetadata? icyMetadata;
 
   /// The index of the currently playing item, or `null` if no item is selected.
+  // TODO: Consider introducing currentAudioSourceId
   final int? currentIndex;
 
   /// The current Android AudioSession ID if set.
   final int? androidAudioSessionId;
 
+  /// The error code when [processingState] is [ProcessingState.error].
+  ///
+  /// Supported on Android, iOS and web. For other platforms, check the
+  /// documentation of the respective platform implementation.
+  final int? errorCode;
+
+  /// The error message when [processingState] is [ProcessingState.error].
+  ///
+  /// Supported on Android, iOS and web. For other platforms, check the
+  /// documentation of the respective platform implementation.
+  final String? errorMessage;
+
   PlaybackEvent({
     this.processingState = ProcessingState.idle,
     DateTime? updateTime,
@@ -1647,6 +1831,8 @@ class PlaybackEvent {
     this.icyMetadata,
     this.currentIndex,
     this.androidAudioSessionId,
+    this.errorCode,
+    this.errorMessage,
   }) : updateTime = updateTime ?? DateTime.now();
 
   /// Returns a copy of this event with given properties replaced.
@@ -1659,6 +1845,8 @@ class PlaybackEvent {
     IcyMetadata? icyMetadata,
     int? currentIndex,
     int? androidAudioSessionId,
+    int? errorCode,
+    String? errorMessage,
   }) =>
       PlaybackEvent(
         processingState: processingState ?? this.processingState,
@@ -1670,6 +1858,8 @@ class PlaybackEvent {
         currentIndex: currentIndex ?? this.currentIndex,
         androidAudioSessionId:
             androidAudioSessionId ?? this.androidAudioSessionId,
+        errorCode: errorCode ?? this.errorCode,
+        errorMessage: errorMessage ?? this.errorMessage,
       );
 
   @override
@@ -1682,6 +1872,8 @@ class PlaybackEvent {
         icyMetadata,
         currentIndex,
         androidAudioSessionId,
+        errorCode,
+        errorMessage,
       );
 
   @override
@@ -1695,7 +1887,9 @@ class PlaybackEvent {
       duration == other.duration &&
       icyMetadata == other.icyMetadata &&
       currentIndex == other.currentIndex &&
-      androidAudioSessionId == other.androidAudioSessionId;
+      androidAudioSessionId == other.androidAudioSessionId &&
+      errorCode == other.errorCode &&
+      errorMessage == other.errorMessage;
 
   @override
   String toString() =>
@@ -1847,11 +2041,17 @@ class IcyMetadata {
 /// `sequence.length`. If `sequence.length` is 0, then [currentIndex] is also
 /// 0.
 class SequenceState {
-  /// The sequence of the current [AudioSource].
+  static const _defaultInt = -9999999;
+
+  /// The current sequence of [IndexedAudioSource]s.
   final List<IndexedAudioSource> sequence;
 
   /// The index of the current source in the sequence.
-  final int currentIndex;
+  int? get currentIndex =>
+      sequence.isNotEmpty ? min(_currentIndex ?? 0, sequence.length - 1) : null;
+
+  // The index of the current source in the sequence.
+  final int? _currentIndex;
 
   /// The current shuffle order
   final List<int> shuffleIndices;
@@ -1862,18 +2062,40 @@ class SequenceState {
   /// The current loop mode.
   final LoopMode loopMode;
 
-  SequenceState(this.sequence, this.currentIndex, this.shuffleIndices,
-      this.shuffleModeEnabled, this.loopMode);
+  SequenceState({
+    required this.sequence,
+    required int? currentIndex,
+    required this.shuffleIndices,
+    required this.shuffleModeEnabled,
+    required this.loopMode,
+  }) : _currentIndex = currentIndex;
 
   /// The current source in the sequence.
   IndexedAudioSource? get currentSource =>
-      sequence.isEmpty ? null : sequence[currentIndex];
+      sequence.isEmpty || currentIndex == null ? null : sequence[currentIndex!];
 
   /// The effective sequence. This is equivalent to [sequence]. If
   /// [shuffleModeEnabled] is true, this is modulated by [shuffleIndices].
   List<IndexedAudioSource> get effectiveSequence => shuffleModeEnabled
       ? shuffleIndices.map((i) => sequence[i]).toList()
       : sequence;
+
+  /// Returns a copy of this [SequenceState] with the given properties replaced.
+  SequenceState copyWith({
+    List<IndexedAudioSource>? sequence,
+    int? currentIndex = _defaultInt,
+    List<int>? shuffleIndices,
+    bool? shuffleModeEnabled,
+    LoopMode? loopMode,
+  }) =>
+      SequenceState(
+        sequence: sequence ?? this.sequence,
+        currentIndex:
+            currentIndex != _defaultInt ? currentIndex : this.currentIndex,
+        shuffleIndices: shuffleIndices ?? this.shuffleIndices,
+        shuffleModeEnabled: shuffleModeEnabled ?? this.shuffleModeEnabled,
+        loopMode: loopMode ?? this.loopMode,
+      );
 }
 
 /// Configuration options to use when loading audio from a source.
@@ -2103,7 +2325,7 @@ class _ProxyHttpServer {
   /// [start] has completed.
   int get port => _server.port;
 
-  /// Register a [UriAudioSource] to be served through this proxy. This may be
+  /// Registers a [UriAudioSource] to be served through this proxy. This may be
   /// called only after [start] has completed.
   Uri addUriAudioSource(UriAudioSource source) {
     final uri = source.uri;
@@ -2124,7 +2346,7 @@ class _ProxyHttpServer {
     );
   }
 
-  /// Register a [StreamAudioSource] to be served through this proxy. This may
+  /// Registers a [StreamAudioSource] to be served through this proxy. This may
   /// be called only after [start] has completed.
   Uri addStreamAudioSource(StreamAudioSource source) {
     final uri = _sourceUri(source);
@@ -2142,7 +2364,7 @@ class _ProxyHttpServer {
   /// but differ in other respects such as the port or headers.
   String _requestKey(Uri uri) => '${uri.path}?${uri.query}';
 
-  /// Start the server if it is not already running.
+  /// Starts the server if it is not already running.
   Future<dynamic> ensureRunning() async {
     if (_running) return;
     return await start();
@@ -2295,14 +2517,17 @@ abstract class AudioSource {
     return AudioSource.uri(Uri.parse('asset:///$keyName'), tag: tag);
   }
 
-  AudioSource() : _id = _uuid.v4();
+  AudioSource({String? id}) : _id = id ?? _uuid.v4();
 
   @mustCallSuper
-  Future<void> _setup(AudioPlayer player) async {
+  void _onAttach(AudioPlayer player) {
     _player = player;
     player._registerAudioSource(this);
   }
 
+  @mustCallSuper
+  Future<void> _onLoad() async {}
+
   String? get _userAgent => _player?._userAgent;
 
   void _shuffle({int? initialIndex});
@@ -2370,16 +2595,16 @@ abstract class UriAudioSource extends IndexedAudioSource {
             };
 
   @override
-  Future<void> _setup(AudioPlayer player) async {
-    await super._setup(player);
+  Future<void> _onLoad() async {
+    await super._onLoad();
     if (uri.scheme == 'asset') {
       _overrideUri = await _loadAsset(uri.pathSegments.join('/'));
     } else if (uri.scheme != 'file' &&
         !kIsWeb &&
-        player._useProxyForRequestHeaders &&
-        (headers != null || player._userAgent != null)) {
-      await player._proxy.ensureRunning();
-      _overrideUri = player._proxy.addUriAudioSource(this);
+        _player!._useProxyForRequestHeaders &&
+        (headers != null || _player!._userAgent != null)) {
+      await _player!._proxy.ensureRunning();
+      _overrideUri = _player!._proxy.addUriAudioSource(this);
     }
   }
 
@@ -2423,7 +2648,7 @@ abstract class UriAudioSource extends IndexedAudioSource {
     }
   }
 
-  /// Get file for caching asset media with proper extension
+  /// Gets the cache file for an asset with the proper extension
   Future<File> _getCacheFile(final String assetPath) async => File(p.joinAll([
         (await _getCacheDir()).path,
         'assets',
@@ -2545,12 +2770,13 @@ class SilenceAudioSource extends IndexedAudioSource {
 /// between items will be gapless on Android, iOS and macOS, while there will
 /// be a slight gap on Web.
 ///
-/// (Untested) Audio sources can be dynamically added, removed and reordered
-/// while the audio is playing.
+/// Audio sources can be dynamically added, removed and reordered while the
+/// audio is playing.
+@Deprecated('Use AudioPlayer.setAudioSources instead')
 class ConcatenatingAudioSource extends AudioSource {
   final List<AudioSource> children;
   final bool useLazyPreparation;
-  final ShuffleOrder _shuffleOrder;
+  ShuffleOrder _shuffleOrder;
 
   /// Creates a [ConcatenatingAudioSorce] with the specified [children]. If
   /// [useLazyPreparation] is `true`, children will be loaded/buffered as late
@@ -2565,11 +2791,28 @@ class ConcatenatingAudioSource extends AudioSource {
   }) : _shuffleOrder = shuffleOrder ?? DefaultShuffleOrder()
           ..insert(0, children.length);
 
+  /// Creates the root playlist of a player.
+  ConcatenatingAudioSource._playlist({
+    required this.children,
+    this.useLazyPreparation = true,
+    ShuffleOrder? shuffleOrder,
+  })  : _shuffleOrder = shuffleOrder ?? DefaultShuffleOrder()
+          ..insert(0, children.length),
+        super(id: '');
+
+  @override
+  void _onAttach(AudioPlayer player) {
+    super._onAttach(player);
+    for (var source in children) {
+      source._onAttach(player);
+    }
+  }
+
   @override
-  Future<void> _setup(AudioPlayer player) async {
-    await super._setup(player);
+  Future<void> _onLoad() async {
+    await super._onLoad();
     for (var source in children) {
-      await source._setup(player);
+      await source._onLoad();
     }
   }
 
@@ -2595,14 +2838,18 @@ class ConcatenatingAudioSource extends AudioSource {
     _shuffleOrder.shuffle(initialIndex: localInitialIndex);
   }
 
-  /// (Untested) Appends an [AudioSource].
+  /// Appends an [AudioSource].
   Future<void> add(AudioSource audioSource) async {
+    if (_player != null) audioSource._onAttach(_player!);
     final index = children.length;
     children.add(audioSource);
     _shuffleOrder.insert(index, 1);
     if (_player != null) {
-      _player!._broadcastSequence();
-      await audioSource._setup(_player!);
+      audioSource._onAttach(_player!);
+      await _player!._broadcastSequence();
+      if (_player!._active) {
+        await audioSource._onLoad();
+      }
       await (await _player!._platform).concatenatingInsertAll(
           ConcatenatingInsertAllRequest(
               id: _id,
@@ -2612,13 +2859,17 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Inserts an [AudioSource] at [index].
+  /// Inserts an [AudioSource] at [index].
   Future<void> insert(int index, AudioSource audioSource) async {
+    if (_player != null) audioSource._onAttach(_player!);
     children.insert(index, audioSource);
     _shuffleOrder.insert(index, 1);
     if (_player != null) {
-      _player!._broadcastSequence();
-      await audioSource._setup(_player!);
+      audioSource._onAttach(_player!);
+      await _player!._broadcastSequence();
+      if (_player!._active) {
+        await audioSource._onLoad();
+      }
       await (await _player!._platform).concatenatingInsertAll(
           ConcatenatingInsertAllRequest(
               id: _id,
@@ -2628,15 +2879,20 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Appends multiple [AudioSource]s.
+  /// Appends multiple [AudioSource]s.
   Future<void> addAll(List<AudioSource> children) async {
     final index = this.children.length;
     this.children.addAll(children);
     _shuffleOrder.insert(index, children.length);
     if (_player != null) {
-      _player!._broadcastSequence();
       for (var child in children) {
-        await child._setup(_player!);
+        child._onAttach(_player!);
+      }
+      await _player!._broadcastSequence();
+      if (_player!._active) {
+        for (var child in children) {
+          await child._onLoad();
+        }
       }
       await (await _player!._platform).concatenatingInsertAll(
           ConcatenatingInsertAllRequest(
@@ -2647,14 +2903,19 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Insert multiple [AudioSource]s at [index].
+  /// Inserts multiple [AudioSource]s at [index].
   Future<void> insertAll(int index, List<AudioSource> children) async {
     this.children.insertAll(index, children);
     _shuffleOrder.insert(index, children.length);
     if (_player != null) {
-      _player!._broadcastSequence();
       for (var child in children) {
-        await child._setup(_player!);
+        child._onAttach(_player!);
+      }
+      await _player!._broadcastSequence();
+      if (_player!._active) {
+        for (var child in children) {
+          await child._onLoad();
+        }
       }
       await (await _player!._platform).concatenatingInsertAll(
           ConcatenatingInsertAllRequest(
@@ -2665,13 +2926,13 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Dynamically remove an [AudioSource] at [index] after this
+  /// Dynamically removes an [AudioSource] at [index] after this
   /// [ConcatenatingAudioSource] has already been loaded.
   Future<void> removeAt(int index) async {
     children.removeAt(index);
     _shuffleOrder.removeRange(index, index + 1);
     if (_player != null) {
-      _player!._broadcastSequence();
+      await _player!._broadcastSequence();
       await (await _player!._platform).concatenatingRemoveRange(
           ConcatenatingRemoveRangeRequest(
               id: _id,
@@ -2681,13 +2942,13 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Removes a range of [AudioSource]s from index [start] inclusive
-  /// to [end] exclusive.
+  /// Removes a range of [AudioSource]s from index [start] inclusive to [end]
+  /// exclusive.
   Future<void> removeRange(int start, int end) async {
     children.removeRange(start, end);
     _shuffleOrder.removeRange(start, end);
     if (_player != null) {
-      _player!._broadcastSequence();
+      await _player!._broadcastSequence();
       await (await _player!._platform).concatenatingRemoveRange(
           ConcatenatingRemoveRangeRequest(
               id: _id,
@@ -2697,13 +2958,13 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Moves an [AudioSource] from [currentIndex] to [newIndex].
+  /// Moves an [AudioSource] from [currentIndex] to [newIndex].
   Future<void> move(int currentIndex, int newIndex) async {
     children.insert(newIndex, children.removeAt(currentIndex));
     _shuffleOrder.removeRange(currentIndex, currentIndex + 1);
     _shuffleOrder.insert(newIndex, 1);
     if (_player != null) {
-      _player!._broadcastSequence();
+      await _player!._broadcastSequence();
       await (await _player!._platform).concatenatingMove(
           ConcatenatingMoveRequest(
               id: _id,
@@ -2713,13 +2974,13 @@ class ConcatenatingAudioSource extends AudioSource {
     }
   }
 
-  /// (Untested) Removes all [AudioSource]s.
+  /// Removes all [AudioSource]s.
   Future<void> clear() async {
     final end = children.length;
     children.clear();
     _shuffleOrder.clear();
     if (_player != null) {
-      _player!._broadcastSequence();
+      await _player!._broadcastSequence();
       await (await _player!._platform).concatenatingRemoveRange(
           ConcatenatingRemoveRangeRequest(
               id: _id,
@@ -2781,9 +3042,15 @@ class ClippingAudioSource extends IndexedAudioSource {
   }) : super(tag: tag, duration: duration);
 
   @override
-  Future<void> _setup(AudioPlayer player) async {
-    await super._setup(player);
-    await child._setup(player);
+  void _onAttach(AudioPlayer player) {
+    super._onAttach(player);
+    child._onAttach(player);
+  }
+
+  @override
+  Future<void> _onLoad() async {
+    await super._onLoad();
+    await child._onLoad();
   }
 
   @override
@@ -2798,6 +3065,7 @@ class ClippingAudioSource extends IndexedAudioSource {
 // An [AudioSource] that loops a nested [AudioSource] a finite number of times.
 // NOTE: this can be inefficient when using a large loop count. If you wish to
 // loop an infinite number of times, use [AudioPlayer.setLoopMode].
+@Deprecated('Use List.filled(N, audioSource) instead')
 class LoopingAudioSource extends AudioSource {
   AudioSource child;
   final int count;
@@ -2808,9 +3076,15 @@ class LoopingAudioSource extends AudioSource {
   }) : super();
 
   @override
-  Future<void> _setup(AudioPlayer player) async {
-    await super._setup(player);
-    await child._setup(player);
+  void _onAttach(AudioPlayer player) {
+    super._onAttach(player);
+    child._onAttach(player);
+  }
+
+  @override
+  Future<void> _onLoad() async {
+    await super._onLoad();
+    await child._onLoad();
   }
 
   @override
@@ -2839,15 +3113,15 @@ abstract class StreamAudioSource extends IndexedAudioSource {
   StreamAudioSource({dynamic tag}) : super(tag: tag);
 
   @override
-  Future<void> _setup(AudioPlayer player) async {
-    await super._setup(player);
+  Future<void> _onLoad() async {
+    await super._onLoad();
     if (kIsWeb) {
       final response = await request();
-      _uri = _encodeDataUrl(await base64.encoder.bind(response.stream).join(),
+      _uri ??= _encodeDataUrl(await base64.encoder.bind(response.stream).join(),
           response.contentType);
     } else {
-      await player._proxy.ensureRunning();
-      _uri = player._proxy.addStreamAudioSource(this);
+      await _player!._proxy.ensureRunning();
+      _uri = _player!._proxy.addStreamAudioSource(this);
     }
   }
 
@@ -2967,7 +3241,7 @@ class LockCachingAudioSource extends StreamAudioSource {
     _downloadProgressSubject.add(0.0);
   }
 
-  /// Get file for caching [uri] with proper extension
+  /// Gets the cache file for [uri] with the proper extension.
   static Future<File> _getCacheFile(final Uri uri) async => File(p.joinAll([
         (await _getCacheDir()).path,
         'remote',
@@ -2993,7 +3267,7 @@ class LockCachingAudioSource extends StreamAudioSource {
     }
   }
 
-  /// Start downloading the whole audio file to the cache and fulfill byte-range
+  /// Starts downloading the whole audio file to the cache and fulfill byte-range
   /// requests during the download. There are 3 scenarios:
   ///
   /// 1. If the byte range request falls entirely within the cache region, it is
@@ -3327,13 +3601,8 @@ _ProxyHandler _proxyHandlerForSource(StreamAudioSource source) {
     }
 
     final completer = Completer<void>();
-    final subscription = stream.listen((event) {
-      request.response.add(event);
-    }, onError: (Object e, StackTrace st) {
-      source._player?._playbackEventSubject.addError(e, st);
-    }, onDone: () {
-      completer.complete();
-    });
+    final subscription = stream.listen(request.response.add,
+        onError: (e, st) {}, onDone: completer.complete);
 
     request.response.done.then((dynamic value) {
       subscription.cancel();
@@ -3474,18 +3743,15 @@ _ProxyHandler _proxyHandlerForUri(
 Future<Directory> _getCacheDir() async =>
     Directory(p.join((await getTemporaryDirectory()).path, 'just_audio_cache'));
 
-/// Defines the algorithm for shuffling the order of a
-/// [ConcatenatingAudioSource]. See [DefaultShuffleOrder] for a default
-/// implementation.
+/// Defines the algorithm for shuffling the order of a playlist. See
+/// [DefaultShuffleOrder] for a default implementation.
 abstract class ShuffleOrder {
   /// The shuffled list of indices of [AudioSource]s to play. For example,
   /// [2,0,1] specifies to play the 3rd, then the 1st, then the 2nd item.
   List<int> get indices;
 
-  /// Shuffles the [indices]. If the current item in the player falls within the
-  /// [ConcatenatingAudioSource] being shuffled, [initialIndex] will point to
-  /// that item. Subclasses may use this information as a hint, for example, to
-  /// make [initialIndex] the first item in the shuffle order.
+  /// Shuffles the [indices]. If specified, [initialIndex] will be the first
+  /// item in the shuffle order.
   void shuffle({int? initialIndex});
 
   /// Inserts [count] new consecutive indices starting from [index] into
@@ -3569,18 +3835,24 @@ enum WebCrossOrigin { anonymous, useCredentials }
 /// state and the native platform is deallocated.
 class _IdleAudioPlayer extends AudioPlayerPlatform {
   final _eventSubject = BehaviorSubject<PlaybackEventMessage>();
-  late Duration _position;
+  Duration _position = Duration.zero;
   int? _index;
-  List<IndexedAudioSource>? _sequence;
+  List<IndexedAudioSource> _sequence = [];
+  int? errorCode;
+  String? errorMessage;
+  StreamSubscription<List<IndexedAudioSource>>? _sequenceSubscription;
 
   /// Holds a pending request.
   SetAndroidAudioAttributesRequest? setAndroidAudioAttributesRequest;
 
   _IdleAudioPlayer({
     required String id,
-    required Stream<List<IndexedAudioSource>?> sequenceStream,
+    required Stream<List<IndexedAudioSource>> sequenceStream,
+    required this.errorCode,
+    required this.errorMessage,
   }) : super(id) {
-    sequenceStream.listen((sequence) => _sequence = sequence);
+    _sequenceSubscription =
+        sequenceStream.listen((sequence) => _sequence = sequence);
   }
 
   void _broadcastPlaybackEvent() {
@@ -3594,12 +3866,14 @@ class _IdleAudioPlayer extends AudioPlayerPlatform {
       duration: _getDurationAtIndex(_index),
       currentIndex: _index,
       androidAudioSessionId: null,
+      errorCode: errorCode,
+      errorMessage: errorMessage,
     ));
   }
 
   Duration? _getDurationAtIndex(int? index) =>
-      index != null && _sequence != null && index < _sequence!.length
-          ? _sequence![index].duration
+      index != null && index < _sequence.length
+          ? _sequence[index].duration
           : null;
 
   @override
@@ -3610,6 +3884,8 @@ class _IdleAudioPlayer extends AudioPlayerPlatform {
   Future<LoadResponse> load(LoadRequest request) async {
     _index = request.initialIndex ?? 0;
     _position = request.initialPosition ?? Duration.zero;
+    errorCode = null;
+    errorMessage = null;
     _broadcastPlaybackEvent();
     return LoadResponse(duration: _getDurationAtIndex(_index));
   }
@@ -3698,6 +3974,8 @@ class _IdleAudioPlayer extends AudioPlayerPlatform {
   Future<SeekResponse> seek(SeekRequest request) async {
     _position = request.position ?? Duration.zero;
     _index = request.index ?? _index;
+    errorCode = null;
+    errorMessage = null;
     _broadcastPlaybackEvent();
     return SeekResponse();
   }
@@ -3711,24 +3989,62 @@ class _IdleAudioPlayer extends AudioPlayerPlatform {
 
   @override
   Future<DisposeResponse> dispose(DisposeRequest request) async {
+    await _sequenceSubscription?.cancel();
     return DisposeResponse();
   }
 
   @override
   Future<ConcatenatingInsertAllResponse> concatenatingInsertAll(
       ConcatenatingInsertAllRequest request) async {
+    if (request.id == '') {
+      if (_index == null) {
+        if (request.children.isNotEmpty) {
+          _index = 0;
+          _broadcastPlaybackEvent();
+        }
+      } else {
+        if (request.index <= _index!) {
+          _index = _index! + request.children.length;
+          _broadcastPlaybackEvent();
+        }
+      }
+    }
     return ConcatenatingInsertAllResponse();
   }
 
   @override
   Future<ConcatenatingRemoveRangeResponse> concatenatingRemoveRange(
       ConcatenatingRemoveRangeRequest request) async {
+    if (request.id == '' && _index != null) {
+      if (request.startIndex <= _index!) {
+        _index = min(request.shuffleOrder.length - 1,
+            _index! - (min(_index!, request.endIndex) - request.startIndex));
+        if (_index! < 0) _index = null;
+        _broadcastPlaybackEvent();
+      }
+    }
     return ConcatenatingRemoveRangeResponse();
   }
 
   @override
   Future<ConcatenatingMoveResponse> concatenatingMove(
       ConcatenatingMoveRequest request) async {
+    if (request.id == '' &&
+        _index != null &&
+        request.currentIndex != request.newIndex) {
+      if (request.currentIndex == _index!) {
+        _index = request.newIndex;
+        _broadcastPlaybackEvent();
+      } else if (request.currentIndex < _index! &&
+          request.newIndex >= _index!) {
+        _index = _index! - 1;
+        _broadcastPlaybackEvent();
+      } else if (request.currentIndex > _index! &&
+          request.newIndex <= _index!) {
+        _index = _index! + 1;
+        _broadcastPlaybackEvent();
+      }
+    }
     return ConcatenatingMoveResponse();
   }
 
@@ -3838,7 +4154,7 @@ abstract class AudioEffect {
 
   String get _type;
 
-  /// Set the [enabled] status of this audio effect.
+  /// Sets the [enabled] status of this audio effect.
   Future<void> setEnabled(bool enabled) async {
     _enabledSubject.add(enabled);
     if (_active) {
@@ -3964,7 +4280,7 @@ class AndroidEqualizerParameters {
     required this.bands,
   });
 
-  /// Restore platform state after reactivating.
+  /// Restores platform state after reactivating.
   Future<void> _restore(AudioPlayerPlatform platform) async {
     for (var band in bands) {
       await band._restore(platform);
diff --git a/just_audio/pubspec.yaml b/just_audio/pubspec.yaml
index 49c5d40b5..79e6f2855 100644
--- a/just_audio/pubspec.yaml
+++ b/just_audio/pubspec.yaml
@@ -1,6 +1,6 @@
 name: just_audio
-description: A feature-rich audio player for Flutter. Loop, clip and concatenate any sound from any source (asset/file/URL/stream) in a variety of audio formats with gapless playback.
-version: 0.10.1
+description: A feature-rich audio player for Flutter. Loop, clip and sequence any sound from any source (asset/file/URL/stream) in gapless playlists.
+version: 0.10.0
 repository: https://github.com/ryanheise/just_audio/tree/minor/just_audio
 issue_tracker: https://github.com/ryanheise/just_audio/issues
 topics:
@@ -10,17 +10,17 @@ topics:
   - background
 
 environment:
-  sdk: ">=3.0.0 <4.0.0"
-  flutter: ">=3.10.0"
+  sdk: ^3.6.0
+  flutter: ">=3.27.0"
 
 dependencies:
-  just_audio_platform_interface: ^4.4.0
+  just_audio_platform_interface: ^4.5.0
   # just_audio_platform_interface:
   #   path: ../just_audio_platform_interface
-  just_audio_web: ^0.4.14
+  just_audio_web: ^0.4.15
   # just_audio_web:
   #   path: ../just_audio_web
-  audio_session: ^0.1.24
+  audio_session: ">=0.1.24 <0.3.0"
   rxdart: '>=0.26.0 <0.29.0'
   path: ^1.8.0
   path_provider: ^2.0.0
diff --git a/just_audio_background/CHANGELOG.md b/just_audio_background/CHANGELOG.md
index ffa9da6fc..2fb423cd5 100644
--- a/just_audio_background/CHANGELOG.md
+++ b/just_audio_background/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.0.1-beta.16
+
+* Support errorCode, errorMessage.
+* Bump min flutter version to 3.27.0, AGP to 8.5.2.
+
 ## 0.0.1-beta.15
 
 * Add setWebSinkId for web.
diff --git a/just_audio_background/example/android/app/build.gradle b/just_audio_background/example/android/app/build.gradle
index a1eb28b14..cdb3d627c 100644
--- a/just_audio_background/example/android/app/build.gradle
+++ b/just_audio_background/example/android/app/build.gradle
@@ -1,44 +1,25 @@
-def localProperties = new Properties()
-def localPropertiesFile = rootProject.file('local.properties')
-if (localPropertiesFile.exists()) {
-    localPropertiesFile.withReader('UTF-8') { reader ->
-        localProperties.load(reader)
-    }
-}
-
-def flutterRoot = localProperties.getProperty('flutter.sdk')
-if (flutterRoot == null) {
-    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
-}
-
-def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
-if (flutterVersionCode == null) {
-    flutterVersionCode = '1'
-}
-
-def flutterVersionName = localProperties.getProperty('flutter.versionName')
-if (flutterVersionName == null) {
-    flutterVersionName = '1.0'
+plugins {
+    id "com.android.application"
+    id "kotlin-android"
+    id "dev.flutter.flutter-gradle-plugin"
 }
 
-apply plugin: 'com.android.application'
-apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
-
 android {
-    compileSdk 34
+    namespace 'com.ryanheise.just_audio_example'
+    compileSdk flutter.compileSdkVersion
 
-    lintOptions {
-        disable 'InvalidPackage'
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
     }
 
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "com.ryanheise.just_audio_example"
         minSdk flutter.minSdkVersion
-        targetSdk 34
-        versionCode flutterVersionCode.toInteger()
-        versionName flutterVersionName
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        targetSdk flutter.targetSdkVersion
+        versionCode flutter.versionCode
+        versionName flutter.versionName
     }
 
     buildTypes {
@@ -48,6 +29,10 @@ android {
             signingConfig signingConfigs.debug
         }
     }
+
+    lint {
+        disable 'InvalidPackage'
+    }
 }
 
 flutter {
diff --git a/just_audio_background/example/android/build.gradle b/just_audio_background/example/android/build.gradle
index 82a639f6c..bc157bd1a 100644
--- a/just_audio_background/example/android/build.gradle
+++ b/just_audio_background/example/android/build.gradle
@@ -1,14 +1,3 @@
-buildscript {
-    repositories {
-        google()
-        mavenCentral()
-    }
-
-    dependencies {
-        classpath 'com.android.tools.build:gradle:7.3.0'
-    }
-}
-
 allprojects {
     repositories {
         google()
diff --git a/just_audio_background/example/android/gradle/wrapper/gradle-wrapper.properties b/just_audio_background/example/android/gradle/wrapper/gradle-wrapper.properties
index 2ec77e51a..ffb9a40b0 100644
--- a/just_audio_background/example/android/gradle/wrapper/gradle-wrapper.properties
+++ b/just_audio_background/example/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,6 @@
+#Thu Mar 27 14:19:19 AEDT 2025
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/just_audio_background/example/android/settings.gradle b/just_audio_background/example/android/settings.gradle
index 5a2f14fb1..c5ab4b5c0 100644
--- a/just_audio_background/example/android/settings.gradle
+++ b/just_audio_background/example/android/settings.gradle
@@ -1,15 +1,25 @@
-include ':app'
+pluginManagement {
+    def flutterSdkPath = {
+        def properties = new Properties()
+        file("local.properties").withInputStream { properties.load(it) }
+        def flutterSdkPath = properties.getProperty("flutter.sdk")
+        assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+        return flutterSdkPath
+    }()
 
-def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
+    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
 
-def plugins = new Properties()
-def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
-if (pluginsFile.exists()) {
-    pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
 }
 
-plugins.each { name, path ->
-    def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
-    include ":$name"
-    project(":$name").projectDir = pluginDirectory
+plugins {
+    id "dev.flutter.flutter-plugin-loader" version "1.0.0"
+    id "com.android.application" version "8.5.2" apply false
+    id "org.jetbrains.kotlin.android" version "1.8.22" apply false
 }
+
+include ":app"
diff --git a/just_audio_background/example/ios/Flutter/AppFrameworkInfo.plist b/just_audio_background/example/ios/Flutter/AppFrameworkInfo.plist
index f2872cf47..8c6e56146 100644
--- a/just_audio_background/example/ios/Flutter/AppFrameworkInfo.plist
+++ b/just_audio_background/example/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
   <key>CFBundleVersion</key>
   <string>1.0</string>
   <key>MinimumOSVersion</key>
-  <string>9.0</string>
+  <string>12.0</string>
 </dict>
 </plist>
diff --git a/just_audio_background/example/ios/Podfile b/just_audio_background/example/ios/Podfile
index 1e8c3c90a..279576f38 100644
--- a/just_audio_background/example/ios/Podfile
+++ b/just_audio_background/example/ios/Podfile
@@ -1,5 +1,5 @@
 # Uncomment this line to define a global platform for your project
-# platform :ios, '9.0'
+# platform :ios, '12.0'
 
 # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
 ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/just_audio_background/example/ios/Runner.xcodeproj/project.pbxproj b/just_audio_background/example/ios/Runner.xcodeproj/project.pbxproj
index c2a7a9046..6c975760c 100644
--- a/just_audio_background/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/just_audio_background/example/ios/Runner.xcodeproj/project.pbxproj
@@ -3,13 +3,14 @@
 	archiveVersion = 1;
 	classes = {
 	};
-	objectVersion = 50;
+	objectVersion = 54;
 	objects = {
 
 /* Begin PBXBuildFile section */
 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
 		41D58EBBC9F1CC56AF29BD71 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E003A1DB8FD9E4937C757BBD /* Pods_Runner.framework */; };
+		78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
 		9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; };
 		978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; };
 		97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; };
@@ -57,6 +58,7 @@
 			isa = PBXFrameworksBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
 				41D58EBBC9F1CC56AF29BD71 /* Pods_Runner.framework in Frameworks */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -157,6 +159,9 @@
 			dependencies = (
 			);
 			name = Runner;
+			packageProductDependencies = (
+				78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+			);
 			productName = Runner;
 			productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
 			productType = "com.apple.product-type.application";
@@ -167,7 +172,7 @@
 		97C146E61CF9000F007C117D /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastUpgradeCheck = 1300;
+				LastUpgradeCheck = 1510;
 				ORGANIZATIONNAME = "The Chromium Authors";
 				TargetAttributes = {
 					97C146ED1CF9000F007C117D = {
@@ -184,6 +189,9 @@
 				Base,
 			);
 			mainGroup = 97C146E51CF9000F007C117D;
+			packageReferences = (
+				781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+			);
 			productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
 			projectDirPath = "";
 			projectRoot = "";
@@ -216,20 +224,10 @@
 			);
 			inputPaths = (
 				"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh",
-				"${BUILT_PRODUCTS_DIR}/FMDB/FMDB.framework",
-				"${BUILT_PRODUCTS_DIR}/audio_service/audio_service.framework",
-				"${BUILT_PRODUCTS_DIR}/audio_session/audio_session.framework",
-				"${BUILT_PRODUCTS_DIR}/just_audio/just_audio.framework",
-				"${BUILT_PRODUCTS_DIR}/path_provider_ios/path_provider_ios.framework",
 				"${BUILT_PRODUCTS_DIR}/sqflite/sqflite.framework",
 			);
 			name = "[CP] Embed Pods Frameworks";
 			outputPaths = (
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FMDB.framework",
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audio_service.framework",
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audio_session.framework",
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/just_audio.framework",
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_ios.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite.framework",
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -239,10 +237,12 @@
 		};
 		3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
 			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
 			buildActionMask = 2147483647;
 			files = (
 			);
 			inputPaths = (
+				"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
 			);
 			name = "Thin Binary";
 			outputPaths = (
@@ -275,6 +275,7 @@
 		};
 		9740EEB61CF901F6004384FC /* Run Script */ = {
 			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
 			buildActionMask = 2147483647;
 			files = (
 			);
@@ -363,7 +364,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				SDKROOT = iphoneos;
 				TARGETED_DEVICE_FAMILY = "1,2";
@@ -383,7 +384,7 @@
 					"$(PROJECT_DIR)/Flutter",
 				);
 				INFOPLIST_FILE = Runner/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -445,7 +446,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
 				MTL_ENABLE_DEBUG_INFO = YES;
 				ONLY_ACTIVE_ARCH = YES;
 				SDKROOT = iphoneos;
@@ -494,7 +495,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				SDKROOT = iphoneos;
 				TARGETED_DEVICE_FAMILY = "1,2";
@@ -514,7 +515,7 @@
 					"$(PROJECT_DIR)/Flutter",
 				);
 				INFOPLIST_FILE = Runner/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -541,7 +542,7 @@
 					"$(PROJECT_DIR)/Flutter",
 				);
 				INFOPLIST_FILE = Runner/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -580,6 +581,20 @@
 			defaultConfigurationName = Release;
 		};
 /* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+		781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+			isa = XCLocalSwiftPackageReference;
+			relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+		};
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+		78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = FlutterGeneratedPluginSwiftPackage;
+		};
+/* End XCSwiftPackageProductDependency section */
 	};
 	rootObject = 97C146E61CF9000F007C117D /* Project object */;
 }
diff --git a/just_audio_background/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/just_audio_background/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index 3db53b6e1..7120d2eaf 100644
--- a/just_audio_background/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/just_audio_background/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,10 +1,28 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1300"
+   LastUpgradeVersion = "1510"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"
       buildImplicitDependencies = "YES">
+      <PreActions>
+         <ExecutionAction
+            ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
+            <ActionContent
+               title = "Run Prepare Flutter Framework Script"
+               scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
+               <EnvironmentBuildable>
+                  <BuildableReference
+                     BuildableIdentifier = "primary"
+                     BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+                     BuildableName = "Runner.app"
+                     BlueprintName = "Runner"
+                     ReferencedContainer = "container:Runner.xcodeproj">
+                  </BuildableReference>
+               </EnvironmentBuildable>
+            </ActionContent>
+         </ExecutionAction>
+      </PreActions>
       <BuildActionEntries>
          <BuildActionEntry
             buildForTesting = "YES"
diff --git a/just_audio_background/example/ios/Runner/Info.plist b/just_audio_background/example/ios/Runner/Info.plist
index 593769634..29f51124a 100644
--- a/just_audio_background/example/ios/Runner/Info.plist
+++ b/just_audio_background/example/ios/Runner/Info.plist
@@ -52,5 +52,9 @@
 	</array>
 	<key>UIViewControllerBasedStatusBarAppearance</key>
 	<false/>
+	<key>CADisableMinimumFrameDurationOnPhone</key>
+	<true/>
+	<key>UIApplicationSupportsIndirectInputEvents</key>
+	<true/>
 </dict>
 </plist>
diff --git a/just_audio_background/example/lib/common.dart b/just_audio_background/example/lib/common.dart
index 150694a83..8d2146378 100644
--- a/just_audio_background/example/lib/common.dart
+++ b/just_audio_background/example/lib/common.dart
@@ -146,6 +146,8 @@ void showSliderDialog({
   required double min,
   required double max,
   String valueSuffix = '',
+  // TODO: Replace these two by ValueStream.
+  required double value,
   required Stream<double> stream,
   required ValueChanged<double> onChanged,
 }) {
@@ -168,7 +170,7 @@ void showSliderDialog({
                 divisions: divisions,
                 min: min,
                 max: max,
-                value: snapshot.data ?? 1.0,
+                value: snapshot.data ?? value,
                 onChanged: onChanged,
               ),
             ],
diff --git a/just_audio_background/example/lib/main.dart b/just_audio_background/example/lib/main.dart
index 7286c3bee..24aaaaa28 100644
--- a/just_audio_background/example/lib/main.dart
+++ b/just_audio_background/example/lib/main.dart
@@ -1,4 +1,5 @@
 import 'package:audio_session/audio_session.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:just_audio/just_audio.dart';
@@ -25,20 +26,24 @@ class MyApp extends StatefulWidget {
 class MyAppState extends State<MyApp> {
   static int _nextMediaId = 0;
   late AudioPlayer _player;
-  final _playlist = ConcatenatingAudioSource(children: [
-    ClippingAudioSource(
-      start: const Duration(seconds: 60),
-      end: const Duration(seconds: 90),
-      child: AudioSource.uri(Uri.parse(
-          "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3")),
-      tag: MediaItem(
-        id: '${_nextMediaId++}',
-        album: "Science Friday",
-        title: "A Salute To Head-Scratching Science (30 seconds)",
-        artUri: Uri.parse(
-            "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"),
+  static final _playlist = [
+    // Remove this audio source from the Windows and Linux version because it's not supported yet
+    if (kIsWeb ||
+        ![TargetPlatform.windows, TargetPlatform.linux]
+            .contains(defaultTargetPlatform))
+      ClippingAudioSource(
+        start: const Duration(seconds: 60),
+        end: const Duration(seconds: 90),
+        child: AudioSource.uri(Uri.parse(
+            "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3")),
+        tag: MediaItem(
+          id: '${_nextMediaId++}',
+          album: "Science Friday",
+          title: "A Salute To Head-Scratching Science (30 seconds)",
+          artUri: Uri.parse(
+              "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"),
+        ),
       ),
-    ),
     AudioSource.uri(
       Uri.parse(
           "https://s3.amazonaws.com/scifri-episodes/scifri20181123-episode.mp3"),
@@ -70,13 +75,14 @@ class MyAppState extends State<MyApp> {
             "https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg"),
       ),
     ),
-  ]);
+  ];
   int _addedCount = 0;
+  final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
 
   @override
   void initState() {
     super.initState();
-    _player = AudioPlayer();
+    _player = AudioPlayer(maxSkipsOnError: 3);
     SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
       statusBarColor: Colors.black,
     ));
@@ -87,17 +93,40 @@ class MyAppState extends State<MyApp> {
     final session = await AudioSession.instance;
     await session.configure(const AudioSessionConfiguration.speech());
     // Listen to errors during playback.
-    _player.playbackEventStream.listen((event) {},
-        onError: (Object e, StackTrace stackTrace) {
+    _player.errorStream.listen((e) {
       print('A stream error occurred: $e');
     });
     try {
-      await _player.setAudioSource(_playlist);
-    } catch (e, stackTrace) {
-      // Catch load errors: 404, invalid url ...
+      // Preloading audio is not currently supported on Linux.
+      await _player.setAudioSources(_playlist,
+          preload: kIsWeb || defaultTargetPlatform != TargetPlatform.linux);
+    } on PlayerException catch (e) {
+      // Catch load errors: 404, invalid url...
       print("Error loading playlist: $e");
-      print(stackTrace);
     }
+    // Show a snackbar whenever reaching the end of an item in the playlist.
+    _player.positionDiscontinuityStream.listen((discontinuity) {
+      if (discontinuity.reason == PositionDiscontinuityReason.autoAdvance) {
+        _showItemFinished(discontinuity.previousEvent.currentIndex);
+      }
+    });
+    _player.processingStateStream.listen((state) {
+      if (state == ProcessingState.completed) {
+        _showItemFinished(_player.currentIndex);
+      }
+    });
+  }
+
+  void _showItemFinished(int? index) {
+    if (index == null) return;
+    final sequence = _player.sequence;
+    if (index >= sequence.length) return;
+    final source = sequence[index];
+    final metadata = source.tag as MediaItem;
+    _scaffoldMessengerKey.currentState?.showSnackBar(SnackBar(
+      content: Text('Finished playing ${metadata.title}'),
+      duration: const Duration(seconds: 1),
+    ));
   }
 
   @override
@@ -118,6 +147,7 @@ class MyAppState extends State<MyApp> {
   Widget build(BuildContext context) {
     return MaterialApp(
       debugShowCheckedModeBanner: false,
+      scaffoldMessengerKey: _scaffoldMessengerKey,
       home: Scaffold(
         body: SafeArea(
           child: Column(
@@ -233,7 +263,7 @@ class MyAppState extends State<MyApp> {
                     return ReorderableListView(
                       onReorder: (int oldIndex, int newIndex) {
                         if (oldIndex < newIndex) newIndex--;
-                        _playlist.move(oldIndex, newIndex);
+                        _player.moveAudioSource(oldIndex, newIndex);
                       },
                       children: [
                         for (var i = 0; i < sequence.length; i++)
@@ -247,18 +277,17 @@ class MyAppState extends State<MyApp> {
                                 child: Icon(Icons.delete, color: Colors.white),
                               ),
                             ),
-                            onDismissed: (dismissDirection) {
-                              _playlist.removeAt(i);
-                            },
+                            onDismissed: (dismissDirection) =>
+                                _player.removeAudioSourceAt(i),
                             child: Material(
                               color: i == state!.currentIndex
                                   ? Colors.grey.shade300
                                   : null,
                               child: ListTile(
                                 title: Text(sequence[i].tag.title as String),
-                                onTap: () {
-                                  _player.seek(Duration.zero, index: i);
-                                },
+                                onTap: () => _player
+                                    .seek(Duration.zero, index: i)
+                                    .catchError((e, st) {}),
                               ),
                             ),
                           ),
@@ -273,7 +302,7 @@ class MyAppState extends State<MyApp> {
         floatingActionButton: FloatingActionButton(
           child: const Icon(Icons.add),
           onPressed: () {
-            _playlist.add(AudioSource.uri(
+            _player.addAudioSource(AudioSource.uri(
               Uri.parse("asset:///audio/nature.mp3"),
               tag: MediaItem(
                 id: '${_nextMediaId++}',
@@ -309,6 +338,7 @@ class ControlButtons extends StatelessWidget {
               divisions: 10,
               min: 0.0,
               max: 1.0,
+              value: player.volume,
               stream: player.volumeStream,
               onChanged: player.setVolume,
             );
@@ -321,12 +351,16 @@ class ControlButtons extends StatelessWidget {
             onPressed: player.hasPrevious ? player.seekToPrevious : null,
           ),
         ),
-        StreamBuilder<PlayerState>(
-          stream: player.playerStateStream,
+        StreamBuilder<(bool, ProcessingState, int)>(
+          stream: Rx.combineLatest3(
+              player.playingStream,
+              player.playbackEventStream,
+              player.sequenceStream,
+              (playing, event, sequence) =>
+                  (playing, event.processingState, sequence.length)),
           builder: (context, snapshot) {
-            final playerState = snapshot.data;
-            final processingState = playerState?.processingState;
-            final playing = playerState?.playing;
+            final (playing, processingState, sequenceLength) =
+                snapshot.data ?? (false, null, 0);
             if (processingState == ProcessingState.loading ||
                 processingState == ProcessingState.buffering) {
               return Container(
@@ -335,11 +369,11 @@ class ControlButtons extends StatelessWidget {
                 height: 64.0,
                 child: const CircularProgressIndicator(),
               );
-            } else if (playing != true) {
+            } else if (!playing) {
               return IconButton(
                 icon: const Icon(Icons.play_arrow),
                 iconSize: 64.0,
-                onPressed: player.play,
+                onPressed: sequenceLength > 0 ? player.play : null,
               );
             } else if (processingState != ProcessingState.completed) {
               return IconButton(
@@ -351,8 +385,10 @@ class ControlButtons extends StatelessWidget {
               return IconButton(
                 icon: const Icon(Icons.replay),
                 iconSize: 64.0,
-                onPressed: () => player.seek(Duration.zero,
-                    index: player.effectiveIndices!.first),
+                onPressed: sequenceLength > 0
+                    ? () => player.seek(Duration.zero,
+                        index: player.effectiveIndices.first)
+                    : null,
               );
             }
           },
@@ -376,6 +412,7 @@ class ControlButtons extends StatelessWidget {
                 divisions: 10,
                 min: 0.5,
                 max: 1.5,
+                value: player.speed,
                 stream: player.speedStream,
                 onChanged: player.setSpeed,
               );
diff --git a/just_audio_background/example/pubspec.yaml b/just_audio_background/example/pubspec.yaml
index a92c00620..b8c595c22 100644
--- a/just_audio_background/example/pubspec.yaml
+++ b/just_audio_background/example/pubspec.yaml
@@ -8,7 +8,7 @@ environment:
 dependencies:
   flutter:
     sdk: flutter
-  audio_session: ^0.1.24
+  audio_session: ^0.2.0
   rxdart: ^0.27.2
   just_audio:
     path: ../../just_audio
diff --git a/just_audio_background/lib/just_audio_background.dart b/just_audio_background/lib/just_audio_background.dart
index 4927721bd..36ac41d14 100644
--- a/just_audio_background/lib/just_audio_background.dart
+++ b/just_audio_background/lib/just_audio_background.dart
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:just_audio_platform_interface/just_audio_platform_interface.dart';
 import 'package:rxdart/rxdart.dart';
+import 'package:synchronized/synchronized.dart';
 
 export 'package:audio_service/audio_service.dart' show MediaItem;
 
@@ -120,29 +121,32 @@ class _JustAudioBackgroundPlugin extends JustAudioPlatform {
   }
 
   _JustAudioPlayer? _player;
+  String? _playerId;
 
   _JustAudioBackgroundPlugin();
 
   @override
   Future<AudioPlayerPlatform> init(InitRequest request) async {
-    if (_player != null) {
+    if (_playerId != null) {
       throw PlatformException(
-          code: "error",
-          message:
-              "just_audio_background supports only a single player instance");
+        code: "error",
+        message: "just_audio_background supports only a single player instance",
+      );
     }
-    _player = _JustAudioPlayer(
-      initRequest: request,
-    );
+    _playerId = request.id;
+    _player ??= _JustAudioPlayer(initRequest: request);
     return _player!;
   }
 
   @override
   Future<DisposePlayerResponse> disposePlayer(
       DisposePlayerRequest request) async {
-    final player = _player;
-    _player = null;
-    await player?.release();
+    if (request.id == _playerId) {
+      _playerId = null;
+      final player = _player;
+      _player = null;
+      await player?.release();
+    }
     return DisposePlayerResponse();
   }
 
@@ -156,74 +160,37 @@ class _JustAudioBackgroundPlugin extends JustAudioPlatform {
   }
 }
 
+final _PlayerAudioHandler _playerAudioHandler = _PlayerAudioHandler();
+
 class _JustAudioPlayer extends AudioPlayerPlatform {
   final InitRequest initRequest;
-  final eventController = StreamController<PlaybackEventMessage>.broadcast();
-  final playerDataController = StreamController<PlayerDataMessage>.broadcast();
-  bool? _playing;
-  IcyMetadataMessage? _icyMetadata;
-  int? _androidAudioSessionId;
-  late final _PlayerAudioHandler _playerAudioHandler;
+  final eventController =
+      StreamController<PlaybackEventMessage>.broadcast(sync: true);
+  final playerDataController =
+      StreamController<PlayerDataMessage>.broadcast(sync: true);
 
   _JustAudioPlayer({required this.initRequest}) : super(initRequest.id) {
-    _playerAudioHandler = _PlayerAudioHandler(initRequest);
+    eventController.onCancel = _playerAudioHandler.cancelStreamSubscriptions;
+    _playerAudioHandler._initPlayer(initRequest);
     _audioHandler.inner = _playerAudioHandler;
-    _audioHandler.playbackState.listen((playbackState) {
-      broadcastPlaybackEvent();
-    });
-    _audioHandler.customEvent.listen((dynamic event) {
-      switch (event['type']) {
-        case 'icyMetadata':
-          _icyMetadata = event['value'] as IcyMetadataMessage?;
-          broadcastPlaybackEvent();
-          break;
-        case 'androidAudioSessionId':
-          _androidAudioSessionId = event['value'] as int?;
-          broadcastPlaybackEvent();
-          break;
-      }
-    });
-    _audioHandler.mediaItem.listen((mediaItem) {
-      if (mediaItem == null) return;
-      broadcastPlaybackEvent();
+    _audioHandler.customEvent
+        .whereType<PlaybackEventMessage>()
+        .listen(eventController.add);
+    _audioHandler.customEvent
+        .whereType<_PlayingEvent>()
+        .map((event) => event.playing)
+        .distinct()
+        .listen((playing) {
+      playerDataController.add(PlayerDataMessage(playing: playing));
     });
   }
 
   PlaybackState get playbackState => _audioHandler.playbackState.nvalue!;
 
   Future<void> release() async {
-    eventController.close();
     await _audioHandler.stop();
   }
 
-  void broadcastPlaybackEvent() {
-    if (eventController.isClosed) return;
-    eventController.add(PlaybackEventMessage(
-      //processingState: playbackState.processingState,
-      processingState: {
-        AudioProcessingState.idle: ProcessingStateMessage.idle,
-        AudioProcessingState.loading: ProcessingStateMessage.loading,
-        AudioProcessingState.ready: ProcessingStateMessage.ready,
-        AudioProcessingState.buffering: ProcessingStateMessage.buffering,
-        AudioProcessingState.completed: ProcessingStateMessage.completed,
-        AudioProcessingState.error: ProcessingStateMessage.idle,
-      }[playbackState.processingState]!,
-      updatePosition: playbackState.position,
-      updateTime: playbackState.updateTime,
-      bufferedPosition: playbackState.bufferedPosition,
-      icyMetadata: _icyMetadata,
-      duration: _playerAudioHandler.currentMediaItem?.duration,
-      currentIndex: playbackState.queueIndex,
-      androidAudioSessionId: _androidAudioSessionId,
-    ));
-    if (playbackState.playing != _playing) {
-      _playing = playbackState.playing;
-      playerDataController.add(PlayerDataMessage(
-        playing: playbackState.playing,
-      ));
-    }
-  }
-
   @override
   Stream<PlaybackEventMessage> get playbackEventMessageStream =>
       eventController.stream;
@@ -379,7 +346,8 @@ class _JustAudioPlayer extends AudioPlayerPlatform {
 
 class _PlayerAudioHandler extends BaseAudioHandler
     with QueueHandler, SeekHandler {
-  final _playerCompleter = Completer<AudioPlayerPlatform>();
+  final _lock = Lock();
+  var _playerCompleter = _ValueCompleter<AudioPlayerPlatform>();
   PlaybackEventMessage _justAudioEvent = PlaybackEventMessage(
     processingState: ProcessingStateMessage.idle,
     updateTime: DateTime.now(),
@@ -402,73 +370,63 @@ class _PlayerAudioHandler extends BaseAudioHandler
   List<int> _effectiveIndicesInv = [];
 
   Future<AudioPlayerPlatform> get _player => _playerCompleter.future;
-  int? get index => _justAudioEvent.currentIndex;
-  MediaItem? get currentMediaItem => index != null &&
-          currentQueue != null &&
-          index! >= 0 &&
-          index! < currentQueue!.length
-      ? currentQueue![index!]
-      : null;
-
-  List<MediaItem>? get currentQueue => queue.nvalue;
-
-  _PlayerAudioHandler(InitRequest initRequest) {
-    _init(initRequest);
-  }
-
-  Future<void> _init(InitRequest initRequest) async {
-    final player = await _platform.init(initRequest);
-    _playerCompleter.complete(player);
-    final playbackEventMessageStream = player.playbackEventMessageStream;
-    playbackEventMessageStream.listen((event) {
-      _justAudioEvent = event;
-      _broadcastState();
-    });
-    playbackEventMessageStream
-        .map((event) => event.icyMetadata)
-        .distinct()
-        .listen((icyMetadata) {
-      customEvent.add({
-        'type': 'icyMetadata',
-        'value': icyMetadata,
+  int? index;
+  MediaItem? get currentMediaItem =>
+      index != null && index! >= 0 && index! < currentQueue.length
+          ? currentQueue[index!]
+          : null;
+
+  List<MediaItem> get currentQueue => queue.value;
+  StreamSubscription<TrackInfo>? _trackInfoSubscription;
+
+  Future<void> _initPlayer(InitRequest initRequest) =>
+      _lock.synchronized(() async {
+        final player = await _platform.init(initRequest);
+        _playerCompleter.complete(player);
+        final playbackEventMessageStream = player.playbackEventMessageStream;
+        _trackInfoSubscription = playbackEventMessageStream
+            .map((event) {
+              index = event.currentIndex ?? _justAudioEvent.currentIndex;
+              _justAudioEvent = event;
+              customEvent.add(event);
+              _broadcastState();
+              return event;
+            })
+            .map((event) => TrackInfo(event.currentIndex, event.duration))
+            .distinct()
+            .debounceTime(const Duration(milliseconds: 100))
+            .map((track) {
+              // Platform may send us a null duration on dispose, which we should
+              // ignore.
+              final currentMediaItem = this.currentMediaItem;
+              if (currentMediaItem != null) {
+                if (track.duration == null &&
+                    currentMediaItem.duration != null) {
+                  return TrackInfo(track.index, currentMediaItem.duration);
+                }
+              }
+              return track;
+            })
+            .distinct()
+            .listen((track) {
+              if (currentMediaItem != null && index != null) {
+                if (track.duration != currentMediaItem!.duration &&
+                    (index! < queue.nvalue!.length && track.duration != null)) {
+                  currentQueue[index!] =
+                      currentQueue[index!].copyWith(duration: track.duration);
+                  queue.add(currentQueue);
+                }
+                mediaItem.add(currentMediaItem!);
+              }
+            }, onError: (Object e, [StackTrace? st]) {});
       });
-    });
-    playbackEventMessageStream
-        .map((event) => event.androidAudioSessionId)
-        .distinct()
-        .listen((audioSessionId) {
-      customEvent.add({
-        'type': 'androidAudioSessionId',
-        'value': audioSessionId,
-      });
-    });
-    playbackEventMessageStream
-        .map((event) => TrackInfo(event.currentIndex, event.duration))
-        .distinct()
-        .debounceTime(const Duration(milliseconds: 100))
-        .map((track) {
-          // Platform may send us a null duration on dispose, which we should
-          // ignore.
-          final currentMediaItem = this.currentMediaItem;
-          if (currentMediaItem != null) {
-            if (track.duration == null && currentMediaItem.duration != null) {
-              return TrackInfo(track.index, currentMediaItem.duration);
-            }
-          }
-          return track;
-        })
-        .distinct()
-        .listen((track) {
-          if (currentMediaItem != null) {
-            if (track.duration != currentMediaItem!.duration &&
-                (index! < queue.nvalue!.length && track.duration != null)) {
-              currentQueue![index!] =
-                  currentQueue![index!].copyWith(duration: track.duration);
-              queue.add(currentQueue!);
-            }
-            mediaItem.add(currentMediaItem!);
-          }
-        });
+
+  Future<void> cancelStreamSubscriptions() async {
+    final trackInfoSubscription = _trackInfoSubscription;
+    if (trackInfoSubscription != null) {
+      _trackInfoSubscription = null;
+      await trackInfoSubscription.cancel();
+    }
   }
 
   @override
@@ -612,7 +570,7 @@ class _PlayerAudioHandler extends BaseAudioHandler
 
   void _updateQueue() {
     assert(sequence.every((source) => source.tag is MediaItem),
-        'Error : When using just_audio_background, you should always use a MediaItem as tag when setting an AudioSource. See AudioSource.uri documentation for more information.');
+        'Error : When using just_audio_background, you should always set a MediaItem tag on every AudioSource. See AudioSource.uri documentation for more information.');
     queue.add(sequence.map((source) => source.tag as MediaItem).toList());
   }
 
@@ -635,22 +593,23 @@ class _PlayerAudioHandler extends BaseAudioHandler
   List<int> get effectiveIndices => _effectiveIndices;
   List<int> get shuffleIndicesInv => _shuffleIndicesInv;
   List<int> get effectiveIndicesInv => _effectiveIndicesInv;
-  int get nextIndex => getRelativeIndex(1);
-  int get previousIndex => getRelativeIndex(-1);
-  bool get hasNext => nextIndex != -1;
-  bool get hasPrevious => previousIndex != -1;
-
-  int getRelativeIndex(int offset) {
-    if (_repeatMode == AudioServiceRepeatMode.one) return index!;
-    if (effectiveIndices.isEmpty) return -1;
-    if (index! >= effectiveIndicesInv.length) return -1;
+  int? get nextIndex => getRelativeIndex(1);
+  int? get previousIndex => getRelativeIndex(-1);
+  bool get hasNext => nextIndex != null;
+  bool get hasPrevious => previousIndex != null;
+
+  int? getRelativeIndex(int offset) {
+    if (currentQueue.isEmpty || index == null) return null;
+    if (_repeatMode == AudioServiceRepeatMode.one) return index;
+    if (effectiveIndices.isEmpty) return null;
+    if (index! >= effectiveIndicesInv.length) return null;
     final invPos = effectiveIndicesInv[index!];
     var newInvPos = invPos + offset;
     if (newInvPos >= effectiveIndices.length || newInvPos < 0) {
       if (_repeatMode == AudioServiceRepeatMode.all) {
         newInvPos %= effectiveIndices.length;
       } else {
-        return -1;
+        return null;
       }
     }
     final result = effectiveIndices[newInvPos];
@@ -665,29 +624,34 @@ class _PlayerAudioHandler extends BaseAudioHandler
   @override
   Future<void> skipToNext() async {
     if (hasNext) {
-      await skipToQueueItem(nextIndex);
+      await skipToQueueItem(nextIndex!);
     }
   }
 
   @override
   Future<void> skipToPrevious() async {
     if (hasPrevious) {
-      await skipToQueueItem(previousIndex);
+      await skipToQueueItem(previousIndex!);
     }
   }
 
   @override
   Future<void> play() async {
-    _updatePosition();
-    _playing = true;
-    _broadcastState();
-    await (await _player).play(PlayRequest());
+    if (_justAudioEvent.processingState == ProcessingStateMessage.completed) {
+      await skipToQueueItem(0);
+    }
+    if (!_playing) {
+      _updatePosition();
+      customEvent.add(_PlayingEvent(_playing = true));
+      _broadcastState();
+      await (await _player).play(PlayRequest());
+    }
   }
 
   @override
   Future<void> pause() async {
     _updatePosition();
-    _playing = false;
+    customEvent.add(_PlayingEvent(_playing = false));
     _broadcastState();
     await (await _player).pause(PauseRequest());
   }
@@ -742,22 +706,18 @@ class _PlayerAudioHandler extends BaseAudioHandler
   }
 
   @override
-  Future<void> stop() async {
-    if (_justAudioEvent.processingState == ProcessingStateMessage.idle) {
-      return;
-    }
-    _updatePosition();
-    _playing = false;
-    _broadcastState();
-    // TODO: We should really stop listening to events here to mimic
-    // just_audio's behaviour. E.g. if stop() was called, we actually want to
-    // keep the state around even though the platform may be disposing its own
-    // state.
-    _platform.disposePlayer(DisposePlayerRequest(id: (await _player).id));
-    _justAudioEvent = _justAudioEvent.copyWith(
-      processingState: ProcessingStateMessage.idle,
-    );
-  }
+  Future<void> stop() => _lock.synchronized(() async {
+        final player = _playerCompleter.value;
+        if (player == null) return;
+        _updatePosition();
+        customEvent.add(_PlayingEvent(_playing = false));
+        _broadcastState();
+        _playerCompleter = _ValueCompleter<AudioPlayerPlatform>();
+        await _platform.disposePlayer(DisposePlayerRequest(id: player.id));
+        _justAudioEvent = _justAudioEvent.copyWith(
+          processingState: ProcessingStateMessage.idle,
+        );
+      });
 
   Duration get currentPosition {
     if (_playing &&
@@ -821,18 +781,27 @@ class _PlayerAudioHandler extends BaseAudioHandler
       androidCompactActionIndices: List.generate(controls.length, (i) => i)
           .where((i) => controls[i].action != MediaAction.stop)
           .toList(),
-      processingState: const {
-        ProcessingStateMessage.idle: AudioProcessingState.idle,
-        ProcessingStateMessage.loading: AudioProcessingState.loading,
-        ProcessingStateMessage.buffering: AudioProcessingState.buffering,
-        ProcessingStateMessage.ready: AudioProcessingState.ready,
-        ProcessingStateMessage.completed: AudioProcessingState.completed,
-      }[_justAudioEvent.processingState]!,
-      playing: _playing,
+      processingState: _justAudioEvent.errorCode != null
+          ? AudioProcessingState.error
+          : const {
+                ProcessingStateMessage.idle: AudioProcessingState.idle,
+                ProcessingStateMessage.loading: AudioProcessingState.loading,
+                ProcessingStateMessage.buffering:
+                    AudioProcessingState.buffering,
+                ProcessingStateMessage.ready: AudioProcessingState.ready,
+                ProcessingStateMessage.completed:
+                    AudioProcessingState.completed,
+              }[_justAudioEvent.processingState] ??
+              AudioProcessingState.idle,
+      playing: _playing &&
+          !{ProcessingStateMessage.idle, ProcessingStateMessage.completed}
+              .contains(_justAudioEvent.processingState),
       updatePosition: currentPosition,
       bufferedPosition: _justAudioEvent.bufferedPosition,
       speed: _speed,
       queueIndex: _justAudioEvent.currentIndex,
+      errorCode: _justAudioEvent.errorCode,
+      errorMessage: _justAudioEvent.errorMessage,
     ));
   }
 }
@@ -968,3 +937,21 @@ extension _ValueStreamExtension<T> on ValueStream<T> {
   /// Backwards compatible version of valueOrNull.
   T? get nvalue => hasValue ? value : null;
 }
+
+class _PlayingEvent {
+  final bool playing;
+
+  const _PlayingEvent(this.playing);
+}
+
+class _ValueCompleter<T> {
+  final _completer = Completer<T>();
+  T? value;
+
+  void complete(T value) {
+    this.value = value;
+    _completer.complete(value);
+  }
+
+  Future<T> get future => _completer.future;
+}
diff --git a/just_audio_background/pubspec.yaml b/just_audio_background/pubspec.yaml
index cda7e1365..ea53b2e0d 100644
--- a/just_audio_background/pubspec.yaml
+++ b/just_audio_background/pubspec.yaml
@@ -1,7 +1,7 @@
 name: just_audio_background
 description: An add-on for just_audio that supports background playback and media notifications.
 homepage: https://github.com/ryanheise/just_audio/tree/master/just_audio_background
-version: 0.0.1-beta.15
+version: 0.0.1-beta.16
 topics:
   - audio
   - sound
@@ -9,21 +9,22 @@ topics:
   - background
 
 dependencies:
-  just_audio_platform_interface: ^4.4.0
+  just_audio_platform_interface: ^4.5.0
   # just_audio_platform_interface:
   #   path: ../just_audio_platform_interface
-  audio_service: ^0.18.14
-  audio_session: ^0.1.23
+  audio_service: ^0.18.18
+  audio_session: ">=0.1.24 <0.3.0"
   flutter:
     sdk: flutter
   flutter_web_plugins:
     sdk: flutter
   meta: ^1.3.0
   rxdart: '>=0.26.0 <0.29.0'
+  synchronized: ^3.3.0+3
 
 dev_dependencies:
   flutter_lints: ^2.0.1
 
 environment:
-  sdk: ">=3.0.0 <4.0.0"
-  flutter: ">=3.10.0"
+  sdk: ^3.6.0
+  flutter: ">=3.27.0"
diff --git a/just_audio_platform_interface/CHANGELOG.md b/just_audio_platform_interface/CHANGELOG.md
index d7c8c7170..07e919577 100644
--- a/just_audio_platform_interface/CHANGELOG.md
+++ b/just_audio_platform_interface/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 4.5.0
+
+* Add errorCode/errorMessage to PlaybackEventMessage.
+* Add useLazyPreparation to InitRequest for top-level playlist.
+
 ## 4.4.0
 
 * Add setWebSinkId for web (@dganzella).
diff --git a/just_audio_platform_interface/lib/just_audio_platform_interface.dart b/just_audio_platform_interface/lib/just_audio_platform_interface.dart
index 71e31753b..7361f61af 100644
--- a/just_audio_platform_interface/lib/just_audio_platform_interface.dart
+++ b/just_audio_platform_interface/lib/just_audio_platform_interface.dart
@@ -293,6 +293,8 @@ class PlaybackEventMessage {
   final IcyMetadataMessage? icyMetadata;
   final int? currentIndex;
   final int? androidAudioSessionId;
+  final int? errorCode;
+  final String? errorMessage;
 
   PlaybackEventMessage({
     required this.processingState,
@@ -303,6 +305,8 @@ class PlaybackEventMessage {
     required this.icyMetadata,
     required this.currentIndex,
     required this.androidAudioSessionId,
+    this.errorCode,
+    this.errorMessage,
   });
 
   static PlaybackEventMessage fromMap(Map<dynamic, dynamic> map) =>
@@ -323,6 +327,8 @@ class PlaybackEventMessage {
                 map['icyMetadata'] as Map<dynamic, dynamic>),
         currentIndex: map['currentIndex'] as int?,
         androidAudioSessionId: map['androidAudioSessionId'] as int?,
+        errorCode: map['errorCode'] as int?,
+        errorMessage: map['errorMessage'] as String?,
       );
 }
 
@@ -408,6 +414,7 @@ class InitRequest {
   final List<AudioEffectMessage> androidAudioEffects;
   final List<AudioEffectMessage> darwinAudioEffects;
   final bool? androidOffloadSchedulingEnabled;
+  final bool useLazyPreparation;
 
   InitRequest({
     required this.id,
@@ -415,6 +422,7 @@ class InitRequest {
     this.androidAudioEffects = const [],
     this.darwinAudioEffects = const [],
     this.androidOffloadSchedulingEnabled,
+    this.useLazyPreparation = true,
   });
 
   Map<dynamic, dynamic> toMap() => <dynamic, dynamic>{
@@ -427,6 +435,7 @@ class InitRequest {
             .map((audioEffect) => audioEffect.toMap())
             .toList(),
         'androidOffloadSchedulingEnabled': androidOffloadSchedulingEnabled,
+        'useLazyPreparation': useLazyPreparation,
       };
 }
 
diff --git a/just_audio_platform_interface/pubspec.yaml b/just_audio_platform_interface/pubspec.yaml
index 5f8a628cb..3633a1bd5 100644
--- a/just_audio_platform_interface/pubspec.yaml
+++ b/just_audio_platform_interface/pubspec.yaml
@@ -3,7 +3,7 @@ description: A common platform interface for the just_audio plugin. Different pl
 homepage: https://github.com/ryanheise/just_audio/tree/master/just_audio_platform_interface
 # NOTE: We strongly prefer non-breaking changes, even at the expense of a
 # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes
-version: 4.4.0
+version: 4.5.0
 
 dependencies:
   flutter:
diff --git a/just_audio_web/CHANGELOG.md b/just_audio_web/CHANGELOG.md
index a2915419d..35dda8094 100644
--- a/just_audio_web/CHANGELOG.md
+++ b/just_audio_web/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.4.15
+
+* Support errorCode, errorMessage.
+
 ## 0.4.14
 
 * Add setWebSinkId (@dganzella).
diff --git a/just_audio_web/lib/just_audio_web.dart b/just_audio_web/lib/just_audio_web.dart
index 01e0807a6..7e3217e55 100644
--- a/just_audio_web/lib/just_audio_web.dart
+++ b/just_audio_web/lib/just_audio_web.dart
@@ -50,12 +50,16 @@ class JustAudioPlugin extends JustAudioPlatform {
 
 /// The web impluementation of [AudioPlayerPlatform].
 abstract class JustAudioPlayer extends AudioPlayerPlatform {
-  final _eventController = StreamController<PlaybackEventMessage>.broadcast();
-  final _dataEventController = StreamController<PlayerDataMessage>.broadcast();
+  final _eventController =
+      StreamController<PlaybackEventMessage>.broadcast(sync: true);
+  final _dataEventController =
+      StreamController<PlayerDataMessage>.broadcast(sync: true);
   ProcessingStateMessage _processingState = ProcessingStateMessage.idle;
   bool _playing = false;
   int? _index;
   double _speed = 1.0;
+  int? errorCode;
+  String? errorMessage;
 
   /// Creates a platform player with the given [id].
   JustAudioPlayer({required String id}) : super(id);
@@ -88,12 +92,18 @@ abstract class JustAudioPlayer extends AudioPlayerPlatform {
       duration: getDuration(),
       currentIndex: _index,
       androidAudioSessionId: null,
+      errorCode: errorCode,
+      errorMessage: errorMessage,
     ));
   }
 
   /// Transitions to [processingState] and broadcasts a playback event.
   void transition(ProcessingStateMessage processingState) {
     _processingState = processingState;
+    if (processingState != ProcessingStateMessage.idle) {
+      errorCode = null;
+      errorMessage = null;
+    }
     broadcastPlaybackEvent();
   }
 }
@@ -116,17 +126,26 @@ class Html5AudioPlayer extends JustAudioPlayer {
         'durationchange',
         (Event event) {
           _durationCompleter?.complete();
+          _durationCompleter = null;
           broadcastPlaybackEvent();
         }.toJS);
     _audioElement.addEventListener(
         'error',
         (Event event) {
+          _eventController.addError(PlatformException(
+            code: '${_audioElement.error!.code}',
+            message: _audioElement.error!.message,
+          ));
+          errorCode = _audioElement.error!.code;
+          errorMessage = _audioElement.error!.message;
+          transition(ProcessingStateMessage.idle);
           _durationCompleter?.completeError(_audioElement.error!);
+          _durationCompleter = null;
         }.toJS);
     _audioElement.addEventListener(
         'ended',
         (Event event) {
-          _currentAudioSourcePlayer?.complete();
+          _currentAudioSourcePlayer?.complete().catchError((e, st) {});
         }.toJS);
     _audioElement.addEventListener(
         'timeupdate',
@@ -676,7 +695,7 @@ abstract class UriAudioSourcePlayer extends IndexedAudioSourcePlayer {
   @override
   Future<void> complete() async {
     _interruptPlay();
-    html5AudioPlayer.onEnded();
+    await html5AudioPlayer.onEnded().catchError((e, st) {});
   }
 
   void _interruptPlay() {
@@ -860,7 +879,7 @@ class ClippingAudioSourcePlayer extends IndexedAudioSourcePlayer {
       _completer = Completer<ClipInterruptReason>();
     }
     if (reason == ClipInterruptReason.end) {
-      html5AudioPlayer.onEnded();
+      await html5AudioPlayer.onEnded().catchError((e, st) {});
     }
     _completer = null;
   }
diff --git a/just_audio_web/pubspec.yaml b/just_audio_web/pubspec.yaml
index b561e0789..03c754a09 100644
--- a/just_audio_web/pubspec.yaml
+++ b/just_audio_web/pubspec.yaml
@@ -1,7 +1,7 @@
 name: just_audio_web
 description: Web platform implementation of just_audio. This implementation is endorsed and therefore doesn't require a direct dependency.
 homepage: https://github.com/ryanheise/just_audio/tree/master/just_audio_web
-version: 0.4.14
+version: 0.4.15
 
 flutter:
   plugin:
@@ -11,7 +11,7 @@ flutter:
         fileName: just_audio_web.dart
 
 dependencies:
-  just_audio_platform_interface: ^4.4.0
+  just_audio_platform_interface: ^4.5.0
   # just_audio_platform_interface:
   #   path: ../just_audio_platform_interface
   flutter:


Test patch:
diff --git a/just_audio/test/just_audio_test.dart b/just_audio/test/just_audio_test.dart
index 1067d50c7..966ffb7cf 100644
--- a/just_audio/test/just_audio_test.dart
+++ b/just_audio/test/just_audio_test.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: deprecated_member_use_from_same_package
+
 import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
@@ -78,7 +80,7 @@ void runTests() {
     expect(player.playing, equals(false));
     expect(player.volume, equals(1.0));
     expect(player.speed, equals(1.0));
-    expect(player.sequence, equals(null));
+    expect(player.sequence, equals([]));
     expect(player.hasNext, equals(false));
     expect(player.hasPrevious, equals(false));
     //expect(player.loopMode, equals(LoopMode.off));
@@ -129,7 +131,7 @@ void runTests() {
         player: player, playing: false, position: const Duration(seconds: 1));
     final playlist = ConcatenatingAudioSource(children: []);
     await player.setAudioSource(playlist, preload: false);
-    await playlist.addAll([
+    final audioSources = [
       AudioSource.uri(
         Uri.parse("https://foo.foo/foo.mp3"),
         tag: 'a',
@@ -142,10 +144,18 @@ void runTests() {
         Uri.parse("https://baz.baz/baz.mp3"),
         tag: 'c',
       ),
-    ]);
+    ];
+    await playlist.addAll(audioSources);
     await playlist.move(2, 1);
     await playlist.removeAt(2);
-    await player.load();
+    expect(player.processingState, equals(ProcessingState.idle));
+    expect(playlist.sequence.map((s) => s.tag as String?).toList(),
+        equals(['a', 'c']));
+    await player.clearAudioSources();
+    await player.addAudioSources(audioSources);
+    await player.moveAudioSource(2, 1);
+    await player.removeAudioSourceAt(2);
+    expect(player.processingState, equals(ProcessingState.idle));
     expect(playlist.sequence.map((s) => s.tag as String?).toList(),
         equals(['a', 'c']));
     await player.dispose();
@@ -161,7 +171,7 @@ void runTests() {
     expect(player.currentIndex, equals(0));
     expect(player.hasNext, equals(false));
     expect(player.hasPrevious, equals(false));
-    expect(player.sequence!.length, equals(1));
+    expect(player.sequence.length, equals(1));
     expect(player.playing, equals(false));
     await player.dispose();
   });
@@ -631,7 +641,11 @@ void runTests() {
 
   test('sequence-state', () async {
     final player = AudioPlayer();
-    expect(player.sequenceState, equals(null));
+    expect(player.sequenceState.sequence, equals([]));
+    expect(player.sequenceState.currentIndex, equals(null));
+    expect(player.sequenceState.shuffleIndices, equals([]));
+    expect(player.sequenceState.shuffleModeEnabled, equals(false));
+    expect(player.sequenceState.loopMode, equals(LoopMode.off));
     for (var shuffle in [false, true]) {
       if (shuffle) {
         await player.setShuffleModeEnabled(shuffle);
@@ -662,10 +676,10 @@ void runTests() {
       );
       void expectEffectiveSequence() {
         expect(
-            player.sequenceState?.effectiveSequence,
+            player.sequenceState.effectiveSequence,
             shuffle
-                ? player.effectiveIndices!
-                    .map((i) => player.sequence![i])
+                ? player.effectiveIndices
+                    .map((i) => player.sequence[i])
                     .toList()
                 : player.sequence);
       }
@@ -674,28 +688,220 @@ void runTests() {
       //  return player.effectiveSequence.map((IndexedAudioSource item) => player1.sequence.indexOf(item)).toList();
       //}
       await player.setAudioSource(playlist);
-      expect(player.sequenceState?.sequence, equals(playlist.children));
-      expect(player.sequenceState?.currentIndex, equals(0));
-      expect(player.sequenceState?.currentSource, equals(playlist.children[0]));
+      expect(player.sequenceState.sequence, equals(playlist.children));
+      expect(player.sequenceState.currentIndex, equals(0));
+      expect(player.sequenceState.currentSource, equals(playlist.children[0]));
       expectEffectiveSequence();
       await player.seek(Duration.zero, index: 4);
-      expect(player.sequenceState?.sequence, equals(playlist.children));
-      expect(player.sequenceState?.currentIndex, equals(4));
-      expect(player.sequenceState?.currentSource, equals(playlist.children[4]));
+      expect(player.sequenceState.sequence, equals(playlist.children));
+      expect(player.sequenceState.currentIndex, equals(4));
+      expect(player.sequenceState.currentSource, equals(playlist.children[4]));
       await playlist.removeAt(4);
       expectEffectiveSequence();
-      expect(player.sequenceState?.sequence, equals(playlist.children));
-      expect(player.sequenceState?.currentIndex, equals(3));
-      expect(player.sequenceState?.currentSource, equals(playlist.children[3]));
+      expect(player.sequenceState.sequence, equals(playlist.children));
+      expect(player.sequenceState.currentIndex, equals(3));
+      expect(player.sequenceState.currentSource, equals(playlist.children[3]));
       await playlist.removeAt(1);
-      expect(player.sequenceState?.sequence, equals(playlist.children));
-      expect(player.sequenceState?.currentIndex, equals(2));
-      expect(player.sequenceState?.currentSource, equals(playlist.children[2]));
+      expect(player.sequenceState.sequence, equals(playlist.children));
+      expect(player.sequenceState.currentIndex, equals(2));
+      expect(player.sequenceState.currentSource, equals(playlist.children[2]));
       await playlist.clear();
-      expect(player.sequenceState?.sequence, equals(playlist.children));
+      expect(player.sequenceState.sequence, equals(playlist.children));
+      // expecting 0 here may change in a future version.
+      expect(player.sequenceState.currentIndex, equals(null));
+      expect(player.sequenceState.currentSource, equals(null));
+      expectEffectiveSequence();
+    }
+    await player.dispose();
+  });
+
+  test('playlist', () async {
+    final player = AudioPlayer();
+    expect(player.sequence.length, equals(0));
+    await player.addAudioSource(
+        AudioSource.uri(Uri.parse('https://b.b/b.mp3'), tag: 'b'));
+    await player.load();
+    await player.insertAudioSource(
+      0,
+      // AudioSource.uri(Uri.parse('https://a.a/a.mp3'), tag: 'a'),
+      LoopingAudioSource(
+        count: 2,
+        child: ClippingAudioSource(
+          start: const Duration(seconds: 60),
+          end: const Duration(seconds: 65),
+          child: AudioSource.uri(Uri.parse("https://foo.foo/foo.mp3")),
+          tag: 'a',
+        ),
+      ),
+    );
+    await player.insertAudioSource(
+        2, AudioSource.uri(Uri.parse('https://c.c/c.mp3'), tag: 'c'));
+    await player.addAudioSources([
+      AudioSource.uri(Uri.parse('https://d.d/d.mp3'), tag: 'd'),
+      AudioSource.uri(Uri.parse('https://e.e/e.mp3'), tag: 'e'),
+    ]);
+    await player.insertAudioSources(3, [
+      AudioSource.uri(Uri.parse('https://e.e/e.mp3'), tag: 'e'),
+      AudioSource.uri(Uri.parse('https://f.f/f.mp3'), tag: 'f'),
+    ]);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['a', 'a', 'b', 'c', 'e', 'f', 'd', 'e']));
+    await player.removeAudioSourceAt(0);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['b', 'c', 'e', 'f', 'd', 'e']));
+    await player.moveAudioSource(3, 2);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['b', 'c', 'f', 'e', 'd', 'e']));
+    await player.moveAudioSource(2, 3);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['b', 'c', 'e', 'f', 'd', 'e']));
+    await player.removeAudioSourceRange(0, 2);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['e', 'f', 'd', 'e']));
+    await player.removeAudioSourceAt(3);
+    expect(
+        player.sequence.map((s) => s.tag as String?), equals(['e', 'f', 'd']));
+    await player.removeAudioSourceRange(1, 3);
+    expect(player.sequence.map((s) => s.tag as String?), equals(['e']));
+    await player.setAudioSources([
+      AudioSource.uri(Uri.parse('https://a.a/a.mp3'), tag: 'a'),
+      AudioSource.uri(Uri.parse('https://b.b/b.mp3'), tag: 'b'),
+    ]);
+    expect(player.sequence.map((s) => s.tag as String?), equals(['a', 'b']));
+    await player.clearAudioSources();
+    expect(player.sequence.map((s) => s.tag as String?), equals(<String>[]));
+    await player.dispose();
+  });
+
+  test('idle-playlist', () async {
+    final player = AudioPlayer();
+    expect(player.sequence.length, equals(0));
+    await player.addAudioSource(
+        AudioSource.uri(Uri.parse('https://b.b/b.mp3'), tag: 'b'));
+    await player.insertAudioSource(
+      0,
+      // AudioSource.uri(Uri.parse('https://a.a/a.mp3'), tag: 'a'),
+      LoopingAudioSource(
+        count: 2,
+        child: ClippingAudioSource(
+          start: const Duration(seconds: 60),
+          end: const Duration(seconds: 65),
+          child: AudioSource.uri(Uri.parse("https://foo.foo/foo.mp3")),
+          tag: 'a',
+        ),
+      ),
+    );
+    await player.insertAudioSource(
+        2, AudioSource.uri(Uri.parse('https://c.c/c.mp3'), tag: 'c'));
+    await player.addAudioSources([
+      AudioSource.uri(Uri.parse('https://d.d/d.mp3'), tag: 'd'),
+      AudioSource.uri(Uri.parse('https://e.e/e.mp3'), tag: 'e'),
+    ]);
+    await player.insertAudioSources(3, [
+      AudioSource.uri(Uri.parse('https://e.e/e.mp3'), tag: 'e'),
+      AudioSource.uri(Uri.parse('https://f.f/f.mp3'), tag: 'f'),
+    ]);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['a', 'a', 'b', 'c', 'e', 'f', 'd', 'e']));
+    await player.removeAudioSourceAt(0);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['b', 'c', 'e', 'f', 'd', 'e']));
+    await player.moveAudioSource(3, 2);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['b', 'c', 'f', 'e', 'd', 'e']));
+    await player.moveAudioSource(2, 3);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['b', 'c', 'e', 'f', 'd', 'e']));
+    await player.removeAudioSourceRange(0, 2);
+    expect(player.sequence.map((s) => s.tag as String?),
+        equals(['e', 'f', 'd', 'e']));
+    await player.removeAudioSourceAt(3);
+    expect(
+        player.sequence.map((s) => s.tag as String?), equals(['e', 'f', 'd']));
+    await player.removeAudioSourceRange(1, 3);
+    expect(player.sequence.map((s) => s.tag as String?), equals(['e']));
+    await player.setAudioSources([
+      AudioSource.uri(Uri.parse('https://a.a/a.mp3'), tag: 'a'),
+      AudioSource.uri(Uri.parse('https://b.b/b.mp3'), tag: 'b'),
+    ], preload: false);
+    expect(player.sequence.map((s) => s.tag as String?), equals(['a', 'b']));
+    await player.clearAudioSources();
+    expect(player.sequence.map((s) => s.tag as String?), equals(<String>[]));
+    await player.dispose();
+  });
+
+  test('playlist-state', () async {
+    final player = AudioPlayer();
+    expect(player.sequenceState.sequence, equals([]));
+    expect(player.sequenceState.currentIndex, equals(null));
+    expect(player.sequenceState.shuffleIndices, equals([]));
+    expect(player.sequenceState.shuffleModeEnabled, equals(false));
+    expect(player.sequenceState.loopMode, equals(LoopMode.off));
+    for (var shuffle in [false, true]) {
+      if (shuffle) {
+        await player.setShuffleModeEnabled(shuffle);
+      }
+      final playlist = [
+        AudioSource.uri(
+          Uri.parse("https://bar.bar/a.mp3"),
+          tag: 'a',
+        ),
+        AudioSource.uri(
+          Uri.parse("https://baz.baz/b.mp3"),
+          tag: 'b',
+        ),
+        AudioSource.uri(
+          Uri.parse("https://baz.baz/c.mp3"),
+          tag: 'c',
+        ),
+        AudioSource.uri(
+          Uri.parse("https://baz.baz/d.mp3"),
+          tag: 'd',
+        ),
+        AudioSource.uri(
+          Uri.parse("https://baz.baz/e.mp3"),
+          tag: 'e',
+        ),
+      ];
+      void expectEffectiveSequence() {
+        expect(
+            player.sequenceState.effectiveSequence,
+            shuffle
+                ? player.effectiveIndices
+                    .map((i) => player.sequence[i])
+                    .toList()
+                : player.sequence);
+      }
+
+      //List<int> effectiveSequenceToIndices() {
+      //  return player.effectiveSequence.map((IndexedAudioSource item) => player1.sequence.indexOf(item)).toList();
+      //}
+      await player.setAudioSources(playlist);
+      expect(player.sequenceState.sequence, equals(playlist));
+      expect(player.sequenceState.currentIndex, equals(0));
+      expect(player.sequenceState.currentSource, equals(playlist[0]));
+      expectEffectiveSequence();
+      await player.seek(Duration.zero, index: 4);
+      expect(player.sequenceState.sequence, equals(playlist));
+      expect(player.sequenceState.currentIndex, equals(4));
+      expect(player.sequenceState.currentSource, equals(playlist[4]));
+      await player.removeAudioSourceAt(4);
+      playlist.removeAt(4);
+      expectEffectiveSequence();
+      expect(player.sequenceState.sequence, equals(playlist));
+      expect(player.sequenceState.currentIndex, equals(3));
+      expect(player.sequenceState.currentSource, equals(playlist[3]));
+      await player.removeAudioSourceAt(1);
+      playlist.removeAt(1);
+      expect(player.sequenceState.sequence, equals(playlist));
+      expect(player.sequenceState.currentIndex, equals(2));
+      expect(player.sequenceState.currentSource, equals(playlist[2]));
+      await player.clearAudioSources();
+      playlist.clear();
+      expect(player.sequenceState.sequence, equals(playlist));
       // expecting 0 here may change in a future version.
-      expect(player.sequenceState?.currentIndex, equals(0));
-      expect(player.sequenceState?.currentSource, equals(null));
+      expect(player.sequenceState.currentIndex, equals(null));
+      expect(player.sequenceState.currentSource, equals(null));
       expectEffectiveSequence();
     }
     await player.dispose();
@@ -806,25 +1012,25 @@ void runTests() {
         equals(1));
     final player1 = AudioPlayer();
     await player1.setAudioSource(source1);
-    checkIndices(player1.shuffleIndices!, 5);
-    expect(player1.shuffleIndices!.first, equals(0));
-    expect(player1.effectiveIndices!,
-        List.generate(player1.sequence!.length, (i) => i));
+    checkIndices(player1.shuffleIndices, 5);
+    expect(player1.shuffleIndices.first, equals(0));
+    expect(player1.effectiveIndices,
+        List.generate(player1.sequence.length, (i) => i));
     await player1.seek(Duration.zero, index: 3);
     await player1.shuffle();
-    checkIndices(player1.shuffleIndices!, 5);
-    expect(player1.shuffleIndices!.first, equals(3));
-    expect(player1.effectiveIndices!,
-        List.generate(player1.sequence!.length, (i) => i));
+    checkIndices(player1.shuffleIndices, 5);
+    expect(player1.shuffleIndices.first, equals(3));
+    expect(player1.effectiveIndices,
+        List.generate(player1.sequence.length, (i) => i));
     await player1.setShuffleModeEnabled(true);
-    expect(player1.effectiveIndices!, player1.shuffleIndices!);
+    expect(player1.effectiveIndices, player1.shuffleIndices);
     await player1.dispose();
 
     final source2 = createSource();
     final player2 = AudioPlayer();
     await player2.setAudioSource(source2, initialIndex: 3);
-    checkIndices(player2.shuffleIndices!, 5);
-    expect(player2.shuffleIndices!.first, equals(3));
+    checkIndices(player2.shuffleIndices, 5);
+    expect(player2.shuffleIndices.first, equals(3));
     await player2.dispose();
   });
 
@@ -840,6 +1046,10 @@ void runTests() {
           Uri.parse("https://baz.baz/bar.mp3"),
           tag: 'bar',
         ),
+        AudioSource.uri(
+          Uri.parse("https://baz.baz/baz.mp3"),
+          tag: 'baz',
+        ),
       ],
     );
     final player = AudioPlayer();
@@ -852,11 +1062,15 @@ void runTests() {
     await player.seekToNext();
     expect(player.currentIndex, 1);
     expect(player.hasPrevious, true);
+    expect(player.hasNext, true);
+    await player.seekToNext();
+    expect(player.currentIndex, 2);
+    expect(player.hasPrevious, true);
     expect(player.hasNext, false);
     await player.seekToNext();
-    expect(player.currentIndex, 1);
+    expect(player.currentIndex, 2);
     await player.seekToPrevious();
-    expect(player.currentIndex, 0);
+    expect(player.currentIndex, 1);
     await player.dispose();
   });
 
@@ -972,7 +1186,7 @@ void runTests() {
       preload: false,
     );
     expect(player.processingState, equals(ProcessingState.idle));
-    expect(player.sequence!.length, equals(2));
+    expect(player.sequence.length, equals(2));
     expect(player.playing, equals(false));
     await player.setAudioSource(
       ConcatenatingAudioSource(
@@ -985,7 +1199,7 @@ void runTests() {
       preload: false,
     );
     expect(player.processingState, equals(ProcessingState.idle));
-    expect(player.sequence!.length, equals(3));
+    expect(player.sequence.length, equals(3));
     expect(player.playing, equals(false));
     await player.dispose();
   });
@@ -1001,7 +1215,7 @@ void runTests() {
       ),
     );
     expect(player.processingState, equals(ProcessingState.ready));
-    expect(player.sequence!.length, equals(2));
+    expect(player.sequence.length, equals(2));
     expect(player.playing, equals(false));
     await player.setAudioSource(
       ConcatenatingAudioSource(
@@ -1013,7 +1227,7 @@ void runTests() {
       ),
     );
     expect(player.processingState, equals(ProcessingState.ready));
-    expect(player.sequence!.length, equals(3));
+    expect(player.sequence.length, equals(3));
     expect(player.playing, equals(false));
     await player.dispose();
   });
@@ -1029,7 +1243,7 @@ void runTests() {
       ),
     );
     expect(player.processingState, equals(ProcessingState.ready));
-    expect(player.sequence!.length, equals(2));
+    expect(player.sequence.length, equals(2));
     expect(player.playing, equals(false));
     await player.setAudioSource(
       ConcatenatingAudioSource(
@@ -1042,11 +1256,11 @@ void runTests() {
       preload: false,
     );
     expect(player.processingState, equals(ProcessingState.idle));
-    expect(player.sequence!.length, equals(3));
+    expect(player.sequence.length, equals(3));
     expect(player.playing, equals(false));
     await player.load();
     expect(player.processingState, equals(ProcessingState.ready));
-    expect(player.sequence!.length, equals(3));
+    expect(player.sequence.length, equals(3));
     expect(player.playing, equals(false));
     await player.dispose();
   });
@@ -1551,7 +1765,10 @@ class MockAudioPlayer extends AudioPlayerPlatform {
 
   @override
   Future<LoadResponse> load(LoadRequest request) async {
-    final audioSource = request.audioSourceMessage;
+    final playlist =
+        request.audioSourceMessage as ConcatenatingAudioSourceMessage;
+    if (playlist.children.isEmpty) return LoadResponse(duration: null);
+    final audioSource = playlist.children.first;
     _processingState = ProcessingStateMessage.loading;
     _broadcastPlaybackEvent();
     if (audioSource is UriAudioSourceMessage) {