--- name: media-playback-footguns description: "Media player traps on Media3/ExoPlayer and desktop engines: crossfade, audio focus, fades, loudness, DSP, queues and shuffle, position restore, service lifecycle, group listening. Use when playback state, queue or audio levels go wrong." --- # Media playback footguns 33 traps mined from a production Kotlin and Compose Multiplatform app, one short file each under `references/`. Every file gives the working pattern, the **Traps** (the specific ways it fails in practice, and why), and a **Verifying it** section with commands to run against the user's own tree. **How to use this index.** Match the code about to be written, or the symptom being chased, against the entries below: each names its topic and the symptom that should send you to it. Read every file that plausibly applies with the Read tool before proposing code or a fix; each is under 150 lines. Where a Verifying command names the source project's paths, substitute the equivalent paths in the user's tree. **Cross-references.** A backticked trap name inside a file that is not listed here lives in a sibling area skill: glob `../*/references/.md` to open it. ## Players, crossfade and engine lifecycle - [`crossfade-dual-player`](references/crossfade-dual-player.md) — Build a crossfade between two media items with one player instance per item and a second live instance during the blend, then keep transport commands and playback settings correct while two players are audible. Use when adding a fade to a player, or when pausing mid-fade leaves the old track playing underneath, a seek appears to do nothing, playback speed reverts to 1.0x after a skip, or the volume slider fights the ramp. - [`guard-on-every-trigger-path`](references/guard-on-every-trigger-path.md) — Keep the start conditions of a feature that fires from two entry points — typically a polling loop and an end-of-item callback — identical on both paths, because a condition added to only one of them is dead code that produces no error, no log and no crash. Use when a newly added guard, exclusion or feature flag appears to have no effect at all, or when a feature behaves correctly most of the time and wrongly in one specific timing. - [`crossfade-exclusion-heuristics`](references/crossfade-exclusion-heuristics.md) — Decide when a crossfade must NOT run — the item plays as video, the item is too short for the fade, or two consecutive items belong to the same album — and encode each rule so it survives shuffle, an auto-length fade and a queue that keeps growing. Use when a fade cuts a song short, a video jumps to its first frame under the previous track, a 25-second interlude spends half its length fading, or an album sequenced to run continuously is interrupted between every track. - [`beatmatched-automix`](references/beatmatched-automix.md) — Derive an automatic crossfade duration and a tempo/key match from how far apart two tracks are, the way a DJ would — halftime normalisation before comparing tempos, beat-quantised durations, a front-loaded ramp and quantised gain/speed steps. Use when an automatic transition length feels arbitrary, when tracks an octave apart in tempo are treated as a huge gap, when tempo matching only lands after the outgoing track is inaudible, or when ramping speed produces ticks. - [`forwarding-player-hot-swap`](references/forwarding-player-hot-swap.md) — Swap the underlying player beneath a `ForwardingPlayer` at runtime while the media session and the UI keep one stable reference — re-attaching listeners and the video output, and answering the playlist questions a one-item timeline cannot. Use when next/previous buttons vanish from the system notification, when nothing updates after the first swap, when video stops rendering after a track change, or when reporting a playlist index makes the app stop. - [`fgs-state-ended-trap`](references/fgs-state-ended-trap.md) — 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. - [`polymorphic-load-media-entry-point`](references/polymorphic-load-media-entry-point.md) — One generic "load this item and start playing" function normalizes several item types into a single internal shape, routes the queue-seeding strategy off a discriminator, and gates every pre-enqueue policy at that one place. Use when playback can start from many screens and a rule (content filter, dedup, analytics, resume-without-autoplay) keeps getting forgotten on one of the paths. - [`play-intent-decided-before-load`](references/play-intent-decided-before-load.md) — Decide whether the freshly-loaded item should start, and where it should start, before you build it — then pass both into the load call, rather than loading with a hardcoded "start playing" and correcting it a moment later. Reach for it when a follower in a synchronised room bursts into sound in a session everyone else has paused, or when a newly loaded item audibly starts and then stops. - [`intent-flag-not-observed-state`](references/intent-flag-not-observed-state.md) — A component that is committed to running but not yet running reports "not running", so anything that means intent must read the intent flag, not the observed one — and at a transition the intent flag must be waited for with a timeout rather than sampled inline. Use when one client's buffering hiccup stops a whole synchronised group, when appending to a queue in the background silences the track that was about to start, when a resume command is issued on every tick, or when a state read is wrong on exactly the transitions it exists for. - [`derive-seek-from-progress-flow`](references/derive-seek-from-progress-flow.md) — Detect a scrub as a playhead that moved further than wall-clock time can account for, from the progress stream every platform already emits, because the platform's own discontinuity callback exists on one backend only and neither the item stream nor the transport stream fires when a scrubber is dragged. Use when a seek by one member of a shared session is never sent, or when a cross-platform layer needs an event only one platform provides. - [`optional-engine-feature-degrade-in-tiers`](references/optional-engine-feature-degrade-in-tiers.md) — An optional build-time dependency of a media engine may be missing from another platform's bundle, and the engine rejects the WHOLE chain string when one stage in it is unknown — so retry without the optional stage rather than losing the mandatory one, and return which tiers were accepted so callers never drive a stage that is not there. Use when a feature works on one platform's bundle and silently does nothing on another. ## Audio processing: focus, fades, loudness and DSP - [`media3-custom-audio-processor`](references/media3-custom-audio-processor.md) — Write a custom audio processor for a Media3/ExoPlayer audio pipeline — a filter, a fade, a gain stage — that is toggled at runtime and shared across several concurrent players. Use when a processor you added does nothing until the next track, when playback wedges with no error, when `put(ByteBuffer)` throws on your own output buffer, or when two simultaneous players need one parameter to reach both. - [`realtime-biquad-dsp`](references/realtime-biquad-dsp.md) — Build a small real-time IIR filter in pure Kotlin from the audio-EQ-cookbook formulas — low-pass, high-pass, or a bank of peaking sections — with cascaded stages for a steeper slope, independent state per channel, neutral stages that keep the state size fixed, and lazy coefficient recompute. Use when a sweepable filter is needed inside an audio callback, when a stereo filter collapses the stereo image, when the filter output is silence or NaN, or when sweeping a cutoff or dragging a band produces ticks. - [`audio-fade-separate-gain-line`](references/audio-fade-separate-gain-line.md) — Implement a programmatic audio fade — a sleep timer, an alarm ramp, a duck — on a gain line of its own instead of the user's volume, and restore that gain from the player's own completion path. Use when a fade drags the volume slider down in the UI, when the app comes back permanently silent with a full slider, when a fade still ends in an audible click, or when audio briefly swells back after a fade completes. - [`audio-focus-multiplayer`](references/audio-focus-multiplayer.md) — Hold Android audio focus once at app level when several player instances are alive at the same time — dual-player crossfade, precached players — and keep focus-driven ducking off whatever gain line a fade already owns. Use when background playback dies between tracks, autoplay stalls after the first item, a duck never takes effect or never lifts, or volume jumps back to full in the middle of a transition. - [`per-track-loudness-normalization`](references/per-track-loudness-normalization.md) — Apply per-track loudness normalisation with the platform's loudness enhancer when each track gets its own player — re-creating the effect per track because it stays attached to one audio session, skipping it while a remote-playback session is active, and clamping the gain that comes from metadata. Use when normalisation works on the first track and silently stops afterwards, when enabling it throws while nothing is playing, or when every other track in a queue comes out louder. - [`filter-chain-two-owners-one-writer`](references/filter-chain-two-owners-one-writer.md) — Two independent features want entries in one engine property that holds the WHOLE chain, so writing it replaces everything — keep each feature's entries in its own field, compose them in a single writer, and let "clear" drop only its own tier. Use when a second effect is added beside an existing one, or when one of two effects works and then randomly stops working after a transition. - [`sampled-supplier-vs-per-handle-reapply`](references/sampled-supplier-vs-per-handle-reapply.md) — Two playback backends need opposite plumbing for the same setting — one whose per-stream consumers sample a shared field needs no push at all, one whose every new handle starts blank needs an explicit re-apply at each creation site. Use when a setting reaches the current track but not the next one, when it survives on one platform and not the other, or when a level set on several handles keeps reverting. - [`one-setting-two-backends`](references/one-setting-two-backends.md) — Make one stored value mean the same thing on two unrelated audio backends by defining the band centres, the width and the range once, verifying both against a reference implementation instead of by ear, and declining the platform's built-in effect whose parameters vary per device. Use when a tone or gain setting is being added on more than one platform, or when the same saved setting sounds different on each. ## Queues, shuffle and position - [`queue-index-vs-shuffle-space`](references/queue-index-vs-shuffle-space.md) — A player's "current index" is exposed to the UI but freezes on track change or highlights the wrong row once shuffle is on, because the engine's timeline order and the shuffled play order are two different index spaces. Use when the now-playing marker in a queue list is stuck, points one row off, or lights up every copy of a repeated track. - [`custom-shuffle-order`](references/custom-shuffle-order.md) — Replacing a media engine's default shuffle order so that tracks added mid-playback land contiguously after the current one instead of being scattered through the rest of the queue. Use when "play next" or an appended continuation page ends up in random positions while shuffle is on, or when writing any custom shuffle order and needing the insert/remove/clone contract to stay consistent. - [`endless-queue-management`](references/endless-queue-management.md) — One StateFlow holds a growing playback queue but has two write paths on purpose — a full setter that resets derived snapshots, and continuation appends that write the backing field directly so those snapshots survive. Use when a feature keyed on "where the queue came from" stops working once the queue auto-extends, or before refactoring two queue write paths into one. - [`queue-rebuild-state-machine`](references/queue-rebuild-state-machine.md) — A rebuild-state flag on the queue marks "being rebuilt" versus "stable" (two operative values, whatever the enum declares), so re-entrant load requests return early and nothing snapshots the queue while it is half-built. Use when pagination fires twice for one scroll, when a restored queue comes back missing the track that was playing, or when a loading state never clears after an error. - [`dual-source-queue-sync`](references/dual-source-queue-sync.md) — A UI-facing track list and the playback engine's timeline both hold the queue, so the UI list is re-derived from the engine timeline by media id after every engine-side change, refused when the sizes disagree, and mutated on both sides for user reorders. Use when the queue on screen plays in a different order than it shows, when shuffle scrambles the list but not playback, or before adding a second place that writes the queue. - [`playback-position-persist-restore`](references/playback-position-persist-restore.md) — Persisting and restoring the playback position cheaply — a full queue save on lifecycle edges versus a light five-second position tick that rides an existing loop, skipping the save while the queue is being rebuilt, and snapshotting values before issuing player commands that change them. Use when background playback resumes from the start of a track after the process is killed, or when a restore lands on the wrong track or position. ## Shared listening sessions - [`sync-room-bridge-echo-guard`](references/sync-room-bridge-echo-guard.md) — One bridge joins a local component to a shared room, with the direction of travel decided by role — the source publishes what it does, the follower applies what arrived and publishes nothing — plus a flag held across the apply so a locally-observed side effect of a remote command is not fed straight back. Use when two clients in a synchronised session ping-pong each other, when applying a remote pause immediately republishes a pause, or when a follower's own reactions fire on state it did not cause. - [`server-default-overrides-client-intent`](references/server-default-overrides-client-intent.md) — A relay that owns the shared state stamps its own default onto fields your command did not set — most painfully forcing "not running" onto every item change — so a follower that obeys the message verbatim stops the thing it just loaded. Carry the previous intent across the change, and publish the missing field as a second command. Use when followers in a synchronised room go silent on every next/previous/end-of-item while the source plays on. - [`position-based-group-sync`](references/position-based-group-sync.md) — Keep a group of clients together by publishing the playhead with every command, correcting it for the time the command spent in flight, and seeking only when the local gap exceeds a tolerance — rather than by making everyone wait for the slowest member. Use when a synchronised session drifts audibly apart, when followers stutter continuously as they chase the source, or when each device resolves its own stream and therefore takes a different amount of time to be ready. - [`joiner-catches-up-by-asking`](references/joiner-catches-up-by-asking.md) — The state a relay pushes to a new member is the source's last command replayed, so its position is however old that command is — obeying it drops the joiner at the start of something everyone else is halfway through. Ask for the live position the moment you are in, and again whenever this client rejoins the shared timeline. Use when a member who joins mid-session starts from the beginning, or restarts at whatever position they last had locally. - [`follower-transport-stays-local`](references/follower-transport-stays-local.md) — A follower in a shared session owns its own stop button — pausing is local and silent, and pressing play asks where the session is now rather than restoring where this device stopped. Use when a follower cannot pause because the next state update immediately resumes it, when a follower's pause stops everyone, or when resuming lands minutes behind the rest of the group. - [`runtime-override-not-preference-write`](references/runtime-override-not-preference-write.md) — Turn a user-facing feature off for the duration of a mode with a runtime override on the component, never by writing the stored preference — a process death mid-mode would leave the user's real setting permanently changed. Use when entering a mode has to disable an existing feature, when a setting mysteriously turned itself off and stayed off, or when a mode's cleanup is the only thing standing between a user and a lost preference. - [`follower-item-built-from-shared-payload`](references/follower-item-built-from-shared-payload.md) — Build a follower's playable item from the payload the shared session carries, not by re-resolving it from your own catalogue — a local resolver that infers the rendition from artwork shape lands on the wrong one, and a per-item network round trip inside the apply collector wedges every later command behind it. Use when a follower in a synchronised room gets silent video where the source has audio, or when one slow lookup freezes a client's whole command stream. - [`readiness-barrier-needs-every-answer`](references/readiness-barrier-needs-every-answer.md) — A barrier that holds a group until every member reports ready — answer it on bufferedness rather than on playing, answer only when you are actually named, name who is being waited for in the UI, and understand that one member that never answers freezes everyone. Use when a shared session stalls for all participants after one slow device joins, when playback silently never starts, or when a "loading" state has no explanation attached to it.