//! Shared application state use std::sync::Arc; use std::time::Instant; use tokio::sync::RwLock; use ratatui::layout::Rect; use crate::config::Config; use crate::subsonic::models::{Album, Artist, Child, Playlist}; use crate::ui::theme::{ThemeColors, ThemeData}; /// Current page in the application #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Page { #[default] Artists, Queue, Playlists, Server, Settings, } impl Page { pub fn index(&self) -> usize { match self { Page::Artists => 0, Page::Queue => 1, Page::Playlists => 2, Page::Server => 3, Page::Settings => 4, } } pub fn from_index(index: usize) -> Self { match index { 0 => Page::Artists, 1 => Page::Queue, 2 => Page::Playlists, 3 => Page::Server, 4 => Page::Settings, _ => Page::Artists, } } pub fn label(&self) -> &'static str { match self { Page::Artists => "Artists", Page::Queue => "Queue", Page::Playlists => "Playlists", Page::Server => "Server", Page::Settings => "Settings", } } pub fn shortcut(&self) -> &'static str { match self { Page::Artists => "F1", Page::Queue => "F2", Page::Playlists => "F3", Page::Server => "F4", Page::Settings => "F5", } } } /// Playback state #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PlaybackState { #[default] Stopped, Playing, Paused, } /// Now playing information #[derive(Debug, Clone, Default)] pub struct NowPlaying { /// Currently playing song pub song: Option, /// Playback state pub state: PlaybackState, /// Current position in seconds pub position: f64, /// Total duration in seconds pub duration: f64, /// Audio sample rate (Hz) pub sample_rate: Option, /// Audio bit depth pub bit_depth: Option, /// Audio format/codec pub format: Option, /// Audio channel layout (e.g., "Stereo", "Mono", "5.1ch") pub channels: Option, } impl NowPlaying { pub fn progress_percent(&self) -> f64 { if self.duration > 0.0 { (self.position / self.duration).clamp(0.0, 1.0) } else { 0.0 } } pub fn format_position(&self) -> String { format_duration(self.position) } pub fn format_duration(&self) -> String { format_duration(self.duration) } } /// Format duration in MM:SS or HH:MM:SS format pub fn format_duration(seconds: f64) -> String { let total_secs = seconds as u64; let hours = total_secs / 3600; let mins = (total_secs % 3600) / 60; let secs = total_secs % 60; if hours > 0 { format!("{:02}:{:02}:{:02}", hours, mins, secs) } else { format!("{:02}:{:02}", mins, secs) } } /// Artists page state #[derive(Debug, Clone, Default)] pub struct ArtistsState { /// List of all artists pub artists: Vec, /// Currently selected index in the tree (artists + expanded albums) pub selected_index: Option, /// Set of expanded artist IDs pub expanded: std::collections::HashSet, /// Albums cached per artist ID pub albums_cache: std::collections::HashMap>, /// Songs in the selected album (shown in right pane) pub songs: Vec, /// Currently selected song index pub selected_song: Option, /// Artist filter text pub filter: String, /// Whether filter input is active pub filter_active: bool, /// Focus: 0 = tree, 1 = songs pub focus: usize, /// Scroll offset for the tree list (set after render) pub tree_scroll_offset: usize, /// Scroll offset for the songs list (set after render) pub song_scroll_offset: usize, } /// Queue page state #[derive(Debug, Clone, Default)] pub struct QueueState { /// Currently selected index in the queue pub selected: Option, /// Scroll offset for the queue list (set after render) pub scroll_offset: usize, } /// Playlists page state #[derive(Debug, Clone, Default)] pub struct PlaylistsState { /// List of all playlists pub playlists: Vec, /// Currently selected playlist index pub selected_playlist: Option, /// Songs in the selected playlist pub songs: Vec, /// Currently selected song index pub selected_song: Option, /// Focus: 0 = playlists, 1 = songs pub focus: usize, /// Scroll offset for the playlists list (set after render) pub playlist_scroll_offset: usize, /// Scroll offset for the songs list (set after render) pub song_scroll_offset: usize, } /// Server page state (connection settings) #[derive(Debug, Clone, Default)] pub struct ServerState { /// Currently focused field (0-4: URL, Username, Password, Test, Save) pub selected_field: usize, /// Edit values pub base_url: String, pub username: String, pub password: String, /// Status message pub status: Option, } /// Settings page state #[derive(Debug, Clone)] pub struct SettingsState { /// Currently focused field (0=Theme, 1=Cava) pub selected_field: usize, /// Available themes (Default + loaded from files) pub themes: Vec, /// Index of the currently selected theme in `themes` pub theme_index: usize, /// Cava visualizer enabled pub cava_enabled: bool, } impl Default for SettingsState { fn default() -> Self { Self { selected_field: 0, themes: vec![ThemeData::default_theme()], theme_index: 0, cava_enabled: false, } } } impl SettingsState { /// Current theme name pub fn theme_name(&self) -> &str { &self.themes[self.theme_index].name } /// Current theme colors pub fn theme_colors(&self) -> &ThemeColors { &self.themes[self.theme_index].colors } /// Current theme data pub fn current_theme(&self) -> &ThemeData { &self.themes[self.theme_index] } /// Cycle to next theme pub fn next_theme(&mut self) { self.theme_index = (self.theme_index + 1) % self.themes.len(); } /// Cycle to previous theme pub fn prev_theme(&mut self) { self.theme_index = (self.theme_index + self.themes.len() - 1) % self.themes.len(); } /// Set theme by name, returning true if found pub fn set_theme_by_name(&mut self, name: &str) -> bool { if let Some(idx) = self.themes.iter().position(|t| t.name.eq_ignore_ascii_case(name)) { self.theme_index = idx; true } else { self.theme_index = 0; // Fall back to Default false } } } /// Notification/alert to display #[derive(Debug, Clone)] pub struct Notification { pub message: String, pub is_error: bool, pub created_at: Instant, } /// Cached layout rectangles from the last render, used for mouse hit-testing. /// Automatically updated every frame, so resize and visualiser toggle are handled. #[derive(Debug, Clone, Default)] pub struct LayoutAreas { pub header: Rect, pub cava: Option, pub content: Rect, pub now_playing: Rect, pub footer: Rect, /// Left pane for dual-pane pages (Artists tree, Playlists list) pub content_left: Option, /// Right pane for dual-pane pages (Songs list) pub content_right: Option, } /// Complete application state #[derive(Debug, Default)] pub struct AppState { /// Application configuration pub config: Config, /// Current page pub page: Page, /// Now playing information pub now_playing: NowPlaying, /// Play queue (songs) pub queue: Vec, /// Current position in queue pub queue_position: Option, /// Artists page state pub artists: ArtistsState, /// Queue page state pub queue_state: QueueState, /// Playlists page state pub playlists: PlaylistsState, /// Server page state (connection settings) pub server_state: ServerState, /// Settings page state (app preferences) pub settings_state: SettingsState, /// Current notification pub notification: Option, /// Whether the app should quit pub should_quit: bool, /// Cava visualizer screen content (rows of styled spans) pub cava_screen: Vec, /// Whether the cava binary is available on the system pub cava_available: bool, /// Cached layout areas from last render (for mouse hit-testing) pub layout: LayoutAreas, } /// A row of styled segments from cava's terminal output #[derive(Debug, Clone, Default)] pub struct CavaRow { pub spans: Vec, } /// A styled text segment from cava's terminal output #[derive(Debug, Clone)] pub struct CavaSpan { pub text: String, pub fg: CavaColor, pub bg: CavaColor, } /// Color from cava's terminal output #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum CavaColor { #[default] Default, Indexed(u8), Rgb(u8, u8, u8), } impl AppState { pub fn new(config: Config) -> Self { let mut state = Self { config: config.clone(), ..Default::default() }; // Initialize server page with current values state.server_state.base_url = config.base_url.clone(); state.server_state.username = config.username.clone(); state.server_state.password = config.password.clone(); // Initialize cava from config state.settings_state.cava_enabled = config.cava; state } /// Get the currently playing song from the queue pub fn current_song(&self) -> Option<&Child> { self.queue_position.and_then(|pos| self.queue.get(pos)) } /// Show a notification pub fn notify(&mut self, message: impl Into) { self.notification = Some(Notification { message: message.into(), is_error: false, created_at: Instant::now(), }); } /// Show an error notification pub fn notify_error(&mut self, message: impl Into) { self.notification = Some(Notification { message: message.into(), is_error: true, created_at: Instant::now(), }); } /// Check if notification should be auto-cleared (after 2 seconds) pub fn check_notification_timeout(&mut self) { if let Some(ref notif) = self.notification { if notif.created_at.elapsed().as_secs() >= 2 { self.notification = None; } } } /// Clear the notification pub fn clear_notification(&mut self) { self.notification = None; } } /// Thread-safe shared state pub type SharedState = Arc>; /// Create new shared state pub fn new_shared_state(config: Config) -> SharedState { Arc::new(RwLock::new(AppState::new(config))) }