diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ab7bb538 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,379 @@ +# sonora + +A native Spotify client in Rust on [GPUI](https://github.com/zed-industries/zed) (Zed's UI +framework), streaming through [librespot](https://github.com/librespot-org/librespot). +Linux-only today. Cargo workspace, edition 2024, resolver 3. + +## Read this before writing code + +1. **A component probably already exists.** Check `crates/ui/src/lib.rs`, `crates/views/src/cells.rs` + and the tables below before writing a new element. See [Before you build a component](#before-you-build-a-component). +2. **Never call the Spotify Web API.** There is no `reqwest` call to `api.spotify.com` anywhere and + there must not be one. All data comes from librespot's `spclient`. See [Backend](#backend-how-data-actually-arrives). +3. **Never hardcode a color, radius, or size.** Everything comes from `cx.theme()`. + See [Theme and metrics](#theme-and-metrics). +4. **Network work runs on the tokio runtime (`Io`), never on GPUI's executor.** + See [Async: two runtimes](#async-two-runtimes). +5. **New assets must be registered in `crates/sonora/src/assets.rs`** or they silently fail to load. + +## Crate layout + +``` +crates/ + sonora/ binary: main, window, actions, asset registry, HTTP client shim + views/ screens: home, library, detail, artist, search, settings, login + workspace/ app chrome: title bar, sidebar, player bar, filter/search field + state/ GPUI entities holding app state; owns all async orchestration + spotify/ Spotify data access over librespot spclient (no GPUI) + audio/ librespot playback engine + custom rodio sink (no GPUI) + ui/ design system: theme, metrics, and reusable elements (gpui only) + router/ Destination enum, navigation history, Link trait + input/ text input element + global actions and keybindings +``` + +Dependency direction is strict; do not create a back edge: + +``` +sonora → views → workspace → state → spotify + → audio + all ui-side crates → ui, router, input → ui → gpui +``` + +- `ui` depends only on `gpui` + `serde`. It must never know about `spotify`, `state`, or playback. +- `spotify` and `audio` must never depend on `gpui`. They are plain async Rust. +- `state` depends on `ui` only for `ThemeOverrides`, `MIN_FONT`, `MAX_FONT` (settings persistence). +- Widgets that need app state (player bar, sidebar) live in `workspace`, not `ui`. + +## Building + +### Any platform: what the build needs + +The GPUI renderer is Vulkan-based, so a Vulkan ICD is a **runtime** requirement, not just a build +one. Link-time deps: `vulkan-loader`, `wayland`, `libxkbcommon`, `libxcb`, `libx11`, `libxcursor`, +`libxi`, `fontconfig`, `freetype`, `alsa-lib`, plus `pkg-config`. + +`.cargo/config.toml` passes `-fuse-ld=mold` for `x86_64-unknown-linux-gnu`, so **mold must be on +PATH** for that target. If it isn't, either install mold or build with +`RUSTFLAGS="" cargo build …` to drop the flag. + +### Nix (primary) + +```sh +nix develop # or: direnv allow (.envrc runs `use flake`) +cargo run --locked --package sonora +nix run # build + run the packaged binary +nix build # ./result/bin/sonora +``` + +The devShell supplies `rustc`, `rustfmt`, `rust-analyzer`, `mold`, `pkg-config`, `sccache` and the +runtime libs via `LD_LIBRARY_PATH`. It does **not** ship `cargo` or `cargo-clippy` — those come from +the ambient system profile here. If `cargo` is missing inside the shell, that's why. + +When `Cargo.lock` changes, `cargoHash` in `flake.nix` goes stale. Build once, take the `got:` hash +from the failure, and paste it in. + +### Arch / CachyOS + +```sh +sudo pacman -S --needed base-devel rust pkgconf alsa-lib fontconfig freetype2 \ + libx11 libxcb libxcursor libxi libxkbcommon libxkbcommon-x11 wayland \ + vulkan-icd-loader mold +# plus a Vulkan driver: vulkan-radeon | vulkan-intel | nvidia-utils + +cargo run --locked --package sonora +cargo build --release --locked --package sonora && ./target/release/sonora +``` + +### Debian/Ubuntu and Fedora + +Not exercised in this repo; package sets translated from the dependency list above. + +```sh +# Debian/Ubuntu +sudo apt install build-essential pkg-config mold libasound2-dev libfontconfig-1-dev \ + libfreetype-dev libx11-dev libxcb1-dev libxcursor-dev libxi-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libvulkan-dev mesa-vulkan-drivers + +# Fedora +sudo dnf install @development-tools pkgconf-pkg-config mold alsa-lib-devel fontconfig-devel \ + freetype-devel libX11-devel libxcb-devel libXcursor-devel libXi-devel \ + libxkbcommon-devel libxkbcommon-x11-devel wayland-devel vulkan-loader-devel mesa-vulkan-drivers +``` + +### macOS / Windows + +Unsupported as configured. `gpui_platform` is pinned to the `x11` + `wayland` features, the flake +only declares `x86_64-linux`/`aarch64-linux`, and the mold linker flag targets Linux. Porting means +changing feature flags and the linker config — do not attempt it as a side effect of another task. + +### Checks + +```sh +cargo fmt # rustfmt.toml: edition 2024, style_edition 2024 +cargo check --workspace +cargo test --workspace +cargo clippy --workspace # lib targets +``` + +The first build compiles GPUI from source; expect several minutes. + +`cargo clippy --workspace --all-targets` currently **fails**, on a deny-level +`reversed_empty_ranges` from a deliberate `clamp_range("abc", &(2..1))` case in +`crates/input/src/text.rs`. That failure predates your change; don't "fix" the test to silence it. +The workspace also carries a handful of clippy warnings (complex types, a missing `Default`). Leave +them unless the task is about them. + +### Runtime environment + +| | | +| ----------------- | ------------------------------------------------------------------ | +| Settings | `$XDG_CONFIG_HOME/sonora/settings.json` | +| Credentials cache | `$XDG_CACHE_HOME/sonora/credentials.json` | +| OAuth redirect | `http://127.0.0.1:8989/login`, override with `SONORA_REDIRECT_URI` | +| Logging | `RUST_LOG`; default filter `warn,symphonia=error` | + +## Before you build a component + +Grep first. In order: `crates/ui/src/lib.rs` (exports), `crates/views/src/cells.rs` (grid cell +renderers), `crates/workspace/src/` (chrome). Extend what's there — add a builder method to `Button` +rather than writing `IconButton`. + +### `ui` — reusable elements + +| Item | Use for | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `Button` | every button. Variants `.ghost() .outline() .primary() .danger()`, plus `.small() .icon() .label() .tint() .selected() .disabled()` | +| `Card` | artwork + title + eyebrow + meta row/tile. `.art()` for tile mode, `.circle()`, `.loading()`, `.trailing()`, `.explicit()`, `.press()` | +| `Artwork` | cover images with skeleton loading and a music-note fallback | +| `Skeleton`, `Initials` | pulsing loading placeholder; avatar initials | +| `Grid`, `GridSource`, `GridDelegate`, `GridState`, `ColumnSpec`, `Cell` | every table. Virtualized, sortable, filterable, hideable columns, responsive `hide_below` | +| `Scroller` + `Scrollbar` | any scrolling region. Do not use bare `overflow_y_scroll` | +| `Scrubber` + `ScrubberState` | any draggable 0..1 track (seek bar, volume) | +| `Menu`, `MenuItem` | dropdowns (deferred + occluded, with `on_dismiss`) | +| `InlineLinks`, `InlineLink` | comma-joined clickable artist lists | +| `eyebrow()`, `heading()` | the two standard text styles | +| `ExplicitBadge` | the "E" badge | +| `WindowControls` | minimize/maximize/close, honoring platform decorations | +| `clock()` | `Duration` → `m:ss` | +| `snapped()` | round a `Pixels` to the device pixel grid | + +Also available: `Input` (`input` crate — full text editing, IME, selection, clipboard) and +`Link` (`router` — makes a `Stateful
` navigate on click). + +### `views/src/cells.rs` — grid cell renderers + +`index` (play/pause/now-playing transport with hover preload), `artists`, `link`, `text`, `dim`, +`title`, `artwork`, `blank`, `transport`, `toggle`, `artist_links`. Reuse these in any new +`GridSource::cell`. + +### Element conventions + +New elements follow one shape — copy `ui/src/button.rs`: + +```rust +#[derive(IntoElement)] +pub struct Thing { base: Stateful
, /* … */ } + +impl Thing { + #[track_caller] + pub fn new(id: impl Into) -> Self { … } + pub fn variant(mut self) -> Self { …; self } // consuming builders +} + +impl Styled for Thing { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } +impl InteractiveElement for Thing { … } + +impl RenderOnce for Thing { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let overrides = std::mem::take(base.style()); // caller styles win + let mut thing = base./* defaults */; + thing.style().refine(&overrides); + thing + } +} +``` + +The `mem::take` / `refine` dance is deliberate: it lets a call site override any default without +the element re-applying it afterwards. Keep it. + +Stateful components (`Scrollbar`, `Input`, `GridState`) are `Render` entities instead, created with +`cx.new(…)` and held by the parent. + +## Theme and metrics + +`ui/src/theme.rs` is the single source of every color, radius, and font size. + +```rust +use ui::ActiveTheme as _; +let theme = *cx.theme(); // Theme is Copy +theme.foreground, theme.muted_foreground, theme.secondary_hover, theme.table_row_border, … +theme.radius +theme.text(Text::Small) // Tiny Small Label Body Large Title Display +theme.metrics.row / .header / .pad / .inset / .control / .field + .title_bar / .player_bar / .list_row / .thumb / .cover +``` + +- Every metric scales off the user's font size (`Metrics::new(base)`), so **never** hardcode a + height for a row, control, or bar — read it from `theme.metrics`. +- A literal `px(…)` is acceptable only for a local, non-scaling detail, declared as a `const` at + module top (see `NUMBER`, `DATE`, `WIDE` in `views/src/cells.rs`). +- Adding a color token means adding it to `Theme`, to every `Theme::*()` constructor, to + `ThemeOverrides`, and to the `apply_color!` list — all four, or overrides break. +- Eight themes exist (`ThemeKind::ALL`). `ocean`/`rose`/`lavender`/`amber` are derived by mutating + `midnight()`/`dark()`; follow that pattern rather than writing a full palette. +- Users can override any token via `settings.json`; `Theme::set` re-renders all windows. + +## Async: two runtimes + +GPUI has its own executor; librespot and `reqwest` need tokio. `state::Io` wraps a multi-thread +tokio `Runtime` and is a GPUI global. + +```rust +let io = Io::global(cx); +self.task = Some(cx.spawn(async move |this, cx| { // GPUI executor + let loaded = join(io.spawn(async move { // tokio: all network work + client.album_tracks(&id).await + })).await; + this.update(cx, |this, cx| { /* apply, cx.notify() */ }).ok(); +})); +``` + +Rules: + +- Anything touching `SpotifyApi`, librespot, or sockets goes inside `io.spawn`. +- Only mutate entity state inside `this.update`, and end with `cx.notify()`. +- Store the returned `Task` in a field (`task`, `load`, `fetch`) — dropping it cancels the work, + which is how sign-out and navigation cancel in-flight loads. Never `.detach()` a data load. +- `join(handle)` (crate-private in `state`) flattens `JoinHandle>`. +- `cx.subscribe(…)` / `cx.observe(…)` **are** `.detach()`-ed, in the constructor. +- Because `join` and `Io` plumbing live in `state`, new network-backed features belong in a `state` + entity — not in a view. + +## Backend: how data actually arrives + +### There is no Spotify Web API here + +`crates/spotify` talks to Spotify through `librespot_core::Session::spclient()` — the same internal +endpoints the official client uses, returning protobuf or JSON. Consequences: + +- Do not add `reqwest` calls to `api.spotify.com`, do not add a client secret, do not add + `rspotify`. The only `reqwest` in the tree is `crates/sonora/src/http.rs`, a `gpui::HttpClient` + adapter that exists purely so GPUI can fetch cover images. +- Auth is OAuth PKCE via `librespot-oauth` against **Spotify's own client id** + (`DEFAULT_CLIENT_ID` in `auth.rs`). A developer-app client id will be refused at session connect — + `auth::denied` documents this. Don't "fix" auth by swapping in a registered app id. + +### Module map + +| Module | Endpoint / mechanism | +| ------------------------- | --------------------------------------------------------------------------------- | +| `auth.rs` | OAuth login, credential cache, `restore` / `login` / `forget` | +| `client.rs` | `SpotifyApi` trait + `LibrespotClient`; the only type views/state see | +| `collection.rs` | saved tracks; `metadata()` — batched `get_extended_metadata` (TRACK_V4) | +| `collection2.rs` | `/collection/v2/paging` — hand-rolled protobuf, paged, honors tombstones | +| `albums.rs`, `artists.rs` | extended metadata for albums/artists, artist portraits | +| `playlists.rs` | `get_playlist` → `SelectedListContent` → uri list → `collection::metadata` | +| `search.rs` | `get_context("spotify:search:…")` → track uris → `collection::metadata` | +| `radio.rs` | `get_radio_for_track` → a generated playlist id → `playlist_tracks` | +| `profiles.rs` | display-name lookups, fanned out over a `JoinSet` | +| `wire.rs` | protobuf → `models` conversion, `image_url` (file id → `i.scdn.co/image/`) | +| `pb.rs` | minimal protobuf `Reader`/`Writer` for endpoints with no generated schema | +| `models.rs` | `Track`, `Album`, `AlbumDetail`, `Artist`, `ArtistRef`, `Playlist`, `UserProfile` | + +Working notes: + +- Prefer generated messages from `librespot-protocol`. `pb.rs` exists only because the collection-v2 + paging schema isn't in that crate — don't hand-roll a new one if a generated type exists. +- The recurring pattern is **uris → `collection::metadata` → `Track`**. A new track-listing endpoint + should resolve uris and reuse `metadata`, not re-parse track fields. +- `Track::id` is `Option` base62 (no `spotify:track:` prefix); the prefix is added at the + audio boundary. `Track::playable` is false for unavailable tracks — check it before playing. +- New endpoints: add the method to the `SpotifyApi` trait, implement on `LibrespotClient` by + delegating to a focused module. The trait exists so `state` depends on an interface, not on + librespot directly. +- Errors use `anyhow` with `.context("cannot …")` in lowercase. + +### Audio + +`crates/audio` owns `librespot_playback::Player` plus `BlazingSink`, a custom rodio sink with +smooth gain ramping and flush-on-seek. `Engine::start(session, config)` returns +`(Engine, AudioEvents)`; `AudioEvents::next()` yields the reduced `AudioEvent` +(`Loading/Playing/Paused/Position/Ended/Unavailable`). + +Never drive librespot's `Player` from a view. Go through `state::Playback`, which owns the engine, +pumps events into `PlaybackState`, and handles shuffle, repeat, skip debouncing, and the cooldown +after an `Unavailable` track. + +### State entities + +`state::init` installs a `Sonora` global holding `session`, `library`, `playback`, `queue`, +`settings`. Reach them with `Sonora::global(cx)`. + +| Entity | Responsibility | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Session` | auth lifecycle; emits `SessionEvent::{SignedIn, SignedOut}`; hands out `Arc` and the raw librespot session | +| `Library` | saved tracks / playlists / albums; `LibraryState` is `Empty \| Loading \| Ready{..,problems} \| Failed` — partial failure is normal, surface `problems` | +| `Playback` | engine ownership, transport, shuffle/repeat, volume, `Origin` tracking | +| `Queue` | past / current / upcoming; `start`, `next`, `next_random`, `previous`, `rewind` | +| `Home`, `Detail`, `ArtistDetail`, `Search` | per-screen loaders, each owning its `Task` | +| `AppSettings` | debounced JSON persistence | + +Everything reacts to `SessionEvent`: signing out must clear derived state. If you add an entity that +caches Spotify data, subscribe to `Session` and clear on `SignedOut`. + +## UI patterns + +**Navigation.** `router::Destination` is the route enum; `router::navigate(dest, cx)`, `back`, +`forward`. `Root` subscribes to `NavigationEvent::Moved` and swaps the workspace content. For a +clickable region, use the `Link` trait (`div().id(..).link(Destination::Album(id))`) instead of a +manual click handler. + +**New screen checklist:** add a `Destination` variant → add a state entity if it loads data → add +the view under `crates/views/src/` → construct it in `Root::new` and wire it in `Root::show` → +add a sidebar entry in `workspace/src/sidebar.rs` if it's top-level. + +**Tables.** Implement `GridSource` (`columns`, `rows`, `cell`, and optionally `compare`, `matches`, +`playing`, `is_loading`), define a `&'static [ColumnSpec]`, hold a +`GridState` entity, render `grid(&state)`. Column widths use `Width::{Fixed, Fill, Thumb}` +and `hide_below` for responsive dropping. Hidden columns persist through +`AppSettings::{hidden_columns, set_hidden_columns}`. + +**Filtering.** Implement `workspace::Searchable` on the view; `Root` binds it to the shared `Filter` +in the title bar. Don't build a second search box. + +**Actions and keys.** Declare actions in `crates/input/src/lib.rs` (`actions!` macro), bind them in +`bindings()`, handle them with `cx.on_action` (global, in `sonora/src/actions.rs`) or +`.on_action(cx.listener(…))` (scoped). Key contexts: `Workspace`, `Input`, `Grid`. Both `cmd-` and +`ctrl-` bindings are registered for every shortcut. + +**Assets.** SVGs live in `assets/icons/`, are embedded with `include_bytes!`, and are referenced as +`"icons/.svg"`. Adding a file is not enough — add the stem to the `ICONS` list in +`crates/sonora/src/assets.rs`, otherwise loading logs `assets: … is not registered` and renders +nothing. Icons are Lucide (`assets/icons/LICENSE`); the UI font is Inter. + +## Code style + +- **Comments: essentially none.** The codebase has almost zero. Name things so they don't need one. + If a comment is unavoidable it is at most three lowercase words, no trailing period. +- Names are short and domain-flavored: `Look`, `Metrics`, `Grab`, `Scored`, `wire`, `pb`, `clock`, + `snapped`. Prefer a noun that reads at the call site over a descriptive compound. +- `use gpui::prelude::*;` then explicit imports; traits imported anonymously (`use ui::ActiveTheme as _;`). +- Module shape: `use` block → `const`s in SCREAMING_SNAKE → types → impls → private free helpers at + the bottom. +- Prefer combinator chaining over branching in render: `.when()`, `.when_some()`, `.when_else()`, + `.map()`. Prefer `match` over `if/else` for two-arm boolean choices — that's the house style + (`match small { true => …, false => … }`). +- Use let-else for early exits; return early rather than nesting. +- `anyhow::Result` at boundaries, `.context("cannot …")` lowercase; log with `log::warn!`/`error!` + prefixed by subsystem (`"playback: …"`, `"settings: …"`, `"assets: …"`). +- Tests are `#[cfg(test)] mod tests` at the bottom of the file they cover — see + `spotify/src/{collection2,radio,search}.rs` and `input/src/text.rs`. They're pure-function tests; + there is no UI or network test harness. +- Dependencies go in the root `[workspace.dependencies]`, then `dep.workspace = true` in the crate. + `gpui`/`gpui_platform` are pinned to one git rev — bump both together or the build breaks. + +## Commits + +Conventional Commits: `type(scope): description`, imperative, lowercase, no trailing period, no body. +Scopes in use: `player`, `playback`, `views`, `ui`. Never add a `Co-Authored-By` trailer or any +assistant attribution.