From 02a396c146ec2bbc8d3116f3738ede4150b7fe36 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 10 Jun 2026 23:30:03 +0100 Subject: [PATCH] Added connected devices. Improved logging. UI fixes --- Cargo.toml | 2 +- src/api/auth.rs | 3 +- src/api/client.rs | 135 ++++++- src/api/models.rs | 112 +++++- src/app/action.rs | 98 +++++- src/app/cmdline.rs | 88 ++++- src/app/command.rs | 154 +++++++- src/app/event.rs | 19 +- src/app/login.rs | 4 +- src/app/mod.rs | 627 ++++++++++++++++++++++++++++++++- src/app/popup.rs | 228 ++++++++++++ src/app/state.rs | 88 ++++- src/app/update.rs | 251 +++++++++---- src/config/default_keymap.toml | 17 + src/config/keymap.rs | 47 ++- src/config/logging.rs | 116 ++++-- src/config/mod.rs | 47 +++ src/media.rs | 7 +- src/player/mod.rs | 32 +- src/ui/global.rs | 156 +++++--- src/ui/login.rs | 88 +++-- src/ui/logs.rs | 36 +- src/ui/mod.rs | 225 +++++++++--- src/ui/playlists.rs | 27 +- src/ui/popup.rs | 247 +++++++++++++ 25 files changed, 2540 insertions(+), 314 deletions(-) create mode 100644 src/app/popup.rs create mode 100644 src/ui/popup.rs diff --git a/Cargo.toml b/Cargo.toml index 4c29750..96aa214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi_tui" -version = "0.1.0" +version = "0.1.1" edition = "2024" [[bin]] diff --git a/src/api/auth.rs b/src/api/auth.rs index c95f072..3d3d526 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -190,8 +190,7 @@ mod tests { #[test] fn sso_code_from_deep_link() { - let code = - extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap(); + let code = extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap(); assert_eq!(code, "furu_mx_abc123"); } diff --git a/src/api/client.rs b/src/api/client.rs index e557570..da32454 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -4,8 +4,9 @@ use tokio::sync::Mutex; use super::auth::{self, AuthSession}; use super::models::{ - ApiErrorBody, ArtistDetail, ArtistsPage, LikesResponse, LoginResponse, MeResponse, - PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, TokensResponse, TrackItem, + ApiErrorBody, ArtistDetail, ArtistsPage, DevicePlaybackState, DevicePollResponse, + LikesResponse, LoginResponse, MeResponse, PlaylistCard, PlaylistDetail, ReleaseDetail, + SearchResults, TokensResponse, TrackItem, }; #[derive(Debug, thiserror::Error)] @@ -38,6 +39,14 @@ pub fn device_name() -> String { format!("furumi-tui ({})", std::env::consts::OS) } +pub fn device_user_agent() -> String { + format!( + "FurumiTUI/{} {}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + ) +} + #[derive(Serialize)] struct PasswordLoginRequest<'a> { username: &'a str, @@ -77,7 +86,11 @@ pub async fn login_password( .send() .await?; let login: LoginResponse = parse_response(response).await?; - Ok(AuthSession::new(base_url.to_string(), login.user, login.tokens)) + Ok(AuthSession::new( + base_url.to_string(), + login.user, + login.tokens, + )) } pub async fn login_sso_exchange( @@ -94,7 +107,11 @@ pub async fn login_sso_exchange( .send() .await?; let login: LoginResponse = parse_response(response).await?; - Ok(AuthSession::new(base_url.to_string(), login.user, login.tokens)) + Ok(AuthSession::new( + base_url.to_string(), + login.user, + login.tokens, + )) } /// Browser entry point for SSO. redirect_uri is either our loopback @@ -136,6 +153,28 @@ pub struct PlaybackStateBody { pub volume: f64, } +#[derive(Serialize)] +struct DevicePollRequest<'a> { + device_id: &'a str, + user_agent: String, + current_jam_id: Option<&'a str>, + playback_state: Option, +} + +#[derive(Serialize)] +struct DeviceActiveRequest<'a> { + device_id: &'a str, + current_device_id: &'a str, +} + +#[derive(Serialize)] +struct DeviceCommandRequest<'a> { + target_device_id: Option<&'a str>, + jam_id: Option<&'a str>, + command: &'a str, + payload: &'a serde_json::Value, +} + /// Percent-encode a query-string value. fn url_encode(value: &str) -> String { let mut out = String::with_capacity(value.len()); @@ -257,7 +296,9 @@ impl ApiClient { pub async fn get_bytes(&self, path: &str) -> Result, ApiError> { let url = format!("{}{path}", self.base_url); let response = self - .send_authed(&url, |client, url, token| client.get(url).bearer_auth(token)) + .send_authed(&url, |client, url, token| { + client.get(url).bearer_auth(token) + }) .await?; let status = response.status(); if !status.is_success() { @@ -274,6 +315,33 @@ impl ApiClient { self.get_json(&format!("/api/player/playlists/{id}")).await } + pub async fn create_playlist(&self, title: &str) -> Result { + #[derive(Serialize)] + struct Body<'a> { + title: &'a str, + } + self.post_json("/api/player/playlists", &Body { title }) + .await + } + + pub async fn add_tracks_to_playlist( + &self, + playlist_id: i64, + track_ids: &[i64], + ) -> Result<(), ApiError> { + #[derive(Serialize)] + struct Body<'a> { + track_ids: &'a [i64], + } + let _: serde_json::Value = self + .post_json( + &format!("/api/player/playlists/{playlist_id}/tracks"), + &Body { track_ids }, + ) + .await?; + Ok(()) + } + pub async fn likes(&self) -> Result, ApiError> { let response: LikesResponse = self.get_json("/api/player/likes").await?; Ok(response.track_ids) @@ -290,7 +358,10 @@ impl ApiClient { Ok(body.liked) } - #[allow(dead_code, reason = "device-sync state restore needs id→track resolution")] + #[allow( + dead_code, + reason = "device-sync state restore needs id→track resolution" + )] pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result, ApiError> { #[derive(Serialize)] struct Body<'a> { @@ -350,6 +421,58 @@ impl ApiClient { Ok(()) } + pub async fn poll_device( + &self, + device_id: &str, + playback_state: Option, + ) -> Result { + self.post_json( + "/api/player/devices/poll", + &DevicePollRequest { + device_id, + user_agent: device_user_agent(), + current_jam_id: None, + playback_state, + }, + ) + .await + } + + pub async fn select_device( + &self, + target_device_id: &str, + current_device_id: &str, + ) -> Result { + self.post_json( + "/api/player/devices/active", + &DeviceActiveRequest { + device_id: target_device_id, + current_device_id, + }, + ) + .await + } + + pub async fn send_device_command( + &self, + target_device_id: Option<&str>, + command: &str, + payload: &serde_json::Value, + ) -> Result<(), ApiError> { + let _: serde_json::Value = self + .post_json( + "/api/player/devices/command", + &DeviceCommandRequest { + target_device_id, + jam_id: None, + command, + payload, + }, + ) + .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 { diff --git a/src/api/models.rs b/src/api/models.rs index 7701117..077843d 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -22,7 +22,10 @@ pub struct LoginResponse { } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "rendered by the profile view in a later milestone")] +#[allow( + dead_code, + reason = "rendered by the profile view in a later milestone" +)] pub struct MeStats { pub liked_tracks: i64, pub playlists: i64, @@ -31,7 +34,10 @@ pub struct MeStats { } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "rendered by the profile view in a later milestone")] +#[allow( + dead_code, + reason = "rendered by the profile view in a later milestone" +)] pub struct MeResponse { pub id: i64, pub name: String, @@ -55,28 +61,37 @@ pub struct ArtistCard { pub track_count: i64, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, 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)] +/// Serialize keeps every field the backend sent us, so device-sync payloads +/// (play_from_index, queue_add) carry full track objects like the web does. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TrackItem { #[allow(dead_code, reason = "playback engine consumes this in milestone 3")] pub id: i64, pub title: String, + /// Absent in the artist-appearance variant of track payloads. + #[serde(default)] pub track_number: Option, pub duration_seconds: f64, + #[serde(default)] pub artists: Vec, + #[serde(default)] pub featured_artists: Vec, #[allow(dead_code, reason = "jump-to-release navigation later")] + #[serde(default)] pub release_id: i64, + #[serde(default)] pub release_title: String, #[allow(dead_code, reason = "shown in queue/now-playing later")] pub release_year: Option, /// Server-relative path to `/api/player/stream/{id}`. + #[serde(default)] pub stream_url: String, #[allow(dead_code, reason = "now-playing artwork in milestone 3")] pub cover_url: Option, @@ -103,21 +118,6 @@ impl TrackItem { 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(); @@ -157,6 +157,10 @@ pub struct ArtistDetail { pub total_play_count: i64, pub top_tracks: Vec, pub releases: Vec, + /// Tracks where this artist is featured (the only content for artists + /// without own releases). + #[serde(default)] + pub featured_tracks: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -208,6 +212,76 @@ pub struct LikesResponse { pub track_ids: Vec, } +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceDto { + pub id: String, + pub name: String, + pub kind: String, + #[allow(dead_code, reason = "server-side flag; we compare ids directly")] + pub is_current: bool, + pub is_active: bool, + #[allow(dead_code, reason = "freshness display later")] + pub last_seen_ms: i64, +} + +#[derive(Debug, Deserialize)] +pub struct DeviceCommandDto { + #[allow(dead_code, reason = "commands are applied in poll order")] + pub id: Option, + pub command: String, + #[serde(default)] + pub payload: serde_json::Value, +} + +/// Mirrors the backend's PlayerDevicePlaybackStateDto; tracks stay raw JSON +/// so unknown fields survive the round trip between clients. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DevicePlaybackState { + #[serde(default)] + pub track: Option, + #[serde(default)] + pub tracks: Vec, + #[serde(default)] + pub index: i32, + #[serde(default)] + pub position_seconds: f64, + #[serde(default)] + pub duration_seconds: f64, + #[serde(default)] + pub paused: bool, + #[serde(default)] + pub shuffle: bool, + #[serde(default = "default_repeat_mode")] + pub repeat_mode: String, + #[serde(default = "default_volume")] + pub volume: f64, + #[serde(default)] + pub updated_at_ms: i64, +} + +fn default_repeat_mode() -> String { + "off".to_string() +} + +fn default_volume() -> f64 { + 1.0 +} + +#[derive(Debug, Deserialize)] +pub struct DevicePollResponse { + #[allow(dead_code, reason = "echo of our own id")] + pub device_id: String, + pub active_device_id: Option, + #[serde(default)] + pub devices: Vec, + #[serde(default)] + pub commands: Vec, + #[serde(default)] + #[allow(dead_code, reason = "Jam control is out of scope for the TUI v1")] + pub current_jam_id: Option, + pub playback_state: Option, +} + #[derive(Debug, Default, Deserialize)] pub struct SearchResults { pub artists: Vec, diff --git a/src/app/action.rs b/src/app/action.rs index 9610c84..30c439e 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -32,13 +32,105 @@ pub enum Action { QueueAddLast, ClearQueue, GoToRelease, + AddToPlaylist, + NewPlaylist, ToggleHelp, ToggleViewMode, + OpenDevices, OpenCommandLine, + OpenSearch, Logout, } +/// Help-window sections, in display order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Category { + Playback, + Queue, + Navigation, + Search, + System, +} + +impl Category { + pub const ALL: [Category; 5] = [ + Category::Playback, + Category::Queue, + Category::Navigation, + Category::Search, + Category::System, + ]; + + pub fn title(self) -> &'static str { + match self { + Category::Playback => "Playback", + Category::Queue => "Queue & playlists", + Category::Navigation => "Navigation", + Category::Search => "Search & commands", + Category::System => "System", + } + } +} + impl Action { + pub fn category(&self) -> Category { + match self { + Action::PlayPause + | Action::NextTrack + | Action::PrevTrack + | Action::SeekForward { .. } + | Action::SeekBackward { .. } + | Action::VolumeUp + | Action::VolumeDown + | Action::ToggleShuffle + | Action::CycleRepeat => Category::Playback, + Action::QueueAddNext + | Action::QueueAddLast + | Action::ClearQueue + | Action::AddToPlaylist + | Action::NewPlaylist + | Action::ToggleLike => Category::Queue, + Action::MoveUp + | Action::MoveDown + | Action::MoveLeft + | Action::MoveRight + | Action::PageUp + | Action::PageDown + | Action::SelectFirst + | Action::SelectLast + | Action::Select + | Action::Back + | Action::NextTab + | Action::PrevTab + | Action::GoToTab(_) + | Action::GoToRelease + | Action::ToggleViewMode => Category::Navigation, + Action::OpenSearch | Action::OpenCommandLine => Category::Search, + Action::OpenDevices => Category::System, + Action::ToggleHelp | Action::Logout | Action::Quit => Category::System, + } + } + + /// The command-line equivalent shown in the help window, if any. + pub fn command_hint(&self) -> Option<&'static str> { + match self { + Action::Quit => Some(":q"), + Action::Logout => Some(":logout"), + Action::PlayPause => Some(":play"), + Action::NextTrack => Some(":next"), + Action::PrevTrack => Some(":prev"), + Action::SeekForward { .. } | Action::SeekBackward { .. } => Some(":seek +30 | 1:30"), + Action::VolumeUp | Action::VolumeDown => Some(":volume 0-100"), + Action::ToggleShuffle => Some(":shuffle"), + Action::CycleRepeat => Some(":repeat [off|one|all]"), + Action::ClearQueue => Some(":clear"), + Action::OpenDevices => Some(":devices"), + Action::ToggleHelp => Some(":help"), + Action::OpenSearch => Some("/text"), + _ => None, + } + } + pub fn describe(&self) -> String { match self { Action::Quit => "Quit".into(), @@ -69,9 +161,13 @@ impl Action { Action::QueueAddLast => "Queue: add to end".into(), Action::ClearQueue => "Queue: clear".into(), Action::GoToRelease => "Open the track's release".into(), + Action::AddToPlaylist => "Add track to a playlist…".into(), + Action::NewPlaylist => "Create a playlist".into(), Action::ToggleHelp => "Show / hide keybindings".into(), Action::ToggleViewMode => "Toggle tiles / table view".into(), - Action::OpenCommandLine => "Open command line (:/name searches)".into(), + Action::OpenDevices => "Connected devices".into(), + Action::OpenCommandLine => "Command line (:help for commands)".into(), + Action::OpenSearch => "Search artists, releases, tracks".into(), Action::Logout => "Sign out".into(), } } diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index ab20c1b..06638f8 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -14,10 +14,10 @@ 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) { +pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { match key.code { KeyCode::Esc => cancel(state), - KeyCode::Enter => commit(state), + KeyCode::Enter => commit(state, runtime), KeyCode::Backspace => { if state.cmdline.input.pop().is_none() { // Backspace on an empty line closes it, like vim. @@ -57,6 +57,21 @@ fn after_change(state: &mut AppState, runtime: &Runtime) { fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) { match command { + // One-shot commands have no live effect. + Command::Quit + | Command::Logout + | Command::Volume(_) + | Command::Seek(_) + | Command::SeekTo(_) + | Command::Shuffle + | Command::Repeat(_) + | Command::ClearQueue + | Command::Next + | Command::Prev + | Command::PlayPause + | Command::Help + | Command::Devices + | Command::Logs(_) => {} Command::Search(query) => { state.active_tab = Tab::Global; if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) { @@ -116,18 +131,81 @@ fn schedule_search(state: &mut AppState, runtime: &Runtime) { } /// Enter: close the line. Live commands already took effect (their view -/// stays open); one-shot commands would execute here. -fn commit(state: &mut AppState) { +/// stays open); one-shot commands execute here. +fn commit(state: &mut AppState, runtime: &mut Runtime) { let parsed = command::parse(&state.cmdline.input); close(state); match parsed { - Parsed::Empty | Parsed::Command(_) => {} + Parsed::Empty => {} + Parsed::Command(command) if command::is_live(&command) => {} + Parsed::Command(command) => execute(state, runtime, command), + Parsed::Invalid(usage) => state.status_message = Some(usage), Parsed::Unknown(name) => { state.status_message = Some(format!("unknown command: {name}")); } } } +/// One-shot command execution. Most commands reuse the same Action/Effect +/// path as keybindings, so behavior stays identical. +fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) { + use crate::app::action::Action; + use crate::app::state::{LOG_LEVELS, RepeatMode, Tab}; + use crate::app::update::Effect; + + let run_action = |state: &mut AppState, runtime: &mut Runtime, action: Action| { + if let Some(effect) = crate::app::update::update(state, action) { + super::perform_effect(state, runtime, effect); + } + }; + match command { + Command::Search(_) => {} + Command::Quit => state.should_quit = true, + Command::Logout => super::perform_logout(state, runtime), + Command::Volume(value) => { + state.player.volume = value; + super::perform_effect(state, runtime, Effect::SetVolume(value)); + state.status_message = Some(format!("volume {value}%")); + } + Command::Seek(delta) => { + if state.player.current.is_some() { + super::perform_effect(state, runtime, Effect::SeekBy(delta)); + } + } + Command::SeekTo(seconds) => { + if state.player.current.is_some() { + let delta = seconds as f64 - state.player.position_secs; + super::perform_effect(state, runtime, Effect::SeekBy(delta.round() as i64)); + } + } + Command::Shuffle => run_action(state, runtime, Action::ToggleShuffle), + Command::Repeat(None) => run_action(state, runtime, Action::CycleRepeat), + Command::Repeat(Some(mode)) => { + state.player.repeat = match mode { + command::RepeatArg::Off => RepeatMode::Off, + command::RepeatArg::One => RepeatMode::One, + command::RepeatArg::All => RepeatMode::All, + }; + super::perform_effect(state, runtime, Effect::SetOptions); + state.status_message = Some(format!("repeat {}", state.player.repeat.label())); + } + Command::ClearQueue => run_action(state, runtime, Action::ClearQueue), + Command::Next => run_action(state, runtime, Action::NextTrack), + Command::Prev => run_action(state, runtime, Action::PrevTrack), + Command::PlayPause => run_action(state, runtime, Action::PlayPause), + Command::Help => state.help_visible = true, + Command::Devices => run_action(state, runtime, Action::OpenDevices), + Command::Logs(level) => { + if let Some(index) = level { + state.logs.level_index = index.min(LOG_LEVELS.len() - 1); + state.logs.follow = true; + state.logs.selected_seq = None; + } + state.active_tab = Tab::Logs; + } + } +} + /// Esc: close the line and undo any live effect it had. fn cancel(state: &mut AppState) { retract_live(state); diff --git a/src/app/command.rs b/src/app/command.rs index 71eb2af..ba54d70 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -1,15 +1,53 @@ -//! Command line (`:`) command parsing. +//! Command line parsing. `/query` is the live search; word commands run on +//! Enter. //! //! 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`. +//! 1. Add a `Command` variant and a match arm in `parse()`. +//! 2. Execute it in `cmdline::execute()`. +//! +//! Live commands (re-evaluated on every keystroke) also need handling in +//! `cmdline::apply_live`. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepeatArg { + Off, + One, + All, +} #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { - /// `:/query` — realtime search over artists, releases and tracks. + /// `/query` — realtime search over artists, releases and tracks. Search(String), + /// `:q` / `:quit` — exit immediately (explicit enough to skip the + /// double-press confirmation). + Quit, + /// `:logout` — sign out and return to the login screen. + Logout, + /// `:volume 40` (also `:vol`) — set the volume precisely. + Volume(u8), + /// `:seek +30` / `:seek -10` — relative seek in seconds. + Seek(i64), + /// `:seek 90` / `:seek 1:30` — absolute position. + SeekTo(u64), + /// `:shuffle` — toggle shuffle. + Shuffle, + /// `:repeat [off|one|all]` — set or cycle the repeat mode. + Repeat(Option), + /// `:clear` — clear the play queue. + ClearQueue, + /// `:next` / `:prev` — queue navigation. + Next, + Prev, + /// `:pause` / `:play` — toggle playback. + PlayPause, + /// `:help` — open the keybinding help. + Help, + /// `:devices` — open the connected-devices picker. + Devices, + /// `:logs [error|warn|info|debug|trace]` — jump to the Logs tab, + /// optionally setting the severity filter. + Logs(Option), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -17,6 +55,8 @@ pub enum Parsed { /// Nothing typed yet. Empty, Command(Command), + /// Recognized command with bad arguments; the message explains usage. + Invalid(String), Unknown(String), } @@ -27,9 +67,67 @@ pub fn parse(input: &str) -> Parsed { 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()) + let mut parts = input.split_whitespace(); + let Some(name) = parts.next() else { + return Parsed::Empty; + }; + let arg = parts.next(); + match name { + "q" | "quit" => Parsed::Command(Command::Quit), + "logout" => Parsed::Command(Command::Logout), + "volume" | "vol" => match arg.and_then(|a| a.parse::().ok()) { + Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)), + _ => Parsed::Invalid("usage: :volume 0-100".to_string()), + }, + "seek" => match arg.map(parse_seek_arg) { + Some(Some(command)) => Parsed::Command(command), + _ => Parsed::Invalid("usage: :seek +30 | -10 | 90 | 1:30".to_string()), + }, + "shuffle" => Parsed::Command(Command::Shuffle), + "repeat" => match arg { + None => Parsed::Command(Command::Repeat(None)), + Some("off") => Parsed::Command(Command::Repeat(Some(RepeatArg::Off))), + Some("one") => Parsed::Command(Command::Repeat(Some(RepeatArg::One))), + Some("all") => Parsed::Command(Command::Repeat(Some(RepeatArg::All))), + Some(_) => Parsed::Invalid("usage: :repeat [off|one|all]".to_string()), + }, + "clear" => Parsed::Command(Command::ClearQueue), + "next" => Parsed::Command(Command::Next), + "prev" => Parsed::Command(Command::Prev), + "pause" | "play" => Parsed::Command(Command::PlayPause), + "help" => Parsed::Command(Command::Help), + "devices" | "device" => Parsed::Command(Command::Devices), + "logs" => match arg { + None => Parsed::Command(Command::Logs(None)), + Some(level) => match ["error", "warn", "info", "debug", "trace"] + .iter() + .position(|l| *l == level) + { + Some(index) => Parsed::Command(Command::Logs(Some(index))), + None => Parsed::Invalid("usage: :logs [error|warn|info|debug|trace]".to_string()), + }, + }, + _ => Parsed::Unknown(name.to_string()), + } +} + +/// `+30`/`-10` → relative; `90` or `1:30` → absolute. +fn parse_seek_arg(arg: &str) -> Option { + if let Some(rest) = arg.strip_prefix('+') { + return rest.parse::().ok().map(Command::Seek); + } + if let Some(rest) = arg.strip_prefix('-') { + return rest.parse::().ok().map(|s| Command::Seek(-s)); + } + if let Some((minutes, seconds)) = arg.split_once(':') { + let minutes = minutes.parse::().ok()?; + let seconds = seconds.parse::().ok()?; + if seconds >= 60 { + return None; + } + return Some(Command::SeekTo(minutes * 60 + seconds)); + } + arg.parse::().ok().map(Command::SeekTo) } /// Live commands take effect while typing; one-shot commands run on Enter. @@ -51,13 +149,47 @@ mod tests { } #[test] - fn unknown_and_empty() { + fn parses_word_commands() { + assert_eq!(parse("q"), Parsed::Command(Command::Quit)); + assert_eq!(parse("quit"), Parsed::Command(Command::Quit)); + assert_eq!(parse("logout"), Parsed::Command(Command::Logout)); + assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40))); + assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0))); + assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle)); + assert_eq!(parse("repeat"), Parsed::Command(Command::Repeat(None))); + assert_eq!( + parse("repeat all"), + Parsed::Command(Command::Repeat(Some(RepeatArg::All))) + ); + assert_eq!(parse("clear"), Parsed::Command(Command::ClearQueue)); + assert_eq!(parse("devices"), Parsed::Command(Command::Devices)); + assert_eq!(parse("logs debug"), Parsed::Command(Command::Logs(Some(3)))); + } + + #[test] + fn parses_seek_forms() { + assert_eq!(parse("seek +30"), Parsed::Command(Command::Seek(30))); + assert_eq!(parse("seek -10"), Parsed::Command(Command::Seek(-10))); + assert_eq!(parse("seek 90"), Parsed::Command(Command::SeekTo(90))); + assert_eq!(parse("seek 1:30"), Parsed::Command(Command::SeekTo(90))); + assert!(matches!(parse("seek"), Parsed::Invalid(_))); + assert!(matches!(parse("seek 1:75"), Parsed::Invalid(_))); + } + + #[test] + fn invalid_and_unknown() { assert_eq!(parse(""), Parsed::Empty); - assert_eq!(parse("volume 50"), Parsed::Unknown("volume".to_string())); + assert!(matches!(parse("volume 150"), Parsed::Invalid(_))); + assert!(matches!(parse("volume"), Parsed::Invalid(_))); + assert_eq!( + parse("frobnicate"), + Parsed::Unknown("frobnicate".to_string()) + ); } #[test] fn search_is_live() { assert!(is_live(&Command::Search("x".into()))); + assert!(!is_live(&Command::Quit)); } } diff --git a/src/app/event.rs b/src/app/event.rs index b0cb0af..6e80fd5 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use crate::api::auth::AuthSession; use crate::api::models::{ - ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, - TrackItem, + ArtistDetail, ArtistsPage, DevicePollResponse, PlaylistCard, PlaylistDetail, ReleaseDetail, + SearchResults, TrackItem, }; use crate::art::ArtImage; @@ -57,9 +57,24 @@ pub enum AppEvent { track_id: i64, liked: bool, }, + /// Connected-devices poll result; carries device list, active id, + /// remote playback state and commands for this TUI. + DevicesPolled(Result), + /// Response from switching the active device. + DeviceActivated(Result), /// A release fetched for queueing (a / shift-a on a release). EnqueueTracks { tracks: Vec, next: bool, }, + PlaylistCreated { + result: Result, + /// Add this track to the new playlist right away (Shift-P flow). + add_track: Option, + }, + PlaylistTracksAdded { + playlist_id: i64, + playlist_title: String, + result: Result<(), String>, + }, } diff --git a/src/app/login.rs b/src/app/login.rs index 81bc5c7..efde19f 100644 --- a/src/app/login.rs +++ b/src/app/login.rs @@ -107,9 +107,7 @@ fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> { } fn is_typing(key: KeyEvent) -> bool { - key.modifiers - .difference(KeyModifiers::SHIFT) - .is_empty() + key.modifiers.difference(KeyModifiers::SHIFT).is_empty() } fn focused_text(form: &mut LoginForm) -> Option<&mut String> { diff --git a/src/app/mod.rs b/src/app/mod.rs index 9e308fe..33cdb8f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,6 +3,7 @@ mod cmdline; pub mod command; pub mod event; mod login; +mod popup; mod sso; pub mod state; pub mod update; @@ -28,6 +29,7 @@ use state::{AppState, Screen}; use update::{Effect, update}; const TICK_INTERVAL: Duration = Duration::from_millis(250); +const DEVICE_POLL_INTERVAL: Duration = Duration::from_millis(500); /// Handles shared by background tasks; AppState stays pure UI data. pub struct Runtime { @@ -43,6 +45,9 @@ pub struct Runtime { pub last_state_push: Option, pub media_tx: std::sync::mpsc::Sender, pub last_media_push: Option, + pub device_id: String, + pub last_device_poll: Option, + pub device_poll_in_flight: bool, } pub async fn run( @@ -53,10 +58,12 @@ pub async fn run( mut event_rx: mpsc::UnboundedReceiver, media_tx: std::sync::mpsc::Sender, ) -> Result<()> { + let device_id = crate::config::load_or_create_device_id(); let mut state = AppState { status_message: startup_warning, ..AppState::default() }; + state.devices.device_id = device_id.clone(); let player_events = event_tx.clone(); let mut runtime = Runtime { @@ -72,6 +79,9 @@ pub async fn run( last_state_push: None, media_tx, last_media_push: None, + device_id, + last_device_poll: None, + device_poll_in_flight: false, }; match auth::load_session() { @@ -100,10 +110,22 @@ pub async fn run( 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() { + if state.player.current.is_some() && state.devices.is_playback_device() { state.player.position_secs = runtime.player.shared.position().as_secs_f64(); state.player.paused = runtime.player.shared.paused(); + } else if state.player.current.is_some() + && state.player.playing + && !state.player.paused + { + state.player.position_secs += TICK_INTERVAL.as_secs_f64(); + if let Some(track) = &state.player.current { + if track.duration_seconds > 0.0 { + state.player.position_secs = + state.player.position_secs.min(track.duration_seconds); + } + } } + maybe_poll_devices(&state, &mut runtime); maybe_prefetch_next(&mut state, &runtime); maybe_push_state(&state, &mut runtime); push_media_update(&state, &mut runtime, false); @@ -144,8 +166,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { // Keep at least a full screen plus a margin loaded, and stay ahead // of the cursor: a big terminal fills itself on startup without any // scrolling, page after page. - let needed = artist_grid_capacity() - .max(global.selected + ARTISTS_PREFETCH_MARGIN) + let needed = artist_grid_capacity().max(global.selected + ARTISTS_PREFETCH_MARGIN) + ARTISTS_PREFETCH_MARGIN; if global.has_more && !global.loading @@ -185,8 +206,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { }); } - // Playlists tab data. - if state.active_tab == state::Tab::Playlists { + // Playlists tab data (also wanted while the add-to-playlist picker is + // open from any tab). + let picker_open = matches!(state.popup, Some(state::Popup::AddToPlaylist { .. })); + if state.active_tab == state::Tab::Playlists || picker_open { if state.playlists.list.is_none() { state.playlists.list = Some(state::Loadable::Loading); let api = Arc::clone(&api); @@ -202,8 +225,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { } 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) + if let std::collections::hash_map::Entry::Vacant(entry) = state.playlist_views.entry(id) { entry.insert(state::Loadable::Loading); let api = Arc::clone(&api); @@ -317,6 +339,63 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { } } +fn maybe_poll_devices(state: &AppState, runtime: &mut Runtime) { + if state.screen != Screen::Main || runtime.device_poll_in_flight { + return; + } + let Some(api) = runtime.api.clone() else { + return; + }; + let due = runtime + .last_device_poll + .is_none_or(|at| at.elapsed() >= DEVICE_POLL_INTERVAL); + if !due { + return; + } + + runtime.last_device_poll = Some(std::time::Instant::now()); + runtime.device_poll_in_flight = true; + let device_id = runtime.device_id.clone(); + let playback_state = state + .devices + .is_playback_device() + .then(|| device_playback_state(state)) + .flatten(); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.poll_device(&device_id, playback_state).await { + Ok(response) => AppEvent::DevicesPolled(Ok(response)), + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::DevicesPolled(Err(err.to_string())), + }; + let _ = tx.send(event); + }); +} + +fn device_playback_state(state: &AppState) -> Option { + let player = &state.player; + let current = player.current.as_ref()?; + if player.queue.is_empty() { + return None; + } + Some(crate::api::models::DevicePlaybackState { + track: serde_json::to_value(current).ok(), + tracks: player + .queue + .iter() + .filter_map(|track| serde_json::to_value(track).ok()) + .collect(), + index: player.queue_pos as i32, + position_seconds: player.position_secs, + duration_seconds: current.duration_seconds, + paused: player.paused || !player.playing, + shuffle: player.shuffle, + repeat_mode: player.repeat.label().to_string(), + volume: f64::from(player.volume) / 100.0, + updated_at_ms: 0, + }) +} + fn spawn_art_fetch( runtime: &Runtime, api: Arc, @@ -352,6 +431,9 @@ fn spawn_art_fetch( /// Execute a side effect requested by update(). fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { + if perform_remote_effect(state, runtime, effect) { + return; + } match effect { Effect::PlayCurrent => { play_current(state, runtime); @@ -370,9 +452,14 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { 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)); + runtime + .player + .seek(std::time::Duration::from_secs_f64(target)); } Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)), + Effect::SetOptions => { + push_state_now(state, runtime); + } Effect::EnqueueRelease { id, next } => { let Some(api) = runtime.api.clone() else { return; @@ -413,9 +500,99 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } } +fn perform_remote_effect(state: &mut AppState, runtime: &Runtime, effect: Effect) -> bool { + let Some(target) = state.devices.remote_target_id().map(str::to_string) else { + return false; + }; + match effect { + Effect::PlayCurrent => { + if let Some(payload) = + device_playback_state(state).and_then(|state| serde_json::to_value(state).ok()) + { + send_device_command(runtime, target, "play_from_index", payload); + state.status_message = Some("sent play command to active device".into()); + } + true + } + Effect::TogglePause => { + let command = if state.player.paused { + "pause" + } else { + "resume" + }; + send_device_command(runtime, target, command, serde_json::json!({})); + true + } + Effect::StopPlayback => { + send_device_command(runtime, target, "queue_clear", serde_json::json!({})); + true + } + Effect::SeekBy(delta) => { + let target_time = (state.player.position_secs + delta as f64).max(0.0); + state.player.position_secs = target_time; + send_device_command( + runtime, + target, + "seek", + serde_json::json!({ "time": target_time }), + ); + true + } + Effect::SetVolume(volume) => { + send_device_command( + runtime, + target, + "set_volume", + serde_json::json!({ "volume": f64::from(volume) / 100.0 }), + ); + true + } + Effect::SetOptions => { + send_device_command( + runtime, + target, + "set_options", + serde_json::json!({ + "shuffle": state.player.shuffle, + "repeat_mode": state.player.repeat.label(), + }), + ); + true + } + Effect::EnqueueRelease { .. } | Effect::ToggleLike { .. } => false, + } +} + +fn send_device_command( + runtime: &Runtime, + target_device_id: String, + command: &'static str, + payload: serde_json::Value, +) { + let Some(api) = runtime.api.clone() else { + return; + }; + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api + .send_device_command(Some(&target_device_id), command, &payload) + .await + { + Ok(()) => AppEvent::StatusMessage(format!("sent {command} to active device")), + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::StatusMessage(format!("device command failed: {err}")), + }; + let _ = tx.send(event); + }); +} + /// 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) { + start_current_audio(state, runtime, 0.0, false); +} + +fn start_current_audio(state: &mut AppState, runtime: &Runtime, position_secs: f64, paused: bool) { let Some(track) = state.player.queue.get(state.player.queue_pos).cloned() else { return; }; @@ -424,7 +601,7 @@ fn play_current(state: &mut AppState, runtime: &Runtime) { }; // 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 { + if state.player.playing && previous.id != track.id { report_history( runtime, previous.id, @@ -436,19 +613,29 @@ fn play_current(state: &mut AppState, runtime: &Runtime) { } state.player.current = Some(track.clone()); state.player.playing = true; - state.player.paused = false; - state.player.position_secs = 0.0; + state.player.paused = paused; + state.player.position_secs = position_secs.max(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())); - report_now_playing(runtime, track.id); + if !paused { + report_now_playing(runtime, track.id); + } 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), + Ok((reader, byte_len)) => { + controller.play(reader, byte_len, volume); + if position_secs > 0.0 { + controller.seek(std::time::Duration::from_secs_f64(position_secs)); + } + if paused { + controller.pause(); + } + } Err(ApiError::SessionExpired) => { let _ = tx.send(AppEvent::SessionExpired); } @@ -464,6 +651,9 @@ fn play_current(state: &mut AppState, runtime: &Runtime) { /// device gap. fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) { const PREFETCH_MARGIN_SECS: f64 = 30.0; + if !state.devices.is_playback_device() { + return; + } let player = &state.player; if !player.playing || player.paused || player.prefetched_pos.is_some() { return; @@ -504,7 +694,7 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) { /// 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 { + if !state.player.playing || !state.devices.is_playback_device() { return; } let due = runtime @@ -601,7 +791,9 @@ fn spawn_session_check(runtime: &Runtime, api: Arc) { } Err(err) => { tracing::warn!(%err, "session check failed"); - let _ = tx.send(AppEvent::StatusMessage(format!("server unreachable: {err}"))); + let _ = tx.send(AppEvent::StatusMessage(format!( + "server unreachable: {err}" + ))); } } }); @@ -622,12 +814,14 @@ fn handle_terminal_event( } match state.screen { Screen::Login => login::handle_key(state, runtime, key), + Screen::Main if state.popup.is_some() => popup::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.popup.is_some() => popup::handle_paste(state, &pasted), Screen::Main if state.cmdline.active => cmdline::handle_paste(state, runtime, &pasted), Screen::Main => {} }, @@ -635,12 +829,19 @@ fn handle_terminal_event( } } -fn handle_main_key(state: &mut AppState, keymap: &mut Keymap, runtime: &mut Runtime, key: KeyEvent) { +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"); + // trace, not debug: on the Logs tab every keypress would + // otherwise append a line and pollute what's being read. + tracing::trace!(?action, "key resolved"); // Logout needs the Runtime, which pure update() never touches. if action == action::Action::Logout { perform_logout(state, runtime); @@ -667,6 +868,8 @@ fn perform_logout(state: &mut AppState, runtime: &mut Runtime) { }); } auth::delete_session(); + runtime.last_device_poll = None; + runtime.device_poll_in_flight = false; runtime.player.stop(); state.player = state::PlayerBar::default(); state.user = None; @@ -689,6 +892,12 @@ fn reset_library_state(state: &mut AppState) { state.queue_tab = state::QueueTab::default(); state.pending_release_focus = None; state.jump_origin = None; + state.popup = None; + let device_id = state.devices.device_id.clone(); + state.devices = state::DevicesState { + device_id, + ..state::DevicesState::default() + }; state.likes.clear(); state.likes_loaded = false; state.search = state::SearchState::default(); @@ -696,6 +905,296 @@ fn reset_library_state(state: &mut AppState) { state.art.clear(); } +fn apply_devices_response( + state: &mut AppState, + runtime: &mut Runtime, + response: crate::api::models::DevicePollResponse, + from_activation: bool, +) { + let was_playback_device = state.devices.is_playback_device(); + state.devices.device_id = response.device_id; + state.devices.active_device_id = response.active_device_id; + state.devices.devices = response.devices; + state.devices.poll_error = None; + if from_activation { + state.devices.switching_to = None; + if matches!(state.popup, Some(state::Popup::Devices { .. })) { + state.popup = None; + } + } + + let is_playback_device = state.devices.is_playback_device(); + if was_playback_device && !is_playback_device { + runtime.player.stop(); + } + + if !is_playback_device { + if let Some(playback_state) = &response.playback_state { + apply_device_playback_state(state, runtime, playback_state, false); + } else { + runtime.player.stop(); + } + } else if from_activation { + if let Some(playback_state) = &response.playback_state { + apply_device_playback_state(state, runtime, playback_state, true); + } + } + + for command in response.commands { + execute_device_command(state, runtime, command); + } +} + +fn apply_device_playback_state( + state: &mut AppState, + runtime: &mut Runtime, + playback_state: &crate::api::models::DevicePlaybackState, + start_audio: bool, +) { + let mut tracks = tracks_from_values(&playback_state.tracks); + let track = playback_state + .track + .as_ref() + .and_then(track_from_value) + .or_else(|| { + usize::try_from(playback_state.index) + .ok() + .and_then(|index| tracks.get(index).cloned()) + }); + if tracks.is_empty() { + if let Some(track) = track.clone() { + tracks.push(track); + } + } + let mut index = usize::try_from(playback_state.index).unwrap_or(0); + if let Some(track) = &track { + index = tracks + .iter() + .position(|item| item.id == track.id) + .unwrap_or(index); + } + if !tracks.is_empty() { + index = index.min(tracks.len() - 1); + } else { + index = 0; + } + + state.player.queue = tracks; + state.player.queue_pos = index; + state.player.current = track.or_else(|| state.player.queue.get(index).cloned()); + state.player.playing = state.player.current.is_some(); + state.player.paused = playback_state.paused; + state.player.position_secs = playback_state.position_seconds.max(0.0); + state.player.prefetched_pos = None; + state.player.original_order = None; + state.player.shuffle = playback_state.shuffle; + state.player.repeat = repeat_from_label(&playback_state.repeat_mode); + state.player.volume = volume_percent(playback_state.volume); + state.queue_tab.cursor = state + .queue_tab + .cursor + .min(state.player.queue.len().saturating_sub(1)); + + if start_audio && state.player.current.is_some() { + start_current_audio( + state, + runtime, + playback_state.position_seconds, + playback_state.paused, + ); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } else { + runtime.player.stop(); + } +} + +fn track_from_value(value: &serde_json::Value) -> Option { + serde_json::from_value(value.clone()) + .map_err(|err| tracing::warn!(%err, "invalid track in device payload")) + .ok() +} + +fn tracks_from_values(values: &[serde_json::Value]) -> Vec { + values.iter().filter_map(track_from_value).collect() +} + +fn repeat_from_label(label: &str) -> state::RepeatMode { + match label { + "one" => state::RepeatMode::One, + "all" => state::RepeatMode::All, + _ => state::RepeatMode::Off, + } +} + +fn volume_percent(volume: f64) -> u8 { + (volume.clamp(0.0, 1.0) * 100.0).round() as u8 +} + +fn payload_playback_state(payload: &serde_json::Value) -> crate::api::models::DevicePlaybackState { + serde_json::from_value(payload.clone()).unwrap_or_default() +} + +fn payload_tracks(payload: &serde_json::Value) -> Vec { + if let Some(values) = payload.get("tracks").and_then(serde_json::Value::as_array) { + let tracks = tracks_from_values(values); + if !tracks.is_empty() { + return tracks; + } + } + payload + .get("track") + .and_then(track_from_value) + .into_iter() + .collect() +} + +fn payload_index(payload: &serde_json::Value, key: &str) -> Option { + payload + .get(key) + .and_then(serde_json::Value::as_i64) + .and_then(|value| usize::try_from(value).ok()) +} + +fn payload_f64(payload: &serde_json::Value, key: &str) -> Option { + payload.get(key).and_then(serde_json::Value::as_f64) +} + +fn execute_device_command( + state: &mut AppState, + runtime: &mut Runtime, + command: crate::api::models::DeviceCommandDto, +) { + let payload = command.payload; + tracing::debug!(command = %command.command, id = ?command.id, "device command"); + match command.command.as_str() { + "transfer_state" | "play_track" | "play_from_index" => { + let playback_state = payload_playback_state(&payload); + let start_audio = state.devices.is_playback_device(); + apply_device_playback_state(state, runtime, &playback_state, start_audio); + } + "pause" => { + state.player.paused = true; + runtime.player.pause(); + push_media_update(state, runtime, true); + } + "resume" | "play" => { + state.player.paused = false; + runtime.player.resume(); + push_media_update(state, runtime, true); + } + "seek" => { + if let Some(time) = + payload_f64(&payload, "time").or_else(|| payload_f64(&payload, "position_seconds")) + { + state.player.position_secs = time.max(0.0); + runtime.player.seek(std::time::Duration::from_secs_f64( + state.player.position_secs, + )); + } + } + "next" => { + apply_options_payload(state, &payload); + if let Some(effect) = update::update(state, action::Action::NextTrack) { + perform_effect(state, runtime, effect); + } + } + "prev" | "previous" => { + if let Some(effect) = update::update(state, action::Action::PrevTrack) { + perform_effect(state, runtime, effect); + } + } + "set_volume" | "volume" => { + if let Some(volume) = payload_f64(&payload, "volume") { + state.player.volume = volume_percent(volume); + runtime + .player + .set_volume(player::amplitude(state.player.volume)); + } + } + "set_options" => apply_options_payload(state, &payload), + "queue_add_end" => { + update::enqueue_tracks(state, payload_tracks(&payload), false); + } + "queue_add_next" => { + update::enqueue_tracks(state, payload_tracks(&payload), true); + } + "queue_remove" => { + if let Some(index) = payload_index(&payload, "index") { + remove_queue_index(state, runtime, index); + } + } + "queue_move" => { + if let (Some(from), Some(to)) = ( + payload_index(&payload, "from_index"), + payload_index(&payload, "to_index"), + ) { + move_queue_index(state, from, to); + } + } + "queue_clear" => { + state.player = state::PlayerBar::default(); + state.queue_tab.cursor = 0; + runtime.player.stop(); + push_media_update(state, runtime, true); + } + _ => {} + } +} + +fn apply_options_payload(state: &mut AppState, payload: &serde_json::Value) { + if let Some(shuffle) = payload.get("shuffle").and_then(serde_json::Value::as_bool) { + if shuffle != state.player.shuffle { + state.player.shuffle = shuffle; + if shuffle { + update::shuffle_upcoming(&mut state.player); + } else { + update::restore_queue_order(&mut state.player); + } + } + } + if let Some(repeat) = payload + .get("repeat_mode") + .and_then(serde_json::Value::as_str) + { + state.player.repeat = repeat_from_label(repeat); + } +} + +fn remove_queue_index(state: &mut AppState, runtime: &Runtime, index: usize) { + if index >= state.player.queue.len() { + return; + } + let current_id = state.player.current.as_ref().map(|track| track.id); + state.player.queue.remove(index); + if state.player.queue.is_empty() { + state.player = state::PlayerBar::default(); + runtime.player.stop(); + return; + } + state.player.queue_pos = current_id + .and_then(|id| state.player.queue.iter().position(|track| track.id == id)) + .unwrap_or_else(|| state.player.queue_pos.min(state.player.queue.len() - 1)); + state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); + state.queue_tab.cursor = state.queue_tab.cursor.min(state.player.queue.len() - 1); +} + +fn move_queue_index(state: &mut AppState, from: usize, to: usize) { + if from >= state.player.queue.len() || to >= state.player.queue.len() || from == to { + return; + } + let current_id = state.player.current.as_ref().map(|track| track.id); + let track = state.player.queue.remove(from); + state.player.queue.insert(to, track); + if let Some(id) = current_id { + if let Some(position) = state.player.queue.iter().position(|track| track.id == id) { + state.player.queue_pos = position; + } + } + state.queue_tab.cursor = to; + state.player.prefetched_pos = None; +} + fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) { match event { AppEvent::StatusMessage(message) => state.status_message = Some(message), @@ -706,6 +1205,8 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent 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))); + runtime.last_device_poll = None; + runtime.device_poll_in_flight = false; state.login = state::LoginForm::default(); state.screen = Screen::Main; } @@ -727,6 +1228,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } } AppEvent::SessionExpired => { + runtime.device_poll_in_flight = false; state.user = None; state.login = state::LoginForm::default(); if let Some(api) = runtime.api.take() { @@ -892,8 +1394,46 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent "like removed".to_string() }); } + AppEvent::DevicesPolled(result) => { + runtime.device_poll_in_flight = false; + match result { + Ok(response) => apply_devices_response(state, runtime, response, false), + Err(message) => { + tracing::warn!(%message, "device poll failed"); + state.devices.poll_error = Some(message); + } + } + } + AppEvent::DeviceActivated(result) => match result { + Ok(response) => apply_devices_response(state, runtime, response, true), + Err(message) => { + tracing::warn!(%message, "device activation failed"); + state.devices.switching_to = None; + state.devices.poll_error = Some(message.clone()); + state.status_message = Some(format!("device switch failed: {message}")); + } + }, AppEvent::EnqueueTracks { tracks, next } => { let count = tracks.len(); + if let Some(target) = state.devices.remote_target_id().map(str::to_string) { + let payload = serde_json::json!({ "tracks": tracks }); + send_device_command( + runtime, + target, + if next { + "queue_add_next" + } else { + "queue_add_end" + }, + payload, + ); + state.status_message = Some(if next { + format!("{count} tracks queued next on active device") + } else { + format!("{count} tracks queued on active device") + }); + return; + } update::enqueue_tracks(state, tracks, next); state.status_message = Some(if next { format!("{count} tracks queued next") @@ -901,6 +1441,59 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent format!("{count} tracks queued") }); } + AppEvent::PlaylistCreated { result, add_track } => match result { + Ok(playlist) => { + tracing::info!(title = %playlist.title, "playlist created"); + state.status_message = Some(format!("playlist \"{}\" created", playlist.title)); + state.popup = None; + // The list is stale; refetch when next needed. + state.playlists.list = None; + if let Some(track) = add_track { + let Some(api) = runtime.api.clone() else { + return; + }; + let tx = runtime.event_tx.clone(); + let (id, title) = (playlist.id, playlist.title.clone()); + tokio::spawn(async move { + let result = api + .add_tracks_to_playlist(id, &[track.id]) + .await + .map_err(|e| e.to_string()); + let _ = tx.send(AppEvent::PlaylistTracksAdded { + playlist_id: id, + playlist_title: title, + result, + }); + }); + } + } + Err(message) => { + tracing::warn!(%message, "playlist creation failed"); + state.status_message = Some(format!("create failed: {message}")); + if let Some(state::Popup::NewPlaylist { busy, .. }) = &mut state.popup { + *busy = false; + } + } + }, + AppEvent::PlaylistTracksAdded { + playlist_id, + playlist_title, + result, + } => { + state.popup = None; + match result { + Ok(()) => { + state.status_message = Some(format!("added to \"{playlist_title}\"")); + // Counts and contents changed; refetch lazily. + state.playlist_views.remove(&playlist_id); + state.playlists.list = None; + } + Err(message) => { + tracing::warn!(%message, playlist_id, "adding to playlist failed"); + state.status_message = Some(format!("add failed: {message}")); + } + } + } AppEvent::Media(command) => { use crate::media::MediaCommand; tracing::debug!(?command, "media key"); diff --git a/src/app/popup.rs b/src/app/popup.rs new file mode 100644 index 0000000..0b6cbfc --- /dev/null +++ b/src/app/popup.rs @@ -0,0 +1,228 @@ +//! Modal dialog input: the add-to-playlist picker and new-playlist name +//! entry. The popup is taken out of the state, handled as an owned value +//! and put back unless the action closed it. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::api::models::TrackItem; +use crate::app::Runtime; +use crate::app::event::AppEvent; +use crate::app::state::{AppState, Popup, addable_playlists}; + +pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { + let Some(popup) = state.popup.take() else { + return; + }; + match popup { + Popup::AddToPlaylist { track, cursor } => { + handle_picker(state, runtime, track, cursor, key); + } + Popup::NewPlaylist { + for_track, + input, + busy, + } => handle_name_entry(state, runtime, for_track, input, busy, key), + Popup::Devices { cursor } => handle_devices(state, runtime, cursor, key), + Popup::LogDetail(entry) => match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} + _ => state.popup = Some(Popup::LogDetail(entry)), + }, + } +} + +/// Pasted text goes into the name field when it is open. +pub fn handle_paste(state: &mut AppState, pasted: &str) { + if let Some(Popup::NewPlaylist { input, busy, .. }) = &mut state.popup { + if !*busy { + input.extend(pasted.chars().filter(|c| !c.is_control())); + } + } +} + +fn handle_devices(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) { + let len = state.devices.devices.len(); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => {} + KeyCode::Up | KeyCode::Char('k') => { + state.popup = Some(Popup::Devices { + cursor: cursor.saturating_sub(1), + }); + } + KeyCode::Down | KeyCode::Char('j') => { + state.popup = Some(Popup::Devices { + cursor: if len == 0 { + 0 + } else { + (cursor + 1).min(len - 1) + }, + }); + } + KeyCode::Enter => { + if let Some(device) = state.devices.devices.get(cursor.min(len.saturating_sub(1))) { + let target = device.id.clone(); + state.devices.switching_to = Some(target.clone()); + state.popup = Some(Popup::Devices { cursor }); + spawn_select_device(runtime, target); + } else { + state.popup = Some(Popup::Devices { cursor: 0 }); + } + } + _ => { + state.popup = Some(Popup::Devices { + cursor: cursor.min(len.saturating_sub(1)), + }) + } + } +} + +fn handle_picker( + state: &mut AppState, + runtime: &Runtime, + track: TrackItem, + cursor: usize, + key: KeyEvent, +) { + let options = addable_playlists(state); + match key.code { + KeyCode::Esc => {} + KeyCode::Up | KeyCode::Char('k') => { + state.popup = Some(Popup::AddToPlaylist { + track, + cursor: cursor.saturating_sub(1), + }); + } + KeyCode::Down | KeyCode::Char('j') => { + state.popup = Some(Popup::AddToPlaylist { + track, + cursor: (cursor + 1).min(options.len()), + }); + } + KeyCode::Enter => { + if cursor == 0 { + state.popup = Some(Popup::NewPlaylist { + for_track: Some(track), + input: String::new(), + busy: false, + }); + } else if let Some((id, title)) = options.get(cursor - 1).cloned() { + spawn_add_track(runtime, id, title, track); + } + } + _ => state.popup = Some(Popup::AddToPlaylist { track, cursor }), + } +} + +fn handle_name_entry( + state: &mut AppState, + runtime: &Runtime, + for_track: Option, + mut input: String, + busy: bool, + key: KeyEvent, +) { + if busy { + state.popup = Some(Popup::NewPlaylist { + for_track, + input, + busy, + }); + return; + } + match key.code { + KeyCode::Esc => { + // Reached from the picker → step back to it; otherwise close. + if let Some(track) = for_track { + state.popup = Some(Popup::AddToPlaylist { track, cursor: 0 }); + } + } + KeyCode::Enter => { + let title = input.trim().to_string(); + if title.is_empty() { + state.status_message = Some("playlist name is empty".into()); + state.popup = Some(Popup::NewPlaylist { + for_track, + input, + busy: false, + }); + return; + } + spawn_create_playlist(runtime, title, for_track.clone()); + state.popup = Some(Popup::NewPlaylist { + for_track, + input, + busy: true, + }); + } + KeyCode::Backspace => { + input.pop(); + state.popup = Some(Popup::NewPlaylist { + for_track, + input, + busy: false, + }); + } + KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + input.push(c); + state.popup = Some(Popup::NewPlaylist { + for_track, + input, + busy: false, + }); + } + _ => { + state.popup = Some(Popup::NewPlaylist { + for_track, + input, + busy: false, + }); + } + } +} + +fn spawn_select_device(runtime: &Runtime, target_device_id: String) { + let Some(api) = runtime.api.clone() else { + return; + }; + let current_device_id = runtime.device_id.clone(); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api + .select_device(&target_device_id, ¤t_device_id) + .await + { + Ok(response) => AppEvent::DeviceActivated(Ok(response)), + Err(crate::api::client::ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::DeviceActivated(Err(err.to_string())), + }; + let _ = tx.send(event); + }); +} + +fn spawn_add_track(runtime: &Runtime, playlist_id: i64, playlist_title: String, track: TrackItem) { + let Some(api) = runtime.api.clone() else { + return; + }; + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = api + .add_tracks_to_playlist(playlist_id, &[track.id]) + .await + .map_err(|e| e.to_string()); + let _ = tx.send(AppEvent::PlaylistTracksAdded { + playlist_id, + playlist_title, + result, + }); + }); +} + +fn spawn_create_playlist(runtime: &Runtime, title: String, add_track: Option) { + let Some(api) = runtime.api.clone() else { + return; + }; + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = api.create_playlist(&title).await.map_err(|e| e.to_string()); + let _ = tx.send(AppEvent::PlaylistCreated { result, add_track }); + }); +} diff --git a/src/app/state.rs b/src/app/state.rs index 32c462f..309246a 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use crate::api::models::{ - ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, + ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, SearchResults, TrackItem, User, }; use crate::art::ArtImage; @@ -57,10 +57,18 @@ pub enum ArtState { 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 }, + Artist { + id: i64, + cursor: usize, + }, + Release { + id: i64, + cursor: usize, + }, /// Linear cursor over search results: artists, then releases, then tracks. - Search { cursor: usize }, + Search { + cursor: usize, + }, } /// The Global tab: the whole server library of artists. @@ -194,8 +202,9 @@ pub struct LogsTab { pub level_index: usize, /// Stick to the newest entries as they arrive. pub follow: bool, - /// When not following: how many (filtered) entries back from the end. - pub scroll_from_end: usize, + /// Cursor anchored to a specific entry's seq; appends never move it. + /// None = newest (follow mode). + pub selected_seq: Option, } impl Default for LogsTab { @@ -203,11 +212,74 @@ impl Default for LogsTab { Self { level_index: 2, follow: true, - scroll_from_end: 0, + selected_seq: None, } } } +#[derive(Debug, Default)] +pub struct DevicesState { + pub device_id: String, + pub active_device_id: Option, + pub devices: Vec, + pub poll_error: Option, + pub switching_to: Option, +} + +impl DevicesState { + pub fn is_playback_device(&self) -> bool { + self.active_device_id + .as_deref() + .is_none_or(|active| active == self.device_id) + } + + pub fn remote_target_id(&self) -> Option<&str> { + self.active_device_id + .as_deref() + .filter(|active| *active != self.device_id) + } + + pub fn active_device_name(&self) -> Option<&str> { + let active = self.active_device_id.as_deref()?; + self.devices + .iter() + .find(|device| device.id == active) + .map(|device| device.name.as_str()) + } +} + +/// Modal dialog over the main screen. +#[derive(Debug)] +pub enum Popup { + /// Pick one of the user's playlists (row 0 = "create new"); the track + /// is added on Enter. + AddToPlaylist { track: TrackItem, cursor: usize }, + /// Name input for a new playlist; when `for_track` is set, the track is + /// added to it right after creation. + NewPlaylist { + for_track: Option, + input: String, + busy: bool, + }, + /// Connected devices list; Enter transfers active playback to the row. + Devices { cursor: usize }, + /// Full, wrapped view of one log entry (Enter on the Logs tab). + LogDetail(crate::config::logging::LogEntry), +} + +/// User's own playlists eligible as add-targets (the virtual Likes playlist +/// is managed through likes, not direct adds). +pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> { + match &state.playlists.list { + Some(Loadable::Ready(list)) => list + .iter() + .filter(|p| p.is_own && p.kind != "likes") + .map(|p| (p.id, p.title.clone())) + .collect(), + _ => Vec::new(), + } +} + /// Command line (`:`), vim-style. Lives on the Main screen status bar. #[derive(Debug, Default)] pub struct Cmdline { @@ -445,6 +517,7 @@ pub struct AppState { pub likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, + pub devices: DevicesState, pub queue_tab: QueueTab, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. @@ -453,6 +526,7 @@ pub struct AppState { /// view): Esc from that view returns to the origin tab instead of /// unwinding the Global stack. pub jump_origin: Option<(Tab, usize)>, + pub popup: Option, pub cmdline: Cmdline, pub search: SearchState, /// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by diff --git a/src/app/update.rs b/src/app/update.rs index 05c65c0..0ee93e9 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -4,7 +4,7 @@ use super::action::Action; use crate::api::models::TrackItem; use super::state::{ - AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, Tab, TILE_HEIGHT, TILE_WIDTH, + AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab, ViewMode, release_display_order, release_rows, }; @@ -22,9 +22,15 @@ pub enum Effect { /// Seek relative to the current position, in seconds. SeekBy(i64), SetVolume(u8), + SetOptions, /// Fetch a release and append all its tracks to the queue. - EnqueueRelease { id: i64, next: bool }, - ToggleLike { track_id: i64 }, + EnqueueRelease { + id: i64, + next: bool, + }, + ToggleLike { + track_id: i64, + }, } pub fn update(state: &mut AppState, action: Action) -> Option { @@ -35,6 +41,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option { .is_some_and(|deadline| Instant::now() <= deadline); state.status_message = None; match action { + // While the help window is open, quit/back just close it. + Action::Quit | Action::Back if state.help_visible => { + state.help_visible = false; + None + } Action::Quit => { if quit_armed { state.should_quit = true; @@ -48,10 +59,6 @@ pub fn update(state: &mut AppState, action: Action) -> Option { state.help_visible = !state.help_visible; None } - Action::Back if state.help_visible => { - state.help_visible = false; - None - } Action::NextTab => { switch_tab(state, state.active_tab.next()); None @@ -85,9 +92,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } Action::NextTrack => queue_step(state, 1), Action::PrevTrack => queue_step(state, -1), - Action::SeekForward { seconds } => { - state.player.current.is_some().then_some(Effect::SeekBy(seconds as i64)) - } + Action::SeekForward { seconds } => state + .player + .current + .is_some() + .then_some(Effect::SeekBy(seconds as i64)), Action::SeekBackward { seconds } => state .player .current @@ -111,11 +120,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } else { restore_queue_order(&mut state.player); } - None + Some(Effect::SetOptions) } Action::CycleRepeat => { state.player.repeat = state.player.repeat.next(); - None + Some(Effect::SetOptions) } Action::MoveUp => { move_selection(state, 0, -1); @@ -156,7 +165,7 @@ pub fn update(state: &mut AppState, action: Action) -> Option { Tab::Logs => { state.logs.level_index = (state.logs.level_index + 1) % super::state::LOG_LEVELS.len(); - state.logs.scroll_from_end = 0; + state.logs.selected_seq = None; state.logs.follow = true; } _ => {} @@ -168,15 +177,41 @@ pub fn update(state: &mut AppState, action: Action) -> Option { state.cmdline.input.clear(); None } + Action::OpenSearch => { + // The command line opens pre-filled with "/": typing continues + // the live search, exactly as if `:` then `/` were pressed. + state.cmdline.active = true; + state.cmdline.input = "/".to_string(); + state.cmdline.live = true; + state.search = SearchState::default(); + state.active_tab = Tab::Global; + if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) { + state.global.stack.push(GlobalView::Search { cursor: 0 }); + } + None + } + Action::OpenDevices => { + let cursor = state + .devices + .devices + .iter() + .position(|device| { + device.id == state.devices.active_device_id.clone().unwrap_or_default() + }) + .unwrap_or(0); + state.popup = Some(super::state::Popup::Devices { cursor }); + None + } Action::Select => select_current(state), Action::Back => { go_back(state); None } Action::ToggleLike => { - let target = selected_track(state) - .map(|t| t.id) - .or(state.player.current.as_ref().map(|t| t.id)); + let target = + selected_track(state) + .map(|t| t.id) + .or(state.player.current.as_ref().map(|t| t.id)); match target { Some(track_id) => Some(Effect::ToggleLike { track_id }), None => { @@ -195,6 +230,24 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } None } + Action::AddToPlaylist => { + let track = selected_track(state).or_else(|| state.player.current.clone()); + match track { + Some(track) => { + state.popup = Some(super::state::Popup::AddToPlaylist { track, cursor: 0 }); + } + None => state.status_message = Some("no track selected".into()), + } + None + } + Action::NewPlaylist => { + state.popup = Some(super::state::Popup::NewPlaylist { + for_track: None, + input: String::new(), + busy: false, + }); + None + } Action::ClearQueue => { let had_tracks = !state.player.queue.is_empty(); state.player.queue.clear(); @@ -223,7 +276,16 @@ pub fn selected_track(state: &AppState) -> Option { match state.active_tab { Tab::Global => match state.global.stack.last()? { GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { - Loadable::Ready(detail) => detail.top_tracks.get(*cursor).cloned(), + Loadable::Ready(detail) => { + let tracks = detail.top_tracks.len(); + if *cursor < tracks { + detail.top_tracks.get(*cursor).cloned() + } else { + cursor + .checked_sub(tracks + detail.releases.len()) + .and_then(|i| detail.featured_tracks.get(i).cloned()) + } + } _ => None, }, GlobalView::Release { id, cursor } => match state.release_views.get(id)? { @@ -238,7 +300,9 @@ pub fn selected_track(state: &AppState) -> Option { }, Tab::Playlists => { let opened = state.playlists.opened.as_ref()?; - playlist_tracks(state, opened.id)?.get(opened.cursor).cloned() + playlist_tracks(state, opened.id)? + .get(opened.cursor) + .cloned() } Tab::Queue => state.player.queue.get(state.queue_tab.cursor).cloned(), Tab::Logs => None, @@ -315,7 +379,10 @@ fn open_release_for_track(state: &mut AppState, track: &TrackItem) { let origin = state.active_tab; state.active_tab = Tab::Global; match state.global.stack.last_mut() { - Some(GlobalView::Release { id, cursor: current }) if *id == release_id => { + Some(GlobalView::Release { + id, + cursor: current, + }) if *id == release_id => { *current = cursor; } _ => state.global.stack.push(GlobalView::Release { @@ -478,7 +545,9 @@ pub fn restore_queue_order(player: &mut super::state::PlayerBar) { }) .collect(); keyed.sort_by_key(|(key, position, _)| (*key, *position)); - player.queue.extend(keyed.into_iter().map(|(_, _, track)| track)); + player + .queue + .extend(keyed.into_iter().map(|(_, _, track)| track)); player.prefetched_pos = None; } @@ -513,14 +582,17 @@ fn page_step(state: &AppState) -> isize { ViewMode::Table => lines, }, Some(GlobalView::Artist { id, cursor }) => { - let in_tracks = match state.artist_views.get(id) { - Some(Loadable::Ready(detail)) => *cursor < detail.top_tracks.len(), - _ => true, + let in_release_tiles = match state.artist_views.get(id) { + Some(Loadable::Ready(detail)) => { + *cursor >= detail.top_tracks.len() + && *cursor < detail.top_tracks.len() + detail.releases.len() + } + _ => false, }; - if in_tracks || state.global.view == ViewMode::Table { - lines - } else { + if in_release_tiles && state.global.view == ViewMode::Tiles { tile_rows + } else { + lines } } Some(GlobalView::Release { .. }) | Some(GlobalView::Search { .. }) => lines, @@ -529,15 +601,21 @@ fn page_step(state: &AppState) -> isize { fn move_selection(state: &mut AppState, dx: isize, dy: isize) { if state.active_tab == Tab::Logs { - let total = crate::config::logging::buffer().map_or(0, |b| b.len()); - let logs = &mut state.logs; - if dy < 0 { - logs.follow = false; - logs.scroll_from_end = (logs.scroll_from_end + dy.unsigned_abs()).min(total); - } else if dy > 0 { - logs.scroll_from_end = logs.scroll_from_end.saturating_sub(dy as usize); - if logs.scroll_from_end == 0 { - logs.follow = true; + // The cursor anchors to an entry's seq, so freshly appended log + // lines (including ones caused by this very keypress) don't shift + // the selection. + if dy != 0 { + let level = super::state::LOG_LEVELS[state.logs.level_index]; + if let Some(buffer) = crate::config::logging::buffer() { + let current = if state.logs.follow { + None + } else { + state.logs.selected_seq + }; + if let Some((seq, is_newest)) = buffer.move_selection(level, current, dy) { + state.logs.selected_seq = Some(seq); + state.logs.follow = is_newest && dy > 0; + } } } return; @@ -589,24 +667,23 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { return; }; let tracks = detail.top_tracks.len(); - let total = tracks + detail.releases.len(); + let releases = detail.releases.len(); + let featured = detail.featured_tracks.len(); + let total = tracks + releases + featured; if total == 0 { return; } - let next = if cursor < tracks { - // Top-tracks zone: vertical only; stepping past the last - // track enters the releases zone (its first item). + let in_release_tiles = state.global.view == ViewMode::Tiles + && cursor >= tracks + && cursor < tracks + releases; + let next = if !in_release_tiles { + // List zones (top tracks, featured tracks; releases in + // table mode): plain vertical steps cross zone boundaries + // in flat order. (cursor as isize + dy).clamp(0, total as isize - 1) as usize - } else if state.global.view == ViewMode::Table { - let next = cursor as isize + dy; - if next < tracks as isize && dy < 0 && tracks > 0 { - tracks - 1 - } else { - next.clamp(0, total as isize - 1) as usize - } } else { - // Tiles: move by visual rows (groups break rows), keeping - // the column, so Up lands on the tile directly above. + // Release tiles: move by visual rows (groups break rows), + // keeping the column, so Up lands on the tile above. let rows = release_rows(&detail.releases, grid_columns()); let position = cursor - tracks; let (row, column) = rows @@ -617,18 +694,21 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { }) .unwrap_or((0, 0)); if dx != 0 { - let last = detail.releases.len() as isize - 1; + let last = releases as isize - 1; tracks + (position as isize + dx).clamp(0, last) as usize } else { let target = row as isize + dy; if target < 0 { - if tracks > 0 { - tracks - 1 + if tracks > 0 { tracks - 1 } else { cursor } + } else if target as usize >= rows.len() { + // Below the last release row: the featured section. + if featured > 0 { + tracks + releases } else { cursor } } else { - let items = &rows[(target as usize).min(rows.len() - 1)]; + let items = &rows[target as usize]; tracks + items[column.min(items.len() - 1)] } } @@ -688,7 +768,9 @@ fn current_view_len(state: &AppState) -> usize { match state.global.stack.last() { None => state.global.artists.len(), Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { - Some(Loadable::Ready(d)) => d.top_tracks.len() + d.releases.len(), + Some(Loadable::Ready(d)) => { + d.top_tracks.len() + d.releases.len() + d.featured_tracks.len() + } _ => 0, }, Some(GlobalView::Release { id, .. }) => match state.release_views.get(id) { @@ -709,11 +791,16 @@ fn jump_selection(state: &mut AppState, first: bool) { } if state.active_tab == Tab::Logs { if first { - state.logs.follow = false; - state.logs.scroll_from_end = crate::config::logging::buffer().map_or(0, |b| b.len()); + let level = super::state::LOG_LEVELS[state.logs.level_index]; + if let Some(buffer) = crate::config::logging::buffer() { + if let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) { + state.logs.selected_seq = Some(seq); + state.logs.follow = false; + } + } } else { state.logs.follow = true; - state.logs.scroll_from_end = 0; + state.logs.selected_seq = None; } return; } @@ -768,6 +855,21 @@ fn select_current(state: &mut AppState) -> Option { if state.active_tab == Tab::Playlists { return select_playlist(state); } + // Logs: open the full, wrapped entry under the cursor. + if state.active_tab == Tab::Logs { + let level = super::state::LOG_LEVELS[state.logs.level_index]; + if let Some(buffer) = crate::config::logging::buffer() { + let selected = if state.logs.follow { + None + } else { + state.logs.selected_seq + }; + if let Some(entry) = buffer.entry_at(level, selected) { + state.popup = Some(super::state::Popup::LogDetail(entry)); + } + } + return None; + } // Queue: jump playback to the track under the cursor. Earlier tracks // stay in the queue as "played"; picking one of them just moves the // playing position back. @@ -784,7 +886,10 @@ fn select_current(state: &mut AppState) -> Option { } enum Outcome { Push(GlobalView), - Play { tracks: Vec, start: usize }, + Play { + tracks: Vec, + start: usize, + }, Nothing, } let outcome = match state.global.stack.last().copied() { @@ -798,12 +903,13 @@ fn select_current(state: &mut AppState) -> Option { Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) { Some(Loadable::Ready(detail)) => { let tracks = detail.top_tracks.len(); + let releases = detail.releases.len(); if cursor < tracks { Outcome::Play { tracks: detail.top_tracks.clone(), start: cursor, } - } else { + } else if cursor < tracks + releases { let order = release_display_order(&detail.releases); match order.get(cursor - tracks) { Some(&original) => Outcome::Push(GlobalView::Release { @@ -812,6 +918,17 @@ fn select_current(state: &mut AppState) -> Option { }), None => Outcome::Nothing, } + } else if detail + .featured_tracks + .get(cursor - tracks - releases) + .is_some() + { + Outcome::Play { + tracks: detail.featured_tracks.clone(), + start: cursor - tracks - releases, + } + } else { + Outcome::Nothing } } _ => Outcome::Nothing, @@ -945,7 +1062,7 @@ fn reset_tab(state: &mut AppState, tab: Tab) { Tab::Playlists => state.playlists.opened = None, Tab::Logs => { state.logs.follow = true; - state.logs.scroll_from_end = 0; + state.logs.selected_seq = None; } Tab::Queue => {} } @@ -1104,6 +1221,7 @@ mod tests { total_track_count: 0, total_play_count: 0, top_tracks: vec![], + featured_tracks: vec![], releases: vec![ release(10, "album"), release(11, "album"), @@ -1115,7 +1233,10 @@ mod tests { }; let mut state = AppState::default(); state.artist_views.insert(1, Loadable::Ready(detail)); - state.global.stack.push(GlobalView::Artist { id: 1, cursor: 4 }); + state + .global + .stack + .push(GlobalView::Artist { id: 1, cursor: 4 }); // Up from the first compilation lands on the album row directly // above (position 3), not three flat items back. @@ -1132,7 +1253,10 @@ mod tests { ); // Up from the second compilation clamps to the single tile above. state.global.stack.pop(); - state.global.stack.push(GlobalView::Artist { id: 1, cursor: 5 }); + state + .global + .stack + .push(GlobalView::Artist { id: 1, cursor: 5 }); update(&mut state, Action::MoveUp); assert_eq!( state.global.stack.last(), @@ -1260,7 +1384,10 @@ mod tests { update(&mut state, Action::MoveUp); update(&mut state, Action::MoveUp); assert_eq!(state.queue_tab.cursor, 0); - assert_eq!(update(&mut state, Action::Select), Some(Effect::PlayCurrent)); + assert_eq!( + update(&mut state, Action::Select), + Some(Effect::PlayCurrent) + ); assert_eq!(state.player.queue_pos, 0); assert_eq!(state.player.queue.len(), 3); diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 235f7a5..0dc71a2 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -64,6 +64,15 @@ context = "queue" key_sequence = "shift-j" command = "GoToRelease" +[[keymaps]] +key_sequence = "shift-p" +command = "AddToPlaylist" + +[[keymaps]] +key_sequence = "n" +command = "NewPlaylist" +context = "playlists" + [[keymaps]] key_sequence = "j" command = "MoveDown" @@ -180,6 +189,10 @@ command = "ToggleLike" key_sequence = "shift-l" command = "Logout" +[[keymaps]] +key_sequence = "shift-d" +command = "OpenDevices" + [[keymaps]] key_sequence = "v" command = "ToggleViewMode" @@ -187,3 +200,7 @@ command = "ToggleViewMode" [[keymaps]] key_sequence = ":" command = "OpenCommandLine" + +[[keymaps]] +key_sequence = "/" +command = "OpenSearch" diff --git a/src/config/keymap.rs b/src/config/keymap.rs index 8594781..0e200a7 100644 --- a/src/config/keymap.rs +++ b/src/config/keymap.rs @@ -133,11 +133,11 @@ impl Keymap { } } - /// All bindings as (keys, description, context) for the help view. - pub fn help_entries(&self) -> Vec<(String, String, KeyContext)> { + /// All bindings as (formatted keys, action, context) for the help view. + pub fn help_entries(&self) -> Vec<(String, Action, KeyContext)> { self.bindings .iter() - .map(|b| (self.format_keys(&b.keys), b.action.describe(), b.context)) + .map(|b| (self.format_keys(&b.keys), b.action.clone(), b.context)) .collect() } @@ -231,14 +231,38 @@ fn normalize(key: KeyCombination) -> KeyCombination { /// layout (lowercase in, lowercase out). fn qwerty_equivalent(c: char) -> Option { Some(match c { - 'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't', - 'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p', - 'х' => '[', 'ъ' => ']', - 'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g', - 'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', 'ж' => ';', + 'й' => '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', 'б' => ',', 'ю' => '.', + 'я' => 'z', + 'ч' => 'x', + 'с' => 'c', + 'м' => 'v', + 'и' => 'b', + 'т' => 'n', + 'ь' => 'm', + 'б' => ',', + 'ю' => '.', 'ё' => '`', _ => return None, }) @@ -417,8 +441,7 @@ mod tests { #[test] fn shift_symbol_normalizes() { let mut km = keymap_from(DEFAULT_KEYMAP); - let question_with_shift = - KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT); + let question_with_shift = KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT); assert_eq!( km.resolve(question_with_shift, KeyContext::Library), KeyResolution::Action(Action::ToggleHelp) diff --git a/src/config/logging.rs b/src/config/logging.rs index ae6ea21..b5ee54a 100644 --- a/src/config/logging.rs +++ b/src/config/logging.rs @@ -13,6 +13,9 @@ pub const LOG_CAPACITY: usize = 10_000; #[derive(Debug, Clone)] pub struct LogEntry { + /// Monotonic id; the Logs-tab cursor anchors to it, so appends never + /// move the selection. + pub seq: u64, pub level: tracing::Level, /// HH:MM:SS, UTC (same clock as the log file). pub time: String, @@ -20,13 +23,29 @@ pub struct LogEntry { pub message: String, } +/// What the Logs tab renders: a window of entries around the cursor. +pub struct LogView { + /// Oldest-first window of entries. + pub entries: Vec, + /// Row index of the cursor within `entries`. + pub cursor_row: Option, + /// Total entries matching the level filter. + pub matched: usize, + /// How far the cursor is from the newest matching entry. + pub from_end: usize, +} + #[derive(Default)] pub struct LogBuffer { entries: Mutex>, + next_seq: std::sync::atomic::AtomicU64, } impl LogBuffer { - fn push(&self, entry: LogEntry) { + fn push(&self, mut entry: LogEntry) { + entry.seq = self + .next_seq + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); if entries.len() == LOG_CAPACITY { entries.pop_front(); @@ -34,35 +53,75 @@ impl LogBuffer { 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( + /// Move the cursor `delta` steps among entries matching the filter + /// (negative = older). `current = None` starts from the newest. Returns + /// the new anchor and whether it is the newest matching entry. + pub fn move_selection( &self, max_level: tracing::Level, - skip: usize, - take: usize, - ) -> (Vec, usize) { + current: Option, + delta: isize, + ) -> Option<(u64, bool)> { 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()); - } + let seqs: Vec = entries + .iter() + .filter(|e| e.level <= max_level) + .map(|e| e.seq) + .collect(); + if seqs.is_empty() { + return None; + } + let index = current + .and_then(|seq| seqs.iter().position(|s| *s == seq)) + .unwrap_or(seqs.len() - 1); + let new = (index as i128 + delta as i128).clamp(0, seqs.len() as i128 - 1) as usize; + Some((seqs[new], new == seqs.len() - 1)) + } + + /// The anchored entry (or the newest matching one when `None`). + pub fn entry_at(&self, max_level: tracing::Level, selected: Option) -> Option { + let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + match selected { + Some(seq) => entries + .iter() + .find(|e| e.seq == seq && e.level <= max_level) + .cloned(), + None => entries.iter().rev().find(|e| e.level <= max_level).cloned(), + } + } + + /// Window of up to `visible` entries with the cursor kept centered. + /// Only the window is cloned; the scan is cheap level comparisons. + pub fn view( + &self, + max_level: tracing::Level, + selected: Option, + visible: usize, + ) -> LogView { + let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let matched: Vec<&LogEntry> = entries.iter().filter(|e| e.level <= max_level).collect(); + let total = matched.len(); + if total == 0 || visible == 0 { + return LogView { + entries: Vec::new(), + cursor_row: None, + matched: total, + from_end: 0, + }; + } + let cursor_index = selected + .and_then(|seq| matched.iter().position(|e| e.seq == seq)) + .unwrap_or(total - 1); + let start = cursor_index + .saturating_sub(visible / 2) + .min(total.saturating_sub(visible)); + let end = (start + visible).min(total); + LogView { + entries: matched[start..end].iter().map(|e| (*e).clone()).collect(), + cursor_row: Some(cursor_index - start), + matched: total, + from_end: total - 1 - cursor_index, } - out.reverse(); - (out, matched) } } @@ -86,6 +145,7 @@ impl tracing_subscriber::Layer for MemoryLayer { event.record(&mut MessageVisitor { out: &mut message }); let metadata = event.metadata(); self.buffer.push(LogEntry { + seq: 0, // assigned in push() level: *metadata.level(), time: hms_now(), target: metadata.target().to_string(), @@ -158,7 +218,9 @@ pub fn init() -> Result<()> { Ok(()) } Err(err) => { - tracing_subscriber::registry().with(memory_layer(buffer)).init(); + tracing_subscriber::registry() + .with(memory_layer(buffer)) + .init(); tracing::warn!(%err, "log file unavailable, in-app logs only"); Err(err) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 2f6631e..895dc72 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,7 +2,54 @@ pub mod keymap; pub mod logging; use directories::ProjectDirs; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; pub fn project_dirs() -> Option { ProjectDirs::from("", "", "furumi") } + +pub fn device_id_path() -> Option { + project_dirs().map(|dirs| dirs.config_dir().join("device_id")) +} + +pub fn load_or_create_device_id() -> String { + if let Some(path) = device_id_path() { + if let Ok(raw) = fs::read_to_string(&path) { + let id = raw.trim(); + if valid_device_id(id) { + return id.to_string(); + } + } + + let id = generate_device_id(); + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + tracing::warn!(path = %parent.display(), %err, "failed to create config directory"); + return id; + } + } + if let Err(err) = fs::write(&path, &id) { + tracing::warn!(path = %path.display(), %err, "failed to persist device id"); + } + return id; + } + generate_device_id() +} + +fn valid_device_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') +} + +fn generate_device_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("tui-{nanos:x}-{:x}", std::process::id()) +} diff --git a/src/media.rs b/src/media.rs index ede89b5..8f8fa72 100644 --- a/src/media.rs +++ b/src/media.rs @@ -11,7 +11,9 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::time::Duration; -use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig}; +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)] @@ -158,8 +160,7 @@ fn apply(controls: &mut MediaControls, update: MediaUpdate) { title: Some(&title), artist: Some(&artist), album: Some(&album), - duration: (duration_secs > 0.0) - .then(|| Duration::from_secs_f64(duration_secs)), + duration: (duration_secs > 0.0).then(|| Duration::from_secs_f64(duration_secs)), cover_url: None, }), MediaUpdate::Playback { diff --git a/src/player/mod.rs b/src/player/mod.rs index 4999399..61c2269 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -27,7 +27,9 @@ pub fn amplitude(percent: u8) -> f32 { 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 }, + TrackFinished { + has_next: bool, + }, Failed(String), } @@ -44,6 +46,8 @@ enum Command { byte_len: Option, }, TogglePause, + Pause, + Resume, Stop, Seek(Duration), SetVolume(f32), @@ -92,6 +96,14 @@ impl Controller { let _ = self.tx.send(Command::TogglePause); } + pub fn pause(&self) { + let _ = self.tx.send(Command::Pause); + } + + pub fn resume(&self) { + let _ = self.tx.send(Command::Resume); + } + pub fn stop(&self) { let _ = self.tx.send(Command::Stop); } @@ -140,7 +152,9 @@ fn run(rx: Receiver, shared: Arc, on_event: impl Fn(PlayerEvent shared .position_ms .store(out.player.get_pos().as_millis() as u64, Ordering::Relaxed); - shared.paused.store(out.player.is_paused(), 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 { @@ -223,7 +237,9 @@ fn handle( match builder.build() { Ok(decoder) => out.player.append(decoder), Err(err) => { - on_event(PlayerEvent::Failed(format!("cannot decode next track: {err}"))); + on_event(PlayerEvent::Failed(format!( + "cannot decode next track: {err}" + ))); } } } @@ -236,6 +252,16 @@ fn handle( } } } + Command::Pause => { + if let Some(out) = output { + out.player.pause(); + } + } + Command::Resume => { + if let Some(out) = output { + out.player.play(); + } + } Command::Stop => { if let Some(out) = output { out.player.stop(); diff --git a/src/ui/global.rs b/src/ui/global.rs index e514a03..4b0dd40 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -39,7 +39,11 @@ 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 }; + let middle = Rect { + y: area.y + area.height / 2, + height: 1, + ..area + }; frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle); } @@ -86,21 +90,29 @@ fn draw_tile( let inner = block.inner(tile); frame.render_widget(block, tile); - let art_area = Rect { height: ART_CELL_HEIGHT.min(inner.height), ..inner }; + 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, - ); + 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 }; + 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, @@ -126,7 +138,6 @@ fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option, se } } - // --------------------------------------------------------------------------- // Scrollable content plan: a vertical list of items with known heights; the // viewport is scrolled so the cursor's item stays centered. @@ -137,7 +148,9 @@ enum PlanItem { Header(String), Gap, /// Selectable track row; the payload is the cursor index it represents. - Track { cursor_index: usize }, + Track { + cursor_index: usize, + }, /// One row of release tiles (display-order positions). TileRow(Vec), /// One release as a table row (display-order position). @@ -147,7 +160,9 @@ enum PlanItem { impl PlanItem { fn height(&self) -> u16 { match self { - PlanItem::Header(_) | PlanItem::Gap | PlanItem::Track { .. } + PlanItem::Header(_) + | PlanItem::Gap + | PlanItem::Track { .. } | PlanItem::TableRow(_) => 1, PlanItem::TileRow(_) => TILE_HEIGHT, } @@ -239,23 +254,30 @@ fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) { 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 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)], + [ + Constraint::Min(24), + Constraint::Length(9), + Constraint::Length(7), + ], ) .header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header())); frame.render_widget(table, inner); @@ -294,9 +316,16 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: .areas(header_area); draw_art( frame, - Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, + Rect { + height: ART_HEADER_HEIGHT.min(art_area.height), + ..art_area + }, header_art(state, detail.image_url.as_ref()), ); + let mut about = format!("{} releases", detail.releases.len()); + if !detail.featured_tracks.is_empty() { + about.push_str(&format!(" · appears on {}", detail.featured_tracks.len())); + } let info = vec![ Line::default(), Line::styled(detail.name.clone(), theme::header()), @@ -308,21 +337,33 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: ), theme::dim(), ), - Line::styled(format!("{} releases", detail.releases.len()), theme::dim()), + Line::styled(about, theme::dim()), ]; frame.render_widget(Paragraph::new(info), info_area); - // Scrollable content: top tracks, then releases grouped by type. + // Scrollable content: top tracks, releases grouped by type, then the + // tracks this artist is featured on. let tracks = detail.top_tracks.len(); + let releases_len = detail.releases.len(); + let featured_len = detail.featured_tracks.len(); let mut items = Vec::new(); let mut cursor_item = None; + if tracks + releases_len + featured_len == 0 { + return centered_line( + frame, + content_area, + Line::styled("nothing here yet", theme::dim()), + ); + } 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::Track { + cursor_index: index, + }); } items.push(PlanItem::Gap); } @@ -353,18 +394,39 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: } items.push(PlanItem::Gap); } + if featured_len > 0 { + items.push(PlanItem::Header(format!("Appears on ({featured_len})"))); + for index in 0..featured_len { + let flat = tracks + releases_len + index; + if cursor == flat { + cursor_item = Some(items.len()); + } + items.push(PlanItem::Track { cursor_index: flat }); + } + 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 { + 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]; + let (track, number) = if *cursor_index < tracks { + (&detail.top_tracks[*cursor_index], cursor_index + 1) + } else { + let offset = cursor_index - tracks - releases_len; + (&detail.featured_tracks[offset], offset + 1) + }; super::track_row( frame, rect, state, track, - (cursor_index + 1).to_string(), + number.to_string(), cursor == *cursor_index, ); } @@ -374,7 +436,8 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: 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)), + width: TILE_WIDTH + .min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)), height: rect.height, }; if tile.width < 3 { @@ -405,8 +468,8 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: ); } _ => unreachable!("headers and gaps are rendered by render_plan"), - } - }); + }, + ); } fn release_tile_meta(release: &ReleaseCard) -> String { @@ -491,7 +554,10 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor .areas(header_area); draw_art( frame, - Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, + Rect { + height: ART_HEADER_HEIGHT.min(art_area.height), + ..art_area + }, header_art(state, detail.cover_url.as_ref()), ); @@ -504,7 +570,11 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor Line::raw(artists.join(", ")), Line::default(), Line::styled( - format!("{}{year} · {} tracks", detail.release_type, detail.tracks.len()), + format!( + "{}{year} · {} tracks", + detail.release_type, + detail.tracks.len() + ), theme::dim(), ), ]; @@ -599,12 +669,6 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { } 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, @@ -614,7 +678,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { theme::dim(), ), ]), - Some(right), + Some(super::track_meta_suffix(track, true)), Some(index), )); index += 1; diff --git a/src/ui/login.rs b/src/ui/login.rs index d30e629..779229d 100644 --- a/src/ui/login.rs +++ b/src/ui/login.rs @@ -25,34 +25,68 @@ fn draw_form(frame: &mut Frame, form: &LoginForm) { // 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); + 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); + 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_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( @@ -68,8 +102,8 @@ fn draw_form(frame: &mut Frame, form: &LoginForm) { fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { // The URL stays on ONE line (wrapping breaks copy-paste); the dialog is // as wide as the terminal allows and ctrl-l copies the full link. - let width = (form.sso_url.len() as u16 + 4) - .clamp(48, frame.area().width.saturating_sub(2).max(40)); + let width = + (form.sso_url.len() as u16 + 4).clamp(48, frame.area().width.saturating_sub(2).max(40)); let area = centered(frame.area(), width, 14.min(frame.area().height)); let block = Block::bordered() @@ -134,7 +168,11 @@ fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { } 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 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()) diff --git a/src/ui/logs.rs b/src/ui/logs.rs index 9088f40..a56ff00 100644 --- a/src/ui/logs.rs +++ b/src/ui/logs.rs @@ -24,13 +24,13 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { }; let visible = usize::from(inner.height.max(1)); - let skip = if logs.follow { 0 } else { logs.scroll_from_end }; - let (entries, matched) = buffer.window(level, skip, visible); - if entries.is_empty() { + let selected = if logs.follow { None } else { logs.selected_seq }; + let view = buffer.view(level, selected, visible); + if view.entries.is_empty() { return centered(frame, inner, "no log entries at this level yet"); } - for (row_index, entry) in entries.iter().enumerate() { + for (row_index, entry) in view.entries.iter().enumerate() { let row = Rect { x: inner.x, y: inner.y + row_index as u16, @@ -44,6 +44,9 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { Span::raw(entry.message.clone()), ]); frame.render_widget(Paragraph::new(line), row); + if view.cursor_row == Some(row_index) { + frame.buffer_mut().set_style(row, theme::tab_active()); + } } // Footer hint with position info while scrolled back. @@ -55,7 +58,10 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { }; frame.render_widget( Paragraph::new(Line::styled( - format!(" ↑{skip} of {matched} · shift-g: follow · v: level "), + format!( + " ↑{} of {} · enter: details · shift-g: follow · v: level ", + view.from_end, view.matched + ), theme::tab_active(), )) .alignment(Alignment::Right), @@ -68,19 +74,23 @@ fn centered(frame: &mut Frame, area: Rect, text: &str) { if area.height == 0 { return; } - let middle = Rect { y: area.y + area.height / 2, height: 1, ..area }; + let middle = Rect { + y: area.y + area.height / 2, + height: 1, + ..area + }; frame.render_widget( - Paragraph::new(Line::styled(text.to_string(), theme::dim())) - .alignment(Alignment::Center), + Paragraph::new(Line::styled(text.to_string(), theme::dim())).alignment(Alignment::Center), middle, ); } fn level_span(level: tracing::Level) -> Span<'static> { match level { - tracing::Level::ERROR => { - Span::styled("ERROR", Style::new().fg(Color::Red).add_modifier(Modifier::BOLD)) - } + tracing::Level::ERROR => Span::styled( + "ERROR", + Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), + ), tracing::Level::WARN => Span::styled("WARN ", Style::new().fg(Color::Yellow)), tracing::Level::INFO => Span::styled("INFO ", theme::accent()), tracing::Level::DEBUG => Span::styled("DEBUG", theme::dim()), @@ -90,7 +100,5 @@ fn level_span(level: tracing::Level) -> Span<'static> { /// `furumi_tui::app::update` → `app::update` — the crate prefix is noise. fn short_target(target: &str) -> &str { - target - .split_once("::") - .map_or(target, |(_, rest)| rest) + target.split_once("::").map_or(target, |(_, rest)| rest) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1ba9787..57fd449 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,13 +3,14 @@ mod global; mod login; mod logs; mod playlists; +mod popup; pub mod theme; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs}; +use ratatui::widgets::{Block, Clear, Paragraph, Tabs}; use crate::app::state::{AppState, Screen, Tab}; use crate::config::keymap::Keymap; @@ -38,6 +39,7 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { if state.help_visible { draw_help(frame, keymap); } + popup::draw(frame, state); } fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) { @@ -75,12 +77,7 @@ pub(crate) fn track_row( ]); 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()) - }; + let right = track_meta_suffix(track, area.width >= 60); frame.render_widget( Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right), area, @@ -90,6 +87,38 @@ pub(crate) fn track_row( } } +pub(crate) fn track_meta_suffix( + track: &crate::api::models::TrackItem, + include_tech: bool, +) -> String { + let has_tech = track.audio_format.is_some() + || track.audio_bitrate.is_some() + || track.file_size_bytes.is_some(); + if !include_tech || !has_tech { + return track.duration_label(); + } + + let format = track + .audio_format + .as_deref() + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_default(); + let format: String = format.chars().take(4).collect(); + let bitrate = track + .audio_bitrate + .map(|value| format!("{value}k")) + .unwrap_or_default(); + let size = track + .file_size_bytes + .map(|bytes| format!("{:.1}MB", bytes as f64 / 1_048_576.0)) + .unwrap_or_default(); + + format!( + "{format:<4} {bitrate:>5} {size:>8} · {:>5}", + track.duration_label() + ) +} + /// Interactive queue: its own cursor, enter plays the selected track and /// already-played tracks stay listed, greyed out. fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { @@ -105,7 +134,11 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { frame.render_widget(block, area); if player.queue.is_empty() { - let middle = Rect { y: inner.y + inner.height / 2, height: 1, ..inner }; + 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", @@ -122,9 +155,7 @@ fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { let first = cursor .saturating_sub(visible / 2) .min(player.queue.len().saturating_sub(visible)); - let played_style = Style::new() - .fg(Color::DarkGray) - .bg(Color::Rgb(28, 28, 32)); + let played_style = Style::new().fg(Color::DarkGray).bg(Color::Rgb(28, 28, 32)); for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) { let row = Rect { x: inner.x, @@ -207,16 +238,36 @@ fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line< )); } } else { - spans.push(Span::styled( - format!(" {}%", player.volume), - theme::dim(), - )); + spans.push(Span::styled(format!(" {}%", player.volume), theme::dim())); } // Keep a gap between the flags and the username block to the right. spans.push(Span::raw(" ")); Line::from(spans) } +fn truncate_chars(value: &str, max: usize) -> String { + let mut out: String = value.chars().take(max).collect(); + if value.chars().count() > max { + out.push('…'); + } + out +} + +fn device_status_line(state: &AppState) -> Line<'static> { + if state.devices.is_playback_device() { + return Line::from(vec![Span::styled("playing here", theme::accent())]); + } + let name = state + .devices + .active_device_name() + .map(|name| truncate_chars(name, 26)) + .unwrap_or_else(|| "remote device".to_string()); + Line::from(vec![ + Span::styled("controlling ", theme::dim()), + Span::styled(name, theme::accent()), + ]) +} + 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); @@ -227,6 +278,8 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { // truncates into whatever is left. let center = player_right_line(player, area.width); let center_width = (center.width() as u16).min(area.width); + let device_line = device_status_line(state); + let device_width = (device_line.width() as u16).min(32); let user_line = state.user.as_ref().map(|user| { Line::from(vec![ Span::styled("◉ ", theme::accent()), @@ -234,12 +287,17 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { ]) }); let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16); - let [title_area, right_area, user_area] = Layout::horizontal([ + let [title_area, right_area, device_area, user_area] = Layout::horizontal([ Constraint::Min(8), Constraint::Length(center_width), + Constraint::Length(device_width.saturating_add(2)), Constraint::Length(user_width), ]) .areas(player_row); + frame.render_widget( + Paragraph::new(device_line).alignment(Alignment::Right), + device_area, + ); if let Some(user_line) = user_line { frame.render_widget( Paragraph::new(user_line).alignment(Alignment::Right), @@ -271,7 +329,6 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { 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![ @@ -280,6 +337,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { Span::styled("█", theme::accent()), ]); frame.render_widget(Paragraph::new(line), message_row); + draw_version(frame, message_row); return; } @@ -294,6 +352,7 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { }, }; frame.render_widget(Paragraph::new(message), message_row); + draw_version(frame, message_row); if let Some(pending) = &state.pending_keys { let pending = Paragraph::new(Line::styled(format!("{pending} …"), theme::header())) @@ -302,36 +361,115 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { } } +fn draw_version(frame: &mut Frame, area: Rect) { + let version = format!("v{}", env!("CARGO_PKG_VERSION")); + frame.render_widget( + Paragraph::new(Line::styled(version, theme::dim())).alignment(Alignment::Right), + area, + ); +} + +/// Help window: bindings merged per action (j / down on one row), grouped +/// into titled sections and laid out in two balanced columns. 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)); + use crate::app::action::{Action, Category}; + use crate::config::keymap::KeyContext; + + struct MergedRow { + keys: Vec, + action: Action, + context: KeyContext, + } + let mut merged: Vec = Vec::new(); + for (keys, action, context) in keymap.help_entries() { + match merged + .iter_mut() + .find(|row| row.action == action && row.context == context) + { + Some(row) => row.keys.push(keys), + None => merged.push(MergedRow { + keys: vec![keys], + action, + context, + }), + } + } + + // One block of lines per category: section header + its rows. + let mut blocks: Vec> = Vec::new(); + for category in Category::ALL { + let rows: Vec<&MergedRow> = merged + .iter() + .filter(|row| row.action.category() == category) + .collect(); + if rows.is_empty() { + continue; + } + let mut lines = vec![Line::styled(category.title(), theme::header())]; + for row in rows { + let keys = row.keys.join(" / "); + let context = if row.context == KeyContext::Global { + String::new() + } else { + format!(" [{}]", row.context.label()) + }; + let command = row.action.command_hint().unwrap_or(""); + lines.push(Line::from(vec![ + Span::styled(format!("{keys:<13}"), theme::accent()), + Span::raw(format!( + "{:<24}", + format!("{}{context}", row.action.describe()) + )), + Span::styled(command.to_string(), theme::accent()), + ])); + } + lines.push(Line::default()); + blocks.push(lines); + } + + // Balance the blocks across two columns. + let total: usize = blocks.iter().map(Vec::len).sum(); + let mut left: Vec = Vec::new(); + let mut right: Vec = Vec::new(); + for block in blocks { + if left.len() < total.div_ceil(2) { + left.extend(block); + } else { + right.extend(block); + } + } + + let column_height = left.len().max(right.len()) as u16; + let width = 110.min(frame.area().width.saturating_sub(2)); + let height = (column_height + 4).min(frame.area().height.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()), - ); - + let block = Block::bordered() + .title(" Keybindings & commands ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); frame.render_widget(Clear, area); - frame.render_widget(table, area); + frame.render_widget(block, area); + + let [columns_area, footer] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); + let [left_area, _, right_area] = Layout::horizontal([ + Constraint::Percentage(50), + Constraint::Length(2), + Constraint::Percentage(50), + ]) + .areas(columns_area); + frame.render_widget(Paragraph::new(left), left_area); + frame.render_widget(Paragraph::new(right), right_area); + frame.render_widget( + Paragraph::new(Line::styled( + ": opens the command line · full forms: :seek +30|1:30 · :volume 0-100 · :repeat off|one|all · :logs [level]", + theme::dim(), + )) + .alignment(Alignment::Center), + footer, + ); } fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { @@ -343,4 +481,3 @@ fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { .areas(rect); rect } - diff --git a/src/ui/playlists.rs b/src/ui/playlists.rs index 617e461..ef216c3 100644 --- a/src/ui/playlists.rs +++ b/src/ui/playlists.rs @@ -29,7 +29,11 @@ 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 }; + let middle = Rect { + y: area.y + area.height / 2, + height: 1, + ..area + }; frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle); } @@ -47,7 +51,11 @@ fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { ); } _ => { - return centered_line(frame, inner, Line::styled("loading playlists…", theme::dim())); + return centered_line( + frame, + inner, + Line::styled("loading playlists…", theme::dim()), + ); } }; if list.is_empty() { @@ -125,7 +133,11 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: 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())); + return centered_line( + frame, + inner, + Line::styled("no tracks here yet", theme::dim()), + ); } let visible = usize::from(inner.height.max(1)); @@ -139,6 +151,13 @@ fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: width: inner.width, height: 1, }; - track_row(frame, row, state, track, (index + 1).to_string(), index == cursor); + track_row( + frame, + row, + state, + track, + (index + 1).to_string(), + index == cursor, + ); } } diff --git a/src/ui/popup.rs b/src/ui/popup.rs new file mode 100644 index 0000000..b5476bb --- /dev/null +++ b/src/ui/popup.rs @@ -0,0 +1,247 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Paragraph}; + +use super::theme; +use crate::app::state::{AppState, Loadable, Popup, addable_playlists}; + +pub fn draw(frame: &mut Frame, state: &AppState) { + match state.popup.as_ref() { + Some(Popup::AddToPlaylist { track, cursor }) => { + draw_picker(frame, state, &track.title, *cursor) + } + Some(Popup::NewPlaylist { input, busy, .. }) => draw_name_entry(frame, input, *busy), + Some(Popup::Devices { cursor }) => draw_devices(frame, state, *cursor), + Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), + None => {} + } +} + +fn draw_devices(frame: &mut Frame, state: &AppState, cursor: usize) { + let rows = state.devices.devices.len().max(1); + let height = (rows as u16 + 4) + .min(frame.area().height.saturating_sub(2)) + .max(7); + let area = centered(frame.area(), 64, height); + let block = Block::bordered() + .title(" Connected devices ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [list_area, _, footer] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + + if state.devices.devices.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled("waiting for device poll…", theme::dim())) + .alignment(Alignment::Center), + list_area, + ); + } else { + let visible = usize::from(list_area.height.max(1)); + let cursor = cursor.min(state.devices.devices.len() - 1); + let first = cursor + .saturating_sub(visible / 2) + .min(state.devices.devices.len().saturating_sub(visible)); + for (index, device) in state + .devices + .devices + .iter() + .enumerate() + .skip(first) + .take(visible) + { + let row = Rect { + x: list_area.x, + y: list_area.y + (index - first) as u16, + width: list_area.width, + height: 1, + }; + let marker = if device.is_active { + Span::styled("▶ ", theme::accent()) + } else { + Span::styled(" ", theme::dim()) + }; + let current = if device.is_current { + " · this TUI" + } else { + "" + }; + let switching = if state.devices.switching_to.as_deref() == Some(device.id.as_str()) { + " · switching" + } else { + "" + }; + let line = Line::from(vec![ + marker, + Span::raw(device.name.clone()), + Span::styled( + format!(" · {}{current}{switching}", device.kind), + theme::dim(), + ), + ]); + frame.render_widget(Paragraph::new(line), row); + if index == cursor { + frame.buffer_mut().set_style(row, theme::tab_active()); + } + } + } + + let hint = if let Some(error) = &state.devices.poll_error { + Line::styled(format!("sync error: {error}"), theme::dim()) + } else { + Line::styled("enter make active · esc close", theme::dim()) + }; + frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer); +} + +fn draw_log_detail(frame: &mut Frame, entry: &crate::config::logging::LogEntry) { + let width = 90.min(frame.area().width.saturating_sub(4)).max(40); + let height = 18.min(frame.area().height.saturating_sub(2)).max(7); + let area = centered(frame.area(), width, height); + + let block = Block::bordered() + .title(format!(" Log entry — {} {} ", entry.time, entry.level)) + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [target_area, body, footer] = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .areas(inner); + + frame.render_widget( + Paragraph::new(Line::styled(entry.target.clone(), theme::dim())), + target_area, + ); + frame.render_widget( + Paragraph::new(entry.message.clone()).wrap(ratatui::widgets::Wrap { trim: false }), + body, + ); + frame.render_widget( + Paragraph::new(Line::styled("esc close", theme::dim())).alignment(Alignment::Center), + footer, + ); +} + +fn draw_picker(frame: &mut Frame, state: &AppState, track_title: &str, cursor: usize) { + let options = addable_playlists(state); + let loading = !matches!(&state.playlists.list, Some(Loadable::Ready(_))); + let rows = options.len() + 1; + let height = (rows as u16 + 4) + .min(frame.area().height.saturating_sub(2)) + .max(6); + let area = centered(frame.area(), 44, height); + + let block = Block::bordered() + .title(" Add to playlist ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [list_area, _, footer] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + + let mut lines: Vec = vec![Line::styled("+ New playlist…", theme::accent())]; + if loading { + lines.push(Line::styled("loading playlists…", theme::dim())); + } else if options.is_empty() { + lines.push(Line::styled("no playlists yet", theme::dim())); + } else { + for (_, title) in &options { + lines.push(Line::raw(title.clone())); + } + } + let visible = usize::from(list_area.height.max(1)); + let first = cursor + .saturating_sub(visible / 2) + .min(lines.len().saturating_sub(visible)); + for (index, line) in lines.into_iter().enumerate().skip(first).take(visible) { + let row = Rect { + x: list_area.x, + y: list_area.y + (index - first) as u16, + width: list_area.width, + height: 1, + }; + frame.render_widget(Paragraph::new(line), row); + if index == cursor { + frame.buffer_mut().set_style(row, theme::tab_active()); + } + } + + frame.render_widget( + Paragraph::new(Line::styled( + format!("♪ {track_title} · enter add · esc close"), + theme::dim(), + )) + .alignment(Alignment::Center), + footer, + ); +} + +fn draw_name_entry(frame: &mut Frame, input: &str, busy: bool) { + let area = centered(frame.area(), 44, 7); + let block = Block::bordered() + .title(" New playlist ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [field, _, footer] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + + let name_block = Block::bordered() + .title("Name") + .border_style(theme::accent()); + let name_inner = name_block.inner(field); + frame.render_widget(name_block, field); + let width = usize::from(name_inner.width.saturating_sub(1)); + let mut shown: String = input + .chars() + .skip(input.chars().count().saturating_sub(width)) + .collect(); + shown.push('█'); + frame.render_widget(Paragraph::new(shown), name_inner); + + let hint = if busy { + Line::styled("creating…", theme::accent()) + } else { + Line::styled("enter create · esc back", theme::dim()) + }; + frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), footer); +} + +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 +}