Added marks
This commit is contained in:
parent
8457ccc448
commit
c5c202384b
5 changed files with 393 additions and 5 deletions
|
|
@ -54,6 +54,15 @@ impl AudiobookStatus {
|
|||
AudiobookStatus::Finished => "Finished",
|
||||
}
|
||||
}
|
||||
|
||||
/// Однобуквенная метка для компактного отображения рядом с книгой в списке.
|
||||
pub fn letter(self) -> char {
|
||||
match self {
|
||||
AudiobookStatus::New => 'N',
|
||||
AudiobookStatus::Listening => 'L',
|
||||
AudiobookStatus::Finished => 'E',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
|
|
@ -106,6 +115,22 @@ impl Audiobook {
|
|||
pub struct AudiobookLibrary {
|
||||
pub watched_roots: Vec<PathBuf>,
|
||||
pub books: Vec<Audiobook>,
|
||||
/// Книги, которые пользователь явно удалил из библиотеки (`d`) — при пересканировании
|
||||
/// такие книги не должны появляться заново, даже если их папка физически всё ещё
|
||||
/// существует внутри отслеживаемого корня. Храним не только пути (для сверки при
|
||||
/// сканировании), но и последние известные название/автора — чтобы было что показать
|
||||
/// в списке "восстановить удалённое" (`R`), не заставляя гадать по одним путям.
|
||||
/// `#[serde(default)]` — чтобы уже сохранённые на диске файлы без этого поля (версии
|
||||
/// до этой правки) продолжали читаться нормально, просто с пустым списком удалённых.
|
||||
#[serde(default)]
|
||||
pub removed_books: Vec<RemovedBook>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RemovedBook {
|
||||
pub folders: Vec<PathBuf>,
|
||||
pub title: String,
|
||||
pub author: String,
|
||||
}
|
||||
|
||||
pub struct AudiobookStore {
|
||||
|
|
@ -213,6 +238,46 @@ impl AudiobookStore {
|
|||
self.save(&lib)?;
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// Удаляет книгу из библиотеки — не трогает файлы на диске, только перестаёт её
|
||||
/// отслеживать. Запоминает набор папок книги как исключение, чтобы при следующем
|
||||
/// пересканировании она не появилась заново (её папка всё ещё может физически
|
||||
/// существовать внутри отслеживаемого корня — иначе рескан бы её тут же вернул).
|
||||
pub fn remove_book(&self, book_id: &str) -> anyhow::Result<AudiobookLibrary> {
|
||||
let mut lib = self.load();
|
||||
if let Some(book) = lib.books.iter().find(|b| b.id == book_id) {
|
||||
lib.removed_books.push(RemovedBook {
|
||||
folders: book.folders.clone(),
|
||||
title: book.title.clone(),
|
||||
author: book.author.clone(),
|
||||
});
|
||||
}
|
||||
lib.books.retain(|b| b.id != book_id);
|
||||
self.save(&lib)?;
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// Возвращает ранее удалённую книгу (`d`) обратно в библиотеку — убирает её из списка
|
||||
/// удалённых и сразу пересканирует, чтобы книга реально появилась (с актуальными
|
||||
/// файлами/длительностью, а не просто "вернуть как было" — папка могла и измениться).
|
||||
/// Если папка книги с тех пор физически пропала с диска — книга не появится, это
|
||||
/// ожидаемо (как и для любой другой пропавшей папки).
|
||||
pub fn restore_book(&self, folders: &[std::path::PathBuf]) -> anyhow::Result<AudiobookLibrary> {
|
||||
let mut lib = self.load();
|
||||
lib.removed_books.retain(|r| r.folders != folders);
|
||||
rescan(&mut lib)?;
|
||||
self.save(&lib)?;
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// Полностью очищает библиотеку аудиокниг: все книги, все отслеживаемые папки и все
|
||||
/// исключения — назад к чистому состоянию. Не трогает файлы на диске — только текущее
|
||||
/// состояние приложения (как и очистка обычной библиотеки/отписка от подкастов).
|
||||
pub fn clear_all(&self) -> anyhow::Result<AudiobookLibrary> {
|
||||
let lib = AudiobookLibrary::default();
|
||||
self.save(&lib)?;
|
||||
Ok(lib)
|
||||
}
|
||||
}
|
||||
|
||||
/// Промежуточное представление "свежесканированной" книги — без id/прогресса, которые
|
||||
|
|
@ -235,6 +300,9 @@ fn rescan(lib: &mut AudiobookLibrary) -> anyhow::Result<()> {
|
|||
|
||||
let mut merged = Vec::new();
|
||||
for disc in discovered {
|
||||
if lib.removed_books.iter().any(|r| r.folders == disc.folders) {
|
||||
continue; // явно удалено пользователем — не появляется заново без restore_book
|
||||
}
|
||||
if let Some(existing) = lib.books.iter().find(|b| b.folders == disc.folders) {
|
||||
let mut updated = existing.clone();
|
||||
updated.files = disc.files;
|
||||
|
|
@ -568,6 +636,13 @@ mod tests {
|
|||
assert_eq!(finished.status(), AudiobookStatus::Finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_letters_are_distinct_and_match_the_requested_scheme() {
|
||||
assert_eq!(AudiobookStatus::New.letter(), 'N');
|
||||
assert_eq!(AudiobookStatus::Listening.letter(), 'L');
|
||||
assert_eq!(AudiobookStatus::Finished.letter(), 'E');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_rescan_does_not_duplicate_and_preserves_progress() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -627,6 +702,85 @@ mod tests {
|
|||
assert!(lib2.books[0].manual_metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_book_stays_gone_after_rescan() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let book_dir = dir.path().join("Author").join("Book");
|
||||
fs::create_dir_all(&book_dir).unwrap();
|
||||
touch_mp3(&book_dir.join("01.mp3"));
|
||||
|
||||
let store_path = dir.path().join("audiobooks.json");
|
||||
let store = AudiobookStore::new(store_path);
|
||||
let lib1 = store.add_root_and_rescan(dir.path()).unwrap();
|
||||
assert_eq!(lib1.books.len(), 1);
|
||||
let id = lib1.books[0].id.clone();
|
||||
|
||||
let lib2 = store.remove_book(&id).unwrap();
|
||||
assert_eq!(lib2.books.len(), 0, "removed book should disappear immediately");
|
||||
|
||||
// Ключевая проверка: папка книги ФИЗИЧЕСКИ всё ещё на месте (никто её не удалял
|
||||
// с диска), а корень всё ещё отслеживается — наивное пересканирование вернуло бы
|
||||
// книгу обратно, если бы не список исключений.
|
||||
let lib3 = store.rescan_all().unwrap();
|
||||
assert_eq!(lib3.books.len(), 0, "removed book must not reappear after rescan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_book_brings_it_back_with_metadata_for_display() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let book_dir = dir.path().join("Author").join("Book");
|
||||
fs::create_dir_all(&book_dir).unwrap();
|
||||
touch_mp3(&book_dir.join("01.mp3"));
|
||||
|
||||
let store_path = dir.path().join("audiobooks.json");
|
||||
let store = AudiobookStore::new(store_path);
|
||||
let lib1 = store.add_root_and_rescan(dir.path()).unwrap();
|
||||
let folders = lib1.books[0].folders.clone();
|
||||
|
||||
let lib2 = store.remove_book(&lib1.books[0].id).unwrap();
|
||||
assert_eq!(lib2.books.len(), 0);
|
||||
assert_eq!(lib2.removed_books.len(), 1, "removed book should be listed for potential restore");
|
||||
assert_eq!(lib2.removed_books[0].title, "Book");
|
||||
assert_eq!(lib2.removed_books[0].author, "Author");
|
||||
|
||||
let lib3 = store.restore_book(&folders).unwrap();
|
||||
assert_eq!(lib3.books.len(), 1, "restored book should reappear");
|
||||
assert_eq!(lib3.removed_books.len(), 0, "no longer in the removed list once restored");
|
||||
assert_eq!(lib3.books[0].title, "Book");
|
||||
|
||||
// И повторное обычное пересканирование теперь снова находит книгу нормально —
|
||||
// restore_book не оставляет её в каком-то особом "полу-удалённом" состоянии.
|
||||
let lib4 = store.rescan_all().unwrap();
|
||||
assert_eq!(lib4.books.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_all_resets_everything_but_does_not_touch_disk_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let book_dir = dir.path().join("Author").join("Book");
|
||||
fs::create_dir_all(&book_dir).unwrap();
|
||||
touch_mp3(&book_dir.join("01.mp3"));
|
||||
|
||||
let store_path = dir.path().join("audiobooks.json");
|
||||
let store = AudiobookStore::new(store_path);
|
||||
let lib1 = store.add_root_and_rescan(dir.path()).unwrap();
|
||||
assert_eq!(lib1.books.len(), 1);
|
||||
assert_eq!(lib1.watched_roots.len(), 1);
|
||||
|
||||
let lib2 = store.clear_all().unwrap();
|
||||
assert!(lib2.books.is_empty());
|
||||
assert!(lib2.watched_roots.is_empty());
|
||||
assert!(lib2.removed_books.is_empty());
|
||||
|
||||
// Файл на диске никто не трогал — очистка была только в состоянии приложения.
|
||||
assert!(book_dir.join("01.mp3").exists());
|
||||
|
||||
// И повторное добавление той же папки заново находит книгу — clear_all не оставил
|
||||
// её в "исключениях" навсегда.
|
||||
let lib3 = store.add_root_and_rescan(dir.path()).unwrap();
|
||||
assert_eq!(lib3.books.len(), 1, "re-adding after clear_all should find the book again");
|
||||
}
|
||||
|
||||
/// Реальный аудиофайл (не фиктивные байты) + настоящий cue-лист рядом — проверяет, что
|
||||
/// главы реально извлекаются, а не просто "не падает". Нужен ffmpeg в PATH.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -13,8 +13,28 @@ struct BiquadCoeffs {
|
|||
}
|
||||
|
||||
impl BiquadCoeffs {
|
||||
/// Тождественный фильтр (полностью прозрачный, не меняет сигнал) — используется вместо
|
||||
/// настоящего биквада для полос, чья частота на/выше частоты Найквиста (sample_rate/2):
|
||||
/// там физически нет никакого содержимого сигнала, которое можно было бы поднять/срезать,
|
||||
/// а сама формула RBJ cookbook в этой области даёт численно нестабильный фильтр —
|
||||
/// коэффициенты получаются конечными, но при многократном рекурсивном применении
|
||||
/// (обычное дело за много секунд воспроизведения) внутреннее состояние фильтра
|
||||
/// расходится и переполняется в NaN, портя весь дальнейший звук. Реальный кейс: полоса
|
||||
/// 16кГц (и отчасти 12кГц) при частоте дискретизации 24000Гц (частый случай для очень
|
||||
/// низкобитрейтных 64кбит аудиокниг) — Найквист там всего 12000Гц.
|
||||
fn identity() -> Self {
|
||||
BiquadCoeffs { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }
|
||||
}
|
||||
|
||||
/// RBJ Audio EQ Cookbook: peaking EQ.
|
||||
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
// Небольшой запас (не ровно 0.5, а 0.49) — вплотную к самой границе Найквиста
|
||||
// отклик фильтра становится всё более острым/численно капризным даже формально
|
||||
// оставаясь "ниже" границы, безопаснее отступить чуть раньше.
|
||||
if freq >= sample_rate * 0.49 {
|
||||
return Self::identity();
|
||||
}
|
||||
|
||||
let a = 10f32.powf(gain_db / 40.0);
|
||||
let w0 = 2.0 * std::f32::consts::PI * freq / sample_rate;
|
||||
let alpha = w0.sin() / (2.0 * q);
|
||||
|
|
@ -113,3 +133,66 @@ impl Equalizer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stability_tests {
|
||||
use super::*;
|
||||
|
||||
/// Реальный найденный баг: EQ_FREQS доходит до 16000Гц, но при частоте
|
||||
/// дискретизации ниже 32000Гц (например, 24000Гц — частый случай для очень
|
||||
/// низкобитрейтных аудиокниг типа 64кбит) частота Найквиста (sample_rate/2) —
|
||||
/// всего 12000Гц, то есть полоса 16000Гц оказывается ВЫШЕ Найквиста, а полоса
|
||||
/// 12000Гц — ровно на границе. Коэффициенты биквада для такой полосы получаются
|
||||
/// конечными числами (сама формула не даёт NaN/inf на этапе вычисления
|
||||
/// коэффициентов), но результирующий фильтр нестабилен — при многократном
|
||||
/// рекурсивном применении (обычное дело за много секунд реального
|
||||
/// воспроизведения) его внутреннее состояние (y1/y2) расходится и в какой-то
|
||||
/// момент переполняется в бесконечность, откуда дальше всё становится NaN —
|
||||
/// именно поэтому в реальном плеере это выглядит как "скрип в начале, потом
|
||||
/// тишина", а не мгновенный сбой с первого сэмпла.
|
||||
#[test]
|
||||
fn eq_stays_finite_at_low_sample_rate_over_many_samples() {
|
||||
let sample_rate = 24000.0f32;
|
||||
let mut eq = Equalizer::new(sample_rate, 1);
|
||||
// Реальный EQ используется с нулевым усилением по умолчанию (gains_db = [0.0; ...]),
|
||||
// так что тест бьёт по сценарию "EQ включён, но пользователь ничего не подкручивал" —
|
||||
// именно так это и происходит в реальном плеере при обычном прослушивании.
|
||||
|
||||
// Симулируем реальный сигнал (не тишину — тишина тривиально стабильна) на несколько
|
||||
// секунд воспроизведения, достаточно много сэмплов, чтобы нестабильность успела
|
||||
// разойтись, если она есть.
|
||||
let mut buf: Vec<f32> = (0..sample_rate as usize * 5) // 5 секунд
|
||||
.map(|i| {
|
||||
let t = i as f32 / sample_rate;
|
||||
(2.0 * std::f32::consts::PI * 300.0 * t).sin() * 0.3
|
||||
})
|
||||
.collect();
|
||||
|
||||
eq.process_interleaved(&mut buf);
|
||||
|
||||
let first_non_finite = buf.iter().position(|s| !s.is_finite());
|
||||
assert!(
|
||||
first_non_finite.is_none(),
|
||||
"EQ produced a non-finite (NaN/inf) sample at index {:?} out of {} — filter is unstable at this sample rate",
|
||||
first_non_finite,
|
||||
buf.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// То же самое, но на "безопасной" высокой частоте дискретизации (44100Гц, Найквист
|
||||
/// 22050Гц — все полосы EQ_FREQS комфортно ниже) — должно быть стабильно всегда,
|
||||
/// это тест-контроль, подтверждающий, что дело именно в частоте, а не в чём-то ещё.
|
||||
#[test]
|
||||
fn eq_stays_finite_at_normal_sample_rate_over_many_samples() {
|
||||
let sample_rate = 44100.0f32;
|
||||
let mut eq = Equalizer::new(sample_rate, 1);
|
||||
let mut buf: Vec<f32> = (0..sample_rate as usize * 5)
|
||||
.map(|i| {
|
||||
let t = i as f32 / sample_rate;
|
||||
(2.0 * std::f32::consts::PI * 300.0 * t).sin() * 0.3
|
||||
})
|
||||
.collect();
|
||||
eq.process_interleaved(&mut buf);
|
||||
assert!(buf.iter().all(|s| s.is_finite()), "should stay finite at a normal sample rate");
|
||||
}
|
||||
}
|
||||
|
|
@ -349,6 +349,9 @@ pub struct App {
|
|||
pub audiobook_selected: usize,
|
||||
/// Если Some(id) — раскрыта книга с этим id, показываем список файлов/глав.
|
||||
pub audiobook_expanded: Option<String>,
|
||||
/// Показываем список удалённых книг (`R`) вместо обычного списка — для восстановления.
|
||||
pub audiobook_showing_removed: bool,
|
||||
pub audiobook_removed_selected: usize,
|
||||
pub audiobook_file_selected: usize,
|
||||
pub audiobook_search_query: String,
|
||||
/// id книги, отмеченной для объединения — следующая книга, на которой нажмут ту же
|
||||
|
|
@ -539,6 +542,8 @@ impl App {
|
|||
audiobook_filter: AudiobookFilter::All,
|
||||
audiobook_selected: 0,
|
||||
audiobook_expanded: None,
|
||||
audiobook_showing_removed: false,
|
||||
audiobook_removed_selected: 0,
|
||||
audiobook_file_selected: 0,
|
||||
audiobook_search_query: String::new(),
|
||||
audiobook_merge_pending: None,
|
||||
|
|
@ -1058,7 +1063,12 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn save_audiobook_progress_now(&mut self) {
|
||||
/// Принудительно сохраняет прогресс аудиокниги прямо сейчас, не дожидаясь обычного
|
||||
/// троттлинга в 5 секунд — вызывается на паузе и перед выходом из приложения, чтобы не
|
||||
/// терять несколько последних секунд точности резюме в самых частых сценариях
|
||||
/// "поставил на паузу и закрыл" / "просто закрыл во время прослушивания".
|
||||
/// Безопасно вызывать всегда — тихо ничего не делает, если сейчас не играет аудиокнига.
|
||||
pub fn save_audiobook_progress_now(&mut self) {
|
||||
let Some(book_id) = self.current_audiobook_id.clone() else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -1141,6 +1151,72 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Удаляет выбранную книгу из библиотеки аудиокниг — не трогает файлы на диске, только
|
||||
/// перестаёт её отслеживать (не появится заново при следующем пересканировании).
|
||||
pub fn remove_selected_audiobook(&mut self) {
|
||||
let books = self.filtered_audiobooks();
|
||||
let Some(book) = books.get(self.audiobook_selected) else {
|
||||
return;
|
||||
};
|
||||
let id = book.id.clone();
|
||||
let title = book.title.clone();
|
||||
match self.audiobook_store.remove_book(&id) {
|
||||
Ok(lib) => {
|
||||
self.audiobooks = lib;
|
||||
self.audiobook_selected = 0;
|
||||
self.status_message = format!("Removed '{title}' from library (R to restore)");
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = format!("Failed to remove book: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Полностью очищает библиотеку аудиокниг (все книги + отслеживаемые папки) — не
|
||||
/// трогает файлы на диске, только состояние приложения. Аналогично 'C' в обычной
|
||||
/// библиотеке и отписке от всех подкастов.
|
||||
pub fn clear_audiobook_library(&mut self) {
|
||||
match self.audiobook_store.clear_all() {
|
||||
Ok(lib) => {
|
||||
self.audiobooks = lib;
|
||||
self.audiobook_selected = 0;
|
||||
self.audiobook_expanded = None;
|
||||
self.status_message = "Audiobook library cleared".to_string();
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = format!("Failed to clear library: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_removed_books_view(&mut self) {
|
||||
self.audiobook_showing_removed = !self.audiobook_showing_removed;
|
||||
self.audiobook_removed_selected = 0;
|
||||
}
|
||||
|
||||
/// Восстанавливает выбранную в списке удалённых книгу обратно в библиотеку — сразу
|
||||
/// пересканирует, чтобы она появилась с актуальными данными.
|
||||
pub fn restore_selected_removed_book(&mut self) {
|
||||
let Some(removed) = self.audiobooks.removed_books.get(self.audiobook_removed_selected) else {
|
||||
return;
|
||||
};
|
||||
let folders = removed.folders.clone();
|
||||
let title = removed.title.clone();
|
||||
match self.audiobook_store.restore_book(&folders) {
|
||||
Ok(lib) => {
|
||||
self.audiobooks = lib;
|
||||
self.audiobook_removed_selected = 0;
|
||||
self.status_message = format!("Restored '{title}'");
|
||||
if self.audiobooks.removed_books.is_empty() {
|
||||
self.audiobook_showing_removed = false;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = format!("Failed to restore book: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_smb_inputs(&mut self) {
|
||||
self.smb_server.clear();
|
||||
self.smb_share.clear();
|
||||
|
|
@ -1791,6 +1867,11 @@ impl App {
|
|||
if currently_playing {
|
||||
self.paused_at = Some(std::time::Instant::now());
|
||||
self.player.play_pause();
|
||||
// Явно сохраняем прогресс прямо в момент паузы, не дожидаясь обычного троттлинга
|
||||
// (Position-события, двигающие троттлинг, всё равно перестанут приходить, пока
|
||||
// на паузе) — самый частый сценарий "поставил на паузу и закрыл" не должен терять
|
||||
// несколько последних секунд точности.
|
||||
self.save_audiobook_progress_now();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ fn event_loop<B: ratatui::backend::Backend>(
|
|||
}
|
||||
|
||||
if app.should_quit {
|
||||
app.save_audiobook_progress_now();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
|
@ -459,6 +460,10 @@ fn handle_audiobook_edit_author_key(app: &mut App, code: KeyCode) {
|
|||
}
|
||||
|
||||
fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
|
||||
if app.audiobook_showing_removed {
|
||||
handle_audiobook_removed_key(app, code);
|
||||
return;
|
||||
}
|
||||
if app.audiobook_expanded.is_some() {
|
||||
handle_audiobook_files_key(app, code);
|
||||
return;
|
||||
|
|
@ -479,6 +484,15 @@ fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
|
|||
KeyCode::Char('x') => {
|
||||
app.toggle_merge_selected_audiobook();
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
app.remove_selected_audiobook();
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
app.clear_audiobook_library();
|
||||
}
|
||||
KeyCode::Char('R') => {
|
||||
app.toggle_removed_books_view();
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
app.audiobook_filter = app.audiobook_filter.next();
|
||||
app.audiobook_selected = 0;
|
||||
|
|
@ -511,6 +525,30 @@ fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
|
|||
}
|
||||
}
|
||||
|
||||
fn handle_audiobook_removed_key(app: &mut App, code: KeyCode) {
|
||||
let len = app.audiobooks.removed_books.len();
|
||||
match code {
|
||||
KeyCode::Esc | KeyCode::Char('R') => {
|
||||
app.audiobook_showing_removed = false;
|
||||
app.audiobook_removed_selected = 0;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 {
|
||||
app.audiobook_removed_selected = (app.audiobook_removed_selected + 1) % len;
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if len > 0 {
|
||||
app.audiobook_removed_selected = (app.audiobook_removed_selected + len - 1) % len;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.restore_selected_removed_book();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_audiobook_files_key(app: &mut App, code: KeyCode) {
|
||||
let file_count = app
|
||||
.audiobook_expanded
|
||||
|
|
|
|||
|
|
@ -166,12 +166,15 @@ fn draw_help_popup(frame: &mut Frame, app: &App) {
|
|||
}
|
||||
}
|
||||
Tab::Audiobooks => {
|
||||
if app.audiobook_expanded.is_some() {
|
||||
if app.audiobook_showing_removed {
|
||||
lines.push(Line::from("Enter restore selected book Esc/R back to library"));
|
||||
} else if app.audiobook_expanded.is_some() {
|
||||
lines.push(Line::from("Enter play from selected file/chapter Esc back"));
|
||||
} else {
|
||||
lines.push(Line::from("Enter resume from saved position o browse files/chapters"));
|
||||
lines.push(Line::from("/ search p cycle status filter L add folder f rescan"));
|
||||
lines.push(Line::from("t edit title/author x mark/merge with another book"));
|
||||
lines.push(Line::from("d remove book D clear entire library R view/restore removed"));
|
||||
}
|
||||
}
|
||||
Tab::Stats => {
|
||||
|
|
@ -628,6 +631,34 @@ fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) {
|
|||
return;
|
||||
}
|
||||
|
||||
if app.audiobook_showing_removed {
|
||||
if app.audiobooks.removed_books.is_empty() {
|
||||
let p = Paragraph::new("No removed books")
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Removed books (? for help) "));
|
||||
frame.render_widget(p, area);
|
||||
return;
|
||||
}
|
||||
let items: Vec<ListItem> = app
|
||||
.audiobooks
|
||||
.removed_books
|
||||
.iter()
|
||||
.map(|r| ListItem::new(format!("{} — {}", r.author, r.title)))
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Removed books (Enter: restore, ? for help) "),
|
||||
)
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("> ");
|
||||
let mut state = ratatui::widgets::ListState::default();
|
||||
state.select(Some(app.audiobook_removed_selected));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(book_id) = &app.audiobook_expanded {
|
||||
let Some(book) = app.audiobooks.books.iter().find(|b| &b.id == book_id) else {
|
||||
return;
|
||||
|
|
@ -695,8 +726,9 @@ fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) {
|
|||
.map(|b| {
|
||||
let pct = (b.progress_ratio() * 100.0).round() as u32;
|
||||
let marked = app.audiobook_merge_pending.as_deref() == Some(b.id.as_str());
|
||||
let marker = if marked { "* " } else { " " };
|
||||
ListItem::new(format!("{marker}{} — {} [{pct}%]", b.author, b.title))
|
||||
let marker = if marked { "*" } else { " " };
|
||||
let letter = b.status().letter();
|
||||
ListItem::new(format!("{marker}[{letter}] {} — {} ({pct}%)", b.author, b.title))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue