Skip to content

Speed, responsiveness and polish, with #1–#6 - #7

Merged
parda21 merged 76 commits into
mainfrom
integrate/polish
Oct 15, 2026
Merged

parda21 merged 76 commits into
mainfrom
integrate/polish

Conversation

@parda21

@parda21 parda21 commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

This brings in the macOS work from #1 to #6 unchanged, the speed and polish work, and fixes for what the review of #1 to #5 found. Merging it fast-forwards main and closes #1 to #6 as merged.

#6 (Keychain) is merged on top: the Web API client keeps its timeouts and retries and reads its token from the chosen store.

Speed and polish (merged as one branch, 57 commits). An audit of the whole app found 58 problems. The fixes, by area:

  • Frames and input. Each frame goes out whole as one synchronized update. Data is drawn in batches, and input comes first.
  • Covers. Kitty scales covers itself, and they are sent as RGB. They load a few at a time, visible ones first, with the next ones ahead. Covers the terminal no longer needs are deleted from it.
  • Controls. Seek, volume, play and skip show at once. Other devices are followed through Spotify's pushes instead of polling.
  • Data. Home, the library and the last list come from a disk cache at start. Requests time out, and rate limits are retried.
  • Interface. Overlays no longer crash in small windows. Every key has a mouse path. Layout and highlights are consistent.
  • Build. Release builds use thin LTO and strip their symbols. Tracks are buffered on disk instead of in /tmp.

Where it meets #1 to #5. The reconnect supervisor from #3 does all the retrying and feeds the loader's session state. Media keys, output, power and focus events go through the new event loop.

Review fixes for #1 to #5.

Every commit was checked to build on its own on Linux. The macOS-only fixes are checked by this PR's CI.

parda21 and others added 30 commits October 15, 2026 16:23
image's default features and ratatui-image's image-defaults pulled in
every format image supports, among them an AV1 encoder (rav1e), EXR,
TIFF, GIF and QOI, plus rayon for the first two. cloofy only decodes
cover art, which Spotify serves as JPEG and occasionally WebP, and
writes PNG snapshots. The unused codecs were about 4 MB of code in the
binary and 53 crates to compile on every clean build.

Enable just jpeg, png and webp. Every cover in the local image cache
(JPEG, WebP and one PNG) still decodes with that set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was no release profile, so release builds used 16 codegen units
per crate, no cross-crate LTO and kept the full symbol table. Thin LTO
with a single codegen unit lets the optimizer inline and drop code
across librespot, ratatui and the rest, and stripping leaves the
symbol table out of the shipped file.

On x86_64 Linux the binary goes from 35.4 MB to 20.7 MB on disk and
its text from 25.1 MB to 20.1 MB. Fat LTO is left out because it adds
minutes to every `cargo install` for little more, and panics still
unwind so a panic in a background task fails only that task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
librespot downloads each track that is not in the audio cache into a
temp file and plays it from there, keeping the file open until the
track ends. The session config left tmp_dir at its default, /tmp, which
is tmpfs on Fedora and many other systems. So every streamed track held
its whole file, 10-15 MB at 320 kbps, in RAM while it played, and twice
that around preloads and crossfades. That memory does not show in
cloofy's RSS but counts against the machine and the user's cgroup.

Put the temp files in ~/.cache/cloofy/tmp, on disk next to the audio
cache. Unlike tmpfs, that directory is not cleared at boot, so files
left by a killed cloofy are removed on the next start. Only files older
than an hour go, because another cloofy process may have just created
one and not yet reopened it by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frames went to stdout through ratatui's default terminal, whose
LineWriter sends at most 1 KiB per write. A full repaint during a page
change, a scroll or an animation reached the terminal as dozens of
small writes, and Ghostty could paint between two of them and show a
half-drawn frame.

Draw through a terminal that collects the frame in a 64 KiB buffer, and
wrap every frame in synchronized-update mode 2026, so the terminal
holds its screen until the frame is complete. ratatui::init still sets
up raw mode, the alternate screen and the panic hook. Terminals without
mode 2026 ignore it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ratatui-image sends a Kitty image's pixels only once, prefixed to the
symbol of the image's top-left cell in the first frame that draws it.
Overlays are drawn after the page and clear the cells under them, so a
cover whose first frame came while the palette, a menu, a picker or
the help was open lost its upload. Its cells then named an image the
terminal never got, and the cover stayed blank until it had been out
of sight long enough to be encoded again.

