From c45f2e700b5180a151fbc940061f947c34c1c8ca Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sat, 25 Jul 2026 23:24:49 +0300 Subject: [PATCH] Reworked library. Added local, my, fed traks overview. --- src/app/event.rs | 6 ++ src/app/mod.rs | 146 +++++++++++++++++++++++++++++++++++++ src/app/popup.rs | 3 + src/app/state.rs | 34 ++++++++- src/federation/mod.rs | 2 +- src/library/mod.rs | 163 +++++++++++++++++++++++++++++++++++++++--- src/ui/global.rs | 127 +++++++++++++++++++++++--------- src/ui/mod.rs | 79 +++++++++++++++++++- 8 files changed, 511 insertions(+), 49 deletions(-) diff --git a/src/app/event.rs b/src/app/event.rs index cde3809..4883e9c 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -52,6 +52,12 @@ pub enum AppEvent { }, /// Liked local content ids for the ♥ markers. LikesLoaded(Result, String>), + /// Local-library content ids for availability markers. + LocalContentIdsLoaded(Result, String>), + /// One content id became available locally while the UI is open. + LocalContentAvailable { + content_id: String, + }, LikeToggled { content_id: String, liked: bool, diff --git a/src/app/mod.rs b/src/app/mod.rs index 85c52f6..cb9d742 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -47,6 +47,8 @@ pub struct Runtime { Arc>>>, pub library_network_done: Arc>>, pub library_network_mode: crate::config::settings::LibrarySourceMode, + pub library_network_art_fetching: Arc, + pub library_network_art_attempted: Arc>>, /// Coalesces urgent personal-device syncs after remote playback commands. pub device_sync_running: Arc, pub device_sync_requested: Arc, @@ -84,6 +86,15 @@ fn err_string(err: anyhow::Error) -> String { format!("{err:#}") } +fn refresh_local_content_ids(runtime: &Runtime) { + let library = Arc::clone(&runtime.library); + let tx = runtime.event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = library.local_content_ids().map_err(err_string); + let _ = tx.send(AppEvent::LocalContentIdsLoaded(result)); + }); +} + fn spawn_content_id_backfill(runtime: &Runtime) { let library = Arc::clone(&runtime.library); let federation = Arc::clone(&runtime.federation); @@ -193,6 +204,10 @@ pub async fn run( library_network_cursors: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), library_network_done: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), library_network_mode: crate::config::settings::LibrarySourceMode::Local, + library_network_art_fetching: Arc::new(std::sync::atomic::AtomicBool::new(false)), + library_network_art_attempted: Arc::new(std::sync::Mutex::new( + std::collections::HashSet::new(), + )), device_sync_running: Arc::new(std::sync::atomic::AtomicBool::new(false)), device_sync_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()), @@ -254,6 +269,8 @@ pub async fn run( } if state.should_quit { + state.shutting_down = true; + terminal.draw(|frame| ui::draw(frame, &state, &keymap))?; runtime.federation.shutdown().await; return Ok(()); } @@ -778,6 +795,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { } maybe_refresh_network_library(state, runtime); + maybe_fetch_network_artist_images(state, runtime); // Liked ids load once per session — markers are shown everywhere. if !state.likes_loaded { @@ -791,6 +809,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { let _ = tx.send(AppEvent::FedLikesLoaded(result)); }); } + if !state.local_content_ids_loaded { + state.local_content_ids_loaded = true; + refresh_local_content_ids(runtime); + } // Playlists tab data (also wanted while the add-to-playlist picker is // open from any tab). @@ -933,6 +955,9 @@ fn maybe_refresh_network_library(state: &AppState, runtime: &mut Runtime) { if let Ok(mut done) = runtime.library_network_done.lock() { done.clear(); } + if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() { + attempted.clear(); + } } let near_end = state .global @@ -1026,6 +1051,103 @@ fn maybe_refresh_network_library(state: &AppState, runtime: &mut Runtime) { }); } +fn maybe_fetch_network_artist_images(state: &AppState, runtime: &mut Runtime) { + if state.active_tab != state::Tab::Global + || !state.global.stack.is_empty() + || !state.global.filters.source_mode.includes_network() + || !state.federation.settings.enabled + { + return; + } + let capacity = artist_grid_capacity().max(24); + let start = state.global.selected.saturating_sub(capacity / 2); + let names = state + .global + .artists + .iter() + .skip(start) + .take(capacity * 2) + .filter(|artist| artist.image_path.is_none() && artist.availability.is_remoteish()) + .map(|artist| artist.name.clone()) + .collect::>(); + if names.is_empty() { + return; + } + use std::sync::atomic::Ordering; + if runtime + .library_network_art_fetching + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + let filters = state.global.filters; + let library = Arc::clone(&runtime.library); + let federation = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + let attempted = Arc::clone(&runtime.library_network_art_attempted); + let running = Arc::clone(&runtime.library_network_art_fetching); + tokio::spawn(async move { + let query_library = Arc::clone(&library); + let requests = tokio::task::spawn_blocking(move || { + query_library.network_artist_image_requests(filters, &names, 8) + }) + .await + .map_err(|err| anyhow::anyhow!("network art query failed: {err:#}")) + .and_then(|result| result); + let requests = match requests { + Ok(requests) => requests, + Err(err) => { + tracing::debug!("network artist image requests failed: {err:#}"); + running.store(false, Ordering::SeqCst); + return; + } + }; + + for request in requests { + let attempt_key = format!("{}:{}", request.source_id, request.artist_key); + let should_try = attempted + .lock() + .map(|mut attempted| attempted.insert(attempt_key)) + .unwrap_or(false); + if !should_try { + continue; + } + let Some(path) = federation + .card_image( + std::slice::from_ref(&request.source_id), + &request.name, + None, + ) + .await + else { + continue; + }; + let update_library = Arc::clone(&library); + let source_id = request.source_id.clone(); + let artist_key = request.artist_key.clone(); + let saved = tokio::task::spawn_blocking(move || { + update_library.set_network_artist_image(&source_id, &artist_key, &path) + }) + .await + .map_err(|err| anyhow::anyhow!("network art save failed: {err:#}")) + .and_then(|result| result); + match saved { + Ok(true) => { + let _ = tx.send(AppEvent::NetworkArtistCacheUpdated { + source_id: request.source_id, + count: 1, + }); + } + Ok(false) => {} + Err(err) => tracing::debug!("network artist image save failed: {err:#}"), + } + } + running.store(false, Ordering::SeqCst); + }); +} + /// Load and decode a local image file for the art cache. fn spawn_art_fetch(runtime: &Runtime, key: String, path: String, width: u16, height: u16) { let tx = runtime.event_tx.clone(); @@ -2075,6 +2197,9 @@ pub(crate) fn fed_download_spawn( let progress = download_progress_sender(tx.clone(), title); match fed.download_to_library_with_progress(track, progress).await { Ok(imported) => { + if let Some(content_id) = track_content_id(&imported) { + let _ = tx.send(AppEvent::LocalContentAvailable { content_id }); + } imported_ids.push(imported.id); imported_fed_tracks.push(track.clone()); } @@ -2288,6 +2413,7 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) { // Likes reload on the next maintenance pass; the old set stays visible // until then. state.likes_loaded = false; + state.local_content_ids_loaded = false; // Fresh copies of whatever sits in the queue. Federated placeholders // and ephemeral tracks (negative ids) are not library rows and keep @@ -2747,6 +2873,12 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent match result { Ok(playable) => { if playable.imported { + if let Some(content_id) = state::track_content_id(&playable.track) { + state.local_content_ids.insert(content_id.clone()); + let _ = runtime + .event_tx + .send(AppEvent::LocalContentAvailable { content_id }); + } // Save-on-listen imported the file; refresh the // library views through the standard change path. let _ = runtime.event_tx.send(AppEvent::LibraryChanged { @@ -3072,6 +3204,20 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } Err(message) => tracing::warn!(%message, "likes load failed"), }, + AppEvent::LocalContentIdsLoaded(result) => match result { + Ok(ids) => { + state.local_content_ids = ids.into_iter().collect(); + } + Err(message) => { + state.local_content_ids_loaded = false; + tracing::warn!(%message, "local content id load failed"); + } + }, + AppEvent::LocalContentAvailable { content_id } => { + if let Some(content_id) = music_dht::normalize_content_id(&content_id) { + state.local_content_ids.insert(content_id); + } + } AppEvent::FedLikesLoaded(result) => match result { Ok(ids) => state.fed_likes = ids.into_iter().collect(), Err(message) => tracing::warn!(%message, "federated likes load failed"), diff --git a/src/app/popup.rs b/src/app/popup.rs index 08c214b..3da5aad 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -261,6 +261,9 @@ fn handle_library_filters( if let Ok(mut done) = runtime.library_network_done.lock() { done.clear(); } + if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() { + attempted.clear(); + } super::save_app_settings(state); super::reset_artist_pagination(state); super::refresh_artists(state, runtime); diff --git a/src/app/state.rs b/src/app/state.rs index cf1fa8b..02f4499 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use crate::app::input::LineEdit; @@ -979,6 +979,7 @@ impl DevicePlaybackState { pub struct AppState { pub active_tab: Tab, pub should_quit: bool, + pub shutting_down: 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, @@ -996,11 +997,14 @@ pub struct AppState { pub playlists: PlaylistsTab, pub playlist_views: HashMap>, /// Liked local-library content ids, for the ♥ markers everywhere tracks are shown. - pub likes: std::collections::HashSet, + pub likes: 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 fed_likes: HashSet, + /// Content ids that currently have a local playable file. + pub local_content_ids: HashSet, pub likes_loaded: bool, + pub local_content_ids_loaded: bool, pub logs: LogsTab, pub queue_tab: QueueTab, pub federation: FederationTab, @@ -1066,6 +1070,30 @@ impl AppState { .any(|(_, item_id)| self.fed_likes.contains(item_id)) } + pub fn content_id_local(&self, content_id: &str) -> bool { + music_dht::normalize_content_id(content_id) + .is_some_and(|content_id| self.local_content_ids.contains(&content_id)) + } + + pub fn fed_track_local(&self, track: &crate::federation::FedTrack) -> bool { + track + .content_id + .as_deref() + .is_some_and(|content_id| self.content_id_local(content_id)) + } + + pub fn fed_card_track_local(&self, track: &crate::federation::FedCardTrack) -> bool { + track + .content_id + .as_deref() + .is_some_and(|content_id| self.content_id_local(content_id)) + } + + pub fn track_content_local(&self, track: &TrackItem) -> bool { + track_content_id(track) + .is_some_and(|content_id| self.local_content_ids.contains(&content_id)) + } + pub fn track_liked(&self, track: &TrackItem) -> bool { if let Some(content_id) = track_content_id(track) { return self.likes.contains(&content_id) || self.fed_likes.contains(&content_id); diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 0f31757..09711f0 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -942,7 +942,7 @@ impl Federation { artist.artist_key }, name: artist.name, - image_path: None, + image_path: artist.image_path, release_count: artist.release_count, track_count: artist.track_count, }) diff --git a/src/library/mod.rs b/src/library/mod.rs index a4b10ac..aa6158e 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -10,7 +10,7 @@ pub mod import; pub mod models; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -139,6 +139,7 @@ CREATE TABLE IF NOT EXISTS network_artist_cache ( artist_key TEXT NOT NULL, name TEXT NOT NULL, image_path TEXT, + remote_image_hint TEXT, release_count INTEGER NOT NULL DEFAULT 0, track_count INTEGER NOT NULL DEFAULT 0, seen_at_ms INTEGER NOT NULL, @@ -217,6 +218,13 @@ pub struct NetworkArtistPreview { pub track_count: i64, } +#[derive(Debug, Clone)] +pub struct NetworkArtistImageRequest { + pub source_id: String, + pub artist_key: String, + pub name: String, +} + pub struct Library { conn: Mutex, /// Directory where extracted embedded covers are stored. @@ -595,7 +603,7 @@ impl Library { source_id: &str, source_kind: &str, artists: &[NetworkArtistPreview], - replace_source: bool, + _replace_source: bool, ) -> Result { let source_id = source_id.trim(); let source_kind = source_kind.trim(); @@ -605,23 +613,18 @@ impl Library { let now = now_ms_i64(); let mut conn = self.lock(); let tx = conn.transaction()?; - if replace_source { - tx.execute( - "DELETE FROM network_artist_cache WHERE source_id = ?1", - [source_id], - )?; - } let mut inserted = 0usize; { let mut stmt = tx.prepare( "INSERT INTO network_artist_cache - (source_id, source_kind, artist_key, name, image_path, + (source_id, source_kind, artist_key, name, image_path, remote_image_hint, release_count, track_count, seen_at_ms) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(source_id, artist_key) DO UPDATE SET source_kind = excluded.source_kind, name = excluded.name, - image_path = excluded.image_path, + image_path = COALESCE(network_artist_cache.image_path, excluded.image_path), + remote_image_hint = excluded.remote_image_hint, release_count = excluded.release_count, track_count = excluded.track_count, seen_at_ms = excluded.seen_at_ms", @@ -640,6 +643,7 @@ impl Library { source_kind, artist_key, artist.name.trim(), + Option::<&str>::None, artist.image_path.as_deref(), artist.release_count.max(0), artist.track_count.max(0), @@ -652,6 +656,76 @@ impl Library { Ok(inserted) } + pub fn network_artist_image_requests( + &self, + filters: crate::config::settings::LibraryFilters, + artist_names: &[String], + limit: usize, + ) -> Result> { + if !filters.source_mode.includes_network() || artist_names.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let conn = self.lock(); + let cutoff = now_ms_i64().saturating_sub(NETWORK_ARTIST_CACHE_TTL_MS); + let source_predicate = if filters.source_mode.includes_global_peers() { + "seen_at_ms >= ?2" + } else { + "seen_at_ms >= ?2 AND source_kind = 'personal'" + }; + let sql = format!( + "SELECT source_id, artist_key, name + FROM network_artist_cache + WHERE artist_key = ?1 + AND {source_predicate} + AND image_path IS NULL + AND remote_image_hint IS NOT NULL + AND remote_image_hint <> '' + ORDER BY CASE source_kind WHEN 'personal' THEN 0 ELSE 1 END, + seen_at_ms DESC + LIMIT 1" + ); + let mut statement = conn.prepare(&sql)?; + let mut seen_keys = HashSet::new(); + let mut requests = Vec::new(); + for name in artist_names { + let artist_key = music_dht::normalize_name(name); + if artist_key.is_empty() || !seen_keys.insert(artist_key.clone()) { + continue; + } + let row = statement + .query_row(params![artist_key, cutoff], |row| { + Ok(NetworkArtistImageRequest { + source_id: row.get(0)?, + artist_key: row.get(1)?, + name: row.get(2)?, + }) + }) + .optional()?; + if let Some(request) = row { + requests.push(request); + if requests.len() >= limit { + break; + } + } + } + Ok(requests) + } + + pub fn set_network_artist_image( + &self, + source_id: &str, + artist_key: &str, + image_path: &str, + ) -> Result { + let changed = self.lock().execute( + "UPDATE network_artist_cache + SET image_path = ?3 + WHERE source_id = ?1 AND artist_key = ?2", + params![source_id, artist_key, image_path], + )?; + Ok(changed > 0) + } + pub fn artist(&self, id: i64) -> Result { let conn = self.lock(); let (name, image_path): (String, Option) = conn @@ -1565,6 +1639,22 @@ impl Library { .collect()) } + pub fn local_content_ids(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT DISTINCT content_id + FROM tracks + WHERE content_id IS NOT NULL", + )?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok(rows + .into_iter() + .filter_map(|content_id| music_dht::normalize_content_id(&content_id)) + .collect()) + } + /// Returns the new liked state for this content id. pub fn toggle_like_by_content_id(&self, content_id: &str) -> Result { let Some(content_id) = music_dht::normalize_content_id(content_id) else { @@ -2521,6 +2611,16 @@ fn ensure_schema_migrations(conn: &Connection) -> Result<()> { [], )?; } + let network_artist_columns = table_columns(conn, "network_artist_cache")?; + if !network_artist_columns + .iter() + .any(|column| column == "remote_image_hint") + { + conn.execute( + "ALTER TABLE network_artist_cache ADD COLUMN remote_image_hint TEXT", + [], + )?; + } let mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?; let missing = rows .query_map([], |row| { @@ -2645,6 +2745,47 @@ mod tests { assert!(!filtered.items.iter().any(|artist| artist.name == "Guest")); } + #[test] + fn network_artist_image_hint_becomes_local_image_after_fetch() { + let lib = test_library(); + let artist_key = music_dht::normalize_name("Remote Artist"); + lib.replace_network_artist_cache( + "peer-a", + "personal", + &[NetworkArtistPreview { + artist_key: artist_key.clone(), + name: "Remote Artist".into(), + image_path: Some("peer-local/image.jpg".into()), + release_count: 1, + track_count: 3, + }], + true, + ) + .unwrap(); + + let filters = crate::config::settings::LibraryFilters { + source_mode: crate::config::settings::LibrarySourceMode::My, + ..Default::default() + }; + let page = lib.artists(1, 10, filters).unwrap(); + assert_eq!(page.items[0].image_path, None); + + let requests = lib + .network_artist_image_requests(filters, &["Remote Artist".into()], 8) + .unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].source_id, "peer-a"); + assert_eq!(requests[0].artist_key, artist_key); + + lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg") + .unwrap(); + let page = lib.artists(1, 10, filters).unwrap(); + assert_eq!( + page.items[0].image_path.as_deref(), + Some("/tmp/remote-artist.jpg") + ); + } + #[test] fn import_creates_artist_release_track() { let lib = test_library(); diff --git a/src/ui/global.rs b/src/ui/global.rs index 70974ae..7e9b6e8 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -5,7 +5,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Row, Table}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::{art, theme}; +use super::{art, availability_marker, availability_prefix, theme}; use crate::app::state::{ ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState, GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, fed_release_display_order, @@ -107,9 +107,6 @@ fn draw_tile_with_availability( ..inner }; draw_art(frame, art_area, art_state); - if let Some(availability) = availability { - draw_availability_badge(frame, art_area, availability); - } if inner.height > ART_CELL_HEIGHT { let name_area = Rect { @@ -131,35 +128,56 @@ fn draw_tile_with_availability( height: 1, ..inner }; - frame.render_widget( - Paragraph::new(Line::styled(meta.to_string(), theme::dim())), - meta_area, - ); - if selected { - frame.buffer_mut().set_style(meta_area, theme::tab_active()); - } + draw_tile_meta(frame, meta_area, meta, availability, selected); } } -fn draw_availability_badge(frame: &mut Frame, area: Rect, availability: Availability) { - if area.width < 2 || area.height == 0 { - return; - } - let (label, style) = match availability { - Availability::Local => ("●", Style::new().fg(Color::Green)), - Availability::Mixed => ("◐", Style::new().fg(Color::Yellow)), - Availability::Remote => ("⇅", theme::accent()), - }; - let badge = Rect { - x: area.x + area.width.saturating_sub(2), - y: area.y, - width: 2, - height: 1, +fn draw_tile_meta( + frame: &mut Frame, + area: Rect, + meta: &str, + availability: Option, + selected: bool, +) { + let marker = availability.map(|availability| availability_marker(availability, selected)); + let marker_width = marker + .map(|(label, _)| UnicodeWidthStr::width(label) as u16) + .unwrap_or(0) + .max(u16::from(marker.is_some()) * 2) + .min(area.width); + let marker_pad = u16::from(marker_width > 0 && area.width > marker_width); + let reserved_width = marker_width.saturating_add(marker_pad).min(area.width); + let text_area = if reserved_width > 0 && area.width > reserved_width { + Rect { + width: area.width - reserved_width, + ..area + } + } else { + area }; frame.render_widget( - Paragraph::new(Line::styled(label, style)).alignment(Alignment::Right), - badge, + Paragraph::new(Line::styled(meta.to_string(), theme::dim())), + text_area, ); + if selected { + frame.buffer_mut().set_style(area, theme::tab_active()); + } + if let Some((label, style)) = marker + && marker_width > 0 + { + let marker_area = Rect { + x: area + .x + .saturating_add(area.width.saturating_sub(reserved_width)), + y: area.y, + width: marker_width, + height: 1, + }; + frame.render_widget( + Paragraph::new(Line::styled(label, style)).alignment(Alignment::Right), + marker_area, + ); + } } fn tile_title(title: &str, width: u16, selected: bool) -> String { @@ -173,6 +191,49 @@ fn tile_title(title: &str, width: u16, selected: bool) -> String { marquee_window(title, width) } +fn fed_track_availability_prefix( + state: &AppState, + track: &crate::federation::FedTrack, +) -> Span<'static> { + let availability = if state.fed_track_local(track) { + Availability::Local + } else { + Availability::Remote + }; + availability_prefix(availability) +} + +fn fed_card_track_availability_prefix( + state: &AppState, + track: &crate::federation::FedCardTrack, +) -> Span<'static> { + let availability = if state.fed_card_track_local(track) { + Availability::Local + } else { + Availability::Remote + }; + availability_prefix(availability) +} + +fn fed_release_availability( + state: &AppState, + release: &crate::federation::FedRelease, +) -> Availability { + if release.tracks.is_empty() { + return Availability::Remote; + } + let local = release + .tracks + .iter() + .filter(|track| state.fed_card_track_local(track)) + .count(); + match local { + 0 => Availability::Remote, + count if count == release.tracks.len() => Availability::Local, + _ => Availability::Mixed, + } +} + fn marquee_window(title: &str, width: usize) -> String { let stream = format!("{title}{TILE_MARQUEE_GAP}"); let cells: Vec<(char, usize)> = stream @@ -331,7 +392,7 @@ fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) { } fn artist_tile_meta(artist: &ArtistCard) -> String { - format!("{} rel · {} trk", artist.release_count, artist.track_count) + format!("{} rel {} trk", artist.release_count, artist.track_count) } fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) { @@ -869,7 +930,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { for hit in &state.search.fed_artists { rows.push(( Line::from(vec![ - Span::styled("⇅ ", theme::accent()), + availability_prefix(Availability::Remote), Span::raw(hit.name.clone()), Span::styled(" artist · open the card", theme::dim()), ]), @@ -903,7 +964,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { rows.push(( Line::from(vec![ heart, - Span::styled("⇅ ", theme::accent()), + fed_track_availability_prefix(state, fed), Span::raw(fed.title.clone()), Span::styled( format!(" {} · {}", fed.artist_line(), origin), @@ -1093,7 +1154,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz &release.title, &meta, cursor == *position, - Some(Availability::Remote), + Some(fed_release_availability(state, release)), ); } } @@ -1143,7 +1204,7 @@ fn draw_fed_appearance_row( let line = Line::from(vec![ Span::styled(format!("{number:>3} "), theme::dim()), heart, - Span::styled("⇅ ", theme::accent()), + fed_card_track_availability_prefix(state, track), Span::raw(track.title.clone()), Span::styled(format!(" {context}"), theme::dim()), ]); @@ -1318,7 +1379,7 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz }; let line = Line::from(vec![ heart, - Span::styled("⇅ ", theme::accent()), + fed_card_track_availability_prefix(state, track), Span::raw(format!("{number}{}", track.title)), ]); if in_selection && cursor != position + 1 { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9af2ef8..1c4a51a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,10 +15,35 @@ use ratatui::widgets::{Block, Clear, Paragraph, Tabs}; use crate::app::input::LineEdit; use crate::app::state::{AppState, Tab, TrackSelectionScope}; use crate::config::keymap::Keymap; +use crate::library::models::Availability; + +pub(crate) fn availability_marker( + availability: Availability, + selected: bool, +) -> (&'static str, Style) { + let (label, style) = match availability { + Availability::Local => ("●", Style::new().fg(Color::Green)), + Availability::Mixed => ("◐", Style::new().fg(Color::Yellow)), + Availability::Remote => ("⇅", theme::accent()), + }; + if selected { + (label, theme::tab_active()) + } else { + (label, style) + } +} + +pub(crate) fn availability_prefix(availability: Availability) -> Span<'static> { + let (label, style) = availability_marker(availability, false); + Span::styled(format!("{label} "), style) +} pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { if state.visualizer.active { crate::visualizer::draw(frame, state); + if state.shutting_down { + draw_shutdown(frame); + } return; } @@ -43,6 +68,37 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { draw_help(frame, keymap); } popup::draw(frame, state); + if state.shutting_down { + draw_shutdown(frame); + } +} + +fn draw_shutdown(frame: &mut Frame) { + let area = centered(frame.area(), 28, 5); + frame.render_widget(Clear, area); + let block = Block::bordered().border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget( + Paragraph::new(Line::styled("Shutting down...", theme::header())) + .alignment(Alignment::Center), + Rect { + y: inner.y + inner.height / 2, + height: 1, + ..inner + }, + ); +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + } } pub(crate) fn loading_line(state: &AppState, text: impl Into) -> Line<'static> { @@ -118,7 +174,12 @@ pub(crate) fn track_row_with_like_marker( Span::raw(" ") }; let fed_marker = if track.fed.is_some() { - Span::styled("⇅ ", theme::accent()) + let availability = if state.track_content_local(track) { + Availability::Local + } else { + Availability::Remote + }; + availability_prefix(availability) } else { Span::raw("") }; @@ -468,6 +529,22 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) { lines.push(Line::default()); blocks.push(lines); } + blocks.push(vec![ + Line::styled("Status icons", theme::header()), + Line::from(vec![ + Span::styled("●", Style::new().fg(Color::Green)), + Span::raw(" Local on this device"), + ]), + Line::from(vec![ + Span::styled("◐", Style::new().fg(Color::Yellow)), + Span::raw(" Local + peer sources"), + ]), + Line::from(vec![ + Span::styled("⇅", theme::accent()), + Span::raw(" Network only"), + ]), + Line::default(), + ]); // Balance the blocks across two columns. let total: usize = blocks.iter().map(Vec::len).sum();