diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd46a3d..846654d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt b/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt index 3b937cb7..238f87e3 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt @@ -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 @@ -38,6 +43,15 @@ 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() @@ -45,7 +59,25 @@ class AudioPlayer( 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 = 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) { @@ -53,7 +85,10 @@ class AudioPlayer( 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) { @@ -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) } @@ -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()) @@ -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()) } diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt b/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt index c3811881..80372745 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt @@ -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) @@ -197,8 +200,18 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { Constants.finishMode -> { val releaseType = call.argument(Constants.finishType) val key = call.argument(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" + ) } } diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt b/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt index 63977db8..bf511ef0 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt @@ -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" diff --git a/lib/src/base/audio_waveforms_interface.dart b/lib/src/base/audio_waveforms_interface.dart index c3691773..812c231f 100644 --- a/lib/src/base/audio_waveforms_interface.dart +++ b/lib/src/base/audio_waveforms_interface.dart @@ -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(key, PlayerState.stopped), + ); + if (PlatformStreams.instance.playerControllerFactory[key] != null) { + PlatformStreams.instance.playerControllerFactory[key]?._playerState = + PlayerState.stopped; + } + PlatformStreams.instance.addPlayerErrorEvent( + PlayerIdentifier(key, error), + ); + break; case Constants.onCurrentExtractedWaveformData: var key = call.arguments[Constants.playerKey]; var progress = call.arguments[Constants.progress]; diff --git a/lib/src/base/constants.dart b/lib/src/base/constants.dart index 885e274a..5289bdad 100644 --- a/lib/src/base/constants.dart +++ b/lib/src/base/constants.dart @@ -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"; diff --git a/lib/src/base/platform_streams.dart b/lib/src/base/platform_streams.dart index 26221758..2ad2259e 100644 --- a/lib/src/base/platform_streams.dart +++ b/lib/src/base/platform_streams.dart @@ -33,6 +33,8 @@ class PlatformStreams { StreamController>.broadcast(); _completionController = StreamController>.broadcast(); + _playerErrorController = + StreamController>.broadcast(); await AudioWaveformsInterface.instance.setMethodCallHandler(); } @@ -51,12 +53,16 @@ class PlatformStreams { Stream> get onCompletion => _completionController.stream; + Stream> get onPlayerError => + _playerErrorController.stream; + late StreamController> _currentDurationController; late StreamController> _playerStateController; late StreamController>> _extractedWaveformDataController; late StreamController> _extractionProgressController; late StreamController> _completionController; + late StreamController> _playerErrorController; void addCurrentDurationEvent(PlayerIdentifier playerIdentifier) { if (!_currentDurationController.isClosed) { @@ -89,12 +95,19 @@ class PlatformStreams { } } + void addPlayerErrorEvent(PlayerIdentifier 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; } diff --git a/lib/src/base/utils.dart b/lib/src/base/utils.dart index d4cad1a1..3c510892 100644 --- a/lib/src/base/utils.dart +++ b/lib/src/base/utils.dart @@ -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. diff --git a/lib/src/controllers/player_controller.dart b/lib/src/controllers/player_controller.dart index 25a723e0..7f7fb8ff 100644 --- a/lib/src/controllers/player_controller.dart +++ b/lib/src/controllers/player_controller.dart @@ -92,6 +92,13 @@ class PlayerController extends ChangeNotifier { Stream 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 get onPlayerError => + PlatformStreams.instance.onPlayerError.filter(playerKey); + PlayerController() { if (!PlatformStreams.instance.isInitialised) { PlatformStreams.instance.init(); diff --git a/pubspec.yaml b/pubspec.yaml index 56dde324..dd6c9d7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: audio_waveforms description: A Flutter package that allow you to generate waveform while recording audio or from audio file. -version: 1.2.0 +version: 1.2.0-lh1 homepage: https://github.com/SimformSolutionsPvtLtd/audio_waveforms issue_tracker: https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues