Added filter. Improved UI. Fixed settings window
This commit is contained in:
+5
-1
@@ -47,6 +47,7 @@ pub enum Action {
|
||||
NewPlaylist,
|
||||
ToggleHelp,
|
||||
ToggleViewMode,
|
||||
OpenLibraryFilters,
|
||||
OpenCommandLine,
|
||||
OpenSearch,
|
||||
EditSelected,
|
||||
@@ -125,7 +126,9 @@ impl Action {
|
||||
| Action::GoToTab(_)
|
||||
| Action::GoToRelease
|
||||
| Action::ToggleViewMode => Category::Navigation,
|
||||
Action::EditSelected | Action::DeleteSelected => Category::Library,
|
||||
Action::EditSelected | Action::DeleteSelected | Action::OpenLibraryFilters => {
|
||||
Category::Library
|
||||
}
|
||||
Action::OpenSearch | Action::OpenCommandLine => Category::Search,
|
||||
Action::ToggleHelp | Action::Quit => Category::System,
|
||||
}
|
||||
@@ -190,6 +193,7 @@ impl Action {
|
||||
Action::NewPlaylist => "Create a playlist".into(),
|
||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||
Action::OpenLibraryFilters => "Library filters…".into(),
|
||||
Action::OpenCommandLine => "Command line (:help for commands)".into(),
|
||||
Action::OpenSearch => "Search artists, releases, tracks".into(),
|
||||
Action::EditSelected => "Edit the selected item".into(),
|
||||
|
||||
+42
-4
@@ -76,10 +76,18 @@ pub async fn run(
|
||||
let library = Arc::new(Library::open(&db_path)?);
|
||||
tracing::info!(path = %db_path.display(), "library opened");
|
||||
|
||||
let (settings, settings_warning) = crate::config::settings::load();
|
||||
let status_message = match (startup_warning, settings_warning) {
|
||||
(Some(left), Some(right)) => Some(format!("{left}; {right}")),
|
||||
(Some(message), None) | (None, Some(message)) => Some(message),
|
||||
(None, None) => None,
|
||||
};
|
||||
let mut state = AppState {
|
||||
status_message: startup_warning,
|
||||
status_message,
|
||||
..AppState::default()
|
||||
};
|
||||
state.player.volume = settings.volume;
|
||||
state.global.filters = settings.library;
|
||||
if let Err(err) = state.visualizer.load_library() {
|
||||
state.status_message = Some(format!("visualizations disabled: {err:#}"));
|
||||
}
|
||||
@@ -191,13 +199,16 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
||||
{
|
||||
global.loading = true;
|
||||
let page = global.next_page;
|
||||
let hide_featured_only = global.filters.hide_featured_only;
|
||||
let limit = *global
|
||||
.page_limit
|
||||
.get_or_insert_with(|| (needed as i64).clamp(48, 200));
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = library.artists(page, limit).map_err(err_string);
|
||||
let result = library
|
||||
.artists(page, limit, hide_featured_only)
|
||||
.map_err(err_string);
|
||||
let _ = tx.send(AppEvent::ArtistsLoaded(result));
|
||||
});
|
||||
}
|
||||
@@ -389,7 +400,10 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
.player
|
||||
.seek(std::time::Duration::from_secs_f64(target));
|
||||
}
|
||||
Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)),
|
||||
Effect::SetVolume(volume) => {
|
||||
runtime.player.set_volume(player::amplitude(volume));
|
||||
save_app_settings(state);
|
||||
}
|
||||
Effect::SetOptions => {}
|
||||
Effect::EnqueueRelease { id, next } => {
|
||||
let library = Arc::clone(&runtime.library);
|
||||
@@ -1104,11 +1118,12 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) {
|
||||
let global = &mut state.global;
|
||||
let needed = artist_grid_capacity() + ARTISTS_PREFETCH_MARGIN;
|
||||
let limit = (global.artists.len().max(needed) as i64).clamp(48, 1000);
|
||||
let hide_featured_only = global.filters.hide_featured_only;
|
||||
global.reloading = true;
|
||||
let library = Arc::clone(&runtime.library);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let event = match library.artists(1, limit) {
|
||||
let event = match library.artists(1, limit, hide_featured_only) {
|
||||
Ok(page) => AppEvent::ArtistsReloaded { page, limit },
|
||||
Err(err) => AppEvent::ArtistsLoaded(Err(err_string(err))),
|
||||
};
|
||||
@@ -1116,6 +1131,29 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) {
|
||||
});
|
||||
}
|
||||
|
||||
fn save_app_settings(state: &AppState) {
|
||||
let settings = crate::config::settings::AppSettings {
|
||||
volume: state.player.volume,
|
||||
library: state.global.filters,
|
||||
};
|
||||
if let Err(err) = crate::config::settings::save(&settings) {
|
||||
tracing::warn!(%err, "saving app settings failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_artist_pagination(state: &mut AppState) {
|
||||
let global = &mut state.global;
|
||||
global.artists.clear();
|
||||
global.total = 0;
|
||||
global.has_more = true;
|
||||
global.next_page = 1;
|
||||
global.loading = false;
|
||||
global.error = None;
|
||||
global.selected = 0;
|
||||
global.page_limit = None;
|
||||
global.reloading = false;
|
||||
}
|
||||
|
||||
/// Swap queue entries for their fresh library copies; tracks that were
|
||||
/// deleted leave the queue.
|
||||
fn apply_queue_refresh(
|
||||
|
||||
@@ -40,6 +40,7 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
Popup::ConfirmDelete { target, label } => {
|
||||
handle_confirm_delete(state, runtime, target, label, key);
|
||||
}
|
||||
Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key),
|
||||
Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor,
|
||||
@@ -63,6 +64,23 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_library_filters(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {}
|
||||
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.popup = Some(Popup::LibraryFilters { cursor: 0 });
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only;
|
||||
super::save_app_settings(state);
|
||||
super::reset_artist_pagination(state);
|
||||
super::refresh_artists(state, runtime);
|
||||
state.popup = Some(Popup::LibraryFilters { cursor });
|
||||
}
|
||||
_ => state.popup = Some(Popup::LibraryFilters { cursor }),
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn handle_fed_input(
|
||||
state: &mut AppState,
|
||||
|
||||
+8
-4
@@ -17,7 +17,7 @@ pub enum Loadable<T> {
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Tile geometry for the Global artist grid (kept here so selection math in
|
||||
/// 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;
|
||||
@@ -52,7 +52,7 @@ pub enum ArtState {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A drill-down view pushed on top of the Global artist grid. Cursors live
|
||||
/// 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 {
|
||||
@@ -82,7 +82,7 @@ pub enum GlobalView {
|
||||
},
|
||||
}
|
||||
|
||||
/// The Global tab: the whole server library of artists.
|
||||
/// The Library tab: the whole server library of artists.
|
||||
#[derive(Debug)]
|
||||
pub struct GlobalTab {
|
||||
pub artists: Vec<ArtistCard>,
|
||||
@@ -93,6 +93,7 @@ pub struct GlobalTab {
|
||||
pub error: Option<String>,
|
||||
pub selected: usize,
|
||||
pub view: ViewMode,
|
||||
pub filters: crate::config::settings::LibraryFilters,
|
||||
pub stack: Vec<GlobalView>,
|
||||
/// Page size, fixed at the first request — the offset is
|
||||
/// `(page-1) * limit`, so it must not change between pages.
|
||||
@@ -113,6 +114,7 @@ impl Default for GlobalTab {
|
||||
error: None,
|
||||
selected: 0,
|
||||
view: ViewMode::default(),
|
||||
filters: crate::config::settings::LibraryFilters::default(),
|
||||
stack: Vec::new(),
|
||||
page_limit: None,
|
||||
reloading: false,
|
||||
@@ -503,6 +505,8 @@ pub enum Popup {
|
||||
},
|
||||
/// 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<TrackItem>,
|
||||
@@ -662,7 +666,7 @@ impl Tab {
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Tab::Global => "Global",
|
||||
Tab::Global => "Library",
|
||||
Tab::Playlists => "Playlists",
|
||||
Tab::Queue => "Queue",
|
||||
Tab::Federation => "Settings",
|
||||
|
||||
@@ -218,6 +218,12 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::OpenLibraryFilters => {
|
||||
if state.active_tab == Tab::Global && state.global.stack.is_empty() {
|
||||
state.popup = Some(super::state::Popup::LibraryFilters { cursor: 0 });
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::OpenCommandLine => {
|
||||
state.cmdline.active = true;
|
||||
state.cmdline.input.clear();
|
||||
@@ -2571,6 +2577,21 @@ mod tests {
|
||||
assert_eq!(state.player.volume, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_filters_popup_opens_only_on_root() {
|
||||
let mut state = AppState::default();
|
||||
update(&mut state, Action::OpenLibraryFilters);
|
||||
assert!(matches!(
|
||||
state.popup,
|
||||
Some(crate::app::state::Popup::LibraryFilters { .. })
|
||||
));
|
||||
|
||||
state.popup = None;
|
||||
state.global.stack.push(GlobalView::Search { cursor: 0 });
|
||||
update(&mut state, Action::OpenLibraryFilters);
|
||||
assert!(state.popup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_closes_help_first() {
|
||||
let mut state = AppState::default();
|
||||
|
||||
@@ -226,6 +226,11 @@ command = "DeleteSelected"
|
||||
key_sequence = "v"
|
||||
command = "ToggleViewMode"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = "f"
|
||||
command = "OpenLibraryFilters"
|
||||
context = "library"
|
||||
|
||||
[[keymaps]]
|
||||
key_sequence = ":"
|
||||
command = "OpenCommandLine"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod keymap;
|
||||
pub mod logging;
|
||||
pub mod settings;
|
||||
|
||||
use directories::ProjectDirs;
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LibraryFilters {
|
||||
#[serde(default)]
|
||||
pub hide_featured_only: bool,
|
||||
}
|
||||
|
||||
impl LibraryFilters {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.hide_featured_only
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
#[serde(default = "default_volume")]
|
||||
pub volume: u8,
|
||||
#[serde(default)]
|
||||
pub library: LibraryFilters,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
volume: default_volume(),
|
||||
library: LibraryFilters::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.volume = self.volume.min(100);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn default_volume() -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
pub fn load() -> (AppSettings, Option<String>) {
|
||||
let Some(path) = settings_path() else {
|
||||
return (AppSettings::default(), None);
|
||||
};
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match toml::from_str::<AppSettings>(&text) {
|
||||
Ok(settings) => (settings.normalized(), None),
|
||||
Err(err) => (
|
||||
AppSettings::default(),
|
||||
Some(format!("settings.toml is malformed: {err}")),
|
||||
),
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => (AppSettings::default(), None),
|
||||
Err(err) => (
|
||||
AppSettings::default(),
|
||||
Some(format!("settings.toml could not be read: {err}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(settings: &AppSettings) -> Result<()> {
|
||||
let path = settings_path().context("cannot determine the config directory")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(
|
||||
&path,
|
||||
toml::to_string_pretty(&settings.clone().normalized())?,
|
||||
)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn settings_path() -> Option<std::path::PathBuf> {
|
||||
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("settings.toml"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_parse_and_normalize() {
|
||||
let settings: AppSettings = toml::from_str(
|
||||
r#"
|
||||
volume = 150
|
||||
|
||||
[library]
|
||||
hide_featured_only = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = settings.normalized();
|
||||
|
||||
assert_eq!(settings.volume, 100);
|
||||
assert!(settings.library.hide_featured_only);
|
||||
assert!(settings.library.is_active());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use music_dht::{
|
||||
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
|
||||
PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||
};
|
||||
use rusqlite::{Connection, OpenFlags, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::library::Library;
|
||||
@@ -159,6 +160,7 @@ pub struct FedStatus {
|
||||
pub connected_peers: Vec<String>,
|
||||
pub known_contacts: usize,
|
||||
pub stored_dht_records: Option<usize>,
|
||||
pub stored_dht_bytes: Option<u64>,
|
||||
pub published_items: usize,
|
||||
pub last_sync: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
@@ -250,6 +252,37 @@ fn now_label() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn unix_time_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64> {
|
||||
tokio::task::spawn_blocking(move || -> Result<u64> {
|
||||
let path = data_dir.join("state.sqlite3");
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.with_context(|| format!("opening {}", path.display()))?;
|
||||
let bytes: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(length(payload)), 0)
|
||||
FROM dht_records
|
||||
WHERE expires_at_ms > ?1",
|
||||
params![now_ms as i64],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(bytes.max(0) as u64)
|
||||
})
|
||||
.await
|
||||
.context("DHT size query task failed")?
|
||||
}
|
||||
|
||||
impl Federation {
|
||||
pub fn new(library: Arc<Library>) -> Arc<Self> {
|
||||
let dirs = crate::config::project_dirs();
|
||||
@@ -534,6 +567,10 @@ impl Federation {
|
||||
.collect();
|
||||
status.known_contacts = service.known_peers().len();
|
||||
status.stored_dht_records = service.dht_record_count().await.ok();
|
||||
status.stored_dht_bytes =
|
||||
dht_record_payload_bytes(self.data_dir.clone(), unix_time_ms())
|
||||
.await
|
||||
.ok();
|
||||
status.published_items = service
|
||||
.list_local_items()
|
||||
.await
|
||||
|
||||
+82
-15
@@ -205,20 +205,42 @@ impl Library {
|
||||
// Reads (same shapes the API used to return)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
pub fn artists(&self, page: i64, limit: i64) -> Result<ArtistsPage> {
|
||||
pub fn artists(&self, page: i64, limit: i64, hide_featured_only: bool) -> Result<ArtistsPage> {
|
||||
let conn = self.lock();
|
||||
let total: i64 = conn.query_row("SELECT COUNT(*) FROM artists", [], |row| row.get(0))?;
|
||||
let hide_featured_only = i64::from(hide_featured_only);
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM artists a
|
||||
WHERE ?1 = 0
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM release_artists ra
|
||||
WHERE ra.artist_id = a.id
|
||||
)",
|
||||
[hide_featured_only],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let offset = (page.max(1) - 1) * limit;
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT a.id, a.name, a.image_path,
|
||||
(SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id),
|
||||
(SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id)
|
||||
(SELECT COUNT(DISTINCT ra.release_id)
|
||||
FROM release_artists ra
|
||||
WHERE ra.artist_id = a.id) AS release_count,
|
||||
(SELECT COUNT(DISTINCT ta.track_id)
|
||||
FROM track_artists ta
|
||||
WHERE ta.artist_id = a.id) AS track_count
|
||||
FROM artists a
|
||||
ORDER BY a.name COLLATE NOCASE
|
||||
LIMIT ?1 OFFSET ?2",
|
||||
WHERE ?1 = 0
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM release_artists ra
|
||||
WHERE ra.artist_id = a.id
|
||||
)
|
||||
ORDER BY release_count DESC, track_count DESC, a.name COLLATE NOCASE
|
||||
LIMIT ?2 OFFSET ?3",
|
||||
)?;
|
||||
let items = statement
|
||||
.query_map(params![limit, offset], |row| {
|
||||
.query_map(params![hide_featured_only, limit, offset], |row| {
|
||||
Ok(ArtistCard {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -371,8 +393,12 @@ impl Library {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT a.id, a.name, a.image_path,
|
||||
(SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id),
|
||||
(SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id)
|
||||
(SELECT COUNT(DISTINCT ra.release_id)
|
||||
FROM release_artists ra
|
||||
WHERE ra.artist_id = a.id),
|
||||
(SELECT COUNT(DISTINCT ta.track_id)
|
||||
FROM track_artists ta
|
||||
WHERE ta.artist_id = a.id)
|
||||
FROM artists a WHERE instr(norm(a.name), ?1) > 0
|
||||
ORDER BY a.name COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
@@ -1222,12 +1248,22 @@ mod tests {
|
||||
}
|
||||
|
||||
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
|
||||
add_track_with_featured(lib, title, artist, &[], album)
|
||||
}
|
||||
|
||||
fn add_track_with_featured(
|
||||
lib: &Library,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
featured: &[&str],
|
||||
album: &str,
|
||||
) -> i64 {
|
||||
let import = import::TrackImport {
|
||||
release_type: None,
|
||||
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
|
||||
title: title.to_string(),
|
||||
artists: vec![artist.to_string()],
|
||||
featured_artists: Vec::new(),
|
||||
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
|
||||
album_artists: vec![artist.to_string()],
|
||||
release_title: album.to_string(),
|
||||
year: Some(2020),
|
||||
@@ -1244,11 +1280,40 @@ mod tests {
|
||||
import::upsert_track(lib, &import).unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artists_page_prioritizes_releases_then_tracks() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Solo", "Zed", "Zed Album");
|
||||
add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album");
|
||||
add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album");
|
||||
|
||||
let page = lib.artists(1, 10, false).unwrap();
|
||||
let zed_pos = page
|
||||
.items
|
||||
.iter()
|
||||
.position(|artist| artist.name == "Zed")
|
||||
.unwrap();
|
||||
let guest_pos = page
|
||||
.items
|
||||
.iter()
|
||||
.position(|artist| artist.name == "Guest")
|
||||
.unwrap();
|
||||
let guest = &page.items[guest_pos];
|
||||
|
||||
assert_eq!(guest.release_count, 0);
|
||||
assert_eq!(guest.track_count, 2);
|
||||
assert!(zed_pos < guest_pos);
|
||||
|
||||
let filtered = lib.artists(1, 10, true).unwrap();
|
||||
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
|
||||
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_creates_artist_release_track() {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let page = lib.artists(1, 10).unwrap();
|
||||
let page = lib.artists(1, 10, false).unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].name, "Artist");
|
||||
assert_eq!(page.items[0].track_count, 1);
|
||||
@@ -1269,7 +1334,7 @@ mod tests {
|
||||
let first = add_track(&lib, "Song", "Artist", "Album");
|
||||
let second = add_track(&lib, "Song", "Artist", "Album");
|
||||
assert_eq!(first, second);
|
||||
let page = lib.artists(1, 10).unwrap();
|
||||
let page = lib.artists(1, 10, false).unwrap();
|
||||
assert_eq!(page.items[0].track_count, 1);
|
||||
}
|
||||
|
||||
@@ -1357,9 +1422,9 @@ mod tests {
|
||||
fn deleting_artist_cleans_up_own_content() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "Song", "Solo", "Solo Album");
|
||||
let page = lib.artists(1, 10).unwrap();
|
||||
let page = lib.artists(1, 10, false).unwrap();
|
||||
lib.delete_artist(page.items[0].id).unwrap();
|
||||
assert_eq!(lib.artists(1, 10).unwrap().total, 0);
|
||||
assert_eq!(lib.artists(1, 10, false).unwrap().total, 0);
|
||||
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
@@ -1368,7 +1433,9 @@ mod tests {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Only", "Artist", "Album");
|
||||
lib.delete_track(track_id).unwrap();
|
||||
let detail = lib.artist(lib.artists(1, 10).unwrap().items[0].id).unwrap();
|
||||
let detail = lib
|
||||
.artist(lib.artists(1, 10, false).unwrap().items[0].id)
|
||||
.unwrap();
|
||||
assert!(detail.releases.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,16 @@ fn status_line(label: &str, value: String) -> Line<'static> {
|
||||
])
|
||||
}
|
||||
|
||||
fn bytes_label(bytes: u64) -> String {
|
||||
if bytes >= 1024 * 1024 {
|
||||
format!("{bytes} B ({:.1} MiB)", bytes as f64 / 1024.0 / 1024.0)
|
||||
} else if bytes >= 1024 {
|
||||
format!("{bytes} B ({:.1} KiB)", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(12).collect::<String>() + "…"
|
||||
}
|
||||
@@ -240,6 +250,13 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
.map(|count| count.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Stored DHT bytes",
|
||||
status
|
||||
.stored_dht_bytes
|
||||
.map(bytes_label)
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
));
|
||||
lines.push(status_line(
|
||||
"Published items",
|
||||
status.published_items.to_string(),
|
||||
|
||||
+15
-9
@@ -36,10 +36,11 @@ fn error_style() -> Style {
|
||||
}
|
||||
|
||||
fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect {
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::dim());
|
||||
bordered_line(frame, area, Line::styled(title, theme::header()))
|
||||
}
|
||||
|
||||
fn bordered_line(frame: &mut Frame, area: Rect, title: Line<'static>) -> Rect {
|
||||
let block = Block::bordered().title(title).border_style(theme::dim());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
inner
|
||||
@@ -271,11 +272,16 @@ fn scroll_offset(items: &[PlanItem], cursor_item: Option<usize>, viewport: u16)
|
||||
fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let global = &state.global;
|
||||
let title = if global.total > 0 {
|
||||
format!(" Global — {} artists ", global.total)
|
||||
format!(" Library — {} artists ", global.total)
|
||||
} else {
|
||||
" Global ".to_string()
|
||||
" Library ".to_string()
|
||||
};
|
||||
let inner = bordered(frame, area, title);
|
||||
let mut title_spans = vec![Span::styled(title, theme::tab_active())];
|
||||
if global.filters.is_active() {
|
||||
title_spans.push(Span::raw(" "));
|
||||
title_spans.push(Span::styled(" FILTERED ", theme::tab_active()));
|
||||
}
|
||||
let inner = bordered_line(frame, area, Line::from(title_spans));
|
||||
|
||||
if global.artists.is_empty() {
|
||||
let message = if let Some(error) = &global.error {
|
||||
@@ -373,7 +379,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor:
|
||||
Some(Loadable::Ready(detail)) => detail.name.clone(),
|
||||
_ => "Artist".to_string(),
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" Global ▸ {name} "));
|
||||
let inner = bordered(frame, area, format!(" Library ▸ {name} "));
|
||||
|
||||
let detail = match loadable {
|
||||
Some(Loadable::Ready(detail)) => detail,
|
||||
@@ -640,7 +646,7 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
Some(Loadable::Ready(detail)) => detail.title.clone(),
|
||||
_ => "Release".to_string(),
|
||||
};
|
||||
let inner = bordered(frame, area, format!(" Global ▸ {title} "));
|
||||
let inner = bordered(frame, area, format!(" Library ▸ {title} "));
|
||||
|
||||
let detail = match loadable {
|
||||
Some(Loadable::Ready(detail)) => detail,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
..
|
||||
}) => draw_edit(frame, title, fields, *focus, error.as_deref()),
|
||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, label),
|
||||
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
||||
Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor,
|
||||
@@ -39,6 +40,49 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_library_filters(frame: &mut Frame, state: &AppState, cursor: usize) {
|
||||
let area = centered(frame.area(), 46, 6);
|
||||
let block = Block::bordered()
|
||||
.title(" Library filters ")
|
||||
.title_style(theme::header())
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let [list_area, _, footer] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let checked = if state.global.filters.hide_featured_only {
|
||||
"[x]"
|
||||
} else {
|
||||
"[ ]"
|
||||
};
|
||||
let row = Rect {
|
||||
height: 1,
|
||||
..list_area
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!("{checked} "), theme::accent()),
|
||||
Span::raw("Hide featured only"),
|
||||
])),
|
||||
row,
|
||||
);
|
||||
if cursor == 0 {
|
||||
frame.buffer_mut().set_style(row, theme::tab_active());
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("space/enter toggle · esc close", theme::dim()))
|
||||
.alignment(Alignment::Center),
|
||||
footer,
|
||||
);
|
||||
}
|
||||
|
||||
/// One-line text entry on the Federation tab (network id / peer ticket).
|
||||
fn draw_fed_input(frame: &mut Frame, title: &str, input: &crate::app::input::LineEdit) {
|
||||
let area = centered(frame.area(), 64, 5);
|
||||
|
||||
Reference in New Issue
Block a user