Take the upload off the cell when the image is drawn, keep it with the
image's placement, and write it after the frame's cells, inside the
same synchronized update. The upload is a virtual placement that does
not move the cursor, and the terminal shows the waiting cells as soon
as it arrives. This also lets the effect and hover filters, which skip
cells that start with the placeholder, recognise that first cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every event from the loader or the player cost a full frame. Opening
Home or an artist page brought one frame per cover, each also sending
that cover's pixels, and the 3 s playback poll redrew even when it only
confirmed what was shown. tokio::select! picked among ready branches at
random, so a key press during such a burst could wait behind several of
these frames.

Handle what else is already queued before drawing, as input already
did, with at most 12 images per frame so that one frame's uploads stay
small. Make the select biased with input first. App::on_event now says
whether anything visible changed: the playback poll while cloofy plays
or nothing plays, like checks that confirm the known state, player
events while another device plays and replies for a page that is no
longer open no longer cause a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The highlight looked for the topmost interactive target under the
mouse. An overlay's body is not interactive, so over the help, a menu
or a picker the search fell through to the row or card underneath and
lit up a band inside the overlay. Clicks and mouse moves already take
the topmost target first.

Take the topmost target and highlight it only if it is interactive.
Overlay items still light up, since they lie above the overlay's body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mouse move redraws only when the target under the mouse changes, but
the remembered target was updated only on mouse moves. After a wheel
scroll, a key or loaded data moved rows or cards under a resting
mouse, the frame highlighted the new target while the old one stayed
remembered. Moving the mouse back onto the old target then looked like
no change, nothing was drawn, and the highlight and a card's play
button stayed on the wrong item.

Note the target under the mouse at the end of every frame, so the next
move compares against what is on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every page change, Back included, ran a 240 ms sweep that started the
whole content area as background and uncovered it from left to right.
The right side of the new page, often its durations and buttons, stayed
hidden for most of that time, covers showed before their text, and
holding Enter or Esc restarted the sweep on every page.

Fade only the text of a new page in, over 120 ms, so backgrounds, the
selection and covers are there in the first frame. Skip the fade when
the page changed less than 300 ms before, so browsing fast is instant,
and leave it out of Back: a page seen before comes back at once, and a
fade still running from the page just left is stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The play button only recolored its three cells. Over a cover they still
held image cells, so Kitty kept drawing the picture around the icon and
half blocks showed through in tmux: a dark notch, two bits of the cover
and a lone triangle instead of a button. On shortcut tiles the button
also landed on the end of the title.

Write blank cells in the accent color before the icon, so the button is
one solid piece. The dark edge that sets it off from the cover is only
drawn over covers; on tiles it looked like a hole. A hovered tile now
truncates its name so it ends before the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers are scaled to fit their slot with the aspect ratio kept. Every
slot is square, so portrait and landscape photos, common for artists
and users, came out narrower or shorter than the slot and left an empty
strip beside or below them, while the square album art around them
filled theirs. Round portraits shrank too, since the circle mask takes
the shorter side.

Cut non-square images to their middle square before anything else, as
Spotify's client does. Square art is untouched and the disk cache keeps
the original bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cover requested by a frame got its own task, and each decoded,
scaled and encoded its image on the blocking pool with no limit. Home's
first paint asks for about fifty covers, so fifty CPU-heavy jobs ran at
once, each holding a few megabytes of buffers: every core busy, memory
spiking by tens of megabytes, and the interface and audio threads
starved while the covers streamed in, all finishing late and together.

Give thumbnails up to four turns (two cores fewer than the machine has,
at least one), taken after the download and held until the encode is
done. Downloads still run freely. The player bar and Now Playing covers
skip the queue, since they are one image each and should not wait
behind a screenful of thumbnails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cover a frame drew was loaded to the end, even when it was long
gone from the screen. A fast scroll through the library or Home queued
every cover it passed, and the ones where the scroll stopped came last,
after the CPU had decoded and encoded all the others for nothing.

