Repository: ryanheise/just_audio

Issue:
Pending timers error during flutter tests
<!--    ALL SECTIONS BELOW MUST BE COMPLETED   -->
**Which API doesn't behave as documented, and how does it misbehave?**
When an instance of `AudioPlayer()` is created the usual way, a !timersPending error is obtained during flutter test although _audioPlayeris properly disposed.

**Package Version**
`just_audio: ^0.10.0`

Note: It isn't reproducible till version `just_audio: ^0.9.46`.

**Minimal reproduction project**
The example

**To Reproduce**
Steps to reproduce the behavior:

Remove the `expect` method from widget_test.dart
Run flutter test

**Error messages**

```
Pending timers:
Timer (duration: 0:00:00.000000, periodic: false), created:
#0      new FakeTimer._ (package:fake_async/fake_async.dart:308:62)
#1      FakeAsync._createTimer (package:fake_async/fake_async.dart:252:27)
#2      FakeAsync.run.<anonymous closure> (package:fake_async/fake_async.dart:185:19)
#6      AudioPlayer._broadcastSequence (package:just_audio/just_audio.dart:975:11)
#7      AudioPlayer._load (package:just_audio/just_audio.dart:1005:13)
#8      AudioPlayer._setPlatformActive.setPlatform (package:just_audio/just_audio.dart:1744:32)
#9      AudioPlayer._setPlatformActive (package:just_audio/just_audio.dart:1757:17)
#10     new AudioPlayer (package:just_audio/just_audio.dart:348:5)
#11     _ExampleWidgetState.initState (file:///Users/xsahil03x/StudioProjects/stream-chat-flutter/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart:422:19)
#12     StatefulElement._firstBuild (package:flutter/src/widgets/framework.dart:5762:55)
#13     ComponentElement.mount (package:flutter/src/widgets/framework.dart:5607:5)
...     Normal element mounting (228 frames)
#241    Element.inflateWidget (package:flutter/src/widgets/framework.dart:4480:16)
#242    MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7049:36)
#243    MultiChildRenderObjectElement.mount (package:flutter/src/widgets/framework.dart:7061:32)
...     Normal element mounting (457 frames)
#700    Element.inflateWidget (package:flutter/src/widgets/framework.dart:4480:16)
#701    Element.updateChild (package:flutter/src/widgets/framework.dart:3957:20)
#702    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#703    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#704    ProxyElement.update (package:flutter/src/widgets/framework.dart:5960:5)
#705    _InheritedNotifierElement.update (package:flutter/src/widgets/inherited_notifier.dart:112:11)
#706    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#707    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#708    StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5794:11)
#709    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#710    StatefulElement.update (package:flutter/src/widgets/framework.dart:5817:5)
#711    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#712    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#713    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#714    ProxyElement.update (package:flutter/src/widgets/framework.dart:5960:5)
#715    _InheritedNotifierElement.update (package:flutter/src/widgets/inherited_notifier.dart:112:11)
#716    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#717    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#718    StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5794:11)
#719    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#720    StatefulElement.update (package:flutter/src/widgets/framework.dart:5817:5)
#721    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#722    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#723    StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5794:11)
#724    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#725    StatefulElement.update (package:flutter/src/widgets/framework.dart:5817:5)
#726    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#727    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#728    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#729    ProxyElement.update (package:flutter/src/widgets/framework.dart:5960:5)
#730    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#731    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#732    StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5794:11)
#733    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#734    StatefulElement.update (package:flutter/src/widgets/framework.dart:5817:5)
#735    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#736    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#737    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#738    ProxyElement.update (package:flutter/src/widgets/framework.dart:5960:5)
#739    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#740    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#741    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#742    ProxyElement.update (package:flutter/src/widgets/framework.dart:5960:5)
#743    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#744    _RawViewElement._updateChild (package:flutter/src/widgets/view.dart:465:16)
#745    _RawViewElement.update (package:flutter/src/widgets/view.dart:552:5)
#746    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#747    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#748    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#749    StatelessElement.update (package:flutter/src/widgets/framework.dart:5707:5)
#750    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#751    ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5656:16)
#752    StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:5794:11)
#753    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#754    StatefulElement.update (package:flutter/src/widgets/framework.dart:5817:5)
#755    Element.updateChild (package:flutter/src/widgets/framework.dart:3941:15)
#756    RootElement._rebuild (package:flutter/src/widgets/binding.dart:1636:16)
#757    RootElement.update (package:flutter/src/widgets/binding.dart:1614:5)
#758    RootElement.performRebuild (package:flutter/src/widgets/binding.dart:1628:7)
#759    Element.rebuild (package:flutter/src/widgets/framework.dart:5347:7)
#760    BuildScope._tryRebuild (package:flutter/src/widgets/framework.dart:2694:15)
#761    BuildScope._flushDirtyElements (package:flutter/src/widgets/framework.dart:2753:11)
#762    BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:3048:18)
#763    AutomatedTestWidgetsFlutterBinding.drawFrame (package:flutter_test/src/binding.dart:1428:19)
#764    RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:475:5)
#765    SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1397:15)
#766    SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1318:9)
#767    AutomatedTestWidgetsFlutterBinding.pump.<anonymous closure> (package:flutter_test/src/binding.dart:1283:9)
#770    TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#771    AutomatedTestWidgetsFlutterBinding.pump (package:flutter_test/src/binding.dart:1270:27)
#772    WidgetTester.pumpWidget.<anonymous closure> (package:flutter_test/src/widget_tester.dart:608:22)
#775    TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:74:41)
#776    WidgetTester.pumpWidget (package:flutter_test/src/widget_tester.dart:605:27)
#777    main.<anonymous closure>.<anonymous closure> (file:///Users/xsahil03x/StudioProjects/stream-chat-flutter/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart:261:22)
#778    testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:189:29)
<asynchronous suspension>
#779    TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1027:5)
<asynchronous suspension>
<asynchronous suspension>
(elided 8 frames from dart:async and package:stack_trace)

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown running a test:
A Timer is still pending even after the widget tree was disposed.
'package:flutter_test/src/binding.dart':
Failed assertion: line 1537 pos 12: '!timersPending'

When the exception was thrown, this was the stack:
#2      AutomatedTestWidgetsFlutterBinding._verifyInvariants (package:flutter_test/src/binding.dart:1537:12)
#3      TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1044:7)
<asynchronous suspension>
<asynchronous suspension>
(elided 3 frames from class _AssertionError and package:stack_trace)
```

