Fix library and search

This commit is contained in:
Magnus Root 2026-07-14 09:57:16 +03:00
parent e6c694a740
commit 359cd63834
6 changed files with 119 additions and 15 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
Cargo.lock

View file

@ -135,8 +135,9 @@ If no path is given, it defaults to `~/Music`.
|---|---| |---|---|
| `j`/`k`, `↓`/`↑` | navigate | | `j`/`k`, `↓`/`↑` | navigate |
| `Enter` | expand album / play | | `Enter` | expand album / play |
| `Esc` | collapse album | | `Esc` | collapse album / clear search and show the full library |
| `/` | search | | `/` | search |
| `t` | toggle view: by album ↔ flat list of all tracks (Enter on any track plays everything from there) |
| `a` | add to playlist | | `a` | add to playlist |
| `c` | connect an SMB share | | `c` | connect an SMB share |
| `L` | add another folder to the library | | `L` | add another folder to the library |

View file

@ -148,8 +148,9 @@ cargo build --release --target x86_64-pc-windows-gnu
|---|---| |---|---|
| `j`/`k`, `↓`/`↑` | навигация | | `j`/`k`, `↓`/`↑` | навигация |
| `Enter` | раскрыть альбом / сыграть | | `Enter` | раскрыть альбом / сыграть |
| `Esc` | свернуть альбом | | `Esc` | свернуть альбом / сбросить поиск и показать всю библиотеку |
| `/` | поиск | | `/` | поиск |
| `t` | переключить вид: по альбомам ↔ плоский список всех треков (Enter на любом треке — играть всё оттуда) |
| `a` | добавить в плейлист | | `a` | добавить в плейлист |
| `c` | подключить SMB-шару | | `c` | подключить SMB-шару |
| `L` | добавить ещё одну папку к библиотеке | | `L` | добавить ещё одну папку к библиотеке |

View file

@ -80,8 +80,35 @@ pub struct SearchRow {
pub album: String, pub album: String,
} }
/// Строка в плоском списке "все треки" (режим Tracks) — в отличие от SearchRow, сразу
/// хранит готовый QueueItem, чтобы не искать Track по пути повторно при проигрывании.
pub struct TrackRow {
pub item: QueueItem,
pub title: String,
pub artist: String,
pub album: String,
}
/// Что показывать, когда не идёт поиск: альбомами (сгруппировано) или плоским списком
/// всех треков библиотеки — переключается клавишей `t`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LibraryMode {
Albums,
Tracks,
}
impl LibraryMode {
pub fn toggle(self) -> Self {
match self {
LibraryMode::Albums => LibraryMode::Tracks,
LibraryMode::Tracks => LibraryMode::Albums,
}
}
}
pub enum LibraryView { pub enum LibraryView {
Albums(Vec<AlbumRow>), Albums(Vec<AlbumRow>),
Tracks(Vec<TrackRow>),
Search(Vec<SearchRow>), Search(Vec<SearchRow>),
} }
@ -169,6 +196,7 @@ pub struct App {
// --- Library tab --- // --- Library tab ---
pub search_query: String, pub search_query: String,
pub library_view: LibraryView, pub library_view: LibraryView,
pub library_mode: LibraryMode,
pub library_selected: usize, pub library_selected: usize,
/// Текущий путь трека/альбома, для которого показывается список треков (None = список альбомов). /// Текущий путь трека/альбома, для которого показывается список треков (None = список альбомов).
pub expanded_album: Option<usize>, pub expanded_album: Option<usize>,
@ -301,6 +329,7 @@ impl App {
should_quit: false, should_quit: false,
search_query: String::new(), search_query: String::new(),
library_view: LibraryView::Albums(Vec::new()), library_view: LibraryView::Albums(Vec::new()),
library_mode: LibraryMode::Albums,
library_selected: 0, library_selected: 0,
expanded_album: None, expanded_album: None,
smb_server: String::new(), smb_server: String::new(),
@ -358,6 +387,8 @@ impl App {
pub fn rebuild_library_view(&mut self) { pub fn rebuild_library_view(&mut self) {
if self.search_query.trim().is_empty() { if self.search_query.trim().is_empty() {
match self.library_mode {
LibraryMode::Albums => {
let rows: Vec<AlbumRow> = self let rows: Vec<AlbumRow> = self
.library .library
.albums() .albums()
@ -369,6 +400,25 @@ impl App {
}) })
.collect(); .collect();
self.library_view = LibraryView::Albums(rows); self.library_view = LibraryView::Albums(rows);
}
LibraryMode::Tracks => {
let mut tracks: Vec<&library::Track> = self.library.tracks().iter().collect();
tracks.sort_by(|a, b| {
(&a.artist, &a.album, a.track_no.unwrap_or(0), a.file_name())
.cmp(&(&b.artist, &b.album, b.track_no.unwrap_or(0), b.file_name()))
});
let rows: Vec<TrackRow> = tracks
.into_iter()
.map(|t| TrackRow {
item: track_to_queue_item(t),
title: t.title.clone(),
artist: t.artist.clone(),
album: t.album.clone(),
})
.collect();
self.library_view = LibraryView::Tracks(rows);
}
}
} else { } else {
let rows: Vec<SearchRow> = self let rows: Vec<SearchRow> = self
.library .library
@ -388,6 +438,15 @@ impl App {
self.expanded_album = None; self.expanded_album = None;
} }
pub fn toggle_library_mode(&mut self) {
self.library_mode = self.library_mode.toggle();
self.rebuild_library_view();
self.status_message = match self.library_mode {
LibraryMode::Albums => "View: Albums".to_string(),
LibraryMode::Tracks => "View: All Tracks".to_string(),
};
}
/// Монтирует введённую SMB-шару (через gvfs) и добавляет её содержимое к библиотеке. /// Монтирует введённую SMB-шару (через gvfs) и добавляет её содержимое к библиотеке.
/// Сначала проверяет, не смонтирована ли она уже (например, вручную через файловый /// Сначала проверяет, не смонтирована ли она уже (например, вручную через файловый
/// менеджер) — тогда пароль вообще не понадобится. /// менеджер) — тогда пароль вообще не понадобится.
@ -610,6 +669,24 @@ impl App {
} }
} }
/// Ставит в очередь ВЕСЬ плоский список треков (режим Tracks), начиная с выбранного —
/// то самое "просто включить всю библиотеку", без необходимости проваливаться в альбом.
pub fn play_all_tracks_from_selected(&mut self) {
self.current_station = None;
if let LibraryView::Tracks(rows) = &self.library_view {
if rows.is_empty() {
return;
}
self.current_queue = rows.iter().map(|r| r.item.clone()).collect();
let start = self.library_selected.min(self.current_queue.len() - 1);
if self.player.set_queue(self.current_queue.clone(), start) {
self.status_message = format!("Playing all {} tracks", self.current_queue.len());
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
}
pub fn play_expanded_track(&mut self, track_idx: usize) { pub fn play_expanded_track(&mut self, track_idx: usize) {
self.current_station = None; self.current_station = None;
if let Some(album_idx) = self.expanded_album { if let Some(album_idx) = self.expanded_album {
@ -655,6 +732,7 @@ impl App {
.map(|t| t.path.clone()) .map(|t| t.path.clone())
} }
} }
LibraryView::Tracks(rows) => rows.get(self.library_selected).map(|r| r.item.path.clone()),
LibraryView::Search(rows) => rows.get(self.library_selected).map(|r| r.path.clone()), LibraryView::Search(rows) => rows.get(self.library_selected).map(|r| r.path.clone()),
} }
} }

View file

@ -498,12 +498,18 @@ fn handle_library_key(app: &mut App, code: KeyCode) {
KeyCode::Char('C') => { KeyCode::Char('C') => {
app.clear_library(); app.clear_library();
} }
KeyCode::Char('t') => {
app.toggle_library_mode();
}
KeyCode::Down | KeyCode::Char('j') => move_selection(app, 1), KeyCode::Down | KeyCode::Char('j') => move_selection(app, 1),
KeyCode::Up | KeyCode::Char('k') => move_selection(app, -1), KeyCode::Up | KeyCode::Char('k') => move_selection(app, -1),
KeyCode::Esc => { KeyCode::Esc => {
if app.expanded_album.is_some() { if app.expanded_album.is_some() {
app.expanded_album = None; app.expanded_album = None;
app.library_selected = 0; app.library_selected = 0;
} else if !app.search_query.is_empty() {
app.search_query.clear();
app.rebuild_library_view();
} }
} }
KeyCode::Enter => match &app.library_view { KeyCode::Enter => match &app.library_view {
@ -515,6 +521,9 @@ fn handle_library_key(app: &mut App, code: KeyCode) {
app.library_selected = 0; app.library_selected = 0;
} }
} }
LibraryView::Tracks(_) => {
app.play_all_tracks_from_selected();
}
LibraryView::Search(_) => { LibraryView::Search(_) => {
app.play_album_from_selected(); app.play_album_from_selected();
} }
@ -532,6 +541,7 @@ fn move_selection(app: &mut App, delta: i32) {
rows.len() rows.len()
} }
} }
LibraryView::Tracks(rows) => rows.len(),
LibraryView::Search(rows) => rows.len(), LibraryView::Search(rows) => rows.len(),
}; };
if len == 0 { if len == 0 {

View file

@ -5,7 +5,7 @@ use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Gauge, List, Lis
use ratatui::Frame; use ratatui::Frame;
use ratatui_image::StatefulImage; use ratatui_image::StatefulImage;
use crate::app::{protocol_to_str, App, Focus, LibraryView, RightPanelView, Tab}; use crate::app::{protocol_to_str, App, Focus, LibraryMode, LibraryView, RightPanelView, Tab};
pub fn draw(frame: &mut Frame, app: &mut App) { pub fn draw(frame: &mut Frame, app: &mut App) {
if app.fullscreen { if app.fullscreen {
@ -220,6 +220,10 @@ fn draw_library(frame: &mut Frame, app: &App, area: Rect) {
.collect() .collect()
} }
} }
LibraryView::Tracks(rows) => rows
.iter()
.map(|r| ListItem::new(format!("{}{} [{}]", r.artist, r.title, r.album)))
.collect(),
LibraryView::Search(rows) => rows LibraryView::Search(rows) => rows
.iter() .iter()
.map(|r| ListItem::new(format!("{}{} [{}]", r.artist, r.title, r.album))) .map(|r| ListItem::new(format!("{}{} [{}]", r.artist, r.title, r.album)))
@ -228,8 +232,17 @@ fn draw_library(frame: &mut Frame, app: &App, area: Rect) {
let title = if app.expanded_album.is_some() { let title = if app.expanded_album.is_some() {
" Album (Enter: play from here, Esc: back) " " Album (Enter: play from here, Esc: back) "
} else if !app.search_query.is_empty() {
" Search results (Enter: play, Esc: clear search & show all) "
} else { } else {
" Albums (Enter: expand/play, /: search, a: add to playlist, c: connect SMB, L: add folder, C: clear library) " match app.library_mode {
LibraryMode::Albums => {
" Albums (Enter: expand/play, /: search, a: add to playlist, t: all tracks, c: connect SMB, L: add folder, C: clear library) "
}
LibraryMode::Tracks => {
" All Tracks (Enter: play all from here, /: search, a: add to playlist, t: by album) "
}
}
}; };
let list = List::new(items) let list = List::new(items)