Number the frames, and keep in each loading image's slot the last frame
that drew it. After its download and again once it has a turn, a job
whose image the last finished frame did not draw is dropped and reports
back as ImageSkipped. The app then forgets the slot, so a later frame
that shows the cover asks for it again, which is quick from the disk
cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cover whose download or decode failed was marked Failed for good:
images are only requested when their slot is empty, and eviction only
drops loaded ones. A network blip, or waking from suspend before the
network is back, left every cover requested meanwhile a placeholder
until cloofy was restarted.

Remember when a cover failed, and request it again when it is drawn
30 seconds later or after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The disk cache named each cover by std's DefaultHasher, whose output
Rust does not promise to keep across releases. A toolchain update could
silently orphan the whole cache, and nothing ever deleted a file, so the
orphans and covers no longer shown stayed on disk for good.

Name the files by the SHA-256 of their URL instead. At startup, delete
in the background cached covers not read or written for 60 days, and
files named the old way, which are never read again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers were only requested once they were drawn. A shelf arrow pages by
a whole page, so every click showed a row of grey squares with icons
that then filled in one by one, and so did scrolling Home, the library
grid and the sidebar: up to a few hundred milliseconds from disk, more
from the network. Spotify's client preloads around what is shown.

While drawing, collect the covers likely to be shown next: the pages
left and right of each visible shelf, the first page of the shelf below
the screen, and the next screenful of the library grid and the sidebar.
Once the frame is drawn, request the ones not loaded yet. They take two
turns of their own, so they never hold up covers on screen, and count
as used once, so they are dropped again if never shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cover was scaled to the pixel size of its slot before it was
sent, enlarging small ones, and went out as RGBA. The Now Playing cover
comes from a 640 px source, but at 19x42 px cells its 72x36 slot is
1368x1512 px: about 11 MB of base64 per track change, built on the
blocking pool and then written through the pty by the UI thread while
input waited, plus about 28 MB of buffers to make it. Card covers paid
the same, about 4/3 more than needed for the always-opaque alpha byte.

With kitty, placements now state their cells and the terminal fits the
image into them itself. So for kitty, shrink a cover only down to the
pixels its cells have and never enlarge it, and build the protocol for
the slot's cells as they are. The ratatui-image fork now sends opaque
images as RGB (f=24). The Now Playing cover is then at most 640x640 RGB,
about 1.6 MB of base64. Other protocols keep scaling as before.

Dev snapshots fit images into their cells the way kitty does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Now Playing asked for its cover at whatever size the last frame had, on
every tick. Resizing the window started one large job for each size it
passed through, none cancelled, and every result, stale or not, cleared
the pending request and replaced the cover. Each stale cover was drawn
and so uploaded to the terminal, and clearing the request made the next
tick ask again for a size already loading. A failed load was retried
every 25 ms tick, offline or on a 404.

Keep one request per track in flight, and only take a result for the
track, size and Now Playing still shown; others are dropped before they
reach the terminal. A new size is requested once the window has kept it
for 150 ms, with the old cover shown meanwhile; a new track or opening
Now Playing still loads at once. A failed cover waits 10 seconds before
it is tried again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cover unseen for two minutes was dropped. Since the fork frees a
kitty upload once it is sent, a drawn kitty cover costs cloofy about a
hundred bytes, so dropping it saved nothing, while showing it again
meant decoding, encoding and uploading it again under a new id. And
nothing ever told the terminal to delete an image: every cover ever
drawn, every replaced player bar and Now Playing cover, stayed in
Ghostty's memory up to its 320 MB image quota.

