Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 121 additions & 60 deletions lib/audio_handler.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';

import 'package:app/app_state.dart';
Expand All @@ -13,19 +14,34 @@ import 'package:collection/collection.dart';
import 'package:just_audio/just_audio.dart';

class KoelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
static const MAX_ERROR_COUNT = 10;
/// How many songs in a row may fail before playback gives up rather than
/// keep skipping. Low, because the common cause of a run of failures is a
/// dead connection, and each skip costs the user their place in the queue.
static const MAX_ERROR_COUNT = 3;

late final DownloadProvider downloadProvider;
late final PlayableProvider playableProvider;
late AudioServiceRepeatMode repeatMode;

/// How long a source is given to become playable before we give up on it.
/// Without this, a stalled connection leaves the player in `loading`
/// indefinitely: neither just_audio nor the platform player time out on
/// their own, so the future never completes and never throws.
final Duration sourceLoadTimeout;

var _errorCount = 0;
var _initialized = false;
var _currentMediaItem = MediaItem(id: '', title: '');
var _isRadioMode = false;
var _playbackFailed = false;
AudioPlayer? _radioPlayer;

final _player = AudioPlayer();
final AudioPlayer _player;

KoelAudioHandler({
AudioPlayer? player,
this.sourceLoadTimeout = const Duration(seconds: 30),
}) : _player = player ?? AudioPlayer();

AudioPlayer get player => _player;

Expand Down Expand Up @@ -88,58 +104,75 @@ class KoelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
}

void _subscribeToPlayerPlaybackEvents() {
_player.playbackEventStream.listen((PlaybackEvent event) {
if (_isRadioMode) return;
final playing = _player.playing;
playbackState.add(playbackState.value.copyWith(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
},
androidCompactActionIndices: const [0, 1, 3],
processingState: {
// iOS 16+ seems to treat "idle" as "stopped" and close the audio
// session, so we use "ready" to keep it alive.
// @see https://stackoverflow.com/a/75236414
ProcessingState.idle: Platform.isIOS
? AudioProcessingState.ready
: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState]!,
repeatMode: repeatMode,
shuffleMode: _player.shuffleModeEnabled
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
playing: playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: currentQueueIndex,
));
});
_player.playbackEventStream.listen(
(_) => _emitPlaybackState(),
onError: (_, __) => _abandonSource(),
);
}

AudioProcessingState get _processingState {
if (_playbackFailed) return AudioProcessingState.error;

return {
// iOS 16+ seems to treat "idle" as "stopped" and close the audio
// session, so we use "ready" to keep it alive.
// @see https://stackoverflow.com/a/75236414
ProcessingState.idle: Platform.isIOS
? AudioProcessingState.ready
: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState]!;
}

void _emitPlaybackState() {
if (_isRadioMode) return;
final playing = _player.playing;

playbackState.add(playbackState.value.copyWith(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
},
androidCompactActionIndices: const [0, 1, 3],
processingState: _processingState,
repeatMode: repeatMode,
shuffleMode: _player.shuffleModeEnabled
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
playing: playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: currentQueueIndex,
));
}

void _subscribeToPlayerProcessingStateEvents() {
_player.processingStateStream.listen((state) async {
if (_isRadioMode) return;
if (state == ProcessingState.completed) {
if (repeatMode == AudioServiceRepeatMode.one) {
await _player.seek(Duration.zero);
await _player.play();
return;
_player.processingStateStream.listen(
(state) async {
if (_isRadioMode) return;
if (state == ProcessingState.completed) {
if (repeatMode == AudioServiceRepeatMode.one) {
await _player.seek(Duration.zero);
await _player.play();
return;
}

await skipToNext();
}

await skipToNext();
}
});
},
// This stream is derived from the playback event stream, so it relays the
// same errors. They are already turned into an error state there.
onError: (_, __) {},
);
}

void _trySetUpQueue() async {
Expand All @@ -160,8 +193,12 @@ class KoelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
);

if (queuedMediaItem != null) {
_setPlayerSource(queuedMediaItem);
player.seek(Duration(seconds: state.playbackPosition));
try {
await _setPlayerSource(queuedMediaItem).timeout(sourceLoadTimeout);
await player.seek(Duration(seconds: state.playbackPosition));
} catch (_) {
await _abandonSource();
}
}
}

Expand Down Expand Up @@ -202,26 +239,41 @@ class KoelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
}

