Init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
+240
@@ -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
File diff suppressed because it is too large
Load Diff
+28
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+87
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod keymap;
|
||||
pub mod logging;
|
||||
|
||||
use directories::ProjectDirs;
|
||||
|
||||
pub fn project_dirs() -> Option<ProjectDirs> {
|
||||
ProjectDirs::from("", "", "furumi")
|
||||
}
|
||||
+111
@@ -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
@@ -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() {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user