This commit is contained in:
Ultradesu
2026-06-10 16:11:09 +01:00
commit 39b955b6e7
31 changed files with 11526 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
+240
View File
@@ -0,0 +1,240 @@
# furumi_cli — Architecture
Cross-platform terminal client (cmus-style TUI) for the furumusic backend.
Targets: macOS, Linux (ALSA/Pulse/PipeWire), Windows (WASAPI, Windows Terminal).
## 1. Technology choices
### TUI: ratatui 0.30 + crossterm 0.29
Evaluated: **ratatui**, cursive, tui-realm, iocraft.
- **ratatui 0.30.x** — the de-facto standard (gitui, yazi, spotify-player all use it).
0.30 split the project into workspace crates (`ratatui-core`, `ratatui-widgets`,
`ratatui-crossterm`) with a stable core API. Stock widgets cover everything we
need: `Tabs`, `List`, `Table`, nested `Layout` for tile grids.
- cursive — maintenance mode since 2024, rejected.
- tui-realm — viable framework on top of ratatui (termusic uses it), but a
single-maintainer abstraction layer; we prefer plain ratatui with our own
thin component layer.
- iocraft — too young, optimized for inline CLI output rather than fullscreen apps.
- termion — Unix-only, eliminated (we need Windows).
crossterm is the only backend that covers Windows. Caveats to handle:
- Enable kitty keyboard enhancement flags only when
`supports_keyboard_enhancement()` returns true; always pop flags on exit.
- Filter key events to `KeyEventKind::Press` (Windows and kitty-enhanced
terminals also deliver Repeat/Release — otherwise bindings double-fire).
- Restore the terminal on panic (panic hook) — a TUI that corrupts the shell
is the #1 reliability complaint.
### Keybindings: crokey + TOML keymap
- **crokey 1.4** — parses/formats key combos (`ctrl-a`, `g`), serde support, used
for the config file format.
- Keymap model copied from spotify-player: a `[[keymaps]]` TOML table mapping a
*key sequence* (space-separated chords, e.g. `"g g"`, `"C-c x"`) to a
`Command` enum, optionally parameterized (`{ SeekForward = { seconds = 10 } }`).
- A small chord state machine resolves sequences; bindings are layered:
built-in defaults ← user config (`~/.config/furumi/keymap.toml`).
- Bindings resolve per *input context* (Global, LibraryGrid, TrackList,
TextInput, Popup) so the same key can mean different things per view.
### Audio: rodio 0.22 + stream-download, behind a backend trait
Evaluated: **rodio**, kira, raw cpal+symphonia, gstreamer-rs, libmpv.
- **rodio 0.22** (`Player` / `DeviceSinkBuilder` API — note the 0.21/0.22 renames;
most older tutorials are outdated). Symphonia is the default decoder; enable
the `aac`, `isomp4`, `alac` features for m4a support. Pure Rust → trivial
cross-compilation; cpal covers CoreAudio / ALSA / WASAPI.
- **stream-download 0.24** bridges HTTP to rodio: background download exposing
blocking `Read + Seek`, built on reqwest (shares our authenticated client,
auth headers included), seek into undownloaded regions via HTTP Range
(the backend's `/stream/{id}` supports Range), temp-file storage, retries.
- kira — game-audio oriented, no network story, rejected.
- gstreamer / libmpv — best playback quality but heavy system dependencies;
not acceptable as the only backend for a portable CLI.
Playback lives behind a trait so backends can be added later (termusic ships
rodio + mpv + gstreamer this way):
```rust
trait AudioBackend {
fn play(&mut self, source: TrackSource) -> Result<()>;
fn pause(&mut self); fn resume(&mut self);
fn seek(&mut self, pos: Duration) -> Result<()>;
fn set_volume(&mut self, v: f32);
fn position(&self) -> Duration;
fn events(&self) -> Receiver<PlayerEvent>; // TrackEnded, Failed, ...
}
```
Gapless-ish playback: pre-open the `stream-download` source and decoder for the
next queue item and append it to the rodio `Player` before the current track
ends. (True gapless is impossible for AAC/M4A anyway — symphonia has no AAC
gapless trim.)
### Async runtime: tokio
Needed for: crossterm `EventStream`, reqwest, stream-download, device-sync
polling, debounced search. The audio decode thread is rodio's own; everything
else is async tasks talking over channels.
## 2. Application architecture
Elm-style (TEA) core with a component-per-view UI layer — the pattern from the
official ratatui component template and spotify-player.
```
┌────────────────────────────────────────────┐
│ main loop │
│ recv Event -> keymap -> Action -> update() │
│ tick -> draw(&state) │
└───────▲──────────────────────────┬──────────┘
Event (mpsc) │ │ Command (spawn task / send msg)
┌─────────────────────┼──────────────┐ │
│ terminal input (crossterm stream) │ ┌───────▼────────┐
│ api task results │ │ side effects │
│ player events (TrackEnded, ...) │ │ api::Client │
│ device-sync poll results/commands │ │ player::Engine │
│ tick (render + position updates) │ │ sync::Poller │
└────────────────────────────────────┘ └────────────────┘
```
Key rules:
- **Single source of truth**: one `AppState` struct, mutated only in `update()`.
Views are pure render functions over `&AppState`.
- **No blocking in the UI loop.** All I/O (HTTP, audio open) happens in spawned
tasks that report back via the event channel. Every remote list is a
`Loadable<T> { NotAsked, Loading, Loaded(T), Failed(Error) }` so views can
render spinners and errors honestly.
- **Input → Action indirection**: raw key events are translated by the keymap
into semantic `Action`s (`PlayPause`, `FocusNextTab`, `Select`, `Back`,
`SeekForward(10)`). Views never see raw keys; this is what makes bindings
configurable and the app testable.
### Module layout (single crate now, splittable later)
```
src/
main.rs // setup: terminal guard, tokio, channels, run loop
config/ // Config + keymap loading (figment or manual TOML merge)
api/ // typed client for /api/player/*
client.rs // reqwest wrapper: base_url, bearer auth, retries
auth.rs // password login, token store, auto-refresh (15min TTL)
models.rs // ArtistCard, Release, TrackItem, PlaylistCard, ...
player/ // playback engine
backend.rs // AudioBackend trait
rodio_backend.rs // rodio Player + stream-download sources
queue.rs // queue, shuffle, repeat_mode, next-track prefetch
sync/ // connected devices: heartbeat/poll loop, command handling
app/ // AppState, Action, Event, update()
ui/ // ratatui rendering
views/ // library_grid, artist, release, playlists, search,
// queue, devices, now_playing bar, popups
theme.rs
```
The `api`, `player`, and `app` layers do not import `ui` or ratatui. If a
shared core for furumi_macos/android ever makes sense, those modules extract
into workspace crates without surgery.
## 3. UI model
Persistent layout: a tab bar on top, the active view in the middle, a
now-playing/status bar at the bottom (track, position gauge, volume, shuffle/
repeat, active device indicator).
Tabs (each owns a navigation stack, like a browser per tab):
1. **Library** — paginated grid of artist tiles (`GET /artists`).
`Enter` on a tile pushes **Artist view** (`GET /artists/{id}`: metadata,
top tracks, releases list). Selecting a release pushes **Release view**
(`GET /releases/{id}`: metadata + track list). `Esc`/`Backspace` pops.
2. **Search** — debounced `GET /search?q=` with artists/releases/tracks sections.
3. **Playlists** — own + saved playlists, likes ("Liked tracks" virtual playlist).
4. **Queue** — current play queue, reorder/remove.
5. **Devices** — connected devices list, pick active device, transfer playback.
Navigation state is `Vec<Route>` per tab; a `Route` is an enum
(`ArtistGrid { page }`, `Artist { id }`, `Release { id }`, ...). Views cache
their loaded data in `AppState` keyed by route so Back is instant.
Tile grid: computed from terminal width (`Layout` columns × rows), each tile a
bordered block with artist name (cover art rendering in-terminal is a later,
optional feature — e.g. ratatui-image with kitty/sixel detection, never a
hard dependency).
## 4. Backend integration notes
(Verified against the furumusic source; base path `/api/player`.)
- **Auth**: `POST /api/auth/password` → access token (15 min) + refresh token
(60 days). Client stores tokens at `~/.config/furumi/credentials.json`
(0600) and refreshes proactively via `POST /api/auth/refresh`. All API calls
go through one client that retries once on 401 after refreshing.
- **Streaming**: `GET /api/player/stream/{track_id}` with `Accept-Ranges:
bytes` — exactly what stream-download needs for seek. Original files are
served untranscoded (mp3/flac/ogg/m4a/...), hence the symphonia feature set.
- **Playback state**: persisted server-side via `PUT /api/player/state`
(queue, position, shuffle, repeat, volume). We push throttled updates
(on track change + every ~10s while playing) and restore on startup.
- **History/scrobbling**: `POST /history` on track completion;
`POST /lastfm/now-playing` and `/lastfm/scrobble` if last.fm is connected.
- **Connected devices**: *polling, not websockets.* The sync task:
- sends `POST /devices/poll` every ~5s while the app runs (device TTL is
30s; commands TTL 20s) with our stable `device_id` (generated once,
persisted) and current `playback_state`;
- applies returned commands (`transfer_state` → load queue/position and
start/stop locally; play/pause/seek commands when we are the active
device but controlled remotely);
- feeds the device list into the Devices tab. Activating another device =
`POST /devices/active`; we then stop local audio and become a remote
control (UI keeps working, actions are sent via `POST /devices/command`).
- **Jams** (collaborative sessions) exist in the API — out of scope for v1,
but the sync task's command-handling design must not preclude them.
## 5. Reliability checklist
- Terminal guard type + panic hook: raw mode/alternate screen/keyboard flags
always restored, even on panic.
- Every spawned task's failure becomes an `Event::TaskFailed` rendered as a
status-bar error — no silent hangs, no `unwrap` on I/O.
- Token refresh races guarded by a single-flight lock.
- Audio device disappearance (headphones unplugged) → backend emits
`PlayerEvent::Failed`, engine retries on default device, pauses on repeated
failure.
- Config/keymap parse errors are reported with line context and fall back to
defaults — a typo in keymap.toml must not brick the app.
## 6. Suggested initial dependencies
```toml
[dependencies]
ratatui = "0.30"
crossterm = "0.29"
crokey = "1.4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
rodio = { version = "0.22", features = ["symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } # check exact feature names
stream-download = { version = "0.24", features = ["reqwest"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
thiserror = "2"
anyhow = "1"
directories = "6" # config/cache paths per-OS
tracing = "0.1" # file-based logging (never stdout — it's the UI)
tracing-subscriber = "0.3"
```
## 7. Build order (milestones)
1. Skeleton: terminal guard, event loop, tab bar, status bar, keymap with defaults.
2. `api` crate-module: auth + artists/releases/tracks; Library grid → Artist → Release navigation.
3. Playback: rodio backend + stream-download, queue, now-playing bar, seek/volume.
4. Likes, playlists, search, history reporting.
5. Device sync: heartbeat/poll, transfer playback, remote-control mode.
6. Polish: server-side state restore, last.fm, config file, themes, optional cover art.
Generated
+4520
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "furumi_cli"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
crokey = "1.4.0"
crossterm = { version = "0.29.0", features = ["event-stream"] }
directories = "6.0.0"
futures-util = "0.3.32"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
open = "5.3.5"
ratatui = "0.30.1"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
souvlaki = "0.8.3"
stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[target.'cfg(target_os="macos")'.dependencies]
core-foundation = "0.10.1"
+227
View File
@@ -0,0 +1,227 @@
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use super::models::{TokensResponse, User};
/// Margin before access-token expiry at which we refresh proactively,
/// mirroring the Android/macOS clients.
pub const EXPIRY_SKEW_SECONDS: i64 = 60;
/// Persisted session, same shape as the macOS client's AuthSession.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthSession {
pub server_base_url: String,
pub user: User,
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_at_epoch_seconds: i64,
}
impl AuthSession {
pub fn new(server_base_url: String, user: User, tokens: TokensResponse) -> Self {
Self {
server_base_url,
user,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type,
expires_at_epoch_seconds: now_epoch_seconds() + tokens.expires_in_seconds,
}
}
pub fn apply_tokens(&mut self, tokens: TokensResponse) {
self.access_token = tokens.access_token;
self.refresh_token = tokens.refresh_token;
self.token_type = tokens.token_type;
self.expires_at_epoch_seconds = now_epoch_seconds() + tokens.expires_in_seconds;
}
pub fn access_token_expired(&self) -> bool {
now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds
}
}
pub fn now_epoch_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn session_path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("credentials.json"))
}
pub fn load_session() -> Option<AuthSession> {
let path = session_path()?;
let text = fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(session) => Some(session),
Err(err) => {
tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file");
None
}
}
}
pub fn save_session(session: &AuthSession) -> Result<()> {
let path = session_path().context("cannot determine config directory")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let text = serde_json::to_string_pretty(session)?;
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))
}
pub fn delete_session() {
if let Some(path) = session_path() {
let _ = fs::remove_file(path);
}
}
#[cfg(unix)]
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(text.as_bytes())
}
#[cfg(not(unix))]
fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> {
fs::write(path, text)
}
/// Same normalization rules as the Android client's ServerConfig:
/// add https:// when no scheme, require http(s) with a host, reject
/// credentials/query/fragment, lowercase the host, trim trailing slashes.
pub fn normalize_base_url(raw: &str) -> Result<String> {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
bail!("server URL is empty");
}
let with_scheme = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let url = reqwest::Url::parse(&with_scheme).context("invalid server URL")?;
if !matches!(url.scheme(), "http" | "https") {
bail!("server URL must use http or https");
}
let host = url.host_str().filter(|h| !h.is_empty());
let Some(host) = host else {
bail!("server URL has no host");
};
if !url.username().is_empty() || url.password().is_some() {
bail!("server URL must not contain credentials");
}
if url.query().is_some() || url.fragment().is_some() {
bail!("server URL must not contain a query or fragment");
}
let mut normalized = format!("{}://{}", url.scheme(), host.to_ascii_lowercase());
if let Some(port) = url.port() {
normalized.push_str(&format!(":{port}"));
}
let path = url.path().trim_end_matches('/');
normalized.push_str(path);
Ok(normalized)
}
/// Accepts what the user pastes after browser SSO: either the full
/// `furumi://auth/callback?code=furu_mx_...` link (copied from the
/// "Open Furumi" button) or the bare `furu_mx_...` code.
pub fn extract_sso_code(input: &str) -> Result<String> {
let input = input.trim();
if input.is_empty() {
bail!("paste the link or code first");
}
if input.starts_with("furu_mx_") {
return Ok(input.to_string());
}
if let Ok(url) = reqwest::Url::parse(input) {
if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") {
bail!("SSO failed: {error}");
}
if let Some((_, code)) = url.query_pairs().find(|(k, _)| k == "code") {
return Ok(code.into_owned());
}
}
bail!("no furu_mx_ code found in the pasted text");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_adds_https_and_strips_slash() {
assert_eq!(
normalize_base_url(" Music.Hexor.cy/ ").unwrap(),
"https://music.hexor.cy"
);
}
#[test]
fn normalize_keeps_port_and_path() {
assert_eq!(
normalize_base_url("http://localhost:8000/furumi/").unwrap(),
"http://localhost:8000/furumi"
);
}
#[test]
fn normalize_rejects_bad_urls() {
assert!(normalize_base_url("").is_err());
assert!(normalize_base_url("ftp://x").is_err());
assert!(normalize_base_url("https://user:pw@host").is_err());
assert!(normalize_base_url("https://host?x=1").is_err());
}
#[test]
fn sso_code_from_deep_link() {
let code =
extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap();
assert_eq!(code, "furu_mx_abc123");
}
#[test]
fn sso_code_bare() {
assert_eq!(extract_sso_code(" furu_mx_x ").unwrap(), "furu_mx_x");
}
#[test]
fn sso_error_is_reported() {
let err = extract_sso_code("furumi://auth/callback?error=provider_denied")
.unwrap_err()
.to_string();
assert!(err.contains("provider_denied"));
}
#[test]
fn expiry_uses_skew() {
let session = AuthSession {
server_base_url: "https://x".into(),
user: User {
id: 1,
name: "n".into(),
role: "user".into(),
},
access_token: "a".into(),
refresh_token: "r".into(),
token_type: "Bearer".into(),
expires_at_epoch_seconds: now_epoch_seconds() + 30,
};
assert!(session.access_token_expired());
}
}
+455
View File
@@ -0,0 +1,455 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::sync::Mutex;
use super::auth::{self, AuthSession};
use super::models::{
ApiErrorBody, ArtistDetail, ArtistsPage, LikesResponse, LoginResponse, MeResponse,
PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, TokensResponse, TrackItem,
};
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("{0}")]
Server(String),
/// Refresh token rejected or expired — the user must sign in again.
#[error("session expired, please sign in again")]
SessionExpired,
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
#[error("{0}")]
Other(#[from] anyhow::Error),
}
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(format!(
"furumi-cli/{} ({})",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS
))
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client config is static")
}
pub fn device_name() -> String {
format!("furumi-cli ({})", std::env::consts::OS)
}
#[derive(Serialize)]
struct PasswordLoginRequest<'a> {
username: &'a str,
password: &'a str,
device_name: String,
}
#[derive(Serialize)]
struct SsoExchangeRequest<'a> {
code: &'a str,
device_name: String,
}
#[derive(Serialize)]
struct RefreshRequest<'a> {
refresh_token: &'a str,
}
#[derive(Serialize)]
struct LogoutRequest<'a> {
refresh_token: &'a str,
}
pub async fn login_password(
http: &reqwest::Client,
base_url: &str,
username: &str,
password: &str,
) -> Result<AuthSession, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/password"))
.json(&PasswordLoginRequest {
username,
password,
device_name: device_name(),
})
.send()
.await?;
let login: LoginResponse = parse_response(response).await?;
Ok(AuthSession::new(base_url.to_string(), login.user, login.tokens))
}
pub async fn login_sso_exchange(
http: &reqwest::Client,
base_url: &str,
code: &str,
) -> Result<AuthSession, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/sso/exchange"))
.json(&SsoExchangeRequest {
code,
device_name: device_name(),
})
.send()
.await?;
let login: LoginResponse = parse_response(response).await?;
Ok(AuthSession::new(base_url.to_string(), login.user, login.tokens))
}
/// Browser entry point for SSO. redirect_uri is either our loopback
/// listener (`http://127.0.0.1:{port}/callback`) or the `furumi://` deep
/// link as a manual-paste fallback.
pub fn sso_start_url(base_url: &str, redirect_uri: &str) -> String {
let mut url = reqwest::Url::parse(&format!("{base_url}/auth/mobile/oidc/start"))
.expect("base_url is pre-validated");
url.query_pairs_mut()
.append_pair("redirect_uri", redirect_uri);
url.to_string()
}
async fn refresh_tokens(
http: &reqwest::Client,
base_url: &str,
refresh_token: &str,
) -> Result<TokensResponse, ApiError> {
let response = http
.post(format!("{base_url}/api/auth/refresh"))
.json(&RefreshRequest { refresh_token })
.send()
.await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err(ApiError::SessionExpired);
}
parse_response(response).await
}
/// Mirrors the backend's PlaybackStateDto.
#[derive(Debug, Serialize)]
pub struct PlaybackStateBody {
pub current_track_id: Option<i64>,
pub position_ms: i32,
pub queue: Vec<i64>,
pub queue_position: i32,
pub shuffle: bool,
pub repeat_mode: String,
pub volume: f64,
}
/// Percent-encode a query-string value.
fn url_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response.json().await?);
}
let message = match response.json::<ApiErrorBody>().await {
Ok(body) => body.error,
Err(_) => format!("server returned {status}"),
};
Err(ApiError::Server(message))
}
/// Authenticated API client. Owns the session; refreshes the access token
/// proactively (60s skew) and once more on 401, persisting rotated tokens.
/// The session mutex makes concurrent refreshes single-flight.
pub struct ApiClient {
http: reqwest::Client,
base_url: String,
session: Mutex<AuthSession>,
}
impl ApiClient {
pub fn new(http: reqwest::Client, session: AuthSession) -> Self {
Self {
http,
base_url: session.server_base_url.clone(),
session: Mutex::new(session),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn me(&self) -> Result<MeResponse, ApiError> {
self.get_json("/api/player/me").await
}
pub async fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage, ApiError> {
self.get_json(&format!("/api/player/artists?page={page}&limit={limit}"))
.await
}
pub async fn artist(&self, id: i64) -> Result<ArtistDetail, ApiError> {
self.get_json(&format!("/api/player/artists/{id}")).await
}
pub async fn release(&self, id: i64) -> Result<ReleaseDetail, ApiError> {
self.get_json(&format!("/api/player/releases/{id}")).await
}
pub async fn search(&self, query: &str, limit: i64) -> Result<SearchResults, ApiError> {
self.get_json(&format!(
"/api/player/search?q={}&limit={limit}",
url_encode(query)
))
.await
}
/// Open an audio stream for playback: background download backed by a
/// temp file, exposing blocking Read+Seek for the decoder; seeking into
/// not-yet-downloaded ranges uses HTTP Range requests.
///
/// The download client carries the bearer token valid at start; on very
/// long tracks a Range request after token expiry (15 min) can fail —
/// acceptable for now, a refreshing middleware can replace this later.
pub async fn open_stream(
&self,
path: &str,
) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> {
use stream_download::Settings;
use stream_download::http::HttpStream;
use stream_download::source::SourceStream as _;
use stream_download::storage::temp::TempStorageProvider;
let token = self.fresh_access_token().await?;
let mut headers = reqwest::header::HeaderMap::new();
let value = format!("Bearer {token}")
.parse()
.map_err(|_| ApiError::Server("invalid token header".to_string()))?;
headers.insert(reqwest::header::AUTHORIZATION, value);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(ApiError::Network)?;
let url = format!("{}{path}", self.base_url)
.parse()
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
let stream = HttpStream::new(client, url)
.await
.map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?;
let byte_len = stream.content_length();
let reader = stream_download::StreamDownload::from_stream(
stream,
TempStorageProvider::new(),
Settings::default(),
)
.await
.map_err(|err| ApiError::Server(format!("stream start failed: {err}")))?;
Ok((reader, byte_len))
}
/// Raw bytes (cover art, artist images) from a server-relative path.
pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, ApiError> {
let url = format!("{}{path}", self.base_url);
let response = self
.send_authed(&url, |client, url, token| client.get(url).bearer_auth(token))
.await?;
let status = response.status();
if !status.is_success() {
return Err(ApiError::Server(format!("server returned {status}")));
}
Ok(response.bytes().await?.to_vec())
}
pub async fn playlists(&self) -> Result<Vec<PlaylistCard>, ApiError> {
self.get_json("/api/player/playlists").await
}
pub async fn playlist(&self, id: i64) -> Result<PlaylistDetail, ApiError> {
self.get_json(&format!("/api/player/playlists/{id}")).await
}
pub async fn likes(&self) -> Result<Vec<i64>, ApiError> {
let response: LikesResponse = self.get_json("/api/player/likes").await?;
Ok(response.track_ids)
}
pub async fn toggle_like(&self, track_id: i64) -> Result<bool, ApiError> {
#[derive(serde::Deserialize)]
struct Body {
liked: bool,
}
let body: Body = self
.post_json(&format!("/api/player/likes/toggle/{track_id}"), &())
.await?;
Ok(body.liked)
}
#[allow(dead_code, reason = "device-sync state restore needs id→track resolution")]
pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result<Vec<TrackItem>, ApiError> {
#[derive(Serialize)]
struct Body<'a> {
track_ids: &'a [i64],
}
self.post_json("/api/player/tracks-by-ids", &Body { track_ids })
.await
}
/// Report a finished/aborted listen to the play history.
pub async fn report_history(
&self,
track_id: i64,
started_at: Option<i64>,
listened_seconds: i32,
) -> Result<(), ApiError> {
#[derive(Serialize)]
struct Body {
track_id: i64,
started_at: Option<i64>,
listened_seconds: i32,
}
let _: serde_json::Value = self
.post_json(
"/api/player/history",
&Body {
track_id,
started_at,
listened_seconds,
},
)
.await?;
Ok(())
}
/// Persist playback state server-side (used for cross-device restore).
pub async fn push_state(&self, state: &PlaybackStateBody) -> Result<(), ApiError> {
let _: serde_json::Value = self.put_json("/api/player/state", state).await?;
Ok(())
}
/// Revoke this device's session server-side. Best effort: local
/// credentials are deleted regardless of the outcome.
pub async fn logout(&self) -> Result<bool, ApiError> {
let (access_token, refresh_token) = {
let session = self.session.lock().await;
(session.access_token.clone(), session.refresh_token.clone())
};
let response = self
.http
.post(format!("{}/api/auth/logout", self.base_url))
.bearer_auth(access_token)
.json(&LogoutRequest {
refresh_token: &refresh_token,
})
.send()
.await?;
#[derive(serde::Deserialize)]
struct LogoutResponse {
revoked: bool,
}
let body: LogoutResponse = parse_response(response).await?;
Ok(body.revoked)
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
self.json_request::<(), T>(reqwest::Method::GET, path, None)
.await
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
self.json_request(reqwest::Method::POST, path, Some(body))
.await
}
pub async fn put_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
self.json_request(reqwest::Method::PUT, path, Some(body))
.await
}
async fn json_request<B: Serialize, T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&B>,
) -> Result<T, ApiError> {
let url = format!("{}{path}", self.base_url);
let response = self
.send_authed(&url, |client, url, token| {
let mut request = client.request(method.clone(), url).bearer_auth(token);
if let Some(body) = body {
request = request.json(body);
}
request
})
.await?;
parse_response(response).await
}
/// Send a request with a fresh bearer token; on 401, refresh once and
/// retry. `build` is called per attempt because RequestBuilder is not
/// reusable after send.
async fn send_authed<F>(&self, url: &str, build: F) -> Result<reqwest::Response, ApiError>
where
F: Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder,
{
let token = self.fresh_access_token().await?;
let response = build(&self.http, url, &token).send().await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let token = self.refresh_after_rejection(&token).await?;
return Ok(build(&self.http, url, &token).send().await?);
}
Ok(response)
}
async fn fresh_access_token(&self) -> Result<String, ApiError> {
let mut session = self.session.lock().await;
if session.access_token_expired() {
self.refresh_locked(&mut session).await?;
}
Ok(session.access_token.clone())
}
/// A 401 with a token another task already rotated just retries with the
/// current token; otherwise this task performs the refresh itself.
async fn refresh_after_rejection(&self, rejected_token: &str) -> Result<String, ApiError> {
let mut session = self.session.lock().await;
if session.access_token != rejected_token {
return Ok(session.access_token.clone());
}
self.refresh_locked(&mut session).await?;
Ok(session.access_token.clone())
}
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
match result {
Ok(tokens) => {
session.apply_tokens(tokens);
if let Err(err) = auth::save_session(session) {
tracing::warn!(%err, "failed to persist rotated tokens");
}
tracing::debug!("access token refreshed");
Ok(())
}
Err(ApiError::SessionExpired) => {
auth::delete_session();
Err(ApiError::SessionExpired)
}
Err(err) => Err(err),
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod auth;
pub mod client;
pub mod models;
+232
View File
@@ -0,0 +1,232 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub name: String,
pub role: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokensResponse {
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in_seconds: i64,
}
#[derive(Debug, Deserialize)]
pub struct LoginResponse {
pub user: User,
pub tokens: TokensResponse,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code, reason = "rendered by the profile view in a later milestone")]
pub struct MeStats {
pub liked_tracks: i64,
pub playlists: i64,
pub plays: i64,
pub listened_minutes: i64,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code, reason = "rendered by the profile view in a later milestone")]
pub struct MeResponse {
pub id: i64,
pub name: String,
pub role: String,
pub stats: MeStats,
}
#[derive(Debug, Deserialize)]
pub struct ApiErrorBody {
pub error: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ArtistCard {
#[allow(dead_code, reason = "opens the artist view in the next milestone")]
pub id: i64,
pub name: String,
/// Relative path like `/api/player/cover/{file_id}/medium`.
pub image_url: Option<String>,
pub release_count: i64,
pub track_count: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ArtistRef {
#[allow(dead_code, reason = "navigation to artists from track rows later")]
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TrackItem {
#[allow(dead_code, reason = "playback engine consumes this in milestone 3")]
pub id: i64,
pub title: String,
pub track_number: Option<i32>,
pub duration_seconds: f64,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
#[allow(dead_code, reason = "jump-to-release navigation later")]
pub release_id: i64,
pub release_title: String,
#[allow(dead_code, reason = "shown in queue/now-playing later")]
pub release_year: Option<i32>,
/// Server-relative path to `/api/player/stream/{id}`.
pub stream_url: String,
#[allow(dead_code, reason = "now-playing artwork in milestone 3")]
pub cover_url: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub file_size_bytes: Option<i64>,
#[allow(dead_code, reason = "popularity column later")]
pub lastfm_playcount: Option<i64>,
}
impl TrackItem {
pub fn artist_line(&self) -> String {
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
if !self.featured_artists.is_empty() {
names.push("feat.");
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
}
names.join(", ")
}
pub fn duration_label(&self) -> String {
let total = self.duration_seconds.round() as i64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Compact "FLAC 929k 24.3MB" suffix for track rows.
pub fn tech_label_short(&self) -> String {
let mut parts = Vec::new();
if let Some(format) = &self.audio_format {
parts.push(format.to_uppercase());
}
if let Some(bitrate) = self.audio_bitrate {
parts.push(format!("{bitrate}k"));
}
if let Some(bytes) = self.file_size_bytes {
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
}
parts.join(" ")
}
/// Full tech line for the status bar, including the sample rate.
pub fn tech_label_full(&self) -> String {
let mut parts = Vec::new();
if let Some(format) = &self.audio_format {
parts.push(format.to_uppercase());
}
if let Some(bitrate) = self.audio_bitrate {
parts.push(format!("{bitrate}kbps"));
}
if let Some(rate) = self.audio_sample_rate {
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
}
if let Some(bytes) = self.file_size_bytes {
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
}
parts.join(" · ")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReleaseCard {
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_url: Option<String>,
pub track_count: i64,
}
#[derive(Debug, Deserialize)]
pub struct ArtistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub name: String,
pub image_url: Option<String>,
pub total_track_count: i64,
pub total_play_count: i64,
pub top_tracks: Vec<TrackItem>,
pub releases: Vec<ReleaseCard>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploaderSummary {
pub name: String,
#[allow(dead_code, reason = "per-uploader stats for a later detail popup")]
pub track_count: i64,
}
#[derive(Debug, Deserialize)]
pub struct ReleaseDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_url: Option<String>,
pub artists: Vec<ArtistRef>,
pub tracks: Vec<TrackItem>,
pub uploaders: Vec<UploaderSummary>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlaylistCard {
pub id: i64,
pub title: String,
pub track_count: i64,
pub is_own: bool,
pub owner_name: Option<String>,
pub is_public: bool,
#[allow(dead_code, reason = "save/unsave playlists later")]
pub is_saved: bool,
#[allow(dead_code, reason = "playlist kinds get distinct icons later")]
pub kind: String,
}
#[derive(Debug, Deserialize)]
pub struct PlaylistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
#[allow(dead_code, reason = "shown in a detail header later")]
pub description: Option<String>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Deserialize)]
pub struct LikesResponse {
pub track_ids: Vec<i64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct SearchResults {
pub artists: Vec<ArtistCard>,
pub releases: Vec<ReleaseCard>,
pub tracks: Vec<TrackItem>,
}
impl SearchResults {
pub fn len(&self) -> usize {
self.artists.len() + self.releases.len() + self.tracks.len()
}
}
#[derive(Debug, Deserialize)]
pub struct ArtistsPage {
pub items: Vec<ArtistCard>,
pub total: i64,
pub page: i64,
#[allow(dead_code, reason = "part of the server pagination envelope")]
pub per_page: i64,
pub has_more: bool,
}
+74
View File
@@ -0,0 +1,74 @@
use serde::Deserialize;
/// Semantic commands the user can trigger. Raw key events are translated into
/// these by the keymap; views and `update()` never see raw keys.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub enum Action {
Quit,
NextTab,
PrevTab,
GoToTab(usize),
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
PageUp,
PageDown,
SelectFirst,
SelectLast,
Select,
Back,
PlayPause,
NextTrack,
PrevTrack,
SeekForward { seconds: u32 },
SeekBackward { seconds: u32 },
VolumeUp,
VolumeDown,
ToggleShuffle,
CycleRepeat,
ToggleLike,
QueueAddNext,
QueueAddLast,
ToggleHelp,
ToggleViewMode,
OpenCommandLine,
Logout,
}
impl Action {
pub fn describe(&self) -> String {
match self {
Action::Quit => "Quit".into(),
Action::NextTab => "Next tab".into(),
Action::PrevTab => "Previous tab".into(),
Action::GoToTab(i) => format!("Go to tab {}", i + 1),
Action::MoveUp => "Move up".into(),
Action::MoveDown => "Move down".into(),
Action::MoveLeft => "Move left".into(),
Action::MoveRight => "Move right".into(),
Action::PageUp => "Page up".into(),
Action::PageDown => "Page down".into(),
Action::SelectFirst => "Jump to first item".into(),
Action::SelectLast => "Jump to last item".into(),
Action::Select => "Open / activate".into(),
Action::Back => "Go back / close".into(),
Action::PlayPause => "Play / pause".into(),
Action::NextTrack => "Next track".into(),
Action::PrevTrack => "Previous track".into(),
Action::SeekForward { seconds } => format!("Seek forward {seconds}s"),
Action::SeekBackward { seconds } => format!("Seek backward {seconds}s"),
Action::VolumeUp => "Volume up".into(),
Action::VolumeDown => "Volume down".into(),
Action::ToggleShuffle => "Toggle shuffle".into(),
Action::CycleRepeat => "Cycle repeat mode".into(),
Action::ToggleLike => "Like / unlike".into(),
Action::QueueAddNext => "Queue: add next".into(),
Action::QueueAddLast => "Queue: add to end".into(),
Action::ToggleHelp => "Show / hide keybindings".into(),
Action::ToggleViewMode => "Toggle tiles / table view".into(),
Action::OpenCommandLine => "Open command line (:/name searches)".into(),
Action::Logout => "Sign out".into(),
}
}
}
+153
View File
@@ -0,0 +1,153 @@
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::client::ApiError;
use crate::app::Runtime;
use crate::app::command::{self, Command, Parsed};
use crate::app::event::AppEvent;
use crate::app::state::{AppState, GlobalView, SearchState, Tab};
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180);
const SEARCH_LIMIT: i64 = 12;
/// Keys go here instead of the keymap while the command line is open.
pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) {
match key.code {
KeyCode::Esc => cancel(state),
KeyCode::Enter => commit(state),
KeyCode::Backspace => {
if state.cmdline.input.pop().is_none() {
// Backspace on an empty line closes it, like vim.
cancel(state);
return;
}
after_change(state, runtime);
}
KeyCode::Char(c) if is_typing(key) => {
state.cmdline.input.push(c);
after_change(state, runtime);
}
_ => {}
}
}
pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) {
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
state.cmdline.input.push_str(&cleaned);
after_change(state, runtime);
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers.difference(KeyModifiers::SHIFT).is_empty()
}
/// Re-evaluate the input after every edit; live commands (search) take
/// effect immediately, while typing.
fn after_change(state: &mut AppState, runtime: &Runtime) {
match command::parse(&state.cmdline.input) {
Parsed::Command(command) if command::is_live(&command) => {
apply_live(state, runtime, command);
}
_ => retract_live(state),
}
}
fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
match command {
Command::Search(query) => {
state.active_tab = Tab::Global;
if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
state.global.stack.push(GlobalView::Search { cursor: 0 });
}
state.cmdline.live = true;
if state.search.query != query {
state.search.query = query;
set_view_cursor_zero(state);
schedule_search(state, runtime);
}
}
}
}
fn set_view_cursor_zero(state: &mut AppState) {
if let Some(GlobalView::Search { cursor }) = state.global.stack.last_mut() {
*cursor = 0;
}
}
/// Debounced, race-free search: every edit bumps the global sequence; the
/// spawned task only queries if it is still the latest after the debounce,
/// and the receiver drops responses that arrive out of date.
fn schedule_search(state: &mut AppState, runtime: &Runtime) {
let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1;
let query = state.search.query.clone();
if query.is_empty() {
state.search.loading = false;
state.search.results = None;
return;
}
let Some(api) = runtime.api.clone() else {
return;
};
state.search.loading = true;
let tx = runtime.event_tx.clone();
let latest = Arc::clone(&runtime.search_seq);
tokio::spawn(async move {
tokio::time::sleep(SEARCH_DEBOUNCE).await;
if latest.load(Ordering::SeqCst) != seq {
return;
}
let event = match api.search(&query, SEARCH_LIMIT).await {
Ok(results) => AppEvent::SearchLoaded {
seq,
result: Ok(results),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::SearchLoaded {
seq,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
});
}
/// Enter: close the line. Live commands already took effect (their view
/// stays open); one-shot commands would execute here.
fn commit(state: &mut AppState) {
let parsed = command::parse(&state.cmdline.input);
close(state);
match parsed {
Parsed::Empty | Parsed::Command(_) => {}
Parsed::Unknown(name) => {
state.status_message = Some(format!("unknown command: {name}"));
}
}
}
/// Esc: close the line and undo any live effect it had.
fn cancel(state: &mut AppState) {
retract_live(state);
close(state);
}
fn close(state: &mut AppState) {
state.cmdline.active = false;
state.cmdline.input.clear();
state.cmdline.live = false;
}
/// Pop the live search view if this command-line session opened it.
fn retract_live(state: &mut AppState) {
if !state.cmdline.live {
return;
}
state.cmdline.live = false;
if matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) {
state.global.stack.pop();
state.search = SearchState::default();
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Command line (`:`) command parsing.
//!
//! To add a command:
//! 1. Add a `Command` variant.
//! 2. Recognize it in `parse()` below.
//! 3. Handle it in `app::cmdline` — live commands (re-evaluated on every
//! keystroke, like search) in `apply_live`, one-shot commands in `commit`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// `:/query` — realtime search over artists, releases and tracks.
Search(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Parsed {
/// Nothing typed yet.
Empty,
Command(Command),
Unknown(String),
}
pub fn parse(input: &str) -> Parsed {
if input.is_empty() {
return Parsed::Empty;
}
if let Some(query) = input.strip_prefix('/') {
return Parsed::Command(Command::Search(query.trim().to_string()));
}
// Future word commands parse here, e.g. "volume 50" / "seek +30".
let name = input.split_whitespace().next().unwrap_or(input);
Parsed::Unknown(name.to_string())
}
/// Live commands take effect while typing; one-shot commands run on Enter.
pub fn is_live(command: &Command) -> bool {
matches!(command, Command::Search(_))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_search() {
assert_eq!(
parse("/daft punk"),
Parsed::Command(Command::Search("daft punk".to_string()))
);
assert_eq!(parse("/"), Parsed::Command(Command::Search(String::new())));
}
#[test]
fn unknown_and_empty() {
assert_eq!(parse(""), Parsed::Empty);
assert_eq!(parse("volume 50"), Parsed::Unknown("volume".to_string()));
}
#[test]
fn search_is_live() {
assert!(is_live(&Command::Search("x".into())));
}
}
+65
View File
@@ -0,0 +1,65 @@
use std::sync::Arc;
use crate::api::auth::AuthSession;
use crate::api::models::{
ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults,
TrackItem,
};
use crate::art::ArtImage;
/// Events delivered to the main loop by background tasks (API fetches, the
/// playback engine, device sync). Tasks never touch AppState directly.
#[derive(Debug)]
pub enum AppEvent {
StatusMessage(String),
LoginSucceeded(Box<AuthSession>),
LoginFailed(String),
/// Loopback listener received the browser SSO callback.
SsoCallback(Result<String, String>),
/// Refresh token rejected — stored credentials were deleted.
SessionExpired,
/// A page of the Global artists list arrived (or failed).
ArtistsLoaded(Result<ArtistsPage, String>),
ArtistViewLoaded {
id: i64,
result: Result<ArtistDetail, String>,
},
ReleaseViewLoaded {
id: i64,
result: Result<ReleaseDetail, String>,
},
/// Live search results; `seq` drops responses that are already stale.
SearchLoaded {
seq: u64,
result: Result<SearchResults, String>,
},
/// Artwork fetched and decoded for the shared art cache.
ArtLoaded {
key: String,
art: Option<Arc<ArtImage>>,
},
Player(crate::player::PlayerEvent),
/// A command from the OS media keys.
Media(crate::media::MediaCommand),
/// Gapless prefetch could not open the stream; the normal track-switch
/// path takes over when the current track ends.
PrefetchFailed {
pos: usize,
},
PlaylistsLoaded(Result<Vec<PlaylistCard>, String>),
PlaylistViewLoaded {
id: i64,
result: Result<PlaylistDetail, String>,
},
/// Liked track ids for the ♥ markers.
LikesLoaded(Result<Vec<i64>, String>),
LikeToggled {
track_id: i64,
liked: bool,
},
/// A release fetched for queueing (a / shift-a on a release).
EnqueueTracks {
tracks: Vec<TrackItem>,
next: bool,
},
}
+190
View File
@@ -0,0 +1,190 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::api::{auth, client};
use crate::app::Runtime;
use crate::app::event::AppEvent;
use crate::app::sso;
use crate::app::state::{AppState, LoginField, LoginForm, LoginMode};
pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
state.should_quit = true;
return;
}
let form = &mut state.login;
if form.busy {
return;
}
match form.mode {
LoginMode::Form => handle_form_key(form, runtime, key),
LoginMode::SsoPending => handle_sso_key(form, runtime, key),
}
}
/// Bracketed paste goes into whichever text field is focused.
pub fn handle_paste(state: &mut AppState, pasted: &str) {
let form = &mut state.login;
if form.busy {
return;
}
let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect();
if let Some(field) = focused_text(form) {
field.push_str(&cleaned);
}
}
fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
KeyCode::Backspace => {
if let Some(field) = focused_text(form) {
field.pop();
}
}
KeyCode::Enter => match form.focus {
LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(),
LoginField::Password | LoginField::SignInButton => {
submit_password(form, runtime);
}
LoginField::SsoButton => start_sso(form, runtime),
},
KeyCode::Char(c) if is_typing(key) => {
if let Some(field) = focused_text(form) {
field.push(c);
}
}
_ => {}
}
}
fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
form.mode = LoginMode::Form;
form.sso_paste.clear();
form.error = None;
}
KeyCode::Backspace => {
form.sso_paste.pop();
}
KeyCode::Enter => submit_sso_code(form, runtime),
KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c),
_ => {}
}
}
fn is_typing(key: KeyEvent) -> bool {
key.modifiers
.difference(KeyModifiers::SHIFT)
.is_empty()
}
fn focused_text(form: &mut LoginForm) -> Option<&mut String> {
if form.mode == LoginMode::SsoPending {
return Some(&mut form.sso_paste);
}
match form.focus {
LoginField::ServerUrl => Some(&mut form.server_url),
LoginField::Username => Some(&mut form.username),
LoginField::Password => Some(&mut form.password),
LoginField::SignInButton | LoginField::SsoButton => None,
}
}
fn submit_password(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
let username = form.username.trim().to_string();
if username.is_empty() {
return form.error = Some("enter a username".to_string());
}
if form.password.is_empty() {
return form.error = Some("enter a password".to_string());
}
form.server_url = base_url.clone();
form.busy = true;
let password = form.password.clone();
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_password(&http, &base_url, &username, &password).await;
let _ = tx.send(login_event(result));
});
}
fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) {
form.error = None;
let base_url = match auth::normalize_base_url(&form.server_url) {
Ok(url) => url,
Err(err) => return form.error = Some(err.to_string()),
};
form.server_url = base_url.clone();
form.sso_paste.clear();
// Preferred flow: loopback listener, the browser redirect finishes the
// login hands-free. Fallback: furumi:// deep link + manual code paste.
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
match sso::start(runtime.event_tx.clone()) {
Ok(listener) => {
let redirect = format!("http://127.0.0.1:{}/callback", listener.port);
form.sso_url = client::sso_start_url(&base_url, &redirect);
form.sso_port = Some(listener.port);
runtime.sso = Some(listener);
}
Err(err) => {
tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste");
form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback");
form.sso_port = None;
}
}
form.mode = LoginMode::SsoPending;
if let Err(err) = open::that_detached(&form.sso_url) {
tracing::warn!(%err, "failed to open browser for SSO");
form.error = Some("couldn't open a browser — use the URL below".to_string());
}
}
fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) {
form.error = None;
let code = match auth::extract_sso_code(&form.sso_paste) {
Ok(code) => code,
Err(err) => return form.error = Some(err.to_string()),
};
spawn_sso_exchange(form, runtime, code);
}
/// Used by both the manual paste path and the loopback callback event.
pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) {
let base_url = form.server_url.clone();
form.busy = true;
let http = runtime.http.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = client::login_sso_exchange(&http, &base_url, &code).await;
let _ = tx.send(login_event(result));
});
}
fn login_event(result: Result<auth::AuthSession, client::ApiError>) -> AppEvent {
match result {
Ok(session) => {
if let Err(err) = auth::save_session(&session) {
tracing::warn!(%err, "failed to persist credentials");
}
AppEvent::LoginSucceeded(Box::new(session))
}
Err(err) => AppEvent::LoginFailed(err.to_string()),
}
}
+887
View File
@@ -0,0 +1,887 @@
pub mod action;
mod cmdline;
pub mod command;
pub mod event;
mod login;
mod sso;
pub mod state;
pub mod update;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use crokey::KeyCombination;
use crossterm::event::{Event as TermEvent, EventStream, KeyEvent, KeyEventKind};
use futures_util::StreamExt;
use ratatui::DefaultTerminal;
use tokio::sync::mpsc;
use tokio::time::MissedTickBehavior;
use crate::api::auth;
use crate::api::client::{ApiClient, ApiError, http_client};
use crate::config::keymap::{KeyResolution, Keymap};
use crate::player;
use crate::ui;
use event::AppEvent;
use state::{AppState, Screen};
use update::{Effect, update};
const TICK_INTERVAL: Duration = Duration::from_millis(250);
/// Handles shared by background tasks; AppState stays pure UI data.
pub struct Runtime {
pub event_tx: mpsc::UnboundedSender<AppEvent>,
pub http: reqwest::Client,
pub api: Option<Arc<ApiClient>>,
pub sso: Option<sso::SsoListener>,
/// Caps concurrent artwork downloads so they never starve API calls.
pub art_semaphore: Arc<tokio::sync::Semaphore>,
/// Monotonic sequence for live search; stale responses are dropped.
pub search_seq: Arc<std::sync::atomic::AtomicU64>,
pub player: player::Controller,
pub last_state_push: Option<std::time::Instant>,
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
pub last_media_push: Option<std::time::Instant>,
}
pub async fn run(
mut terminal: DefaultTerminal,
mut keymap: Keymap,
startup_warning: Option<String>,
event_tx: mpsc::UnboundedSender<AppEvent>,
mut event_rx: mpsc::UnboundedReceiver<AppEvent>,
media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
) -> Result<()> {
let mut state = AppState {
status_message: startup_warning,
..AppState::default()
};
let player_events = event_tx.clone();
let mut runtime = Runtime {
event_tx,
http: http_client(),
api: None,
sso: None,
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
player: player::spawn(move |event| {
let _ = player_events.send(AppEvent::Player(event));
}),
last_state_push: None,
media_tx,
last_media_push: None,
};
match auth::load_session() {
Some(session) => {
state.user = Some(session.user.clone());
let api = Arc::new(ApiClient::new(runtime.http.clone(), session));
runtime.api = Some(Arc::clone(&api));
spawn_session_check(&runtime, api);
}
None => state.screen = Screen::Login,
}
let mut input = EventStream::new();
let mut tick = tokio::time::interval(TICK_INTERVAL);
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
tokio::select! {
maybe_event = input.next() => match maybe_event {
Some(Ok(event)) => handle_terminal_event(&mut state, &mut keymap, &mut runtime, event),
Some(Err(err)) => return Err(err.into()),
None => state.should_quit = true,
},
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
_ = tick.tick() => {
expire_quit_confirmation(&mut state);
if state.player.current.is_some() {
state.player.position_secs = runtime.player.shared.position().as_secs_f64();
state.player.paused = runtime.player.shared.paused();
}
maybe_prefetch_next(&mut state, &runtime);
maybe_push_state(&state, &mut runtime);
push_media_update(&state, &mut runtime, false);
}
}
if state.should_quit {
return Ok(());
}
maintenance(&mut state, &mut runtime);
}
}
const ARTISTS_PAGE_SIZE: i64 = 48;
const ARTISTS_PREFETCH_MARGIN: usize = 24;
/// Runs after every event: kicks off whatever background work the current
/// state needs — the first artists page, the next page when the selection
/// nears the end, and artwork for loaded artists.
fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
let Some(api) = runtime.api.clone() else {
return;
};
if state.screen != Screen::Main {
return;
}
{
let global = &mut state.global;
let initial = global.artists.is_empty();
let near_end =
!initial && global.selected + ARTISTS_PREFETCH_MARGIN >= global.artists.len();
if global.has_more && !global.loading && global.error.is_none() && (initial || near_end) {
global.loading = true;
let page = global.next_page;
let api = Arc::clone(&api);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.artists(page, ARTISTS_PAGE_SIZE).await {
Ok(page) => AppEvent::ArtistsLoaded(Ok(page)),
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
}
// Liked ids load once per session — markers are shown everywhere.
if !state.likes_loaded {
state.likes_loaded = true;
let api = Arc::clone(&api);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.likes().await {
Ok(ids) => AppEvent::LikesLoaded(Ok(ids)),
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::LikesLoaded(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
// Playlists tab data.
if state.active_tab == state::Tab::Playlists {
if state.playlists.list.is_none() {
state.playlists.list = Some(state::Loadable::Loading);
let api = Arc::clone(&api);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.playlists().await {
Ok(list) => AppEvent::PlaylistsLoaded(Ok(list)),
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::PlaylistsLoaded(Err(err.to_string())),
};
let _ = tx.send(event);
});
}
if let Some(opened) = state.playlists.opened {
let id = opened.id;
if let std::collections::hash_map::Entry::Vacant(entry) =
state.playlist_views.entry(id)
{
entry.insert(state::Loadable::Loading);
let api = Arc::clone(&api);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.playlist(id).await {
Ok(detail) => AppEvent::PlaylistViewLoaded {
id,
result: Ok(detail),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::PlaylistViewLoaded {
id,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
});
}
}
}
// Drill-down views pushed on the stack fetch their data on first sight.
for view in state.global.stack.clone() {
match view {
state::GlobalView::Artist { id, .. } => {
if let std::collections::hash_map::Entry::Vacant(entry) =
state.artist_views.entry(id)
{
entry.insert(state::Loadable::Loading);
let api = Arc::clone(&api);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.artist(id).await {
Ok(detail) => AppEvent::ArtistViewLoaded {
id,
result: Ok(detail),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::ArtistViewLoaded {
id,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
});
}
}
state::GlobalView::Release { id, .. } => {
if let std::collections::hash_map::Entry::Vacant(entry) =
state.release_views.entry(id)
{
entry.insert(state::Loadable::Loading);
let api = Arc::clone(&api);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let event = match api.release(id).await {
Ok(detail) => AppEvent::ReleaseViewLoaded {
id,
result: Ok(detail),
},
Err(ApiError::SessionExpired) => AppEvent::SessionExpired,
Err(err) => AppEvent::ReleaseViewLoaded {
id,
result: Err(err.to_string()),
},
};
let _ = tx.send(event);
});
}
}
state::GlobalView::Search { .. } => {}
}
}
// Artwork wanted by everything currently loaded, at its display size.
let mut wanted: Vec<(String, u16, u16)> = Vec::new();
let tile = (state::ART_CELL_WIDTH, state::ART_CELL_HEIGHT);
let header = (state::ART_HEADER_WIDTH, state::ART_HEADER_HEIGHT);
for artist in &state.global.artists {
if let Some(url) = &artist.image_url {
wanted.push((url.clone(), tile.0, tile.1));
}
}
for detail in state.artist_views.values() {
if let state::Loadable::Ready(detail) = detail {
if let Some(url) = &detail.image_url {
wanted.push((url.clone(), header.0, header.1));
}
for release in &detail.releases {
if let Some(url) = &release.cover_url {
wanted.push((url.clone(), tile.0, tile.1));
}
}
}
}
for detail in state.release_views.values() {
if let state::Loadable::Ready(detail) = detail {
if let Some(url) = &detail.cover_url {
wanted.push((url.clone(), header.0, header.1));
}
}
}
for (url, width, height) in wanted {
let key = crate::art::cache_key(&url, width, height);
if state.art.contains_key(&key) {
continue;
}
state.art.insert(key.clone(), state::ArtState::Loading);
spawn_art_fetch(runtime, Arc::clone(&api), key, url, width, height);
}
}
fn spawn_art_fetch(
runtime: &Runtime,
api: Arc<ApiClient>,
key: String,
url: String,
width: u16,
height: u16,
) {
let tx = runtime.event_tx.clone();
let semaphore = Arc::clone(&runtime.art_semaphore);
tokio::spawn(async move {
let Ok(_permit) = semaphore.acquire_owned().await else {
return;
};
let art = match api.get_bytes(&url).await {
Ok(bytes) => tokio::task::spawn_blocking(move || {
crate::art::decode_to_cells(&bytes, width, height)
})
.await
.map_err(anyhow::Error::from)
.and_then(|r| r)
.map_err(|err| tracing::warn!(%err, url, "artwork decode failed"))
.ok()
.map(Arc::new),
Err(err) => {
tracing::warn!(%err, url, "artwork fetch failed");
None
}
};
let _ = tx.send(AppEvent::ArtLoaded { key, art });
});
}
/// Execute a side effect requested by update().
fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
match effect {
Effect::PlayCurrent => {
play_current(state, runtime);
push_state_now(state, runtime);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
}
Effect::TogglePause => {
runtime.player.toggle_pause();
push_media_update(state, runtime, true);
}
Effect::StopPlayback => {
runtime.player.stop();
push_media_update(state, runtime, true);
}
Effect::SeekBy(delta) => {
let target = (state.player.position_secs + delta as f64).max(0.0);
state.player.position_secs = target;
runtime.player.seek(std::time::Duration::from_secs_f64(target));
}
Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)),
Effect::EnqueueRelease { id, next } => {
let Some(api) = runtime.api.clone() else {
return;
};
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
match api.release(id).await {
Ok(detail) => {
let _ = tx.send(AppEvent::EnqueueTracks {
tracks: detail.tracks,
next,
});
}
Err(err) => {
tracing::warn!(%err, release = id, "queueing a release failed");
let _ = tx.send(AppEvent::StatusMessage(format!("queue failed: {err}")));
}
}
});
}
Effect::ToggleLike { track_id } => {
let Some(api) = runtime.api.clone() else {
return;
};
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
match api.toggle_like(track_id).await {
Ok(liked) => {
let _ = tx.send(AppEvent::LikeToggled { track_id, liked });
}
Err(err) => {
tracing::warn!(%err, track_id, "like toggle failed");
let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {err}")));
}
}
});
}
}
}
/// Start streaming `queue[queue_pos]`: open the authenticated HTTP stream in
/// a background task and hand the reader to the audio thread.
fn play_current(state: &mut AppState, runtime: &Runtime) {
let Some(track) = state.player.queue.get(state.player.queue_pos).cloned() else {
return;
};
let Some(api) = runtime.api.clone() else {
return;
};
// The track that was playing until now was cut short by this switch.
if let Some(previous) = state.player.current.take() {
if state.player.playing {
report_history(
runtime,
previous.id,
state.player.track_started_at,
state.player.position_secs.round() as i32,
);
}
}
state.player.current = Some(track.clone());
state.player.playing = true;
state.player.paused = false;
state.player.position_secs = 0.0;
state.player.track_started_at = Some(auth::now_epoch_seconds());
state.player.prefetched_pos = None;
state.status_message = Some(format!("{}{}", track.title, track.artist_line()));
let controller = runtime.player.clone();
let volume = player::amplitude(state.player.volume);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
match api.open_stream(&track.stream_url).await {
Ok((reader, byte_len)) => controller.play(reader, byte_len, volume),
Err(ApiError::SessionExpired) => {
let _ = tx.send(AppEvent::SessionExpired);
}
Err(err) => {
let _ = tx.send(AppEvent::StatusMessage(format!("playback failed: {err}")));
}
}
});
}
/// Start streaming the next queue item ~30s before the current track ends
/// and append it in the audio thread, so rodio switches sources without a
/// device gap.
fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
const PREFETCH_MARGIN_SECS: f64 = 30.0;
let player = &state.player;
if !player.playing || player.paused || player.prefetched_pos.is_some() {
return;
}
let Some(track) = &player.current else {
return;
};
if track.duration_seconds <= 0.0
|| track.duration_seconds - player.position_secs > PREFETCH_MARGIN_SECS
{
return;
}
let Some(next_pos) = update::peek_next_pos(player) else {
return;
};
let Some(next) = player.queue.get(next_pos).cloned() else {
return;
};
let Some(api) = runtime.api.clone() else {
return;
};
state.player.prefetched_pos = Some(next_pos);
tracing::debug!(title = %next.title, "prefetching next track");
let controller = runtime.player.clone();
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
match api.open_stream(&next.stream_url).await {
Ok((reader, byte_len)) => controller.enqueue(reader, byte_len),
Err(err) => {
tracing::warn!(%err, "prefetch failed; falling back to a normal switch");
let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos });
}
}
});
}
/// Persist playback state server-side: on track changes (called directly)
/// and every ~10s while something is playing (called from the tick).
fn maybe_push_state(state: &AppState, runtime: &mut Runtime) {
const PUSH_INTERVAL: Duration = Duration::from_secs(10);
if !state.player.playing {
return;
}
let due = runtime
.last_state_push
.is_none_or(|at| at.elapsed() >= PUSH_INTERVAL);
if due {
push_state_now(state, runtime);
}
}
fn push_state_now(state: &AppState, runtime: &mut Runtime) {
let Some(api) = runtime.api.clone() else {
return;
};
runtime.last_state_push = Some(std::time::Instant::now());
let player = &state.player;
let body = crate::api::client::PlaybackStateBody {
current_track_id: player.current.as_ref().map(|t| t.id),
position_ms: (player.position_secs * 1000.0) as i32,
queue: player.queue.iter().map(|t| t.id).collect(),
queue_position: player.queue_pos as i32,
shuffle: player.shuffle,
repeat_mode: player.repeat.label().to_string(),
volume: f64::from(player.volume) / 100.0,
};
tokio::spawn(async move {
if let Err(err) = api.push_state(&body).await {
tracing::warn!(%err, "state push failed");
}
});
}
/// Fire-and-forget history report; listens shorter than 5s are noise.
fn report_history(runtime: &Runtime, track_id: i64, started_at: Option<i64>, listened: i32) {
if listened < 5 {
return;
}
let Some(api) = runtime.api.clone() else {
return;
};
tokio::spawn(async move {
if let Err(err) = api.report_history(track_id, started_at, listened).await {
tracing::warn!(%err, "history report failed");
}
});
}
/// Clear a timed-out quit confirmation and its status-bar hint.
fn expire_quit_confirmation(state: &mut AppState) {
if state
.quit_armed_until
.is_some_and(|deadline| std::time::Instant::now() > deadline)
{
state.quit_armed_until = None;
if state.status_message.as_deref() == Some(update::QUIT_CONFIRM_HINT) {
state.status_message = None;
}
}
}
/// Validate the stored session in the background: a dead refresh token sends
/// the user back to the login screen instead of failing on first use.
fn spawn_session_check(runtime: &Runtime, api: Arc<ApiClient>) {
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
match api.me().await {
Ok(me) => {
let _ = tx.send(AppEvent::StatusMessage(format!("signed in as {}", me.name)));
}
Err(ApiError::SessionExpired) => {
let _ = tx.send(AppEvent::SessionExpired);
}
Err(err) => {
tracing::warn!(%err, "session check failed");
let _ = tx.send(AppEvent::StatusMessage(format!("server unreachable: {err}")));
}
}
});
}
fn handle_terminal_event(
state: &mut AppState,
keymap: &mut Keymap,
runtime: &mut Runtime,
event: TermEvent,
) {
match event {
TermEvent::Key(key) => {
// Kitty-enhanced terminals and Windows also deliver Release
// events; acting on them would double-fire every binding.
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return;
}
match state.screen {
Screen::Login => login::handle_key(state, runtime, key),
Screen::Main if state.cmdline.active => cmdline::handle_key(state, runtime, key),
Screen::Main => handle_main_key(state, keymap, runtime, key),
}
}
TermEvent::Paste(pasted) => match state.screen {
Screen::Login => login::handle_paste(state, &pasted),
Screen::Main if state.cmdline.active => cmdline::handle_paste(state, runtime, &pasted),
Screen::Main => {}
},
_ => {}
}
}
fn handle_main_key(state: &mut AppState, keymap: &mut Keymap, runtime: &mut Runtime, key: KeyEvent) {
let combo = KeyCombination::from(key);
match keymap.resolve(combo, state.active_tab.key_context()) {
KeyResolution::Action(action) => {
state.pending_keys = None;
tracing::debug!(?action, "key resolved");
// Logout needs the Runtime, which pure update() never touches.
if action == action::Action::Logout {
perform_logout(state, runtime);
} else if let Some(effect) = update(state, action) {
perform_effect(state, runtime, effect);
}
}
KeyResolution::Pending(keys) => state.pending_keys = Some(keys),
KeyResolution::Unmatched => state.pending_keys = None,
}
}
/// Sign out: revoke the session server-side (best effort, in the background),
/// delete stored credentials, return to the login screen with the server
/// URL kept for convenience.
fn perform_logout(state: &mut AppState, runtime: &mut Runtime) {
let server_url = runtime.api.as_ref().map(|api| api.base_url().to_string());
if let Some(api) = runtime.api.take() {
tokio::spawn(async move {
match api.logout().await {
Ok(revoked) => tracing::info!(revoked, "logged out"),
Err(err) => tracing::warn!(%err, "server-side logout failed"),
}
});
}
auth::delete_session();
runtime.player.stop();
state.player = state::PlayerBar::default();
state.user = None;
state.login = state::LoginForm::default();
if let Some(url) = server_url {
state.login.server_url = url;
}
reset_library_state(state);
state.screen = Screen::Login;
state.status_message = None;
}
/// Drop everything fetched from the previous account/server.
fn reset_library_state(state: &mut AppState) {
state.global = state::GlobalTab::default();
state.artist_views.clear();
state.release_views.clear();
state.playlists = state::PlaylistsTab::default();
state.playlist_views.clear();
state.likes.clear();
state.likes_loaded = false;
state.search = state::SearchState::default();
state.cmdline = state::Cmdline::default();
state.art.clear();
}
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
match event {
AppEvent::StatusMessage(message) => state.status_message = Some(message),
AppEvent::LoginSucceeded(session) => {
if let Some(listener) = runtime.sso.take() {
listener.abort();
}
state.status_message = Some(format!("signed in as {}", session.user.name));
state.user = Some(session.user.clone());
runtime.api = Some(Arc::new(ApiClient::new(runtime.http.clone(), *session)));
state.login = state::LoginForm::default();
state.screen = Screen::Main;
}
AppEvent::LoginFailed(message) => {
state.login.busy = false;
state.login.error = Some(message);
}
AppEvent::SsoCallback(result) => {
runtime.sso = None;
if state.screen != Screen::Login
|| state.login.mode != state::LoginMode::SsoPending
|| state.login.busy
{
return;
}
match result {
Ok(code) => login::spawn_sso_exchange(&mut state.login, runtime, code),
Err(message) => state.login.error = Some(message),
}
}
AppEvent::SessionExpired => {
state.user = None;
state.login = state::LoginForm::default();
if let Some(api) = runtime.api.take() {
state.login.server_url = api.base_url().to_string();
}
state.login.error = Some("session expired — sign in again".to_string());
runtime.player.stop();
state.player = state::PlayerBar::default();
reset_library_state(state);
state.screen = Screen::Login;
}
AppEvent::ArtistsLoaded(Ok(page)) => {
let global = &mut state.global;
global.loading = false;
global.total = page.total;
global.has_more = page.has_more;
global.next_page = page.page + 1;
global.artists.extend(page.items);
}
AppEvent::ArtistsLoaded(Err(message)) => {
state.global.loading = false;
state.global.error = Some(message.clone());
state.status_message = Some(message);
}
AppEvent::ArtistViewLoaded { id, result } => {
let entry = match result {
Ok(detail) => state::Loadable::Ready(detail),
Err(message) => state::Loadable::Failed(message),
};
state.artist_views.insert(id, entry);
}
AppEvent::ReleaseViewLoaded { id, result } => {
let entry = match result {
Ok(detail) => state::Loadable::Ready(detail),
Err(message) => state::Loadable::Failed(message),
};
state.release_views.insert(id, entry);
}
AppEvent::SearchLoaded { seq, result } => {
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
state.search.loading = false;
match result {
Ok(results) => state.search.results = Some(results),
Err(message) => state.status_message = Some(message),
}
}
AppEvent::ArtLoaded { key, art } => {
let entry = match art {
Some(image) => state::ArtState::Ready(image),
None => state::ArtState::Failed,
};
state.art.insert(key, entry);
}
AppEvent::Player(player::PlayerEvent::TrackFinished { has_next }) => {
// The finished track gets a full-duration history entry.
if let Some(finished) = state.player.current.clone() {
report_history(
runtime,
finished.id,
state.player.track_started_at,
finished.duration_seconds.round() as i32,
);
}
if has_next {
// A prefetched source is already playing; just realign state.
let next_pos = state
.player
.prefetched_pos
.take()
.unwrap_or(state.player.queue_pos + 1);
state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1));
state.player.current = state.player.queue.get(state.player.queue_pos).cloned();
state.player.position_secs = 0.0;
state.player.track_started_at = Some(auth::now_epoch_seconds());
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
} else {
state.player.current = None;
state.player.prefetched_pos = None;
if let Some(effect) = update::advance_after_finish(state) {
perform_effect(state, runtime, effect);
}
}
push_state_now(state, runtime);
}
AppEvent::Player(player::PlayerEvent::Failed(message)) => {
state.player.playing = false;
state.player.paused = false;
state.status_message = Some(message);
}
AppEvent::PrefetchFailed { pos } => {
if state.player.prefetched_pos == Some(pos) {
state.player.prefetched_pos = None;
}
}
AppEvent::PlaylistsLoaded(result) => {
state.playlists.list = Some(match result {
Ok(list) => state::Loadable::Ready(list),
Err(message) => {
tracing::warn!(%message, "playlists load failed");
state::Loadable::Failed(message)
}
});
}
AppEvent::PlaylistViewLoaded { id, result } => {
let entry = match result {
Ok(detail) => state::Loadable::Ready(detail),
Err(message) => state::Loadable::Failed(message),
};
state.playlist_views.insert(id, entry);
}
AppEvent::LikesLoaded(result) => match result {
Ok(ids) => {
state.likes = ids.into_iter().collect();
}
Err(message) => tracing::warn!(%message, "likes load failed"),
},
AppEvent::LikeToggled { track_id, liked } => {
if liked {
state.likes.insert(track_id);
} else {
state.likes.remove(&track_id);
}
// The virtual Likes playlist is stale now; refetch on next open.
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
state.status_message = Some(if liked {
"♥ liked".to_string()
} else {
"like removed".to_string()
});
}
AppEvent::EnqueueTracks { tracks, next } => {
let count = tracks.len();
update::enqueue_tracks(state, tracks, next);
state.status_message = Some(if next {
format!("{count} tracks queued next")
} else {
format!("{count} tracks queued")
});
}
AppEvent::Media(command) => {
use crate::media::MediaCommand;
tracing::debug!(?command, "media key");
let action = match command {
MediaCommand::TogglePause | MediaCommand::Play | MediaCommand::Pause => {
action::Action::PlayPause
}
MediaCommand::Next => action::Action::NextTrack,
MediaCommand::Previous => action::Action::PrevTrack,
MediaCommand::Stop => {
state.player.playing = false;
state.player.paused = false;
state.player.current = None;
runtime.player.stop();
push_media_update(state, runtime, true);
return;
}
};
if let Some(effect) = update(state, action) {
perform_effect(state, runtime, effect);
}
}
}
}
/// Mirror the playback state to the OS now-playing surface. `force` skips
/// the position throttle (track switches, pauses).
fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) {
use crate::media::MediaUpdate;
const POSITION_INTERVAL: Duration = Duration::from_secs(2);
if !force
&& runtime
.last_media_push
.is_some_and(|at| at.elapsed() < POSITION_INTERVAL)
{
return;
}
runtime.last_media_push = Some(std::time::Instant::now());
let player = &state.player;
if !player.playing {
let _ = runtime.media_tx.send(MediaUpdate::Stopped);
return;
}
let _ = runtime.media_tx.send(MediaUpdate::Playback {
playing: player.playing,
paused: player.paused,
position_secs: player.position_secs,
});
}
fn push_media_metadata(state: &AppState, runtime: &Runtime) {
use crate::media::MediaUpdate;
if let Some(track) = &state.player.current {
let _ = runtime.media_tx.send(MediaUpdate::Metadata {
title: track.title.clone(),
artist: track.artist_line(),
album: track.release_title.clone(),
duration_secs: track.duration_seconds,
});
}
}
+134
View File
@@ -0,0 +1,134 @@
use std::io;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc::UnboundedSender;
use crate::app::event::AppEvent;
/// Loopback callback listener for browser SSO (RFC 8252 native-app flow).
/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback`
/// with the exchange code; the listener delivers it as an AppEvent and exits.
pub struct SsoListener {
pub port: u16,
handle: tokio::task::JoinHandle<()>,
}
impl SsoListener {
pub fn abort(&self) {
self.handle.abort();
}
}
pub fn start(tx: UnboundedSender<AppEvent>) -> io::Result<SsoListener> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
let handle = tokio::spawn(async move {
let result = match serve_one(listener).await {
Ok(result) => result,
Err(err) => Err(format!("callback listener failed: {err}")),
};
let _ = tx.send(AppEvent::SsoCallback(result));
});
Ok(SsoListener { port, handle })
}
async fn serve_one(listener: std::net::TcpListener) -> io::Result<Result<String, String>> {
let listener = tokio::net::TcpListener::from_std(listener)?;
loop {
let (mut stream, _) = listener.accept().await?;
let request_line = read_request_line(&mut stream).await?;
let Some(result) = parse_request_line(&request_line) else {
// Stray request (favicon, prefetch) — keep waiting for the code.
let _ = stream.write_all(&response(404, "Not Found")).await;
continue;
};
let page = match &result {
Ok(_) => "Sign-in complete. You can close this window and return to the terminal.",
Err(_) => "Sign-in failed. Return to the terminal to see the error.",
};
let _ = stream.write_all(&response(200, page)).await;
let _ = stream.shutdown().await;
return Ok(result);
}
}
async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result<String> {
let mut buf = vec![0u8; 8192];
let mut len = 0;
while len < buf.len() {
let n = stream.read(&mut buf[len..]).await?;
if n == 0 {
break;
}
len += n;
if buf[..len].windows(2).any(|w| w == b"\r\n") {
break;
}
}
let text = String::from_utf8_lossy(&buf[..len]);
Ok(text.lines().next().unwrap_or_default().to_string())
}
/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error).
/// Values are plain tokens (no percent-encoded characters expected).
fn parse_request_line(line: &str) -> Option<Result<String, String>> {
let path = line.split_whitespace().nth(1)?;
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
let mut code = None;
let mut error = None;
for pair in query.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
match key {
"code" if !value.is_empty() => code = Some(value.to_string()),
"error" if !value.is_empty() => error = Some(value.to_string()),
_ => {}
}
}
if let Some(error) = error {
return Some(Err(format!("SSO failed: {error}")));
}
code.map(Ok)
}
fn response(status: u16, body: &str) -> Vec<u8> {
let reason = if status == 200 { "OK" } else { "Not Found" };
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>furumi</title></head>\
<body style=\"font-family:sans-serif;background:#101114;color:#f5f2ea;\
display:grid;place-items:center;min-height:100vh;margin:0\"><p>{body}</p></body></html>"
);
format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_code_from_request_line() {
assert_eq!(
parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"),
Some(Ok("furu_mx_abc".to_string()))
);
}
#[test]
fn parses_error_from_request_line() {
assert_eq!(
parse_request_line("GET /callback?error=provider_denied HTTP/1.1"),
Some(Err("SSO failed: provider_denied".to_string()))
);
}
#[test]
fn ignores_unrelated_requests() {
assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None);
assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None);
}
}
+407
View File
@@ -0,0 +1,407 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::api::models::{
ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail,
SearchResults, TrackItem, User,
};
use crate::art::ArtImage;
use crate::config::keymap::KeyContext;
/// Remote data that a view renders: spinner, content, or error.
#[derive(Debug)]
pub enum Loadable<T> {
Loading,
Ready(T),
Failed(String),
}
/// Tile geometry for the Global artist grid (kept here so selection math in
/// update() and rendering in ui::global agree). Width × height in cells,
/// including the tile border; the art area inside is 18×8 cells = 18×16 px.
pub const TILE_WIDTH: u16 = 20;
pub const TILE_HEIGHT: u16 = 12;
pub const ART_CELL_WIDTH: u16 = 18;
pub const ART_CELL_HEIGHT: u16 = 8;
/// Header artwork (artist page, release page): 24×12 cells = 24×24 px.
pub const ART_HEADER_WIDTH: u16 = 24;
pub const ART_HEADER_HEIGHT: u16 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
#[default]
Tiles,
Table,
}
impl ViewMode {
pub fn toggle(self) -> ViewMode {
match self {
ViewMode::Tiles => ViewMode::Table,
ViewMode::Table => ViewMode::Tiles,
}
}
}
/// Artist image in the shared art cache.
#[derive(Debug, Clone)]
pub enum ArtState {
Loading,
Ready(Arc<ArtImage>),
Failed,
}
/// A drill-down view pushed on top of the Global artist grid. Cursors live
/// in the stack entry so going Back restores the previous position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlobalView {
/// Linear cursor over top tracks (0..tracks) then releases in display
/// order (tracks..tracks+releases).
Artist { id: i64, cursor: usize },
Release { id: i64, cursor: usize },
/// Linear cursor over search results: artists, then releases, then tracks.
Search { cursor: usize },
}
/// The Global tab: the whole server library of artists.
#[derive(Debug)]
pub struct GlobalTab {
pub artists: Vec<ArtistCard>,
pub total: i64,
pub has_more: bool,
pub next_page: i64,
pub loading: bool,
pub error: Option<String>,
pub selected: usize,
pub view: ViewMode,
pub stack: Vec<GlobalView>,
}
impl Default for GlobalTab {
fn default() -> Self {
Self {
artists: Vec::new(),
total: 0,
has_more: true,
next_page: 1,
loading: false,
error: None,
selected: 0,
view: ViewMode::default(),
stack: Vec::new(),
}
}
}
/// Releases of an artist in display order: grouped by type (albums, EPs,
/// singles, compilations, then anything else), keeping server order within a
/// group. Returns (group label, indices into the original slice). Cursor
/// positions use this flattened order, so update() and ui must both go
/// through here.
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
const GROUPS: [(&str, &str); 4] = [
("album", "Albums"),
("ep", "EPs"),
("single", "Singles"),
("compilation", "Compilations"),
];
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
for (kind, label) in GROUPS {
let indices: Vec<usize> = releases
.iter()
.enumerate()
.filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind))
.map(|(i, _)| i)
.collect();
if !indices.is_empty() {
groups.push((label, indices));
}
}
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
let other: Vec<usize> = (0..releases.len()).filter(|i| !known.contains(i)).collect();
if !other.is_empty() {
groups.push(("Other", other));
}
groups
}
/// Flattened display order of releases (concatenated groups).
pub fn release_display_order(releases: &[ReleaseCard]) -> Vec<usize> {
release_groups(releases)
.into_iter()
.flat_map(|(_, indices)| indices)
.collect()
}
/// Visual tile-grid rows of the releases section: each group starts its own
/// rows, chunked by the column count. Values are display-order positions.
/// Vertical cursor movement must follow these rows to match the rendering.
pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>> {
let columns = columns.max(1);
let mut rows = Vec::new();
let mut position = 0;
for (_, group) in release_groups(releases) {
for chunk in group.chunks(columns) {
rows.push((position..position + chunk.len()).collect());
position += chunk.len();
}
}
rows
}
/// The virtual server-side Likes playlist id (`kind == "likes"`).
pub const LIKES_PLAYLIST_ID: i64 = -1;
#[derive(Debug, Clone, Copy)]
pub struct OpenedPlaylist {
pub id: i64,
pub cursor: usize,
}
/// The Playlists tab. The server list includes the virtual "Likes"
/// playlist (id = -1), rendered with a ♥ marker.
#[derive(Debug, Default)]
pub struct PlaylistsTab {
pub list: Option<Loadable<Vec<PlaylistCard>>>,
pub selected: usize,
pub opened: Option<OpenedPlaylist>,
}
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
#[derive(Debug, Default)]
pub struct Cmdline {
pub active: bool,
pub input: String,
/// A live command (search) applied effects during this session; Esc
/// undoes them, Enter keeps them.
pub live: bool,
}
/// Live search state driven by the `:/query` command.
#[derive(Debug, Default)]
pub struct SearchState {
pub query: String,
pub loading: bool,
pub results: Option<SearchResults>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Screen {
#[default]
Main,
Login,
}
/// SSO is the primary sign-in path, so it sits right under the server URL;
/// the password fields below are the rare fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginField {
#[default]
ServerUrl,
SsoButton,
Username,
Password,
SignInButton,
}
impl LoginField {
const ORDER: [LoginField; 5] = [
LoginField::ServerUrl,
LoginField::SsoButton,
LoginField::Username,
LoginField::Password,
LoginField::SignInButton,
];
pub fn next(self) -> LoginField {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
Self::ORDER[(i + 1) % Self::ORDER.len()]
}
pub fn prev(self) -> LoginField {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap();
Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginMode {
/// Server / username / password fields plus the SSO button.
#[default]
Form,
/// Browser SSO started; waiting for the pasted callback link or code.
SsoPending,
}
#[derive(Debug)]
pub struct LoginForm {
pub server_url: String,
pub username: String,
pub password: String,
pub sso_paste: String,
pub sso_url: String,
pub sso_port: Option<u16>,
pub focus: LoginField,
pub mode: LoginMode,
pub busy: bool,
pub error: Option<String>,
}
impl Default for LoginForm {
fn default() -> Self {
Self {
server_url: "https://music.hexor.cy".to_string(),
username: String::new(),
password: String::new(),
sso_paste: String::new(),
sso_url: String::new(),
sso_port: None,
focus: LoginField::default(),
mode: LoginMode::default(),
busy: false,
error: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Global,
Playlists,
Queue,
Devices,
}
impl Tab {
pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Devices];
pub fn title(self) -> &'static str {
match self {
Tab::Global => "Global",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Devices => "Devices",
}
}
pub fn index(self) -> usize {
Self::ALL.iter().position(|t| *t == self).unwrap()
}
pub fn from_index(index: usize) -> Option<Tab> {
Self::ALL.get(index).copied()
}
pub fn next(self) -> Tab {
Self::ALL[(self.index() + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Tab {
Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
}
pub fn key_context(self) -> KeyContext {
match self {
Tab::Global => KeyContext::Library,
Tab::Playlists => KeyContext::Playlists,
Tab::Queue => KeyContext::Queue,
Tab::Devices => KeyContext::Devices,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RepeatMode {
#[default]
Off,
One,
All,
}
impl RepeatMode {
pub fn label(self) -> &'static str {
match self {
RepeatMode::Off => "off",
RepeatMode::One => "one",
RepeatMode::All => "all",
}
}
pub fn next(self) -> RepeatMode {
match self {
RepeatMode::Off => RepeatMode::All,
RepeatMode::All => RepeatMode::One,
RepeatMode::One => RepeatMode::Off,
}
}
}
/// Playback state mirrored for the UI: the queue, the loaded track and the
/// position polled from the audio thread on every tick.
#[derive(Debug)]
pub struct PlayerBar {
pub queue: Vec<TrackItem>,
pub queue_pos: usize,
pub current: Option<TrackItem>,
/// A track is loaded (playing or paused); false = stopped.
pub playing: bool,
pub paused: bool,
pub position_secs: f64,
/// Epoch seconds when the current track started (for history reports).
pub track_started_at: Option<i64>,
/// Queue index already enqueued in the audio thread for gapless play.
pub prefetched_pos: Option<usize>,
pub volume: u8,
pub shuffle: bool,
pub repeat: RepeatMode,
}
impl Default for PlayerBar {
fn default() -> Self {
Self {
queue: Vec::new(),
queue_pos: 0,
current: None,
playing: false,
paused: false,
position_secs: 0.0,
track_started_at: None,
prefetched_pos: None,
volume: 80,
shuffle: false,
repeat: RepeatMode::Off,
}
}
}
/// Single source of truth for the UI. Mutated only by `update()` and the
/// event handlers in the main loop; views render from `&AppState`.
#[derive(Debug, Default)]
pub struct AppState {
pub screen: Screen,
pub active_tab: Tab,
pub should_quit: bool,
/// Double-press quit confirmation: set by the first Quit press, expires
/// after a short window (any other action also cancels it).
pub quit_armed_until: Option<std::time::Instant>,
pub help_visible: bool,
pub pending_keys: Option<String>,
pub status_message: Option<String>,
pub player: PlayerBar,
pub login: LoginForm,
pub user: Option<User>,
pub global: GlobalTab,
pub artist_views: HashMap<i64, Loadable<ArtistDetail>>,
pub release_views: HashMap<i64, Loadable<ReleaseDetail>>,
pub playlists: PlaylistsTab,
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
pub likes: std::collections::HashSet<i64>,
pub likes_loaded: bool,
pub cmdline: Cmdline,
pub search: SearchState,
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
/// every view that shows artwork.
pub art: HashMap<String, ArtState>,
}
+1028
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
//! Image → terminal-art conversion, used wherever the player shows pictures
//! (artist tiles, release covers, now-playing).
//!
//! The format is "half-block art": each terminal cell renders `▀` with the
//! foreground colored as the top pixel and the background as the bottom
//! pixel, giving 2 vertical pixels per cell. This needs no terminal image
//! protocol (sixel/kitty), so it works everywhere crossterm does, and it
//! looks far better than glyph-luminance ASCII at tile sizes. The UI layer
//! converts `ArtImage` cells into styled spans.
use anyhow::{Context as _, Result};
/// Cache key for the shared artwork cache: the same image can be cached at
/// several cell sizes (grid tile vs page header).
pub fn cache_key(url: &str, width_cells: u16, height_cells: u16) -> String {
format!("{url}#{width_cells}x{height_cells}")
}
/// Decoded, cell-sized art. `pixels` holds `width_cells * height_cells * 2`
/// RGB triples, row-major, two pixel rows per cell row (top, then bottom).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtImage {
pub width_cells: u16,
pub height_cells: u16,
pixels: Vec<[u8; 3]>,
}
impl ArtImage {
/// Top and bottom pixel of the cell at (column, cell row).
pub fn cell(&self, x: u16, y: u16) -> ([u8; 3], [u8; 3]) {
let width = self.width_cells as usize;
let top = (y as usize * 2) * width + x as usize;
let bottom = (y as usize * 2 + 1) * width + x as usize;
(self.pixels[top], self.pixels[bottom])
}
}
/// Decode image bytes (jpeg/png/webp/gif/bmp) and scale them to fill a
/// `width_cells` × `height_cells` terminal area, center-cropping overflow
/// like CSS object-fit: cover.
pub fn decode_to_cells(bytes: &[u8], width_cells: u16, height_cells: u16) -> Result<ArtImage> {
let width_px = u32::from(width_cells.max(1));
let height_px = u32::from(height_cells.max(1)) * 2;
let image = image::load_from_memory(bytes).context("unsupported or corrupt image")?;
let image = image
.resize_to_fill(width_px, height_px, image::imageops::FilterType::Triangle)
.into_rgb8();
let pixels = image.pixels().map(|p| p.0).collect();
Ok(ArtImage {
width_cells: width_cells.max(1),
height_cells: height_cells.max(1),
pixels,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png_2x2() -> Vec<u8> {
// red, green / blue, white
let mut img = image::RgbImage::new(2, 2);
img.put_pixel(0, 0, image::Rgb([255, 0, 0]));
img.put_pixel(1, 0, image::Rgb([0, 255, 0]));
img.put_pixel(0, 1, image::Rgb([0, 0, 255]));
img.put_pixel(1, 1, image::Rgb([255, 255, 255]));
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
}
#[test]
fn decodes_to_requested_cell_grid() {
let art = decode_to_cells(&png_2x2(), 2, 1).unwrap();
assert_eq!((art.width_cells, art.height_cells), (2, 1));
let (top, bottom) = art.cell(0, 0);
assert_eq!(top, [255, 0, 0]);
assert_eq!(bottom, [0, 0, 255]);
}
#[test]
fn rejects_garbage() {
assert!(decode_to_cells(b"not an image", 4, 4).is_err());
}
}
+180
View File
@@ -0,0 +1,180 @@
# Default keybindings for furumi-cli.
#
# To customize, copy entries into <config dir>/furumi/keymap.toml
# (~/.config/furumi/keymap.toml on Linux/macOS). A user binding replaces the
# default binding with the same key sequence and context.
#
# key_sequence: space-separated chords, e.g. "g g" or "ctrl-a x".
# Modifiers: ctrl-, alt-, shift-, cmd-. Uppercase letters: "shift-g".
# command: an Action name, optionally with parameters:
# command = { SeekForward = { seconds = 30 } }
# context: optional view filter — global (default), library, search,
# playlists, queue, devices.
[[keymaps]]
key_sequence = "q"
command = "Quit"
[[keymaps]]
key_sequence = "ctrl-c"
command = "Quit"
[[keymaps]]
key_sequence = "?"
command = "ToggleHelp"
[[keymaps]]
key_sequence = "tab"
command = "NextTab"
[[keymaps]]
key_sequence = "backtab"
command = "PrevTab"
[[keymaps]]
key_sequence = "1"
command = { GoToTab = 0 }
[[keymaps]]
key_sequence = "2"
command = { GoToTab = 1 }
[[keymaps]]
key_sequence = "3"
command = { GoToTab = 2 }
[[keymaps]]
key_sequence = "4"
command = { GoToTab = 3 }
[[keymaps]]
key_sequence = "a"
command = "QueueAddNext"
[[keymaps]]
key_sequence = "shift-a"
command = "QueueAddLast"
[[keymaps]]
key_sequence = "j"
command = "MoveDown"
[[keymaps]]
key_sequence = "down"
command = "MoveDown"
[[keymaps]]
key_sequence = "k"
command = "MoveUp"
[[keymaps]]
key_sequence = "up"
command = "MoveUp"
[[keymaps]]
key_sequence = "h"
command = "MoveLeft"
[[keymaps]]
key_sequence = "left"
command = "MoveLeft"
[[keymaps]]
key_sequence = "l"
command = "MoveRight"
[[keymaps]]
key_sequence = "right"
command = "MoveRight"
[[keymaps]]
key_sequence = "pageup"
command = "PageUp"
[[keymaps]]
key_sequence = "pagedown"
command = "PageDown"
[[keymaps]]
key_sequence = "ctrl-u"
command = "PageUp"
[[keymaps]]
key_sequence = "ctrl-d"
command = "PageDown"
[[keymaps]]
key_sequence = "g g"
command = "SelectFirst"
[[keymaps]]
key_sequence = "shift-g"
command = "SelectLast"
[[keymaps]]
key_sequence = "enter"
command = "Select"
[[keymaps]]
key_sequence = "esc"
command = "Back"
[[keymaps]]
key_sequence = "backspace"
command = "Back"
[[keymaps]]
key_sequence = "space"
command = "PlayPause"
[[keymaps]]
key_sequence = "n"
command = "NextTrack"
[[keymaps]]
key_sequence = "p"
command = "PrevTrack"
[[keymaps]]
key_sequence = "."
command = { SeekForward = { seconds = 10 } }
[[keymaps]]
key_sequence = ","
command = { SeekBackward = { seconds = 10 } }
[[keymaps]]
key_sequence = "+"
command = "VolumeUp"
[[keymaps]]
key_sequence = "="
command = "VolumeUp"
[[keymaps]]
key_sequence = "-"
command = "VolumeDown"
[[keymaps]]
key_sequence = "s"
command = "ToggleShuffle"
[[keymaps]]
key_sequence = "r"
command = "CycleRepeat"
[[keymaps]]
key_sequence = "x"
command = "ToggleLike"
[[keymaps]]
key_sequence = "shift-l"
command = "Logout"
[[keymaps]]
key_sequence = "v"
command = "ToggleViewMode"
[[keymaps]]
key_sequence = ":"
command = "OpenCommandLine"
+484
View File
@@ -0,0 +1,484 @@
use std::{fs, path::PathBuf, str::FromStr};
use anyhow::{Context as _, Result, bail};
use crokey::{KeyCombination, KeyCombinationFormat, key};
use crossterm::event::{KeyCode, KeyModifiers};
use serde::Deserialize;
use crate::app::action::Action;
const DEFAULT_KEYMAP: &str = include_str!("default_keymap.toml");
/// Input context a binding applies to. `Global` bindings work everywhere;
/// view-specific bindings shadow global ones for the same key sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeyContext {
#[default]
Global,
Library,
Search,
Playlists,
Queue,
Devices,
}
impl KeyContext {
pub fn label(self) -> &'static str {
match self {
KeyContext::Global => "global",
KeyContext::Library => "library",
KeyContext::Search => "search",
KeyContext::Playlists => "playlists",
KeyContext::Queue => "queue",
KeyContext::Devices => "devices",
}
}
}
#[derive(Debug, Clone)]
pub struct Binding {
pub keys: Vec<KeyCombination>,
pub action: Action,
pub context: KeyContext,
}
#[derive(Debug, Deserialize)]
struct RawBinding {
key_sequence: String,
command: Action,
#[serde(default)]
context: KeyContext,
}
#[derive(Debug, Default, Deserialize)]
struct KeymapFile {
#[serde(default)]
keymaps: Vec<RawBinding>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum KeyResolution {
Action(Action),
/// The pressed keys are a prefix of a longer sequence; the formatted
/// pending chord is returned for display in the status bar.
Pending(String),
Unmatched,
}
pub struct Keymap {
bindings: Vec<Binding>,
pending: Vec<KeyCombination>,
format: KeyCombinationFormat,
}
impl Keymap {
/// Load defaults merged with the user's keymap.toml. A broken user file
/// must not brick the app: it is ignored and reported as a warning.
pub fn load() -> (Self, Option<String>) {
let mut bindings =
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
let mut warning = None;
if let Some(path) = user_keymap_path() {
if path.exists() {
match fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| parse_bindings(&text))
{
Ok(user) => merge(&mut bindings, user),
Err(err) => {
warning = Some(format!(
"{} ignored: {err:#}; using default keybindings",
path.display()
));
}
}
}
}
let keymap = Self {
bindings,
pending: Vec::new(),
format: KeyCombinationFormat::default(),
};
(keymap, warning)
}
/// Feed one key combination; returns an action, a pending-chord state, or
/// nothing. Esc clears a pending chord instead of resolving.
pub fn resolve(&mut self, key: KeyCombination, context: KeyContext) -> KeyResolution {
let key = self.localize(normalize(key));
if key == key!(esc) && !self.pending.is_empty() {
self.pending.clear();
return KeyResolution::Unmatched;
}
self.pending.push(key);
match self.lookup(context) {
Lookup::Exact(action) => {
self.pending.clear();
KeyResolution::Action(action)
}
Lookup::Prefix => KeyResolution::Pending(self.format_pending()),
Lookup::Nothing => {
let retry = self.pending.len() > 1;
self.pending.clear();
if retry {
// The aborted chord's last key may start a new sequence.
self.resolve(key, context)
} else {
KeyResolution::Unmatched
}
}
}
}
/// All bindings as (keys, description, context) for the help view.
pub fn help_entries(&self) -> Vec<(String, String, KeyContext)> {
self.bindings
.iter()
.map(|b| (self.format_keys(&b.keys), b.action.describe(), b.context))
.collect()
}
fn lookup(&self, context: KeyContext) -> Lookup {
let mut exact_ctx: Option<&Binding> = None;
let mut exact_global: Option<&Binding> = None;
let mut has_prefix = false;
for b in &self.bindings {
if b.context != KeyContext::Global && b.context != context {
continue;
}
if b.keys.len() < self.pending.len() || b.keys[..self.pending.len()] != self.pending {
continue;
}
if b.keys.len() == self.pending.len() {
if b.context == KeyContext::Global {
exact_global.get_or_insert(b);
} else {
exact_ctx.get_or_insert(b);
}
} else {
has_prefix = true;
}
}
// An exact match fires immediately even if a longer sequence shares
// the prefix — don't bind both "g" and "g g".
if let Some(b) = exact_ctx.or(exact_global) {
Lookup::Exact(b.action.clone())
} else if has_prefix {
Lookup::Prefix
} else {
Lookup::Nothing
}
}
/// Layout fallback (vim langmap style): a Cyrillic key that no binding
/// uses directly is translated to the Latin key in the same physical
/// position (ЙЦУКЕН ↔ QWERTY), so bindings work in the Russian layout.
/// Text input is unaffected — this runs only inside keymap resolution.
fn localize(&self, key: KeyCombination) -> KeyCombination {
let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes else {
return key;
};
let lower = c.to_lowercase().next().unwrap_or(c);
let Some(latin) = qwerty_equivalent(lower) else {
return key;
};
// A binding that mentions the Cyrillic key directly wins.
if self.bindings.iter().any(|b| b.keys.contains(&key)) {
return key;
}
let mapped = if c.is_uppercase() || key.modifiers.contains(KeyModifiers::SHIFT) {
KeyCode::Char(latin.to_ascii_uppercase())
} else {
KeyCode::Char(latin)
};
normalize(KeyCombination::new(mapped, key.modifiers))
}
fn format_keys(&self, keys: &[KeyCombination]) -> String {
keys.iter()
.map(|k| self.format.to_string(*k))
.collect::<Vec<_>>()
.join(" ")
}
fn format_pending(&self) -> String {
self.format_keys(&self.pending)
}
}
enum Lookup {
Exact(Action),
Prefix,
Nothing,
}
/// Terminals report SHIFT alongside symbol keys ('?', '+', ...) inconsistently.
/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works);
/// symbols drop it so a "?" binding matches everywhere.
fn normalize(key: KeyCombination) -> KeyCombination {
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes {
if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
}
}
key
}
/// The Latin character on the same physical key in the standard ЙЦУКЕН
/// layout (lowercase in, lowercase out).
fn qwerty_equivalent(c: char) -> Option<char> {
Some(match c {
'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't',
'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p',
'х' => '[', 'ъ' => ']',
'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g',
'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', 'ж' => ';',
'э' => '\'',
'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b',
'т' => 'n', 'ь' => 'm', 'б' => ',', 'ю' => '.',
'ё' => '`',
_ => return None,
})
}
pub fn user_keymap_path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("keymap.toml"))
}
fn parse_bindings(text: &str) -> Result<Vec<Binding>> {
let file: KeymapFile = toml::from_str(text).context("invalid TOML")?;
file.keymaps
.into_iter()
.map(|raw| {
let keys = parse_sequence(&raw.key_sequence)
.with_context(|| format!("bad key_sequence {:?}", raw.key_sequence))?;
Ok(Binding {
keys,
action: raw.command,
context: raw.context,
})
})
.collect()
}
fn parse_sequence(s: &str) -> Result<Vec<KeyCombination>> {
let keys: Vec<KeyCombination> = s
.split_whitespace()
.map(parse_chord)
.collect::<Result<_>>()?;
if keys.is_empty() {
bail!("empty key sequence");
}
Ok(keys)
}
fn parse_chord(chord: &str) -> Result<KeyCombination> {
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
// bindings) are built directly.
let mut chars = chord.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if !c.is_ascii() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
}
}
KeyCombination::from_str(chord)
.map_err(|e| anyhow::anyhow!("{e}"))
.map(normalize)
}
fn merge(bindings: &mut Vec<Binding>, user: Vec<Binding>) {
for b in user {
bindings.retain(|d| !(d.keys == b.keys && d.context == b.context));
bindings.push(b);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crokey::key;
fn keymap_from(toml: &str) -> Keymap {
Keymap {
bindings: parse_bindings(toml).unwrap(),
pending: Vec::new(),
format: KeyCombinationFormat::default(),
}
}
#[test]
fn default_keymap_parses() {
let bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
assert!(bindings.len() > 20);
}
#[test]
fn single_key_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert_eq!(
km.resolve(key!(q), KeyContext::Library),
KeyResolution::Action(Action::Quit)
);
}
#[test]
fn chord_sequence_resolves() {
let mut km = keymap_from(DEFAULT_KEYMAP);
assert!(matches!(
km.resolve(key!(g), KeyContext::Library),
KeyResolution::Pending(_)
));
assert_eq!(
km.resolve(key!(g), KeyContext::Library),
KeyResolution::Action(Action::SelectFirst)
);
}
#[test]
fn aborted_chord_retries_last_key() {
let mut km = keymap_from(DEFAULT_KEYMAP);
km.resolve(key!(g), KeyContext::Library);
assert_eq!(
km.resolve(key!(q), KeyContext::Library),
KeyResolution::Action(Action::Quit)
);
}
#[test]
fn esc_clears_pending_chord() {
let mut km = keymap_from(DEFAULT_KEYMAP);
km.resolve(key!(g), KeyContext::Library);
assert_eq!(
km.resolve(key!(esc), KeyContext::Library),
KeyResolution::Unmatched
);
// Esc with no pending chord is a normal binding (Back).
assert_eq!(
km.resolve(key!(esc), KeyContext::Library),
KeyResolution::Action(Action::Back)
);
}
#[test]
fn context_binding_shadows_global() {
let mut km = keymap_from(
r#"
[[keymaps]]
key_sequence = "n"
command = "NextTrack"
[[keymaps]]
key_sequence = "n"
command = "MoveDown"
context = "search"
"#,
);
assert_eq!(
km.resolve(key!(n), KeyContext::Search),
KeyResolution::Action(Action::MoveDown)
);
assert_eq!(
km.resolve(key!(n), KeyContext::Library),
KeyResolution::Action(Action::NextTrack)
);
}
#[test]
fn user_binding_overrides_default() {
let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap();
let user = parse_bindings(
r#"
[[keymaps]]
key_sequence = "q"
command = "Back"
"#,
)
.unwrap();
merge(&mut bindings, user);
let mut km = Keymap {
bindings,
pending: Vec::new(),
format: KeyCombinationFormat::default(),
};
assert_eq!(
km.resolve(key!(q), KeyContext::Library),
KeyResolution::Action(Action::Back)
);
}
#[test]
fn shift_symbol_normalizes() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let question_with_shift =
KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(question_with_shift, KeyContext::Library),
KeyResolution::Action(Action::ToggleHelp)
);
}
#[test]
fn russian_layout_maps_to_physical_keys() {
let mut km = keymap_from(DEFAULT_KEYMAP);
// physical J → 'о' in ЙЦУКЕН
let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE);
assert_eq!(
km.resolve(o, KeyContext::Library),
KeyResolution::Action(Action::MoveDown)
);
// physical Shift+G → 'П'
let cap_pe = KeyCombination::new(KeyCode::Char('П'), KeyModifiers::SHIFT);
assert_eq!(
km.resolve(cap_pe, KeyContext::Library),
KeyResolution::Action(Action::SelectLast)
);
// chord: 'п п' = physical "g g"
let pe = KeyCombination::new(KeyCode::Char('п'), KeyModifiers::NONE);
assert!(matches!(
km.resolve(pe, KeyContext::Library),
KeyResolution::Pending(_)
));
assert_eq!(
km.resolve(pe, KeyContext::Library),
KeyResolution::Action(Action::SelectFirst)
);
// punctuation positions: 'ю' sits on the '.' key (SeekForward)
let yu = KeyCombination::new(KeyCode::Char('ю'), KeyModifiers::NONE);
assert_eq!(
km.resolve(yu, KeyContext::Library),
KeyResolution::Action(Action::SeekForward { seconds: 10 })
);
}
#[test]
fn explicit_cyrillic_binding_wins_over_layout_fallback() {
let mut km = keymap_from(
r#"
[[keymaps]]
key_sequence = "о"
command = "Quit"
"#,
);
let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE);
assert_eq!(
km.resolve(o, KeyContext::Library),
KeyResolution::Action(Action::Quit)
);
}
#[test]
fn parameterized_command_parses() {
let mut km = keymap_from(DEFAULT_KEYMAP);
let dot: KeyCombination = ".".parse().unwrap();
assert_eq!(
km.resolve(dot, KeyContext::Library),
KeyResolution::Action(Action::SeekForward { seconds: 10 })
);
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::collections::VecDeque;
use std::fs;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context as _, Result};
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
/// Ring-buffer capacity for the in-app Logs tab. Bounded so a player left
/// running for days cannot grow memory; ~10k entries ≈ a few MB worst case.
pub const LOG_CAPACITY: usize = 10_000;
#[derive(Debug, Clone)]
pub struct LogEntry {
pub level: tracing::Level,
/// HH:MM:SS, UTC (same clock as the log file).
pub time: String,
pub target: String,
pub message: String,
}
#[derive(Default)]
pub struct LogBuffer {
entries: Mutex<VecDeque<LogEntry>>,
}
impl LogBuffer {
fn push(&self, entry: LogEntry) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() == LOG_CAPACITY {
entries.pop_front();
}
entries.push_back(entry);
}
pub fn len(&self) -> usize {
self.entries.lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Window for the UI, newest-last: entries at most `max_level` verbose,
/// skipping `skip` newest matches and returning up to `take`. Also
/// returns the total number of matching entries (for scroll clamping).
/// Only the visible window is cloned, so rendering stays O(buffer scan)
/// with cheap comparisons even at full capacity.
pub fn window(
&self,
max_level: tracing::Level,
skip: usize,
take: usize,
) -> (Vec<LogEntry>, usize) {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let mut matched = 0usize;
let mut out = Vec::with_capacity(take);
for entry in entries.iter().rev() {
if entry.level > max_level {
continue;
}
matched += 1;
if matched > skip && out.len() < take {
out.push(entry.clone());
}
}
out.reverse();
(out, matched)
}
}
static BUFFER: OnceLock<Arc<LogBuffer>> = OnceLock::new();
pub fn buffer() -> Option<Arc<LogBuffer>> {
BUFFER.get().cloned()
}
struct MemoryLayer {
buffer: Arc<LogBuffer>,
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for MemoryLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut message = String::new();
event.record(&mut MessageVisitor { out: &mut message });
let metadata = event.metadata();
self.buffer.push(LogEntry {
level: *metadata.level(),
time: hms_now(),
target: metadata.target().to_string(),
message,
});
}
}
struct MessageVisitor<'a> {
out: &'a mut String,
}
impl tracing::field::Visit for MessageVisitor<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
use std::fmt::Write as _;
if field.name() == "message" {
let _ = write!(self.out, "{value:?}");
} else {
let _ = write!(self.out, " {}={:?}", field.name(), value);
}
}
}
fn hms_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!(
"{:02}:{:02}:{:02}",
(secs / 3600) % 24,
(secs / 60) % 60,
secs % 60
)
}
/// Two sinks: the log file (filtered by RUST_LOG, default info) and the
/// in-app ring buffer for the Logs tab (our crate down to TRACE, noisy
/// dependencies capped at INFO). The buffer works even when the file can't
/// be opened — the error is returned for the status bar, logging still runs.
pub fn init() -> Result<()> {
let buffer = Arc::new(LogBuffer::default());
let _ = BUFFER.set(Arc::clone(&buffer));
let memory_layer = MemoryLayer { buffer }.with_filter(
tracing_subscriber::filter::Targets::new()
.with_default(LevelFilter::INFO)
.with_target("furumi_cli", LevelFilter::TRACE),
);
match open_log_file() {
Ok(file) => {
let file = Arc::new(file);
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(move || Arc::clone(&file))
.with_ansi(false)
.with_filter(filter);
tracing_subscriber::registry()
.with(file_layer)
.with(memory_layer)
.init();
tracing::info!(version = env!("CARGO_PKG_VERSION"), "furumi-cli starting");
Ok(())
}
Err(err) => {
tracing_subscriber::registry().with(memory_layer).init();
tracing::warn!(%err, "log file unavailable, in-app logs only");
Err(err)
}
}
}
fn open_log_file() -> Result<fs::File> {
let dirs = crate::config::project_dirs().context("cannot determine home directory")?;
let dir = dirs.cache_dir();
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join("furumi-cli.log");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {}", path.display()))
}
+8
View File
@@ -0,0 +1,8 @@
pub mod keymap;
pub mod logging;
use directories::ProjectDirs;
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "furumi")
}
+111
View File
@@ -0,0 +1,111 @@
mod api;
mod app;
mod art;
mod config;
mod media;
mod player;
mod ui;
use std::io;
use anyhow::Result;
use crossterm::event::{
DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
fn main() -> Result<()> {
let mut startup_warning = None;
if let Err(err) = config::logging::init() {
startup_warning = Some(format!("logging disabled: {err:#}"));
}
let (keymap, keymap_warning) = config::keymap::Keymap::load();
let startup_warning = keymap_warning.or(startup_warning);
// The app (tokio + TUI) runs on a worker thread; the main thread stays
// dedicated to the OS media-key event loop — on macOS the system only
// delivers media commands while the main thread pumps its CFRunLoop.
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<app::event::AppEvent>();
let (media_tx, media_rx) = std::sync::mpsc::channel::<media::MediaUpdate>();
let media_event_tx = event_tx.clone();
let app_thread = std::thread::Builder::new()
.name("app".to_string())
.spawn(move || run_app(keymap, startup_warning, event_tx, event_rx, media_tx))
.expect("spawning the app thread cannot fail");
media::run_on_main_thread(media_rx, move |command| {
let _ = media_event_tx.send(app::event::AppEvent::Media(command));
});
app_thread.join().expect("app thread panicked")
}
fn run_app(
keymap: config::keymap::Keymap,
startup_warning: Option<String>,
event_tx: tokio::sync::mpsc::UnboundedSender<app::event::AppEvent>,
event_rx: tokio::sync::mpsc::UnboundedReceiver<app::event::AppEvent>,
media_tx: std::sync::mpsc::Sender<media::MediaUpdate>,
) -> Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
// ratatui::init() enables raw mode + alternate screen and installs a
// panic hook that restores the terminal.
let terminal = ratatui::init();
let keyboard_enhanced = push_keyboard_enhancements();
let bracketed_paste = crossterm::execute!(io::stdout(), EnableBracketedPaste).is_ok();
if bracketed_paste {
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::execute!(io::stdout(), DisableBracketedPaste);
previous_hook(info);
}));
}
let result = runtime.block_on(app::run(
terminal,
keymap,
startup_warning,
event_tx,
event_rx,
media_tx,
));
if bracketed_paste {
let _ = crossterm::execute!(io::stdout(), DisableBracketedPaste);
}
if keyboard_enhanced {
let _ = crossterm::execute!(io::stdout(), PopKeyboardEnhancementFlags);
}
ratatui::restore();
result
}
/// Kitty keyboard protocol, where supported, disambiguates Esc from alt-keys
/// and modifier combos. The flags are popped on exit and on panic — leaving
/// them pushed corrupts the user's shell.
fn push_keyboard_enhancements() -> bool {
if !matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
) {
return false;
}
if crossterm::execute!(
io::stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)
.is_err()
{
return false;
}
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::execute!(io::stdout(), PopKeyboardEnhancementFlags);
previous_hook(info);
}));
true
}
+159
View File
@@ -0,0 +1,159 @@
//! System media-key integration (play/pause/next/prev from the OS).
//!
//! Backends via souvlaki: MPRIS over D-Bus on Linux, MPNowPlayingInfoCenter /
//! MPRemoteCommandCenter on macOS, SMTC on Windows. On macOS the command
//! callbacks are only delivered while the main thread services its CFRunLoop,
//! so the app runs on a worker thread and `run_on_main_thread` keeps the main
//! thread pumping the run loop and applying metadata updates.
//!
//! Windows note: SMTC needs a window handle; creating a hidden window is not
//! wired up yet, so media keys are skipped there with a log line.
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig};
/// Commands arriving from the OS media keys, translated for the app.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaCommand {
TogglePause,
Play,
Pause,
Next,
Previous,
Stop,
}
/// Now-playing updates pushed from the app to the OS.
#[derive(Debug)]
pub enum MediaUpdate {
Metadata {
title: String,
artist: String,
album: String,
duration_secs: f64,
},
Playback {
playing: bool,
paused: bool,
position_secs: f64,
},
Stopped,
}
/// Runs until the app drops its `Sender<MediaUpdate>` (i.e. until quit).
/// Must be called on the process main thread (macOS requirement).
pub fn run_on_main_thread(
updates: Receiver<MediaUpdate>,
on_command: impl Fn(MediaCommand) + Send + 'static,
) {
let mut controls = match create_controls() {
Some(controls) => controls,
None => {
// No OS integration: just wait for the app to finish.
while updates.recv().is_ok() {}
return;
}
};
if let Err(err) = controls.attach(move |event| {
let command = match event {
MediaControlEvent::Toggle => MediaCommand::TogglePause,
MediaControlEvent::Play => MediaCommand::Play,
MediaControlEvent::Pause => MediaCommand::Pause,
MediaControlEvent::Next => MediaCommand::Next,
MediaControlEvent::Previous => MediaCommand::Previous,
MediaControlEvent::Stop => MediaCommand::Stop,
_ => return,
};
on_command(command);
}) {
tracing::warn!(?err, "attaching media key handler failed");
while updates.recv().is_ok() {}
return;
}
tracing::info!("media keys attached");
loop {
pump_platform_events();
match updates.recv_timeout(Duration::from_millis(200)) {
Ok(update) => apply(&mut controls, update),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
let _ = controls.detach();
}
fn create_controls() -> Option<MediaControls> {
let config = PlatformConfig {
display_name: "Furumi",
dbus_name: "cy.hexor.furumi_cli",
hwnd: None,
};
if cfg!(windows) {
tracing::info!("media keys: hidden-window SMTC setup not implemented yet, skipping");
return None;
}
match MediaControls::new(config) {
Ok(controls) => Some(controls),
Err(err) => {
tracing::warn!(?err, "media controls unavailable");
None
}
}
}
fn apply(controls: &mut MediaControls, update: MediaUpdate) {
let result = match update {
MediaUpdate::Metadata {
title,
artist,
album,
duration_secs,
} => controls.set_metadata(MediaMetadata {
title: Some(&title),
artist: Some(&artist),
album: Some(&album),
duration: (duration_secs > 0.0)
.then(|| Duration::from_secs_f64(duration_secs)),
cover_url: None,
}),
MediaUpdate::Playback {
playing,
paused,
position_secs,
} => {
let progress = Some(MediaPosition(Duration::from_secs_f64(
position_secs.max(0.0),
)));
let playback = if !playing {
MediaPlayback::Stopped
} else if paused {
MediaPlayback::Paused { progress }
} else {
MediaPlayback::Playing { progress }
};
controls.set_playback(playback)
}
MediaUpdate::Stopped => controls.set_playback(MediaPlayback::Stopped),
};
if let Err(err) = result {
tracing::debug!(?err, "media update failed");
}
}
/// On macOS, MPRemoteCommandCenter callbacks arrive only while the main
/// thread's CFRunLoop is running; pump it briefly every cycle.
#[cfg(target_os = "macos")]
fn pump_platform_events() {
use core_foundation::runloop::{CFRunLoop, kCFRunLoopDefaultMode};
CFRunLoop::run_in_mode(
unsafe { kCFRunLoopDefaultMode },
Duration::from_millis(50),
false,
);
}
#[cfg(not(target_os = "macos"))]
fn pump_platform_events() {}
+258
View File
@@ -0,0 +1,258 @@
//! Playback engine: a dedicated audio thread owning the rodio output device
//! and player. The app talks to it through `Controller` (commands over a
//! channel, position/pause state over atomics) and receives `PlayerEvent`s
//! through the callback given to `spawn` — this module knows nothing about
//! the UI or app state.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
use stream_download::StreamDownload;
use stream_download::storage::temp::TempStorageProvider;
pub type TrackReader = StreamDownload<TempStorageProvider>;
/// Perceptual volume: cubic mapping from percent to linear amplitude, so
/// equal percent steps sound like equal loudness steps and low percentages
/// give fine-grained control.
pub fn amplitude(percent: u8) -> f32 {
let v = f32::from(percent.min(100)) / 100.0;
v * v * v
}
#[derive(Debug)]
pub enum PlayerEvent {
/// A track played to its end. `has_next` is true when a prefetched
/// source was already queued and is now playing gaplessly.
TrackFinished { has_next: bool },
Failed(String),
}
enum Command {
Play {
reader: Box<TrackReader>,
byte_len: Option<u64>,
volume: f32,
},
/// Append the next track behind the current one without interrupting
/// playback — rodio switches sources back to back (gapless-ish).
Enqueue {
reader: Box<TrackReader>,
byte_len: Option<u64>,
},
TogglePause,
Stop,
Seek(Duration),
SetVolume(f32),
}
/// Lock-free playback state for the UI tick to read.
#[derive(Debug, Default)]
pub struct Shared {
position_ms: AtomicU64,
paused: AtomicBool,
}
impl Shared {
pub fn position(&self) -> Duration {
Duration::from_millis(self.position_ms.load(Ordering::Relaxed))
}
pub fn paused(&self) -> bool {
self.paused.load(Ordering::Relaxed)
}
}
#[derive(Clone)]
pub struct Controller {
tx: Sender<Command>,
pub shared: Arc<Shared>,
}
impl Controller {
pub fn play(&self, reader: TrackReader, byte_len: Option<u64>, volume: f32) {
let _ = self.tx.send(Command::Play {
reader: Box::new(reader),
byte_len,
volume,
});
}
pub fn enqueue(&self, reader: TrackReader, byte_len: Option<u64>) {
let _ = self.tx.send(Command::Enqueue {
reader: Box::new(reader),
byte_len,
});
}
pub fn toggle_pause(&self) {
let _ = self.tx.send(Command::TogglePause);
}
pub fn stop(&self) {
let _ = self.tx.send(Command::Stop);
}
pub fn seek(&self, position: Duration) {
let _ = self.tx.send(Command::Seek(position));
}
pub fn set_volume(&self, volume: f32) {
let _ = self.tx.send(Command::SetVolume(volume));
}
}
pub fn spawn(on_event: impl Fn(PlayerEvent) + Send + 'static) -> Controller {
let (tx, rx) = std::sync::mpsc::channel();
let shared = Arc::new(Shared::default());
let thread_shared = Arc::clone(&shared);
std::thread::Builder::new()
.name("audio".to_string())
.spawn(move || run(rx, thread_shared, on_event))
.expect("spawning the audio thread cannot fail");
Controller { tx, shared }
}
struct Output {
/// Keeps the audio device open; dropping it stops the mixer.
_device: MixerDeviceSink,
player: Player,
}
fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent)) {
let mut output: Option<Output> = None;
let mut track_loaded = false;
let mut last_len = 0usize;
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(command) => {
// Commands change the source queue legitimately; resync the
// length so the next tick doesn't read it as a track ending.
handle(command, &mut output, &mut track_loaded, &on_event);
last_len = output.as_ref().map_or(0, |out| out.player.len());
}
Err(RecvTimeoutError::Timeout) => {
if let Some(out) = &output {
shared
.position_ms
.store(out.player.get_pos().as_millis() as u64, Ordering::Relaxed);
shared.paused.store(out.player.is_paused(), Ordering::Relaxed);
let len = out.player.len();
if track_loaded && len < last_len {
if len == 0 {
track_loaded = false;
}
for _ in len..last_len {
on_event(PlayerEvent::TrackFinished { has_next: len > 0 });
}
}
last_len = len;
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
fn handle(
command: Command,
output: &mut Option<Output>,
track_loaded: &mut bool,
on_event: &impl Fn(PlayerEvent),
) {
match command {
Command::Play {
reader,
byte_len,
volume,
} => {
// The device is opened lazily on first playback so the app works
// on machines with no audio output until you actually press play.
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(device) => {
let player = Player::connect_new(device.mixer());
*output = Some(Output {
_device: device,
player,
});
}
Err(err) => {
on_event(PlayerEvent::Failed(format!("no audio device: {err}")));
return;
}
}
}
let out = output.as_ref().expect("output opened above");
let mut builder = Decoder::builder()
.with_data(reader)
.with_seekable(true)
.with_gapless(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
match builder.build() {
Ok(decoder) => {
out.player.stop();
out.player.set_volume(volume);
out.player.append(decoder);
out.player.play();
*track_loaded = true;
}
Err(err) => {
on_event(PlayerEvent::Failed(format!("cannot decode track: {err}")));
}
}
}
Command::Enqueue { reader, byte_len } => {
let Some(out) = output.as_ref() else {
return;
};
let mut builder = Decoder::builder()
.with_data(reader)
.with_seekable(true)
.with_gapless(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
match builder.build() {
Ok(decoder) => out.player.append(decoder),
Err(err) => {
on_event(PlayerEvent::Failed(format!("cannot decode next track: {err}")));
}
}
}
Command::TogglePause => {
if let Some(out) = output {
if out.player.is_paused() {
out.player.play();
} else {
out.player.pause();
}
}
}
Command::Stop => {
if let Some(out) = output {
out.player.stop();
}
*track_loaded = false;
}
Command::Seek(position) => {
if let Some(out) = output {
if let Err(err) = out.player.try_seek(position) {
tracing::warn!(%err, "seek failed");
}
}
}
Command::SetVolume(volume) => {
if let Some(out) = output {
out.player.set_volume(volume);
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span, Text};
use crate::art::ArtImage;
/// Render half-block art as ratatui text: one `▀` per cell, foreground =
/// top pixel, background = bottom pixel.
pub fn to_text(art: &ArtImage) -> Text<'static> {
let mut lines = Vec::with_capacity(usize::from(art.height_cells));
for y in 0..art.height_cells {
let mut spans = Vec::with_capacity(usize::from(art.width_cells));
for x in 0..art.width_cells {
let (top, bottom) = art.cell(x, y);
spans.push(Span::styled(
"",
Style::new()
.fg(Color::Rgb(top[0], top[1], top[2]))
.bg(Color::Rgb(bottom[0], bottom[1], bottom[2])),
));
}
lines.push(Line::from(spans));
}
Text::from(lines)
}
+643
View File
@@ -0,0 +1,643 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Row, Table};
use super::{art, theme};
use crate::api::models::{ArtistCard, ReleaseCard};
use crate::app::state::{
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
};
use crate::art::cache_key;
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
match state.global.stack.last() {
None => draw_grid(frame, area, state),
Some(GlobalView::Artist { id, cursor }) => draw_artist(frame, area, state, *id, *cursor),
Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor),
Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor),
}
}
fn error_style() -> Style {
Style::new().fg(Color::Red)
}
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
let block = Block::bordered()
.title(title)
.title_style(theme::header())
.border_style(theme::dim());
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
if area.height == 0 {
return;
}
let middle = Rect { y: area.y + area.height / 2, height: 1, ..area };
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle);
}
fn tile_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> {
state
.art
.get(&cache_key(url?, ART_CELL_WIDTH, ART_CELL_HEIGHT))
}
fn header_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> {
state
.art
.get(&cache_key(url?, ART_HEADER_WIDTH, ART_HEADER_HEIGHT))
}
fn draw_art(frame: &mut Frame, area: Rect, art_state: Option<&ArtState>) {
match art_state {
Some(ArtState::Ready(image)) => {
frame.render_widget(Paragraph::new(art::to_text(image)), area);
}
Some(ArtState::Loading) => centered_line(frame, area, Line::styled("", theme::dim())),
_ => centered_line(frame, area, Line::styled("", theme::dim())),
}
}
/// Bordered tile with artwork, a title line and a dim meta line. The
/// selected tile gets a thick accent border and an inverted (filled)
/// caption so it stands out in a large grid; the artwork stays untouched.
fn draw_tile(
frame: &mut Frame,
tile: Rect,
art_state: Option<&ArtState>,
title: &str,
meta: &str,
selected: bool,
) {
let block = if selected {
Block::bordered()
.border_type(ratatui::widgets::BorderType::Thick)
.border_style(theme::accent())
} else {
Block::bordered().border_style(theme::dim())
};
let inner = block.inner(tile);
frame.render_widget(block, tile);
let art_area = Rect { height: ART_CELL_HEIGHT.min(inner.height), ..inner };
draw_art(frame, art_area, art_state);
if inner.height > ART_CELL_HEIGHT {
let name_area = Rect { y: inner.y + ART_CELL_HEIGHT, height: 1, ..inner };
frame.render_widget(
Paragraph::new(Line::raw(title.to_string())),
name_area,
);
if selected {
frame.buffer_mut().set_style(name_area, theme::tab_active());
}
}
if inner.height > ART_CELL_HEIGHT + 1 {
let meta_area = Rect { y: inner.y + ART_CELL_HEIGHT + 1, height: 1, ..inner };
frame.render_widget(
Paragraph::new(Line::styled(meta.to_string(), theme::dim())),
meta_area,
);
if selected {
frame.buffer_mut().set_style(meta_area, theme::tab_active());
}
}
}
/// One selectable row: left content, optional right-aligned suffix, full-row
/// highlight when selected.
fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option<String>, selected: bool) {
frame.render_widget(Paragraph::new(line), area);
if let Some(right) = right {
frame.render_widget(
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
area,
);
}
if selected {
frame.buffer_mut().set_style(area, theme::tab_active());
}
}
// ---------------------------------------------------------------------------
// Scrollable content plan: a vertical list of items with known heights; the
// viewport is scrolled so the cursor's item stays centered.
// ---------------------------------------------------------------------------
enum PlanItem {
/// Section header line.
Header(String),
Gap,
/// Selectable track row; the payload is the cursor index it represents.
Track { cursor_index: usize },
/// One row of release tiles (display-order positions).
TileRow(Vec<usize>),
/// One release as a table row (display-order position).
TableRow(usize),
}
impl PlanItem {
fn height(&self) -> u16 {
match self {
PlanItem::Header(_) | PlanItem::Gap | PlanItem::Track { .. }
| PlanItem::TableRow(_) => 1,
PlanItem::TileRow(_) => TILE_HEIGHT,
}
}
}
fn scroll_offset(items: &[PlanItem], cursor_item: Option<usize>, viewport: u16) -> u16 {
let total: u16 = items.iter().map(PlanItem::height).sum();
if total <= viewport {
return 0;
}
let Some(cursor_item) = cursor_item else {
return 0;
};
let top: u16 = items[..cursor_item].iter().map(PlanItem::height).sum();
let center = top + items[cursor_item].height() / 2;
center
.saturating_sub(viewport / 2)
.min(total.saturating_sub(viewport))
}
// ---------------------------------------------------------------------------
// Artist grid (stack root)
// ---------------------------------------------------------------------------
fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
let global = &state.global;
let title = if global.total > 0 {
format!(" Global — {} artists ", global.total)
} else {
" Global ".to_string()
};
let inner = bordered(frame, area, title);
if global.artists.is_empty() {
let message = if let Some(error) = &global.error {
Line::styled(error.clone(), error_style())
} else if global.loading {
Line::styled("loading artists…", theme::dim())
} else {
Line::styled("no artists in the library", theme::dim())
};
centered_line(frame, inner, message);
return;
}
match global.view {
ViewMode::Tiles => draw_grid_tiles(frame, inner, state),
ViewMode::Table => draw_grid_table(frame, inner, state),
}
}
fn artist_tile_meta(artist: &ArtistCard) -> String {
format!("{} rel · {} trk", artist.release_count, artist.track_count)
}
fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) {
let global = &state.global;
let columns = usize::from((inner.width / TILE_WIDTH).max(1));
let visible_rows = usize::from((inner.height / TILE_HEIGHT).max(1));
let selected_row = global.selected / columns;
let first_row = (selected_row / visible_rows) * visible_rows;
let first_index = first_row * columns;
let last_index = (first_index + visible_rows * columns).min(global.artists.len());
for (offset, artist) in global.artists[first_index..last_index].iter().enumerate() {
let index = first_index + offset;
let tile = Rect {
x: inner.x + (offset % columns) as u16 * TILE_WIDTH,
y: inner.y + (offset / columns) as u16 * TILE_HEIGHT,
width: TILE_WIDTH,
height: TILE_HEIGHT,
};
draw_tile(
frame,
tile,
tile_art(state, artist.image_url.as_ref()),
&artist.name,
&artist_tile_meta(artist),
index == global.selected,
);
}
}
fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) {
let global = &state.global;
let visible_rows = usize::from(inner.height.saturating_sub(1).max(1));
let first = (global.selected / visible_rows) * visible_rows;
let last = (first + visible_rows).min(global.artists.len());
let rows = global.artists[first..last].iter().enumerate().map(|(offset, artist)| {
let index = first + offset;
let style = if index == global.selected {
theme::tab_active()
} else {
Style::new()
};
Row::new(vec![
artist.name.clone(),
artist.release_count.to_string(),
artist.track_count.to_string(),
])
.style(style)
});
let table = Table::new(
rows,
[Constraint::Min(24), Constraint::Length(9), Constraint::Length(7)],
)
.header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header()));
frame.render_widget(table, inner);
}
// ---------------------------------------------------------------------------
// Artist view
// ---------------------------------------------------------------------------
fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.artist_views.get(&id);
let name = match loadable {
Some(Loadable::Ready(detail)) => detail.name.clone(),
_ => "Artist".to_string(),
};
let inner = bordered(frame, area, format!(" Global ▸ {name} "));
let detail = match loadable {
Some(Loadable::Ready(detail)) => detail,
Some(Loadable::Failed(error)) => {
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
}
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
};
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner);
// Header: artwork left, metadata right.
let [art_area, _, info_area] = Layout::horizontal([
Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)),
Constraint::Length(2),
Constraint::Min(0),
])
.areas(header_area);
draw_art(
frame,
Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area },
header_art(state, detail.image_url.as_ref()),
);
let info = vec![
Line::default(),
Line::styled(detail.name.clone(), theme::header()),
Line::default(),
Line::styled(
format!(
"{} tracks · {} plays",
detail.total_track_count, detail.total_play_count
),
theme::dim(),
),
Line::styled(format!("{} releases", detail.releases.len()), theme::dim()),
];
frame.render_widget(Paragraph::new(info), info_area);
// Scrollable content: top tracks, then releases grouped by type.
let tracks = detail.top_tracks.len();
let mut items = Vec::new();
let mut cursor_item = None;
if tracks > 0 {
items.push(PlanItem::Header("Top tracks".to_string()));
for index in 0..tracks {
if cursor == index {
cursor_item = Some(items.len());
}
items.push(PlanItem::Track { cursor_index: index });
}
items.push(PlanItem::Gap);
}
let columns = usize::from((content_area.width / TILE_WIDTH).max(1));
let mut position = 0;
for (label, group) in release_groups(&detail.releases) {
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
match state.global.view {
ViewMode::Tiles => {
for chunk in group.chunks(columns) {
let row: Vec<usize> = (position..position + chunk.len()).collect();
if row.contains(&(cursor.wrapping_sub(tracks))) {
cursor_item = Some(items.len());
}
items.push(PlanItem::TileRow(row));
position += chunk.len();
}
}
ViewMode::Table => {
for _ in &group {
if cursor == tracks + position {
cursor_item = Some(items.len());
}
items.push(PlanItem::TableRow(position));
position += 1;
}
}
}
items.push(PlanItem::Gap);
}
let display_order = crate::app::state::release_display_order(&detail.releases);
render_plan(frame, content_area, state, &items, cursor_item, &mut |frame, rect, item| {
match item {
PlanItem::Track { cursor_index } => {
let track = &detail.top_tracks[*cursor_index];
super::track_row(
frame,
rect,
state,
track,
(cursor_index + 1).to_string(),
cursor == *cursor_index,
);
}
PlanItem::TileRow(row) => {
for (column, position) in row.iter().enumerate() {
let release = &detail.releases[display_order[*position]];
let tile = Rect {
x: rect.x + column as u16 * TILE_WIDTH,
y: rect.y,
width: TILE_WIDTH.min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)),
height: rect.height,
};
if tile.width < 3 {
break;
}
draw_tile(
frame,
tile,
tile_art(state, release.cover_url.as_ref()),
&release.title,
&release_tile_meta(release),
cursor == tracks + position,
);
}
}
PlanItem::TableRow(position) => {
let release = &detail.releases[display_order[*position]];
let year = release.year.map(|y| y.to_string()).unwrap_or_default();
draw_row(
frame,
rect,
Line::from(vec![
Span::raw(release.title.clone()),
Span::styled(format!(" {year}"), theme::dim()),
]),
Some(format!("{} trk", release.track_count)),
cursor == tracks + position,
);
}
_ => unreachable!("headers and gaps are rendered by render_plan"),
}
});
}
fn release_tile_meta(release: &ReleaseCard) -> String {
match release.year {
Some(year) => format!("{year} · {} trk", release.track_count),
None => format!("{} trk", release.track_count),
}
}
/// Render plan items into `area`, scrolled so the cursor item is visible.
/// Headers and gaps are drawn here; everything else is delegated.
fn render_plan(
frame: &mut Frame,
area: Rect,
_state: &AppState,
items: &[PlanItem],
cursor_item: Option<usize>,
draw_item: &mut dyn FnMut(&mut Frame, Rect, &PlanItem),
) {
if area.height == 0 {
return;
}
let offset = scroll_offset(items, cursor_item, area.height);
let mut top: u16 = 0;
for item in items {
let height = item.height();
let item_top = top;
top += height;
if item_top < offset {
continue;
}
let rel_y = item_top - offset;
if rel_y >= area.height {
break;
}
let rect = Rect {
x: area.x,
y: area.y + rel_y,
width: area.width,
height: height.min(area.height - rel_y),
};
match item {
PlanItem::Header(label) => frame.render_widget(
Paragraph::new(Line::styled(label.clone(), theme::header())),
rect,
),
PlanItem::Gap => {}
other => draw_item(frame, rect, other),
}
}
}
// ---------------------------------------------------------------------------
// Release view
// ---------------------------------------------------------------------------
fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.release_views.get(&id);
let title = match loadable {
Some(Loadable::Ready(detail)) => detail.title.clone(),
_ => "Release".to_string(),
};
let inner = bordered(frame, area, format!(" Global ▸ {title} "));
let detail = match loadable {
Some(Loadable::Ready(detail)) => detail,
Some(Loadable::Failed(error)) => {
return centered_line(frame, inner, Line::styled(error.clone(), error_style()));
}
_ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())),
};
let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height);
let [header_area, tracks_area] =
Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner);
let [art_area, _, info_area] = Layout::horizontal([
Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)),
Constraint::Length(2),
Constraint::Min(0),
])
.areas(header_area);
draw_art(
frame,
Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area },
header_art(state, detail.cover_url.as_ref()),
);
let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect();
let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default();
let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect();
let mut info = vec![
Line::default(),
Line::styled(detail.title.clone(), theme::header()),
Line::raw(artists.join(", ")),
Line::default(),
Line::styled(
format!("{}{year} · {} tracks", detail.release_type, detail.tracks.len()),
theme::dim(),
),
];
if !uploaders.is_empty() {
info.push(Line::styled(
format!("uploaded by {}", uploaders.join(", ")),
theme::dim(),
));
}
frame.render_widget(Paragraph::new(info), info_area);
// Track list with centered scrolling.
let visible = usize::from(tracks_area.height.max(1));
let total = detail.tracks.len();
let first = cursor
.saturating_sub(visible / 2)
.min(total.saturating_sub(visible));
for (offset, track) in detail.tracks.iter().enumerate().skip(first).take(visible) {
let rect = Rect {
x: tracks_area.x,
y: tracks_area.y + (offset - first) as u16,
width: tracks_area.width,
height: 1,
};
let number = track
.track_number
.map(|n| n.to_string())
.unwrap_or_else(|| (offset + 1).to_string());
super::track_row(frame, rect, state, track, number, cursor == offset);
}
}
// ---------------------------------------------------------------------------
// Search view (driven by the `:/query` command)
// ---------------------------------------------------------------------------
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
let search = &state.search;
let mut title = format!(" Search: {} ", search.query);
if search.loading {
title.push_str("· searching… ");
}
let inner = bordered(frame, area, title);
let Some(results) = &search.results else {
let hint = if search.query.is_empty() {
"type to search artists, releases and tracks"
} else {
"searching…"
};
return centered_line(frame, inner, Line::styled(hint, theme::dim()));
};
if results.len() == 0 {
return centered_line(frame, inner, Line::styled("nothing found", theme::dim()));
}
// All rows are one line tall: (line, right column, cursor index).
let mut rows: Vec<(Line, Option<String>, Option<usize>)> = Vec::new();
let mut index = 0;
if !results.artists.is_empty() {
rows.push((Line::styled("Artists", theme::header()), None, None));
for artist in &results.artists {
rows.push((
Line::raw(artist.name.clone()),
Some(artist_tile_meta(artist)),
Some(index),
));
index += 1;
}
rows.push((Line::default(), None, None));
}
if !results.releases.is_empty() {
rows.push((Line::styled("Releases", theme::header()), None, None));
for release in &results.releases {
rows.push((
Line::from(vec![
Span::raw(release.title.clone()),
Span::styled(format!(" {}", release.release_type), theme::dim()),
]),
Some(release_tile_meta(release)),
Some(index),
));
index += 1;
}
rows.push((Line::default(), None, None));
}
if !results.tracks.is_empty() {
rows.push((Line::styled("Tracks", theme::header()), None, None));
for track in &results.tracks {
let heart = if state.likes.contains(&track.id) {
Span::styled("", theme::accent())
} else {
Span::raw(" ")
};
let tech = track.tech_label_short();
let right = if tech.is_empty() {
track.duration_label()
} else {
format!("{tech} · {}", track.duration_label())
};
rows.push((
Line::from(vec![
heart,
Span::raw(track.title.clone()),
Span::styled(
format!(" {} · {}", track.artist_line(), track.release_title),
theme::dim(),
),
]),
Some(right),
Some(index),
));
index += 1;
}
}
let cursor_row = rows
.iter()
.position(|(_, _, c)| *c == Some(cursor))
.unwrap_or(0);
let visible = usize::from(inner.height.max(1));
let first = cursor_row
.saturating_sub(visible / 2)
.min(rows.len().saturating_sub(visible));
for (offset, (line, right, row_cursor)) in
rows.into_iter().enumerate().skip(first).take(visible)
{
let rect = Rect {
x: inner.x,
y: inner.y + (offset - first) as u16,
width: inner.width,
height: 1,
};
draw_row(frame, rect, line, right, row_cursor == Some(cursor));
}
}
+176
View File
@@ -0,0 +1,176 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Paragraph, Wrap};
use super::theme;
use crate::app::state::{LoginField, LoginForm, LoginMode};
pub fn draw(frame: &mut Frame, form: &LoginForm) {
match form.mode {
LoginMode::Form => draw_form(frame, form),
LoginMode::SsoPending => draw_sso_pending(frame, form),
}
}
fn draw_form(frame: &mut Frame, form: &LoginForm) {
let area = centered(frame.area(), 52, 19);
let block = Block::bordered()
.title(" Sign in to furumi ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
// SSO is the primary path: server URL + SSO button up top, the rarely
// used password fallback below a separator.
let [server, sso_button, separator, username, password, signin_button, message, hint] =
Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(inner);
draw_field(frame, server, "Server URL", &form.server_url, false,
form.focus == LoginField::ServerUrl);
draw_button(frame, sso_button, "[ Continue with SSO ]",
form.focus == LoginField::SsoButton);
frame.render_widget(
Paragraph::new(Line::styled("── or sign in with password ──", theme::dim()))
.alignment(Alignment::Center),
separator,
);
draw_field(frame, username, "Username", &form.username, false,
form.focus == LoginField::Username);
draw_field(frame, password, "Password", &form.password, true,
form.focus == LoginField::Password);
draw_button(frame, signin_button, "[ Sign in ]",
form.focus == LoginField::SignInButton);
draw_message(frame, message, form);
frame.render_widget(
Paragraph::new(Line::styled(
"tab/↑↓ move · enter submit · ctrl-c quit",
theme::dim(),
))
.alignment(Alignment::Center),
hint,
);
}
fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) {
let area = centered(frame.area(), 64, 16);
let block = Block::bordered()
.title(" Continue with SSO ")
.title_style(theme::header())
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
let [steps, url, _, paste, message, hint] = Layout::vertical([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(inner);
let lines = if let Some(port) = form.sso_port {
vec![
Line::raw("1. Finish signing in, in the browser window."),
Line::raw("2. Sign-in completes here automatically."),
Line::styled(format!(" (waiting on 127.0.0.1:{port})"), theme::dim()),
Line::raw("3. If it doesn't, paste the code from the page below."),
]
} else {
vec![
Line::raw("1. Finish signing in, in the browser window."),
Line::raw("2. Copy the code shown on the final page"),
Line::raw(" (or right-click \"Open Furumi\" and copy its link)."),
Line::raw("3. Paste it below and press Enter."),
]
};
frame.render_widget(Paragraph::new(lines), steps);
frame.render_widget(
Paragraph::new(Line::styled(form.sso_url.clone(), theme::dim()))
.wrap(Wrap { trim: true })
.block(Block::bordered().title("If the browser didn't open, visit").border_style(theme::dim())),
url,
);
draw_field(frame, paste, "Link or code", &form.sso_paste, false, true);
draw_message(frame, message, form);
frame.render_widget(
Paragraph::new(Line::styled("enter submit · esc back · ctrl-c quit", theme::dim()))
.alignment(Alignment::Center),
hint,
);
}
fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) {
let border = if focused { theme::accent() } else { theme::dim() };
let block = Block::bordered().title(label).border_style(border);
let shown = if mask {
"".repeat(value.chars().count())
} else {
value.to_string()
};
// Keep the tail visible when the value overflows the field.
let width = block.inner(area).width.saturating_sub(1) as usize;
let mut text: String = shown
.chars()
.skip(shown.chars().count().saturating_sub(width))
.collect();
if focused {
text.push('█');
}
frame.render_widget(Paragraph::new(text).block(block), area);
}
fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) {
let style = if focused {
theme::tab_active()
} else {
theme::dim()
};
frame.render_widget(
Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center),
area,
);
}
fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) {
let line = if form.busy {
Line::styled("signing in…", theme::accent())
} else if let Some(error) = &form.error {
Line::styled(error.clone(), Style::new().fg(Color::Red))
} else {
Line::default()
};
frame.render_widget(
Paragraph::new(line)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center),
area,
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(rect);
rect
}
+350
View File
@@ -0,0 +1,350 @@
pub mod art;
mod global;
mod login;
mod playlists;
pub mod theme;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs};
use crate::app::state::{AppState, Screen, Tab};
use crate::config::keymap::Keymap;
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
if state.screen == Screen::Login {
login::draw(frame, &state.login);
return;
}
let [tabs_area, main_area, status_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(2),
])
.areas(frame.area());
draw_tabs(frame, tabs_area, state);
match state.active_tab {
Tab::Global => global::draw(frame, main_area, state),
Tab::Playlists => playlists::draw(frame, main_area, state),
Tab::Queue => draw_queue(frame, main_area, state),
Tab::Devices => draw_main(frame, main_area, state),
}
draw_status(frame, status_area, state);
if state.help_visible {
draw_help(frame, keymap);
}
}
fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) {
let titles = Tab::ALL
.iter()
.map(|tab| format!(" {} {} ", tab.index() + 1, tab.title()));
let tabs = Tabs::new(titles)
.select(state.active_tab.index())
.style(theme::dim())
.highlight_style(theme::tab_active())
.divider("");
frame.render_widget(tabs, area);
}
fn draw_main(frame: &mut Frame, area: Rect, state: &AppState) {
let block = Block::bordered()
.title(format!(" {} ", state.active_tab.title()))
.title_style(theme::header())
.border_style(theme::dim());
let inner = block.inner(area);
frame.render_widget(block, area);
let (summary, milestone) = match state.active_tab {
Tab::Devices => ("Connected devices and playback transfer", "milestone 5"),
_ => ("", ""),
};
let lines = vec![
Line::default(),
Line::styled(summary, theme::accent()),
Line::styled(format!("coming in {milestone}"), theme::dim()),
Line::default(),
Line::styled("Tab / Shift-Tab or 1-5 to switch tabs", theme::dim()),
Line::styled("? keybindings q quit", theme::dim()),
];
let paragraph = Paragraph::new(lines).alignment(Alignment::Center);
frame.render_widget(paragraph, centered_vertically(inner, 6));
}
/// One track row used by every track list: ♥ marker for liked tracks, the
/// title and artists on the left, tech info and duration on the right.
pub(crate) fn track_row(
frame: &mut Frame,
area: Rect,
state: &AppState,
track: &crate::api::models::TrackItem,
index_label: String,
selected: bool,
) {
let heart = if state.likes.contains(&track.id) {
Span::styled("", theme::accent())
} else {
Span::raw(" ")
};
let line = Line::from(vec![
Span::styled(format!("{index_label:>3} "), theme::dim()),
heart,
Span::raw(track.title.clone()),
Span::styled(format!(" {}", track.artist_line()), theme::dim()),
]);
frame.render_widget(Paragraph::new(line), area);
let tech = track.tech_label_short();
let right = if tech.is_empty() || area.width < 60 {
track.duration_label()
} else {
format!("{tech} · {}", track.duration_label())
};
frame.render_widget(
Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right),
area,
);
if selected {
frame.buffer_mut().set_style(area, theme::tab_active());
}
}
/// Read-only queue listing; the playing track is highlighted.
fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) {
let player = &state.player;
let block = Block::bordered()
.title(format!(" Queue — {} tracks ", player.queue.len()))
.title_style(theme::header())
.border_style(theme::dim());
let inner = block.inner(area);
frame.render_widget(block, area);
if player.queue.is_empty() {
let middle = Rect { y: inner.y + inner.height / 2, height: 1, ..inner };
frame.render_widget(
Paragraph::new(Line::styled(
"queue is empty — open a track and press enter",
theme::dim(),
))
.alignment(Alignment::Center),
middle,
);
return;
}
let visible = usize::from(inner.height.max(1));
let first = player
.queue_pos
.saturating_sub(visible / 2)
.min(player.queue.len().saturating_sub(visible));
for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: inner.x,
y: inner.y + (index - first) as u16,
width: inner.width,
height: 1,
};
track_row(
frame,
row,
state,
track,
(index + 1).to_string(),
index == player.queue_pos,
);
}
}
fn format_secs(secs: f64) -> String {
let total = secs.max(0.0).round() as i64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Playback time, progress bar, queue position, volume and mode flags.
/// Wider consoles get a longer bar and full flags; narrow ones drop pieces.
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(track) = &player.current {
if player.playing {
let bar_width: usize = match width {
0..=59 => 0,
60..=79 => 8,
80..=109 => 14,
_ => 22,
};
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
if bar_width > 0 && track.duration_seconds > 0.0 {
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
let filled = (ratio * bar_width as f64).round() as usize;
spans.push(Span::styled("".repeat(filled), theme::accent()));
spans.push(Span::styled("".repeat(bar_width - filled), theme::dim()));
spans.push(Span::raw(" "));
} else {
spans.push(Span::styled("/ ", theme::dim()));
}
spans.push(Span::raw(track.duration_label()));
if !player.queue.is_empty() && width >= 70 {
spans.push(Span::styled(
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
theme::dim(),
));
}
}
}
if width >= 80 {
let volume_cells = usize::from(player.volume / 10);
spans.extend([
Span::styled(" vol ", theme::dim()),
Span::styled("".repeat(volume_cells), theme::accent()),
Span::styled("".repeat(10 - volume_cells), theme::dim()),
Span::raw(format!(" {:3}%", player.volume)),
Span::styled(" shuffle ", theme::dim()),
Span::raw(if player.shuffle { "on" } else { "off" }.to_string()),
Span::styled(" repeat ", theme::dim()),
Span::raw(player.repeat.label().to_string()),
]);
} else {
spans.push(Span::styled(
format!(" {}%", player.volume),
theme::dim(),
));
}
Line::from(spans)
}
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
let [player_row, message_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let player = &state.player;
// Layout: track title left, time/progress/flags centered, user right.
// The center block is built first and gets a fixed width; the title
// truncates into whatever is left.
let center = player_right_line(player, area.width);
let center_width = (center.width() as u16).min(area.width);
let user_line = state.user.as_ref().map(|user| {
Line::from(vec![
Span::styled("", theme::accent()),
Span::raw(user.name.clone()),
])
});
let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16);
let [title_area, right_area, user_area] = Layout::horizontal([
Constraint::Min(8),
Constraint::Length(center_width),
Constraint::Length(user_width),
])
.areas(player_row);
if let Some(user_line) = user_line {
frame.render_widget(
Paragraph::new(user_line).alignment(Alignment::Right),
user_area,
);
}
let mut spans = Vec::new();
match &player.current {
Some(track) if player.playing => {
if player.paused {
spans.push(Span::styled("", theme::dim()));
} else {
spans.push(Span::styled("", theme::accent()));
}
if state.likes.contains(&track.id) {
spans.push(Span::styled("", theme::accent()));
}
spans.push(Span::raw(track.title.clone()));
spans.push(Span::styled(
format!("{}", track.artist_line()),
theme::dim(),
));
}
_ => {
spans.push(Span::styled("■ stopped", theme::dim()));
}
}
frame.render_widget(Paragraph::new(Line::from(spans)), title_area);
frame.render_widget(Paragraph::new(center), right_area);
if state.cmdline.active {
// Vim-style command line takes over the message row.
let line = Line::from(vec![
Span::styled(":", theme::header()),
Span::raw(state.cmdline.input.clone()),
Span::styled("", theme::accent()),
]);
frame.render_widget(Paragraph::new(line), message_row);
return;
}
let message = match &state.status_message {
Some(message) => Line::styled(message.clone(), theme::accent()),
None => match &state.player.current {
// Idle line doubles as the current track's tech data display.
Some(track) if state.player.playing && !track.tech_label_full().is_empty() => {
Line::styled(track.tech_label_full(), theme::dim())
}
_ => Line::styled("press ? for keybindings", theme::dim()),
},
};
frame.render_widget(Paragraph::new(message), message_row);
if let Some(pending) = &state.pending_keys {
let pending = Paragraph::new(Line::styled(format!("{pending}"), theme::header()))
.alignment(Alignment::Right);
frame.render_widget(pending, message_row);
}
}
fn draw_help(frame: &mut Frame, keymap: &Keymap) {
let entries = keymap.help_entries();
let height = (entries.len() as u16 + 4).min(frame.area().height.saturating_sub(2));
let width = 56.min(frame.area().width.saturating_sub(2));
let area = centered_rect(frame.area(), width, height);
let rows = entries.into_iter().map(|(keys, description, context)| {
Row::new(vec![
Span::styled(keys, theme::accent()),
Span::raw(description),
Span::styled(context.label(), theme::dim()),
])
});
let table = Table::new(
rows,
[
Constraint::Length(12),
Constraint::Min(20),
Constraint::Length(9),
],
)
.header(Row::new(vec!["keys", "action", "context"]).style(theme::header()))
.block(
Block::bordered()
.title(" Keybindings ")
.title_style(theme::header()),
);
frame.render_widget(Clear, area);
frame.render_widget(table, area);
}
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
let [rect] = Layout::horizontal([Constraint::Length(width)])
.flex(ratatui::layout::Flex::Center)
.areas(area);
let [rect] = Layout::vertical([Constraint::Length(height)])
.flex(ratatui::layout::Flex::Center)
.areas(rect);
rect
}
fn centered_vertically(area: Rect, content_height: u16) -> Rect {
let [rect] = Layout::vertical([Constraint::Length(content_height)])
.flex(ratatui::layout::Flex::Center)
.areas(area);
rect
}
+144
View File
@@ -0,0 +1,144 @@
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use super::{theme, track_row};
use crate::app::state::{AppState, Loadable};
use crate::app::update::playlist_tracks;
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
match state.playlists.opened {
Some(opened) => draw_opened(frame, area, state, opened.id, opened.cursor),
None => draw_list(frame, area, state),
}
}
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
let block = Block::bordered()
.title(title)
.title_style(theme::header())
.border_style(theme::dim());
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
fn centered_line(frame: &mut Frame, area: Rect, line: Line) {
if area.height == 0 {
return;
}
let middle = Rect { y: area.y + area.height / 2, height: 1, ..area };
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle);
}
fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) {
let inner = bordered(frame, area, " Playlists ".to_string());
let selected = state.playlists.selected;
let list = match &state.playlists.list {
Some(Loadable::Ready(list)) => list,
Some(Loadable::Failed(error)) => {
return centered_line(
frame,
inner,
Line::styled(error.clone(), Style::new().fg(Color::Red)),
);
}
_ => {
return centered_line(frame, inner, Line::styled("loading playlists…", theme::dim()));
}
};
if list.is_empty() {
return centered_line(frame, inner, Line::styled("no playlists yet", theme::dim()));
}
let visible = usize::from(inner.height.max(1));
let first = selected
.saturating_sub(visible / 2)
.min(list.len().saturating_sub(visible));
for (index, playlist) in list.iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: inner.x,
y: inner.y + (index - first) as u16,
width: inner.width,
height: 1,
};
let marker = if playlist.kind == "likes" {
Span::styled("", theme::accent())
} else {
Span::raw(" ")
};
let mut flags = Vec::new();
if !playlist.is_own {
if let Some(owner) = &playlist.owner_name {
flags.push(format!("by {owner}"));
}
}
if playlist.is_public {
flags.push("public".to_string());
}
let suffix = if flags.is_empty() {
String::new()
} else {
format!(" {}", flags.join(" · "))
};
frame.render_widget(
Paragraph::new(Line::from(vec![
marker,
Span::raw(playlist.title.clone()),
Span::styled(suffix, theme::dim()),
])),
row,
);
frame.render_widget(
Paragraph::new(Line::styled(
format!("{} trk", playlist.track_count),
theme::dim(),
))
.alignment(Alignment::Right),
row,
);
if index == selected {
frame.buffer_mut().set_style(row, theme::tab_active());
}
}
}
fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) {
let loadable = state.playlist_views.get(&id);
let title = match loadable {
Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title),
_ => " Playlists ▸ … ".to_string(),
};
let inner = bordered(frame, area, title);
if let Some(Loadable::Failed(error)) = loadable {
return centered_line(
frame,
inner,
Line::styled(error.clone(), Style::new().fg(Color::Red)),
);
}
let Some(tracks) = playlist_tracks(state, id) else {
return centered_line(frame, inner, Line::styled("loading…", theme::dim()));
};
if tracks.is_empty() {
return centered_line(frame, inner, Line::styled("no tracks here yet", theme::dim()));
}
let visible = usize::from(inner.height.max(1));
let first = cursor
.saturating_sub(visible / 2)
.min(tracks.len().saturating_sub(visible));
for (index, track) in tracks.iter().enumerate().skip(first).take(visible) {
let row = Rect {
x: inner.x,
y: inner.y + (index - first) as u16,
width: inner.width,
height: 1,
};
track_row(frame, row, state, track, (index + 1).to_string(), index == cursor);
}
}
+23
View File
@@ -0,0 +1,23 @@
use ratatui::style::{Color, Modifier, Style};
pub const ACCENT: Color = Color::Cyan;
pub const DIM: Color = Color::DarkGray;
pub fn accent() -> Style {
Style::new().fg(ACCENT)
}
pub fn dim() -> Style {
Style::new().fg(DIM)
}
pub fn tab_active() -> Style {
Style::new()
.fg(Color::Black)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
}
pub fn header() -> Style {
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
}