Skip to content

Stop streamed songs from wedging the player on weak connections - #209

Merged
phanan merged 2 commits into
masterfrom
fix/stuck-song-loading
Aug 6, 2026
Merged

Stop streamed songs from wedging the player on weak connections#209
phanan merged 2 commits into
masterfrom
fix/stuck-song-loading

Conversation

@phanan

@phanan phanan commented Aug 6, 2026

Copy link
Copy Markdown
Member

Streamed playback gets stuck on "loading" indefinitely on weak or unstable connections, and stays stuck: restoring the connection doesn't help, and skipping to the next song hangs the same way. Only downloaded songs still play. Reported against 2.2.8/2.2.9.

Cause

#156 replaced setUrl with LockCachingAudioSource, which routes streaming through just_audio's loopback proxy and a Dart HttpClient that sets neither a connection nor a read timeout. When a socket stalls mid-response:

  • the proxy's byte-range request stays parked in _requests, so no HTTP response ever reaches the platform player;
  • AudioPlayer._load sits on await processingStateStream.firstWhere((state) => state != loading), so setAudioSource neither completes nor throws;
  • _playAtIndex's catch therefore never runs, and every subsequent track hangs identically.

Downloaded songs take the setFilePath branch, never touch the network, and were unaffected — which is what made the failure look selective.

Fix

Go back to setUrl so the platform player does its own networking; AVPlayer and ExoPlayer have real timeouts, stall detection and range-request retry, none of which the proxy path provides.

Bound the load regardless of source, so a failure leaves a state the user can act on:

  • give up after sourceLoadTimeout (30s);
  • _player.stop() — this also aborts just_audio's pending _load, which otherwise stays parked;
  • report AudioProcessingState.error, so the mini player shows a warning icon instead of spinning forever;
  • play() restarts the current item rather than resuming a player that holds no source.

Both player stream subscriptions now handle errors instead of leaking them into the zone.

On failure, skip or hold?

Depends on what failed, so the two are split:

  • The song — a 404, a corrupt file, a codec the device won't decode. Says nothing about the rest of the queue, so skip past it; stopping is just an obstacle.
  • The connection — a stalled load, surfacing as TimeoutException. The next song stalls identically at 30s a time, and each skip costs the user their place in the queue. Hold position and report the error.

MAX_ERROR_COUNT caps consecutive failures either way. It existed but was never read; lowered from 10 to 3, since a run that long is systematic and skipping further only buries the cause. A song that plays clears the count.

Tests

test/audio_handler_test.dart (new, needs the player injectable) covers streamed vs. downloaded source selection, a source that never becomes playable, a source that throws, skip-vs-hold per error type, the consecutive-failure cap and its reset, the end of the queue, play-as-retry, and a player-emitted error. test/ui/widgets/mini_player_test.dart (new) covers the error icon vs. spinner vs. neither.

Summary by CodeRabbit

  • New Features

    • Added configurable playback source timeouts.
    • Playback failures now appear as clear error states, with automatic recovery and retry support.
    • Queue playback can skip unavailable tracks and continue when appropriate.
    • The mini player displays an error icon when playback fails and a loading indicator while content loads.
  • Bug Fixes

    • Improved handling of stalled, failed, and player-reported playback errors.
    • Reduced repeated failures before playback is abandoned.
  • Tests

    • Added comprehensive coverage for playback recovery, queue behavior, timeouts, and mini-player states.

