The ended-state trap during a delegate swap

SkillDev tools

Suppress the "ended" playback state at a forwarding-player boundary while the underlying player is being replaced, so the media service is not torn down in the gap between one player finishing and the next starting. Use when background playback stops partway through a queue on some devices but never on your development phone, when the playback notification disappears between tracks, or when the app is frozen by the system mid-queue.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the The ended-state trap during a delegate swap skill

What this skill tells your AI

The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/fgs-state-ended-trap/SKILL.md and read by ahel’s review.

The "FGS" in the name is Android's foreground service — the status that keeps a media app alive in the background. When each track gets its own player object, the end of a track looks like this from the outside:

player A reports ended ──┐
                         │  ~25 ms
player B swapped in, plays ┘

For those few milliseconds the media session is asked for the playback state and answers ended. Foreground status survives only while the player intends to play and the state is ready or buffering — ended fails that test, so the service detaches its notification and stops being a foreground service. (The state field is the one consulted, which is why the fix below remaps the state rather than faking isPlaying.) On stock builds the app usually survives long enough for player B to arrive. On the more aggressive power-management builds some manufacturers ship, the app is frozen in that window and playback simply stops mid-queue.

The fix lives at the forwarding boundary — one flag and one getter:

@Volatile var suppressPlaybackEnded = false

override fun getPlaybackState(): Int {
    val state = super.getPlaybackState()
    return if (state == Player.STATE_ENDED && suppressPlaybackEnded) Player.STATE_BUFFERING else state
}

The wrapper reports buffering — true in substance, since the next player is being prepared — and the service has no reason to stand down.

Traps

Remap in the getter, not by rewriting events. The session layer does not only listen; it queries the player when it builds its state. Filtering onPlaybackStateChanged before it reaches listeners leaves the pull path untouched, and the pull path is the one that decides the service's fate. Override the getter and let the events say whatever they say.

Branch on "is there a next item". Remapping unconditionally means the genuine end of a queue is never reported: the UI keeps a spinner forever, the notification never clears, and the service never stands down at all — which is a worse bug than the one being fixed, just quieter. Set the flag only on the branch that is about to continue:

Player.STATE_ENDED -> {
    if (hasNextMediaItem()) {
        forwardingPlayer.suppressPlaybackEnded = true
        transitionToState(InternalState.PREPARING)
    } else {
        transitionToState(InternalState.ENDED)
    }
    handleTrackEndInternal()
}

Clear it on every exit, not just the happy one. A flag set and never cleared makes the player permanently answer "buffering", so the service never releases and the transport controls behave as if a track were forever about to start. The paths that must clear it:

  • the next player actually started playing — the intended exit;
  • the track finished loading and reached ready;
  • the load failed — an error path that leaves the flag set is the trap inside the trap, because the app now looks busy forever instead of showing an error;
  • pause() and stop() — the user ended playback themselves, and the flag must not outlive that decision.

Grep for the assignment, not the declaration: the count of = false sites should match the number of ways out of the transition, and a review that only reads the getter will not notice one missing.

Mark it @Volatile. It is written from the playback thread as the track ends and read from whichever thread the session queries on. Without it, the clear can go unseen and the player is stuck reporting buffering for the rest of the process.

This will not reproduce on your device. The window is a few tens of milliseconds and only matters where the system is willing to act within it. Testing means turning on the strictest battery-optimisation setting the device offers, backgrounding the app, locking the screen and letting a long queue run — not stepping through a debugger, which widens the window past anything realistic. Log the value the wrapper returns rather than the one the underlying player reports; those are the two different numbers this whole mechanism exists to keep apart.

Suppression hides genuine end-of-stream from your own code too. Anything else reading the state through the wrapper — a UI that resets on ended, analytics that count completions — sees buffering as well. Keep your internal state machine on its own signal (the adapter above knows perfectly well that the track ended; it is what set the flag) and treat the remapped value as something exported to the platform, not as your source of truth.

Do not extend the window "to be safe". The temptation is to set the flag on every transition, or a little earlier, or to leave it until some later checkpoint. Each extension enlarges the period in which a real stop is invisible to the platform, and the platform's view of your service is the only thing keeping playback alive. Set it as late as possible, clear it as early as possible, and make the clear unconditional in a finally-shaped position wherever the language allows.

Verifying it

Run from the repository root; the flag and its call sites live under core/media/media3/src/main/java/com/maxrave/media3/exoplayer/.

  1. The flag is @Volatile:

    grep -n -B1 "var suppressPlaybackEnded" \
      core/media/media3/src/main/java/com/maxrave/media3/exoplayer/DelegatingForwardingPlayer.kt
    

    Pass condition: the line above the declaration is @Volatile.

  2. Every = false site maps to a real exit, and there is exactly one = true:

    grep -n "suppressPlaybackEnded = " \
      core/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt
    

    Run here: one = true (transition starts) and five = false sites, each at one of the four listed exits (next player playing, reached ready, load failed, pause/stop). Fewer = false sites than your state machine has exits is one that will get stuck.

  3. The getter remaps state; the event stream stays untouched:

    grep -n "override fun getPlaybackState\|override fun onPlaybackStateChanged" \
      core/media/media3/src/main/java/com/maxrave/media3/exoplayer/DelegatingForwardingPlayer.kt
    

    Pass condition: only getPlaybackState is overridden. Filtering onPlaybackStateChanged instead leaves the session's own pull-based query seeing the real ENDED state.

  4. By hand — a debugger cannot reproduce this. Turn on the device's strictest battery-optimisation setting, start a queue of four-plus tracks, background the app, lock the screen, let it run unattended. Correct: audio survives every track boundary. Regression: playback stops exactly at a boundary (never mid-track) and the notification is gone on unlock.

Signals

GitHub stars
315
Forks
6
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
fgs-state-ended-trap
Source
github.com/maxrave-dev/kotlin-footguns