8.6 KiB
sonora
A native music streaming client, built with Rust and GPUI (Zed's UI framework). Cargo workspace, edition 2024, resolver 3.
Crate layout
crates/
sonora/ binary: main, window, actions, asset registry, HTTP client shim, tray, dock
views/ screens, shells (Workspace, FullscreenView) and app chrome
state/ GPUI entities holding app state, and all async orchestration
music/ provider traits and models in the root, one submodule per provider (no GPUI)
storage/ the state.sqlite and cache.sqlite paths, schema and connection setup
ui/ design system: theme, metrics, layout ladder and reusable elements
router/ Destination enum, navigation history, Link trait
input/ text input element, global actions and keybindings
i18n/ Fluent localization: the `t!` macro, locale selection, embedded .ftl
icons/ the icon packs: registry, active pack, path resolution, AssetSource
embed/ build-script helper that walks a folder and writes include_bytes! literals
webview/ a native browser window over a throwaway session, for cookie sign-ins and PO tokens
widevine/ the Widevine CDM host, pssh and CENC fMP4 parsing, for any DRM'd provider
Dependency direction is strict. Do not create a back edge:
sonora → views → state → music
state, music → storage
state → webview
music → widevine
all ui-side crates → ui, router, input → ui → gpui
every ui-side crate → i18n, icons → gpui
stateandviewssee only the traits and models in the root ofmusic, never a provider module. Onlysonora/src/main.rsnames a concrete provider.musicnever depends ongpui.uinever knows aboutmusic,stateor playback. Widgets that need app state live inviews/src/chrome/.music::engineis the one playback engine andmusic::streamthe one progressive download. A provider implementsengine::Fetchandstream::Bodyrather than writing its own. The engine applies loudness normalisation, so a provider only reports a track's level throughFetch::loudness. Spotify is the one exception, because librespot decodes and normalises inside its own player.music::drmis the only waystateandviewsreach protected playback. The CDM is never shipped in any artefact or package.
Rules
- Reuse before you build. Check
crates/ui/src/lib.rs,crates/views/src/shared/andcrates/views/src/chrome/first, and extend an element with a builder method rather than writing a sibling. New elements copy the shape ofui/src/button.rs, including themem::takeandrefineof caller styles. - Never call the Spotify Web API. No
reqwesttoapi.spotify.com, no client secret, norspotify. Spotify data comes from librespot'sspclient, and auth uses Spotify's own client id inauth.rs. - Never hardcode a color, radius or size. Read them from
cx.theme()andtheme.metrics. A literalpx(…)is allowed only for a local, non-scaling detail declared as a moduleconst. A new color token goes intoTheme, everyTheme::*()constructor,ThemeOverridesandapply_color!. - Never write a bare breakpoint. Use the
ui::Roomladder, and classify width withChrome::roomorChrome::content, never the raw viewport. A view whose layout depends on width observesChrome::entity(cx). - Every user-facing string comes from Fluent. Add the key to
assets/i18n/en-US/main.ftl. Other locales may lag. Never callt!in a constructor: store the key and resolve it inrender. Counts use Fluent selectors. Log messages and.context(…)stay in English. - Every interactive element is announced by screen readers. A node reaches the
accessibility tree only with both
.id()and.role(); pair every role with anaria_labelresolved from Fluent inrender(icon-only buttons take their name from the tooltip). Keep ids stable across frames and unique within a list: wrap rows indiv().id(("kind", stable_id))witharia_position_in_set/aria_size_of_set, never a bare row index. Extendcrates/ui/tests/a11y_tree.rswith every new primitive. - Every interactive element is announced by screen readers. A node reaches the
accessibility tree only with both
.id()and.role(); pair every role with anaria_labelresolved from Fluent inrender(icon-only buttons take their name from the tooltip). Keep ids stable across frames and unique within a list: wrap rows indiv().id(("kind", stable_id))witharia_position_in_set/aria_size_of_set, never a bare row index. Extendcrates/ui/tests/a11y_tree.rswith every new primitive. - Network work runs on tokio. Anything touching
MusicApi, librespot or sockets goes insideIo::global(cx).spawn, awaited fromcx.spawnand applied inthis.updateending withcx.notify(). Store the returnedTaskin a field and never detach a data load. Network-backed features belong in astateentity, not a view. An entity that caches provider data clears onSessionEvent::SignedOut. - Never drive a player from a view. Go through
state::Playback. - Assets are picked up from their folder. Drop an icon into
assets/icons/<pack>/or a face intoassets/fonts/. Call sites spell"icons/<name>.svg"and resolve it withicons::pathat render. - Generated files are regenerated, never edited. App icons come from
scripts/generate-icons.py,THIRD-PARTY.mdfromscripts/generate-notices.py, the README translation table fromscripts/i18n-coverage.py. - Do not add tests unless asked. Offering is fine. Existing tests are
#[cfg(test)] mod testsat the bottom of the file they cover. Accessibility regression tests incrates/ui/tests/a11y_tree.rsare the exception: extend them with every new primitive. Accessibility regression tests incrates/ui/tests/a11y_tree.rsare the exception: extend them with every new primitive.
Building and checks
nix develop # or direnv allow
cargo run --locked --package sonora
cargo fmt
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets
The devShell has no cargo or cargo-clippy; those come from the system profile. The flake's
default package is the released binary, so nix build never sees the working tree, while
nix build .#sonora-src compiles it.
.cargo/config.toml links with mold on x86_64-unknown-linux-gnu, so mold must be on PATH, or
build with RUSTFLAGS="". crates/widevine compiles C++, so a C++ compiler is needed too.
Clippy is clean, --all-targets included, and stays that way. Boxed callback fields get a
module-local type alias rather than a type_complexity warning. The one #[allow] is
reversed_empty_ranges on the clamp_range("abc", &(2..1)) test in crates/ui/src/input/mod.rs.
Code style
- Every type and every function whose behaviour is not obvious from its name carries a
///comment saying what it is for and what a caller has to know. One or two plain sentences. use gpui::prelude::*;then explicit imports. Traits are imported anonymously (use ui::ActiveTheme as _;).- Module order:
useblock,consts, types, impls, private free helpers at the bottom. - In render, prefer
.when(),.when_some()and.map()over branching. Two-arm boolean choices usematch flag { true => …, false => … }. Use let-else and return early. anyhow::Resultat boundaries with lowercase.context("cannot …"). Logs are prefixed by subsystem ("playback: …").- Dependencies go in the root
[workspace.dependencies].gpuiandgpui_platformare pinned to one git rev and move together.
Commits
Conventional Commits: type(scope): description, imperative, lowercase, no trailing period, no
body. Scopes in use: views, ui, music, state, playback, player, settings, router,
local, sonora, nix.
Pull requests
One heading, one list, nothing else:
## Summary
- add playlist create, rename, delete, visibility, and track mutation support
- add album and playlist queue actions
Bullets are lowercase, imperative, one line each, and describe behaviour rather than files.
Changelog
CHANGELOG.md follows Keep a Changelog. Add to
## [Unreleased] as features land, under Added, Changed or Fixed. Each entry is one short
line saying what someone using Sonora can now do, since the notes are posted to Discord. Leave out
implementation details and any work no user can observe. Cutting a release is the
release-sonora skill.