2408 lines
90 KiB
Rust
2408 lines
90 KiB
Rust
use std::{
|
|
collections::BTreeSet,
|
|
io,
|
|
path::PathBuf,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use ratatui::{
|
|
DefaultTerminal, Frame,
|
|
crossterm::{
|
|
cursor::SetCursorStyle,
|
|
event::{
|
|
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent,
|
|
KeyEventKind,
|
|
},
|
|
execute,
|
|
},
|
|
layout::Margin,
|
|
style::{Color, Style, Stylize},
|
|
text::Line,
|
|
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
|
};
|
|
use serde_json::Value;
|
|
use tubular::anime::{Identity, Provider};
|
|
|
|
use crate::{
|
|
Options, PreviewMode, config,
|
|
keys::{Action, Context},
|
|
miruro_browser::{self, Episode, Media, Request, Response, Worker},
|
|
thumbnail::Thumbnail,
|
|
ui,
|
|
video_state::{CollectionKind, State, WatchStatus},
|
|
youtube::{PlayerLaunch, Video},
|
|
};
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
enum WatchFilter {
|
|
#[default]
|
|
All,
|
|
Unwatched,
|
|
Watched,
|
|
}
|
|
impl WatchFilter {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::All => Self::Unwatched,
|
|
Self::Unwatched => Self::Watched,
|
|
Self::Watched => Self::All,
|
|
}
|
|
}
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::All => "All",
|
|
Self::Unwatched => "Unwatched",
|
|
Self::Watched => "Watched",
|
|
}
|
|
}
|
|
fn includes(self, watched: bool) -> bool {
|
|
match self {
|
|
Self::All => true,
|
|
Self::Unwatched => !watched,
|
|
Self::Watched => watched,
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Content {
|
|
Catalog {
|
|
query: String,
|
|
media: Vec<Media>,
|
|
searched: bool,
|
|
},
|
|
Series {
|
|
media: Media,
|
|
data: Option<Vec<Episode>>,
|
|
},
|
|
Collection {
|
|
kind: CollectionKind,
|
|
filter: WatchFilter,
|
|
videos: Vec<Video>,
|
|
},
|
|
Library {
|
|
categories: BTreeSet<String>,
|
|
shows: Vec<crate::video_state::SavedShow>,
|
|
},
|
|
}
|
|
struct DisplayRow {
|
|
key: Option<String>,
|
|
title: String,
|
|
details: String,
|
|
metadata: String,
|
|
}
|
|
struct Screen {
|
|
content: Content,
|
|
provider: Provider,
|
|
selection: ListState,
|
|
checked: Vec<String>,
|
|
description_scroll: u16,
|
|
error: Option<String>,
|
|
title: String,
|
|
overview: String,
|
|
description: String,
|
|
rows: Vec<DisplayRow>,
|
|
}
|
|
impl Screen {
|
|
fn new(content: Content) -> Self {
|
|
let mut screen = Self {
|
|
content,
|
|
provider: Provider::Miruro,
|
|
selection: ListState::default(),
|
|
checked: Vec::new(),
|
|
description_scroll: 0,
|
|
error: None,
|
|
title: String::new(),
|
|
overview: String::new(),
|
|
description: String::new(),
|
|
rows: Vec::new(),
|
|
};
|
|
screen.clamp();
|
|
screen.rebuild_display();
|
|
screen
|
|
}
|
|
fn len(&self) -> usize {
|
|
match &self.content {
|
|
Content::Catalog { media, .. } => media.len(),
|
|
Content::Series { data, .. } => data.as_ref().map_or(0, Vec::len),
|
|
Content::Collection { videos, .. } => videos.len(),
|
|
Content::Library { shows, .. } => shows.len(),
|
|
}
|
|
}
|
|
fn clamp(&mut self) {
|
|
self.selection.select(if self.len() == 0 {
|
|
None
|
|
} else {
|
|
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
|
|
});
|
|
}
|
|
fn selected_media(&self) -> Option<&Media> {
|
|
let index = self.selection.selected();
|
|
match &self.content {
|
|
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)),
|
|
Content::Series { media, .. } => Some(media),
|
|
Content::Library { shows, .. } => {
|
|
index.and_then(|i| shows.get(i)).map(|show| &show.media)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
fn selected_video(&self) -> Option<Video> {
|
|
let index = self.selection.selected()?;
|
|
match &self.content {
|
|
Content::Series {
|
|
media,
|
|
data: Some(data),
|
|
} => data.get(index).map(|episode| episode.snapshot(media)),
|
|
Content::Collection { videos, .. } => videos.get(index).cloned(),
|
|
_ => None,
|
|
}
|
|
}
|
|
fn playable(&self) -> bool {
|
|
matches!(
|
|
self.content,
|
|
Content::Series { .. } | Content::Collection { .. }
|
|
)
|
|
}
|
|
fn toggle_checked(&mut self) {
|
|
if self.playable()
|
|
&& let Some(id) = self.selected_key()
|
|
{
|
|
if let Some(index) = self.checked.iter().position(|checked| checked == id) {
|
|
self.checked.remove(index);
|
|
} else {
|
|
let id = id.to_owned();
|
|
self.checked.push(id);
|
|
}
|
|
}
|
|
}
|
|
fn playback_videos(&self) -> Vec<Video> {
|
|
if self.checked.is_empty() {
|
|
return self.selected_video().into_iter().collect();
|
|
}
|
|
// ponytail: scan rows per check; index IDs if very large lists need it.
|
|
self.checked
|
|
.iter()
|
|
.filter_map(|id| {
|
|
let index = self
|
|
.rows
|
|
.iter()
|
|
.position(|row| row.key.as_ref() == Some(id))?;
|
|
match &self.content {
|
|
Content::Series {
|
|
media,
|
|
data: Some(data),
|
|
} => data.get(index).map(|episode| episode.snapshot(media)),
|
|
Content::Collection { videos, .. } => videos.get(index).cloned(),
|
|
_ => None,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
fn selected_key(&self) -> Option<&str> {
|
|
self.selection
|
|
.selected()
|
|
.and_then(|index| self.rows.get(index))
|
|
.and_then(|row| row.key.as_deref())
|
|
}
|
|
fn update_description(&mut self) {
|
|
self.description.clear();
|
|
if let Some(row) = self
|
|
.selection
|
|
.selected()
|
|
.and_then(|index| self.rows.get(index))
|
|
{
|
|
self.description.push_str(&row.metadata);
|
|
}
|
|
if !self.overview.is_empty() {
|
|
if !self.description.is_empty() {
|
|
self.description.push_str("\n\n");
|
|
}
|
|
self.description.push_str(&self.overview);
|
|
}
|
|
}
|
|
fn rebuild_display(&mut self) {
|
|
self.rows.clear();
|
|
self.overview.clear();
|
|
match &self.content {
|
|
Content::Catalog { media, .. } => {
|
|
self.title = format!("{} · Catalog (up to 8 titles)", self.provider.name());
|
|
self.rows = media
|
|
.iter()
|
|
.map(|media| DisplayRow {
|
|
key: Some(media.id.clone()),
|
|
title: safe(&media.title),
|
|
details: media_summary(media),
|
|
metadata: media_details(media),
|
|
})
|
|
.collect();
|
|
}
|
|
Content::Series { media, data } => {
|
|
self.title = format!(
|
|
"{} · {} · Episodes",
|
|
self.provider.name(),
|
|
safe(&media.title)
|
|
);
|
|
self.overview = media_details(media);
|
|
if let Some(data) = data {
|
|
if self.provider == Provider::AniList {
|
|
self.overview.push_str("\n\nProvider availability UNVERIFIED. Sub only; resolved on selection, not a stream inventory.");
|
|
if media
|
|
.metadata
|
|
.get("catalog_count")
|
|
.is_none_or(Value::is_null)
|
|
{
|
|
self.overview.push_str("\nCatalog/airing count unknown. Use search/edit (i by default) here to select an explicit positive episode number.");
|
|
} else {
|
|
self.overview.push_str(&format!("\n{} catalog/airing episode numbers (planned totals are not availability).", data.len()));
|
|
}
|
|
} else {
|
|
self.overview.push_str(&format!(
|
|
"\n\n{} returned episodes (source availability is checked on play).",
|
|
data.len()
|
|
));
|
|
}
|
|
self.rows = data
|
|
.iter()
|
|
.map(|episode| {
|
|
let title = format!(
|
|
"Episode {}{}",
|
|
episode.identity.episode,
|
|
episode
|
|
.title
|
|
.as_ref()
|
|
.map(|title| format!(" · {}", safe(title)))
|
|
.unwrap_or_default()
|
|
);
|
|
let mut details = Vec::new();
|
|
if let Some(date) = &episode.aired_on {
|
|
details.push(safe(date));
|
|
}
|
|
if let Some(seconds) = episode.duration_seconds {
|
|
details.push(format!("{}:{:02}", seconds / 60, seconds % 60));
|
|
}
|
|
if self.provider == Provider::AniList {
|
|
if let Some(minutes) = media
|
|
.metadata
|
|
.get("episode_duration_minutes")
|
|
.and_then(Value::as_u64)
|
|
.filter(|minutes| *minutes > 0)
|
|
{
|
|
details.push(format!("{minutes} min avg"));
|
|
}
|
|
if let Some(format) =
|
|
media.metadata.get("format").and_then(Value::as_str)
|
|
{
|
|
details.push(safe(format));
|
|
}
|
|
if let Some(year) =
|
|
media.metadata.get("season_year").and_then(Value::as_u64)
|
|
{
|
|
details.push(format!("{year} series"));
|
|
}
|
|
details.push("sub · UNVERIFIED".into());
|
|
}
|
|
let details = details.join(" · ");
|
|
let mut metadata = title.clone();
|
|
if !details.is_empty() {
|
|
metadata.push('\n');
|
|
metadata.push_str(&details);
|
|
}
|
|
if let Some(synopsis) = &episode.synopsis {
|
|
metadata.push_str(&format!("\n\n{}", plain(synopsis)));
|
|
}
|
|
for (field, label) in [
|
|
("kind", "Kind"),
|
|
("canon_type", "Canon"),
|
|
("finale_type", "Finale"),
|
|
] {
|
|
if let Some(value) =
|
|
episode.metadata.get(field).and_then(Value::as_str)
|
|
{
|
|
metadata.push_str(&format!("\n{label}: {}", safe(value)));
|
|
}
|
|
}
|
|
DisplayRow {
|
|
key: Some(episode.identity.key()),
|
|
title,
|
|
details,
|
|
metadata,
|
|
}
|
|
})
|
|
.collect();
|
|
}
|
|
}
|
|
Content::Collection {
|
|
kind,
|
|
filter,
|
|
videos,
|
|
} => {
|
|
self.title = format!(
|
|
"{} · {} episodes{}",
|
|
self.provider.name(),
|
|
collection_name(*kind),
|
|
if matches!(kind, CollectionKind::WatchLater) {
|
|
format!(" · {}", filter.label())
|
|
} else {
|
|
String::new()
|
|
}
|
|
);
|
|
self.rows = videos.iter().map(|video| {
|
|
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
|
let title = safe(&video.title);
|
|
let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Provider availability UNVERIFIED. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id);
|
|
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
|
}).collect();
|
|
}
|
|
Content::Library { categories, shows } => {
|
|
self.title = format!(
|
|
"{} · Saved shows · {}",
|
|
self.provider.name(),
|
|
if categories.is_empty() {
|
|
"All saved shows".into()
|
|
} else {
|
|
categories.iter().cloned().collect::<Vec<_>>().join(" + ")
|
|
}
|
|
);
|
|
self.rows = shows
|
|
.iter()
|
|
.map(|show| DisplayRow {
|
|
key: Some(show.media.id.clone()),
|
|
title: safe(&show.media.title),
|
|
details: media_summary(&show.media),
|
|
metadata: media_details(&show.media),
|
|
})
|
|
.collect();
|
|
}
|
|
}
|
|
if !self.checked.is_empty() {
|
|
let visible: BTreeSet<_> = self
|
|
.rows
|
|
.iter()
|
|
.filter_map(|row| row.key.as_deref())
|
|
.collect();
|
|
self.checked.retain(|id| visible.contains(id.as_str()));
|
|
}
|
|
self.update_description();
|
|
}
|
|
}
|
|
|
|
// History owns the actual screens, so back/forward never has to refetch a catalog.
|
|
pub(crate) struct Browser {
|
|
options: Options,
|
|
provider: Provider,
|
|
episode_input: bool,
|
|
config_path: PathBuf,
|
|
config: config::Config,
|
|
pub(crate) state: State,
|
|
screens: Vec<Screen>,
|
|
index: usize,
|
|
input: Option<String>,
|
|
preview: Option<PreviewMode>,
|
|
thumbnail: Thumbnail,
|
|
help: Option<u16>,
|
|
removal: Option<(CollectionKind, Vec<String>)>,
|
|
modal: Option<LibraryModal>,
|
|
status: String,
|
|
worker: Option<Worker>,
|
|
generation: u64,
|
|
pending: Option<Request>,
|
|
intent: Option<Intent>,
|
|
player: Option<PlayerLaunch>,
|
|
}
|
|
enum Intent {
|
|
Catalog,
|
|
Series(String),
|
|
}
|
|
|
|
enum LibraryModal {
|
|
Tags {
|
|
media: Media,
|
|
checked: BTreeSet<String>,
|
|
names: Vec<String>,
|
|
picker: Picker,
|
|
name: Option<String>,
|
|
error: Option<String>,
|
|
},
|
|
Categories {
|
|
names: Vec<String>,
|
|
checked: BTreeSet<String>,
|
|
picker: Picker,
|
|
},
|
|
}
|
|
#[derive(Default)]
|
|
struct Picker {
|
|
query: String,
|
|
editing: bool,
|
|
matches: Vec<usize>,
|
|
selection: ListState,
|
|
}
|
|
impl Picker {
|
|
fn refilter(&mut self, names: &[String]) {
|
|
let query = self.query.to_lowercase();
|
|
self.matches = names
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, name)| name.to_lowercase().contains(&query))
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
self.selection.select(if self.matches.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
self.selection
|
|
.selected()
|
|
.unwrap_or(0)
|
|
.min(self.matches.len() - 1),
|
|
)
|
|
});
|
|
}
|
|
fn selected<'a>(&self, names: &'a [String]) -> Option<&'a String> {
|
|
self.selection
|
|
.selected()
|
|
.and_then(|i| self.matches.get(i))
|
|
.and_then(|i| names.get(*i))
|
|
}
|
|
fn step(&mut self, down: bool) {
|
|
if !self.matches.is_empty() {
|
|
let i = self.selection.selected().unwrap_or(0);
|
|
self.selection.select(Some(if down {
|
|
(i + 1).min(self.matches.len() - 1)
|
|
} else {
|
|
i.saturating_sub(1)
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Browser {
|
|
pub(crate) fn new(
|
|
options: Options,
|
|
config_path: PathBuf,
|
|
config: config::Config,
|
|
mut state: State,
|
|
) -> Self {
|
|
let provider = if options.anilist {
|
|
Provider::AniList
|
|
} else {
|
|
Provider::Miruro
|
|
};
|
|
state.select_catalog(provider);
|
|
let mut query = String::new();
|
|
crate::paste_input(&mut query, options.query.as_deref().unwrap_or(""));
|
|
let mut browser = Self {
|
|
provider,
|
|
episode_input: false,
|
|
options,
|
|
config_path,
|
|
config,
|
|
state,
|
|
screens: vec![Screen::new(Content::Catalog {
|
|
query,
|
|
media: Vec::new(),
|
|
searched: false,
|
|
})],
|
|
index: 0,
|
|
input: None,
|
|
preview: None,
|
|
thumbnail: Thumbnail::new(),
|
|
help: None,
|
|
removal: None,
|
|
modal: None,
|
|
status: String::new(),
|
|
worker: None,
|
|
generation: 0,
|
|
pending: None,
|
|
intent: None,
|
|
player: None,
|
|
};
|
|
browser.screens[0].provider = provider;
|
|
browser.screens[0].rebuild_display();
|
|
if matches!(&browser.screen().content, Content::Catalog { query, .. } if !query.trim().is_empty())
|
|
{
|
|
browser.search();
|
|
}
|
|
browser
|
|
}
|
|
fn screen(&self) -> &Screen {
|
|
&self.screens[self.index]
|
|
}
|
|
fn screen_mut(&mut self) -> &mut Screen {
|
|
&mut self.screens[self.index]
|
|
}
|
|
fn invalidate(&mut self) {
|
|
self.generation = self.generation.wrapping_add(1);
|
|
self.pending = None;
|
|
self.intent = None;
|
|
}
|
|
fn request(&mut self, request: Request, intent: Intent) {
|
|
self.invalidate();
|
|
self.pending = Some(request);
|
|
self.intent = Some(intent);
|
|
self.screen_mut().error = None;
|
|
}
|
|
fn push(&mut self, mut screen: Screen) {
|
|
screen.provider = self.provider;
|
|
screen.rebuild_display();
|
|
self.invalidate();
|
|
self.screens.truncate(self.index + 1);
|
|
self.screens.push(screen);
|
|
self.index += 1;
|
|
self.preview = None;
|
|
self.status.clear();
|
|
}
|
|
fn history(&mut self, forward: bool) {
|
|
let index = if forward {
|
|
(self.index + 1).min(self.screens.len() - 1)
|
|
} else {
|
|
self.index.saturating_sub(1)
|
|
};
|
|
if index == self.index {
|
|
return;
|
|
}
|
|
self.invalidate();
|
|
self.index = index;
|
|
self.preview = None;
|
|
self.status.clear();
|
|
self.rebuild_collections();
|
|
if matches!(&self.screen().content, Content::Series { data: None, .. }) {
|
|
self.load_series();
|
|
} else if matches!(&self.screen().content, Content::Catalog { searched: false, query, .. } if !query.trim().is_empty())
|
|
{
|
|
self.search();
|
|
}
|
|
}
|
|
fn edit_search(&mut self) {
|
|
self.episode_input = false;
|
|
if self.provider == Provider::AniList
|
|
&& matches!(&self.screen().content, Content::Series { media, data: Some(_) } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
|
|
{
|
|
self.invalidate();
|
|
self.preview = None;
|
|
self.input = Some(String::new());
|
|
self.episode_input = true;
|
|
self.status =
|
|
"Enter an explicit episode number; availability UNVERIFIED (sub only).".into();
|
|
return;
|
|
}
|
|
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
|
self.push(Screen::new(Content::Catalog {
|
|
query: String::new(),
|
|
media: Vec::new(),
|
|
searched: false,
|
|
}));
|
|
}
|
|
self.invalidate();
|
|
if let Content::Catalog { query, .. } = &self.screen().content {
|
|
self.input = Some(query.clone());
|
|
}
|
|
self.preview = None;
|
|
}
|
|
fn select_episode(&mut self, input: &str) {
|
|
let result = (|| -> io::Result<()> {
|
|
let Content::Series {
|
|
media,
|
|
data: Some(data),
|
|
} = &mut self.screen_mut().content
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let identity = Identity::from_key(&format!("anilist:{}:{}", media.id, input.trim()))?;
|
|
let episode = miruro_browser::anilist_episode(media, identity.episode)?;
|
|
let index = data
|
|
.iter()
|
|
.position(|item| item.identity == identity)
|
|
.unwrap_or(data.len());
|
|
if index == data.len() {
|
|
data.push(episode);
|
|
}
|
|
self.screen_mut().selection.select(Some(index));
|
|
self.screen_mut().rebuild_display();
|
|
Ok(())
|
|
})();
|
|
match result {
|
|
Ok(()) => {
|
|
self.screen_mut().error = None;
|
|
self.status = "Explicit episode selected; provider availability UNVERIFIED. Enter resolves sub on demand.".into();
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
fn search(&mut self) {
|
|
let Content::Catalog { query, .. } = &self.screen().content else {
|
|
return;
|
|
};
|
|
let query = query.trim().to_owned();
|
|
self.invalidate();
|
|
self.screen_mut().error = None;
|
|
if query.is_empty() {
|
|
if let Content::Catalog {
|
|
media, searched, ..
|
|
} = &mut self.screen_mut().content
|
|
{
|
|
media.clear();
|
|
*searched = false;
|
|
}
|
|
self.screen_mut().clamp();
|
|
self.screen_mut().rebuild_display();
|
|
self.status.clear();
|
|
return;
|
|
}
|
|
self.request(Request::Search(query), Intent::Catalog);
|
|
self.status = format!("Searching {} catalog…", self.provider.name());
|
|
}
|
|
fn load_series(&mut self) {
|
|
let Content::Series { media, .. } = &self.screen().content else {
|
|
return;
|
|
};
|
|
let id = media.id.clone();
|
|
let slug = if self.provider == Provider::AniList {
|
|
Ok(String::new())
|
|
} else {
|
|
miruro_browser::slug(&media.title)
|
|
};
|
|
self.screen_mut().checked.clear();
|
|
match slug {
|
|
Ok(slug) => {
|
|
self.request(
|
|
Request::Episodes {
|
|
media_id: id.clone(),
|
|
slug,
|
|
},
|
|
Intent::Series(id),
|
|
);
|
|
self.status = "Loading catalog episode metadata…".into();
|
|
}
|
|
Err(error) => {
|
|
self.invalidate();
|
|
self.fail(error);
|
|
}
|
|
}
|
|
}
|
|
fn open(&mut self) {
|
|
if let Some(media) = self.screen().selected_media().cloned()
|
|
&& !matches!(self.screen().content, Content::Series { .. })
|
|
{
|
|
self.push(Screen::new(Content::Series { media, data: None }));
|
|
self.load_series();
|
|
} else {
|
|
self.play(false);
|
|
}
|
|
}
|
|
fn play(&mut self, enqueue: bool) {
|
|
if self.screen().playable() {
|
|
let videos = self.screen().playback_videos();
|
|
if videos.is_empty() {
|
|
return;
|
|
}
|
|
self.invalidate();
|
|
self.screen_mut().error = None;
|
|
// Dropping the launcher cancels only its CLI child, not the native player.
|
|
self.player = None;
|
|
let result =
|
|
PlayerLaunch::start(&videos, &self.options.player, &mut self.state, enqueue);
|
|
match result {
|
|
Ok(player) => {
|
|
self.player = Some(player);
|
|
self.screen_mut().checked.clear();
|
|
self.status = if videos.len() == 1 {
|
|
format!("Handing {} to Terebi…", safe(&videos[0].title))
|
|
} else {
|
|
format!("Handing {} episodes to Terebi…", videos.len())
|
|
};
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
}
|
|
fn fail(&mut self, error: impl std::fmt::Display) {
|
|
let message = safe(&error.to_string());
|
|
self.screen_mut().error = Some(message.clone());
|
|
self.status = message;
|
|
}
|
|
fn accept(&mut self, generation: u64, result: io::Result<Response>) {
|
|
if generation != self.generation {
|
|
return;
|
|
}
|
|
let Some(intent) = self.intent.take() else {
|
|
return;
|
|
};
|
|
self.screen_mut().error = None;
|
|
match (intent, result) {
|
|
(Intent::Catalog, Ok(Response::Catalog(media))) => {
|
|
let selected = self.screen().selection.selected();
|
|
if let Content::Catalog {
|
|
media: current,
|
|
searched,
|
|
..
|
|
} = &mut self.screens[self.index].content
|
|
{
|
|
// Selection is preserved by stable catalog ID when refreshing.
|
|
let id = selected
|
|
.and_then(|index| current.get(index))
|
|
.map(|media| media.id.clone());
|
|
let next = id.and_then(|id| media.iter().position(|media| media.id == id));
|
|
*current = media;
|
|
*searched = true;
|
|
self.screens[self.index].selection.select(next);
|
|
self.screen_mut().clamp();
|
|
self.screen_mut().rebuild_display();
|
|
self.status.clear();
|
|
}
|
|
}
|
|
(Intent::Series(id), Ok(Response::Series(series))) if series.media.id == id => {
|
|
let selected = self.screen().selected_video().map(|video| video.id);
|
|
if let Content::Series { media, data } = &mut self.screen_mut().content {
|
|
if media.id != id {
|
|
return;
|
|
}
|
|
let next = selected.and_then(|key| {
|
|
series
|
|
.episodes
|
|
.iter()
|
|
.position(|episode| episode.identity.key() == key)
|
|
});
|
|
// Keep catalog fields absent from detail, but explicit detail nulls remain unknown.
|
|
// The selected catalog title stays the title of this screen.
|
|
if let (Some(current), Value::Object(incoming)) =
|
|
(media.metadata.as_object_mut(), series.media.metadata)
|
|
{
|
|
current.extend(incoming);
|
|
}
|
|
*data = Some(series.episodes);
|
|
self.screen_mut().checked.clear();
|
|
self.screen_mut().selection.select(next);
|
|
self.screen_mut().clamp();
|
|
self.screen_mut().rebuild_display();
|
|
self.status.clear();
|
|
}
|
|
}
|
|
(_, Err(error)) => self.fail(error),
|
|
_ => self.fail(io::Error::other(
|
|
"Anime catalog response did not match the current request",
|
|
)),
|
|
}
|
|
}
|
|
fn update(&mut self) {
|
|
if let Some((generation, result)) = self.worker.as_mut().and_then(Worker::poll) {
|
|
self.accept(generation, result);
|
|
}
|
|
if self.pending.is_some() && !self.worker.as_ref().is_some_and(|worker| worker.busy) {
|
|
let request = self.pending.take().expect("pending request");
|
|
let provider = self.provider;
|
|
let worker = self.worker.get_or_insert_with(|| Worker::new(provider));
|
|
if let Err(error) = worker.submit(self.generation, request) {
|
|
self.intent = None;
|
|
self.fail(error);
|
|
}
|
|
}
|
|
if let Some(result) = self.player.as_mut().and_then(PlayerLaunch::poll) {
|
|
self.player = None;
|
|
match result {
|
|
Ok(()) => {
|
|
self.status =
|
|
"Episode handed to Terebi; playback continues in its window.".into()
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
match self.state.poll_progress() {
|
|
Ok(true) => self.rebuild_collections(),
|
|
Ok(false) => {}
|
|
Err(error) => self.status = safe(&error.to_string()),
|
|
}
|
|
}
|
|
fn collection(&mut self, kind: CollectionKind) {
|
|
let videos = collection_videos(&self.state, kind, WatchFilter::All, self.provider);
|
|
let screen = Screen::new(Content::Collection {
|
|
kind,
|
|
filter: WatchFilter::All,
|
|
videos,
|
|
});
|
|
self.push(screen);
|
|
}
|
|
fn categories(&self) -> BTreeSet<String> {
|
|
self.state
|
|
.shows()
|
|
.iter()
|
|
.flat_map(|show| show.tags.iter().cloned())
|
|
.collect()
|
|
}
|
|
fn tags(&mut self) {
|
|
let Some(media) = self.screen().selected_media().cloned() else {
|
|
return;
|
|
};
|
|
let checked = self
|
|
.state
|
|
.shows()
|
|
.iter()
|
|
.find(|show| show.media.id == media.id)
|
|
.map(|show| show.tags.clone())
|
|
.unwrap_or_default();
|
|
let names: Vec<_> = self.categories().into_iter().collect();
|
|
let mut picker = Picker::default();
|
|
picker.refilter(&names);
|
|
self.preview = None;
|
|
self.modal = Some(LibraryModal::Tags {
|
|
media,
|
|
checked,
|
|
names,
|
|
picker,
|
|
name: None,
|
|
error: None,
|
|
});
|
|
}
|
|
fn category_picker(&mut self) {
|
|
let mut names = vec!["All saved shows".to_owned()];
|
|
names.extend(self.categories());
|
|
let mut picker = Picker::default();
|
|
picker.refilter(&names);
|
|
self.preview = None;
|
|
self.modal = Some(LibraryModal::Categories {
|
|
names,
|
|
checked: BTreeSet::new(),
|
|
picker,
|
|
});
|
|
}
|
|
fn library(&mut self, categories: BTreeSet<String>) {
|
|
let shows = library_shows(&self.state, &categories);
|
|
self.push(Screen::new(Content::Library { categories, shows }));
|
|
}
|
|
fn rebuild_library(&mut self) {
|
|
for screen in &mut self.screens {
|
|
if let Content::Library { categories, shows } = &mut screen.content {
|
|
let selected = screen
|
|
.selection
|
|
.selected()
|
|
.and_then(|i| shows.get(i))
|
|
.map(|show| show.media.id.clone());
|
|
let replacement = library_shows(&self.state, categories);
|
|
let next = selected
|
|
.and_then(|id| replacement.iter().position(|show| show.media.id == id))
|
|
.or(screen.selection.selected());
|
|
*shows = replacement;
|
|
screen.selection.select(next);
|
|
screen.clamp();
|
|
screen.rebuild_display();
|
|
}
|
|
}
|
|
}
|
|
fn modal_key(&mut self, key: KeyEvent, action: Option<Action>) {
|
|
if matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
|
|
return;
|
|
}
|
|
let mut modal = self.modal.take().expect("modal active");
|
|
if let LibraryModal::Tags {
|
|
name: Some(name),
|
|
checked,
|
|
names,
|
|
picker,
|
|
error,
|
|
..
|
|
} = &mut modal
|
|
{
|
|
match action {
|
|
Some(Action::InputCancel) => {
|
|
if let LibraryModal::Tags { name, error, .. } = &mut modal {
|
|
*name = None;
|
|
*error = None;
|
|
}
|
|
}
|
|
Some(Action::InputSubmit) => {
|
|
let value = name.trim().to_owned();
|
|
if value.is_empty() || value.chars().any(char::is_control) {
|
|
*error = Some("Enter a nonempty tag without control characters.".into());
|
|
} else if names.contains(&value) {
|
|
*error = Some("That tag already exists.".into());
|
|
} else {
|
|
checked.insert(value.clone());
|
|
names.push(value.clone());
|
|
names.sort();
|
|
picker.query.clear();
|
|
picker.refilter(names);
|
|
picker
|
|
.selection
|
|
.select(names.iter().position(|name| name == &value));
|
|
if let LibraryModal::Tags { name, error, .. } = &mut modal {
|
|
*name = None;
|
|
*error = None;
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
crate::edit_input(name, key, action);
|
|
}
|
|
}
|
|
self.modal = Some(modal);
|
|
return;
|
|
}
|
|
let is_tags = matches!(modal, LibraryModal::Tags { .. });
|
|
let (names, picker) = match &mut modal {
|
|
LibraryModal::Tags { names, picker, .. }
|
|
| LibraryModal::Categories { names, picker, .. } => (names, picker),
|
|
};
|
|
let action = if picker.editing {
|
|
match action {
|
|
Some(Action::InputCancel) => {
|
|
picker.editing = false;
|
|
None
|
|
}
|
|
Some(Action::InputSubmit) if is_tags => {
|
|
picker.editing = false;
|
|
None
|
|
}
|
|
Some(Action::InputSubmit) => Some(Action::SelectorOpen),
|
|
Some(Action::TagInputNext | Action::SelectorInputNext) => {
|
|
picker.step(true);
|
|
None
|
|
}
|
|
Some(Action::TagInputPrevious | Action::SelectorInputPrevious) => {
|
|
picker.step(false);
|
|
None
|
|
}
|
|
_ => {
|
|
crate::edit_input(&mut picker.query, key, action);
|
|
picker.refilter(names);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
action
|
|
};
|
|
match action {
|
|
Some(Action::TagCancel | Action::SelectorClose) => return,
|
|
Some(Action::TagEdit | Action::SelectorEdit) => picker.editing = true,
|
|
Some(Action::TagNext | Action::SelectorNext) => picker.step(true),
|
|
Some(Action::TagPrevious | Action::SelectorPrevious) => picker.step(false),
|
|
Some(Action::TagNew) => {
|
|
if let LibraryModal::Tags { name, error, .. } = &mut modal {
|
|
*name = Some(String::new());
|
|
*error = None;
|
|
}
|
|
}
|
|
Some(Action::TagToggle | Action::SelectorToggle) => {
|
|
if let Some(selected) = picker.selected(names).cloned() {
|
|
let all = !is_tags
|
|
&& picker.matches.get(picker.selection.selected().unwrap_or(0)) == Some(&0);
|
|
let checked = match &mut modal {
|
|
LibraryModal::Tags { checked, .. }
|
|
| LibraryModal::Categories { checked, .. } => checked,
|
|
};
|
|
if all {
|
|
checked.clear();
|
|
} else if !checked.remove(&selected) {
|
|
checked.insert(selected);
|
|
}
|
|
}
|
|
}
|
|
Some(Action::TagSave) => {
|
|
if let LibraryModal::Tags {
|
|
media,
|
|
checked,
|
|
error,
|
|
..
|
|
} = &mut modal
|
|
{
|
|
match self.state.save_show(media, checked) {
|
|
Ok(()) => {
|
|
self.rebuild_library();
|
|
self.status = "Saved show and category tags.".into();
|
|
return;
|
|
}
|
|
Err(failure) => *error = Some(safe(&failure.to_string())),
|
|
}
|
|
}
|
|
}
|
|
Some(Action::SelectorOpen) => {
|
|
if let LibraryModal::Categories {
|
|
names,
|
|
checked,
|
|
picker,
|
|
} = &modal
|
|
{
|
|
if !checked.is_empty() {
|
|
self.library(checked.clone());
|
|
return;
|
|
}
|
|
if let Some(selected) = picker.selected(names) {
|
|
let mut categories = checked.clone();
|
|
if picker.matches.get(picker.selection.selected().unwrap_or(0)) == Some(&0)
|
|
{
|
|
categories.clear();
|
|
} else {
|
|
categories.insert(selected.clone());
|
|
}
|
|
self.library(categories);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
self.modal = Some(modal);
|
|
}
|
|
fn rebuild_collections(&mut self) {
|
|
for screen in &mut self.screens {
|
|
let Content::Collection {
|
|
kind,
|
|
filter,
|
|
videos,
|
|
} = &mut screen.content
|
|
else {
|
|
continue;
|
|
};
|
|
let selected = screen
|
|
.selection
|
|
.selected()
|
|
.and_then(|index| videos.get(index))
|
|
.map(|video| video.id.clone());
|
|
let replacement = collection_videos(&self.state, *kind, *filter, self.provider);
|
|
let next = selected
|
|
.and_then(|id| replacement.iter().position(|video| video.id == id))
|
|
.or(screen.selection.selected());
|
|
*videos = replacement;
|
|
screen.selection.select(next);
|
|
screen.clamp();
|
|
screen.rebuild_display();
|
|
}
|
|
}
|
|
fn save(&mut self, kind: CollectionKind) {
|
|
let Some(video) = self.screen().selected_video() else {
|
|
return;
|
|
};
|
|
match self.state.save(kind, &video) {
|
|
Ok(()) => {
|
|
self.rebuild_collections();
|
|
self.status = format!("Saved episode to {}.", collection_name(kind));
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
fn set_watch_status(&mut self, status: WatchStatus) {
|
|
let Some(video) = self.screen().selected_video() else {
|
|
return;
|
|
};
|
|
let result = self.state.set_watch_status(&video, status);
|
|
self.rebuild_collections();
|
|
match result {
|
|
Ok(()) => {
|
|
self.status = match status {
|
|
WatchStatus::Watching => "Marked episode as watching.",
|
|
WatchStatus::Watched => "Marked episode as watched.",
|
|
}
|
|
.into();
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
fn remove(&mut self, bulk: bool) {
|
|
if matches!(self.screen().content, Content::Library { .. }) {
|
|
if !bulk && let Some(media) = self.screen().selected_media() {
|
|
let id = media.id.clone();
|
|
match self.state.remove_show(&id) {
|
|
Ok(()) => {
|
|
self.rebuild_library();
|
|
self.status = "Removed saved show; episode state unchanged.".into();
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
let Content::Collection { kind, videos, .. } = &self.screen().content else {
|
|
return;
|
|
};
|
|
let kind = *kind;
|
|
let ids: Vec<_> = if bulk {
|
|
videos.iter().map(|video| video.id.clone()).collect()
|
|
} else {
|
|
self.screen()
|
|
.selected_video()
|
|
.map(|video| vec![video.id])
|
|
.unwrap_or_default()
|
|
};
|
|
if ids.is_empty() {
|
|
return;
|
|
}
|
|
self.invalidate();
|
|
if bulk {
|
|
self.removal = Some((kind, ids));
|
|
self.preview = None;
|
|
} else {
|
|
self.remove_ids(kind, ids);
|
|
}
|
|
}
|
|
fn remove_ids(&mut self, kind: CollectionKind, ids: Vec<String>) {
|
|
// Even the confirmation snapshot is provider-scoped at the mutation seam.
|
|
let ids: Vec<_> = ids
|
|
.into_iter()
|
|
.filter(|id| self.provider.includes(id))
|
|
.collect();
|
|
match self.state.remove(kind, &ids) {
|
|
Ok(()) => {
|
|
self.rebuild_collections();
|
|
self.status = format!(
|
|
"Removed {} episodes from {}.",
|
|
ids.len(),
|
|
collection_name(kind)
|
|
);
|
|
}
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
fn refresh(&mut self) {
|
|
self.invalidate();
|
|
self.screen_mut().checked.clear();
|
|
match config::load(&self.config_path) {
|
|
Ok(config) => {
|
|
self.config = config;
|
|
self.thumbnail.clear_cache();
|
|
}
|
|
Err(error) => {
|
|
self.fail(error);
|
|
return;
|
|
}
|
|
}
|
|
match self.screen().content {
|
|
Content::Catalog { .. } => self.search(),
|
|
Content::Series { .. } => self.load_series(),
|
|
Content::Library { .. } => self.rebuild_library(),
|
|
Content::Collection { .. } => {
|
|
self.rebuild_collections();
|
|
self.status = "Collection metadata refreshed.".into();
|
|
}
|
|
}
|
|
}
|
|
fn context(&self) -> Context {
|
|
if self.help.is_some() {
|
|
Context::Help
|
|
} else if let Some(modal) = &self.modal {
|
|
match modal {
|
|
LibraryModal::Tags { name: Some(_), .. } => Context::TagInput,
|
|
LibraryModal::Tags { picker, .. } => {
|
|
if picker.editing {
|
|
Context::TagFilter
|
|
} else {
|
|
Context::TagList
|
|
}
|
|
}
|
|
LibraryModal::Categories { picker, .. } => {
|
|
if picker.editing {
|
|
Context::SelectorInput
|
|
} else {
|
|
Context::SelectorNormal
|
|
}
|
|
}
|
|
}
|
|
} else if self.input.is_some() {
|
|
Context::SearchInput
|
|
} else if matches!(
|
|
self.screen().content,
|
|
Content::Collection { .. } | Content::Library { .. }
|
|
) {
|
|
match self.preview {
|
|
Some(PreviewMode::Info) => Context::CollectionInfo,
|
|
Some(PreviewMode::Description) => Context::CollectionDescription,
|
|
None => Context::CollectionNormal,
|
|
}
|
|
} else {
|
|
match self.preview {
|
|
Some(PreviewMode::Info) => Context::Info,
|
|
Some(PreviewMode::Description) => Context::Description,
|
|
None => Context::Normal,
|
|
}
|
|
}
|
|
}
|
|
fn select(&mut self, action: Action) {
|
|
let screen = self.screen_mut();
|
|
let last = screen.len().saturating_sub(1);
|
|
let selected = screen.selection.selected().unwrap_or(0);
|
|
let next = match action {
|
|
Action::VideoNext => selected.saturating_add(1).min(last),
|
|
Action::VideoPrevious => selected.saturating_sub(1),
|
|
Action::VideoLast => last,
|
|
_ => 0,
|
|
};
|
|
screen.selection.select(Some(next));
|
|
screen.clamp();
|
|
screen.description_scroll = 0;
|
|
screen.update_description();
|
|
}
|
|
fn paste(&mut self, text: &str) {
|
|
if self.help.is_none()
|
|
&& let Some(modal) = &mut self.modal
|
|
{
|
|
match modal {
|
|
LibraryModal::Tags {
|
|
name: Some(name), ..
|
|
} => crate::paste_input(name, text),
|
|
LibraryModal::Tags { names, picker, .. }
|
|
| LibraryModal::Categories { names, picker, .. }
|
|
if picker.editing =>
|
|
{
|
|
crate::paste_input(&mut picker.query, text);
|
|
picker.refilter(names);
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
if self.removal.is_none()
|
|
&& self.help.is_none()
|
|
&& let Some(input) = &mut self.input
|
|
{
|
|
crate::paste_input(input, text);
|
|
}
|
|
}
|
|
fn key(&mut self, key: KeyEvent) -> bool {
|
|
let action = self.config.keys.resolve(self.context(), key);
|
|
if action == Some(Action::Interrupt) {
|
|
self.invalidate();
|
|
return true;
|
|
}
|
|
if action == Some(Action::Quit) {
|
|
self.invalidate();
|
|
return true;
|
|
}
|
|
if self.modal.is_some() && self.help.is_none() {
|
|
if action == Some(Action::Help) {
|
|
self.help = Some(0);
|
|
} else {
|
|
self.modal_key(key, action);
|
|
}
|
|
return false;
|
|
}
|
|
if self.removal.is_some() {
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Enter if key.modifiers.is_empty() => {
|
|
let (kind, ids) = self.removal.take().expect("confirmation active");
|
|
self.remove_ids(kind, ids);
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Esc => {
|
|
self.removal = None;
|
|
self.status = "Collection removal cancelled.".into();
|
|
}
|
|
_ => {}
|
|
}
|
|
return false;
|
|
}
|
|
if let Some(scroll) = &mut self.help {
|
|
match action {
|
|
Some(Action::Help | Action::HelpClose) => self.help = None,
|
|
Some(Action::HelpNext) => *scroll = scroll.saturating_add(1),
|
|
Some(Action::HelpPrevious) => *scroll = scroll.saturating_sub(1),
|
|
Some(Action::HelpPageNext) => *scroll = scroll.saturating_add(10),
|
|
Some(Action::HelpPagePrevious) => *scroll = scroll.saturating_sub(10),
|
|
_ => {}
|
|
}
|
|
return false;
|
|
}
|
|
if let Some(input) = &mut self.input {
|
|
match action {
|
|
Some(Action::InputSubmit) => {
|
|
let input = self.input.take().expect("input active");
|
|
if self.episode_input {
|
|
self.episode_input = false;
|
|
self.select_episode(&input);
|
|
} else {
|
|
if let Content::Catalog { query, .. } = &mut self.screen_mut().content {
|
|
*query = input;
|
|
}
|
|
self.search();
|
|
}
|
|
}
|
|
Some(Action::InputCancel) => {
|
|
self.input = None;
|
|
self.episode_input = false;
|
|
}
|
|
_ => {
|
|
// Typed controls are rejected too, not merely bracketed paste.
|
|
if !matches!(key.code, KeyCode::Char(ch) if ch.is_control()) {
|
|
crate::edit_input(input, key, action);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
match action {
|
|
Some(Action::ChannelTags) => self.tags(),
|
|
Some(Action::CategoryBrowse) => self.category_picker(),
|
|
Some(Action::Help) => self.help = Some(0),
|
|
Some(Action::SearchEdit) => self.edit_search(),
|
|
Some(Action::ViewSearch) => {
|
|
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
|
self.push(Screen::new(Content::Catalog {
|
|
query: String::new(),
|
|
media: Vec::new(),
|
|
searched: false,
|
|
}));
|
|
}
|
|
self.edit_search();
|
|
}
|
|
Some(Action::ViewPrevious) => self.history(false),
|
|
Some(Action::ViewNext) => self.history(true),
|
|
Some(Action::ViewRefresh) => self.refresh(),
|
|
Some(Action::VideoPlay) => self.open(),
|
|
Some(Action::VideoEnqueue) => self.play(true),
|
|
Some(Action::VideoToggle) => self.screen_mut().toggle_checked(),
|
|
Some(Action::VideoClear) => self.screen_mut().checked.clear(),
|
|
Some(Action::VideoCopyUrl) if self.screen().playable() => {
|
|
if let Some(id) = self.screen().selected_key() {
|
|
self.status = ui::copy_video_url(id);
|
|
}
|
|
}
|
|
Some(
|
|
action @ (Action::VideoNext
|
|
| Action::VideoPrevious
|
|
| Action::VideoFirst
|
|
| Action::VideoLast),
|
|
) => self.select(action),
|
|
Some(Action::InfoOverview) => self.preview = Some(PreviewMode::Info),
|
|
Some(Action::InfoDescription) => self.preview = Some(PreviewMode::Description),
|
|
Some(Action::InfoHide) => self.preview = None,
|
|
Some(Action::DescriptionNext) => {
|
|
self.screen_mut().description_scroll =
|
|
self.screen().description_scroll.saturating_add(1)
|
|
}
|
|
Some(Action::DescriptionPrevious) => {
|
|
self.screen_mut().description_scroll =
|
|
self.screen().description_scroll.saturating_sub(1)
|
|
}
|
|
Some(Action::DescriptionPageNext) => {
|
|
self.screen_mut().description_scroll =
|
|
self.screen().description_scroll.saturating_add(10)
|
|
}
|
|
Some(Action::DescriptionPagePrevious) => {
|
|
self.screen_mut().description_scroll =
|
|
self.screen().description_scroll.saturating_sub(10)
|
|
}
|
|
Some(Action::WatchLaterBrowse) => self.collection(CollectionKind::WatchLater),
|
|
Some(Action::SavedVideoBrowse) => self.collection(CollectionKind::Saved),
|
|
Some(Action::WatchingBrowse) => self.collection(CollectionKind::Watching),
|
|
Some(Action::WatchLaterSave) => self.save(CollectionKind::WatchLater),
|
|
Some(Action::SavedVideoSave) => self.save(CollectionKind::Saved),
|
|
Some(Action::WatchStatusWatching) => self.set_watch_status(WatchStatus::Watching),
|
|
Some(Action::WatchStatusWatched) => self.set_watch_status(WatchStatus::Watched),
|
|
Some(Action::CollectionRemoveSelected) => self.remove(false),
|
|
Some(Action::CollectionRemoveBulk) => self.remove(true),
|
|
Some(Action::WatchLaterFilter) => {
|
|
if let Content::Collection {
|
|
kind: CollectionKind::WatchLater,
|
|
filter,
|
|
..
|
|
} = &mut self.screen_mut().content
|
|
{
|
|
*filter = filter.next();
|
|
self.invalidate();
|
|
self.rebuild_collections();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
false
|
|
}
|
|
fn bindings(&self) -> Vec<(Action, &'static str)> {
|
|
if let Some(modal) = &self.modal {
|
|
return match modal {
|
|
LibraryModal::Tags { name: Some(_), .. } => vec![
|
|
(Action::InputSubmit, "Stage checked tag"),
|
|
(Action::InputCancel, "Cancel new tag"),
|
|
(Action::DeleteWord, "Delete word"),
|
|
(Action::DeleteLine, "Clear name"),
|
|
],
|
|
LibraryModal::Tags { picker, .. } if picker.editing => vec![
|
|
(Action::InputSubmit, "Return to tags"),
|
|
(Action::InputCancel, "Return to tags"),
|
|
(Action::TagInputNext, "Next match"),
|
|
(Action::TagInputPrevious, "Previous match"),
|
|
(Action::DeleteLine, "Clear filter"),
|
|
],
|
|
LibraryModal::Tags { .. } => vec![
|
|
(Action::TagNext, "Next tag"),
|
|
(Action::TagPrevious, "Previous tag"),
|
|
(Action::TagEdit, "Filter tags"),
|
|
(Action::TagToggle, "Toggle tag"),
|
|
(Action::TagNew, "New checked tag"),
|
|
(Action::TagSave, "Save show and tags"),
|
|
(Action::TagCancel, "Cancel without saving"),
|
|
],
|
|
LibraryModal::Categories { picker, .. } if picker.editing => vec![
|
|
(Action::InputSubmit, "Browse selected categories"),
|
|
(Action::InputCancel, "Return to selector"),
|
|
(Action::SelectorInputNext, "Next match"),
|
|
(Action::SelectorInputPrevious, "Previous match"),
|
|
(Action::DeleteLine, "Clear filter"),
|
|
],
|
|
LibraryModal::Categories { .. } => vec![
|
|
(Action::SelectorNext, "Next category"),
|
|
(Action::SelectorPrevious, "Previous category"),
|
|
(Action::SelectorEdit, "Filter categories"),
|
|
(Action::SelectorToggle, "Toggle combined category"),
|
|
(Action::SelectorOpen, "Browse selected categories"),
|
|
(Action::SelectorClose, "Close"),
|
|
],
|
|
};
|
|
}
|
|
if self.input.is_some() {
|
|
return vec![
|
|
(
|
|
Action::InputSubmit,
|
|
if self.episode_input {
|
|
"Select explicit episode (UNVERIFIED)"
|
|
} else {
|
|
"Search catalog"
|
|
},
|
|
),
|
|
(Action::InputCancel, "Cancel input"),
|
|
(Action::DeleteCharacter, "Delete character"),
|
|
(Action::DeleteWord, "Delete word"),
|
|
(Action::DeleteLine, "Clear query"),
|
|
(Action::Interrupt, "Exit"),
|
|
];
|
|
}
|
|
let mut bindings = vec![
|
|
(Action::SearchEdit, "Edit catalog query"),
|
|
(Action::ViewSearch, "Search catalog"),
|
|
(
|
|
Action::VideoPlay,
|
|
if matches!(
|
|
self.screen().content,
|
|
Content::Catalog { .. } | Content::Library { .. }
|
|
) {
|
|
"Open series"
|
|
} else if self.screen().checked.is_empty() {
|
|
"Play episode in Terebi"
|
|
} else {
|
|
"Play checked episodes in Terebi"
|
|
},
|
|
),
|
|
(Action::VideoNext, "Next title/episode"),
|
|
(Action::VideoPrevious, "Previous title/episode"),
|
|
(Action::VideoFirst, "First title/episode"),
|
|
(Action::VideoLast, "Last title/episode"),
|
|
(Action::ViewPrevious, "Back (cached screen)"),
|
|
(Action::ViewNext, "Forward (cached screen)"),
|
|
(Action::ViewRefresh, "Refresh screen and configured keys"),
|
|
(Action::WatchLaterBrowse, "Watch later episodes"),
|
|
(Action::SavedVideoBrowse, "Saved episodes"),
|
|
(Action::WatchingBrowse, "Currently watching episodes"),
|
|
(
|
|
Action::InfoOverview,
|
|
"Show series/episode info with cover art",
|
|
),
|
|
(Action::Help, "Keyboard help"),
|
|
(Action::ProviderChoose, "Choose provider"),
|
|
(Action::Quit, "Exit"),
|
|
(Action::Interrupt, "Exit immediately"),
|
|
];
|
|
bindings.push((Action::CategoryBrowse, "Browse saved shows by category"));
|
|
if !matches!(self.screen().content, Content::Collection { .. }) {
|
|
bindings.push((Action::ChannelTags, "Save show / edit category tags"));
|
|
}
|
|
if matches!(self.screen().content, Content::Library { .. }) {
|
|
bindings.push((
|
|
Action::CollectionRemoveSelected,
|
|
"Remove selected saved show only",
|
|
));
|
|
}
|
|
if self.preview.is_some()
|
|
|| self.config.keys.label(Action::InfoDescription)
|
|
!= self.config.keys.label(Action::InfoOverview)
|
|
{
|
|
bindings.push((Action::InfoDescription, "Show full synopsis/metadata"));
|
|
}
|
|
if matches!(
|
|
self.screen().content,
|
|
Content::Series { .. } | Content::Collection { .. }
|
|
) {
|
|
bindings.extend([
|
|
(
|
|
Action::VideoEnqueue,
|
|
"Append checked or selected episodes to Terebi's queue",
|
|
),
|
|
(Action::VideoToggle, "Toggle checked episode"),
|
|
(Action::VideoClear, "Clear checked episodes"),
|
|
(Action::VideoCopyUrl, "Copy selected episode URL"),
|
|
(Action::WatchLaterSave, "Save episode to watch later"),
|
|
(Action::SavedVideoSave, "Save episode"),
|
|
(Action::WatchStatusWatching, "Mark episode as watching"),
|
|
(Action::WatchStatusWatched, "Mark episode as watched"),
|
|
]);
|
|
}
|
|
if let Content::Collection { kind, .. } = self.screen().content {
|
|
bindings.extend([
|
|
(Action::CollectionRemoveSelected, "Remove selected episode"),
|
|
(
|
|
Action::CollectionRemoveBulk,
|
|
"Confirm removal of visible episodes",
|
|
),
|
|
]);
|
|
if matches!(kind, CollectionKind::WatchLater) {
|
|
bindings.push((Action::WatchLaterFilter, "Cycle All / Unwatched / Watched"));
|
|
}
|
|
}
|
|
if self.preview.is_some() {
|
|
bindings.push((Action::InfoHide, "Close preview"));
|
|
}
|
|
if self.preview == Some(PreviewMode::Description) {
|
|
bindings.extend([
|
|
(Action::DescriptionNext, "Scroll metadata down"),
|
|
(Action::DescriptionPrevious, "Scroll metadata up"),
|
|
(Action::DescriptionPageNext, "Metadata page down"),
|
|
(Action::DescriptionPagePrevious, "Metadata page up"),
|
|
]);
|
|
}
|
|
bindings
|
|
}
|
|
fn hints(&self) -> String {
|
|
if self.modal.is_some() && self.help.is_none() {
|
|
return self
|
|
.bindings()
|
|
.into_iter()
|
|
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
|
|
.collect::<Vec<_>>()
|
|
.join(" · ");
|
|
}
|
|
if self.modal.is_none()
|
|
&& self.help.is_none()
|
|
&& self.input.is_none()
|
|
&& self.removal.is_none()
|
|
&& self.preview.is_none()
|
|
{
|
|
let mut actions = vec![
|
|
(Action::Help, "help"),
|
|
(
|
|
Action::SearchEdit,
|
|
if self.provider == Provider::AniList
|
|
&& matches!(&self.screen().content, Content::Series { media, .. } if media.metadata.get("catalog_count").is_none_or(Value::is_null))
|
|
{
|
|
"select episode"
|
|
} else {
|
|
"search"
|
|
},
|
|
),
|
|
(Action::InfoOverview, "info"),
|
|
(
|
|
Action::VideoPlay,
|
|
if matches!(
|
|
self.screen().content,
|
|
Content::Catalog { .. } | Content::Library { .. }
|
|
) {
|
|
"open"
|
|
} else if self.screen().checked.is_empty() {
|
|
"play"
|
|
} else {
|
|
"play checked"
|
|
},
|
|
),
|
|
(Action::CategoryBrowse, "categories"),
|
|
];
|
|
if self.screen().playable() {
|
|
actions.extend([
|
|
(Action::VideoEnqueue, "enqueue"),
|
|
(Action::VideoToggle, "check"),
|
|
(Action::VideoClear, "clear checks"),
|
|
]);
|
|
}
|
|
if !matches!(self.screen().content, Content::Collection { .. }) {
|
|
actions.push((Action::ChannelTags, "save show/tags"));
|
|
}
|
|
if matches!(
|
|
self.screen().content,
|
|
Content::Library { .. } | Content::Collection { .. }
|
|
) {
|
|
actions.push((Action::CollectionRemoveSelected, "remove"));
|
|
}
|
|
actions.extend([(Action::ViewPrevious, "back"), (Action::Quit, "quit")]);
|
|
return actions
|
|
.into_iter()
|
|
.map(|(action, label)| format!("{} {label}", self.config.keys.label(action)))
|
|
.collect::<Vec<_>>()
|
|
.join(" · ");
|
|
}
|
|
if self.removal.is_some() {
|
|
return format!(
|
|
"y/Enter confirms · n/Esc cancels · {} exits",
|
|
self.config.keys.label(Action::Quit)
|
|
);
|
|
}
|
|
let (mode, actions): (&str, &[(Action, &str)]) = if self.help.is_some() {
|
|
(
|
|
"HELP",
|
|
&[
|
|
(Action::HelpClose, "close"),
|
|
(Action::HelpNext, "down"),
|
|
(Action::HelpPrevious, "up"),
|
|
(Action::HelpPageNext, "page down"),
|
|
(Action::HelpPagePrevious, "page up"),
|
|
(Action::Quit, "quit"),
|
|
],
|
|
)
|
|
} else if self.input.is_some() {
|
|
(
|
|
"INSERT",
|
|
&[
|
|
(Action::InputSubmit, "search"),
|
|
(Action::InputCancel, "cancel"),
|
|
(Action::DeleteWord, "delete word"),
|
|
],
|
|
)
|
|
} else if self.preview == Some(PreviewMode::Info) {
|
|
(
|
|
"INFO",
|
|
&[
|
|
(Action::InfoDescription, "description"),
|
|
(Action::VideoPlay, "play"),
|
|
(Action::VideoEnqueue, "enqueue"),
|
|
(Action::VideoToggle, "check"),
|
|
(Action::VideoClear, "clear checks"),
|
|
(Action::InfoHide, "close"),
|
|
(Action::Help, "help"),
|
|
],
|
|
)
|
|
} else if self.preview == Some(PreviewMode::Description) {
|
|
(
|
|
"DESCRIPTION",
|
|
&[
|
|
(Action::InfoOverview, "info"),
|
|
(Action::VideoPlay, "play"),
|
|
(Action::VideoEnqueue, "enqueue"),
|
|
(Action::VideoToggle, "check"),
|
|
(Action::VideoClear, "clear checks"),
|
|
(Action::InfoHide, "close"),
|
|
(Action::Help, "help"),
|
|
(Action::WatchStatusWatching, "mark watching"),
|
|
(Action::WatchStatusWatched, "mark watched"),
|
|
(Action::DescriptionNext, "down"),
|
|
(Action::DescriptionPrevious, "up"),
|
|
(Action::DescriptionPageNext, "page down"),
|
|
],
|
|
)
|
|
} else if matches!(self.screen().content, Content::Collection { .. }) {
|
|
(
|
|
"NORMAL",
|
|
&[
|
|
(Action::Help, "help"),
|
|
(Action::ProviderChoose, "provider"),
|
|
(Action::VideoPlay, "play"),
|
|
(Action::VideoEnqueue, "enqueue"),
|
|
(Action::InfoOverview, "info"),
|
|
(Action::WatchStatusWatching, "mark watching"),
|
|
(Action::WatchStatusWatched, "mark watched"),
|
|
(Action::CollectionRemoveSelected, "remove"),
|
|
(Action::CollectionRemoveBulk, "remove all"),
|
|
(Action::WatchLaterFilter, "filter"),
|
|
(Action::ViewPrevious, "back"),
|
|
(Action::Quit, "quit"),
|
|
],
|
|
)
|
|
} else {
|
|
(
|
|
"NORMAL",
|
|
&[
|
|
(Action::Help, "help"),
|
|
(Action::ProviderChoose, "provider"),
|
|
(Action::SearchEdit, "search"),
|
|
(
|
|
Action::VideoPlay,
|
|
if matches!(self.screen().content, Content::Catalog { .. }) {
|
|
"open"
|
|
} else {
|
|
"play"
|
|
},
|
|
),
|
|
(Action::VideoEnqueue, "enqueue"),
|
|
(Action::InfoOverview, "info"),
|
|
(Action::WatchStatusWatching, "mark watching"),
|
|
(Action::WatchStatusWatched, "mark watched"),
|
|
(Action::ViewPrevious, "back"),
|
|
(Action::WatchLaterBrowse, "later"),
|
|
(Action::SavedVideoBrowse, "saved"),
|
|
(Action::WatchingBrowse, "watching"),
|
|
(Action::Quit, "quit"),
|
|
],
|
|
)
|
|
};
|
|
let supported = self.bindings();
|
|
let mut hints = mode.to_owned();
|
|
for (action, label) in actions {
|
|
if self.help.is_some() || supported.iter().any(|(supported, _)| supported == action) {
|
|
hints.push_str(" · ");
|
|
hints.push_str(self.config.keys.label(*action));
|
|
hints.push(' ');
|
|
hints.push_str(label);
|
|
}
|
|
}
|
|
hints
|
|
}
|
|
fn markers(&self, id: &str) -> String {
|
|
let mut markers = String::new();
|
|
for (present, label) in [
|
|
(self.state.is_watched(id), "watched"),
|
|
(
|
|
self.state.contains(CollectionKind::WatchLater, id),
|
|
"watch later",
|
|
),
|
|
(self.state.contains(CollectionKind::Saved, id), "saved"),
|
|
(
|
|
self.state.contains(CollectionKind::Watching, id),
|
|
"watching",
|
|
),
|
|
] {
|
|
if present {
|
|
if !markers.is_empty() {
|
|
markers.push_str(" · ");
|
|
}
|
|
markers.push_str(label);
|
|
}
|
|
}
|
|
markers
|
|
}
|
|
fn episode_style(&self, id: &str) -> Style {
|
|
if self.state.contains(CollectionKind::Saved, id) {
|
|
Style::new().fg(Color::Green)
|
|
} else if self.state.contains(CollectionKind::WatchLater, id) {
|
|
Style::new().fg(Color::Magenta)
|
|
} else {
|
|
Style::default()
|
|
}
|
|
}
|
|
fn rows(&self) -> Vec<ListItem<'_>> {
|
|
self.screen()
|
|
.rows
|
|
.iter()
|
|
.map(|row| {
|
|
let show = if matches!(
|
|
self.screen().content,
|
|
Content::Catalog { .. } | Content::Library { .. }
|
|
) {
|
|
row.key
|
|
.as_deref()
|
|
.and_then(|id| self.state.shows().iter().find(|show| show.media.id == id))
|
|
} else {
|
|
None
|
|
};
|
|
let style = row
|
|
.key
|
|
.as_deref()
|
|
.map(|id| self.episode_style(id))
|
|
.unwrap_or_default();
|
|
let markers = row
|
|
.key
|
|
.as_deref()
|
|
.map(|id| self.markers(id))
|
|
.unwrap_or_default();
|
|
let markers = show
|
|
.map(|show| {
|
|
format!(
|
|
"saved show{}",
|
|
if show.tags.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
" · {}",
|
|
show.tags
|
|
.iter()
|
|
.map(|tag| safe(tag))
|
|
.collect::<Vec<_>>()
|
|
.join(" · ")
|
|
)
|
|
}
|
|
)
|
|
})
|
|
.unwrap_or(markers);
|
|
let separator = if row.details.is_empty() || markers.is_empty() {
|
|
""
|
|
} else {
|
|
" · "
|
|
};
|
|
ListItem::new(vec![
|
|
Line::from(vec![
|
|
ratatui::text::Span::raw(if self.screen().playable() {
|
|
if row
|
|
.key
|
|
.as_ref()
|
|
.is_some_and(|id| self.screen().checked.contains(id))
|
|
{
|
|
"* "
|
|
} else if row
|
|
.key
|
|
.as_deref()
|
|
.is_some_and(|id| self.state.is_watched(id))
|
|
{
|
|
"✓ "
|
|
} else {
|
|
" "
|
|
}
|
|
} else {
|
|
""
|
|
}),
|
|
ratatui::text::Span::raw(row.title.as_str()),
|
|
])
|
|
.style(style),
|
|
Line::from(vec![
|
|
ratatui::text::Span::raw(if self.screen().playable() { " " } else { "" }),
|
|
ratatui::text::Span::raw(row.details.as_str()),
|
|
ratatui::text::Span::raw(separator),
|
|
ratatui::text::Span::raw(markers),
|
|
])
|
|
.dim(),
|
|
])
|
|
})
|
|
.collect()
|
|
}
|
|
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
|
|
let area = frame.area();
|
|
if area.width < 24 || area.height < 10 {
|
|
frame.render_widget(
|
|
Paragraph::new(format!(
|
|
"{}\nTerminal too small; enlarge to browse.\n{} exits",
|
|
self.provider.name(),
|
|
self.config.keys.label(Action::Quit)
|
|
))
|
|
.wrap(Wrap { trim: true }),
|
|
area,
|
|
);
|
|
return;
|
|
}
|
|
let [header, query_area, content, status, hints] = ui::browser_layout(area, 1);
|
|
frame.render_widget(
|
|
Paragraph::new(Line::from(vec![
|
|
" ".into(),
|
|
self.screen().title.as_str().into(),
|
|
" ".into(),
|
|
]))
|
|
.style(ui::ACCENT)
|
|
.bold(),
|
|
header,
|
|
);
|
|
let query = self.input.as_deref().unwrap_or_else(|| {
|
|
self.screens[..=self.index]
|
|
.iter()
|
|
.rev()
|
|
.find_map(|screen| match &screen.content {
|
|
Content::Catalog { query, .. } => Some(query.as_str()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or("")
|
|
});
|
|
ui::search(
|
|
frame,
|
|
query_area,
|
|
if self.episode_input {
|
|
" Explicit episode · UNVERIFIED · sub "
|
|
} else if self.provider == Provider::AniList {
|
|
" tubular / AniList search "
|
|
} else {
|
|
" tubular / Miruro legacy search "
|
|
},
|
|
query,
|
|
self.input.is_some() && self.help.is_none() && self.removal.is_none(),
|
|
);
|
|
let title = if self.screen().playable() {
|
|
format!(" Episodes · {} checked ", self.screen().checked.len())
|
|
} else {
|
|
" Titles ".into()
|
|
};
|
|
let block = Block::bordered().title(crate::loading_title(
|
|
&title,
|
|
"",
|
|
self.intent.is_some().then(|| ui::spinner(elapsed)),
|
|
));
|
|
if self.screen().rows.is_empty() {
|
|
let empty = if let Some(error) = &self.screen().error {
|
|
format!(
|
|
"{error}\n\n{} refreshes; {} searches the catalog.",
|
|
self.config.keys.label(Action::ViewRefresh),
|
|
self.config.keys.label(Action::SearchEdit)
|
|
)
|
|
} else if self.intent.is_some() {
|
|
format!("Loading {} metadata…", self.provider.name())
|
|
} else {
|
|
match &self.screen().content {
|
|
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for episode metadata; AniList provider availability is UNVERIFIED.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
|
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
|
|
Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Provider availability UNVERIFIED; sub resolved on selection.", self.config.keys.label(Action::SearchEdit)),
|
|
Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".into(),
|
|
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
|
|
Content::Collection { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()),
|
|
Content::Library { .. } => "No saved shows in these categories. Save a catalog title with the show tags action, then confirm.".into(),
|
|
}
|
|
};
|
|
frame.render_widget(
|
|
Paragraph::new(empty).wrap(Wrap { trim: true }).block(block),
|
|
content,
|
|
);
|
|
} else {
|
|
let mut selection = std::mem::take(&mut self.screen_mut().selection);
|
|
frame.render_stateful_widget(
|
|
List::new(self.rows())
|
|
.block(block)
|
|
.highlight_symbol(ui::SELECTION_MARKER)
|
|
.highlight_style(ui::SELECTED),
|
|
content,
|
|
&mut selection,
|
|
);
|
|
self.screen_mut().selection = selection;
|
|
}
|
|
frame.render_widget(
|
|
Paragraph::new(self.status.as_str())
|
|
.wrap(Wrap { trim: true })
|
|
.block(Block::bordered().title(" Status ")),
|
|
status,
|
|
);
|
|
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
|
|
let popup = crate::preview_area(area);
|
|
if let Some(mode) = self.preview {
|
|
let cover = self.screens[self.index]
|
|
.selected_media()
|
|
.and_then(|media| media.metadata.get("cover_url"))
|
|
.and_then(Value::as_str)
|
|
.filter(|url| !url.trim().is_empty());
|
|
if mode == PreviewMode::Info {
|
|
if let Some(url) = cover {
|
|
self.thumbnail.select_url(url);
|
|
} else {
|
|
self.thumbnail.cancel();
|
|
}
|
|
}
|
|
frame.render_widget(Clear, popup);
|
|
let block = Block::bordered()
|
|
.title(crate::loading_title(
|
|
if mode == PreviewMode::Info {
|
|
" Info "
|
|
} else {
|
|
" Description "
|
|
},
|
|
"",
|
|
(mode == PreviewMode::Info && self.thumbnail.is_loading())
|
|
.then(|| ui::spinner(elapsed)),
|
|
))
|
|
.border_style(ui::ACCENT);
|
|
let inner = block.inner(popup);
|
|
let screen = &self.screens[self.index];
|
|
let row = screen.selection.selected().and_then(|i| screen.rows.get(i));
|
|
let mut metadata = Vec::new();
|
|
let mut text = screen.description.as_str();
|
|
if let Some(row) = row {
|
|
metadata.extend([
|
|
Line::from(row.title.as_str()).bold(),
|
|
Line::default(),
|
|
Line::from(row.details.as_str()).dim(),
|
|
]);
|
|
text = text
|
|
.strip_prefix(&row.title)
|
|
.unwrap_or(text)
|
|
.trim_start_matches('\n');
|
|
text = text
|
|
.strip_prefix(&row.details)
|
|
.unwrap_or(text)
|
|
.trim_start_matches('\n');
|
|
}
|
|
if let Some(key) = screen.selected_key() {
|
|
let markers = self.markers(key);
|
|
if !markers.is_empty() {
|
|
metadata.push(Line::from(markers).dim());
|
|
}
|
|
}
|
|
if mode == PreviewMode::Info {
|
|
ui::info_preview(
|
|
frame,
|
|
inner,
|
|
cover.map(|_| &mut self.thumbnail),
|
|
metadata,
|
|
text,
|
|
);
|
|
} else {
|
|
let inner = inner.inner(Margin::new(1, 0));
|
|
let mut content = ratatui::text::Text::from(text);
|
|
content.lines.splice(
|
|
0..0,
|
|
[Line::default()]
|
|
.into_iter()
|
|
.chain(metadata)
|
|
.chain([Line::default()]),
|
|
);
|
|
let paragraph = Paragraph::new(content).wrap(Wrap { trim: false });
|
|
let max_scroll = if inner.is_empty() {
|
|
0
|
|
} else {
|
|
paragraph
|
|
.line_count(inner.width)
|
|
.saturating_sub(usize::from(inner.height))
|
|
.min(u16::MAX as usize) as u16
|
|
};
|
|
let scroll = screen.description_scroll.min(max_scroll);
|
|
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
|
self.screen_mut().description_scroll = scroll;
|
|
}
|
|
frame.render_widget(block, popup);
|
|
} else {
|
|
self.thumbnail.cancel();
|
|
}
|
|
if let Some((kind, ids)) = &self.removal {
|
|
frame.render_widget(Clear, popup);
|
|
frame.render_widget(
|
|
Paragraph::new(format!("Remove {} {} episodes from {}?\n\nOnly the visible/filter snapshot leaves this collection. Other providers, collections and watched state are unchanged.\n\ny/Enter confirms · n/Esc cancels", ids.len(), self.provider.name(), collection_name(*kind)))
|
|
.wrap(Wrap { trim: true })
|
|
.block(
|
|
Block::bordered()
|
|
.title(" Remove collection ")
|
|
.border_style(Style::new().fg(Color::Yellow)),
|
|
),
|
|
popup,
|
|
);
|
|
} else if let Some(scroll) = self.help {
|
|
let mut lines = vec![
|
|
Line::from(format!(
|
|
"{} catalog → series → episodes · availability UNVERIFIED",
|
|
self.provider.name()
|
|
))
|
|
.bold(),
|
|
Line::from(
|
|
"Terebi resolves episode URLs, sources, subtitles and playback metadata.",
|
|
),
|
|
Line::from("✓ is acknowledged playback progress, never CLI completion."),
|
|
Line::default(),
|
|
];
|
|
lines.extend(self.bindings().into_iter().map(|(action, label)| {
|
|
Line::from(format!("{} {label}", self.config.keys.label(action)))
|
|
}));
|
|
frame.render_widget(Clear, popup);
|
|
let block = Block::bordered()
|
|
.title(format!(" {} keyboard help ", self.provider.name()))
|
|
.border_style(ui::ACCENT);
|
|
let inner = block.inner(popup).inner(Margin::new(1, 0));
|
|
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
|
|
let max_scroll = if inner.is_empty() {
|
|
0
|
|
} else {
|
|
paragraph
|
|
.line_count(inner.width)
|
|
.saturating_sub(usize::from(inner.height))
|
|
.min(u16::MAX as usize) as u16
|
|
};
|
|
let scroll = scroll.min(max_scroll);
|
|
self.help = Some(scroll);
|
|
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
|
frame.render_widget(block, popup);
|
|
}
|
|
if self.help.is_none()
|
|
&& let Some(modal) = &mut self.modal
|
|
{
|
|
let (title, names, checked, picker, name, error) = match modal {
|
|
LibraryModal::Tags {
|
|
media,
|
|
names,
|
|
checked,
|
|
picker,
|
|
name,
|
|
error,
|
|
} => (
|
|
format!(" Save show · {} ", safe(&media.title)),
|
|
names,
|
|
checked,
|
|
picker,
|
|
name.as_deref(),
|
|
error.as_deref(),
|
|
),
|
|
LibraryModal::Categories {
|
|
names,
|
|
checked,
|
|
picker,
|
|
} => (
|
|
" Saved-show categories ".into(),
|
|
names,
|
|
checked,
|
|
picker,
|
|
None,
|
|
None,
|
|
),
|
|
};
|
|
frame.render_widget(Clear, popup);
|
|
let block = Block::bordered().title(title).border_style(ui::ACCENT);
|
|
let inner = block.inner(popup);
|
|
frame.render_widget(block, popup);
|
|
let [input, list, message] = ratatui::layout::Layout::vertical([
|
|
ratatui::layout::Constraint::Length(3),
|
|
ratatui::layout::Constraint::Min(0),
|
|
ratatui::layout::Constraint::Length(2),
|
|
])
|
|
.areas(inner);
|
|
ui::search(
|
|
frame,
|
|
input,
|
|
if name.is_some() {
|
|
" New checked tag "
|
|
} else {
|
|
" Filter "
|
|
},
|
|
name.unwrap_or(&picker.query),
|
|
name.is_some() || picker.editing,
|
|
);
|
|
let items: Vec<_> = picker
|
|
.matches
|
|
.iter()
|
|
.map(|i| {
|
|
ListItem::new(format!(
|
|
"[{}] {}",
|
|
if checked.contains(&names[*i]) {
|
|
"x"
|
|
} else {
|
|
" "
|
|
},
|
|
safe(&names[*i])
|
|
))
|
|
})
|
|
.collect();
|
|
frame.render_stateful_widget(
|
|
List::new(items)
|
|
.highlight_symbol(ui::SELECTION_MARKER)
|
|
.highlight_style(ui::SELECTED),
|
|
list,
|
|
&mut picker.selection,
|
|
);
|
|
frame.render_widget(
|
|
Paragraph::new(
|
|
error.unwrap_or("Category tags may overlap; see configured controls below."),
|
|
)
|
|
.wrap(Wrap { trim: true }),
|
|
message,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn library_shows(
|
|
state: &State,
|
|
categories: &BTreeSet<String>,
|
|
) -> Vec<crate::video_state::SavedShow> {
|
|
let mut shows: Vec<_> = state
|
|
.shows()
|
|
.iter()
|
|
.filter(|show| categories.is_empty() || !show.tags.is_disjoint(categories))
|
|
.cloned()
|
|
.collect();
|
|
shows.sort_by_cached_key(|show| show.media.title.to_lowercase());
|
|
shows
|
|
}
|
|
|
|
fn collection_videos(
|
|
state: &State,
|
|
kind: CollectionKind,
|
|
filter: WatchFilter,
|
|
provider: Provider,
|
|
) -> Vec<Video> {
|
|
state
|
|
.videos(kind)
|
|
.iter()
|
|
.filter(|video| {
|
|
provider.includes(&video.id)
|
|
&& (!matches!(kind, CollectionKind::WatchLater)
|
|
|| filter.includes(state.is_watched(&video.id)))
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
fn collection_name(kind: CollectionKind) -> &'static str {
|
|
match kind {
|
|
CollectionKind::WatchLater => "Watch later",
|
|
CollectionKind::Saved => "Saved",
|
|
CollectionKind::Watching => "Currently watching",
|
|
}
|
|
}
|
|
fn safe(text: &str) -> String {
|
|
text.chars().filter(|ch| !ch.is_control()).collect()
|
|
}
|
|
fn plain(text: &str) -> String {
|
|
let mut in_tag = false;
|
|
let mut tag = String::new();
|
|
let mut plain = String::new();
|
|
for ch in text.chars() {
|
|
match ch {
|
|
'<' => {
|
|
in_tag = true;
|
|
tag.clear();
|
|
}
|
|
'>' if in_tag => {
|
|
in_tag = false;
|
|
let name = tag
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or("")
|
|
.trim_matches('/')
|
|
.to_ascii_lowercase();
|
|
if matches!(name.as_str(), "br" | "p" | "div" | "li") && !plain.ends_with('\n') {
|
|
plain.push('\n');
|
|
}
|
|
}
|
|
_ if in_tag => tag.push(ch),
|
|
'\n' => plain.push('\n'),
|
|
_ if !ch.is_control() => plain.push(ch),
|
|
_ => {}
|
|
}
|
|
}
|
|
plain
|
|
.replace("&", "&")
|
|
.replace(""", "\"")
|
|
.replace("'", "'")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace(" ", " ")
|
|
}
|
|
fn media_summary(media: &Media) -> String {
|
|
let mut parts = Vec::new();
|
|
for field in ["format", "status", "season"] {
|
|
if let Some(value) = media.metadata.get(field).and_then(Value::as_str) {
|
|
parts.push(safe(value));
|
|
}
|
|
}
|
|
if let Some(year) = media.metadata.get("season_year").and_then(Value::as_u64) {
|
|
parts.push(year.to_string());
|
|
}
|
|
if let Some(count) = media.metadata.get("episode_count").and_then(Value::as_u64) {
|
|
parts.push(format!("{count} planned"));
|
|
}
|
|
if miruro_browser::media_provider(media) == Provider::AniList {
|
|
parts.push(
|
|
match media.metadata.get("catalog_count").and_then(Value::as_u64) {
|
|
Some(count) => format!("{count} catalog/airing numbers"),
|
|
None => "catalog/airing count unknown".into(),
|
|
},
|
|
);
|
|
if media
|
|
.metadata
|
|
.get("episode_count")
|
|
.is_none_or(Value::is_null)
|
|
{
|
|
parts.push("total unknown".into());
|
|
}
|
|
parts.push("provider availability UNVERIFIED · sub".into());
|
|
}
|
|
for (field, label) in [
|
|
("raw", "raw listed"),
|
|
("sub", "sub listed"),
|
|
("dub", "dub listed"),
|
|
] {
|
|
if let Some(count) = media
|
|
.metadata
|
|
.get("episode_counts")
|
|
.and_then(|counts| counts.get(field))
|
|
.and_then(Value::as_u64)
|
|
{
|
|
parts.push(format!("{count} {label}"));
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
"Metadata unknown".into()
|
|
} else {
|
|
parts.join(" · ")
|
|
}
|
|
}
|
|
fn media_details(media: &Media) -> String {
|
|
let mut text = format!("{}\n{}", safe(&media.title), media_summary(media));
|
|
if let Some(titles) = media.metadata.get("title").and_then(Value::as_object) {
|
|
for field in ["english", "romaji", "native"] {
|
|
if let Some(title) = titles
|
|
.get(field)
|
|
.and_then(Value::as_str)
|
|
.filter(|title| *title != media.title)
|
|
{
|
|
text.push_str(&format!("\n{field}: {}", safe(title)));
|
|
}
|
|
}
|
|
}
|
|
for (field, label) in [
|
|
("source", "Source"),
|
|
("country_of_origin", "Origin"),
|
|
("started_on", "Started"),
|
|
("ended_on", "Ended"),
|
|
] {
|
|
if let Some(value) = media.metadata.get(field).and_then(Value::as_str) {
|
|
text.push_str(&format!("\n{label}: {}", safe(value)));
|
|
}
|
|
}
|
|
if let Some(minutes) = media
|
|
.metadata
|
|
.get("episode_duration_minutes")
|
|
.and_then(Value::as_u64)
|
|
{
|
|
text.push_str(&format!("\nEpisode duration: {minutes} min"));
|
|
}
|
|
if let Some(score) = media.metadata.get("average_score").and_then(Value::as_f64) {
|
|
text.push_str(&format!("\nAverage score: {score}"));
|
|
}
|
|
if let Some(genres) = media.metadata.get("genres").and_then(Value::as_array) {
|
|
let genres = genres
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.map(safe)
|
|
.collect::<Vec<_>>();
|
|
if !genres.is_empty() {
|
|
text.push_str(&format!("\nGenres: {}", genres.join(", ")));
|
|
}
|
|
}
|
|
if let Some(description) = media.metadata.get("description").and_then(Value::as_str) {
|
|
text.push_str(&format!("\n\n{}", plain(description)));
|
|
}
|
|
text
|
|
}
|
|
|
|
impl Browser {
|
|
pub(crate) fn run(
|
|
&mut self,
|
|
terminal: &mut DefaultTerminal,
|
|
) -> io::Result<Option<crate::BrowseProvider>> {
|
|
self.state.select_catalog(self.provider);
|
|
struct RestoreTerminalInput;
|
|
impl Drop for RestoreTerminalInput {
|
|
fn drop(&mut self) {
|
|
let _ = execute!(
|
|
io::stdout(),
|
|
DisableBracketedPaste,
|
|
SetCursorStyle::DefaultUserShape
|
|
);
|
|
}
|
|
}
|
|
let _restore = RestoreTerminalInput;
|
|
execute!(
|
|
io::stdout(),
|
|
EnableBracketedPaste,
|
|
SetCursorStyle::SteadyBlock
|
|
)?;
|
|
self.thumbnail.query_terminal();
|
|
let started = Instant::now();
|
|
loop {
|
|
self.update();
|
|
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
|
if event::poll(Duration::from_millis(100))? {
|
|
match event::read()? {
|
|
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
|
if self.config.keys.resolve(self.context(), key)
|
|
== Some(Action::ProviderChoose)
|
|
{
|
|
return crate::choose_provider(
|
|
terminal,
|
|
&self.config.keys,
|
|
Some(if self.provider == Provider::AniList {
|
|
crate::BrowseProvider::AniList
|
|
} else {
|
|
crate::BrowseProvider::Miruro
|
|
}),
|
|
);
|
|
}
|
|
if self.key(key) {
|
|
return Ok(None);
|
|
}
|
|
}
|
|
Event::Paste(text) => self.paste(&text),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../tests/unit/miruro_ui.rs"]
|
|
mod tests;
|