use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
use ratatui::layout::Rect;
use crate::app::models::{BrowseTab, SongOption};
use crate::config::Config;
use crate::subsonic::models::{Album, Artist, Child, InternetRadioStation, Playlist};
use crate::ui::theme::{ThemeColors, ThemeData};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Page {
#[default]
Browse,
Artists,
Queue,
Playlists,
Radio,
Server,
Settings,
}
impl Page {
pub const DEFAULT_TABS: [Page; 7] = [
Page::Browse,
Page::Artists,
Page::Queue,
Page::Playlists,
Page::Radio,
Page::Server,
Page::Settings,
];
pub fn label(&self) -> &'static str {
match self {
Page::Browse => "Browse",
Page::Artists => "Artists",
Page::Queue => "Queue",
Page::Playlists => "Playlists",
Page::Radio => "Radio",
Page::Server => "Server",
Page::Settings => "Settings",
}
}
pub fn from_config_name(name: &str) -> Option<Self> {
match name
.to_ascii_lowercase()
.replace([' ', '-', '_'], "")
.as_str()
{
"browse" => Some(Page::Browse),
"artists" => Some(Page::Artists),
"queue" => Some(Page::Queue),
"playlists" => Some(Page::Playlists),
"radio" => Some(Page::Radio),
"server" => Some(Page::Server),
"settings" => Some(Page::Settings),
_ => None,
}
}
pub fn visible_from_config(names: &[String]) -> Vec<Self> {
let mut pages = Vec::new();
for name in names {
if let Some(page) = Page::from_config_name(name) {
if !pages.contains(&page) {
pages.push(page);
}
}
}
if pages.is_empty() {
Page::DEFAULT_TABS.to_vec()
} else {
pages
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlaybackState {
#[default]
Stopped,
Playing,
Paused,
}
#[derive(Debug, Clone, Default)]
pub struct NowPlaying {
pub song: Option<Child>,
pub radio_station: Option<InternetRadioStation>,
pub radio_title: Option<String>,
pub radio_artist: Option<String>,
pub state: PlaybackState,
pub position: f64,
pub duration: f64,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub format: Option<String>,
pub channels: Option<String>,
pub scrobbled: bool,
}
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)
}
}
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)
}
}
#[derive(Debug, Clone, Default)]
pub struct BrowseState {
pub browse_tab: BrowseTab,
pub songs: Vec<Child>,
pub backing_songs: Vec<Child>,
pub selected_index: Option<usize>,
pub scroll_offset: usize,
pub starred_songs_dirty: bool,
pub starred_albums_dirty: bool,
pub all_songs_offset: usize,
pub all_songs_has_more: bool,
pub all_songs_loading: bool,
pub albums: Vec<Album>,
pub backing_albums: Vec<Album>,
pub selected_album: Option<usize>,
pub album_scroll_offset: usize,
pub albums_offset: usize,
pub albums_has_more: bool,
pub albums_loading: bool,
pub focus: usize,
pub filter: String,
pub filter_active: bool,
pub selected_option: Option<SongOption>,
}
impl BrowseState {
pub fn apply_album_filter(&mut self) {
if self.filter.is_empty() {
self.albums = self.backing_albums.clone();
} else {
let lower = self.filter.to_lowercase();
self.albums = self
.backing_albums
.iter()
.filter(|a| {
a.name.to_lowercase().contains(&lower)
|| a.artist
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&lower)
})
.cloned()
.collect();
}
self.selected_album = if self.albums.is_empty() {
None
} else {
Some(0)
};
self.album_scroll_offset = 0;
}
pub fn apply_filter(&mut self) {
if self.filter.is_empty() {
self.songs = self.backing_songs.clone();
} else {
let lower = self.filter.to_lowercase();
self.songs = self
.backing_songs
.iter()
.filter(|s| {
s.title.to_lowercase().contains(&lower)
|| s.artist
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&lower)
|| s.album
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&lower)
})
.cloned()
.collect();
}
self.selected_index = if self.songs.is_empty() { None } else { Some(0) };
self.scroll_offset = 0;
}
}
#[derive(Debug, Clone, Default)]
pub struct ArtistsState {
pub artists: Vec<Artist>,
pub selected_index: Option<usize>,
pub expanded: std::collections::HashSet<String>,
pub albums_cache: std::collections::HashMap<String, Vec<Album>>,
pub songs: Vec<Child>,
pub selected_song: Option<usize>,
pub filter: String,
pub filter_active: bool,
pub focus: usize,
pub tree_scroll_offset: usize,
pub song_scroll_offset: usize,
}
impl ArtistsState {
pub const MAX_ALBUMS_CACHE: usize = 100;
}
#[derive(Debug, Clone, Default)]
pub struct QueueState {
pub selected: Option<usize>,
pub scroll_offset: usize,
}
#[derive(Debug, Clone, Default)]
pub struct PlaylistsState {
pub playlists: Vec<Playlist>,
pub selected_playlist: Option<usize>,
pub songs: Vec<Child>,
pub selected_song: Option<usize>,
pub focus: usize,
pub playlist_scroll_offset: usize,
pub song_scroll_offset: usize,
}
#[derive(Debug, Clone, Default)]
pub struct RadioState {
pub stations: Vec<InternetRadioStation>,
pub selected: Option<usize>,
pub scroll_offset: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ServerState {
pub selected_field: usize,
pub base_url: String,
pub username: String,
pub password: String,
pub status: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SettingsState {
pub selected_field: usize,
pub themes: Vec<ThemeData>,
pub theme_index: usize,
pub cava_enabled: bool,
pub cava_size: u8,
pub notifications_enabled: bool,
pub scrobble_enabled: bool,
pub save_queue_enabled: bool,
}
impl Default for SettingsState {
fn default() -> Self {
Self {
selected_field: 0,
themes: vec![ThemeData::default_theme()],
theme_index: 0,
cava_enabled: false,
cava_size: 40,
notifications_enabled: false,
scrobble_enabled: true,
save_queue_enabled: true,
}
}
}
impl SettingsState {
pub fn theme_name(&self) -> &str {
&self.themes[self.theme_index].name
}
pub fn theme_colors(&self) -> &ThemeColors {
&self.themes[self.theme_index].colors
}
pub fn current_theme(&self) -> &ThemeData {
&self.themes[self.theme_index]
}
pub fn next_theme(&mut self) {
self.theme_index = (self.theme_index + 1) % self.themes.len();
}
pub fn prev_theme(&mut self) {
self.theme_index = (self.theme_index + self.themes.len() - 1) % self.themes.len();
}
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; false
}
}
}
#[derive(Debug, Clone)]
pub struct Notification {
pub message: String,
pub is_error: bool,
pub created_at: Instant,
}
#[derive(Debug, Clone, Default)]
pub struct LayoutAreas {
pub header: Rect,
pub content: Rect,
pub now_playing: Rect,
pub content_left: Option<Rect>,
pub content_right: Option<Rect>,
}
#[derive(Debug, Default)]
pub struct RenderMutations {
pub layout: LayoutAreas,
pub browse_scroll_offset: Option<usize>,
pub browse_album_scroll_offset: Option<usize>,
pub queue_scroll_offset: Option<usize>,
pub radio_scroll_offset: Option<usize>,
pub playlists_playlist_scroll_offset: Option<usize>,
pub playlists_song_scroll_offset: Option<usize>,
pub artists_tree_scroll_offset: Option<usize>,
pub artists_song_scroll_offset: Option<usize>,
}
#[derive(Debug, Default)]
pub struct AppState {
pub config: Config,
pub page: Page,
pub now_playing: NowPlaying,
pub queue: Vec<Child>,
pub queue_position: Option<usize>,
pub browse: BrowseState,
pub artists: ArtistsState,
pub queue_state: QueueState,
pub playlists: PlaylistsState,
pub radio: RadioState,
pub server_state: ServerState,
pub settings_state: SettingsState,
pub notification: Option<Notification>,
pub should_quit: bool,
pub cava_screen: Vec<CavaRow>,
pub cava_available: bool,
pub layout: LayoutAreas,
}
#[derive(Debug, Clone, Default)]
pub struct CavaRow {
pub spans: Vec<CavaSpan>,
}
#[derive(Debug, Clone)]
pub struct CavaSpan {
pub text: String,
pub fg: CavaColor,
pub bg: CavaColor,
}
#[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()
};
state.server_state.base_url = config.base_url.clone();
state.server_state.username = config.username.clone();
state.server_state.password = config.password.clone();
state.settings_state.cava_enabled = config.cava;
state.settings_state.cava_size = config.cava_size.clamp(10, 80);
state.settings_state.notifications_enabled = config.notifications;
state.settings_state.scrobble_enabled = config.scrobble;
state.settings_state.save_queue_enabled = config.save_queue;
state.browse.selected_option = Some(SongOption::All);
state
}
pub fn current_song(&self) -> Option<&Child> {
self.queue_position.and_then(|pos| self.queue.get(pos))
}
pub fn notify(&mut self, message: impl Into<String>) {
self.notification = Some(Notification {
message: message.into(),
is_error: false,
created_at: Instant::now(),
});
}
pub fn notify_error(&mut self, message: impl Into<String>) {
self.notification = Some(Notification {
message: message.into(),
is_error: true,
created_at: Instant::now(),
});
}
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;
}
}
}
pub fn clear_notification(&mut self) {
self.notification = None;
}
pub fn visible_pages(&self) -> Vec<Page> {
Page::visible_from_config(&self.config.visible_tabs)
}
}
#[cfg(test)]
mod tests {
use super::Page;
#[test]
fn visible_pages_use_default_when_not_configured() {
assert_eq!(Page::visible_from_config(&[]), Page::DEFAULT_TABS);
}
#[test]
fn visible_pages_follow_config_order() {
let names = vec![
"Artists".to_string(),
"Queue".to_string(),
"Playlists".to_string(),
"Browse".to_string(),
];
assert_eq!(
Page::visible_from_config(&names),
vec![Page::Artists, Page::Queue, Page::Playlists, Page::Browse]
);
}
#[test]
fn visible_pages_ignore_unknown_and_duplicate_names() {
let names = vec![
"Queue".to_string(),
"Nope".to_string(),
"queue".to_string(),
"Server".to_string(),
];
assert_eq!(
Page::visible_from_config(&names),
vec![Page::Queue, Page::Server]
);
}
}
pub type SharedState = Arc<RwLock<AppState>>;
pub fn new_shared_state(config: Config) -> SharedState {
Arc::new(RwLock::new(AppState::new(config)))
}