Reworked library. Added local, my, fed traks overview.
This commit is contained in:
@@ -52,6 +52,12 @@ pub enum AppEvent {
|
|||||||
},
|
},
|
||||||
/// Liked local content ids for the ♥ markers.
|
/// Liked local content ids for the ♥ markers.
|
||||||
LikesLoaded(Result<Vec<String>, String>),
|
LikesLoaded(Result<Vec<String>, String>),
|
||||||
|
/// Local-library content ids for availability markers.
|
||||||
|
LocalContentIdsLoaded(Result<Vec<String>, String>),
|
||||||
|
/// One content id became available locally while the UI is open.
|
||||||
|
LocalContentAvailable {
|
||||||
|
content_id: String,
|
||||||
|
},
|
||||||
LikeToggled {
|
LikeToggled {
|
||||||
content_id: String,
|
content_id: String,
|
||||||
liked: bool,
|
liked: bool,
|
||||||
|
|||||||
+146
@@ -47,6 +47,8 @@ pub struct Runtime {
|
|||||||
Arc<std::sync::Mutex<std::collections::HashMap<String, Option<String>>>>,
|
Arc<std::sync::Mutex<std::collections::HashMap<String, Option<String>>>>,
|
||||||
pub library_network_done: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
pub library_network_done: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
||||||
pub library_network_mode: crate::config::settings::LibrarySourceMode,
|
pub library_network_mode: crate::config::settings::LibrarySourceMode,
|
||||||
|
pub library_network_art_fetching: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
pub library_network_art_attempted: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
||||||
/// Coalesces urgent personal-device syncs after remote playback commands.
|
/// Coalesces urgent personal-device syncs after remote playback commands.
|
||||||
pub device_sync_running: Arc<std::sync::atomic::AtomicBool>,
|
pub device_sync_running: Arc<std::sync::atomic::AtomicBool>,
|
||||||
pub device_sync_requested: Arc<std::sync::atomic::AtomicBool>,
|
pub device_sync_requested: Arc<std::sync::atomic::AtomicBool>,
|
||||||
@@ -84,6 +86,15 @@ fn err_string(err: anyhow::Error) -> String {
|
|||||||
format!("{err:#}")
|
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) {
|
fn spawn_content_id_backfill(runtime: &Runtime) {
|
||||||
let library = Arc::clone(&runtime.library);
|
let library = Arc::clone(&runtime.library);
|
||||||
let federation = Arc::clone(&runtime.federation);
|
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_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_done: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||||
library_network_mode: crate::config::settings::LibrarySourceMode::Local,
|
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_running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
device_sync_requested: 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()),
|
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||||
@@ -254,6 +269,8 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if state.should_quit {
|
if state.should_quit {
|
||||||
|
state.shutting_down = true;
|
||||||
|
terminal.draw(|frame| ui::draw(frame, &state, &keymap))?;
|
||||||
runtime.federation.shutdown().await;
|
runtime.federation.shutdown().await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -778,6 +795,7 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maybe_refresh_network_library(state, runtime);
|
maybe_refresh_network_library(state, runtime);
|
||||||
|
maybe_fetch_network_artist_images(state, runtime);
|
||||||
|
|
||||||
// Liked ids load once per session — markers are shown everywhere.
|
// Liked ids load once per session — markers are shown everywhere.
|
||||||
if !state.likes_loaded {
|
if !state.likes_loaded {
|
||||||
@@ -791,6 +809,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
let _ = tx.send(AppEvent::FedLikesLoaded(result));
|
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
|
// Playlists tab data (also wanted while the add-to-playlist picker is
|
||||||
// open from any tab).
|
// 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() {
|
if let Ok(mut done) = runtime.library_network_done.lock() {
|
||||||
done.clear();
|
done.clear();
|
||||||
}
|
}
|
||||||
|
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
|
||||||
|
attempted.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let near_end = state
|
let near_end = state
|
||||||
.global
|
.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::<Vec<_>>();
|
||||||
|
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.
|
/// 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) {
|
fn spawn_art_fetch(runtime: &Runtime, key: String, path: String, width: u16, height: u16) {
|
||||||
let tx = runtime.event_tx.clone();
|
let tx = runtime.event_tx.clone();
|
||||||
@@ -2075,6 +2197,9 @@ pub(crate) fn fed_download_spawn(
|
|||||||
let progress = download_progress_sender(tx.clone(), title);
|
let progress = download_progress_sender(tx.clone(), title);
|
||||||
match fed.download_to_library_with_progress(track, progress).await {
|
match fed.download_to_library_with_progress(track, progress).await {
|
||||||
Ok(imported) => {
|
Ok(imported) => {
|
||||||
|
if let Some(content_id) = track_content_id(&imported) {
|
||||||
|
let _ = tx.send(AppEvent::LocalContentAvailable { content_id });
|
||||||
|
}
|
||||||
imported_ids.push(imported.id);
|
imported_ids.push(imported.id);
|
||||||
imported_fed_tracks.push(track.clone());
|
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
|
// Likes reload on the next maintenance pass; the old set stays visible
|
||||||
// until then.
|
// until then.
|
||||||
state.likes_loaded = false;
|
state.likes_loaded = false;
|
||||||
|
state.local_content_ids_loaded = false;
|
||||||
|
|
||||||
// Fresh copies of whatever sits in the queue. Federated placeholders
|
// Fresh copies of whatever sits in the queue. Federated placeholders
|
||||||
// and ephemeral tracks (negative ids) are not library rows and keep
|
// 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 {
|
match result {
|
||||||
Ok(playable) => {
|
Ok(playable) => {
|
||||||
if playable.imported {
|
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
|
// Save-on-listen imported the file; refresh the
|
||||||
// library views through the standard change path.
|
// library views through the standard change path.
|
||||||
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
|
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"),
|
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 {
|
AppEvent::FedLikesLoaded(result) => match result {
|
||||||
Ok(ids) => state.fed_likes = ids.into_iter().collect(),
|
Ok(ids) => state.fed_likes = ids.into_iter().collect(),
|
||||||
Err(message) => tracing::warn!(%message, "federated likes load failed"),
|
Err(message) => tracing::warn!(%message, "federated likes load failed"),
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ fn handle_library_filters(
|
|||||||
if let Ok(mut done) = runtime.library_network_done.lock() {
|
if let Ok(mut done) = runtime.library_network_done.lock() {
|
||||||
done.clear();
|
done.clear();
|
||||||
}
|
}
|
||||||
|
if let Ok(mut attempted) = runtime.library_network_art_attempted.lock() {
|
||||||
|
attempted.clear();
|
||||||
|
}
|
||||||
super::save_app_settings(state);
|
super::save_app_settings(state);
|
||||||
super::reset_artist_pagination(state);
|
super::reset_artist_pagination(state);
|
||||||
super::refresh_artists(state, runtime);
|
super::refresh_artists(state, runtime);
|
||||||
|
|||||||
+31
-3
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::app::input::LineEdit;
|
use crate::app::input::LineEdit;
|
||||||
@@ -979,6 +979,7 @@ impl DevicePlaybackState {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub active_tab: Tab,
|
pub active_tab: Tab,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
|
pub shutting_down: bool,
|
||||||
/// Double-press quit confirmation: set by the first Quit press, expires
|
/// Double-press quit confirmation: set by the first Quit press, expires
|
||||||
/// after a short window (any other action also cancels it).
|
/// after a short window (any other action also cancels it).
|
||||||
pub quit_armed_until: Option<std::time::Instant>,
|
pub quit_armed_until: Option<std::time::Instant>,
|
||||||
@@ -996,11 +997,14 @@ pub struct AppState {
|
|||||||
pub playlists: PlaylistsTab,
|
pub playlists: PlaylistsTab,
|
||||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||||
/// Liked local-library content ids, for the ♥ markers everywhere tracks are shown.
|
/// Liked local-library content ids, for the ♥ markers everywhere tracks are shown.
|
||||||
pub likes: std::collections::HashSet<String>,
|
pub likes: HashSet<String>,
|
||||||
/// Liked federated tracks (DHT item ids and content ids) — likes that
|
/// Liked federated tracks (DHT item ids and content ids) — likes that
|
||||||
/// reference peers' tracks without downloading them.
|
/// reference peers' tracks without downloading them.
|
||||||
pub fed_likes: std::collections::HashSet<String>,
|
pub fed_likes: HashSet<String>,
|
||||||
|
/// Content ids that currently have a local playable file.
|
||||||
|
pub local_content_ids: HashSet<String>,
|
||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
|
pub local_content_ids_loaded: bool,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub queue_tab: QueueTab,
|
pub queue_tab: QueueTab,
|
||||||
pub federation: FederationTab,
|
pub federation: FederationTab,
|
||||||
@@ -1066,6 +1070,30 @@ impl AppState {
|
|||||||
.any(|(_, item_id)| self.fed_likes.contains(item_id))
|
.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 {
|
pub fn track_liked(&self, track: &TrackItem) -> bool {
|
||||||
if let Some(content_id) = track_content_id(track) {
|
if let Some(content_id) = track_content_id(track) {
|
||||||
return self.likes.contains(&content_id) || self.fed_likes.contains(&content_id);
|
return self.likes.contains(&content_id) || self.fed_likes.contains(&content_id);
|
||||||
|
|||||||
@@ -942,7 +942,7 @@ impl Federation {
|
|||||||
artist.artist_key
|
artist.artist_key
|
||||||
},
|
},
|
||||||
name: artist.name,
|
name: artist.name,
|
||||||
image_path: None,
|
image_path: artist.image_path,
|
||||||
release_count: artist.release_count,
|
release_count: artist.release_count,
|
||||||
track_count: artist.track_count,
|
track_count: artist.track_count,
|
||||||
})
|
})
|
||||||
|
|||||||
+152
-11
@@ -10,7 +10,7 @@
|
|||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -139,6 +139,7 @@ CREATE TABLE IF NOT EXISTS network_artist_cache (
|
|||||||
artist_key TEXT NOT NULL,
|
artist_key TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
image_path TEXT,
|
image_path TEXT,
|
||||||
|
remote_image_hint TEXT,
|
||||||
release_count INTEGER NOT NULL DEFAULT 0,
|
release_count INTEGER NOT NULL DEFAULT 0,
|
||||||
track_count INTEGER NOT NULL DEFAULT 0,
|
track_count INTEGER NOT NULL DEFAULT 0,
|
||||||
seen_at_ms INTEGER NOT NULL,
|
seen_at_ms INTEGER NOT NULL,
|
||||||
@@ -217,6 +218,13 @@ pub struct NetworkArtistPreview {
|
|||||||
pub track_count: i64,
|
pub track_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NetworkArtistImageRequest {
|
||||||
|
pub source_id: String,
|
||||||
|
pub artist_key: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
/// Directory where extracted embedded covers are stored.
|
/// Directory where extracted embedded covers are stored.
|
||||||
@@ -595,7 +603,7 @@ impl Library {
|
|||||||
source_id: &str,
|
source_id: &str,
|
||||||
source_kind: &str,
|
source_kind: &str,
|
||||||
artists: &[NetworkArtistPreview],
|
artists: &[NetworkArtistPreview],
|
||||||
replace_source: bool,
|
_replace_source: bool,
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let source_id = source_id.trim();
|
let source_id = source_id.trim();
|
||||||
let source_kind = source_kind.trim();
|
let source_kind = source_kind.trim();
|
||||||
@@ -605,23 +613,18 @@ impl Library {
|
|||||||
let now = now_ms_i64();
|
let now = now_ms_i64();
|
||||||
let mut conn = self.lock();
|
let mut conn = self.lock();
|
||||||
let tx = conn.transaction()?;
|
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 inserted = 0usize;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO network_artist_cache
|
"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)
|
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
|
ON CONFLICT(source_id, artist_key) DO UPDATE SET
|
||||||
source_kind = excluded.source_kind,
|
source_kind = excluded.source_kind,
|
||||||
name = excluded.name,
|
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,
|
release_count = excluded.release_count,
|
||||||
track_count = excluded.track_count,
|
track_count = excluded.track_count,
|
||||||
seen_at_ms = excluded.seen_at_ms",
|
seen_at_ms = excluded.seen_at_ms",
|
||||||
@@ -640,6 +643,7 @@ impl Library {
|
|||||||
source_kind,
|
source_kind,
|
||||||
artist_key,
|
artist_key,
|
||||||
artist.name.trim(),
|
artist.name.trim(),
|
||||||
|
Option::<&str>::None,
|
||||||
artist.image_path.as_deref(),
|
artist.image_path.as_deref(),
|
||||||
artist.release_count.max(0),
|
artist.release_count.max(0),
|
||||||
artist.track_count.max(0),
|
artist.track_count.max(0),
|
||||||
@@ -652,6 +656,76 @@ impl Library {
|
|||||||
Ok(inserted)
|
Ok(inserted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn network_artist_image_requests(
|
||||||
|
&self,
|
||||||
|
filters: crate::config::settings::LibraryFilters,
|
||||||
|
artist_names: &[String],
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<NetworkArtistImageRequest>> {
|
||||||
|
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<bool> {
|
||||||
|
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<ArtistDetail> {
|
pub fn artist(&self, id: i64) -> Result<ArtistDetail> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let (name, image_path): (String, Option<String>) = conn
|
let (name, image_path): (String, Option<String>) = conn
|
||||||
@@ -1565,6 +1639,22 @@ impl Library {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn local_content_ids(&self) -> Result<Vec<String>> {
|
||||||
|
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::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
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.
|
/// Returns the new liked state for this content id.
|
||||||
pub fn toggle_like_by_content_id(&self, content_id: &str) -> Result<bool> {
|
pub fn toggle_like_by_content_id(&self, content_id: &str) -> Result<bool> {
|
||||||
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
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 mut rows = conn.prepare("SELECT id, title FROM playlists WHERE sync_id IS NULL")?;
|
||||||
let missing = rows
|
let missing = rows
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
@@ -2645,6 +2745,47 @@ mod tests {
|
|||||||
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
|
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]
|
#[test]
|
||||||
fn import_creates_artist_release_track() {
|
fn import_creates_artist_release_track() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
|
|||||||
+94
-33
@@ -5,7 +5,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Paragraph, Row, Table};
|
use ratatui::widgets::{Block, Paragraph, Row, Table};
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use super::{art, theme};
|
use super::{art, availability_marker, availability_prefix, theme};
|
||||||
use crate::app::state::{
|
use crate::app::state::{
|
||||||
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
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,
|
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, fed_release_display_order,
|
||||||
@@ -107,9 +107,6 @@ fn draw_tile_with_availability(
|
|||||||
..inner
|
..inner
|
||||||
};
|
};
|
||||||
draw_art(frame, art_area, art_state);
|
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 {
|
if inner.height > ART_CELL_HEIGHT {
|
||||||
let name_area = Rect {
|
let name_area = Rect {
|
||||||
@@ -131,35 +128,56 @@ fn draw_tile_with_availability(
|
|||||||
height: 1,
|
height: 1,
|
||||||
..inner
|
..inner
|
||||||
};
|
};
|
||||||
frame.render_widget(
|
draw_tile_meta(frame, meta_area, meta, availability, selected);
|
||||||
Paragraph::new(Line::styled(meta.to_string(), theme::dim())),
|
|
||||||
meta_area,
|
|
||||||
);
|
|
||||||
if selected {
|
|
||||||
frame.buffer_mut().set_style(meta_area, theme::tab_active());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_availability_badge(frame: &mut Frame, area: Rect, availability: Availability) {
|
fn draw_tile_meta(
|
||||||
if area.width < 2 || area.height == 0 {
|
frame: &mut Frame,
|
||||||
return;
|
area: Rect,
|
||||||
}
|
meta: &str,
|
||||||
let (label, style) = match availability {
|
availability: Option<Availability>,
|
||||||
Availability::Local => ("●", Style::new().fg(Color::Green)),
|
selected: bool,
|
||||||
Availability::Mixed => ("◐", Style::new().fg(Color::Yellow)),
|
) {
|
||||||
Availability::Remote => ("⇅", theme::accent()),
|
let marker = availability.map(|availability| availability_marker(availability, selected));
|
||||||
};
|
let marker_width = marker
|
||||||
let badge = Rect {
|
.map(|(label, _)| UnicodeWidthStr::width(label) as u16)
|
||||||
x: area.x + area.width.saturating_sub(2),
|
.unwrap_or(0)
|
||||||
y: area.y,
|
.max(u16::from(marker.is_some()) * 2)
|
||||||
width: 2,
|
.min(area.width);
|
||||||
height: 1,
|
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(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::styled(label, style)).alignment(Alignment::Right),
|
Paragraph::new(Line::styled(meta.to_string(), theme::dim())),
|
||||||
badge,
|
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 {
|
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)
|
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 {
|
fn marquee_window(title: &str, width: usize) -> String {
|
||||||
let stream = format!("{title}{TILE_MARQUEE_GAP}");
|
let stream = format!("{title}{TILE_MARQUEE_GAP}");
|
||||||
let cells: Vec<(char, usize)> = stream
|
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 {
|
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) {
|
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 {
|
for hit in &state.search.fed_artists {
|
||||||
rows.push((
|
rows.push((
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("⇅ ", theme::accent()),
|
availability_prefix(Availability::Remote),
|
||||||
Span::raw(hit.name.clone()),
|
Span::raw(hit.name.clone()),
|
||||||
Span::styled(" artist · open the card", theme::dim()),
|
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((
|
rows.push((
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
heart,
|
heart,
|
||||||
Span::styled("⇅ ", theme::accent()),
|
fed_track_availability_prefix(state, fed),
|
||||||
Span::raw(fed.title.clone()),
|
Span::raw(fed.title.clone()),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {} · {}", fed.artist_line(), origin),
|
format!(" {} · {}", fed.artist_line(), origin),
|
||||||
@@ -1093,7 +1154,7 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
|||||||
&release.title,
|
&release.title,
|
||||||
&meta,
|
&meta,
|
||||||
cursor == *position,
|
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![
|
let line = Line::from(vec![
|
||||||
Span::styled(format!("{number:>3} "), theme::dim()),
|
Span::styled(format!("{number:>3} "), theme::dim()),
|
||||||
heart,
|
heart,
|
||||||
Span::styled("⇅ ", theme::accent()),
|
fed_card_track_availability_prefix(state, track),
|
||||||
Span::raw(track.title.clone()),
|
Span::raw(track.title.clone()),
|
||||||
Span::styled(format!(" {context}"), theme::dim()),
|
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![
|
let line = Line::from(vec![
|
||||||
heart,
|
heart,
|
||||||
Span::styled("⇅ ", theme::accent()),
|
fed_card_track_availability_prefix(state, track),
|
||||||
Span::raw(format!("{number}{}", track.title)),
|
Span::raw(format!("{number}{}", track.title)),
|
||||||
]);
|
]);
|
||||||
if in_selection && cursor != position + 1 {
|
if in_selection && cursor != position + 1 {
|
||||||
|
|||||||
+78
-1
@@ -15,10 +15,35 @@ use ratatui::widgets::{Block, Clear, Paragraph, Tabs};
|
|||||||
use crate::app::input::LineEdit;
|
use crate::app::input::LineEdit;
|
||||||
use crate::app::state::{AppState, Tab, TrackSelectionScope};
|
use crate::app::state::{AppState, Tab, TrackSelectionScope};
|
||||||
use crate::config::keymap::Keymap;
|
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) {
|
pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
||||||
if state.visualizer.active {
|
if state.visualizer.active {
|
||||||
crate::visualizer::draw(frame, state);
|
crate::visualizer::draw(frame, state);
|
||||||
|
if state.shutting_down {
|
||||||
|
draw_shutdown(frame);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +68,37 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
|
|||||||
draw_help(frame, keymap);
|
draw_help(frame, keymap);
|
||||||
}
|
}
|
||||||
popup::draw(frame, state);
|
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<String>) -> Line<'static> {
|
pub(crate) fn loading_line(state: &AppState, text: impl Into<String>) -> Line<'static> {
|
||||||
@@ -118,7 +174,12 @@ pub(crate) fn track_row_with_like_marker(
|
|||||||
Span::raw(" ")
|
Span::raw(" ")
|
||||||
};
|
};
|
||||||
let fed_marker = if track.fed.is_some() {
|
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 {
|
} else {
|
||||||
Span::raw("")
|
Span::raw("")
|
||||||
};
|
};
|
||||||
@@ -468,6 +529,22 @@ fn draw_help(frame: &mut Frame, keymap: &Keymap) {
|
|||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
blocks.push(lines);
|
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.
|
// Balance the blocks across two columns.
|
||||||
let total: usize = blocks.iter().map(Vec::len).sum();
|
let total: usize = blocks.iter().map(Vec::len).sum();
|
||||||
|
|||||||
Reference in New Issue
Block a user