Keep drawn kitty covers instead, as long as the pixels the terminal
holds for them stay under 48 MB together; beyond that, drop the least
recently drawn ones that are off screen and delete them from the
terminal. Covers that were never drawn, and those of other protocols,
which hold their encoded data in cloofy, keep the two-minute limit.
Replacing the player bar or Now Playing cover deletes the old one too.
The deletes are written after the next frame, once nothing on screen
shows those images any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
glibc raises its mmap threshold to the largest block freed so far, up
to 32 MB. After the first big cover had been freed, every later image
buffer and response body came out of the two heap arenas instead of
its own mapping, and the arenas only shrink when trimmed. The only trim
ran inside image eviction, every 30 seconds at best and only when
something was evicted, so the process stayed near its peak after
loading Home, scrolling the library or changing tracks.

Fix the mmap threshold at 256 KB, which keeps large buffers in their
own mappings, returned as soon as they are freed. And trim the heap one
second after the last of a burst of image and page loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No HTTP client had a timeout. After the laptop woke from suspend, a
pooled connection that had died meanwhile hung the next request until
TCP gave up, which can take minutes, so the view showed a spinner that
never ended. A shared client builder now sets a 5 s connect timeout, a
15 s request timeout and TCP keepalive; the web player bundles that
pathfinder reads get 60 s, as they are several megabytes.

The Web API client also turned every 429 into an error, so a burst of
browsing that hit the Development Mode rate limit showed a red toast
or an empty view instead of a short wait. GET requests now honour
Retry-After (up to 10 s, three tries in all) and are repeated once
after a timeout, a failed connect or a 502/503. Writes such as play,
save or queue are never repeated, so they cannot run twice or fire
seconds after the user asked for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the player failed to start (offline at launch, flaky Wi-Fi after
suspend, an access point error), the app only stored the error. Home,
artist pages and followed playlists waited for a session that never
came and spun forever, with no error and no retry, and the app redrew
every frame for good because it still counted as connecting.

The loader now sees the session as Connecting, Ready or Failed, so
waiting requests fail with "No connection to Spotify" instead of
hanging. A failed start is retried after 2, 5, 15 and then every 60
seconds, unless the streaming login is missing; once the player is up,
a Home, artist or track page that failed for lack of it loads again.
Registering the device starts no playback.

Lyrics no longer read the session without waiting: a track restored at
startup asked before the session existed and always fell back to
LRCLIB. They now wait up to five seconds for Spotify's synced lyrics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At startup the app asked for /me and restored the last view in the
same breath, so `me` was still unknown when a playlist page reopened.
The loader then took even the user's own playlist for a followed one
and sent it the slow way through librespot: it waited for the session
and fetched every track's metadata before showing a single row,
instead of the Web API's first page in a fraction of a second.

The loader now keeps the user's ID itself. A track list that needs it
before /me has answered asks for it and waits, so the routing no
longer depends on what the UI happens to know yet. The saved view also
remembers whether a playlist is collaborative, which restore used to
reset to false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every paged list was fetched strictly one page after another: 300
saved albums took six round trips, 2,000 liked songs forty, and the
liked count, artists and podcasts waited for the slowest part because
the whole library arrived as one event.

Once the first page tells how many there are, the remaining pages of
playlists, saved albums, podcasts, Liked Songs, own playlists and
albums are now fetched four at a time. Pages still arrive in order,
so track lists keep filling from the top and stop at the first error.
The library is split into one event per part (liked count, albums,
artists, podcasts), so each shows up as soon as it is complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The like state of a list was checked in batches of 40 one after
another inside a single task, and nothing showed until every batch
had answered. A 500-song playlist needed 13 requests in a row before
the first heart appeared, even on the rows in view.

The checks now run two batches at a time and report each one as soon
as it is known, so the top of a list gets its hearts first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Followed and Spotify-made playlists (Discover Weekly, Daily Mixes,
most of Home) resolve through librespot, which asked for every track's
metadata in a request of its own, eight at a time, and showed nothing
until the last one had answered: about a second for 50 songs and five
to ten for a long playlist, all behind a spinner.

Track details now come from Spotify's extended-metadata endpoint 100
tracks per request, three requests at a time, and each batch is shown
as soon as it arrives, so the first rows appear after one round trip.
Should a batch request fail, its tracks are asked for one by one as
before. Tracks without details are still left out, and the final
count is corrected to the rows that actually arrived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving an album, following a playlist or artist, or liking a song only
flipped the heart. The toast said it was saved, but the item did not
appear in Your Library until a restart, an unfollowed one stayed
listed, and the Liked Songs count and list never changed.