**Expected behavior**
There should be no pending timers after the audio player was disposed.

**Flutter SDK version**
```
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.27.4, on macOS 15.3.1 24D70 darwin-arm64, locale en-IN)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.3)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.3)
[✓] IntelliJ IDEA Community Edition (version 2024.3.5)
[✓] Connected device (4 available)
    ! Error: Browsing on the local area network for Sahil’s iPhone. Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources

• No issues found!
```

**Additional context**
This is a regression of a previously solved issue: https://github.com/ryanheise/just_audio/issues/356 which started happening again in version `just_audio: ^0.10.0`. I tested till version `just_audio: ^0.9.46` and everything is working fine.

NPE on Android with just_audio 0.10.0
Within Crashlytics we see the following error being thrown on Android 11 (a Motorola device):

```
Fatal Exception: java.lang.NullPointerException
Attempt to invoke interface method 'boolean x0.z.x()' on a null object reference
com.ryanheise.just_audio.AudioPlayer.updatePositionIfChanged (AudioPlayer.java:257)
com.ryanheise.just_audio.AudioPlayer$ObserverRenderer.lambda$render$0 (AudioPlayer.java:1131)
android.os.Handler.handleCallback (Handler.java:938)
android.os.Handler.dispatchMessage (Handler.java:99)
android.os.Looper.loop (Looper.java:250)
android.app.ActivityThread.main (ActivityThread.java:7868)
java.lang.reflect.Method.invoke (Method.java)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:592)
com.android.internal.os.ZygoteInit.main (ZygoteInit.java:958)
```


Discussion: > **Package Version** `just_audio: ^0.10.0`
> 
> Note: It isn't reproducible till version `just_audio: ^0.9.46`.

