# How AudioPlaybackButton Handles State Changes in the Muse Audio Player

> Discover how the AudioPlaybackButton in kkoshin/muse uses PlaybackState enum, observers, and Compose MutableState to efficiently handle state changes. Learn more about its robust implementation.

- Repository: [Ko Shin/muse](https://github.com/kkoshin/muse)
- Tags: internals
- Published: 2026-03-05

---

**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](https://github.com/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/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](https://github.com/kkoshin/muse/blob/main/muse/src/iosMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.ios.kt#L68-L76)). Conversely, playback failures are caught by `AVPlayerItemFailedToPlayToEndTimeNotification`, which logs the error and resets the state to `Idle` ([lines 78-86](https://github.com/kkoshin/muse/blob/main/muse/src/iosMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.ios.kt#L78-L86)).

### 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](https://github.com/kkoshin/muse/blob/main/muse/src/iosMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.ios.kt#L104-L136)).

## 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](https://github.com/kkoshin/muse/blob/main/muse/src/androidMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.android.kt#L52-L58)). The `onIsPlayingChanged` callback directly updates the `playing` mutable state boolean ([lines 60-62](https://github.com/kkoshin/muse/blob/main/muse/src/androidMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.android.kt#L60-L62)).

### 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](https://github.com/kkoshin/muse/blob/main/muse/src/androidMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.android.kt#L72-L89)).

## 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](https://github.com/kkoshin/muse/blob/main/muse/src/iosMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.ios.kt#L176-L191), while Android implements similar logic in [lines 135-150](https://github.com/kkoshin/muse/blob/main/muse/src/androidMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.android.kt#L135-L150).

```kotlin
@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](https://github.com/kkoshin/muse/blob/main/muse/src/commonMain/kotlin/io/github/kkoshin/muse/audio/ui/AudioPlaybackButton.kt#L7-L12)).
- 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.