_setPlayerSource(MediaItem mediaItem) async {
_playbackFailed = false;
_currentMediaItem = mediaItem;
this.mediaItem.add(_currentMediaItem);

final playable = playableProvider.byId(mediaItem.id)!;
final download = downloadProvider.getForPlayable(playable);

if (download == null) {
final uri = Uri.parse(mediaItem.extras?['sourceUrl'] as String);
await _player.setAudioSource(LockCachingAudioSource(uri));
await _player.setUrl(mediaItem.extras?['sourceUrl'] as String);
} else {
await _player.setFilePath(download.path);
}
}

/// Tear down a source that stalled or errored, so the player is left in a
/// state the user can retry or skip out of instead of a permanent spinner.
Future<void> _abandonSource() async {
_playbackFailed = true;
await _player.stop();
_emitPlaybackState();
}

@override
Future<void> play() async {
if (_isRadioMode && _radioPlayer != null) {
await _radioPlayer!.play();
return;
}

// The player holds no usable source after a failed load, so pressing play
// has to start the current item over rather than resume it.
if (_playbackFailed && currentQueueIndex > -1) {
return _playAtIndex(currentQueueIndex);
}

playbackState.add(playbackState.value.copyWith(playing: true));
await _player.play();
}
Expand Down Expand Up @@ -302,23 +354,32 @@ class KoelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final position = getPlaybackPositionFromState(mediaItem.id) ?? 0;

try {
await _setPlayerSource(mediaItem);
await _setPlayerSource(mediaItem).timeout(sourceLoadTimeout);
_errorCount = 0;
exitRadioMode();
_player.seek(Duration(seconds: position.toInt()));
await _player.seek(Duration(seconds: position.toInt()));
await play();
_emitPlaybackState();
Comment on lines +357 to +362

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.


put('queue/playback-status', data: {
'song': mediaItem.id,
'position': _player.position.inSeconds,
});

// Reset the error count if the song is successfully loaded.
_errorCount = 0;
} catch (e) {
} catch (error) {
_errorCount++;
await _abandonSource();

if (_shouldSkipPast(error)) await skipToNext();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// A stalled load means the connection is at fault, not the song, and the
/// next one would stall just the same. Every other failure belongs to this
/// song alone, so move past it — but a run of them is something systematic
/// that churning through the queue would only hide.
bool _shouldSkipPast(Object error) =>
error is! TimeoutException && _errorCount < MAX_ERROR_COUNT;

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

Expand Down
39 changes: 21 additions & 18 deletions lib/ui/widgets/mini_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:provider/provider.dart';
class MiniPlayer extends StatefulWidget {
static Key pauseButtonKey = UniqueKey();
static Key nextButtonKey = UniqueKey();
static Key playbackErrorIconKey = UniqueKey();

final AppRouter router;

Expand Down Expand Up @@ -202,15 +203,12 @@ class _MiniPlayerState extends State<MiniPlayer> with StreamSubscriber {

if (playable == null || state == null) return SizedBox.shrink();

late final bool isLoading;

if ((state.processingState == AudioProcessingState.buffering ||
state.processingState == AudioProcessingState.loading) &&
state.playing) {
isLoading = true;
} else {
isLoading = false;
}
final isLoading = state.playing &&
(state.processingState == AudioProcessingState.buffering ||
state.processingState == AudioProcessingState.loading);
final hasFailed = state.processingState == AudioProcessingState.error;
final overlayDimension =
PlayableThumbnail.dimensionForSize(ThumbnailSize.xs);

return _buildShell(
content: InkWell(
Expand All @@ -228,12 +226,9 @@ class _MiniPlayerState extends State<MiniPlayer> with StreamSubscriber {
playable: playable,
),
),
if (isLoading)
if (isLoading || hasFailed)
SizedBox.square(
dimension:
PlayableThumbnail.dimensionForSize(
ThumbnailSize.xs,
),
dimension: overlayDimension,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(
Expand All @@ -249,13 +244,21 @@ class _MiniPlayerState extends State<MiniPlayer> with StreamSubscriber {
),
if (isLoading)
SizedBox.square(
dimension:
PlayableThumbnail.dimensionForSize(
ThumbnailSize.xs,
),
dimension: overlayDimension,
child: SpinKitThreeBounce(
color: AppColors.white, size: 16),
),
if (hasFailed)
SizedBox.square(
dimension: overlayDimension,
child: Icon(
CupertinoIcons.exclamationmark_triangle_fill,
key: MiniPlayer.playbackErrorIconKey,
semanticLabel: "Couldn't play this song",
color: AppColors.white,
size: 16,
),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
),
Expanded(
Expand Down
Loading
Loading