Just to clarify your use of "till", do you mean that `0.9.46` also has the issue? (I am guessing you mean it isn't reproducible until `0.10.0`.)
No, it's passing in `0.9.46` but not in `0.10.0`.
Got it, so you mean it's reproducible until `0.10.0`.

I tested `0.9.46` and I think the reason it works is a bit of a fluke. There was a PR at the time of #356 which did make it work, but I recall when I reviewed it that it involved a logic change that did not seem correct. I merged anyway it since it seemed to at least resolve the testing issue, but it didn't really fix the underlying issue hence why it has resurfaced now after improving other aspects of the plugin.

The underlying issue is described here:

https://github.com/flutter/flutter/issues/146094

The widget being tested monitors `positionStream` which necessitates a timer. That timer is cancelled only when the widget is disposed, but the player's dispose method is async while the widget's dispose method returns void. So the testing framework can't properly wait for the player's dispose to complete.

I think that's a flaw in the design of the testing framework, but I will see if I can work around it.
Oops, it appears that your issue did not follow the template and is missing one or more required sections. Please open a new issue, and provide all required sections and information.

FAQ:

1. Do I really need to submit a minimal reproduction project for a bug? A: Yes. I prioritise bugs _secondarily_ on how many people are affected, and _primarily_ on whether the bug report is complete, in the sense that it enables me to immediately reproduce it and start working on a fix. If a bug is important to you, the best thing you can do is to provide all requested information ASAP so that I can start looking into it ASAP.

2. I think I supplied all required information, so did the bot make a mistake? A: The bot only checks the section headings, so when you post a new issue, make sure you leave the section headings intact. (Note that because of this, it is even possible to trick the bot by including only the section headings, and then not providing the requested information under each heading. This is frowned upon, and the issue will be closed manually.)
Reopened. This is a hot fix to `AudioPlayer.java`:

```java
    private boolean updatePositionIfChanged() {
        if (player == null) return false;  // <-- ADD THIS LINE
        if (!player.getPlayWhenReady() || processingState != ProcessingState.ready) {
            if (getCurrentPosition() == updatePosition) return false;
        }
        updatePosition = getCurrentPosition();
        updateTime = System.currentTimeMillis();
        return true;
    }

```

Fix patch:
diff --git a/just_audio/CHANGELOG.md b/just_audio/CHANGELOG.md
index fec1a6fa1..551d4bafe 100644
--- a/just_audio/CHANGELOG.md
+++ b/just_audio/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.10.3
+
+* Fix pending timers bug in unit tests.
+* Fix NPE in Android position broadcast.
+
 ## 0.10.2
 
 * Add Player.playerEvent.
diff --git a/just_audio/README.md b/just_audio/README.md
index b7b7ce938..00e88ba1a 100644
--- a/just_audio/README.md
+++ b/just_audio/README.md
@@ -277,6 +277,8 @@ dependencies {
 }
 ```
 
+Note: the Android Gradle Plugin (AGP) versions 8.6 and 8.7 contain a bug that affects ExoPlayer in release mode. To avoid this, either downgrade or upgrade your AGP version.
+
 ### iOS
 
 If you wish to connect to non-HTTPS URLs, or if you use a feature that depends on the proxy such as headers, caching or stream audio sources, add the following to your `Info.plist` file:
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 966397a6c..020aa17ba 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
@@ -254,6 +254,7 @@ public void onTracksChanged(Tracks tracks) {
     }
 
     private boolean updatePositionIfChanged() {
+        if (player == null) return false;
         if (!player.getPlayWhenReady() || processingState != ProcessingState.ready) {
             if (getCurrentPosition() == updatePosition) return false;
         }
diff --git a/just_audio/lib/just_audio.dart b/just_audio/lib/just_audio.dart
index 874406560..09d830df2 100644
--- a/just_audio/lib/just_audio.dart
+++ b/just_audio/lib/just_audio.dart
@@ -58,6 +58,7 @@ JustAudioPlatform get _pluginPlatform {
 class AudioPlayer {
   static String _generateId() => _uuid.v4();
   final _lock = Lock();
+  Future<void>? _playbackEventPipe;
 
   /// The user agent to set on all HTTP requests.
   final String? _userAgent;
@@ -263,7 +264,7 @@ class AudioPlayer {
       _automaticallyWaitsToMinimizeStalling = _audioLoadConfiguration!
           .darwinLoadControl!.automaticallyWaitsToMinimizeStalling;
     }
-    _playbackEventSubject.addStream(
+    _playbackEventPipe = _playbackEventSubject.addStream(
         playerEventStream.map((event) => event.playbackEvent).distinct());
     _playingSubject
         .addStream(playerEventStream.map((event) => event.playing).distinct());
@@ -468,20 +469,20 @@ class AudioPlayer {
   Duration? get duration => playbackEvent.duration;
 
   /// The duration of the current audio.
-  Stream<Duration?> get durationStream => _durationSubject.stream;
+  Stream<Duration?> get durationStream => _durationSubject.stream.distinct();
 
   /// The current [ProcessingState].
   ProcessingState get processingState => playbackEvent.processingState;
 
   /// A stream of [ProcessingState]s.
   Stream<ProcessingState> get processingStateStream =>
-      _processingStateSubject.stream;
+      _processingStateSubject.stream.distinct();
 
   /// Whether the player is playing.
   bool get playing => _playerEventSubject.nvalue!.playing;
 
   /// A stream of changing [playing] states.
-  Stream<bool> get playingStream => _playingSubject.stream;
+  Stream<bool> get playingStream => _playingSubject.stream.distinct();
 
   /// The current volume of the player.
   double get volume => _volumeSubject.nvalue!;
@@ -514,14 +515,15 @@ class AudioPlayer {
 
   /// A stream of buffered positions.
   Stream<Duration> get bufferedPositionStream =>
-      _bufferedPositionSubject.stream;
+      _bufferedPositionSubject.stream.distinct();
 
   /// The latest ICY metadata received through the audio source, or `null` if no
   /// metadata is available.
   IcyMetadata? get icyMetadata => playbackEvent.icyMetadata;
 
   /// A stream of ICY metadata received through the audio source.
-  Stream<IcyMetadata?> get icyMetadataStream => _icyMetadataSubject.stream;
+  Stream<IcyMetadata?> get icyMetadataStream =>
+      _icyMetadataSubject.stream.distinct();
 
   /// The current player state containing only the processing and playing
   /// states.
@@ -682,7 +684,8 @@ class AudioPlayer {
   /// See [createPositionStream] for more control over the stream parameters.
   Stream<Duration> get positionStream {
     if (_positionSubject == null) {
-      _positionSubject = BehaviorSubject<Duration>();
+      _positionSubject =
+          BehaviorSubject<Duration>(onCancel: () => _positionSubject = null);
       if (!_disposed) {
         _positionSubject!.addStream(createPositionStream(
             steps: 800,
@@ -722,13 +725,11 @@ class AudioPlayer {
     }
 
     Timer? currentTimer;
-    StreamSubscription<Duration?>? durationSubscription;
-    StreamSubscription<PlaybackEvent>? playbackEventSubscription;
+    StreamSubscription<PlayerEvent>? playerEventSubscription;
     void yieldPosition(Timer timer) {
       if (controller.isClosed || _durationSubject.isClosed) {
         timer.cancel();
-        durationSubscription?.cancel();
-        playbackEventSubscription?.cancel();
+        playerEventSubscription?.cancel();
         if (!controller.isClosed) {
           // This will in turn close _positionSubject.
           controller.close();
@@ -740,12 +741,12 @@ class AudioPlayer {
       }
     }
 
-    durationSubscription = durationStream.listen((duration) {
-      currentTimer?.cancel();
-      currentTimer = Timer.periodic(step(), yieldPosition);
-    }, onError: (Object e, StackTrace stackTrace) {});
-    playbackEventSubscription = playbackEventStream.listen((event) {
+    playerEventSubscription = playerEventStream.listen((event) {
       controller.add(position);
+      currentTimer?.cancel();
+      if (playing) {
+        currentTimer = Timer.periodic(step(), yieldPosition);
+      }
     });
     return controller.stream.distinct();
   }
@@ -971,7 +972,7 @@ class AudioPlayer {
       _shuffleIndicesInv[shuffleIndices[i]] = i;
     }
     // Allow this event to propagate to derived streams.
-    await Future<void>.delayed(Duration.zero);
+    await currentIndexStream.firstWhere((i) => i == sequenceState.currentIndex);
   }
 
   void _registerAudioSource(AudioSource source) {
@@ -1132,7 +1133,7 @@ class AudioPlayer {
       ),
     ));
     // Allow propagation to secondary streams.
-    await Future<void>.delayed(Duration.zero);
+    await playingStream.firstWhere((p) => p == playing);
     // TODO: perhaps modify platform side to ensure new state is broadcast
     // before this method returns.
     await (await _platform).pause(PauseRequest());
@@ -1430,7 +1431,8 @@ class AudioPlayer {
 
       await _playerEventSubject.close();
 
-      await Future<void>.delayed(Duration.zero);
+      await _playbackEventPipe;
+
       await _playbackEventSubject.close();
       await _sequenceStateSubject.close();
       await _playingSubject.close();
@@ -1438,7 +1440,6 @@ class AudioPlayer {
       await _speedSubject.close();
       await _pitchSubject.close();
 
-      await Future<void>.delayed(Duration.zero);
       await _durationSubject.close();
       await _processingStateSubject.close();
       await _bufferedPositionSubject.close();
diff --git a/just_audio/pubspec.yaml b/just_audio/pubspec.yaml
index 8ad1d1711..ced04a69c 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 sequence any sound from any source (asset/file/URL/stream) in gapless playlists.
-version: 0.10.2
+version: 0.10.3
 repository: https://github.com/ryanheise/just_audio/tree/minor/just_audio
 issue_tracker: https://github.com/ryanheise/just_audio/issues
 topics:


Test patch:
diff --git a/just_audio/example/test/widget_test.dart b/just_audio/example/test/widget_test.dart
index 683f5161d..ed9e1795f 100644
--- a/just_audio/example/test/widget_test.dart
+++ b/just_audio/example/test/widget_test.dart
@@ -8,7 +8,7 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 
-import 'package:just_audio_example/main.dart';
+import 'package:just_audio_example/example_playlist.dart';
 
 void main() {
   testWidgets('Verify Playlist', (WidgetTester tester) async {