The sidebar, the library page and Liked Songs now follow the change
immediately, using what the page it was made on knows about the item;
a failed request puts everything back where it was. Once Spotify
confirms a change, the affected list is loaded again (after a second
of quiet, so a burst of saves costs one reload), and fresh lists keep
changes Spotify has not confirmed yet instead of undoing them.

The sidebar cursor, the library page cursor and open playlist pickers
refer to items by position, so they are pointed back at the same items
whenever a list changes; otherwise "Add to playlist" could add a song
to the wrong playlist after a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every visit to a playlist or Liked Songs pushed a fresh, fully loaded
copy of the list and kept the previous ones in the history, up to 50
views deep, while their loads kept paging in the background for views
nobody could see. Going back and forth between a 3,000-song Liked
Songs and a few playlists piled up a copy (about 1.5 MB) per visit.

Opening a list now empties its earlier copies in the history and gives
them a new ID, so pages still on their way are turned away; Back loads
such a list again and keeps its cursor. Loads of track lists can be
stopped, and are, as soon as their list is emptied or dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a playlist, album or artist page always started from an empty
view and fetched everything again, even seconds after a visit, so
going from one playlist to another and back showed a spinner each
time. Clicking Home asked for the whole feed again, and when the
answer landed every shelf jumped back to its first card.

A track list or artist page that is still in the history is now shown
at once with its cursor where it was. If it was loaded within the last
minute that is all; otherwise a fresh copy loads behind the scenes and
replaces it when complete, keeping the cursor on the same song, and a
failed refresh leaves the list as it was. The history keeps a stand-in
where the list was taken from, so Back works as before and the list
stays in memory once. Home is asked for again only after five minutes,
and a newer Home keeps the selected card and how far each shelf was
paged, found by URI.

Opening the page that is already open no longer pushes a copy of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only images were cached on disk. Every start showed an empty sidebar
and a Home spinner until the Web API, the librespot session and the
Home query had all answered, a restored library page claimed "Nothing
here yet" meanwhile, and a restored track list loaded from scratch.

The user, the library lists, the liked count and Home are now written
to ~/.cache/cloofy/library.json (two seconds after they change, and on
quit), and the open track list to tracks.json once it is complete.
The next start shows them right away and swaps in fresh data as it
arrives: the sidebar keeps its selection by URI, Home keeps its place,
and the restored list refreshes behind the scenes. Both files carry a
version, so a changed format is ignored rather than misread; data
older than 30 days is ignored too, a cached greeting older than an
hour is left out, and a different account drops the cached library.
The files are only readable by the user, like the other private ones.

While the library is still loading, the library page shows a spinner
instead of "Nothing here yet", and a restored cursor there is no
longer clamped to the part that happened to arrive first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parda21 and others added 20 commits October 15, 2026 16:35
The More button on playlist, album and artist pages opened its menu at
a fixed eight rows below the top of the content. The button sits four
or six rows down depending on the header, and moves when the artist
page scrolls, so the menu opened detached from it and could cover the
track list.

Anchor the menu below the button's own click area, lined up with its
left edge. The menu already flips above its anchor when there is no
room below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While an artist page loaded, "ARTIST" and the name sat at the left
edge of the first two rows. Once the page arrived they moved 17
columns right and one row down, next to the portrait, so the text
jumped.

Lay the loading header out like the loaded one: an empty portrait
square, the label and name beside it, and the spinner or error on the
row where the stats appear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The media keys, AirPods and Control Center now control cloofy, and
Control Center shows the track with its cover.

- cloofy-os is the new home of operating system integration. It offers
  one interface for every platform; macOS implements it with the
  MediaPlayer framework, and other systems get a no-op until they have
  an integration of their own.
- macOS delivers remote commands through the main thread's run loop, so
  cloofy itself now runs on a worker thread and `run_main` gives the
  main thread to CoreFoundation.
