Repository navigation
Speed, responsiveness and polish, with #1–#6 - #7
Merged
Merged
Conversation
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>
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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
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. Comment |
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>
This was referenced Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.