use std::collections::HashMap; use std::sync::Arc; use crate::api::models::{ ArtistCard, ArtistDetail, DeviceDto, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, SearchResults, TrackItem, User, }; use crate::art::ArtImage; use crate::config::keymap::KeyContext; /// Remote data that a view renders: spinner, content, or error. #[derive(Debug)] pub enum Loadable { Loading, Ready(T), Failed(String), } /// Tile geometry for the Global artist grid (kept here so selection math in /// update() and rendering in ui::global agree). Width × height in cells, /// including the tile border; the art area inside is 18×8 cells = 18×16 px. pub const TILE_WIDTH: u16 = 20; pub const TILE_HEIGHT: u16 = 12; pub const ART_CELL_WIDTH: u16 = 18; pub const ART_CELL_HEIGHT: u16 = 8; /// Header artwork (artist page, release page): 24×12 cells = 24×24 px. pub const ART_HEADER_WIDTH: u16 = 24; pub const ART_HEADER_HEIGHT: u16 = 12; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ViewMode { #[default] Tiles, Table, } impl ViewMode { pub fn toggle(self) -> ViewMode { match self { ViewMode::Tiles => ViewMode::Table, ViewMode::Table => ViewMode::Tiles, } } } /// Artist image in the shared art cache. #[derive(Debug, Clone)] pub enum ArtState { Loading, Ready(Arc), Failed, } /// A drill-down view pushed on top of the Global artist grid. Cursors live /// in the stack entry so going Back restores the previous position. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GlobalView { /// Linear cursor over top tracks (0..tracks) then releases in display /// order (tracks..tracks+releases). Artist { id: i64, cursor: usize, }, Release { id: i64, cursor: usize, }, /// Linear cursor over search results: artists, then releases, then tracks. Search { cursor: usize, }, } /// The Global tab: the whole server library of artists. #[derive(Debug)] pub struct GlobalTab { pub artists: Vec, pub total: i64, pub has_more: bool, pub next_page: i64, pub loading: bool, pub error: Option, pub selected: usize, pub view: ViewMode, pub stack: Vec, /// Page size, fixed at the first request — the server's offset is /// `(page-1) * limit`, so it must not change between pages. pub page_limit: Option, } impl Default for GlobalTab { fn default() -> Self { Self { artists: Vec::new(), total: 0, has_more: true, next_page: 1, loading: false, error: None, selected: 0, view: ViewMode::default(), stack: Vec::new(), page_limit: None, } } } /// Releases of an artist in display order: grouped by type (albums, EPs, /// singles, compilations, then anything else), keeping server order within a /// group. Returns (group label, indices into the original slice). Cursor /// positions use this flattened order, so update() and ui must both go /// through here. pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec)> { const GROUPS: [(&str, &str); 4] = [ ("album", "Albums"), ("ep", "EPs"), ("single", "Singles"), ("compilation", "Compilations"), ]; let mut groups: Vec<(&'static str, Vec)> = Vec::new(); for (kind, label) in GROUPS { let indices: Vec = releases .iter() .enumerate() .filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind)) .map(|(i, _)| i) .collect(); if !indices.is_empty() { groups.push((label, indices)); } } let known: Vec = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect(); let other: Vec = (0..releases.len()).filter(|i| !known.contains(i)).collect(); if !other.is_empty() { groups.push(("Other", other)); } groups } /// Flattened display order of releases (concatenated groups). pub fn release_display_order(releases: &[ReleaseCard]) -> Vec { release_groups(releases) .into_iter() .flat_map(|(_, indices)| indices) .collect() } /// Visual tile-grid rows of the releases section: each group starts its own /// rows, chunked by the column count. Values are display-order positions. /// Vertical cursor movement must follow these rows to match the rendering. pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec> { let columns = columns.max(1); let mut rows = Vec::new(); let mut position = 0; for (_, group) in release_groups(releases) { for chunk in group.chunks(columns) { rows.push((position..position + chunk.len()).collect()); position += chunk.len(); } } rows } /// The virtual server-side Likes playlist id (`kind == "likes"`). pub const LIKES_PLAYLIST_ID: i64 = -1; #[derive(Debug, Clone, Copy)] pub struct OpenedPlaylist { pub id: i64, pub cursor: usize, } /// The Playlists tab. The server list includes the virtual "Likes" /// playlist (id = -1), rendered with a ♥ marker. #[derive(Debug, Default)] pub struct PlaylistsTab { pub list: Option>>, pub selected: usize, pub opened: Option, } /// The Queue tab's own cursor — independent from the playing position, so /// the user can browse and pick tracks while something else plays. #[derive(Debug, Default)] pub struct QueueTab { pub cursor: usize, } /// Severity steps for the Logs tab filter, cycled with the view-toggle key. pub const LOG_LEVELS: [tracing::Level; 5] = [ tracing::Level::ERROR, tracing::Level::WARN, tracing::Level::INFO, tracing::Level::DEBUG, tracing::Level::TRACE, ]; /// The Logs tab: a live view over the in-memory ring buffer. #[derive(Debug)] pub struct LogsTab { /// Index into LOG_LEVELS; entries more verbose than this are hidden. pub level_index: usize, /// Stick to the newest entries as they arrive. pub follow: bool, /// Cursor anchored to a specific entry's seq; appends never move it. /// None = newest (follow mode). pub selected_seq: Option, } impl Default for LogsTab { fn default() -> Self { Self { level_index: 2, follow: true, selected_seq: None, } } } #[derive(Debug, Default)] pub struct DevicesState { pub device_id: String, pub active_device_id: Option, pub devices: Vec, pub poll_error: Option, pub switching_to: Option, } impl DevicesState { pub fn is_playback_device(&self) -> bool { self.active_device_id .as_deref() .is_none_or(|active| active == self.device_id) } pub fn remote_target_id(&self) -> Option<&str> { self.active_device_id .as_deref() .filter(|active| *active != self.device_id) } pub fn active_device_name(&self) -> Option<&str> { let active = self.active_device_id.as_deref()?; self.devices .iter() .find(|device| device.id == active) .map(|device| device.name.as_str()) } } /// Modal dialog over the main screen. #[derive(Debug)] pub enum Popup { /// Pick one of the user's playlists (row 0 = "create new"); the track /// is added on Enter. AddToPlaylist { track: TrackItem, cursor: usize }, /// Name input for a new playlist; when `for_track` is set, the track is /// added to it right after creation. NewPlaylist { for_track: Option, input: String, busy: bool, }, /// Connected devices list; Enter transfers active playback to the row. Devices { cursor: usize }, /// Full, wrapped view of one log entry (Enter on the Logs tab). LogDetail(crate::config::logging::LogEntry), } /// User's own playlists eligible as add-targets (the virtual Likes playlist /// is managed through likes, not direct adds). pub fn addable_playlists(state: &AppState) -> Vec<(i64, String)> { match &state.playlists.list { Some(Loadable::Ready(list)) => list .iter() .filter(|p| p.is_own && p.kind != "likes") .map(|p| (p.id, p.title.clone())) .collect(), _ => Vec::new(), } } /// Command line (`:`), vim-style. Lives on the Main screen status bar. #[derive(Debug, Default)] pub struct Cmdline { pub active: bool, pub input: String, /// A live command (search) applied effects during this session; Esc /// undoes them, Enter keeps them. pub live: bool, } /// Live search state driven by the `:/query` command. #[derive(Debug, Default)] pub struct SearchState { pub query: String, pub loading: bool, pub results: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Screen { #[default] Main, Login, } /// SSO is the primary sign-in path, so it sits right under the server URL; /// the password fields below are the rare fallback. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum LoginField { #[default] ServerUrl, SsoButton, Username, Password, SignInButton, } impl LoginField { const ORDER: [LoginField; 5] = [ LoginField::ServerUrl, LoginField::SsoButton, LoginField::Username, LoginField::Password, LoginField::SignInButton, ]; pub fn next(self) -> LoginField { let i = Self::ORDER.iter().position(|f| *f == self).unwrap(); Self::ORDER[(i + 1) % Self::ORDER.len()] } pub fn prev(self) -> LoginField { let i = Self::ORDER.iter().position(|f| *f == self).unwrap(); Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()] } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum LoginMode { /// Server / username / password fields plus the SSO button. #[default] Form, /// Browser SSO started; waiting for the pasted callback link or code. SsoPending, } #[derive(Debug)] pub struct LoginForm { pub server_url: String, pub username: String, pub password: String, pub sso_paste: String, pub sso_url: String, pub sso_port: Option, pub focus: LoginField, pub mode: LoginMode, pub busy: bool, pub error: Option, } impl Default for LoginForm { fn default() -> Self { Self { server_url: "https://music.hexor.cy".to_string(), username: String::new(), password: String::new(), sso_paste: String::new(), sso_url: String::new(), sso_port: None, focus: LoginField::default(), mode: LoginMode::default(), busy: false, error: None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Tab { #[default] Global, Playlists, Queue, Logs, } impl Tab { pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Logs]; pub fn title(self) -> &'static str { match self { Tab::Global => "Global", Tab::Playlists => "Playlists", Tab::Queue => "Queue", Tab::Logs => "Logs", } } pub fn index(self) -> usize { Self::ALL.iter().position(|t| *t == self).unwrap() } pub fn from_index(index: usize) -> Option { Self::ALL.get(index).copied() } pub fn next(self) -> Tab { Self::ALL[(self.index() + 1) % Self::ALL.len()] } pub fn prev(self) -> Tab { Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] } pub fn key_context(self) -> KeyContext { match self { Tab::Global => KeyContext::Library, Tab::Playlists => KeyContext::Playlists, Tab::Queue => KeyContext::Queue, Tab::Logs => KeyContext::Logs, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum RepeatMode { #[default] Off, One, All, } impl RepeatMode { pub fn label(self) -> &'static str { match self { RepeatMode::Off => "off", RepeatMode::One => "one", RepeatMode::All => "all", } } pub fn next(self) -> RepeatMode { match self { RepeatMode::Off => RepeatMode::All, RepeatMode::All => RepeatMode::One, RepeatMode::One => RepeatMode::Off, } } } /// Playback state mirrored for the UI: the queue, the loaded track and the /// position polled from the audio thread on every tick. #[derive(Debug)] pub struct PlayerBar { pub queue: Vec, pub queue_pos: usize, pub current: Option, /// A track is loaded (playing or paused); false = stopped. pub playing: bool, pub paused: bool, pub position_secs: f64, /// Epoch seconds when the current track started (for history reports). pub track_started_at: Option, /// Queue index already enqueued in the audio thread for gapless play. pub prefetched_pos: Option, pub volume: u8, pub shuffle: bool, /// Track ids in pre-shuffle order; restores the queue when shuffle is /// turned off. pub original_order: Option>, pub repeat: RepeatMode, } impl Default for PlayerBar { fn default() -> Self { Self { queue: Vec::new(), queue_pos: 0, current: None, playing: false, paused: false, position_secs: 0.0, track_started_at: None, prefetched_pos: None, original_order: None, volume: 80, shuffle: false, repeat: RepeatMode::Off, } } } /// Single source of truth for the UI. Mutated only by `update()` and the /// event handlers in the main loop; views render from `&AppState`. #[derive(Debug, Default)] pub struct AppState { pub screen: Screen, pub active_tab: Tab, pub should_quit: bool, /// Double-press quit confirmation: set by the first Quit press, expires /// after a short window (any other action also cancels it). pub quit_armed_until: Option, pub help_visible: bool, pub pending_keys: Option, pub status_message: Option, pub player: PlayerBar, pub login: LoginForm, pub user: Option, pub global: GlobalTab, pub artist_views: HashMap>, pub release_views: HashMap>, pub playlists: PlaylistsTab, pub playlist_views: HashMap>, /// Liked track ids, for the ♥ markers everywhere tracks are shown. pub likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, pub devices: DevicesState, pub queue_tab: QueueTab, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. pub pending_release_focus: Option<(i64, i64)>, /// Where a Shift-J jump came from (tab, stack depth of the pushed /// view): Esc from that view returns to the origin tab instead of /// unwinding the Global stack. pub jump_origin: Option<(Tab, usize)>, pub popup: Option, pub cmdline: Cmdline, pub search: SearchState, /// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by /// every view that shows artwork. pub art: HashMap, }