- The TUI shows only cloofy's own playback. While another device plays,
  its entry is cleared: claiming Now Playing there would take the
  controls away from the Spotify app that actually plays. A paused
  entry doesn't take over, so the restored track shows from the start.
- `cloofy play` and `cloofy connect` get the same controls.

`cargo run -p cloofy-os --example now_playing` shows a made-up track
for trying it without Spotify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The TUI tried a cover that failed to load never again for that track.
  It now retries after 30 seconds, so a cover that keeps failing isn't
  requested on every loop.
- `cloofy play` and `cloofy connect` could put the previous track's
  cover on a track without one, when the old download finished late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Playback moves to AirPods or headphones as they connect, and pauses
when the output in use disconnects, instead of carrying on through the
speakers.

librespot's rodio output stays on the device that was the default when
it opened. When that device disconnected, its write loop waited forever
for a stream that never drained again, and playback stalled.

- cloofy-os reports changes of the default output, through a CoreAudio
  property listener on macOS.
- cloofy-player gets a macOS output that reopens on the new default when
  it changes. If the output it played on is gone, it reports it and
  plays silence until the pause arrives, so nothing leaks through the
  speakers. Its waits are bounded, so a dead stream can't block the
  player anymore.
- The TUI and `cloofy play`/`cloofy connect` pause on the report. The
  TUI says which output went away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the connection to Spotify dropped, librespot shut its session down
and the Spotify Connect task ended. cloofy never looked at that task
again, so after the computer slept long enough, or the network was gone
for a while, playback stayed unavailable until a restart.

- The TUI keeps the device connected: a supervisor connects again
  whenever the connection ends without being asked to, with backoff
  while Spotify can't be reached and librespot's limit of five
  reconnects in ten minutes. What played gets ready to continue, as
  after a restart. This works on every OS.
- macOS: cloofy-os reports sleep and wake through IOKit's power
  notifications, which reach non-Cocoa processes too. cloofy pauses
  before the Mac sleeps, which waits a second for it, and reconnects as
  soon as it wakes after more than a minute asleep, instead of waiting
  for the keep-alive to time out.

The saved volume never applied: librespot ignores volume commands until
the device is active, so cloofy always started at 50% and then saved
that. It is now the device's initial volume, and reconnecting keeps the
current one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The supervisor gave up after five reconnects in ten minutes, like
librespot's binary. That left a dead player in the app, which still
looked connected and only answered with channel errors until a restart.
Forced reconnects after sleep counted toward the limit too, and a
connection that dropped right after it started was retried at once.

Now a connection that drops within 30 seconds waits like a failed
attempt, with the backoff doubling up to a minute, so Spotify isn't
hammered. There's no limit and so no dead end. Waking up cuts a running
wait short, for the case where Spotify was out of reach before sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Reduce Motion: the slides of pages and menus become short fades, the
  lyrics jump to the current line, and the equalizer stands still.
  Spinners keep turning; they show progress, which Apple's guidance
  keeps.
- Low Power Mode: no animations and an instant accent color. The timer
  ticks every 100 ms instead of 25 ms, and a playing track redraws once
  a second instead of eight times. Idle on Home, cloofy woke the Mac
  about a quarter as often in a test.

cloofy-os reads both settings and the TUI checks them every few seconds,
so changes apply without a restart. Elsewhere both read as off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`credentials = "keychain"` in config.toml keeps the Web API token and
the streaming credentials in the macOS Keychain instead of files.

- cloofy-os gets a secret store, the login keychain on macOS through
  security-framework, under the service "cloofy". Elsewhere it isn't
  available, and the setting says so instead of being ignored.
- A file in the old place always wins and moves into the keychain when
  read. That covers the switch from files and `cloofy login`, where
  librespot writes the reusable credentials itself: they move over
  right after. Otherwise librespot doesn't get to keep them in a file.
- After connecting, cloofy updates the stored credentials when Spotify
  handed out new ones, as librespot does for files.

It's off by default: macOS ties keychain access to the binary, so an
unsigned build has to ask again after each update. With signed releases
it can become the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While cloofy's window is in the background, each new track shows a
desktop notification with its title, artists and album. The terminal
posts it, so cloofy needs no notification permission or app bundle of
its own, and a click leads back to the right window. This works the same
on Linux.

