Added connected devices. Improved logging. UI fixes
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumi_tui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
|
||||
+1
-2
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+129
-6
@@ -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<DevicePlaybackState>,
|
||||
}
|
||||
|
||||
#[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<Vec<u8>, ApiError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.send_authed(&url, |client, url, token| client.get(url).bearer_auth(token))
|
||||
.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<PlaylistCard, ApiError> {
|
||||
#[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<Vec<i64>, 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<Vec<TrackItem>, 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<DevicePlaybackState>,
|
||||
) -> Result<DevicePollResponse, ApiError> {
|
||||
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<DevicePollResponse, ApiError> {
|
||||
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<bool, ApiError> {
|
||||
|
||||
+93
-19
@@ -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<i32>,
|
||||
pub duration_seconds: f64,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<ArtistRef>,
|
||||
#[serde(default)]
|
||||
pub featured_artists: Vec<ArtistRef>,
|
||||
#[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<i32>,
|
||||
/// 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<String>,
|
||||
@@ -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<TrackItem>,
|
||||
pub releases: Vec<ReleaseCard>,
|
||||
/// Tracks where this artist is featured (the only content for artists
|
||||
/// without own releases).
|
||||
#[serde(default)]
|
||||
pub featured_tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -208,6 +212,76 @@ pub struct LikesResponse {
|
||||
pub track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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_json::Value>,
|
||||
#[serde(default)]
|
||||
pub tracks: Vec<serde_json::Value>,
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub devices: Vec<DeviceDto>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<DeviceCommandDto>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code, reason = "Jam control is out of scope for the TUI v1")]
|
||||
pub current_jam_id: Option<String>,
|
||||
pub playback_state: Option<DevicePlaybackState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct SearchResults {
|
||||
pub artists: Vec<ArtistCard>,
|
||||
|
||||
+97
-1
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
+83
-5
@@ -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);
|
||||
|
||||
+143
-11
@@ -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<RepeatArg>),
|
||||
/// `: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<usize>),
|
||||
}
|
||||
|
||||
#[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::<u8>().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<Command> {
|
||||
if let Some(rest) = arg.strip_prefix('+') {
|
||||
return rest.parse::<i64>().ok().map(Command::Seek);
|
||||
}
|
||||
if let Some(rest) = arg.strip_prefix('-') {
|
||||
return rest.parse::<i64>().ok().map(|s| Command::Seek(-s));
|
||||
}
|
||||
if let Some((minutes, seconds)) = arg.split_once(':') {
|
||||
let minutes = minutes.parse::<u64>().ok()?;
|
||||
let seconds = seconds.parse::<u64>().ok()?;
|
||||
if seconds >= 60 {
|
||||
return None;
|
||||
}
|
||||
return Some(Command::SeekTo(minutes * 60 + seconds));
|
||||
}
|
||||
arg.parse::<u64>().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));
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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<DevicePollResponse, String>),
|
||||
/// Response from switching the active device.
|
||||
DeviceActivated(Result<DevicePollResponse, String>),
|
||||
/// A release fetched for queueing (a / shift-a on a release).
|
||||
EnqueueTracks {
|
||||
tracks: Vec<TrackItem>,
|
||||
next: bool,
|
||||
},
|
||||
PlaylistCreated {
|
||||
result: Result<PlaylistCard, String>,
|
||||
/// Add this track to the new playlist right away (Shift-P flow).
|
||||
add_track: Option<TrackItem>,
|
||||
},
|
||||
PlaylistTracksAdded {
|
||||
playlist_id: i64,
|
||||
playlist_title: String,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
}
|
||||
|
||||
+1
-3
@@ -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> {
|
||||
|
||||
+610
-17
@@ -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<std::time::Instant>,
|
||||
pub media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
|
||||
pub last_media_push: Option<std::time::Instant>,
|
||||
pub device_id: String,
|
||||
pub last_device_poll: Option<std::time::Instant>,
|
||||
pub device_poll_in_flight: bool,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
@@ -53,10 +58,12 @@ pub async fn run(
|
||||
mut event_rx: mpsc::UnboundedReceiver<AppEvent>,
|
||||
media_tx: std::sync::mpsc::Sender<crate::media::MediaUpdate>,
|
||||
) -> 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<crate::api::models::DevicePlaybackState> {
|
||||
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<ApiClient>,
|
||||
@@ -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<ApiClient>) {
|
||||
}
|
||||
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<crate::api::models::TrackItem> {
|
||||
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<crate::api::models::TrackItem> {
|
||||
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<crate::api::models::TrackItem> {
|
||||
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<usize> {
|
||||
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<f64> {
|
||||
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");
|
||||
|
||||
@@ -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<TrackItem>,
|
||||
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<TrackItem>) {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
+81
-7
@@ -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<u64>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub devices: Vec<DeviceDto>,
|
||||
pub poll_error: Option<String>,
|
||||
pub switching_to: Option<String>,
|
||||
}
|
||||
|
||||
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<TrackItem>,
|
||||
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<i64>,
|
||||
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<Popup>,
|
||||
pub cmdline: Cmdline,
|
||||
pub search: SearchState,
|
||||
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
||||
|
||||
+189
-62
@@ -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<Effect> {
|
||||
@@ -35,6 +41,11 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
.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<Effect> {
|
||||
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<Effect> {
|
||||
}
|
||||
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<Effect> {
|
||||
} 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<Effect> {
|
||||
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<Effect> {
|
||||
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<Effect> {
|
||||
}
|
||||
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<TrackItem> {
|
||||
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<TrackItem> {
|
||||
},
|
||||
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<Effect> {
|
||||
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<Effect> {
|
||||
}
|
||||
enum Outcome {
|
||||
Push(GlobalView),
|
||||
Play { tracks: Vec<crate::api::models::TrackItem>, start: usize },
|
||||
Play {
|
||||
tracks: Vec<crate::api::models::TrackItem>,
|
||||
start: usize,
|
||||
},
|
||||
Nothing,
|
||||
}
|
||||
let outcome = match state.global.stack.last().copied() {
|
||||
@@ -798,12 +903,13 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
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<Effect> {
|
||||
}),
|
||||
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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+35
-12
@@ -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<char> {
|
||||
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)
|
||||
|
||||
+89
-27
@@ -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<LogEntry>,
|
||||
/// Row index of the cursor within `entries`.
|
||||
pub cursor_row: Option<usize>,
|
||||
/// 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<VecDeque<LogEntry>>,
|
||||
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<LogEntry>, usize) {
|
||||
current: Option<u64>,
|
||||
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<u64> = 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<u64>) -> Option<LogEntry> {
|
||||
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<u64>,
|
||||
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<S: tracing::Subscriber> tracing_subscriber::Layer<S> 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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
ProjectDirs::from("", "", "furumi")
|
||||
}
|
||||
|
||||
pub fn device_id_path() -> Option<PathBuf> {
|
||||
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())
|
||||
}
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
+29
-3
@@ -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<u64>,
|
||||
},
|
||||
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<Command>, shared: Arc<Shared>, 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();
|
||||
|
||||
+110
-46
@@ -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<String>, 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<usize>),
|
||||
/// 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;
|
||||
|
||||
+63
-25
@@ -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())
|
||||
|
||||
+22
-14
@@ -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)
|
||||
}
|
||||
|
||||
+181
-44
@@ -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<String>,
|
||||
action: Action,
|
||||
context: KeyContext,
|
||||
}
|
||||
let mut merged: Vec<MergedRow> = 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<Line>> = 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<Line> = Vec::new();
|
||||
let mut right: Vec<Line> = 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
|
||||
}
|
||||
|
||||
|
||||
+23
-4
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+247
@@ -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<Line> = 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
|
||||
}
|
||||
Reference in New Issue
Block a user