From 32617c6de62030014ae05c08a87dc739dbe7d0ca Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Fri, 7 Aug 2026 12:21:51 +0200 Subject: [PATCH 01/11] fix(android): reply exactly once to preparePlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preparePlayer captured the one-shot MethodChannel.Result inside a Player.Listener that stays attached for the player's whole lifetime. The success path was guarded by isPlayerPrepared; the error path was guarded by nothing, so a playback failure after a successful prepare called result.error on a Result that had already been answered. Flutter's DartMessenger enforces one reply per message with an atomic flag and throws IllegalStateException "Reply already submitted" on the second — on the main thread, inside a listener callback, so the process dies. - Add hasReplied, reset at the top of preparePlayer, and guard both result.success(true) and result.error(...) with it. Once isPlayerPrepared is true, onPlayerError no longer touches result at all. - Tear the previous player down at the top of preparePlayer via the new no-arg stop(). The old listener still captures the previous, already-answered Result; leaving it attached let the old player reply to it. This also resets isPlayerPrepared, so re-preparing the same key can reply again instead of leaving the Dart future pending forever (the "dead play button"). - release() now detaches the listener and nulls the player. A listener left bound to a released player can still receive queued events, which is a second route to a late reply. - stopAllPlayers replied once per player via stop(result) and then once more after the loop: a second, independent route to the same fatal on the same channel. It now uses the no-arg stop() and replies exactly once. - Post-prepare failures can no longer be returned through the spent Result, so surface them on a new onPlayerError channel event, exposed in Dart as PlayerController.onPlayerError, and move the player to stopped. Upstream #495 fires onDidFinishPlayingAudio with finishType 2 here, which Dart turns into a completion event — a broken file would read as "finished normally". Port of upstream PR #495 (Closes #488) onto af6bc5a8. Refs: https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/495 Co-Authored-By: Claude Opus 5 (1M context) --- .../simform/audio_waveforms/AudioPlayer.kt | 55 ++++++++++++++++++- .../audio_waveforms/AudioWaveformsPlugin.kt | 5 +- .../com/simform/audio_waveforms/Utils.kt | 3 + lib/src/base/audio_waveforms_interface.dart | 22 ++++++++ lib/src/base/constants.dart | 3 + lib/src/base/platform_streams.dart | 13 +++++ lib/src/base/utils.dart | 21 +++++++ lib/src/controllers/player_controller.dart | 7 +++ 8 files changed, 125 insertions(+), 4 deletions(-) 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..7d401c56 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()) 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..e2e2f95e 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) 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(); From dd40b593eb9220e17337e383746fd1f3073b5a75 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Fri, 7 Aug 2026 12:22:22 +0200 Subject: [PATCH 02/11] fix(android): deliver the waveform extraction result exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaveformExtractor replied to its Result from four places with no coordination between them: the plugin's onProgress == 1.0F callback, MediaCodec.onError, the input-buffer failure path, and the pre-decode catch. Any two of them firing for one extractWaveformData call throws "Reply already submitted". This is the variant reported in #375, which the preparePlayer guard does not cover. - Route every reply through submitWaveformData()/submitError(), which claim isReplySubmitted under a lock so the first reply wins and later ones are no-ops. - Post replies and onCurrentExtractedWaveformData on the main handler. Codec callbacks arrive on a codec-owned thread and Flutter channels are main-thread only, so these were already being invoked off the platform thread. - Add the @Volatile released flag and take the lock in the codec callbacks, so a callback that lands during teardown bails out instead of touching a freed codec or extractor. stop() claims the codec under the lock and releases outside it — MediaCodec.stop()/release() drains in-flight callbacks that contend for the same lock — and off the callback thread, wrapping each call so an already-released codec cannot crash teardown. - Reply at EOF as well. Previously a file whose sample count never reached the expected point count left the Dart future pending forever. - The plugin called stop() on the *new* extractor right after startDecode(). That was harmless only because stop() early-returned on a `started` flag that was never set to true, so the codec was never released at all. It now tears down the previous extractor before replacing it, matching upstream main. Port of upstream PR #495 onto af6bc5a8. Refs: https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/495 Co-Authored-By: Claude Opus 5 (1M context) --- .../audio_waveforms/AudioWaveformsPlugin.kt | 9 +- .../audio_waveforms/WaveformExtractor.kt | 128 ++++++++++++++---- 2 files changed, 110 insertions(+), 27 deletions(-) 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 e2e2f95e..98d9018a 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt @@ -276,6 +276,10 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.error(Constants.LOG_TAG, "Path can't be null", "") return } + // Tear down any previous extraction for this key before replacing it. This used to run + // after startDecode() and only worked because stop() was a no-op; now that stop() really + // releases the codec, it has to target the *previous* extractor. + extractors[playerKey]?.stop() extractors[playerKey] = WaveformExtractor( context = applicationContext, methodChannel = channel, @@ -286,14 +290,15 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { extractorCallBack = object : ExtractorCallBack { override fun onProgress(value: Float) { if (value == 1.0F) { - result.success(extractors[playerKey]?.sampleData) + // Route through the extractor's guarded, main-thread reply so the + // success and error paths can never both answer the same Result. + extractors[playerKey]?.submitWaveformData() } } } ) extractors[playerKey]?.startDecode() - extractors[playerKey]?.stop() } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt index 8c4c1062..294a7cd4 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt @@ -7,6 +7,9 @@ import android.media.MediaExtractor import android.media.MediaFormat import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log import io.flutter.plugin.common.MethodChannel import java.nio.ByteBuffer import java.util.concurrent.CountDownLatch @@ -28,8 +31,22 @@ class WaveformExtractor( private var progress = 0F private var currentProgress = 0F + /** Guards the result so it is delivered exactly once. Guarded by [lock]; read and written + * from the decode and codec callback threads. */ @Volatile - private var started = false + private var isReplySubmitted = false + + /** Guards decoder/extractor teardown against the live MediaCodec callbacks */ + private val lock = Any() + + /** Set once [stop] has released the codec/extractor, so in-flight callbacks bail out */ + @Volatile + private var released = false + + /** Delivers MethodChannel replies and events on the platform (main) thread. MediaCodec + * callbacks arrive on a codec-owned thread, and Flutter channels are main-thread only. */ + private val mainHandler = Handler(Looper.getMainLooper()) + private val finishCount = CountDownLatch(1) private var inputEof = false private var sampleRate = 0 @@ -38,6 +55,33 @@ class WaveformExtractor( private var totalSamples = 0L private var perSamplePoints = 0L + /** + * Delivers the final waveform data to Flutter exactly once, on the main thread. + * Idempotent: the first reply (success or error) wins and every later call is a no-op. + */ + fun submitWaveformData() { + synchronized(lock) { + if (isReplySubmitted) return + isReplySubmitted = true + } + // Snapshot: the reply is posted async and sampleData may still be mutated by the + // decode thread before the post runs. + val data = ArrayList(sampleData) + mainHandler.post { result.success(data) } + } + + /** + * Delivers an error to Flutter exactly once, on the main thread. + * Idempotent: a no-op if a result (success or error) was already submitted. + */ + private fun submitError(message: String?, details: String) { + synchronized(lock) { + if (isReplySubmitted) return + isReplySubmitted = true + } + mainHandler.post { result.error(Constants.LOG_TAG, message, details) } + } + private fun getFormat(path: String): MediaFormat? { val mediaExtractor = MediaExtractor() this.extractor = mediaExtractor @@ -63,9 +107,12 @@ class WaveformExtractor( decoder = MediaCodec.createDecoderByType(mime).also { it.configure(format, null, null, 0) it.setCallback(object : MediaCodec.Callback() { - override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { - if (inputEof) return - val extractor = extractor ?: return + override fun onInputBufferAvailable( + codec: MediaCodec, + index: Int + ): Unit = synchronized(lock) { + if (released || inputEof) return@synchronized + val extractor = extractor ?: return@synchronized codec.getInputBuffer(index)?.let { buf -> val size = extractor.readSampleData(buf, 0) if (size > 0) { @@ -106,11 +153,7 @@ class WaveformExtractor( } override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - result.error( - Constants.LOG_TAG, - e.message, - "An error is thrown while decoding the audio file" - ) + submitError(e.message, "An error is thrown while decoding the audio file") finishCount.countDown() } @@ -118,7 +161,8 @@ class WaveformExtractor( codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo - ) { + ): Unit = synchronized(lock) { + if (released || decoder == null) return@synchronized if (info.size > 0) { codec.getOutputBuffer(index)?.let { buf -> val size = info.size @@ -139,6 +183,10 @@ class WaveformExtractor( } if (info.isEof()) { + // Decoding ended. Reply with whatever was extracted before tearing + // down; otherwise a file whose sample count never reaches the + // expected point count leaves the Dart future hanging forever. + submitWaveformData() stop() } } @@ -148,11 +196,7 @@ class WaveformExtractor( } } catch (e: Exception) { - result.error( - Constants.LOG_TAG, - e.message, - "An error is thrown before decoding the audio file" - ) + submitError(e.message, "An error is thrown before decoding the audio file") } @@ -169,6 +213,7 @@ class WaveformExtractor( // Discard redundant values and release resources if (progress > 1.0F) { + submitWaveformData() stop() return } @@ -180,13 +225,18 @@ class WaveformExtractor( sampleSum = 0.0 val args: MutableMap = HashMap() - args[Constants.waveformData] = sampleData + args[Constants.waveformData] = ArrayList(sampleData) args[Constants.progress] = progress args[Constants.playerKey] = key - methodChannel.invokeMethod( - Constants.onCurrentExtractedWaveformData, - args - ) + // Codec callbacks arrive on a codec-owned thread; MethodChannel must be invoked on + // the main thread, and the snapshot above keeps the posted list from being mutated + // underneath the platform message. + mainHandler.post { + methodChannel.invokeMethod( + Constants.onCurrentExtractedWaveformData, + args + ) + } } sampleCount++ @@ -234,12 +284,40 @@ class WaveformExtractor( } fun stop() { - if (!started) return - started = false - decoder?.stop() - decoder?.release() - extractor?.release() + var decoderToRelease: MediaCodec? = null + var extractorToRelease: MediaExtractor? = null + // Claim ownership of the codec/extractor under the lock and flip `released`, so any + // callback that wins the lock afterwards bails out instead of touching freed objects. + synchronized(lock) { + if (released) return + released = true + decoderToRelease = decoder + extractorToRelease = extractor + decoder = null + extractor = null + } finishCount.countDown() + // Release outside the lock and off the codec callback thread: MediaCodec.stop()/release() + // block while draining in-flight callbacks, and those callbacks contend for this same + // lock, so releasing from inside one risks a deadlock. Each call is wrapped so an + // already-released codec cannot crash the teardown. + mainHandler.post { + try { + decoderToRelease?.stop() + } catch (e: Exception) { + Log.e(Constants.LOG_TAG, "Error stopping decoder: ${e.message}") + } + try { + decoderToRelease?.release() + } catch (e: Exception) { + Log.e(Constants.LOG_TAG, "Error releasing decoder: ${e.message}") + } + try { + extractorToRelease?.release() + } catch (e: Exception) { + Log.e(Constants.LOG_TAG, "Error releasing extractor: ${e.message}") + } + } } } From a09b3dd0e1cb97038cc20802daaa5f10ee718993 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Fri, 7 Aug 2026 12:22:30 +0200 Subject: [PATCH 03/11] perf(android): run waveform decode setup off the main thread MediaExtractor.setDataSource() and getTrackFormat() block synchronously, and for a large or unreadable file they can stall for seconds. startDecode() runs from the platform method-channel handler, so that stall was on the Android main thread: UI freeze, and an ANR if it ran long enough. Move the setup onto a decode thread and interrupt it from stop(). The reply and teardown paths were already made thread-safe in the previous commit, which is what makes this safe to move off the platform thread. Port of upstream PR #495 onto af6bc5a8. Refs: https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/495 Co-Authored-By: Claude Opus 5 (1M context) --- .../simform/audio_waveforms/WaveformExtractor.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt index 294a7cd4..826d29da 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt @@ -47,6 +47,9 @@ class WaveformExtractor( * callbacks arrive on a codec-owned thread, and Flutter channels are main-thread only. */ private val mainHandler = Handler(Looper.getMainLooper()) + /** Background thread running the blocking decode setup */ + private var decodeThread: Thread? = null + private val finishCount = CountDownLatch(1) private var inputEof = false private var sampleRate = 0 @@ -101,6 +104,15 @@ class WaveformExtractor( } fun startDecode() { + // setDataSource()/getTrackFormat() block synchronously and, for a large or unreadable + // file, can stall for seconds. Running that on the platform main thread freezes the UI + // and trips an ANR, so do the setup off the main thread. + decodeThread = Thread { + decodeInternal() + }.also { it.start() } + } + + private fun decodeInternal() { try { val format = getFormat(path) ?: error("No audio format found") val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME type found") @@ -284,6 +296,8 @@ class WaveformExtractor( } fun stop() { + decodeThread?.interrupt() + decodeThread = null var decoderToRelease: MediaCodec? = null var extractorToRelease: MediaExtractor? = null // Claim ownership of the codec/extractor under the lock and flip `released`, so any From ca3e5330945c928a2b73f98db21d010e215f54e9 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Fri, 7 Aug 2026 12:24:09 +0200 Subject: [PATCH 04/11] docs: changelog for the 1.2.0-lh1 port of upstream #495 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd46a3d..f2bd69dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## 1.2.0-lh1 + +Labhouse fork of upstream `1.2.0` (`af6bc5a8`). Ports upstream PR +[#495](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/495), +which is still unmerged, onto this ref. Applied by hand rather than +cherry-picked: #495 targets `main` (2.0.x), where `preparePlayer` opens with a +call to a no-argument `stop()` that does not exist here. + +- Fixed [#488](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/488) - `IllegalStateException: Reply already submitted` crash on Android. `preparePlayer` held a one-shot `MethodChannel.Result` in a listener that outlives it and replied twice: success on `STATE_READY`, then error on a later `onPlayerError`. Both reply sites are now guarded by `hasReplied`, and the previous player is torn down before it is replaced. +- Fixed - `stopAllPlayers` replied once per player *and* once after the loop, a second route to the same crash on the same channel. Not part of #495; found while auditing every reply site. +- Fixed [#375](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/375) - the same double-reply in `WaveformExtractor`, where four independent paths could answer one `extractWaveformData` call. All of them now go through a guarded `submitWaveformData()`/`submitError()` pair, and codec teardown is serialized with a lock. +- Fixed - waveform extraction never replied at end-of-stream, so a file whose sample count never reached the expected point count left the Dart future pending forever. +- Fixed - `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"). Upstream fixed this in 2.0.x by calling `stop()` at the top of `preparePlayer`; the equivalent teardown is inlined here. +- Fixed - `MethodChannel` was invoked from MediaCodec callback threads. Replies and waveform events are now posted to the main handler. +- Changed - waveform decode setup runs off the platform main thread. `setDataSource()` blocks and could ANR. +- Added - `PlayerController.onPlayerError`, a stream of playback failures that occur after the player is prepared and so cannot be thrown from `preparePlayer`. + +**Deliberately omitted from #495:** + +- The network auto-retry block (`isRecoverableNetworkError`, `networkRetryCount`, `maxNetworkRetries`, `retryRunnable`). We play 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` instead and moves the player to stopped. + +Known upstream defect *not* fixed here, to keep this branch to the crash only: +`AudioPlayer.setFinishMode` never replies on success at this ref, so +`await controller.setFinishMode(...)` never completes. Upstream added +`result.success(null)` after 1.2.0. + ## 1.2.0 - Fixed [#350](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/350) - Waveform clipping at starting position From 9bb09db438173420172ffe41388680b40d63f081 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Fri, 7 Aug 2026 12:25:48 +0200 Subject: [PATCH 05/11] fix(android): reply to the extractor that produced the data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction callback resolved its target with extractors[playerKey] at the moment it fired. Since decode now runs on its own thread, a late callback from a superseded extractor resolved to whichever extractor currently occupies the key and replied to that one's Result — answering the wrong call and leaving the superseded call pending forever. Close over the extractor instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../simform/audio_waveforms/AudioWaveformsPlugin.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 98d9018a..2b4519b3 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt @@ -280,7 +280,13 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { // after startDecode() and only worked because stop() was a no-op; now that stop() really // releases the codec, it has to target the *previous* extractor. extractors[playerKey]?.stop() - extractors[playerKey] = WaveformExtractor( + // Hold the extractor in a local and let the callback close over it, rather than looking + // it up in `extractors` when the callback fires. The decode runs on its own thread, so a + // late callback from a superseded extractor would otherwise resolve to whichever one now + // occupies the key and reply to *its* Result — answering the wrong call and leaving the + // superseded one pending forever. + var extractor: WaveformExtractor? = null + extractor = WaveformExtractor( context = applicationContext, methodChannel = channel, expectedPoints = noOfSamples, @@ -292,13 +298,14 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { if (value == 1.0F) { // Route through the extractor's guarded, main-thread reply so the // success and error paths can never both answer the same Result. - extractors[playerKey]?.submitWaveformData() + extractor?.submitWaveformData() } } } ) - extractors[playerKey]?.startDecode() + extractors[playerKey] = extractor + extractor.startDecode() } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { From 3604ce59800aea09b1693812a1a2e8abaa72a48b Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Tue, 11 Aug 2026 12:25:55 +0200 Subject: [PATCH 06/11] revert(android): drop the WaveformExtractor rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream already fixed every defect this addressed, so carrying a hand-rolled version on a 1.2.0 base duplicated merged work and added maintenance surface for a code path this app never reaches (`shouldExtractWaveform: false` at both call sites). - #375, the extractor's double-reply, was closed by #409 in 1.3.0; `main` carries an `isReplySubmitted` guard. - Cancelling a superseded extraction was fixed by #414. - Codec-teardown crashes were fixed by #431. Reverts dd40b59, a09b3dd and 9bb09db. `WaveformExtractor.kt` is now byte-identical to upstream 1.2.0. The `stopAllPlayers` reply fix from 32617c6 is untouched — that one is a genuine second route to #488. Co-Authored-By: Claude Opus 5 (1M context) --- .../audio_waveforms/AudioWaveformsPlugin.kt | 20 +-- .../audio_waveforms/WaveformExtractor.kt | 142 +++--------------- 2 files changed, 29 insertions(+), 133 deletions(-) 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 2b4519b3..e2e2f95e 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt @@ -276,17 +276,7 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.error(Constants.LOG_TAG, "Path can't be null", "") return } - // Tear down any previous extraction for this key before replacing it. This used to run - // after startDecode() and only worked because stop() was a no-op; now that stop() really - // releases the codec, it has to target the *previous* extractor. - extractors[playerKey]?.stop() - // Hold the extractor in a local and let the callback close over it, rather than looking - // it up in `extractors` when the callback fires. The decode runs on its own thread, so a - // late callback from a superseded extractor would otherwise resolve to whichever one now - // occupies the key and reply to *its* Result — answering the wrong call and leaving the - // superseded one pending forever. - var extractor: WaveformExtractor? = null - extractor = WaveformExtractor( + extractors[playerKey] = WaveformExtractor( context = applicationContext, methodChannel = channel, expectedPoints = noOfSamples, @@ -296,16 +286,14 @@ class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { extractorCallBack = object : ExtractorCallBack { override fun onProgress(value: Float) { if (value == 1.0F) { - // Route through the extractor's guarded, main-thread reply so the - // success and error paths can never both answer the same Result. - extractor?.submitWaveformData() + result.success(extractors[playerKey]?.sampleData) } } } ) - extractors[playerKey] = extractor - extractor.startDecode() + extractors[playerKey]?.startDecode() + extractors[playerKey]?.stop() } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt index 826d29da..8c4c1062 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt @@ -7,9 +7,6 @@ import android.media.MediaExtractor import android.media.MediaFormat import android.net.Uri import android.os.Build -import android.os.Handler -import android.os.Looper -import android.util.Log import io.flutter.plugin.common.MethodChannel import java.nio.ByteBuffer import java.util.concurrent.CountDownLatch @@ -31,25 +28,8 @@ class WaveformExtractor( private var progress = 0F private var currentProgress = 0F - /** Guards the result so it is delivered exactly once. Guarded by [lock]; read and written - * from the decode and codec callback threads. */ @Volatile - private var isReplySubmitted = false - - /** Guards decoder/extractor teardown against the live MediaCodec callbacks */ - private val lock = Any() - - /** Set once [stop] has released the codec/extractor, so in-flight callbacks bail out */ - @Volatile - private var released = false - - /** Delivers MethodChannel replies and events on the platform (main) thread. MediaCodec - * callbacks arrive on a codec-owned thread, and Flutter channels are main-thread only. */ - private val mainHandler = Handler(Looper.getMainLooper()) - - /** Background thread running the blocking decode setup */ - private var decodeThread: Thread? = null - + private var started = false private val finishCount = CountDownLatch(1) private var inputEof = false private var sampleRate = 0 @@ -58,33 +38,6 @@ class WaveformExtractor( private var totalSamples = 0L private var perSamplePoints = 0L - /** - * Delivers the final waveform data to Flutter exactly once, on the main thread. - * Idempotent: the first reply (success or error) wins and every later call is a no-op. - */ - fun submitWaveformData() { - synchronized(lock) { - if (isReplySubmitted) return - isReplySubmitted = true - } - // Snapshot: the reply is posted async and sampleData may still be mutated by the - // decode thread before the post runs. - val data = ArrayList(sampleData) - mainHandler.post { result.success(data) } - } - - /** - * Delivers an error to Flutter exactly once, on the main thread. - * Idempotent: a no-op if a result (success or error) was already submitted. - */ - private fun submitError(message: String?, details: String) { - synchronized(lock) { - if (isReplySubmitted) return - isReplySubmitted = true - } - mainHandler.post { result.error(Constants.LOG_TAG, message, details) } - } - private fun getFormat(path: String): MediaFormat? { val mediaExtractor = MediaExtractor() this.extractor = mediaExtractor @@ -104,27 +57,15 @@ class WaveformExtractor( } fun startDecode() { - // setDataSource()/getTrackFormat() block synchronously and, for a large or unreadable - // file, can stall for seconds. Running that on the platform main thread freezes the UI - // and trips an ANR, so do the setup off the main thread. - decodeThread = Thread { - decodeInternal() - }.also { it.start() } - } - - private fun decodeInternal() { try { val format = getFormat(path) ?: error("No audio format found") val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME type found") decoder = MediaCodec.createDecoderByType(mime).also { it.configure(format, null, null, 0) it.setCallback(object : MediaCodec.Callback() { - override fun onInputBufferAvailable( - codec: MediaCodec, - index: Int - ): Unit = synchronized(lock) { - if (released || inputEof) return@synchronized - val extractor = extractor ?: return@synchronized + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + if (inputEof) return + val extractor = extractor ?: return codec.getInputBuffer(index)?.let { buf -> val size = extractor.readSampleData(buf, 0) if (size > 0) { @@ -165,7 +106,11 @@ class WaveformExtractor( } override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - submitError(e.message, "An error is thrown while decoding the audio file") + result.error( + Constants.LOG_TAG, + e.message, + "An error is thrown while decoding the audio file" + ) finishCount.countDown() } @@ -173,8 +118,7 @@ class WaveformExtractor( codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo - ): Unit = synchronized(lock) { - if (released || decoder == null) return@synchronized + ) { if (info.size > 0) { codec.getOutputBuffer(index)?.let { buf -> val size = info.size @@ -195,10 +139,6 @@ class WaveformExtractor( } if (info.isEof()) { - // Decoding ended. Reply with whatever was extracted before tearing - // down; otherwise a file whose sample count never reaches the - // expected point count leaves the Dart future hanging forever. - submitWaveformData() stop() } } @@ -208,7 +148,11 @@ class WaveformExtractor( } } catch (e: Exception) { - submitError(e.message, "An error is thrown before decoding the audio file") + result.error( + Constants.LOG_TAG, + e.message, + "An error is thrown before decoding the audio file" + ) } @@ -225,7 +169,6 @@ class WaveformExtractor( // Discard redundant values and release resources if (progress > 1.0F) { - submitWaveformData() stop() return } @@ -237,18 +180,13 @@ class WaveformExtractor( sampleSum = 0.0 val args: MutableMap = HashMap() - args[Constants.waveformData] = ArrayList(sampleData) + args[Constants.waveformData] = sampleData args[Constants.progress] = progress args[Constants.playerKey] = key - // Codec callbacks arrive on a codec-owned thread; MethodChannel must be invoked on - // the main thread, and the snapshot above keeps the posted list from being mutated - // underneath the platform message. - mainHandler.post { - methodChannel.invokeMethod( - Constants.onCurrentExtractedWaveformData, - args - ) - } + methodChannel.invokeMethod( + Constants.onCurrentExtractedWaveformData, + args + ) } sampleCount++ @@ -296,42 +234,12 @@ class WaveformExtractor( } fun stop() { - decodeThread?.interrupt() - decodeThread = null - var decoderToRelease: MediaCodec? = null - var extractorToRelease: MediaExtractor? = null - // Claim ownership of the codec/extractor under the lock and flip `released`, so any - // callback that wins the lock afterwards bails out instead of touching freed objects. - synchronized(lock) { - if (released) return - released = true - decoderToRelease = decoder - extractorToRelease = extractor - decoder = null - extractor = null - } + if (!started) return + started = false + decoder?.stop() + decoder?.release() + extractor?.release() finishCount.countDown() - // Release outside the lock and off the codec callback thread: MediaCodec.stop()/release() - // block while draining in-flight callbacks, and those callbacks contend for this same - // lock, so releasing from inside one risks a deadlock. Each call is wrapped so an - // already-released codec cannot crash the teardown. - mainHandler.post { - try { - decoderToRelease?.stop() - } catch (e: Exception) { - Log.e(Constants.LOG_TAG, "Error stopping decoder: ${e.message}") - } - try { - decoderToRelease?.release() - } catch (e: Exception) { - Log.e(Constants.LOG_TAG, "Error releasing decoder: ${e.message}") - } - try { - extractorToRelease?.release() - } catch (e: Exception) { - Log.e(Constants.LOG_TAG, "Error releasing extractor: ${e.message}") - } - } } } From 97a05a97d848626398f0de455e0222e1f08c7965 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Tue, 11 Aug 2026 12:26:09 +0200 Subject: [PATCH 07/11] fix(android): always reply to setFinishMode Backport of upstream #420, merged after 1.2.0 and so missing from this base. `setFinishMode` never replied on success, so a caller awaiting `controller.setFinishMode(...)` waited forever. The plugin's dispatch for it had the same defect from a different angle: a nested `?.let` fell through silently when the key was null or no player was registered. Same one-shot-Result defect family as #488, and live for any caller that awaits it. Co-Authored-By: Claude Opus 5 (1M context) --- .../simform/audio_waveforms/AudioPlayer.kt | 35 +++++++++++-------- .../audio_waveforms/AudioWaveformsPlugin.kt | 14 ++++++-- 2 files changed, 32 insertions(+), 17 deletions(-) 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 7d401c56..238f87e3 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt @@ -238,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 e2e2f95e..80372745 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt @@ -200,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" + ) } } From ff1a783e0e051e7b088bc584eb4e79f9c1f7d756 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Tue, 11 Aug 2026 12:26:09 +0200 Subject: [PATCH 08/11] docs: rescope the 1.2.0-lh1 changelog and mark the fork version Records why this fork exists at all: #488 is open, #495 is open, and upstream main (2.0.2) still carries the bare `result.error(...)` in `onPlayerError`, so upgrading does not resolve the crash. Also documents what was deliberately dropped and why, so nobody reimplements the extractor work that #409/#414/#431 already merged. Bumps the version to 1.2.0-lh1 so the lockfile and build output identify the fork rather than reporting plain 1.2.0. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 94 +++++++++++++++++++++++++++++++++++++++------------- pubspec.yaml | 2 +- 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bd69dc..846654d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,77 @@ ## 1.2.0-lh1 -Labhouse fork of upstream `1.2.0` (`af6bc5a8`). Ports upstream PR +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), -which is still unmerged, onto this ref. Applied by hand rather than -cherry-picked: #495 targets `main` (2.0.x), where `preparePlayer` opens with a -call to a no-argument `stop()` that does not exist here. - -- Fixed [#488](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/488) - `IllegalStateException: Reply already submitted` crash on Android. `preparePlayer` held a one-shot `MethodChannel.Result` in a listener that outlives it and replied twice: success on `STATE_READY`, then error on a later `onPlayerError`. Both reply sites are now guarded by `hasReplied`, and the previous player is torn down before it is replaced. -- Fixed - `stopAllPlayers` replied once per player *and* once after the loop, a second route to the same crash on the same channel. Not part of #495; found while auditing every reply site. -- Fixed [#375](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/375) - the same double-reply in `WaveformExtractor`, where four independent paths could answer one `extractWaveformData` call. All of them now go through a guarded `submitWaveformData()`/`submitError()` pair, and codec teardown is serialized with a lock. -- Fixed - waveform extraction never replied at end-of-stream, so a file whose sample count never reached the expected point count left the Dart future pending forever. -- Fixed - `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"). Upstream fixed this in 2.0.x by calling `stop()` at the top of `preparePlayer`; the equivalent teardown is inlined here. -- Fixed - `MethodChannel` was invoked from MediaCodec callback threads. Replies and waveform events are now posted to the main handler. -- Changed - waveform decode setup runs off the platform main thread. `setDataSource()` blocks and could ANR. -- Added - `PlayerController.onPlayerError`, a stream of playback failures that occur after the player is prepared and so cannot be thrown from `preparePlayer`. - -**Deliberately omitted from #495:** - -- The network auto-retry block (`isRecoverableNetworkError`, `networkRetryCount`, `maxNetworkRetries`, `retryRunnable`). We play 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` instead and moves the player to stopped. - -Known upstream defect *not* fixed here, to keep this branch to the crash only: -`AudioPlayer.setFinishMode` never replies on success at this ref, so -`await controller.setFinishMode(...)` never completes. Upstream added -`result.success(null)` after 1.2.0. +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 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 From a522a07990957d85557974feafb474ca210ef2b8 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Tue, 11 Aug 2026 12:26:21 +0200 Subject: [PATCH 09/11] =?UTF-8?q?test(android):=20on-device=20repro=20scaf?= =?UTF-8?q?fold=20for=20#488=20=E2=80=94=20REVERT=20BEFORE=20MERGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The natural trigger for #488 (ExoPlayer failing *after* prepare already answered its Result) cannot be driven from the UI, so a clean run proves nothing on its own. This adds a synthetic late-error injection plus a switch that restores the pre-fix reply path, so the crash can be reproduced on demand as a negative control. Drives the two-phase harness in the app repo at scripts/test-reply-already-submitted.sh. Verified on a OnePlus A6003 (arm64-v8a, API 30): the control run reproduces the exact Crashlytics 10820c7c stack (DartMessenger$Reply.reply -> MethodChannel$IncomingMethodCallHandler$1.error -> onPlayerError), and the guarded run survives with zero fatals and delivers a PlayerError. This commit is scaffolding, not product code. Revert it before merging. Co-Authored-By: Claude Opus 5 (1M context) --- .../simform/audio_waveforms/AudioPlayer.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 238f87e3..f473d8ba 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper +import android.util.Log import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.PlaybackException @@ -31,6 +32,27 @@ class AudioPlayer( private var key = playerKey private var updateFrequency: Long = 200 + // ========================================================================== + // TEMPORARY TEST SCAFFOLD — remove before merging. Exists only to reproduce + // upstream #488 ("Reply already submitted", Crashlytics 10820c7c) on demand, + // because the natural trigger (ExoPlayer failing *after* prepare succeeded) + // cannot be driven from the UI. + // ========================================================================== + private companion object { + /** + * Milliseconds after a successful prepare to fire a synthetic late player error. + * 0 disables the injection entirely. + */ + val INJECT_LATE_ERROR_AFTER_MS = 2_000L + + /** + * true restores the pre-fix reply behaviour verbatim, so the run is expected to + * die with IllegalStateException("Reply already submitted"). This is the negative + * control: without seeing it crash here, a clean run with the guard proves nothing. + */ + val BYPASS_REPLY_GUARD = false + } + fun preparePlayer( result: MethodChannel.Result, path: String?, @@ -59,6 +81,21 @@ class AudioPlayer( override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) + // TEMPORARY TEST SCAFFOLD — remove before merging. + if (BYPASS_REPLY_GUARD) { + Log.w( + Constants.LOG_TAG, + "SCAFFOLD: replying unguarded (pre-fix behaviour), " + + "isPlayerPrepared=$isPlayerPrepared hasReplied=$hasReplied" + ) + result.error(Constants.LOG_TAG, error.message, "Unable to load media source.") + return + } + Log.w( + Constants.LOG_TAG, + "onPlayerError code=${error.errorCode} msg=${error.message} " + + "isPlayerPrepared=$isPlayerPrepared hasReplied=$hasReplied" + ) if (!isPlayerPrepared) { if (!hasReplied) { hasReplied = true @@ -77,6 +114,10 @@ class AudioPlayer( args[Constants.errorCode] = error.errorCode args[Constants.errorMessage] = error.message ?: "Unable to play media source." methodChannel.invokeMethod(Constants.onPlayerError, args) + Log.w( + Constants.LOG_TAG, + "late error routed to onPlayerError channel event, Result untouched" + ) } } @@ -88,6 +129,8 @@ class AudioPlayer( if (!hasReplied) { hasReplied = true result.success(true) + Log.w(Constants.LOG_TAG, "prepare replied success for key=$key") + scheduleInjectedLateError() } } } @@ -129,6 +172,39 @@ class AudioPlayer( } } + /** + * TEMPORARY TEST SCAFFOLD — remove before merging. + * + * Fires a synthetic [PlaybackException] at the attached listener once prepare has already + * been answered. That is the exact precondition for upstream #488: the one-shot Result is + * spent, so the pre-fix code path replies a second time and DartMessenger throws + * IllegalStateException("Reply already submitted") on the main thread. + */ + private fun scheduleInjectedLateError() { + if (INJECT_LATE_ERROR_AFTER_MS <= 0L) return + val listener = playerListener ?: return + Log.w( + Constants.LOG_TAG, + "SCAFFOLD: injecting late player error in ${INJECT_LATE_ERROR_AFTER_MS}ms " + + "(bypassGuard=$BYPASS_REPLY_GUARD)" + ) + handler.postDelayed({ + // Only meaningful while this listener is still the live one; stop()/release() + // null it out, and a stale injection would tell us nothing. + if (playerListener !== listener) { + Log.w(Constants.LOG_TAG, "SCAFFOLD: listener replaced, skipping injection") + return@postDelayed + } + listener.onPlayerError( + PlaybackException( + "SCAFFOLD injected late error", + null, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED + ) + ) + }, INJECT_LATE_ERROR_AFTER_MS) + } + fun seekToPosition(result: MethodChannel.Result, progress: Long?) { if (progress != null) { player?.seekTo(progress) From dacad529da0d3ad96d12fc75b9bbe4150194b004 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Tue, 11 Aug 2026 14:03:05 +0200 Subject: [PATCH 10/11] =?UTF-8?q?test(android):=20make=20the=20injected=20?= =?UTF-8?q?error=20code=20configurable=20=E2=80=94=20REVERT=20BEFORE=20MER?= =?UTF-8?q?GE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the app-side ExoPlayer-code-to-exception mapping be exercised on a real device instead of only in unit tests. Second half of the scaffold; revert alongside a522a07 before merging. Co-Authored-By: Claude Opus 5 (1M context) --- .../kotlin/com/simform/audio_waveforms/AudioPlayer.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 f473d8ba..f8e78ae9 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt @@ -45,6 +45,13 @@ class AudioPlayer( */ val INJECT_LATE_ERROR_AFTER_MS = 2_000L + /** + * ExoPlayer error code carried by the injected failure. Lets the app-side + * code-to-exception mapping be exercised on a device: 2005 file-not-found, + * 2xxx network, 3xxx/4xxx format, anything else playback. + */ + val INJECT_ERROR_CODE = 2000 + /** * true restores the pre-fix reply behaviour verbatim, so the run is expected to * die with IllegalStateException("Reply already submitted"). This is the negative @@ -199,7 +206,7 @@ class AudioPlayer( PlaybackException( "SCAFFOLD injected late error", null, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED + INJECT_ERROR_CODE ) ) }, INJECT_LATE_ERROR_AFTER_MS) From 899167bb2b6a070aaca6ef461c6c76e54ceaffe9 Mon Sep 17 00:00:00 2001 From: yagoquesadafloriach Date: Wed, 12 Aug 2026 11:04:10 +0200 Subject: [PATCH 11/11] revert: remove the on-device repro scaffold Reverts a522a07 and dacad52. The scaffold existed to reproduce #488 on demand and to exercise the app-side error-code mapping on a real device. Both are done, so the injection, the guard bypass and their logging come out. AudioPlayer.kt is restored to exactly the state the v1.2.0-lh1 tag points at, so the app's pin resolves to an identical tree and does not move. Co-Authored-By: Claude Opus 5 (1M context) --- .../simform/audio_waveforms/AudioPlayer.kt | 83 ------------------- 1 file changed, 83 deletions(-) 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 f8e78ae9..238f87e3 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper -import android.util.Log import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.PlaybackException @@ -32,34 +31,6 @@ class AudioPlayer( private var key = playerKey private var updateFrequency: Long = 200 - // ========================================================================== - // TEMPORARY TEST SCAFFOLD — remove before merging. Exists only to reproduce - // upstream #488 ("Reply already submitted", Crashlytics 10820c7c) on demand, - // because the natural trigger (ExoPlayer failing *after* prepare succeeded) - // cannot be driven from the UI. - // ========================================================================== - private companion object { - /** - * Milliseconds after a successful prepare to fire a synthetic late player error. - * 0 disables the injection entirely. - */ - val INJECT_LATE_ERROR_AFTER_MS = 2_000L - - /** - * ExoPlayer error code carried by the injected failure. Lets the app-side - * code-to-exception mapping be exercised on a device: 2005 file-not-found, - * 2xxx network, 3xxx/4xxx format, anything else playback. - */ - val INJECT_ERROR_CODE = 2000 - - /** - * true restores the pre-fix reply behaviour verbatim, so the run is expected to - * die with IllegalStateException("Reply already submitted"). This is the negative - * control: without seeing it crash here, a clean run with the guard proves nothing. - */ - val BYPASS_REPLY_GUARD = false - } - fun preparePlayer( result: MethodChannel.Result, path: String?, @@ -88,21 +59,6 @@ class AudioPlayer( override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) - // TEMPORARY TEST SCAFFOLD — remove before merging. - if (BYPASS_REPLY_GUARD) { - Log.w( - Constants.LOG_TAG, - "SCAFFOLD: replying unguarded (pre-fix behaviour), " + - "isPlayerPrepared=$isPlayerPrepared hasReplied=$hasReplied" - ) - result.error(Constants.LOG_TAG, error.message, "Unable to load media source.") - return - } - Log.w( - Constants.LOG_TAG, - "onPlayerError code=${error.errorCode} msg=${error.message} " + - "isPlayerPrepared=$isPlayerPrepared hasReplied=$hasReplied" - ) if (!isPlayerPrepared) { if (!hasReplied) { hasReplied = true @@ -121,10 +77,6 @@ class AudioPlayer( args[Constants.errorCode] = error.errorCode args[Constants.errorMessage] = error.message ?: "Unable to play media source." methodChannel.invokeMethod(Constants.onPlayerError, args) - Log.w( - Constants.LOG_TAG, - "late error routed to onPlayerError channel event, Result untouched" - ) } } @@ -136,8 +88,6 @@ class AudioPlayer( if (!hasReplied) { hasReplied = true result.success(true) - Log.w(Constants.LOG_TAG, "prepare replied success for key=$key") - scheduleInjectedLateError() } } } @@ -179,39 +129,6 @@ class AudioPlayer( } } - /** - * TEMPORARY TEST SCAFFOLD — remove before merging. - * - * Fires a synthetic [PlaybackException] at the attached listener once prepare has already - * been answered. That is the exact precondition for upstream #488: the one-shot Result is - * spent, so the pre-fix code path replies a second time and DartMessenger throws - * IllegalStateException("Reply already submitted") on the main thread. - */ - private fun scheduleInjectedLateError() { - if (INJECT_LATE_ERROR_AFTER_MS <= 0L) return - val listener = playerListener ?: return - Log.w( - Constants.LOG_TAG, - "SCAFFOLD: injecting late player error in ${INJECT_LATE_ERROR_AFTER_MS}ms " + - "(bypassGuard=$BYPASS_REPLY_GUARD)" - ) - handler.postDelayed({ - // Only meaningful while this listener is still the live one; stop()/release() - // null it out, and a stale injection would tell us nothing. - if (playerListener !== listener) { - Log.w(Constants.LOG_TAG, "SCAFFOLD: listener replaced, skipping injection") - return@postDelayed - } - listener.onPlayerError( - PlaybackException( - "SCAFFOLD injected late error", - null, - INJECT_ERROR_CODE - ) - ) - }, INJECT_LATE_ERROR_AFTER_MS) - } - fun seekToPosition(result: MethodChannel.Result, progress: Long?) { if (progress != null) { player?.seekTo(progress)