LockCachingAudioSource (#156, shipped in 2.2.8) routes streaming through
just_audio's loopback proxy and a Dart HttpClient that sets neither a
connection nor a read timeout. A stalled socket parks the proxy's byte-range
request forever, so no HTTP response ever reaches the platform player, and
`setAudioSource` neither completes nor throws. `_playAtIndex`'s catch never
runs, and every subsequent track hangs the same way — only downloaded songs,
which skip the network, still play.

Go back to `setUrl` so the platform player does its own networking, and bound
the load so any failure leaves a state the user can retry or skip out of:
give up after `sourceLoadTimeout`, stop the player, and report
AudioProcessingState.error. Play then restarts the current item rather than
resuming a player that holds no source.

Both player stream subscriptions now handle errors instead of leaking them
into the zone.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20448275-9a07-486f-8dcb-bfeb55eaced4

📥 Commits

Reviewing files that changed from the base of the PR and between b15277f and 43381c3.

📒 Files selected for processing (11)
  • lib/audio_handler.dart
  • lib/ui/widgets/mini_player.dart
  • test/audio_handler_test.dart
  • test/audio_handler_test.mocks.dart
  • test/ui/screens/album_action_sheet_test.mocks.dart
  • test/ui/screens/artist_action_sheet_test.mocks.dart
  • test/ui/screens/downloaded_test.mocks.dart
  • test/ui/screens/playable_action_sheet_test.mocks.dart
  • test/ui/screens/podcast_action_sheet_test.mocks.dart
  • test/ui/widgets/mini_player_test.dart
  • test/ui/widgets/mini_player_test.mocks.dart
🚧 Files skipped from review as they are similar to previous changes (10)
  • test/ui/widgets/mini_player_test.dart
  • lib/ui/widgets/mini_player.dart
  • test/ui/screens/podcast_action_sheet_test.mocks.dart
  • test/ui/screens/playable_action_sheet_test.mocks.dart
  • test/audio_handler_test.dart
  • test/ui/widgets/mini_player_test.mocks.dart
  • test/ui/screens/album_action_sheet_test.mocks.dart
  • test/audio_handler_test.mocks.dart
  • lib/audio_handler.dart
  • test/ui/screens/downloaded_test.mocks.dart

📝 Walkthrough

Walkthrough

KoelAudioHandler now supports configurable source-load timeouts, injected players, playback-error states, retries, and queue recovery. MiniPlayer renders playback errors. Tests and generated Mockito mocks cover the updated audio and UI behavior.

Changes

Playback error recovery

Layer / File(s) Summary
Audio handler lifecycle and recovery
lib/audio_handler.dart
KoelAudioHandler uses setUrl, applies source-load timeouts, abandons failed sources, retries failed items, and limits consecutive failures to three.
MiniPlayer playback feedback
lib/ui/widgets/mini_player.dart
MiniPlayer displays a keyed warning icon for playback errors and retains the loading spinner for loading states.
Audio handler validation and mocks
test/audio_handler_test.dart, test/audio_handler_test.mocks.dart
Tests cover source selection, timeouts, queue skipping, retries, failure limits, recovery, and player errors. Generated mocks provide audio and provider doubles.
MiniPlayer validation and generated contract updates
test/ui/widgets/mini_player_test.dart, test/ui/widgets/mini_player_test.mocks.dart, test/ui/screens/*.mocks.dart
Widget tests cover error, loading, and ready states. Generated mocks include the new sourceLoadTimeout property and updated fake types.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MiniPlayer
  participant KoelAudioHandler
  participant AudioPlayer
  User->>MiniPlayer: press play
  MiniPlayer->>KoelAudioHandler: play current queue item
  KoelAudioHandler->>AudioPlayer: load source with timeout
  AudioPlayer-->>KoelAudioHandler: playback error or timeout
  KoelAudioHandler->>AudioPlayer: stop failed source
  KoelAudioHandler-->>MiniPlayer: emit error processing state
  MiniPlayer-->>User: show warning icon
Loading

Possibly related PRs

  • koel/player#156: Both PRs modify KoelAudioHandler source-loading behavior and the streamed-media setUrl path.
  • koel/player#190: The album action sheet uses the updated KoelAudioHandler queue and playback behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary fix for streamed playback hanging during weak network conditions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stuck-song-loading

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
lib/audio_handler.dart (2)

103-109: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider reusing _abandonSource for player-emitted errors.

The onError handler sets _playbackFailed and emits state, but it leaves the player running. _abandonSource performs the same two steps and also calls _player.stop(). A player-emitted error leaves the platform player in an undefined state, so stopping it makes the retry path in play() start from a clean state.

♻️ Proposed change
     _player.playbackEventStream.listen(
       (_) => _emitPlaybackState(),
-      onError: (_, __) {
-        _playbackFailed = true;
-        _emitPlaybackState();
-      },
+      onError: (_, __) => _abandonSource(),
     );

Note: _abandonSource returns a Future, so confirm that the unawaited call does not trip your lint rules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/audio_handler.dart` around lines 103 - 109, Update the
playbackEventStream error handler to invoke _abandonSource so player-emitted
errors also stop the player and mark playback as failed. Handle its returned
Future in the project’s accepted unawaited manner, and avoid duplicating the
existing failure-state updates.

369-372: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused error-count state.

_errorCount is incremented and reset in _playAtIndex, but MAX_ERROR_COUNT and _errorCount are never read for auto-advance logic or another branch. Keep either the count consumer or the declarations; do not keep dead state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/audio_handler.dart` around lines 369 - 372, Remove the unused error-count
state by deleting _errorCount and MAX_ERROR_COUNT, along with their
increment/reset logic in _playAtIndex and the catch block. Preserve the existing
_abandonSource behavior and other playback error handling.
test/audio_handler_test.dart (1)

103-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a test that a failed load does not advance the queue.

The PR description states that the change avoids automatic queue advancement after a failure. No test asserts that. A short assertion here would lock in that behavior.

💚 Proposed addition
test('does not advance the queue after a failed load', () async {
  final failing = registerSong();
  final next = registerSong();
  when(player.setUrl(failing.sourceUrl))
      .thenThrow(Exception('no route to host'));

  await handler.replaceQueue([failing, next]);

  verifyNever(player.setUrl(next.sourceUrl));
  expect(handler.currentQueueIndex, 0);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/audio_handler_test.dart` around lines 103 - 121, Add a test alongside
the failed-load tests verifying that when the first song in a queue fails during
player.setUrl, the handler does not load the next song and keeps
currentQueueIndex at 0. Use distinct failing and subsequent songs, stub the
failure for the first source, and assert player.setUrl was never called with the
next source.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/audio_handler.dart`:
- Around line 356-360: Update the playback flow around
_setPlayerSource(mediaItem).timeout(sourceLoadTimeout) to track and validate the
current load generation after the await, preventing interrupted or stale loads
from continuing. When the generation is no longer current, await _player.stop()
before returning or taking the abandoned-source path; only call exitRadioMode,
seek, play, and _emitPlaybackState for the active generation.

In `@lib/ui/widgets/mini_player.dart`:
- Around line 251-260: Add a descriptive semanticLabel to the Icon identified by
MiniPlayer.playbackErrorIconKey in the hasFailed overlay, so screen readers
announce that playback failed while preserving the existing visual icon and
layout.

---

Nitpick comments:
In `@lib/audio_handler.dart`:
- Around line 103-109: Update the playbackEventStream error handler to invoke
_abandonSource so player-emitted errors also stop the player and mark playback
as failed. Handle its returned Future in the project’s accepted unawaited
manner, and avoid duplicating the existing failure-state updates.
- Around line 369-372: Remove the unused error-count state by deleting
_errorCount and MAX_ERROR_COUNT, along with their increment/reset logic in
_playAtIndex and the catch block. Preserve the existing _abandonSource behavior
and other playback error handling.

In `@test/audio_handler_test.dart`:
- Around line 103-121: Add a test alongside the failed-load tests verifying that
when the first song in a queue fails during player.setUrl, the handler does not
load the next song and keeps currentQueueIndex at 0. Use distinct failing and
subsequent songs, stub the failure for the first source, and assert
player.setUrl was never called with the next source.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e268f449-3b54-4cf4-bf9b-2ee8e0c7a608

📥 Commits

Reviewing files that changed from the base of the PR and between b15277f and f2c7671.

📒 Files selected for processing (11)
  • lib/audio_handler.dart
  • lib/ui/widgets/mini_player.dart
  • test/audio_handler_test.dart
  • test/audio_handler_test.mocks.dart
  • test/ui/screens/album_action_sheet_test.mocks.dart
  • test/ui/screens/artist_action_sheet_test.mocks.dart
  • test/ui/screens/downloaded_test.mocks.dart
  • test/ui/screens/playable_action_sheet_test.mocks.dart
  • test/ui/screens/podcast_action_sheet_test.mocks.dart
  • test/ui/widgets/mini_player_test.dart
  • test/ui/widgets/mini_player_test.mocks.dart

Comment thread lib/audio_handler.dart
Comment on lines +356 to +360
await _setPlayerSource(mediaItem).timeout(sourceLoadTimeout);
exitRadioMode();
_player.seek(Duration(seconds: position.toInt()));
await play();
_emitPlaybackState();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🌐 Web query:

just_audio setUrl call not cancelled by stop, late completion replaces current audio source

💡 Result:

In just_audio, calling setUrl is an asynchronous operation that initiates a load process [1][2]. If a previous call to setUrl or load is still in progress when a new one is initiated, or if the player is stopped, the original pending operation may be interrupted [3][1]. When an interruption occurs, such as calling stop or initiating a new load while a previous setUrl is still loading, the original call will throw a PlayerInterruptedException [1]. This is the documented behavior in just_audio to indicate that the operation was aborted because the player state changed (e.g., the player was stopped or a new source was loaded) before the original source could complete its loading process [1]. If you are experiencing "late completion" where a previous source seems to "replace" the current one, it is often due to not properly awaiting the setUrl operation or not handling the resulting PlayerInterruptedException [4][1]. To resolve this: 1. Always await the setUrl or setAudioSource call to ensure the operation completes or throws an exception before proceeding to the next step [4][1]. 2. Wrap your loading calls in a try-catch block to specifically handle PlayerInterruptedException [1]. This allows your application to gracefully ignore the error when you intentionally trigger an interruption (such as a user cancelling a selection) [1]. 3. If you are managing rapid user interactions, ensure that you track the current active load request or use a state-management pattern that prevents outdated "in-flight" requests from overriding the intended current state once they eventually complete [1]. The library has undergone numerous fixes over time to ensure that stop and setUrl behave predictably across platforms, including ensuring that platform-side events are properly synchronized with the Flutter side to avoid race conditions [5][6][7]. If you are on an older version of just_audio, updating to the latest version is strongly recommended [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)(pubspec\.yaml|pubspec\.lock|audio_handler\.dart|.*audio.*test.*|.*audio.*test.*\.dart|.*test.*audio.*\.dart)$' || true

echo
echo "audio_handler outline:"
ast-grep outline lib/audio_handler.dart --view compact || true

echo
echo "Relevant audio_handler lines:"
sed -n '320,400p' lib/audio_handler.dart | cat -n

echo
echo "Search just_audio dependency:"
rg -n "just_audio|audio_player|playback_state|sourceLoadTimeout|_setPlayerSource|_abandonSource|setAudioSource|setUrl|stop\(" pubspec.yaml pubspec.lock lib test -S

Repository: koel/player

Length of output: 11288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "audio_handler relevant methods:"
sed -n '176,265p' lib/audio_handler.dart | cat -n

echo
echo "stalled source test:"
sed -n '96,138p' test/audio_handler_test.dart | cat -n

echo
echo "search for load generation/RequestTracker/PlayerInterruptedException handling:"
rg -n "PlayerInterruptedException|generation|request|load.*counter|stale|_setPlayerSource|setAudioSource|setUrl|stop\(\)" lib test pubspec.yaml -S

Repository: koel/player

Length of output: 25882


🌐 Web query:

just_audio 0.9.32 setUrl stop PlayerInterruptedException pending future completes after stop

💡 Result:

In just_audio, a PlayerInterruptedException occurs when a pending operation—such as setUrl—is interrupted by a subsequent, conflicting operation on the same player instance [1][2][3]. If you call setUrl and then immediately call stop before the setUrl future has completed, the original setUrl future will complete with a PlayerInterruptedException [4][2][3]. This exception indicates that the loading process was aborted because another operation (in this case, stop) took precedence [2][3]. To handle this behavior correctly, you should use a try-catch block to specifically handle PlayerInterruptedException when awaiting operations like setUrl [3]: try { await player.setUrl('https://example.com/audio.mp3'); } on PlayerInterruptedException catch (e) { // Handle the interruption (e.g., the player was stopped or another // setUrl call occurred before this one finished loading). print("Loading was interrupted: ${e.message}"); } The best practice to avoid unnecessary interruptions is to always await your player methods (like setUrl) before initiating a new one [5][6]. If your application logic requires starting a new audio source while one is currently loading or playing, ensure you have managed your player state or instances appropriately, as just_audio is designed to handle one primary sequence of operations per instance [7][8][6]. If you need to play multiple sounds simultaneously, the recommended approach is to use multiple AudioPlayer instances rather than interrupting a single one [8].

Citations:


🌐 Web query:

just_audio 0.9.32 setUrl pending stop setUrl no PlayerInterruptedException

💡 Result:

The PlayerInterruptedException is an intended behavior in just_audio designed to signal that an operation (like setUrl) was aborted because the player state changed—such as when the player is stopped, disposed, or another audio source is loaded while a previous load was still in progress [1][2]. If you encounter this exception, it generally means your application code is attempting to change the player's state (e.g., calling stop or loading a new URL) while a previous setUrl operation is still awaiting completion [1][3]. To handle or avoid this: 1. Catch the exception: You should wrap your setUrl calls in a try-catch block to specifically handle PlayerInterruptedException. This allows your app to gracefully ignore the error if it is expected behavior [1][2][3]. try { await player.setUrl("https://your-url.com"); } on PlayerInterruptedException { // Ignore this exception as it signifies an intentional interruption } catch (e) { // Handle other actual errors } 2. Ensure proper awaiting: Ensure you are correctly awaiting your setUrl and stop operations before triggering subsequent state changes. Failing to await an asynchronous stop call before initiating a new load is a common cause of this exception [1][4]. 3. State management: If your player is controlled by UI elements that can be triggered rapidly (like tabs or buttons), ensure that your logic is synchronized so that redundant or conflicting commands are not sent to the player simultaneously [4][3]. The version 0.9.32 of just_audio includes specific fixes related to hang issues when stopping while loading and handling pending timers, which may improve stability regarding these interruptions [5][6][7]. However, the core behavior of throwing PlayerInterruptedException remains the standard mechanism for managing interrupted loading sequences [1][2].

Citations:


🌐 Web query:

just_audio 0.9.32 Future.timeout setUrl pending call stopped while still running

💡 Result:

When using just_audio, a Future such as setUrl can be interrupted if a new audio source is loaded, or if the player is stopped or disposed before the initial operation completes [1][2]. If you encounter a situation where an asynchronous operation like setUrl is cancelled or reports an interruption, it typically results in a PlayerInterruptedException [1]. Technical details regarding this behavior include: 1. Handling Interruptions: The library is designed to allow operations to be superseded. If you call setUrl and then immediately call stop or load a different URL, the previous pending operation will be aborted as it is no longer relevant to the current player state [1]. 2. Exception Handling: You should handle these cases explicitly using a try-catch block specifically for PlayerInterruptedException [1][2]. This distinguishes intentional interruptions (e.g., user navigated away, playback was stopped) from network errors or platform-level timeouts [1]. 3. Network Timeouts: If the "pending call stopped" behavior is associated with an error message like "The request timed out" (e.g., error -1001 on iOS), this is often a network-related issue rather than a code-level interruption [3][4]. This occurs when the underlying platform player (AVPlayer on iOS or ExoPlayer on Android) fails to fetch the media within the platform's default timeout period [3]. 4. Event Lag: There is a known, slight lag between when a method like setUrl is called in Dart and when the platform side updates the player state [5]. During this transition period, querying the playback state might return unexpected values (such as 'connecting' or 'idle') [5]. To resolve or debug this: - Ensure you are wrapping your load operations in try-catch blocks to catch PlayerInterruptedException specifically [1]. - If you suspect a network timeout, check your network environment, especially if dealing with large files or slow connections [3]. - If you are experiencing this when switching tracks, verify that your logic does not implicitly call stop or clear the player state before the next source has successfully initialized [4].

Citations:


🌐 Web query:

"just_audio" "PlayerInterruptedException" documentation version 0.9.32

💡 Result:

In the just_audio package, PlayerInterruptedException is an exception class that indicates an operation on the audio player was interrupted by another simultaneous operation (such as loading a new audio source before the previous one finished) [1][2][3]. Documentation and usage patterns for this exception, consistent with version 0.9.32 and other versions, recommend handling it within a try-catch block when calling asynchronous methods like setUrl or load [4][5][6]. Example usage for catching this exception: try { await player.setUrl("https://example.com/audio.mp3"); } on PlayerInterruptedException catch (e) { // This call was interrupted since another audio source was loaded or the // player was stopped or disposed before this audio source could complete // loading. print("Connection aborted: ${e.message}"); } catch (e) { // Fallback for all other errors print('An error occured: $e'); } The changelog for version 0.9.32 focuses on fixes related to tag parameters in AudioSource and HLS nested URIs [7][8]; it does not introduce or fundamentally change the PlayerInterruptedException class itself, which remains a standard part of the library's error handling API [2][3].

Citations:


🌐 Web query:

just_audio 0.9.32 changelog stop while loading PlayerInterruptedException

💡 Result:

The PlayerInterruptedException in just_audio is not a bug, but rather the documented behavior of the library when an ongoing operation (such as loading or playing an audio source) is interrupted by another operation (like a new load request, a stop command, or disposing of the player) before the original operation completes [1][2][3]. Regarding version 0.9.32 specifically, the changelog entries focus on fixes for tag parameters in asset/file audio sources and nested HLS URIs [4][5]. There is no changelog entry for this version indicating a change to how PlayerInterruptedException is handled. The best practice for managing this exception is to catch it explicitly in your code when performing operations that might be interrupted [1][3]. The official documentation provides the following pattern for handling this: try { await player.setUrl("https://example.com/audio.mp3"); } on PlayerInterruptedException catch (e) { // Handle the interruption (e.g., ignore it if it was expected) print("Connection aborted: ${e.message}"); } catch (e) { // Handle other types of errors print('An error occurred: $e'); } If you encounter this exception frequently, it is often due to asynchronous code triggering multiple load or play commands simultaneously [6]. Ensuring you await your player operations properly and managing state to avoid overlapping commands can often resolve or mitigate the issue [3].

Citations:


🌐 Web query:

"just_audio" "setUrl" "no longer running" "PlayerInterruptedException" 2021

💡 Result:

The PlayerInterruptedException is not a bug that was removed; rather, it is the intended behavior in the just_audio package when a loading operation (such as setUrl) is interrupted by another action [1][2]. Around 2021, the package was updated to formalize this exception handling [3][4]. Specifically, in version 0.10.1, the library was updated to explicitly throw PlayerInterruptedException instead of a generic PlatformException [3] when an operation is cancelled (for example, if a new audio source is loaded or the player is stopped before the previous one completes loading) [1][2][5]. To resolve issues where this exception disrupts your application flow, you should wrap your loading calls in a try-catch block and specifically catch the PlayerInterruptedException [1][6][2]: try { await player.setUrl("https://example.com/audio.mp3"); } on PlayerInterruptedException catch (e) { // This exception occurs when the operation is interrupted. // You can safely ignore it if your app logic handles the interruption. print("Connection aborted: ${e.message}"); } catch (e) { // Handle other unexpected errors print("An error occurred: $e"); } If you continue to see this error frequently, ensure that you are properly awaiting your asynchronous calls (e.g., await player.setUrl(...)) [1][6] and check if your UI logic is triggering multiple conflicting playback commands simultaneously (such as rapid tab switching or repeated play/stop calls) [6][7].

Citations:


🌐 Web query:

"just_audio" "setUrl" "stopped" "PlayerInterruptedException" "pending" "never completed"

💡 Result:

The PlayerInterruptedException in the just_audio Flutter package is not a bug, but rather intended behavior indicating that an audio loading operation was interrupted before it could complete [1][2]. This occurs when a new load request (e.g., calling setUrl or setAudioSource) is initiated while a previous load request is still in progress, or if the player is stopped or disposed during the loading process [3][4][2]. To handle this exception, you should wrap your audio loading calls in a try-catch block and specifically catch PlayerInterruptedException [3][5]. Example implementation: try { await player.setUrl("https://example.com/audio.mp3"); } on PlayerInterruptedException catch (e) { // Handle the interruption (e.g., log it or simply ignore it) print("Connection aborted: ${e.message}"); } catch (e) { // Handle other types of errors print('An error occurred: $e'); } Recommendations to avoid or manage this: 1. Ensure proper awaiting: Always use the await keyword when calling methods like setUrl or load to ensure operations complete before moving to the next step [6][1]. 2. Avoid rapid load requests: If your app logic triggers multiple play requests in rapid succession (e.g., during UI transitions or tab switching), check the player state or implement logic to prevent redundant load requests [7][6][2]. 3. Graceful handling: Since this exception is often a natural consequence of user interaction (like changing a screen or clicking play on a new file before the previous one finished loading), catching it and suppressing it is the standard, documented approach [1][2]. If the player appears to be stuck in a pending or loading state despite your attempts to handle it, ensure your code is not creating a race condition where multiple load commands are fighting for the player's internal state [2][8].

Citations:


🌐 Web query:

"just_audio" "0.9.32" "setUrl" "completed" "PlayerInterruptedException" "setUrl" "completed successfully" "pending"

💡 Result:

The PlayerInterruptedException in the just_audio package occurs when a player operation (such as setUrl, load, or play) is interrupted by another simultaneous operation, such as the player being stopped, disposed, or a new audio source being loaded before the previous one finished loading [1][2][3]. In just_audio version 0.9.32 and later, this exception is specifically used to signal that a loading operation was aborted because the state of the player changed (e.g., a new source was requested) [4][2]. To handle this exception, you should wrap your audio loading and playback calls in a try-catch block and specifically catch PlayerInterruptedException [1][3]. This allows your application to gracefully handle cases where, for example, a user rapidly navigates between screens or triggers multiple play requests in quick succession [5][6]. Example of proper handling: try { await player.setUrl("https://your-audio-url.mp3"); } on PlayerInterruptedException catch (e) { // This call was interrupted; log it or ignore it if expected print("Connection aborted: ${e.message}"); } catch (e) { // Handle other types of errors print('An error occurred: $e'); } If you encounter this exception frequently, it is often a sign of asynchronous race conditions in your code where multiple calls to player methods are overlapping [6]. Ensure that your logic properly awaits previous operations or uses state checks (e.g., checking the player's processing state) before initiating new ones [5][6].

Citations:


🌐 Web query:

"just_audio" "0.9.32" "Futures" "timeout" "isCompleted" "PlayerInterruptedException"

💡 Result:

In just_audio, a PlayerInterruptedException occurs when a pending operation on the audio player is interrupted by another simultaneous operation, such as loading a new audio source, stopping the player, or disposing of it before the original operation could complete [1][2][3]. In version 0.9.32, this behavior is a standard part of the library's error handling design, where asynchronous operations like load are explicitly aborted if the state changes before they finish [4][3]. How to Handle PlayerInterruptedException: The recommended way to deal with this error is to catch the exception specifically using a try-catch block around your audio operations [2][5]. This is particularly necessary if you are triggering audio loads based on UI interactions (like switching tabs or rapid button clicks) where a previous operation might still be in progress [6]. Example implementation: try { await player.setUrl("https://your-audio-url.mp3"); await player.load; } on PlayerInterruptedException catch (e) { // This call was interrupted because another audio source was loaded, // the player was stopped, or disposed of. // You can safely ignore this or log it for debugging purposes. print("Operation interrupted: ${e.message}"); } catch (e) { // Handle other types of errors print("An unexpected error occurred: $e"); } Common Scenarios for Interruption: 1. UI Transitions: If you trigger an audio load inside a tab change or navigation event, the previous load might still be executing, causing the library to interrupt it to prioritize the new, current state [6]. 2. Rapid Sequential Calls: If you call load or setUrl multiple times in rapid succession, only the latest call is guaranteed to complete, while previous ones will throw PlayerInterruptedException [7][3]. If you are experiencing timeouts (e.g., "The request timed out") in addition to interruptions, note that these are separate from PlayerInterruptedException and typically relate to network conditions [8][9]. While Dart's Future.timeout can be used to limit how long you wait for a player operation, it does not prevent the underlying interruption exception [10]. If you are facing persistent issues with this exception in version 0.9.32, it is often a sign of race conditions in your app's state management. Ensure that your audio player logic is properly synchronized and that you are not initiating multiple conflicting load operations simultaneously [7][11].

Citations:


Handle or guard _setPlayerSource interruptions before using sourceLoadTimeout.

Future.timeout(...) only drops the await at Dart; it does not change the player state. In this flow, a stalled setUrl can leave _setPlayerSource awaiting while other state has moved on, and the surrounding catch turns interruption into the generic abandoned-source path. Apply a load generation guard when awaiting the future, then await _player.stop() before proceeding.
[low_effort_or_unknown]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/audio_handler.dart` around lines 356 - 360, Update the playback flow
around _setPlayerSource(mediaItem).timeout(sourceLoadTimeout) to track and
validate the current load generation after the await, preventing interrupted or
stale loads from continuing. When the generation is no longer current, await
_player.stop() before returning or taking the abandoned-source path; only call
exitRadioMode, seek, play, and _emitPlaybackState for the active generation.

Comment thread lib/ui/widgets/mini_player.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/audio_handler.dart (2)

362-363: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Await the restored-position seek before starting playback.

AudioPlayer.seek(Duration) is asynchronous; call await _player.seek(...) so _playAtIndex() starts after the restored position has been applied.

Suggested ordering
-      _player.seek(Duration(seconds: position.toInt()));
+      await _player.seek(Duration(seconds: position.toInt()));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/audio_handler.dart` around lines 362 - 363, Update the restored-position
flow in _playAtIndex() to await _player.seek(...) before calling play(),
ensuring playback starts only after the seek completes.

Source: MCP tools


106-114: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Subscribe to errorStream for player failures.

playbackEventStream carries PlaybackEvent values for playback state; it is not the documented path for PlayerException player errors. AudioPlayer.errorStream is the recommended source for decoder/network failures, and those should set _playbackFailed and emit the error state. Add a test case that emits an error through the mocked errorStream.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/audio_handler.dart` around lines 106 - 114, Update
_subscribeToPlayerPlaybackEvents to listen to AudioPlayer.errorStream for
PlayerException failures instead of relying on playbackEventStream's onError
callback; set _playbackFailed and call _emitPlaybackState when an error is
received. Add a test using the mocked errorStream to verify the failure state is
emitted.

Source: MCP tools

🧹 Nitpick comments (1)
lib/audio_handler.dart (1)

108-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use descriptive callback and catch parameter names.

The stream callbacks use _ and __. The restoration catch uses e. Rename them to names such as streamError, stackTrace, and restoreError.

As per coding guidelines, do not use single-letter variable names except i, j, and h; use names that encode intent.

Also applies to: 175-177, 202-202

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/audio_handler.dart` around lines 108 - 112, Rename the stream error
callback parameters in _emitPlaybackState-related handling from _ and __ to
descriptive names such as streamError and stackTrace, and rename the restoration
catch parameter from e to restoreError. Apply these naming changes consistently
at the referenced callback and catch sites without changing behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ios/Runner/Info.plist`:
- Around line 29-32: Update the LSApplicationQueriesSchemes array in the plist
to use the scheme name https without the trailing colon, replacing the current
https: entry.

In `@lib/audio_handler.dart`:
- Around line 373-378: Update the playback flow around _playAtIndex so
_errorCount is reset immediately after the track becomes ready and before
awaiting play(). Ensure every play() attempt resets the counter, including
attempts that fail, while preserving the existing _errorCount++ handling for
skipped attempts and error recovery.

In `@lib/main.dart`:
- Line 98: Update the KoelAudioHandler construction in the builder to use the
documented production sourceLoadTimeout of 30 seconds instead of the current
5-second override, preserving the handler’s intended slow-connection behavior.

---

Outside diff comments:
In `@lib/audio_handler.dart`:
- Around line 362-363: Update the restored-position flow in _playAtIndex() to
await _player.seek(...) before calling play(), ensuring playback starts only
after the seek completes.
- Around line 106-114: Update _subscribeToPlayerPlaybackEvents to listen to
AudioPlayer.errorStream for PlayerException failures instead of relying on
playbackEventStream's onError callback; set _playbackFailed and call
_emitPlaybackState when an error is received. Add a test using the mocked
errorStream to verify the failure state is emitted.

---

Nitpick comments:
In `@lib/audio_handler.dart`:
- Around line 108-112: Rename the stream error callback parameters in
_emitPlaybackState-related handling from _ and __ to descriptive names such as
streamError and stackTrace, and rename the restoration catch parameter from e to
restoreError. Apply these naming changes consistently at the referenced callback
and catch sites without changing behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4fa033d6-5d1f-4fc8-a5ce-0f5b8cda3ec6

📥 Commits

Reviewing files that changed from the base of the PR and between f2c7671 and 863f7fd.

⛔ Files ignored due to path filters (1)
  • ios/Podfile.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • ios/Flutter/AppFrameworkInfo.plist
  • ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  • ios/Runner/AppDelegate.swift
  • ios/Runner/Info.plist
  • lib/audio_handler.dart
  • lib/main.dart
  • test/audio_handler_test.dart
💤 Files with no reviewable changes (1)
  • ios/Flutter/AppFrameworkInfo.plist
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/audio_handler_test.dart

Comment thread ios/Runner/Info.plist Outdated
Comment on lines +29 to +32
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https:</string>
</array>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

plist="ios/Runner/Info.plist"

python3 - "$plist" <<'PY'
import plistlib
import sys
from pathlib import Path

plist_path = Path(sys.argv[1])
with plist_path.open("rb") as stream:
    values = plistlib.load(stream)

schemes = values.get("LSApplicationQueriesSchemes", [])
assert "https" in schemes, f"Missing exact https scheme: {schemes!r}"
assert "https:" not in schemes, f"Invalid colon-suffixed scheme: {schemes!r}"
print(schemes)
PY

rg -nP '\b(canOpenURL|canLaunchUrl|canLaunch)\b|LSApplicationQueriesSchemes' \
  -g '*.swift' -g '*.m' -g '*.mm' -g '*.dart' . || true

Repository: koel/player

Length of output: 278


🌐 Web query:

Apple LSApplicationQueriesSchemes scheme values cannot include colons https: documentation

💡 Result:

Yes, entries in the LSApplicationQueriesSchemes array should be the URL scheme only, not the full URL or the scheme with a trailing colon [1][2]. The LSApplicationQueriesSchemes key is used specifically with the canOpenURL: method to check if an app capable of handling a specific URL scheme is installed on the device [3][1][4]. According to Apple's requirements, you must declare the scheme itself as a string within the array [3][1]. Adding characters such as colons (e.g., "scheme://") or providing a full URL (e.g., "scheme://path") is incorrect and can result in the query failing because the system is looking specifically for the scheme identifier [2]. For example, if you want to check for the scheme "instagram", your Info.plist should contain: LSApplicationQueriesSchemes instagram It is also important to note that since iOS 15, there is a hard limit of 50 entries permitted in the LSApplicationQueriesSchemes array [5]. Apps attempting to query for schemes not declared in this list—or using the incorrect format—will have their calls to canOpenURL: return false [1][5].

Citations:


Use https, not https: in LSApplicationQueriesSchemes.

LSApplicationQueriesSchemes expects URL scheme names without the trailing colon. On iOS, a colon-suffixed value can make canOpenURL queries for HTTPS URLs fail because the query does not match the declared scheme.

Replace https: with https in ios/Runner/Info.plist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/Runner/Info.plist` around lines 29 - 32, Update the
LSApplicationQueriesSchemes array in the plist to use the scheme name https
without the trailing colon, replacing the current https: entry.

Source: MCP tools

Comment thread lib/audio_handler.dart
Comment thread lib/main.dart Outdated
A 404, a corrupt file or a codec the device won't decode says nothing about
the rest of the queue, so stopping dead on one is just an obstacle. A stalled
load says the opposite: the connection is gone and the next song would stall
identically, 30 seconds at a time, costing the user their place in the queue
for nothing.

Skip on the former, hold position on the latter, and cap consecutive failures
via MAX_ERROR_COUNT — which existed but was never read. Lowered it to 3: a run
that long is systematic, and skipping further only buries the cause.

Also from review: reuse _abandonSource for player-emitted errors so they stop
the player too, await the restored-position seek before starting playback, and
label the error icon for screen readers.
@phanan
phanan force-pushed the fix/stuck-song-loading branch from 863f7fd to 43381c3 Compare August 6, 2026 19:51
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@phanan

phanan commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thanks — took four, rejecting three.

Applied

  • onError now delegates to _abandonSource(), so a player-emitted error stops the player instead of leaving it running with the failure flag set. Test extended to assert the stop().
  • await _player.seek(...) before play(), so a restored position is applied before playback starts rather than racing it. This one bites podcast episodes, which are the only playables that resume mid-item.
  • _errorCount = 0 moved to immediately after a successful load. The counter tracks consecutive load failures, so a source that loaded should clear it regardless of what play() does next.
  • semanticLabel on the error icon.

Not applied

  • Remove the unused _errorCount/MAX_ERROR_COUNT — stale, that review ran against the first commit. Both are read in _shouldSkipPast as of 863f7fd.
  • Add a test that a failed load does not advance the queue — same vintage. That is no longer the behaviour: song-specific failures skip on purpose. The stall case is covered by stays put when the load stalls instead of skipping.
  • Subscribe to AudioPlayer.errorStream — no such member exists in just_audio 0.9.46. The only error channel is _playbackEventSubject.addError (just_audio.dart:1409, :3333), surfaced on playbackEventStream and the streams derived from it, which is what this already listens to.

Left _ / __ for genuinely unused stream-callback parameters, matching the existing convention in the repo; the one unused catch (e) I introduced is now catch (_).

The ios/* and lib/main.dart changes flagged in the second pass were local build artifacts and a temporary 5s timeout for manual testing that got swept into the branch by mistake. Both are out; the branch is force-pushed and now touches three files.

@phanan
phanan merged commit f6770cd into master Aug 6, 2026
1 of 2 checks passed
@phanan
phanan deleted the fix/stuck-song-loading branch August 6, 2026 20:02
@phanan phanan mentioned this pull request Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant