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();
|
||||
|
||||
Reference in New Issue
Block a user