use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use crate::app::input::LineEdit; use crate::art::ArtImage; use crate::config::keymap::KeyContext; use crate::library::models::{ ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, SearchResults, TrackItem, }; /// Remote data that a view renders: spinner, content, or error. #[derive(Debug)] pub enum Loadable { Loading, Ready(T), Failed(String), } /// Tile geometry for the Library 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 Library 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, }, /// A federated artist card (data lives in `AppState::fed_artist_view`). FedArtist { cursor: usize, }, /// One release of the open federated card: row 0 is the /// download-release button, rows 1..=n are its tracks. FedRelease { index: usize, cursor: usize, }, } /// The Library 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 filters: crate::config::settings::LibraryFilters, pub stack: Vec, /// Page size, fixed at the first request — the offset is /// `(page-1) * limit`, so it must not change between pages. pub page_limit: Option, /// A full atomic reload is in flight (after a library change); incoming /// pages of the old pagination are dropped until it lands. pub reloading: bool, } 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(), filters: crate::config::settings::LibraryFilters::default(), stack: Vec::new(), page_limit: None, reloading: false, } } } /// Releases of an artist in display order: grouped by type (albums, singles, /// EPs, compilations, then anything else), newest first within each 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)> { release_type_groups( releases, |release| &release.release_type, |release| release.year, |release| &release.title, ) } fn release_type_groups( items: &[T], release_type: impl Fn(&T) -> &str, release_year: impl Fn(&T) -> Option, release_title: impl Fn(&T) -> &str, ) -> Vec<(&'static str, Vec)> { const GROUPS: [(&str, &str); 4] = [ ("album", "Albums"), ("single", "Singles"), ("ep", "EPs"), ("compilation", "Compilations"), ]; let mut groups: Vec<(&'static str, Vec)> = Vec::new(); for (kind, label) in GROUPS { let mut indices: Vec = items .iter() .enumerate() .filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind)) .map(|(i, _)| i) .collect(); sort_release_indices(&mut indices, items, &release_year, &release_title); if !indices.is_empty() { groups.push((label, indices)); } } let known: Vec = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect(); let mut other: Vec = (0..items.len()).filter(|i| !known.contains(i)).collect(); sort_release_indices(&mut other, items, &release_year, &release_title); if !other.is_empty() { groups.push(("Other", other)); } groups } fn sort_release_indices( indices: &mut [usize], items: &[T], release_year: &impl Fn(&T) -> Option, release_title: &impl Fn(&T) -> &str, ) { indices.sort_by(|&left, &right| { release_year(&items[right]) .unwrap_or(i32::MIN) .cmp(&release_year(&items[left]).unwrap_or(i32::MIN)) .then_with(|| release_title(&items[left]).cmp(release_title(&items[right]))) }); } /// 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> { grouped_release_rows(release_groups(releases), columns) } pub fn fed_release_groups( releases: &[crate::federation::FedRelease], ) -> Vec<(&'static str, Vec)> { release_type_groups( releases, |release| &release.release_type, |release| release.year, |release| &release.title, ) } /// Flattened display order of federated releases (concatenated groups). pub fn fed_release_display_order(releases: &[crate::federation::FedRelease]) -> Vec { fed_release_groups(releases) .into_iter() .flat_map(|(_, indices)| indices) .collect() } /// Visual tile-grid rows of the federated releases section. pub fn fed_release_rows( releases: &[crate::federation::FedRelease], columns: usize, ) -> Vec> { grouped_release_rows(fed_release_groups(releases), columns) } fn grouped_release_rows( groups: Vec<(&'static str, Vec)>, columns: usize, ) -> Vec> { let columns = columns.max(1); let mut rows = Vec::new(); let mut position = 0; for (_, group) in groups { for chunk in group.chunks(columns) { rows.push((position..position + chunk.len()).collect()); position += chunk.len(); } } rows } #[cfg(test)] mod tests { use super::*; fn release(id: i64, title: &str, release_type: &str, year: Option) -> ReleaseCard { ReleaseCard { id, title: title.to_string(), release_type: release_type.to_string(), year, cover_path: None, track_count: 1, } } fn fed_release( title: &str, release_type: &str, year: Option, ) -> crate::federation::FedRelease { crate::federation::FedRelease { title: title.to_string(), release_type: release_type.to_string(), year, ..Default::default() } } #[test] fn release_display_order_is_newest_first_within_each_type() { let releases = vec![ release(1, "Old Album", "album", Some(1991)), release(2, "New Single", "single", Some(2024)), release(3, "New Album", "album", Some(2020)), release(4, "Undated Album", "album", None), release(5, "Old Single", "single", Some(1999)), ]; assert_eq!(release_display_order(&releases), vec![2, 0, 3, 1, 4]); } #[test] fn fed_release_display_order_is_newest_first_within_each_type() { let releases = vec![ fed_release("Old Album", "album", Some(1991)), fed_release("New Single", "single", Some(2024)), fed_release("New Album", "album", Some(2020)), fed_release("Undated Album", "album", None), fed_release("Old Single", "single", Some(1999)), ]; assert_eq!(fed_release_display_order(&releases), vec![2, 0, 3, 1, 4]); } } /// The virtual Likes playlist id (`kind == "likes"`). pub use crate::library::LIKES_PLAYLIST_ID; #[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, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TrackSelectionScope { ArtistTop(i64), ArtistFeatured(i64), Release(i64), Playlist(i64), Queue, /// The federated section of the search results (its tracks). FedSearch, /// The tracklist of the open federated release view. FedRelease(usize), /// The appears-on track list of the open federated artist card. FedAppearsOn, } /// Vim-like Shift-V selection for line-oriented track lists. The selected /// range is always contiguous: anchor is where visual mode started, cursor is /// extended by normal navigation. #[derive(Debug, Clone, Default)] pub struct TrackSelection { pub scope: Option, pub anchor: usize, pub cursor: usize, } impl TrackSelection { pub fn is_active(&self) -> bool { self.scope.is_some() } pub fn is_active_for(&self, scope: &TrackSelectionScope) -> bool { self.scope.as_ref() == Some(scope) } pub fn start(&mut self, scope: TrackSelectionScope, cursor: usize) { self.scope = Some(scope); self.anchor = cursor; self.cursor = cursor; } pub fn clear(&mut self) { self.scope = None; self.anchor = 0; self.cursor = 0; } pub fn set_cursor(&mut self, scope: TrackSelectionScope, cursor: usize) { if self.scope.as_ref() == Some(&scope) { self.cursor = cursor; } } pub fn contains(&self, scope: &TrackSelectionScope, index: usize) -> bool { if self.scope.as_ref() != Some(scope) { return false; } let start = self.anchor.min(self.cursor); let end = self.anchor.max(self.cursor); (start..=end).contains(&index) } pub fn indices(&self, scope: &TrackSelectionScope, len: usize) -> Option> { if len == 0 || self.scope.as_ref() != Some(scope) { return None; } let start = self.anchor.min(self.cursor).min(len - 1); let end = self.anchor.max(self.cursor).min(len - 1); Some((start..=end).collect()) } } /// 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, } } } /// What an open edit form writes to when saved. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditTarget { Track(i64), Release(i64), Artist(i64), Playlist(i64), } /// What a confirmed delete removes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeleteTarget { Track(i64), Release(i64), Artist(i64), Playlist(i64), } /// One text field of an edit form. #[derive(Debug, Clone)] pub struct EditField { pub label: &'static str, pub value: LineEdit, } impl EditField { pub fn new(label: &'static str, value: impl Into) -> Self { Self { label, value: LineEdit::new(value), } } } /// What an add-to-playlist flow adds: local library tracks directly (including /// already-materialized federation placeholders), or federated search/card /// tracks that are downloaded into the library first. #[derive(Debug, Clone)] pub enum PlaylistAddTarget { Local(Vec), Fed(Vec), } impl PlaylistAddTarget { /// Short description for popup titles. pub fn label(&self) -> String { match self { PlaylistAddTarget::Local(tracks) if tracks.len() == 1 => tracks[0].title.clone(), PlaylistAddTarget::Fed(tracks) if tracks.len() == 1 => { format!("{} (federation)", tracks[0].title) } PlaylistAddTarget::Local(tracks) => format!("{} tracks", tracks.len()), PlaylistAddTarget::Fed(tracks) => format!("{} tracks (federation)", tracks.len()), } } } /// Modal dialog over the main screen. #[derive(Debug)] pub enum Popup { /// Pick one of the playlists (last row = "create new"); the target is /// added on Enter (federated tracks are downloaded first). AddToPlaylist { target: PlaylistAddTarget, cursor: usize, }, /// Name input for a new playlist; when `for_target` is set, it is /// added to the playlist right after creation. NewPlaylist { for_target: Option, input: LineEdit, busy: bool, }, /// Metadata edit form for a track, release, artist or playlist. Edit { target: EditTarget, title: String, fields: Vec, focus: usize, error: Option, }, /// Delete confirmation; Enter/y deletes, Esc/n cancels. ConfirmDelete { target: DeleteTarget, label: String }, /// Library-home filters. Cursor is kept for the next filters added here. LibraryFilters { cursor: usize }, /// Track metadata viewer; left/right switch between selected tracks. TrackInfo { tracks: Vec, cursor: usize, scroll: usize, }, /// Artist picker over the info popup ('a' with several artists): /// Enter jumps to the chosen artist's page, Esc returns to the info. TrackArtists { tracks: Vec, cursor: usize, scroll: usize, selected: usize, }, /// Full, wrapped view of one log entry (Enter on the Logs tab). LogDetail(crate::config::logging::LogEntry), /// One-line text entry on the Federation tab (network id, peer ticket). FedInput { field: FedInputField, input: LineEdit, }, /// Wrapped read-only text (this peer's connection ticket). FedText { title: String, text: String }, /// Incoming trusted-device pairing request. DevicePairing { request_id: String, device_id: String, name: String, client_version: String, requester_group_id: Option, requester_group_active_devices: usize, }, /// Confirmation before revoking a trusted device. ConfirmDeviceRevoke { device_id: String, name: String }, /// Connected playback devices and their current role/status. ConnectedDevices { cursor: usize }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FedInputField { NetworkId, ConnectTicket, DeviceName, ConnectInvite, } impl FedInputField { pub fn title(self) -> &'static str { match self { FedInputField::NetworkId => "Network ID", FedInputField::ConnectTicket => "Connect to peer (paste ticket)", FedInputField::DeviceName => "Device name", FedInputField::ConnectInvite => "Connect device (paste frid://i invite)", } } } /// Rows of the federation block inside Settings, in display order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FedRow { Toggle, NetworkId, SaveOnListen, SyncNow, ShowTicket, Connect, } impl FedRow { pub const ALL: [FedRow; 6] = [ FedRow::Toggle, FedRow::NetworkId, FedRow::SaveOnListen, FedRow::SyncNow, FedRow::ShowTicket, FedRow::Connect, ]; } /// Rows of the Settings tab, in display order. Federation rows are fixed; /// visualization script rows are derived from the scripts currently found in /// the config directory. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsRow { Federation(FedRow), DeviceName, DeviceInvite, DeviceConnect, DeviceSyncNow, Device(usize), VisualizationClock, VisualizationScript(usize), VisualizationNew, VisualizationEdit, } pub const DEVICE_ONLINE_TTL_MS: i64 = 45_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DevicePresenceSection { Online, Offline, Revoked, } impl DevicePresenceSection { pub fn title(self) -> &'static str { match self { DevicePresenceSection::Online => "Online devices", DevicePresenceSection::Offline => "Offline devices", DevicePresenceSection::Revoked => "Revoked devices", } } fn sort_rank(self) -> u8 { match self { DevicePresenceSection::Online => 0, DevicePresenceSection::Offline => 1, DevicePresenceSection::Revoked => 2, } } } pub fn unix_time_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) } pub fn device_display_name(device: &crate::devices::DeviceStatusRow) -> String { if device.name.trim().is_empty() { device.device_id.chars().take(10).collect() } else { device.name.clone() } } pub fn device_status_active(state: &AppState, device_id: &str) -> bool { state .device_playback .active_device_id .as_deref() .is_some_and(|active| active == device_id) || state .device_playback .remote .get(device_id) .is_some_and(|snapshot| snapshot.active) } pub fn device_status_online( state: &AppState, device: &crate::devices::DeviceStatusRow, now_ms: i64, ) -> bool { device.is_self || device.device_id == state.device_playback.self_device_id || device .last_seen_ms .is_some_and(|seen| now_ms.saturating_sub(seen) <= DEVICE_ONLINE_TTL_MS) || state .device_playback .remote .get(&device.device_id) .is_some_and(|snapshot| { now_ms.saturating_sub(snapshot.updated_at_ms) <= DEVICE_ONLINE_TTL_MS }) } pub fn device_presence_section( state: &AppState, device: &crate::devices::DeviceStatusRow, now_ms: i64, ) -> DevicePresenceSection { if device.revoked { DevicePresenceSection::Revoked } else if device_status_online(state, device, now_ms) { DevicePresenceSection::Online } else { DevicePresenceSection::Offline } } pub fn device_status_order(state: &AppState) -> Vec { let Some(status) = &state.federation.devices else { return Vec::new(); }; let now = unix_time_ms(); let mut indices: Vec = status .devices .iter() .enumerate() .filter_map(|(index, device)| (!device.revoked).then_some(index)) .collect(); indices.sort_by(|left, right| { let left_device = &status.devices[*left]; let right_device = &status.devices[*right]; let left_section = device_presence_section(state, left_device, now).sort_rank(); let right_section = device_presence_section(state, right_device, now).sort_rank(); ( left_section, !device_status_active(state, &left_device.device_id), !left_device.is_self, device_display_name(left_device).to_ascii_lowercase(), left_device.device_id.as_str(), ) .cmp(&( right_section, !device_status_active(state, &right_device.device_id), !right_device.is_self, device_display_name(right_device).to_ascii_lowercase(), right_device.device_id.as_str(), )) }); indices } pub fn settings_rows(state: &AppState) -> Vec { let mut rows = Vec::new(); rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation)); rows.push(SettingsRow::DeviceName); rows.push(SettingsRow::DeviceInvite); rows.push(SettingsRow::DeviceConnect); rows.push(SettingsRow::DeviceSyncNow); rows.extend( device_status_order(state) .into_iter() .map(SettingsRow::Device), ); rows.push(SettingsRow::VisualizationClock); rows.extend( state .visualizer .scripts .iter() .enumerate() .map(|(index, _)| SettingsRow::VisualizationScript(index)), ); rows.push(SettingsRow::VisualizationNew); if !state.visualizer.scripts.is_empty() { rows.push(SettingsRow::VisualizationEdit); } rows } /// Federation settings mirror + the latest status snapshot for Settings. #[derive(Debug, Default)] pub struct FederationTab { pub settings: crate::federation::FedSettings, pub status: Option, pub devices: Option, } /// 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.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: LineEdit, /// 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, /// Tracks found on the federated network (empty while federation is /// off); rendered as a separate, marked section. pub fed_tracks: Vec, /// Artists a federated card can be opened for — from artist records and /// from the artist names of matching tracks. pub fed_artists: Vec, pub fed_loading: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Tab { #[default] Global, Playlists, Queue, Federation, Logs, } impl Tab { pub const ALL: [Tab; 5] = [ Tab::Global, Tab::Playlists, Tab::Queue, Tab::Federation, Tab::Logs, ]; pub fn title(self) -> &'static str { match self { Tab::Global => "Library", Tab::Playlists => "Playlists", Tab::Queue => "Queue", Tab::Federation => "Settings", 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::Federation => KeyContext::Federation, 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, pub audio_analysis: crate::player::AudioAnalysisSnapshot, /// 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, audio_analysis: crate::player::AudioAnalysisSnapshot::default(), track_started_at: None, prefetched_pos: None, original_order: None, volume: 80, shuffle: false, repeat: RepeatMode::Off, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DevicePlaybackRole { #[default] Active, Control, } impl DevicePlaybackRole { pub fn label(self) -> &'static str { match self { DevicePlaybackRole::Active => "active", DevicePlaybackRole::Control => "control", } } } #[derive(Debug, Default)] pub struct DevicePlaybackState { pub role: DevicePlaybackRole, pub self_device_id: String, pub self_device_name: String, pub active_device_id: Option, pub active_device_name: Option, pub online_devices: usize, pub local_idle_since_ms: Option, pub remote: BTreeMap, pub last_remote_snapshot: Option, } impl DevicePlaybackState { pub fn is_control(&self) -> bool { self.role == DevicePlaybackRole::Control } pub fn active_label(&self) -> String { self.active_device_name .clone() .or_else(|| self.active_device_id.clone()) .unwrap_or_else(|| "this device".to_string()) } } /// 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 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 settings_cursor: usize, pub player: PlayerBar, pub device_playback: DevicePlaybackState, pub visualizer: crate::visualizer::VisualizerState, 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, /// Liked federated tracks (DHT item ids and content ids) — likes that /// reference peers' tracks without downloading them. pub fed_likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, pub queue_tab: QueueTab, pub federation: FederationTab, /// The one federated artist card being viewed (name + loading state); /// opening another card replaces it. pub fed_artist_view: Option<(String, Loadable)>, /// The "search this artist in the federation" button of the open local /// artist view has the focus (reached by pressing Up from the first /// row, like the download button on a federated release). pub artist_fed_button: bool, pub track_selection: TrackSelection, /// 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, } impl AppState { pub fn connected_devices_enabled(&self) -> bool { self.federation.settings.enabled && !self.federation.settings.network_id.trim().is_empty() } pub fn fed_track_liked(&self, fed: &crate::federation::FedTrack) -> bool { self.fed_likes.contains(&fed.item_id) || fed .content_id .as_deref() .and_then(music_dht::normalize_content_id) .is_some_and(|content_id| self.fed_likes.contains(&content_id)) } pub fn fed_card_track_liked(&self, track: &crate::federation::FedCardTrack) -> bool { track .content_id .as_deref() .and_then(music_dht::normalize_content_id) .is_some_and(|content_id| self.fed_likes.contains(&content_id)) || track .sources .iter() .any(|(_, item_id)| self.fed_likes.contains(item_id)) } }