- kitty gets OSC 99: title and body, base64 encoded, and each new track
  replaces the last notification. Ghostty and WezTerm get OSC 777.
  iTerm2 gets OSC 9 as a single line. Other terminals get nothing.
- Inside tmux the sequence is wrapped for passthrough, which tmux sends
  on with `allow-passthrough on`.
- cloofy turns on focus reporting to know when it is in the background,
  and stays quiet where the terminal doesn't report focus.
- Only cloofy's own playback, only after a track played for 1.5 s, so
  skipping through doesn't flood. A track that started while cloofy was
  in front isn't announced later.
- `notifications = false` in config.toml turns them off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`doctor`, `query` and `dj-sample` connect through
`PendingSession::connect`, which didn't save credentials Spotify renewed
while connecting. In keychain mode the cache has no file for librespot
to update, so those were lost. `connect` now saves them itself, and
`login` relies on that too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An audit of the whole app found 58 problems: frames written in pieces,
covers uploaded at full size and decoded all at once, nothing cached
between runs, controls that waited for Spotify before moving, and a
long list of layout and mouse issues. These commits fix them, area by
area, alongside the macOS work merged before.

Where both touched the same code: the macOS reconnect supervisor keeps
all retrying and feeds the loader's session state; media keys, output
changes, power and focus events go through the event loop that now
draws only when something changed; the saved volume is applied the same
way for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Losing the output paused the player but left it shown as playing until
the player confirmed, which takes up to a second while the sink drains.
A pause from the headphones in that window, as AirPods send when they
switch to another device, toggled playback and resumed it through the
speakers. And a stale "another device plays" state skipped the pause.

Pause whatever the state says, show it paused at once, and answer the
system's pause command with a pause instead of a toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When opening the new default output failed, as freshly connected
Bluetooth headphones may at first, playback carried on in silence
until the output changed again or playback was restarted: the progress
bar moved, nothing was audible and nothing said why.

Try again once a second while there is no output, and log only the
first failure in a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the connection dropped by itself, the player kept going: librespot
only pauses on a requested shutdown, and the player now outlives each
connection. A downloaded track played on while the app showed it paused
and offered to resume it, and pause could not reach it. Stop the player
before reporting the loss, so what is heard matches what is shown.

The wake signal that should cut the wait before reconnecting short was
sent while nothing waited, and got lost; the reconnect cloofy forces
after sleep then waited out the backoff like a failed attempt, up to a
minute. Keep the signal for the next wait, and let a reconnect cloofy
asked for go ahead at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reduce Motion and Low Power Mode stopped the equalizer only in the
sidebar. The one beside the playing row of track lists, search results,
the artist's popular tracks and cards kept moving, and these are the
most visible ones on a playlist page.

Decide it in one place, and use that everywhere an equalizer is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under Reduce Motion and in Low Power Mode the lyrics jump to the current
line instead of gliding there, but the jump didn't ask for a redraw.
While paused, nothing else redrew either: after seeking, or on opening
Now Playing, the lyrics stayed at the old line until the next key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A notification joined artists and album with " · " whatever they held,
so podcast episodes, which have no album, read "Show Name · ", and
iTerm2 added another separator before an empty text. Leave out what is
missing, as the player bar does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With tmux's defaults no notification arrives: tmux forwards no focus
changes, so cloofy never knows it is in the background, and it drops
passthrough sequences from panes out of view. Name the two settings
that make it work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 05f0d2da-7a0b-4530-966f-bbc8a3549a48
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Merged with the speed and polish work: the Web API client keeps its
timeouts and rate-limit retries and now reads its token from the
chosen store, and the player saves renewed credentials before it
subscribes to Spotify Connect pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@parda21 parda21 changed the title Speed, responsiveness and polish, with #1–#5 Speed, responsiveness and polish, with #1–#6 Oct 15, 2026
@parda21
parda21 merged commit 2882c32 into main Oct 15, 2026
3 checks passed
@parda21
parda21 deleted the integrate/polish branch October 15, 2026 15:00
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.

2 participants