How AudioPlaybackButton Handles State Changes in the Muse Audio Player

The AudioPlaybackButton component manages playback state through a shared PlaybackState enum, platform-specific observers that listen to native player callbacks, and Compose MutableState objects that trigger UI recomposition.

The AudioPlaybackButton in the kkoshin/muse repository provides a multiplatform Compose UI solution for audio playback across iOS and Android. This component abstracts platform-specific audio engines into a unified state management system that synchronizes native player events with Jetpack Compose recomposition.

Shared State Architecture in commonMain

The PlaybackState Enum

The foundation of state handling begins in the common module where the PlaybackState enum defines all possible playback conditions. Defined in [AudioPlaybackButton.kt](https://github.com/kkoshin/muse/blob/main/muse/src/commonMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.kt#L7-L12), this enum includes Idle, Ready, Buffering, and Finished states that both platforms must map their native players to.

iOS State Management with AVFoundation

AVPlayer Observers and Notifications

On iOS, the implementation creates an AVPlayer and AVPlayerItem then registers two critical NSNotificationCenter observers. When playback completes naturally, the AVPlayerItemDidPlayToEndTimeNotification triggers a callback that sets playbackState to Finished and isPlaying to false (lines 68-76). Conversely, playback failures are caught by AVPlayerItemFailedToPlayToEndTimeNotification, which logs the error and resets the state to Idle (lines 78-86).

Polling with LaunchedEffect

Because AVPlayer does not provide continuous callbacks for buffering transitions, the iOS implementation uses a LaunchedEffect(player) coroutine that polls every 100 milliseconds. This loop inspects currentItem.status to transition from Buffering to Ready, captures errors, calculates progress using CMTimeGetSeconds, and mirrors timeControlStatus to the isPlaying boolean (lines 104-136).

Android State Management with ExoPlayer

ExoPlayer Listener Integration

The Android implementation attaches a Player.Listener to the ExoPlayer instance to receive immediate state changes without polling. The onPlaybackStateChanged callback maps ExoPlayer's native states to the shared PlaybackState enum, converting states like STATE_BUFFERING to PlaybackState.Buffering (lines 52-58). The onIsPlayingChanged callback directly updates the playing mutable state boolean (lines 60-62).

Progress Synchronization Coroutines

Rather than polling, Android uses a LaunchedEffect that activates whenever playbackState or playing changes. This coroutine updates the progress float while the player is actively playing, and forces final values when reaching Finished or Idle states (lines 72-89).

UI State Binding and User Interaction

MutableState Composition

Both platforms declare MutableState objects to bridge native player state with Compose recomposition. The iOS implementation uses var playbackState by remember { mutableStateOf(PlaybackState.Idle) } while Android uses val playbackState = remember { mutableStateOf(PlaybackState.Idle) }. These states drive the visual PlaybackButton composable shared across platforms.

Handling User Interactions

The click handler inspects the current playbackState to determine behavior. When the state is Ready, it toggles play/pause via onUpdatePlayWhenReady. When Finished, it triggers onResetProgress to seek to the start and restart playback. The iOS implementation handles this in lines 176-191, while Android implements similar logic in lines 135-150.

@Composable
fun AudioPlayerScreen(audioPath: Path) {
    AudioPlaybackButton(
        modifier = Modifier.size(64.dp),
        audioSource = audioPath,
        onProgress = { progress ->
            // Handle progress updates (0.0 to 1.0)
            println("Playback progress: ${(progress * 100).toInt()}%")
        }
    )
}

Summary

  • The PlaybackState enum in commonMain provides a platform-agnostic state contract (AudioPlaybackButton.kt).
  • iOS uses NSNotificationCenter observers for completion and error events, plus a polling LaunchedEffect for buffering detection and progress updates.
  • Android leverages ExoPlayer's Player.Listener for real-time state callbacks and coroutine-based progress tracking.
  • Both platforms expose state through MutableState objects that trigger Compose recomposition automatically.
  • User interactions are handled by inspecting the current playbackState and delegating to platform-specific play/pause or seek operations.

Frequently Asked Questions

How does AudioPlaybackButton detect when audio finishes playing on iOS?

On iOS, the component registers an observer for AVPlayerItemDidPlayToEndTimeNotification through NSNotificationCenter. When this notification fires, the callback updates the playbackState to Finished and sets isPlaying to false, triggering UI recomposition to show the replay button.

Why does the iOS implementation use polling while Android uses listeners?

The iOS implementation polls every 100 milliseconds via LaunchedEffect because AVPlayer does not provide continuous callbacks for buffering state transitions or precise progress tracking. Android's ExoPlayer offers the Player.Listener interface with onPlaybackStateChanged and onIsPlayingChanged callbacks, eliminating the need for polling and providing more efficient real-time updates.

What happens when a user clicks the button while audio is playing?

When the user clicks during playback (state Ready with isPlaying true), the component calls onUpdatePlayWhenReady to toggle the native player's pause state. On iOS this pauses the AVPlayer; on Android it sets playWhenReady to false on the ExoPlayer. The native player then triggers its state callbacks, which update the Compose MutableState and recompose the UI to show the play icon.

How is playback progress calculated and updated in the UI?

Progress updates differ by platform. iOS calculates progress by converting CMTime to seconds using CMTimeGetSeconds within the polling LaunchedEffect loop. Android derives progress from player.currentPosition and player.duration within a LaunchedEffect that activates when playback state changes. Both update a MutableState<Float> that drives the circular progress indicator in the shared PlaybackButton composable.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →