Files
tubular/src/miruro_ui.rs
T

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("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&nbsp;", " ")
}
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;