mirror of
https://github.com/sonorahq/sonora.git
synced 2026-10-28 08:55:04 +08:00
docs: add AGENTS.md
This commit is contained in:
1 parent
27407f5065
commit
a04e39cfbd
2 files changed
+380
No files matched your search
@@ -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<Div>` 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<Div>, /* … */ }
|
||||
|
||||
impl Thing {
|
||||
#[track_caller]
|
||||
pub fn new(id: impl Into<ElementId>) -> 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<Result<T>>`.
|
||||
- `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/<hex>`) |
|
||||
| `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<String>` 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<dyn SpotifyApi>` 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<Field>]`, hold a
|
||||
`GridState<Source>` 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/<name>.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.
|
||||
Reference in new issue
Block a user