Skip to content

Volume wheel bounce + mini-player-over-Spotify fixes - #1

Open
edward-rosado wants to merge 11 commits into
mainfrom
fix/volume-wheel-and-miniplayer
Open

Volume wheel bounce + mini-player-over-Spotify fixes#1
edward-rosado wants to merge 11 commits into
mainfrom
fix/volume-wheel-and-miniplayer

Conversation

@edward-rosado

Copy link
Copy Markdown
Owner

Two device-shell fixes, both confirmed on the Car Thing.

What's new here (the top 2 commits)

  • Stop the volume wheel bouncing. The wheel re-seeded each notch from a stale
    server echo, so it fought the user (turn up → jumps down, and vice-versa; seen
    live as requests ping-ponging 45↔100). setSong now holds the local volume for
    VOLUME_HOLD_MS after a wheel touch. Rule extracted to stores/volumeHold.ts
    with a test whose "THE REVERSAL" case models the exact loop and fails if the
    hold is removed.
  • Hide the shell mini-player over an app that owns its transport. Spotify draws
    its own transport, so the shell bar was a duplicate that also swallowed the
    volume wheel on reconnect. Gated on the app's isAudioSource flag, failing
    closed so it never flashes back over Spotify while the app list is still loading.

Notes

🤖 Generated with Claude Code

Edward Rosado and others added 11 commits August 4, 2026 23:31
Adds a small badge to the connection overlay naming the link that is
actually carrying data. The transport is probed, not assumed: /__bt is
answered locally by the Bluetooth mux when it owns the client port, so
a JSON reply means Bluetooth while a 404 means the request travelled to
the server over USB — no server support required, the badge simply
reads USB without it.

Also prefers Spotify's 300x300 artwork variant over the 640x640 one on
the proxied thumbnail path. The panel is 800x480, so the larger asset
is wasted bytes — about 0.7s of a saturated ~155KB/s Bluetooth link on
every track change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the computer initiates pairing, the on-device agent exposes the
6-digit numeric-comparison code at 127.0.0.1:8892/pairing; this overlay
polls it and draws the code fullscreen, then flashes the outcome — the
original Car Thing pairing experience. On anything that is not a
provisioned Car Thing the endpoint does not exist, the first two polls
fail, and the overlay permanently stands down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pairing exchange can resolve in a couple of seconds; at a 2s poll the
overlay could miss the whole window (observed on a real pairing — the
device agent captured the code but the screen never showed it). Poll
every 500ms so the code is on screen the entire time the person is
comparing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
closeExisting only closed a socket in the OPEN state, so a socket stuck
in CONNECTING — which is what happens when the server is unreachable at
dial time, the norm on a Bluetooth link that comes up ~80s after the
device boots — was abandoned without closing. Those half-open sockets
lingered, held a tunnel stream, and kept the client from cleanly
reconnecting once the link was finally up. Over USB the server is
reachable instantly so this never surfaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The size preference was only applied in the startsWith('http') branch,
but that branch is not the one Spotify artwork takes. The DeskThing
server rewrites thumbnails to a relative /proxy/v1?url=... before
sending them (songCache.ts:124), so the client lands in the
startsWith('/') branch, which only prefixed the host. The optimization
was dead code and the device kept fetching 640x640.

Caught by screenshotting the device and reading the actual <img> src:
ab67616d0000b273 (640px) where 00001e02 (300px) was expected.

Two changes: match the bare Spotify id prefix instead of '/image/<id>',
because by the time we see the URL the slashes are usually percent-
encoded inside the proxy query while the id is not; and apply it in the
relative branch too.

Verified on hardware over Bluetooth: the element now loads
ab67616d00001e02... at 300x276 instead of 640x588, and the art renders.
That is ~110KB down to ~36KB per track change on a link that saturates
at 155KB/s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured on hardware: 11.9s from page load until the track and artwork
were on screen. Two independent causes, both fixed.

The client never asked for song state. requestMusicData was only called
from inside the handler for an incoming song message, so the only path
to data was an unsolicited push — after any (re)connect the screen sat
on "Waiting For Track…" until the track happened to change or the
server's refresh interval came round. It now requests the current track
as soon as the socket reports connected.

Reconnect took a flat 15s before even trying. onclose waited 5s before
calling reconnect(), which waited a further 10s before dialling, with no
fast first attempt — so a link that dropped and returned immediately
still cost 15 seconds. Now 250ms to the first attempt and exponential
backoff from 500ms to a 10s ceiling, reset on a successful open.

Both are general client behaviour, not Bluetooth-specific, but Bluetooth
makes them constantly visible: the link legitimately drops when the
device is power-cycled, and it has no battery, so every power change is
a reconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hardware wheel's VolUp/VolDown compute each notch from song.volume, and
every server push merges its (lagging) volume back over it — so the wheel
re-seeds from a stale base and fights the user: turn up it jumps down, turn
down it jumps up. Seen live in the server log as requests ping-ponging
45<->100 before settling.

setVolume already stamps _volumeTouchedAt; setSong now holds the local volume
for VOLUME_HOLD_MS (4s) after a touch so the echo can't clobber it. Extracted
the rule to stores/volumeHold.ts (pure) with volumeHold.test.ts — its 'THE
REVERSAL' case models the exact 45<->100 loop and fails if the hold is removed.
Confirmed fixed on the device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsport

An app that IS the audio source (Spotify) draws its own transport, so the
shell's mini-player was a second set of the same controls and a second scrub
bar — and it swallowed the volume wheel. Gate it on the focused app's
isAudioSource manifest flag, read from the app LIST (currentView is persisted
prefs and carries no manifest after a view change). Fails CLOSED: while the app
list is still loading after a connect, assume the focused app owns its
transport, so the bar never flashes back over Spotify (and never eats the wheel)
on a fresh attach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the hold state

- musicStore now calls resolveVolumeOnPush (the TESTED helper) instead of
  re-implementing its one-line decision inline, and _volumeTouchedAt is
  declared in MusicState — the 'as any' smuggling meant a typo in either
  string compiled clean and silently disabled the anti-bounce hold.
- The focused-app-owns-transport rule extracted to
  overlay/useFocusedAppOwnsTransport (one home for the fail-closed logic and
  the app-list-not-currentView lookup; Miniplayer's internal stale
  currentView.manifest read is documented there as a follow-up).
- npm test self-contained: tsm as a devDependency instead of loading the
  loader out of a sibling repo's node_modules.
- VOLUME_HOLD_MS documented as DERIVED from the server's cache TTL + confirm
  margin, cross-referenced both ways — the two constants encode one physical
  quantity with no import to enforce it.
- Drop a pre-existing unused useEffect import surfaced by the build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two classes of error made `tsc --noEmit` fail, and the first would have broken
CI outright on Linux:

- TS1261: App.tsx imported './components/ButtonListener' while the file was
  buttonListener.tsx. macOS resolves this by accident; a case-sensitive
  filesystem does not. Renamed the file to match its exported component and the
  convention every sibling already follows (ConfigComponent, SwipeListener,
  TimeUpdater).
- 7x TS6133: unused imports across ConnectingScreen, WelcomeScreen, timeStore
  and serverWebsocketHandler. Each name appeared exactly once -- the import
  itself -- so all were dead.

Adds a `typecheck` script so this is one command in CI instead of a convention.
tsc 0 errors, tests 6/6, vite build unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs typecheck, tests and the vite build on ubuntu. The case guard exists
because the bug it catches shipped: App.tsx imported './components/ButtonListener'
while the file was buttonListener.tsx -- macOS resolved it, a case-sensitive
runner does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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