Skip to content
Open
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
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,78 @@
## 1.2.0-lh1

Labhouse fork of upstream `1.2.0` (`af6bc5a8`), scoped to the Android
`IllegalStateException: Reply already submitted` crash and nothing else.

The crash is upstream issue
[#488](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/488),
open since 2026-04-27, and the fix for it is PR
[#495](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/495),
open since 2026-06-19. **Both are still unmerged, and upstream `main` (2.0.2)
still contains the bare `result.error(...)` in `onPlayerError`** — so upgrading
does not resolve this. That is the reason this fork exists. #495 was applied by
hand rather than cherry-picked: it targets 2.0.x, where `preparePlayer` opens
with a no-argument `stop()` that does not exist at this ref.

### Fixed

- [#488](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/488) —
`preparePlayer` held a one-shot `MethodChannel.Result` in an ExoPlayer listener
that outlives it, and replied twice: `success` on `STATE_READY`, then `error`
on a later `onPlayerError`. A channel result may be answered exactly once; the
second reply throws from `DartMessenger` on the main thread and is fatal. Both
reply sites are now guarded by `hasReplied`, and the previous player is torn
down before it is replaced.
- `stopAllPlayers` replied once per player *and* once after the loop — a second
route to the same crash on the same channel, so one prepared player was enough
to trigger it. Now replies exactly once, after the loop. (Upstream 2.0.2
arrived at the same shape independently in `stopAllPlayer`.)
- `release()` left the listener attached to a released player, and
`preparePlayer` never reset `isPlayerPrepared`, so re-preparing the same
controller left `preparePlayer` awaiting forever — the "dead play button".
- Backport of [#420](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/420)
(merged upstream after 1.2.0): `setFinishMode` never replied on success, so
`await controller.setFinishMode(...)` never completed. The plugin's dispatch
for it also fell through silently when no player was registered for the key.
Both now always reply.

### Added

- `PlayerController.onPlayerError`, a `Stream<PlayerError>` of playback failures
that occur *after* the player is prepared, when they can no longer be thrown
from `preparePlayer`. The player is torn down and left in
`PlayerState.stopped` before this emits.

### Deliberately omitted from #495

- The network auto-retry block (`isRecoverableNetworkError`,
`networkRetryCount`, `maxNetworkRetries`, `retryRunnable`). This fork plays
local files. A truncated or deleted recording surfaces as
`ERROR_CODE_IO_UNSPECIFIED`, which that predicate matches, giving
5 retries x 3s = 15 seconds of silence before anything is reported.
- #495's unrecoverable branch reports `onDidFinishPlayingAudio` with
`finishType: 2`, which Dart turns into a completion event — so a genuinely
broken file reads as "finished normally". This fork reports `onPlayerError`
and moves the player to stopped instead.

### Not carried: `WaveformExtractor`

An earlier revision of this branch also rewrote `WaveformExtractor` to guard its
reply paths, serialize codec teardown, and move decode setup off the main
thread. That work was dropped, because upstream had already fixed it:

- [#375](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/375)
(the extractor's double-reply) was closed by
[#409](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/409) in
1.3.0 — `main` carries an `isReplySubmitted` guard.
- Cancelling a superseded extraction was fixed by
[#414](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/414).
- Related codec-teardown crashes were fixed by
[#431](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/431).

`WaveformExtractor.kt` is therefore byte-identical to upstream `1.2.0` here.
Anything wanted from those fixes should come from upgrading to 2.0.x, not from
a hand-rolled reimplementation on top of an old base.

## 1.2.0

- Fixed [#350](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/350) - Waveform clipping at starting position
Expand Down
90 changes: 72 additions & 18 deletions android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class AudioPlayer(
private var player: ExoPlayer? = null
private var playerListener: Player.Listener? = null
private var isPlayerPrepared: Boolean = false
// Guards the prepare result so it is delivered exactly once (success or error).
// A MethodChannel.Result accepts a single reply; the second one throws
// IllegalStateException("Reply already submitted") from DartMessenger, on the main
// thread, which is fatal.
private var hasReplied: Boolean = false
private var finishMode = FinishMode.Stop
private var key = playerKey
private var updateFrequency: Long = 200
Expand All @@ -38,22 +43,52 @@ class AudioPlayer(
}
val uri = Uri.parse(path)
val mediaItem = MediaItem.fromUri(uri)
hasReplied = false
// Tear the previous player down before replacing it. stop() detaches the previous
// listener, which still captures the previous (already answered) Result and would
// otherwise be able to reply to it from the old player's events. It also resets
// isPlayerPrepared, so re-preparing the same key can reply again instead of leaving
// the Dart future hanging forever.
stop()
player?.clearMediaItems()
player?.release()
player = ExoPlayer.Builder(appContext).build()
player?.addMediaItem(mediaItem)
player?.prepare()
playerListener = object : Player.Listener {

override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
result.error(Constants.LOG_TAG, error.message, "Unable to load media source.")
if (!isPlayerPrepared) {
if (!hasReplied) {
hasReplied = true
result.error(Constants.LOG_TAG, error.message, "Unable to load media source.")
}
} else {
// Prepare already succeeded, so the one-shot Result is spent and must not
// be touched again. Tear the player down and surface the failure over the
// method channel instead, so Dart sees an error rather than silence or a
// normal completion.
stop()
player?.release()
player = null
val args: MutableMap<String, Any?> = HashMap()
args[Constants.playerKey] = key
args[Constants.errorCode] = error.errorCode
args[Constants.errorMessage] = error.message ?: "Unable to play media source."
methodChannel.invokeMethod(Constants.onPlayerError, args)
}
}

override fun onPlayerStateChanged(isReady: Boolean, state: Int) {
if (!isPlayerPrepared) {
if (state == Player.STATE_READY) {
player?.volume = volume ?: 1F
isPlayerPrepared = true
result.success(true)
if (!hasReplied) {
hasReplied = true
result.success(true)
}
}
}
if (state == Player.STATE_ENDED) {
Expand Down Expand Up @@ -130,13 +165,23 @@ class AudioPlayer(
}

fun stop(result: MethodChannel.Result) {
stop()
result.success(true)
}

/**
* Detaches the listener and resets the prepared state without replying to any Result.
* Callers that own a Result reply themselves; callers that don't (preparePlayer, release)
* use this so the teardown can never touch a spent Result.
*/
fun stop() {
stopListening()
if (playerListener != null) {
player?.removeListener(playerListener!!)
playerListener = null
}
isPlayerPrepared = false
player?.stop()
result.success(true)
}


Expand All @@ -153,7 +198,11 @@ class AudioPlayer(

fun release(result: MethodChannel.Result) {
try {
// Detach the listener first: a listener left bound to a released player can still
// receive queued events, which is a second route to a late reply on a spent Result.
stop()
player?.release()
player = null
result.success(true)
} catch (e: Exception) {
result.error(Constants.LOG_TAG, "Failed to release player resource", e.toString())
Expand Down Expand Up @@ -189,26 +238,31 @@ class AudioPlayer(

fun setFinishMode(result: MethodChannel.Result, releaseModeType: Int?) {
try {
releaseModeType?.let {
when (releaseModeType) {
0 -> {
this.finishMode = FinishMode.Loop
}
when (releaseModeType) {
0 -> {
this.finishMode = FinishMode.Loop
}

1 -> {
this.finishMode = FinishMode.Pause
}
1 -> {
this.finishMode = FinishMode.Pause
}

2 -> {
this.finishMode = FinishMode.Stop
}
2 -> {
this.finishMode = FinishMode.Stop
}

else -> {
throw Exception("Invalid Finish mode")
}
null -> {
throw Exception("Release mode is null")
}
}

else -> {
throw Exception("Invalid Finish mode")
}
}
// Without this the one-shot Result is never answered and
// `await controller.setFinishMode(...)` hangs forever. The old
// `releaseModeType?.let { ... }` also dropped the reply on a null mode.
result.success(null)
} catch (e: Exception) {
result.error(Constants.LOG_TAG, "Can not set the release mode", e.toString())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,10 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {

Constants.stopAllPlayers -> {
for ((key, _) in audioPlayers) {
audioPlayers[key]?.stop(result)
// Must not be stop(result): that replies once per player and the call
// below replies again, so a single prepared player was enough to throw
// "Reply already submitted". Reply exactly once, after the loop.
audioPlayers[key]?.stop()
audioPlayers[key] = null
}
result.success(true)
Expand All @@ -197,8 +200,18 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
Constants.finishMode -> {
val releaseType = call.argument<Int?>(Constants.finishType)
val key = call.argument<String?>(Constants.playerKey)
key?.let {
audioPlayers[it]?.setFinishMode(result, releaseType)
// Every branch must reply. The nested `?.let` used to fall through
// silently when the key was null or no player was registered for it,
// leaving `await controller.setFinishMode(...)` pending forever.
val player = key?.let { audioPlayers[it] }
if (player != null) {
player.setFinishMode(result, releaseType)
} else {
result.error(
Constants.LOG_TAG,
"Can not set the finish mode",
"No player registered for key $key"
)
}
}

Expand Down
3 changes: 3 additions & 0 deletions android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ object Constants {
const val onCurrentDuration = "onCurrentDuration"
const val stopAllPlayers = "stopAllPlayers"
const val onDidFinishPlayingAudio = "onDidFinishPlayingAudio"
const val onPlayerError = "onPlayerError"
const val errorCode = "errorCode"
const val errorMessage = "errorMessage"
const val extractWaveformData = "extractWaveformData"
const val noOfSamples = "noOfSamples"
const val onCurrentExtractedWaveformData = "onCurrentExtractedWaveformData"
Expand Down
22 changes: 22 additions & 0 deletions lib/src/base/audio_waveforms_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,28 @@ class AudioWaveformsInterface {
?._playerState = playerState;
}
break;
case Constants.onPlayerError:
// Playback failed after the player was prepared, so the failure could not be
// returned through [preparePlayer]'s future. The platform has already torn the
// player down: report it as an error and move the player to stopped, without
// emitting a completion event, which would read as "finished normally".
var key = call.arguments[Constants.playerKey];
var error = PlayerError(
message: call.arguments[Constants.errorMessage] as String? ??
'Playback failed',
code: call.arguments[Constants.errorCode] as int?,
);
PlatformStreams.instance.addPlayerStateEvent(
PlayerIdentifier<PlayerState>(key, PlayerState.stopped),
);
if (PlatformStreams.instance.playerControllerFactory[key] != null) {
PlatformStreams.instance.playerControllerFactory[key]?._playerState =
PlayerState.stopped;
}
PlatformStreams.instance.addPlayerErrorEvent(
PlayerIdentifier<PlayerError>(key, error),
);
break;
case Constants.onCurrentExtractedWaveformData:
var key = call.arguments[Constants.playerKey];
var progress = call.arguments[Constants.progress];
Expand Down
3 changes: 3 additions & 0 deletions lib/src/base/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class Constants {
static const String onCurrentDuration = "onCurrentDuration";
static const String stopAllPlayers = "stopAllPlayers";
static const String onDidFinishPlayingAudio = "onDidFinishPlayingAudio";
static const String onPlayerError = "onPlayerError";
static const String errorCode = "errorCode";
static const String errorMessage = "errorMessage";
static const String extractWaveformData = "extractWaveformData";
static const String noOfSamples = "noOfSamples";
static const String waveformData = "waveformData";
Expand Down
13 changes: 13 additions & 0 deletions lib/src/base/platform_streams.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class PlatformStreams {
StreamController<PlayerIdentifier<double>>.broadcast();
_completionController =
StreamController<PlayerIdentifier<void>>.broadcast();
_playerErrorController =
StreamController<PlayerIdentifier<PlayerError>>.broadcast();
await AudioWaveformsInterface.instance.setMethodCallHandler();
}

Expand All @@ -51,12 +53,16 @@ class PlatformStreams {
Stream<PlayerIdentifier<void>> get onCompletion =>
_completionController.stream;

Stream<PlayerIdentifier<PlayerError>> get onPlayerError =>
_playerErrorController.stream;

late StreamController<PlayerIdentifier<int>> _currentDurationController;
late StreamController<PlayerIdentifier<PlayerState>> _playerStateController;
late StreamController<PlayerIdentifier<List<double>>>
_extractedWaveformDataController;
late StreamController<PlayerIdentifier<double>> _extractionProgressController;
late StreamController<PlayerIdentifier<void>> _completionController;
late StreamController<PlayerIdentifier<PlayerError>> _playerErrorController;

void addCurrentDurationEvent(PlayerIdentifier<int> playerIdentifier) {
if (!_currentDurationController.isClosed) {
Expand Down Expand Up @@ -89,12 +95,19 @@ class PlatformStreams {
}
}

void addPlayerErrorEvent(PlayerIdentifier<PlayerError> playerIdentifier) {
if (!_playerErrorController.isClosed) {
_playerErrorController.add(playerIdentifier);
}
}

void dispose() {
_currentDurationController.close();
_playerStateController.close();
_extractedWaveformDataController.close();
_currentDurationController.close();
_completionController.close();
_playerErrorController.close();
AudioWaveformsInterface.instance.removeMethodCallHandler();
isInitialised = false;
}
Expand Down
21 changes: 21 additions & 0 deletions lib/src/base/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,27 @@ extension RecorderStateExtension on RecorderState {
bool get isStopped => this == RecorderState.stopped;
}

/// A playback failure reported by the platform *after* the player was already
/// prepared, when the one-shot `preparePlayer` reply has been spent and the
/// failure can no longer be returned to the awaiting caller.
///
/// Emitted on [PlayerController.onPlayerError]. The player is torn down before
/// this is reported, so the controller is left in [PlayerState.stopped] and the
/// audio has to be prepared again before it can play.
class PlayerError {
const PlayerError({required this.message, this.code});

/// Human readable description of the failure, straight from the platform.
final String message;

/// Platform specific error code. On Android this is the ExoPlayer
/// `PlaybackException.errorCode`; `null` where the platform reports none.
final int? code;

@override
String toString() => 'PlayerError(code: $code, message: $message)';
}

/// Rate of updating the reported current duration.
enum UpdateFrequency {
/// Reports duration at every 50 milliseconds.
Expand Down
7 changes: 7 additions & 0 deletions lib/src/controllers/player_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ class PlayerController extends ChangeNotifier {
Stream<void> get onCompletion =>
PlatformStreams.instance.onCompletion.filter(playerKey);

/// A stream of playback failures reported after the player was prepared, when
/// they can no longer be thrown from [preparePlayer]. The player is already
/// torn down when this emits and [playerState] is [PlayerState.stopped], so
/// the audio must be prepared again before it can play.
Stream<PlayerError> get onPlayerError =>
PlatformStreams.instance.onPlayerError.filter(playerKey);

PlayerController() {
if (!PlatformStreams.instance.isInitialised) {
PlatformStreams.instance.init();
Expand Down
Loading