Skip to content

feat: debounce rapid next/prev into a single deferred track load - #350

Open
palchrb wants to merge 1 commit into
devgianlu:masterfrom
palchrb:claude/upstream-skip-debounce
Open

feat: debounce rapid next/prev into a single deferred track load#350
palchrb wants to merge 1 commit into
devgianlu:masterfrom
palchrb:claude/upstream-skip-debounce

Conversation

@palchrb

@palchrb palchrb commented Aug 3, 2026

Copy link
Copy Markdown

Closes the first half of #346.

A burst of next/prev presses currently loads every track it passes through — one audio-key request per press — and Spotify answers 429 well before the listener finds their song. This splits the skip into a pointer move and a deferred load: presses move the selection immediately (published via will_play and a new pending_track_uri in /status), and only the track the user stops on gets loaded.

The window (skip_debounce_ms, default 600, 0 disables) was measured, not guessed: across two sessions of continuous skipping from the Spotify iOS app, gaps between Connect commands ranged 74–495ms. Deferred pointer moves also skip the connect-state PUT (only the first of a burst publishes) — the burst otherwise outruns the endpoint's tolerance and reproduces the same 429 on a different endpoint. Field-tested on a Pi Zero 2 W: a 20-press burst now costs 2 loads and no rate limiting.

Holding down next on a Connect controller currently loads every track it
passes through: each press resolves a track, requests an audio key and
opens a stream, only to throw all of it away when the next press arrives
milliseconds later. Skipping ten tracks costs ten audio-key requests,
and Spotify starts answering 429 well before a listener has found what
they were looking for.

Split the advance into a pointer move and a load. A skip that arrives
inside skip_debounce_ms of the previous one moves the pointer and arms a
settle timer instead of loading; the timer is reset by every further
skip, so a burst of any length resolves to exactly one load of the track
the user actually stopped on. The first skip of a burst still loads
immediately, so a single press is as responsive as before, and setting
skip_debounce_ms to 0 disables debouncing entirely.

The settle timer is a case in the existing Run loop select, alongside
the prefetch and state timers, so the deferred load runs on the same
goroutine as every other player mutation and needs no locking. Clients
are not left guessing during the wait: every pointer move emits
will_play with the pending track, and the daemon exposes the pending
position so a UI can follow the burst in real time.

The window defaults to 600ms, chosen from measurement rather than
taste. Burst detection measures the gap between one skip finishing and
the next arriving, and that gap is far wider than the interval between
presses when the controller is the Spotify app, which paces its Connect
commands. Cached tracks widen it further rather than narrowing it: they
load in a fraction of the time, so the daemon spends longer idle between
skips and each press starts to look like the first press of a new burst.
Across two measured sessions of continuous mashing from the app, the
gaps ranged from 74ms to 495ms — a 400ms window classified a third of
one burst as fresh presses. The costs of mis-sizing are asymmetric: a
window that is slightly too small splits a burst and costs one extra
load, while a window that is too large adds its full length to the
silence after the last press of every burst. 600ms covers everything
observed with headroom without making the settle noticeably laggy.

Deferred pointer moves do not publish the connect state. Each one is
superseded within the window, so PUTting them describes a track that is
never loaded and outruns the endpoint's coalescing interval — the same
429 the feature exists to avoid, on a different endpoint. Only the first
deferral publishes, which is what tells the app that skipping has
started, and the settled load publishes the track that was chosen. A
client driving its own UI from the websocket is unaffected, since
will_play still fires on every move.

Several paths need to know a load is outstanding. Prefetching is
suppressed during a settle, since it would perform the very requests
being deferred. The not-playing and stop player events are ignored,
because the pointer has already moved past the track that ended and the
output will be reopened by the settle. A seek flushes the pending load
first, so it acts on the track the user chose rather than the one still
playing. The wait itself is not counted as playback, so the settled
track starts at 0:00 instead of skip_debounce_ms in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FHWG4ossSBydy7jbVFWho
@palchrb
palchrb force-pushed the claude/upstream-skip-debounce branch from c7fade9 to 70376a8 Compare August 4, 2026 05:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant