Files
tubular/src/main.rs
T

3964 lines
175 KiB
Rust

//! Terminal YouTube and AniList browsing with playback handed to Terebi.
//!
//! Startup offers YouTube, AniList and the explicitly separate Miruro legacy catalog.
//! `--anilist` opens AniList directly; `--miruro` retains legacy catalog access.
//! AniList episode numbers are catalog metadata; visible episodes receive background source checks.
//! Unknown counts allow explicit positive episode selection; Terebi resolves sub on demand.
//! Stable provider-scoped identities persist, never resolved sources or subtitle URLs.
//! AniList and Miruro show libraries remain separate; existing Miruro IDs are never mapped.
//! `--default-config` prints complete TOML without starting the terminal.
//! Key instructions below describe defaults; live hints reflect `[keys]` overrides.
//! Category and combined-category queries search their channels' full histories; other views search YouTube.
//! Insert: Enter submits a trimmed changed query; clearing a category query restores uploads.
//! Esc keeps the draft without submitting; Search and each category retain independent drafts and pages.
//! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word.
//! Cmd/Super+Backspace or Ctrl-U clears the line; plain Backspace removes one character.
//! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U.
//! Active text fields show a steady block cursor at the scrolled end; other views hide it.
//! The terminal's configured default cursor shape is restored when the app exits.
//! Bracketed paste inserts into the active text field at once, without submitting or triggering shortcuts.
//! Pasted line breaks/tabs become spaces; other controls are removed. Non-input contexts ignore paste.
//! Terminal bracketed-paste mode is disabled on exit, including unwinding after an error.
//! Normal: j/k selects, Space checks, Esc discards checks; Enter plays checks in check order or the hovered video.
//! Ctrl+Enter appends the same selection to Terebi's queue without interrupting playback.
//! Checks are per view and survive pagination/reordering; replacement queries/refresh clear, collection filters prune.
//! Previews share their view's checks. Saves/removes/status remain hovered-only; i edits, / searches and F opens Feed.
//! View tabs wrap whole labels into rows using terminal-cell widths and reflow on resize.
//! Short terminals keep the selected tab row visible while reserving space for the other panes.
//! A single label wider than the terminal is clipped rather than split across rows.
//! Normal-mode d opens video info; d expands the full description in place, then returns to info.
//! Both modes share one centered popup, up to 70% width and 60% height, capped at 96 by 20 cells.
//! Full descriptions scroll with Up/Down, Ctrl-J/K, and PgUp/PgDn; plain j/k selects videos.
//! Esc closes the popup; i returns to editing. Mode toggles retain cached content and pending work.
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
//! HQ thumbnails reserve their proportional 4:3 footprint before loading, avoiding metadata reflow.
//! Thumbnails have one column of left padding and two columns before the left-aligned text.
//! A blank row separates the bold title from the dimmed author · publication date.
//! Full descriptions scroll metadata and paragraphs together, with a leading blank row and one-column side padding.
//! Description excerpts use normal brightness and retain a two-column right gutter.
//! Overflowing excerpts end with … without splitting wide glyphs; full descriptions preserve paragraphs.
//! Both modes share an in-memory LRU of 64 full descriptions, keyed by video ID across views.
//! Thumbnails retain eight recently used inactive decoded images plus the selected image.
//! Reopening or revisiting cached videos skips extraction/download/decoding; resize work stays off-thread.
//! Title, author, and publication metadata remain in loaded view rows; preview caches survive view/query changes.
//! Caches last for this session without a timer. A successful r refresh clears both preview buffers.
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
//! Checked tags save the creator's @handle; `[channels]` retains its canonical URL.
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
//! Deselecting removes the channel from that tag, preserving other creators and empty tags.
//! Missing search handles use a saved mapping or an asynchronous canonical-channel lookup.
//! Lookup cancellation, missing ID/handle metadata, or conflicting mappings never save a guessed identity.
//! Normal-mode s edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels.
//! i filters tags case-insensitively; arrows or Ctrl-J/K move through matches while typing.
//! Enter/Esc leaves filter editing without saving; hidden staged memberships remain unchanged.
//! n prompts for a new tag; Enter stages it checked, clears the filter, and selects it. Esc abandons the name.
//! Normal-mode S in a category view edits every saved channel, even with no videos loaded.
//! Shared tags start checked; Enter atomically replaces every target's tags with the checked set.
//! Unchecked tags lose only target channels; other creators, registrations, pins, and empty categories remain.
//! n stages a new shared tag; Esc cancels the whole edit. Changed source membership requires reopening.
//! Clearing all tags is refused for category-only channels, avoiding accidental unsubscription.
//! YouTube Search and Feed are permanent tabs. Normal-mode c opens the saved-category browser.
//! The category browser starts NORMAL: j/k or arrows move, i filters, Space checks, Enter browses, p pins.
//! INSERT filters live with case-insensitive substring matching; arrows or Ctrl-J/K move, Enter browses.
//! Esc returns to NORMAL without clearing the filter; another Esc closes. Search input editing shortcuts apply.
//! Checks survive filtering: Enter browses checked categories, or the hovered match when none are checked.
//! Pinned categories stay visible; browsing another category replaces the single temporary tab.
//! Multiple checked categories form a newest-first deduplicated temporary feed; c rechecks its members.
//! Category rows show checkboxes independently of the dim p pin suffix; Esc cancels staged choices.
//! P saves a combined feed's current channel union as a new flat, unpinned category; Esc cancels the name.
//! Saving a group or renaming a category preserves its query drafts, submitted query, and matching cached pages.
//! Changed channel membership resets pages and cursors without discarding the view's queries.
//! Pinned category tab titles use a dimmed `p` suffix; Search, Feed, and temporary tabs do not.
//! The suffix stays dimmed when selected and counts toward whole-label wrapping without changing category names.
//! Browsing a pinned category focuses its existing tab and removes the temporary tab.
//! `[settings].pinned_categories = ["Favorites"]` persists pins; missing category names are ignored.
//! Settings, channels, categories and keys tables can appear in any order. Move legacy root pins into settings.
//! Tab/Shift-Tab cycle only visible tabs; hidden views retain cached rows and selection.
//! In normal mode minus goes back through visited views, restoring cached rows and selection.
//! Plus goes forward after going back; a new view navigation discards the old forward branch.
//! Esc closes popups or leaves insert mode first; missing historical views are skipped.
//! Normal-mode ? overlays a scrollable keybinding reference without changing the view or dialog.
//! Help scrolls with j/k, arrows, Ctrl-J/K and page keys; ? or Esc closes it. Text inputs keep literal ?.
//! Normal-mode u opens a searchable saved-channel selector with the category browser's modal controls.
//! Normal-mode U opens the selected video's creator directly, without saving a subscription.
//! Choices include untagged registrations and category-only channels, deduplicated with readable handle labels.
//! Channels, combined feeds and unpinned categories share one temporary tab; hidden views retain history.
//! Channel views use the same pagination but extract only their stored channel identity; S stays category-only.
//! Unfiltered channel/feed/tag catalogs traverse uploads, then flatten Releases album tracks.
//! Release tracks share the raw-row budget; absent Releases tabs are normal, other failures are page errors.
//! Config changes preserve unsaved browsing, relabel saved identities, and drop explicitly removed subscriptions.
//! r reloads config and refreshes the current view.
//! Feed includes saved `[channels]` entries, even untagged, plus direct tag entries, without duplicates.
//! Untagging is not unsubscribing: remove mappings and tag references from the file to unsubscribe.
//! Search pages contain 60 results; Feed/tag pages share a total budget of 60 catalog rows or query matches.
//! Upload/search ranges are batched; release discovery/albums also stay within four concurrent extractors.
//! Invalid individual video entries are discarded; raw entry counts still determine pagination.
//! Invalid JSON, playlist-level metadata, and extractor failures remain page errors.
//! Forward video navigation authorizes prefetch within 20 rows of the loaded end in unobstructed normal mode.
//! Each accepted page clears that intent, so short or invalid batches never auto-chain on startup.
//! Loading result/preview panes animate a spinner after their title, including additional pages.
//! Info spinners cover image download/decode/resize and description fetching; errors stop them.
//! Feed rows, including undated rows, are globally newest-first among loaded catalog entries; selection follows video ID.
//! Switching views cancels in-flight extraction; returning retains the unaccepted request and per-channel offsets.
//! Errors keep loaded rows and stop automatic retries; r reloads config and restarts the current view.
//! Deep pages may slow down because yt-dlp offsets re-traverse earlier continuation pages.
//! Video entries show the title above channel · YYYY-MM-DD; ~ marks estimated UTC dates, Unknown missing metadata.
//! Prefer exact upload dates and cache date labels during extraction, not on every redraw.
//! One item uses `--new-instance --url URL --progress-file PATH`; a batch uses `--new-instance --queue-file ABS_PATH`.
//! Private version-1 manifests preserve canonical URLs, order, and an independent reporting session for each item.
//! Checks clear after launcher spawn succeeds and remain checked on failure; each submission opens its own player.
//! `--player` overrides its CLI path; otherwise `terebi` is resolved through PATH.
//! The CLI exits after handoff, not playback completion. Later playback errors appear in Terebi.
//! Quitting cancels pending metadata/launch processes, not the separately running player.
//! w/v save to watch later/saved videos; W/V choose combined or category scopes in searchable selectors.
//! Alt-w/Alt-v browse the current explicit category's collection, including from collection previews.
//! C opens combined currently watching; m/M mark the selected video watching/watched, including previews.
//! Collection views allow x/X selected/bulk removal, including previews; removal never marks watched.
//! All three collections store offline metadata beside config.toml; channel tags determine category lists.
//! Saved titles are green before watch-later magenta; ✓ marks watched status, set manually or by eligible playback reaching 95%.
//! In watch-later, f cycles All/Unwatched/Watched. X confirms removal of the current filtered/category subset.
//! In saved-video views, X confirms removal of the current category/list subset; other collections and watched state stay unchanged.
//! Configure view.collection.remove.selected and view.collection.remove.bulk; y/Enter confirms bulk, n/Esc cancels.
mod config;
mod keys;
mod miruro_browser;
mod miruro_ui;
mod thumbnail;
mod titles;
mod ui;
mod video_state;
mod youtube;
use std::{
collections::{BTreeMap, HashSet},
ffi::OsString,
io,
path::PathBuf,
time::{Duration, Instant},
};
use clap::Parser;
use keys::{Action, Context};
use ratatui::{
DefaultTerminal, Frame,
crossterm::{
cursor::SetCursorStyle,
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers,
},
execute,
},
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span, Text},
widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap},
};
use video_state::{CollectionKind, WatchStatus};
use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
#[derive(Clone, Parser)]
#[command(
version,
about = "Browse YouTube or AniList and open selected videos in Terebi",
after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nAniList browsing uses native GraphQL with no Miruro dependency. Episode numbers are catalog metadata; visible episodes are checked separately for ZokoAnime sub sources, and playback resolves fresh. Unknown-count series accept an explicit positive episode using i. --anilist opens this catalog directly; --miruro is explicitly legacy. Show libraries and episode identities remain provider-isolated. YouTube browsing requires yt-dlp; thumbnails/cover art use curl. Legacy Miruro browsing uses the native catalog client; Terebi must support Miruro episode URLs and version-2 progress. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nMiruro Enter opens catalog episodes, then plays checked episodes in check order, or the hovered episode. One episode uses --new-instance --url URL --progress-file PATH; multiple episodes use --new-instance --queue-file PATH. Terebi resolves each current episode on demand, including sources, subtitles and playback metadata. Episodes use stable catalog/episode identity for collections and progress; channel tags remain YouTube-only.\nFor one YouTube video, Terebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file. Multiple videos use --new-instance --queue-file with an absolute private version-1 JSON manifest containing ordered canonical URLs and independent private progress_file paths. Terebi resolves only the current item on demand; plain Enter opens one independent player without replacing existing playback. Ctrl+Enter (video.enqueue) sends either one video or checked videos using --enqueue --queue-file with the same private manifest format, appending to a running player without interrupting playback, or launching the queue if none is running.\nStartup selects independent YouTube, AniList or Miruro legacy interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --anilist or --miruro skips the chooser. The chosen browser starts in NORMAL and searches any CLI query automatically; otherwise press i to enter a query.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\nINSERT: type to edit; Enter submits the trimmed query if changed and returns to NORMAL; Esc keeps the draft without submitting. In category and combined-category views, queries search only those channels' full histories, not just loaded rows; clearing the query restores uploads. Each category keeps its own draft, submitted query and pagination, independent of general Search. In the independent anime interfaces, input searches the selected catalog (or selects an explicit episode for unknown AniList counts); YouTube views submit to general YouTube Search. Repeating a submitted query reuses its cache or pending request.\nTEXT EDITING (search, category/channel filters, new tag, and category name prompts): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nYOUTUBE NORMAL: j/k or arrows select; Space toggles a playback check; Esc discards checks (Ctrl-Space also clears); Enter plays checked videos in check order, or the hovered video when none are checked. These controls also work in collection views and previews. Configure video.select.toggle, video.select.clear and video.enqueue. Checks belong to each view, survive pagination and reordering by video ID, clear on replacement refresh/query and successful launcher spawn, and are pruned when filters or collections hide/remove rows. Failed launches retain checks. Saves, removals and status actions still affect only the hovered video; i edits the query; / jumps to Search; F jumps to Feed; d opens video info (again for full description); c browses saved categories; u browses saved channels; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; R renames the current explicit category; P saves the active combined category feed as a new category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. Watch-later titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\nSAVED VIDEOS: v saves the selected video once (also in previews); V opens a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, combined category feeds, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCURRENTLY WATCHING: C (Shift-C) directly opens the provider-local combined offline list of acknowledged started, unfinished videos. Positive playback progress admits a video, not launcher success; watched videos leave automatically. x removes selected and X confirms removing the list, without marking watched. Removal persists and old session reports never re-add it; a new playback session can. currently-watching.toml sits beside config.toml. This view shares collection previews, temporary tabs and navigation history; it has no category selector or watched filter.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nYouTube Search and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Space checks categories; Enter browses checked categories, or the hovered match if none are checked; p immediately saves pin/unpin; R renames the hovered category; Esc cancels checks and closes. Checkboxes are separate from the dim p pin marker. One checked category opens its ordinary view; multiple checked categories open their combined newest-first, deduplicated feed. Configure selector.toggle to change Space. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses checked categories, or the selected match if none are checked; Esc returns to NORMAL with the filter retained. Space remains text while filtering. Hidden checked categories stay checked. With no checks and no matches, Enter/p do nothing. Reopening clears the filter and prechecks the active combined feed members.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nChannels, combined category feeds and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\nHidden category, combined category and channel views retain loaded rows, selection, and pagination for this session. Combined feeds retain their cache when member assignments change but the resolved channel union stays equal; deleting a member invalidates the combined view rather than widening it. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 60 results per page; Feed, categories, and channel views share a 60-row budget across up to four concurrent extractors. Category queries request matching channel-history results instead of uploads.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views sort all loaded rows newest-first using approximate upload dates, including undated rows. Selection follows video identity when later pages reorder rows.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; R renames the selected tag; Esc cancels staged memberships without undoing committed renames.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nSAVE COMBINED CATEGORY (P / Shift-P): only an active combined category feed can be saved, including from its previews. The floating name prompt uses shared input editing controls; Enter saves a new flat category containing the currently viewed channel union, using mapped handles where available; Esc cancels without writes. Invalid or duplicate names keep the prompt open and current view unchanged. Saving copies memberships, not dynamic source-category definitions; later source-category edits do not affect the saved category. The new category becomes the active unpinned temporary tab, preserving matching cached rows, selection, pending pages, query draft and submitted query, and pagination without refetching. Source categories and existing pins stay unchanged. Configure category.save; P remains text in inputs.\nCATEGORY RENAME (R / Shift-R): rename the current category or category-scoped collection, the hovered category in category/collection selectors, or a selected tag in single/bulk tagging. Combined and channel rows cannot be renamed. The prefilled floating input uses shared editing controls; Enter commits, Esc cancels. Existing category identity is saved immediately, with memberships, pins, comments, cached selection/pages/watch filters and navigation history preserved. Tag membership changes remain staged until the main tag-list Enter; an unstored new tag rename also stays staged. Invalid, duplicate, or externally changed source categories keep the prompt open without writes; close dialogs and reload config with r before retrying external changes. Configure category.rename; R remains literal in filter/search inputs.\nNEW TAG: type a name; Enter stages it checked, clears the filter, selects the new tag, and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nPlain Enter opens a new Terebi player, leaving existing playback untouched; Ctrl+Enter appends to the running queue instead. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\""
)]
struct Options {
/// Terebi CLI executable with URL, queue-file and per-item progress support.
#[arg(long, default_value = "terebi")]
player: OsString,
/// Print the complete default TOML configuration and exit.
#[arg(long, conflicts_with = "query")]
default_config: bool,
/// Skip the provider chooser and open the dedicated Miruro catalog interface.
#[arg(long)]
miruro: bool,
/// Open AniList with visible-episode source checks and fresh sub resolution on play.
#[arg(long, conflicts_with = "miruro")]
anilist: bool,
/// Optional initial search for the selected provider.
query: Option<String>,
}
const DESCRIPTION_CACHE_SIZE: usize = 64;
fn cached_description<'a>(
cache: &'a std::collections::VecDeque<(String, String)>,
id: &str,
) -> Option<&'a str> {
cache
.iter()
.find(|(cached, _)| cached == id)
.map(|(_, text)| text.as_str())
}
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Insert,
Normal,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum ViewSource {
Search,
Feed,
Category(String),
CategoryGroup(Vec<String>),
Channel(String),
WatchLater(Option<String>),
SavedVideos(Option<String>),
Watching,
}
impl ViewSource {
fn channels<'a>(
&'a self,
config: &'a config::Config,
feed: &'a [String],
group: &'a [String],
) -> &'a [String] {
match self {
Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[],
Self::Feed => feed,
Self::Category(name) => &config.categories[name],
Self::CategoryGroup(_) => group,
Self::Channel(identity) => std::slice::from_ref(identity),
}
}
fn category(&self) -> Option<&str> {
match self {
Self::Category(name) | Self::WatchLater(Some(name)) | Self::SavedVideos(Some(name)) => {
Some(name)
}
_ => None,
}
}
fn rename_category(&mut self, old: &str, new: &str) -> bool {
match self {
Self::Category(name) | Self::WatchLater(Some(name)) | Self::SavedVideos(Some(name))
if name.as_str() == old =>
{
*name = new.to_owned();
true
}
Self::CategoryGroup(names) if names.iter().any(|name| name == old) => {
for name in names.iter_mut() {
if name == old {
*name = new.to_owned();
}
}
names.sort_unstable();
names.dedup();
true
}
_ => false,
}
}
}
fn category_union(names: &[String], config: &config::Config) -> Option<Vec<String>> {
let mut channels = Vec::new();
for name in names {
channels.extend(config.categories.get(name)?.iter().cloned());
}
channels.sort_unstable();
channels.dedup();
Some(channels)
}
fn group_name(names: &[String]) -> String {
format!("Combined · {}", names.join(" + "))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum WatchFilter {
#[default]
All,
Unwatched,
Watched,
}
impl WatchFilter {
fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Unwatched => "Unwatched",
Self::Watched => "Watched",
}
}
fn next(self) -> Self {
match self {
Self::All => Self::Unwatched,
Self::Unwatched => Self::Watched,
Self::Watched => Self::All,
}
}
fn includes(self, watched: bool) -> bool {
self == Self::All || watched == (self == Self::Watched)
}
}
fn video_in_channels(video: &Video, channels: &[String], config: &config::Config) -> bool {
channels.iter().any(|identity| {
video.channel_id.as_deref().is_some_and(|id| {
identity == id || identity.strip_prefix("https://www.youtube.com/channel/") == Some(id)
}) || video.uploader_id.as_deref().is_some_and(|handle| {
identity.strip_prefix("https://www.youtube.com/") == Some(handle)
|| (video.channel_id.is_none() && config.channels.get(handle) == Some(identity))
})
})
}
struct CollectionRemoval {
kind: CollectionKind,
ids: Vec<String>,
category: Option<String>,
filter: Option<WatchFilter>,
text: String,
}
struct View {
source: ViewSource,
name: String,
group_channels: Vec<String>,
query: String,
submitted_query: String,
title: String,
videos: Vec<Video>,
selection: TableState,
checked: Vec<String>,
loaded: bool,
next_start: usize,
has_more: bool,
feed_cursor: youtube::FeedCursor,
feed_requests: Vec<youtube::FeedRequest>,
prefetch_requested: bool,
page_error: Option<String>,
watch_filter: WatchFilter,
}
impl View {
fn new(name: String, source: ViewSource) -> Self {
Self {
title: format!(" {name} "),
source,
group_channels: Vec::new(),
query: String::new(),
submitted_query: String::new(),
name,
videos: Vec::new(),
selection: TableState::default(),
checked: Vec::new(),
loaded: false,
next_start: 1,
has_more: false,
feed_cursor: youtube::FeedCursor::default(),
feed_requests: Vec::new(),
prefetch_requested: false,
page_error: None,
watch_filter: WatchFilter::All,
}
}
// Invalidate pagination, retaining displayed rows until the first replacement page is valid.
fn reset(&mut self) {
self.checked.clear();
self.loaded = false;
self.next_start = 1;
self.has_more = false;
self.feed_cursor = youtube::FeedCursor::default();
self.feed_requests.clear();
self.prefetch_requested = false;
self.page_error = None;
}
fn accept_page(&mut self, mut page: Page, feed: bool) -> Result<(), String> {
let next_start = self
.next_start
.checked_add(PAGE_SIZE)
.ok_or("Page offset exceeds the supported range")?;
if feed {
self.feed_cursor.accept(&page.feed_progress)?;
}
if !self.loaded {
self.videos.clear();
self.selection = TableState::default();
self.checked.clear();
}
if !page.videos.is_empty() {
let selected = if feed {
self.selection.selected().and_then(|index| {
self.videos.get(index).map(|video| {
<[u8; 11]>::try_from(video.id.as_bytes()).expect("validated video ID")
})
})
} else {
None
};
let mut ids = HashSet::with_capacity(self.videos.len() + page.videos.len());
ids.extend(self.videos.iter().map(|video| {
<[u8; 11]>::try_from(video.id.as_bytes()).expect("validated video ID")
}));
page.videos.retain(|video| {
ids.insert(<[u8; 11]>::try_from(video.id.as_bytes()).expect("validated video ID"))
});
self.videos.append(&mut page.videos);
if feed {
// ponytail: sort loaded metadata only; a globally newest archive merge needs
// per-channel frontier lookahead rather than fair quotas.
self.videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(id) = selected {
self.selection.select(
self.videos
.iter()
.position(|video| video.id.as_bytes() == id.as_slice()),
);
}
}
}
if self.selection.selected().is_none() && !self.videos.is_empty() {
self.selection.select(Some(0));
}
self.loaded = true;
self.next_start = next_start;
self.has_more = if feed {
self.feed_cursor.has_more()
} else {
page.has_more
};
self.prefetch_requested = false;
self.page_error = None;
Ok(())
}
fn toggle_checked(&mut self) {
if let Some(video) = self
.selection
.selected()
.and_then(|index| self.videos.get(index))
{
if let Some(index) = self.checked.iter().position(|id| id == &video.id) {
self.checked.remove(index);
} else {
self.checked.push(video.id.clone());
}
}
}
fn prune_checked(&mut self) {
// ponytail: scan displayed rows per check; index IDs if very large collections need it.
self.checked
.retain(|id| self.videos.iter().any(|video| &video.id == id));
}
fn playback_videos(&self) -> Vec<Video> {
if self.checked.is_empty() {
self.selection
.selected()
.and_then(|index| self.videos.get(index))
.cloned()
.into_iter()
.collect()
} else {
// ponytail: scan rows per check; index IDs if very large lists need it.
self.checked
.iter()
.filter_map(|id| self.videos.iter().find(|video| &video.id == id))
.cloned()
.collect()
}
}
fn needs_page(&self) -> bool {
self.loaded
&& self.has_more
&& self.prefetch_requested
&& self.page_error.is_none()
&& (self.videos.is_empty()
|| self
.selection
.selected()
.is_some_and(|index| index.saturating_add(PAGE_SIZE / 3) >= self.videos.len()))
}
}
enum CategoryTarget {
Channel { handle: String, channel: String },
Category { name: String, channels: Vec<String> },
}
struct CategoryDialog {
target: CategoryTarget,
label: String,
tags: BTreeMap<String, bool>,
mode: Mode,
query: String,
search_names: Vec<String>,
matches: Vec<usize>,
selection: ListState,
name: Option<String>,
error: Option<String>,
}
impl CategoryDialog {
fn new(target: CategoryTarget, label: String, tags: BTreeMap<String, bool>) -> Self {
let mut dialog = Self {
target,
label,
search_names: tags.keys().map(|name| name.to_lowercase()).collect(),
matches: Vec::with_capacity(tags.len()),
tags,
mode: Mode::Normal,
query: String::new(),
selection: ListState::default(),
name: None,
error: None,
};
dialog.refilter();
dialog
}
fn refilter(&mut self) {
filter_choices(
&self.query,
&self.search_names,
&mut self.matches,
&mut self.selection,
);
self.error = None;
}
}
enum CategoryNameTarget {
Rename { old: String, persisted: bool },
SaveGroup { channels: Vec<String> },
}
struct CategoryNamePrompt {
target: CategoryNameTarget,
name: String,
error: Option<String>,
}
fn filter_choices(
query: &str,
search_names: &[String],
matches: &mut Vec<usize>,
selection: &mut ListState,
) {
let selected = selection
.selected()
.and_then(|row| matches.get(row))
.copied();
let query = query.to_lowercase();
matches.clear();
matches.extend(
search_names
.iter()
.enumerate()
.filter(|(_, name)| name.contains(&query))
.map(|(index, _)| index),
);
selection.select(
selected
.and_then(|index| matches.binary_search(&index).ok())
.or_else(|| (!matches.is_empty()).then_some(0)),
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BrowserKind {
Categories,
Channels,
WatchLater,
SavedVideos,
}
struct Browser {
kind: BrowserKind,
choices: Vec<(String, ViewSource)>,
checked: std::collections::BTreeSet<String>,
mode: Mode,
query: String,
search_names: Vec<String>,
matches: Vec<usize>,
selection: ListState,
error: Option<String>,
}
impl Browser {
fn refilter(&mut self) {
filter_choices(
&self.query,
&self.search_names,
&mut self.matches,
&mut self.selection,
);
self.error = None;
}
fn selected_choice(&self) -> Option<&(String, ViewSource)> {
self.selection
.selected()
.and_then(|row| self.matches.get(row))
.and_then(|index| self.choices.get(*index))
}
fn choice_to_open(&self) -> Option<(String, ViewSource)> {
if self.kind == BrowserKind::Categories && !self.checked.is_empty() {
let names: Vec<_> = self.checked.iter().cloned().collect();
return Some(if names.len() == 1 {
(names[0].clone(), ViewSource::Category(names[0].clone()))
} else {
(group_name(&names), ViewSource::CategoryGroup(names))
});
}
self.selected_choice().cloned()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PreviewMode {
Info,
Description,
}
struct KeyHints {
footers: [(Context, String); 14],
preview: [String; 4],
browser: [String; 4],
tags: [String; 3],
tags_empty: String,
help: String,
watch_later: String,
saved_videos: String,
watching: String,
group_save: String,
}
impl KeyHints {
fn new(bindings: &keys::Bindings) -> Self {
let key = |action| bindings.label(action);
let playback = format!(
"{} check · {} clear · {} play · {} enqueue",
key(Action::VideoToggle),
key(Action::VideoClear),
key(Action::VideoPlay),
key(Action::VideoEnqueue)
);
let status = format!(
"{} watching · {} watched",
key(Action::WatchStatusWatching),
key(Action::WatchStatusWatched)
);
Self {
footers: [
(
Context::Normal,
format!(
"NORMAL · {}/{} select · {playback} · {status} · {}/{} later · {}/{} saved · {} search · {} provider · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::WatchLaterSave),
key(Action::WatchLaterBrowse),
key(Action::SavedVideoSave),
key(Action::SavedVideoBrowse),
key(Action::SearchEdit),
key(Action::ProviderChoose),
key(Action::Help),
key(Action::Quit)
),
),
(
Context::Info,
format!(
"INFO · {playback} · {} description · {status} · {} later · {} save · {} close · {} help",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide),
key(Action::Help)
),
),
(
Context::Description,
format!(
"DESCRIPTION · {playback} · {}/{} scroll · {} info · {status} · {} later · {} save · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide),
key(Action::Help)
),
),
(
Context::CollectionNormal,
format!(
"NORMAL · {}/{} select · {playback} · {status} · {}/{} later · {}/{} saved · {} remove selected · {} remove list · {} search · {} provider · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::WatchLaterSave),
key(Action::WatchLaterBrowse),
key(Action::SavedVideoSave),
key(Action::SavedVideoBrowse),
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk),
key(Action::SearchEdit),
key(Action::ProviderChoose),
key(Action::Help),
key(Action::Quit)
),
),
(
Context::CollectionInfo,
format!(
"INFO · {playback} · {} description · {status} · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk),
key(Action::InfoHide),
key(Action::Help)
),
),
(
Context::CollectionDescription,
format!(
"DESCRIPTION · {playback} · {}/{} scroll · {} info · {status} · {} later · {} save · {} remove selected · {} remove list · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk),
key(Action::InfoHide),
key(Action::Help)
),
),
(
Context::SearchInput,
format!(
"INSERT · {} search · {} normal · {} quit",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::Interrupt)
),
),
(
Context::SelectorNormal,
format!(
"BROWSE · {}/{} move · {} filter · {} browse · {} close · {} help",
key(Action::SelectorNext),
key(Action::SelectorPrevious),
key(Action::SelectorEdit),
key(Action::SelectorOpen),
key(Action::SelectorClose),
key(Action::Help)
),
),
(
Context::SelectorInput,
format!(
"FILTER INSERT · {}/{} move · {} browse · {} normal",
key(Action::SelectorInputNext),
key(Action::SelectorInputPrevious),
key(Action::InputSubmit),
key(Action::InputCancel)
),
),
(
Context::TagList,
format!(
"TAGS · {} filter · {} toggle · {} save · {} cancel · {} help",
key(Action::TagEdit),
key(Action::TagToggle),
key(Action::TagSave),
key(Action::TagCancel),
key(Action::Help)
),
),
(
Context::TagFilter,
format!(
"TAG FILTER INSERT · {}/{} move · {}/{} normal",
key(Action::TagInputNext),
key(Action::TagInputPrevious),
key(Action::InputSubmit),
key(Action::InputCancel)
),
),
(
Context::TagInput,
format!(
"NEW TAG · {} stage · {} cancel · {} quit",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::Interrupt)
),
),
(
Context::CategoryNameInput,
format!(
"NAME · {} save · {} cancel · {} clear · {} quit",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::DeleteLine),
key(Action::Interrupt)
),
),
(
Context::Help,
format!(
"HELP · {}/{} scroll · {}/{} page · {}/{} close",
key(Action::HelpNext),
key(Action::HelpPrevious),
key(Action::HelpPageNext),
key(Action::HelpPagePrevious),
key(Action::Help),
key(Action::HelpClose)
),
),
],
preview: [
format!(
"· {playback} · {} description · {status} · {} later · {} save · {} close ",
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide)
),
format!(
"· {playback} · {}/{} scroll · {} info · {status} · {} later · {} save · {} close ",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide)
),
format!(
"· {} remove selected · {} remove list · {playback} · {} description · {status} · {} later · {} save · {} close ",
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk),
key(Action::InfoDescription),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide)
),
format!(
"· {} remove selected · {} remove list · {playback} · {}/{} scroll · {} info · {status} · {} later · {} save · {} close ",
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk),
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
key(Action::WatchLaterSave),
key(Action::SavedVideoSave),
key(Action::InfoHide)
),
],
browser: [
format!(
" Categories · {} filter · {} check · {} browse · {} pin · {} rename ",
key(Action::SelectorEdit),
key(Action::SelectorToggle),
key(Action::SelectorOpen),
key(Action::SelectorPin),
key(Action::CategoryRename)
),
format!(
" Channels · {} filter · {} browse ",
key(Action::SelectorEdit),
key(Action::SelectorOpen)
),
format!(
" Watch later · {} filter · {} browse · {} rename ",
key(Action::SelectorEdit),
key(Action::SelectorOpen),
key(Action::CategoryRename)
),
format!(
" Saved videos · {} filter · {} browse · {} rename ",
key(Action::SelectorEdit),
key(Action::SelectorOpen),
key(Action::CategoryRename)
),
],
tags: [
format!(
"{}/{} move · {} filter · {} toggle · {} save · {} new · {} rename · {} cancel",
key(Action::TagNext),
key(Action::TagPrevious),
key(Action::TagEdit),
key(Action::TagToggle),
key(Action::TagSave),
key(Action::TagNew),
key(Action::CategoryRename),
key(Action::TagCancel)
),
format!(
"{} create · {} back · {} clear",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::DeleteLine)
),
format!(
"{}/{} move · {}/{} back · {} clear",
key(Action::TagInputNext),
key(Action::TagInputPrevious),
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::DeleteLine)
),
],
tags_empty: format!("No tags. Press {} to create one.", key(Action::TagNew)),
help: format!(
" Keybindings · {}/{} close · {}/{} scroll ",
key(Action::Help),
key(Action::HelpClose),
key(Action::HelpNext),
key(Action::HelpPrevious)
),
watch_later: format!(
"· {} filter · {}/{} category later/saved · {} remove selected · {} remove filtered ",
key(Action::WatchLaterFilter),
key(Action::WatchLaterBrowseCategory),
key(Action::SavedVideoBrowseCategory),
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk)
),
saved_videos: format!(
"· {}/{} category later/saved · {} remove selected · {} remove list ",
key(Action::WatchLaterBrowseCategory),
key(Action::SavedVideoBrowseCategory),
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk)
),
watching: format!(
"· {} currently watching · {} remove selected · {} remove list ",
key(Action::WatchingBrowse),
key(Action::CollectionRemoveSelected),
key(Action::CollectionRemoveBulk)
),
group_save: format!("· {} save as category ", key(Action::CategorySave)),
}
}
fn footer(&self, context: Context) -> &str {
self.footers
.iter()
.find(|(candidate, _)| *candidate == context)
.expect("every input context has a hint")
.1
.as_str()
}
}
#[derive(Default)]
struct HelpPopup {
paragraph: Paragraph<'static>,
scroll: u16,
max_scroll: u16,
viewport: Option<(u16, u16)>,
}
struct App {
options: Options,
mode: Mode,
config_path: PathBuf,
config: config::Config,
video_state: video_state::State,
collection_removal: Option<CollectionRemoval>,
key_hints: KeyHints,
feed_channels: Vec<String>,
views: Vec<View>,
view_index: usize,
view_history: Vec<ViewSource>,
forward_history: Vec<ViewSource>,
category_dialog: Option<CategoryDialog>,
channel_job: Option<(String, String, Job)>,
category_name_input: Option<CategoryNamePrompt>,
browser: Option<Browser>,
temporary_view: Option<ViewSource>,
preview: Option<PreviewMode>,
help_popup: Option<HelpPopup>,
thumbnail: thumbnail::Thumbnail,
titles: titles::Titles,
description_job: Option<(String, Job)>,
description_error: Option<String>,
// ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows.
descriptions: std::collections::VecDeque<(String, String)>,
description_scroll: u16,
description_max_scroll: u16,
description_viewport: Option<(u16, u16)>,
job: Option<Job>,
player_launch: Option<PlayerLaunch>,
browser_launch: Option<std::sync::mpsc::Receiver<Result<(), String>>>,
status: String,
}
impl App {
fn with_state(
mut options: Options,
config_path: PathBuf,
mut config: config::Config,
video_state: video_state::State,
) -> Self {
// Cursor channel indices must remain stable when a config only reorders memberships.
for channels in config.categories.values_mut() {
channels.sort_unstable();
}
let feed_channels = config.subscriptions();
let key_hints = KeyHints::new(&config.keys);
let mut views = vec![
View::new("Search".into(), ViewSource::Search),
View::new("Feed".into(), ViewSource::Feed),
];
views.extend(
config
.categories
.keys()
.map(|name| View::new(name.clone(), ViewSource::Category(name.clone()))),
);
let initial_index = 0;
views[initial_index].query = options.query.take().unwrap_or_default();
Self {
options,
mode: Mode::Normal,
config_path,
config,
video_state,
collection_removal: None,
key_hints,
feed_channels,
views,
view_index: initial_index,
view_history: Vec::new(),
forward_history: Vec::new(),
category_dialog: None,
channel_job: None,
category_name_input: None,
browser: None,
temporary_view: None,
preview: None,
help_popup: None,
thumbnail: thumbnail::Thumbnail::new(),
titles: titles::Titles::default(),
description_job: None,
description_error: None,
descriptions: std::collections::VecDeque::with_capacity(DESCRIPTION_CACHE_SIZE),
description_scroll: 0,
description_max_scroll: 0,
description_viewport: None,
job: None,
player_launch: None,
browser_launch: None,
status: "Enter a query to search YouTube.".into(),
}
}
fn visible_views(&self) -> impl Iterator<Item = (usize, &View)> {
self.views
.iter()
.enumerate()
.filter(|(_, view)| match &view.source {
ViewSource::Search | ViewSource::Feed => true,
ViewSource::Category(name) => self.config.pinned_categories.contains(name),
ViewSource::CategoryGroup(_) | ViewSource::Channel(_)
| ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false,
})
.chain(self.temporary_view.as_ref().and_then(|source| {
self.views.iter().enumerate().find(|(_, view)| {
&view.source == source
&& !matches!(source, ViewSource::Category(name) if self.config.pinned_categories.contains(name))
})
}))
}
fn cycle_view(&mut self, backwards: bool) {
let next = if backwards {
self.visible_views()
.map(|(index, _)| index)
.take_while(|index| *index != self.view_index)
.last()
.or_else(|| self.visible_views().last().map(|(index, _)| index))
.unwrap_or(0)
} else {
self.visible_views()
.map(|(index, _)| index)
.skip_while(|index| *index != self.view_index)
.nth(1)
.unwrap_or(0)
};
self.switch_view(next);
}
fn open_browser(&mut self, kind: BrowserKind) {
self.close_preview();
let choices: Vec<_> = match kind {
BrowserKind::Categories => self
.config
.categories
.keys()
.map(|name| (name.clone(), ViewSource::Category(name.clone())))
.collect(),
BrowserKind::Channels => self
.config
.saved_channels()
.into_iter()
.map(|(label, identity)| (label, ViewSource::Channel(identity)))
.collect(),
BrowserKind::WatchLater => std::iter::once((
"All categories (combined)".into(),
ViewSource::WatchLater(None),
))
.chain(
self.config
.categories
.keys()
.map(|name| (name.clone(), ViewSource::WatchLater(Some(name.clone())))),
)
.collect(),
BrowserKind::SavedVideos => std::iter::once((
"All categories (combined)".into(),
ViewSource::SavedVideos(None),
))
.chain(
self.config
.categories
.keys()
.map(|name| (name.clone(), ViewSource::SavedVideos(Some(name.clone())))),
)
.collect(),
};
let selected = choices
.iter()
.position(|(_, source)| source == &self.views[self.view_index].source)
.or_else(|| (!choices.is_empty()).then_some(0));
let mut browser = Browser {
kind,
checked: match (&self.views[self.view_index].source, kind) {
(ViewSource::CategoryGroup(names), BrowserKind::Categories) => {
names.iter().cloned().collect()
}
_ => Default::default(),
},
mode: Mode::Normal,
query: String::new(),
search_names: choices
.iter()
.map(|(label, _)| label.to_lowercase())
.collect(),
matches: Vec::with_capacity(choices.len()),
choices,
selection: ListState::default(),
error: None,
};
browser.refilter();
browser.selection.select(selected);
self.browser = Some(browser);
}
fn browse_key(&mut self, key: KeyEvent, action: Option<Action>) {
let mut browser = self.browser.take().expect("browser is open");
let action = if browser.mode == Mode::Insert {
match action {
Some(Action::InputCancel) => {
browser.mode = Mode::Normal;
self.browser = Some(browser);
return;
}
Some(Action::InputSubmit) => Some(Action::SelectorOpen),
Some(Action::SelectorInputNext) => Some(Action::SelectorNext),
Some(Action::SelectorInputPrevious) => Some(Action::SelectorPrevious),
_ => {
edit_input(&mut browser.query, key, action);
browser.refilter();
self.browser = Some(browser);
return;
}
}
} else {
action
};
let count = browser.matches.len();
match action {
Some(Action::SelectorClose) => return,
Some(Action::SelectorEdit) => browser.mode = Mode::Insert,
Some(Action::SelectorNext) if count != 0 => {
browser.selection.select(Some(
browser
.selection
.selected()
.unwrap_or(0)
.saturating_add(1)
.min(count - 1),
));
}
Some(Action::SelectorPrevious) if count != 0 => {
browser.selection.select(Some(
browser.selection.selected().unwrap_or(0).saturating_sub(1),
));
}
Some(Action::SelectorToggle) if browser.kind == BrowserKind::Categories => {
if let Some((name, _)) = browser.selected_choice() {
let name = name.clone();
if !browser.checked.remove(&name) {
browser.checked.insert(name);
}
}
}
Some(Action::SelectorOpen) => {
if let Some((name, source)) = browser.choice_to_open() {
match source {
ViewSource::WatchLater(None) => {
self.browse_collection(CollectionKind::WatchLater)
}
ViewSource::SavedVideos(None) => {
self.browse_collection(CollectionKind::Saved)
}
ViewSource::WatchLater(category) => {
self.browse_collection_scope(CollectionKind::WatchLater, category)
}
ViewSource::SavedVideos(category) => {
self.browse_collection_scope(CollectionKind::Saved, category)
}
_ => self.browse_view(name, source),
}
return;
}
}
Some(Action::SelectorPin) if browser.kind == BrowserKind::Categories => {
if let Some((name, _)) = browser.selected_choice().cloned() {
let pinned = !self.config.pinned_categories.contains(&name);
match config::set_category_pinned(&self.config_path, &name, pinned) {
Ok(config) => {
let names_changed =
config.categories.keys().ne(self.config.categories.keys());
self.apply_config(config);
self.switch_view(self.view_index);
if names_changed {
browser
.checked
.retain(|name| self.config.categories.contains_key(name));
browser.choices = self
.config
.categories
.keys()
.map(|name| (name.clone(), ViewSource::Category(name.clone())))
.collect();
browser.search_names = browser
.choices
.iter()
.map(|(label, _)| label.to_lowercase())
.collect();
browser.refilter();
browser.selection.select(
self.config
.categories
.keys()
.position(|key| key == &name)
.and_then(|index| {
browser.matches.binary_search(&index).ok()
}),
);
}
browser.error = None;
}
Err(error) => browser.error = Some(error.to_string()),
}
}
}
_ => {}
}
self.browser = Some(browser);
}
fn browse_view(&mut self, name: String, mut source: ViewSource) {
if let ViewSource::CategoryGroup(names) = &mut source {
names.sort_unstable();
names.dedup();
}
if matches!(source, ViewSource::Category(_)) {
self.temporary_view = None;
}
let watch_filter = match (&self.views[self.view_index].source, &source) {
(ViewSource::WatchLater(_), ViewSource::WatchLater(_)) => {
Some(self.views[self.view_index].watch_filter)
}
_ => None,
};
let index = self
.views
.iter()
.position(|view| view.source == source)
.unwrap_or_else(|| {
let mut view = View::new(name, source);
if let ViewSource::CategoryGroup(names) = &view.source {
view.group_channels = category_union(names, &self.config)
.expect("browser selects existing categories");
}
self.views.push(view);
self.views.len() - 1
});
if let Some(filter) = watch_filter
&& self.views[index].watch_filter != filter
{
self.views[index].watch_filter = filter;
self.views[index].loaded = false;
}
self.switch_view(index);
}
fn open_video_channel(&mut self) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
self.status = "Select a video first.".into();
return;
};
if matches!(view.source, ViewSource::Channel(_)) {
self.switch_view(self.view_index);
return;
}
let handle = video
.uploader_id
.as_deref()
.filter(|handle| handle.starts_with('@'))
.and_then(|handle| {
config::normalize_channel(handle)
.ok()
.map(|url| (handle, url))
});
let identity = if let Some(id) = video.channel_id.as_deref() {
config::normalize_channel(id)
} else if let Some((handle, url)) = &handle {
Ok(self
.config
.channels
.get(*handle)
.cloned()
.unwrap_or_else(|| url.clone()))
} else {
self.status = "This video's channel identity is unavailable.".into();
return;
};
let identity = match identity {
Ok(identity) => identity,
Err(error) => {
self.status = error.to_string();
return;
}
};
let saved = self
.config
.saved_channels()
.into_iter()
.find(|(_, channel)| {
channel == &identity || handle.as_ref().is_some_and(|(_, url)| channel == url)
});
let (label, identity) = saved.unwrap_or_else(|| {
let label = handle
.as_ref()
.map(|(handle, _)| *handle)
.or(video.channel.as_deref())
.unwrap_or(&identity)
.to_owned();
(label, identity)
});
self.browse_view(label, ViewSource::Channel(identity));
}
fn query_view_index(&self) -> usize {
if matches!(
self.views[self.view_index].source,
ViewSource::Category(_) | ViewSource::CategoryGroup(_)
) {
self.view_index
} else {
0
}
}
fn search(&mut self) {
let index = self.query_view_index();
let view = &mut self.views[index];
let query = view.query.trim();
if index == 0 && query.is_empty() {
self.status = "Enter a query to search YouTube.".into();
return;
}
if query != view.submitted_query {
view.submitted_query = query.to_owned();
view.reset();
view.videos.clear();
view.selection = TableState::default();
self.job = None;
}
self.switch_view(index);
}
fn switch_view(&mut self, index: usize) {
if index != self.view_index {
self.view_history
.push(self.views[self.view_index].source.clone());
self.forward_history.clear();
}
self.activate_view(index);
}
fn back_view(&mut self) {
while let Some(source) = self.view_history.pop() {
if let Some(index) = self.views.iter().position(|view| view.source == source)
&& index != self.view_index
{
self.forward_history
.push(self.views[self.view_index].source.clone());
self.activate_view(index);
return;
}
}
}
fn forward_view(&mut self) {
while let Some(source) = self.forward_history.pop() {
if let Some(index) = self.views.iter().position(|view| view.source == source)
&& index != self.view_index
{
self.view_history
.push(self.views[self.view_index].source.clone());
self.activate_view(index);
return;
}
}
}
fn activate_view(&mut self, index: usize) {
let source = &self.views[index].source;
if matches!(
source,
ViewSource::CategoryGroup(_)
| ViewSource::Channel(_)
| ViewSource::WatchLater(_)
| ViewSource::SavedVideos(_)
| ViewSource::Watching
) || matches!(source, ViewSource::Category(name) if !self.config.pinned_categories.contains(name))
{
self.temporary_view = Some(source.clone());
}
if index != self.view_index {
self.job = None;
self.view_index = index;
}
self.close_preview();
if !self.views[index].loaded && self.views[index].page_error.is_none() {
if self.job.is_none() {
self.load_page();
}
} else if self.job.is_none() {
self.status = self.view_status();
}
}
fn load_view(&mut self) {
self.job = None;
self.close_preview();
self.views[self.view_index].checked.clear();
if matches!(
self.views[self.view_index].source,
ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching
) {
self.rebuild_collections();
self.status = self.view_status();
return;
}
self.views[self.view_index].reset();
self.load_page();
}
fn load_page(&mut self) {
if self.job.is_some() {
return;
}
if matches!(
self.views[self.view_index].source,
ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching
) {
self.rebuild_collections();
self.status = self.view_status();
return;
}
let view = &mut self.views[self.view_index];
let page = (view.next_start - 1) / PAGE_SIZE + 1;
let result = if view.source == ViewSource::Search {
if view.submitted_query.is_empty() {
self.status = "Enter a query to search YouTube.".into();
return;
}
self.status = format!("Searching for {} (page {page})...", view.submitted_query);
Job::search(&view.submitted_query, view.next_start)
} else {
let channels =
view.source
.channels(&self.config, &self.feed_channels, &view.group_channels);
if channels.is_empty() {
view.videos.clear();
view.checked.clear();
view.selection = TableState::default();
view.loaded = true;
view.has_more = false;
self.status = format!(
"{} has no channels. Add channels from search results or edit config.",
view.name
);
return;
}
self.status = format!(
"Loading {} page {page} ({} videos kept)...",
view.name,
view.videos.len()
);
view.feed_cursor.plan(channels.len()).and_then(|requests| {
view.feed_requests = requests;
let query = if matches!(
view.source,
ViewSource::Category(_) | ViewSource::CategoryGroup(_)
) {
view.submitted_query.as_str()
} else {
""
};
Job::feed(channels, &view.feed_requests, query)
})
};
match result {
Ok(job) => self.job = Some(job),
Err(error) => {
view.page_error = Some(error.to_string());
self.status = self.view_status();
}
}
}
fn view_status(&self) -> String {
let view = &self.views[self.view_index];
if matches!(view.source, ViewSource::WatchLater(_)) {
return format!(
"{} videos in {} · {}. Saved metadata is available offline.",
view.videos.len(),
view.name,
view.watch_filter.label()
);
}
if matches!(
view.source,
ViewSource::SavedVideos(_) | ViewSource::Watching
) {
return format!(
"{} videos in {}. Saved metadata is available offline.",
view.videos.len(),
view.name
);
}
if let Some(error) = &view.page_error {
return format!(
"{} videos kept in {}. Page {} failed: {error}. Refresh this view to retry.",
view.videos.len(),
view.name,
(view.next_start - 1) / PAGE_SIZE + 1
);
}
if view.source != ViewSource::Search
&& view
.source
.channels(&self.config, &self.feed_channels, &view.group_channels)
.is_empty()
{
return format!(
"{} has no channels. Add channels from search results or edit config.",
view.name
);
}
let continuation = if view.has_more {
"more available"
} else {
"end"
};
format!(
"{} videos in {} (page {}; {continuation}). Select a video to stream.",
view.videos.len(),
view.name,
(view.next_start - 1) / PAGE_SIZE
)
}
fn finish_page(&mut self, result: Result<String, String>) {
self.job = None;
let parsed = result.and_then(|output| {
if self.views[self.view_index].source == ViewSource::Search {
youtube::search_results(&output)
} else {
youtube::feed_results(&output, &self.views[self.view_index].feed_requests)
}
});
let feed = self.views[self.view_index].source != ViewSource::Search;
let replacing = !self.views[self.view_index].loaded;
match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
Ok(()) if replacing => self.close_preview(),
Err(error) => self.views[self.view_index].page_error = Some(error),
_ => {}
}
self.status = self.view_status();
}
fn can_prefetch(&self) -> bool {
self.job.is_none()
&& self.mode == Mode::Normal
&& self.category_dialog.is_none()
&& self.category_name_input.is_none()
&& self.browser.is_none()
&& self.collection_removal.is_none()
&& self.preview.is_none()
&& self.help_popup.is_none()
&& self.views[self.view_index].needs_page()
}
fn rebuild_collections(&mut self) {
let mut active_changed = false;
for (index, view) in self.views.iter_mut().enumerate() {
let (kind, category) = match &view.source {
ViewSource::WatchLater(category) => (CollectionKind::WatchLater, category),
ViewSource::SavedVideos(category) => (CollectionKind::Saved, category),
ViewSource::Watching => (CollectionKind::Watching, &None),
_ => continue,
};
view.title = if matches!(kind, CollectionKind::WatchLater) {
format!(" {} · {} ", view.name, view.watch_filter.label())
} else {
format!(" {} ", view.name)
};
let selected = view.selection.selected();
let selected_id = selected
.and_then(|row| view.videos.get(row))
.map(|video| video.id.clone());
view.videos = self
.video_state
.videos(kind)
.iter()
.filter(|video| {
youtube::valid_video_id(&video.id)
&& (!matches!(kind, CollectionKind::WatchLater)
|| view
.watch_filter
.includes(self.video_state.is_watched(&video.id)))
&& category.as_ref().is_none_or(|name| {
self.config.categories.get(name).is_some_and(|channels| {
video_in_channels(video, channels, &self.config)
})
})
})
.cloned()
.collect();
view.prune_checked();
let selection = selected_id
.as_ref()
.and_then(|id| view.videos.iter().position(|video| &video.id == id))
.or_else(|| {
(!view.videos.is_empty())
.then(|| selected.unwrap_or(0).min(view.videos.len() - 1))
});
view.selection.select(selection);
view.loaded = true;
view.has_more = false;
view.page_error = None;
if index == self.view_index {
active_changed = selected_id.as_deref()
!= selection
.and_then(|row| view.videos.get(row))
.map(|video| video.id.as_str());
}
}
if active_changed {
self.close_preview();
}
}
fn browse_collection(&mut self, kind: CollectionKind) {
self.browse_collection_scope(kind, None);
}
fn browse_category_collection(&mut self, kind: CollectionKind) {
let category = match &self.views[self.view_index].source {
ViewSource::Category(name)
| ViewSource::WatchLater(Some(name))
| ViewSource::SavedVideos(Some(name)) => name.clone(),
_ => {
self.status = "Open a category view first.".into();
return;
}
};
self.browse_collection_scope(kind, Some(category));
}
fn browse_collection_scope(&mut self, kind: CollectionKind, category: Option<String>) {
let label = match kind {
CollectionKind::WatchLater => "Watch later",
CollectionKind::Saved => "Saved videos",
CollectionKind::Watching => "Currently watching",
};
let name = category
.as_ref()
.map_or_else(|| label.into(), |name| format!("{name} · {label}"));
let source = match kind {
CollectionKind::WatchLater => ViewSource::WatchLater(category),
CollectionKind::Saved => ViewSource::SavedVideos(category),
CollectionKind::Watching => ViewSource::Watching,
};
self.browse_view(name, source);
}
fn save_collection(&mut self, kind: CollectionKind) {
if matches!(kind, CollectionKind::Watching) {
return;
}
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|row| view.videos.get(row))
else {
self.status = "Select a video first.".into();
return;
};
let already_saved = self.video_state.contains(kind, &video.id);
match self.video_state.save(kind, video) {
Ok(()) => {
self.rebuild_collections();
self.status = match (kind, already_saved) {
(CollectionKind::WatchLater, true) => "Video is already in watch later.",
(CollectionKind::WatchLater, false) => "Video saved to watch later.",
(CollectionKind::Saved, true) => "Video is already in saved videos.",
(CollectionKind::Saved, false) => "Video saved to saved videos.",
(CollectionKind::Watching, _) => {
"Currently watching is populated by playback acknowledgements."
}
}
.into();
}
Err(error) => self.status = error.to_string(),
}
}
fn set_watch_status(&mut self, status: WatchStatus) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|row| view.videos.get(row))
else {
self.status = "Select a video first.".into();
return;
};
let result = self.video_state.set_watch_status(video, status);
self.rebuild_collections();
self.status = match result {
Ok(()) => match status {
WatchStatus::Watching => "Video marked as watching.".into(),
WatchStatus::Watched => "Video marked as watched.".into(),
},
Err(error) => error.to_string(),
};
}
fn filter_watch_later(&mut self) {
let view = &mut self.views[self.view_index];
if !matches!(view.source, ViewSource::WatchLater(_)) {
return;
}
view.watch_filter = view.watch_filter.next();
self.rebuild_collections();
self.status = self.view_status();
}
fn current_collection(&self) -> Option<CollectionKind> {
match self.views[self.view_index].source {
ViewSource::WatchLater(_) => Some(CollectionKind::WatchLater),
ViewSource::SavedVideos(_) => Some(CollectionKind::Saved),
ViewSource::Watching => Some(CollectionKind::Watching),
_ => None,
}
}
fn remove_collection(&mut self) {
let Some(kind) = self.current_collection() else {
return;
};
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|row| view.videos.get(row))
else {
self.status = "Select a video first.".into();
return;
};
if !self.video_state.contains(kind, &video.id) {
self.status = match kind {
CollectionKind::WatchLater => "Video is not in watch later.",
CollectionKind::Saved => "Video is not in saved videos.",
CollectionKind::Watching => "Video is not in currently watching.",
}
.into();
return;
}
let id = video.id.clone();
match self.video_state.remove(kind, &[id]) {
Ok(()) => {
self.rebuild_collections();
self.status = match kind {
CollectionKind::WatchLater => "Video removed from watch later.",
CollectionKind::Saved => "Video removed from saved videos.",
CollectionKind::Watching => "Video removed from currently watching.",
}
.into();
}
Err(error) => self.status = error.to_string(),
}
}
fn remove_collection_bulk(&mut self) {
let Some(kind) = self.current_collection() else {
return;
};
let view = &self.views[self.view_index];
let category = match &view.source {
ViewSource::WatchLater(category) | ViewSource::SavedVideos(category) => category,
ViewSource::Watching => &None,
_ => unreachable!("current collection has a collection source"),
};
if view.videos.is_empty() {
return;
}
let mut removal = CollectionRemoval {
kind,
ids: view.videos.iter().map(|video| video.id.clone()).collect(),
category: category.clone(),
filter: matches!(kind, CollectionKind::WatchLater).then_some(view.watch_filter),
text: String::new(),
};
let collection = match removal.kind {
CollectionKind::WatchLater => "Watch later",
CollectionKind::Saved => "Saved videos",
CollectionKind::Watching => "Currently watching",
};
let (filter_prefix, filter_label) = removal
.filter
.map_or(("", ""), |filter| ("\nFilter: ", filter.label()));
removal.text = format!(
"Remove {} videos?\n\nCollection: {collection}\nCategory: {}{filter_prefix}{filter_label}\n\nOnly these videos leave this collection; other collections and watched state are unchanged.\n\ny confirms · n/Esc cancels",
removal.ids.len(),
removal.category.as_deref().unwrap_or("All categories"),
);
self.collection_removal = Some(removal);
self.close_preview();
}
fn apply_config(&mut self, mut config: config::Config) {
for channels in config.categories.values_mut() {
channels.sort_unstable();
}
if config.keys != self.config.keys {
self.key_hints = KeyHints::new(&config.keys);
if let Some(help) = &mut self.help_popup {
help.paragraph = Paragraph::new(config.keys.help_text()).wrap(Wrap { trim: false });
help.viewport = None;
}
}
if config.categories == self.config.categories
&& config.channels == self.config.channels
&& config.pinned_categories == self.config.pinned_categories
{
self.config.keys = config.keys;
return;
}
let active_source = self.views[self.view_index].source.clone();
let mut active_reset = false;
let feed_channels = config.subscriptions();
let mut old = std::mem::take(&mut self.views).into_iter();
self.views.push(old.next().expect("search view exists"));
let mut feed = old.next().expect("feed view exists");
if feed_channels != self.feed_channels {
feed.reset();
active_reset = active_source == ViewSource::Feed;
}
self.views.push(feed);
let mut old: Vec<_> = old.collect();
// ponytail: category lookup is quadratic; use a keyed map if hundreds of categories matter.
for name in config.categories.keys() {
let mut view = if let Some(index) = old.iter().position(
|view| matches!(&view.source, ViewSource::Category(category) if category == name),
) {
old.swap_remove(index)
} else {
View::new(name.clone(), ViewSource::Category(name.clone()))
};
if config.categories.get(name) != self.config.categories.get(name) {
view.reset();
active_reset |= view.source == active_source;
}
self.views.push(view);
}
let saved_channels = config.saved_channels();
for (label, identity) in &saved_channels {
if let Some(index) = old.iter().position(
|view| matches!(&view.source, ViewSource::Channel(channel) if channel == identity),
) {
let mut view = old.swap_remove(index);
if &view.name != label {
view.title = format!(" {label} ");
view.name = label.clone();
}
self.views.push(view);
}
}
// Unsaved channels opened from video rows remain browseable without subscribing.
self.views.extend(old.into_iter().filter_map(|mut view| {
if let ViewSource::CategoryGroup(names) = &view.source {
let channels = category_union(names, &config)?;
if channels != view.group_channels {
active_reset |= view.source == active_source;
view.reset();
view.group_channels = channels;
}
return Some(view);
}
(matches!(&view.source, ViewSource::Channel(identity) if !self.feed_channels.contains(identity))
|| matches!(&view.source, ViewSource::Watching)
|| matches!(&view.source, ViewSource::WatchLater(category) | ViewSource::SavedVideos(category) if category.as_ref().is_none_or(|name| config.categories.contains_key(name))))
.then_some(view)
}));
self.view_index = self
.views
.iter()
.position(|view| view.source == active_source)
.unwrap_or(0);
self.feed_channels = feed_channels;
self.config = config;
self.rebuild_collections();
if self.temporary_view.as_ref().is_some_and(|source| {
!self.views.iter().any(|view| &view.source == source)
|| matches!(source, ViewSource::Category(name) if self.config.pinned_categories.contains(name))
}) {
self.temporary_view = None;
}
if matches!(
&self.views[self.view_index].source,
ViewSource::CategoryGroup(_)
| ViewSource::WatchLater(_)
| ViewSource::SavedVideos(_)
| ViewSource::Watching
) || matches!(&self.views[self.view_index].source, ViewSource::Category(name) if !self.config.pinned_categories.contains(name))
{
self.temporary_view = Some(self.views[self.view_index].source.clone());
}
if active_reset || self.views[self.view_index].source != active_source {
self.job = None;
}
self.close_preview();
}
fn refresh(&mut self) {
self.close_preview();
match config::load(&self.config_path) {
Ok(config) => {
self.thumbnail.clear_cache();
self.descriptions.clear();
self.apply_config(config);
self.load_view();
}
Err(error) => self.status = error.to_string(),
}
}
fn open_category_rename(&mut self) {
let name = if let Some(dialog) = &self.category_dialog {
dialog
.selection
.selected()
.and_then(|row| dialog.matches.get(row))
.and_then(|index| dialog.tags.keys().nth(*index))
.cloned()
} else if let Some(browser) = &self.browser {
browser
.selection
.selected()
.and_then(|row| browser.matches.get(row))
.and_then(|index| browser.choices.get(*index))
.and_then(|(_, source)| source.category())
.map(str::to_owned)
} else {
self.views[self.view_index]
.source
.category()
.map(str::to_owned)
};
if let Some(name) = name {
self.close_preview();
self.category_name_input = Some(CategoryNamePrompt {
target: CategoryNameTarget::Rename {
persisted: self.config.categories.contains_key(&name),
old: name.clone(),
},
name,
error: None,
});
} else {
self.status = "Select a category to rename.".into();
}
}
fn open_category_save(&mut self) {
let view = &self.views[self.view_index];
if !matches!(view.source, ViewSource::CategoryGroup(_)) {
return;
}
self.category_name_input = Some(CategoryNamePrompt {
target: CategoryNameTarget::SaveGroup {
channels: view.group_channels.clone(),
},
name: String::new(),
error: None,
});
self.close_preview();
}
fn category_name_key(&mut self, key: KeyEvent, action: Option<Action>) {
let mut input = self
.category_name_input
.take()
.expect("category name input is open");
match action {
Some(Action::InputCancel) => return,
Some(Action::InputSubmit) => {
let result = match &input.target {
CategoryNameTarget::Rename { old, persisted } => {
self.commit_category_rename(old, *persisted, &input.name)
}
CategoryNameTarget::SaveGroup { channels } => {
self.commit_category_save(&input.name, channels)
}
};
match result {
Ok(()) => return,
Err(error) => input.error = Some(error.to_string()),
}
}
_ => edit_input(&mut input.name, key, action),
}
self.category_name_input = Some(input);
}
fn commit_category_rename(&mut self, old: &str, persisted: bool, name: &str) -> io::Result<()> {
let new = name.trim();
let invalid = |message: &str| io::Error::new(io::ErrorKind::InvalidInput, message);
if new.is_empty() || name.chars().any(char::is_control) {
return Err(invalid(
"Enter a nonempty category name without control characters.",
));
}
if new != old
&& self
.category_dialog
.as_ref()
.is_some_and(|dialog| dialog.tags.contains_key(new))
{
return Err(invalid("That tag already exists."));
}
let disk = config::load(&self.config_path)?;
if persisted {
if disk
.categories
.get(old)
.is_none_or(|channels| Some(channels) != self.config.categories.get(old))
{
return Err(invalid(
"This category changed or disappeared on disk. Close dialogs and reload config before renaming.",
));
}
let config = config::rename_category(&self.config_path, old, new)?;
self.migrate_category_name(old, new);
self.apply_config(config);
} else {
if disk.categories.contains_key(old) || disk.categories.contains_key(new) {
return Err(invalid(
"That tag now exists on disk. Close dialogs and reload config before renaming.",
));
}
self.migrate_staged_category_name(old, new);
}
if let Some(browser) = self.browser.take() {
self.open_browser(browser.kind);
let browser_new = self.browser.as_mut().expect("browser reopened");
browser_new.checked = browser.checked;
browser_new.mode = browser.mode;
browser_new.query = browser.query;
if !new
.to_lowercase()
.contains(&browser_new.query.to_lowercase())
{
browser_new.query.clear();
}
browser_new.refilter();
browser_new.selection.select(
browser_new
.choices
.iter()
.position(|(_, source)| source.category() == Some(new))
.and_then(|index| browser_new.matches.binary_search(&index).ok()),
);
}
self.status = if persisted {
if self.category_dialog.is_some() {
format!("Renamed {old} to {new}. Tag membership changes remain staged.")
} else {
format!("Renamed {old} to {new}.")
}
} else {
format!("Renamed staged tag {old} to {new}; save tags to persist it.")
};
Ok(())
}
fn commit_category_save(&mut self, name: &str, channels: &[String]) -> io::Result<()> {
let config = config::create_category(&self.config_path, name, channels)?;
let name = name.trim().to_owned();
let source = &self.views[self.view_index];
let mut cached = View::new(name.clone(), ViewSource::Category(name.clone()));
cached.query = source.query.clone();
cached.submitted_query = source.submitted_query.clone();
// Keep the viewed snapshot even if its source categories changed on disk.
let matching = config.categories[&name] == channels;
if matching {
cached.videos = source.videos.clone();
cached.selection = source.selection;
cached.loaded = source.loaded;
cached.next_start = source.next_start;
cached.has_more = source.has_more;
cached.feed_cursor = source.feed_cursor.clone();
cached.feed_requests = source.feed_requests.clone();
cached.prefetch_requested = source.prefetch_requested;
cached.page_error = source.page_error.clone();
}
let job = if matching { self.job.take() } else { None };
self.apply_config(config);
let index = self
.views
.iter()
.position(|view| view.source == cached.source)
.expect("new category has a view");
self.views[index] = cached;
self.forward_history.clear();
self.view_history
.push(self.views[self.view_index].source.clone());
self.view_index = index;
self.job = job;
self.activate_view(index);
self.status = format!("Saved combined view as {name}.");
Ok(())
}
fn migrate_staged_category_name(&mut self, old: &str, new: &str) {
let Some(dialog) = &mut self.category_dialog else {
return;
};
if let Some(checked) = dialog.tags.remove(old) {
dialog.tags.insert(new.to_owned(), checked);
}
if let CategoryTarget::Category { name, channels } = &mut dialog.target
&& name.as_str() == old
{
*name = new.to_owned();
dialog.label = format!(" {new} · {} channels ", channels.len());
}
dialog.search_names = dialog.tags.keys().map(|tag| tag.to_lowercase()).collect();
if !new.to_lowercase().contains(&dialog.query.to_lowercase()) {
dialog.query.clear();
}
dialog.refilter();
dialog.selection.select(
dialog
.tags
.keys()
.position(|tag| tag == new)
.and_then(|index| dialog.matches.binary_search(&index).ok()),
);
}
fn migrate_category_name(&mut self, old: &str, new: &str) {
if old == new {
return;
}
// Migrate the comparison baseline too: apply_config must not treat a rename as
// deleting one cache and creating another, or cancel an unchanged feed job.
if let Some(channels) = self.config.categories.remove(old) {
self.config.categories.insert(new.to_owned(), channels);
}
for name in &mut self.config.pinned_categories {
if name.as_str() == old {
*name = new.to_owned();
}
}
for view in &mut self.views {
if view.source.rename_category(old, new) {
view.name = match &view.source {
ViewSource::CategoryGroup(names) => group_name(names),
ViewSource::WatchLater(_) => format!("{new} · Watch later"),
ViewSource::SavedVideos(_) => format!("{new} · Saved videos"),
_ => new.to_owned(),
};
view.title = format!(" {} ", view.name);
}
}
for source in self
.view_history
.iter_mut()
.chain(&mut self.forward_history)
.chain(self.temporary_view.iter_mut())
{
source.rename_category(old, new);
}
if let Some(browser) = &mut self.browser
&& browser.checked.remove(old)
{
browser.checked.insert(new.to_owned());
}
let active = self.views[self.view_index].source.clone();
let category_end = self
.views
.iter()
.position(|view| {
matches!(
view.source,
ViewSource::CategoryGroup(_)
| ViewSource::Channel(_)
| ViewSource::WatchLater(_)
| ViewSource::SavedVideos(_)
| ViewSource::Watching
)
})
.unwrap_or(self.views.len());
self.views[2..category_end].sort_unstable_by(|a, b| a.name.cmp(&b.name));
self.view_index = self
.views
.iter()
.position(|view| view.source == active)
.expect("active view retained");
self.migrate_staged_category_name(old, new);
}
fn open_category_picker(&mut self) {
self.close_preview();
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
self.status = "Select a video first.".into();
return;
};
let Some(id) = video.channel_id.as_deref() else {
self.status = "This video has no available channel identity.".into();
return;
};
let id = id.to_owned();
let name = video.channel.as_deref().unwrap_or(&id).to_owned();
let channel = format!("https://www.youtube.com/channel/{id}");
let handle = video
.uploader_id
.as_deref()
.filter(|handle| handle.starts_with('@'))
.map(str::to_owned)
.or_else(|| {
self.config
.channels
.iter()
.find_map(|(handle, identity)| (identity == &channel).then(|| handle.clone()))
});
if let Some(handle) = handle {
self.show_channel_tags(channel, handle, name);
} else {
match Job::channel(&id) {
Ok(job) => {
self.channel_job = Some((id, name, job));
self.status = "Looking up channel handle...".into();
}
Err(error) => self.status = error.to_string(),
}
}
}
fn show_channel_tags(&mut self, channel: String, handle: String, name: String) {
let label = format!(" {name} ({handle}) ");
match config::load(&self.config_path) {
Ok(config) => self.apply_config(config),
Err(error) => {
self.status = error.to_string();
return;
}
}
let handle_url = format!("https://www.youtube.com/{handle}");
let tags = self
.config
.categories
.iter()
.map(|(name, channels)| {
(
name.clone(),
channels
.iter()
.any(|url| url == &channel || url == &handle_url),
)
})
.collect();
self.category_dialog = Some(CategoryDialog::new(
CategoryTarget::Channel { handle, channel },
label,
tags,
));
}
fn open_bulk_category_picker(&mut self) {
let ViewSource::Category(name) = &self.views[self.view_index].source else {
self.status = "Browse a category first to edit all its channels.".into();
return;
};
let name = name.clone();
let config = match config::load(&self.config_path) {
Ok(config) => config,
Err(error) => {
self.status = error.to_string();
return;
}
};
let channels = config.categories.get(&name).cloned().unwrap_or_default();
self.apply_config(config);
if channels.is_empty() {
self.status = format!("{name} has no saved channels to edit.");
return;
}
let tags = self
.config
.categories
.iter()
.map(|(tag, members)| {
(
tag.clone(),
channels.iter().all(|channel| members.contains(channel)),
)
})
.collect();
self.close_preview();
let mut dialog = CategoryDialog::new(
CategoryTarget::Category {
name: name.clone(),
channels,
},
format!(
" {name} · {} channels ",
self.config.categories[&name].len()
),
tags,
);
dialog
.selection
.select(self.config.categories.keys().position(|tag| tag == &name));
self.category_dialog = Some(dialog);
}
fn category_key(&mut self, key: KeyEvent, action: Option<Action>) {
let mut dialog = self
.category_dialog
.take()
.expect("category dialog is open");
if let Some(name) = &mut dialog.name {
match action {
Some(Action::InputCancel) => {
dialog.name = None;
dialog.error = None;
}
Some(Action::InputSubmit) => {
let name = name.trim().to_owned();
if name.is_empty() || name.chars().any(char::is_control) {
dialog.error =
Some("Enter a nonempty tag name without control characters.".into());
} else if dialog.tags.contains_key(&name) {
dialog.error = Some("That tag already exists.".into());
} else {
dialog.tags.insert(name.clone(), true);
dialog.search_names =
dialog.tags.keys().map(|tag| tag.to_lowercase()).collect();
dialog.query.clear();
dialog.refilter();
dialog
.selection
.select(dialog.tags.keys().position(|tag| tag == &name));
dialog.name = None;
dialog.error = None;
}
}
_ => edit_input(name, key, action),
}
} else {
let action = if dialog.mode == Mode::Insert {
match action {
Some(Action::InputCancel | Action::InputSubmit) => {
dialog.mode = Mode::Normal;
self.category_dialog = Some(dialog);
return;
}
Some(Action::TagInputNext) => Some(Action::TagNext),
Some(Action::TagInputPrevious) => Some(Action::TagPrevious),
_ => {
edit_input(&mut dialog.query, key, action);
dialog.refilter();
self.category_dialog = Some(dialog);
return;
}
}
} else {
action
};
let count = dialog.matches.len();
match action {
Some(Action::TagEdit) => dialog.mode = Mode::Insert,
Some(Action::TagCancel) => {
self.switch_view(self.view_index);
return;
}
Some(Action::TagNew) => {
dialog.name = Some(String::new());
dialog.error = None;
}
Some(Action::TagToggle) => {
if let Some((_, checked)) = dialog
.selection
.selected()
.and_then(|row| dialog.matches.get(row))
.and_then(|index| dialog.tags.iter_mut().nth(*index))
{
*checked = !*checked;
dialog.error = None;
}
}
Some(Action::TagNext) if count != 0 => {
dialog.selection.select(Some(
dialog
.selection
.selected()
.unwrap_or(0)
.saturating_add(1)
.min(count - 1),
));
}
Some(Action::TagPrevious) if count != 0 => {
dialog.selection.select(Some(
dialog.selection.selected().unwrap_or(0).saturating_sub(1),
));
}
Some(Action::TagSave) => {
let result = match &dialog.target {
CategoryTarget::Channel { handle, channel } => config::set_channel_tags(
&self.config_path,
&dialog.tags,
handle,
channel,
),
CategoryTarget::Category { name, channels } => config::set_category_tags(
&self.config_path,
name,
channels,
&dialog.tags,
),
};
match result {
Ok(config) => {
self.apply_config(config);
self.switch_view(self.view_index);
self.status = match &dialog.target {
CategoryTarget::Channel { .. } => "Saved channel tags.".into(),
CategoryTarget::Category { channels, .. } => {
format!("Saved tags for {} channels.", channels.len())
}
};
return;
}
Err(error) => dialog.error = Some(error.to_string()),
}
}
_ => {}
}
}
self.category_dialog = Some(dialog);
}
fn close_preview(&mut self) {
if self.preview.take().is_some() {
self.thumbnail.cancel();
}
self.description_job = None;
self.description_viewport = None;
}
fn load_description(&mut self) {
self.description_job = None;
self.description_error = None;
self.description_scroll = 0;
self.description_max_scroll = 0;
self.description_viewport = None;
let view = &self.views[self.view_index];
let Some(index) = view
.selection
.selected()
.filter(|&index| index < view.videos.len())
else {
return;
};
let video = &view.videos[index];
if let Some(index) = self.descriptions.iter().position(|(id, _)| id == &video.id) {
let hit = self
.descriptions
.remove(index)
.expect("cache position exists");
self.descriptions.push_back(hit);
return;
}
match Job::description(&video.id) {
Ok(job) => self.description_job = Some((video.id.clone(), job)),
Err(error) => self.description_error = Some(error.to_string()),
}
}
fn remember_description(&mut self, id: String, description: String) {
if let Some(index) = self
.descriptions
.iter()
.position(|(cached, _)| cached == &id)
{
self.descriptions.remove(index);
}
if self.descriptions.len() == DESCRIPTION_CACHE_SIZE {
self.descriptions.pop_front();
}
self.descriptions.push_back((id, description));
}
fn play(&mut self) {
self.launch(false);
}
fn launch(&mut self, enqueue: bool) {
let view = &mut self.views[self.view_index];
view.prune_checked();
let videos = view.playback_videos();
if videos.is_empty() {
return;
}
self.player_launch = None;
match PlayerLaunch::start(
&videos,
&self.options.player,
&mut self.video_state,
enqueue,
) {
Ok(launch) => {
view.checked.clear();
let title = if videos.len() == 1 {
videos[0].title.clone()
} else {
format!("{} videos", videos.len())
};
self.status = format!(
"Handing {title} to {}...",
self.options.player.to_string_lossy()
);
self.player_launch = Some(launch);
}
Err(error) => self.status = error.to_string(),
}
}
fn open_video_in_browser(&mut self) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
return;
};
match youtube::open_in_browser(&video.id) {
Ok(launch) => {
self.browser_launch = Some(launch);
self.status = format!("Opening {} in the default browser...", video.title);
}
Err(error) => self.status = error.to_string(),
}
}
fn finish_description(&mut self, id: String, result: Result<String, String>) {
match result.and_then(|output| youtube::full_description(&output, &id)) {
Ok(description) => self.remember_description(id, description),
Err(error) => self.description_error = Some(error),
}
self.description_viewport = None;
}
fn update(&mut self) {
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.finish_page(result);
}
if let Some(result) = self.channel_job.as_mut().and_then(|(_, _, job)| job.poll()) {
let (id, name, _) = self.channel_job.take().expect("channel lookup is active");
match result.and_then(|output| youtube::channel_handle(&output, &id)) {
Ok(handle) => {
for video in self.views.iter_mut().flat_map(|view| &mut view.videos) {
if video.channel_id.as_deref() == Some(&id) {
video.uploader_id = Some(handle.clone());
}
}
self.show_channel_tags(
format!("https://www.youtube.com/channel/{id}"),
handle,
name,
);
}
Err(error) => self.status = error,
}
}
if let Some(result) = self
.description_job
.as_mut()
.and_then(|(_, job)| job.poll())
{
let (id, _) = self
.description_job
.take()
.expect("completed description job exists");
self.finish_description(id, result);
}
if let Some(result) = self.player_launch.as_mut().and_then(PlayerLaunch::poll) {
self.player_launch = None;
self.status = match result {
Ok(()) => "Video handed to player; playback continues in its window.".into(),
Err(error) => error,
};
}
if let Some(result) = self
.browser_launch
.as_ref()
.and_then(|launch| launch.try_recv().ok())
{
self.browser_launch = None;
self.status = match result {
Ok(()) => "Video handed to the default browser.".into(),
Err(error) => error,
};
}
match self.video_state.poll_progress() {
Ok(true) => {
self.rebuild_collections();
if matches!(
self.views[self.view_index].source,
ViewSource::WatchLater(_) | ViewSource::Watching
) {
self.status = self.view_status();
}
}
Ok(false) => {}
Err(error) => self.status = error.to_string(),
}
if self.can_prefetch() {
self.load_page();
}
}
fn key_context(&self) -> Context {
if self.help_popup.is_some() {
Context::Help
} else if self.category_name_input.is_some() {
Context::CategoryNameInput
} else if self.channel_job.is_some() {
Context::TagList
} else if let Some(dialog) = &self.category_dialog {
if dialog.name.is_some() {
Context::TagInput
} else if dialog.mode == Mode::Insert {
Context::TagFilter
} else {
Context::TagList
}
} else if let Some(browser) = &self.browser {
if browser.mode == Mode::Insert {
Context::SelectorInput
} else {
Context::SelectorNormal
}
} else if self.mode == Mode::Insert {
Context::SearchInput
} else {
match (self.current_collection(), self.preview) {
(Some(_), Some(PreviewMode::Info)) => Context::CollectionInfo,
(Some(_), Some(PreviewMode::Description)) => Context::CollectionDescription,
(Some(_), None) => Context::CollectionNormal,
(None, Some(PreviewMode::Info)) => Context::Info,
(None, Some(PreviewMode::Description)) => Context::Description,
(None, None) => Context::Normal,
}
}
}
fn paste(&mut self, text: &str) {
if self.collection_removal.is_some() {
return;
}
match self.key_context() {
Context::SearchInput => {
let index = self.query_view_index();
paste_input(&mut self.views[index].query, text);
}
Context::CategoryNameInput => {
paste_input(
&mut self
.category_name_input
.as_mut()
.expect("category name input is active")
.name,
text,
);
}
Context::SelectorInput => {
let browser = self.browser.as_mut().expect("selector input is active");
paste_input(&mut browser.query, text);
browser.refilter();
}
Context::TagFilter => {
let dialog = self.category_dialog.as_mut().expect("tag filter is active");
paste_input(&mut dialog.query, text);
dialog.refilter();
}
Context::TagInput => {
let name = self
.category_dialog
.as_mut()
.and_then(|dialog| dialog.name.as_mut())
.expect("new-tag input is active");
paste_input(name, text);
}
_ => {}
}
}
fn key(&mut self, key: KeyEvent) -> bool {
let action = self.config.keys.resolve(self.key_context(), key);
if action == Some(Action::Interrupt) {
return true;
}
if self.channel_job.is_some() {
if action == Some(Action::TagCancel) {
self.channel_job = None;
self.status = "Channel lookup cancelled.".into();
}
return false;
}
if self.collection_removal.is_some() {
match key.code {
KeyCode::Char('y') if key.modifiers.is_empty() => {
let removal = self.collection_removal.take().expect("removal is active");
match self.video_state.remove(removal.kind, &removal.ids) {
Ok(()) => {
self.rebuild_collections();
let collection = match removal.kind {
CollectionKind::WatchLater => "watch-later",
CollectionKind::Saved => "saved",
CollectionKind::Watching => "currently-watching",
};
self.status =
format!("Removed {} {collection} videos.", removal.ids.len());
}
Err(error) => self.status = error.to_string(),
}
}
KeyCode::Char('n') | KeyCode::Esc => {
self.collection_removal = None;
self.status = "Collection removal cancelled.".into();
}
_ => {}
}
return false;
}
if let Some(help) = &mut self.help_popup {
match action {
Some(Action::HelpClose | Action::Help) => self.help_popup = None,
Some(Action::Quit) => return true,
Some(Action::HelpNext) => {
help.scroll = help.scroll.saturating_add(1).min(help.max_scroll);
}
Some(Action::HelpPrevious) => help.scroll = help.scroll.saturating_sub(1),
Some(Action::HelpPageNext) => {
let page = help.viewport.map_or(1, |(_, height)| height.max(1));
help.scroll = help.scroll.saturating_add(page).min(help.max_scroll);
}
Some(Action::HelpPagePrevious) => {
let page = help.viewport.map_or(1, |(_, height)| height.max(1));
help.scroll = help.scroll.saturating_sub(page);
}
_ => {}
}
return false;
}
if action == Some(Action::Help) {
self.help_popup = Some(HelpPopup {
paragraph: Paragraph::new(self.config.keys.help_text()).wrap(Wrap { trim: false }),
..Default::default()
});
return false;
}
if self.category_name_input.is_some() {
self.category_name_key(key, action);
return false;
}
if action == Some(Action::CategoryRename) {
self.open_category_rename();
return false;
}
if self.category_dialog.is_some() {
self.category_key(key, action);
return false;
}
if self.browser.is_some() {
self.browse_key(key, action);
return false;
}
let selected_before = self.views[self.view_index].selection.selected();
let preview_was_open = self.preview.is_some();
if self.mode == Mode::Insert {
match action {
Some(Action::InputCancel) => self.mode = Mode::Normal,
Some(Action::InputSubmit) => {
self.search();
self.mode = Mode::Normal;
}
_ => {
let index = self.query_view_index();
edit_input(&mut self.views[index].query, key, action);
}
}
} else {
match action {
Some(Action::Quit) => return true,
Some(Action::SearchEdit) => {
self.close_preview();
self.mode = Mode::Insert;
}
Some(action @ (Action::InfoOverview | Action::InfoDescription)) => {
self.preview = self.views[self.view_index]
.selection
.selected()
.filter(|&index| index < self.views[self.view_index].videos.len())
.map(|_| {
if action == Action::InfoOverview {
PreviewMode::Info
} else {
PreviewMode::Description
}
});
}
Some(Action::InfoHide) => self.close_preview(),
Some(Action::ViewPrevious) => self.back_view(),
Some(Action::ViewNext) => self.forward_view(),
Some(Action::ViewSearch) => self.switch_view(0),
Some(Action::ViewFeed) => self.switch_view(1),
Some(Action::DescriptionNext) => {
self.description_scroll = self
.description_scroll
.saturating_add(1)
.min(self.description_max_scroll);
}
Some(Action::DescriptionPrevious) => {
self.description_scroll = self.description_scroll.saturating_sub(1);
}
Some(Action::DescriptionPageNext) => {
let page = self
.description_viewport
.map(|(_, height)| height)
.unwrap_or(1)
.max(1);
self.description_scroll = self
.description_scroll
.saturating_add(page)
.min(self.description_max_scroll);
}
Some(Action::DescriptionPagePrevious) => {
let page = self
.description_viewport
.map(|(_, height)| height)
.unwrap_or(1)
.max(1);
self.description_scroll = self.description_scroll.saturating_sub(page);
}
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels),
Some(Action::VideoChannel) => self.open_video_channel(),
Some(Action::ChannelTags) => self.open_category_picker(),
Some(Action::CategoryTags) => self.open_bulk_category_picker(),
Some(Action::CategorySave) => self.open_category_save(),
Some(Action::ViewRefresh) => self.refresh(),
Some(Action::ViewTabNext) => self.cycle_view(false),
Some(Action::ViewTabPrevious) => self.cycle_view(true),
Some(Action::WatchLaterSave) => self.save_collection(CollectionKind::WatchLater),
Some(Action::WatchLaterBrowse) => self.open_browser(BrowserKind::WatchLater),
Some(Action::WatchLaterBrowseCategory) => {
self.browse_category_collection(CollectionKind::WatchLater)
}
Some(Action::WatchLaterFilter) => self.filter_watch_later(),
Some(Action::CollectionRemoveBulk) => self.remove_collection_bulk(),
Some(Action::CollectionRemoveSelected) => self.remove_collection(),
Some(Action::SavedVideoSave) => self.save_collection(CollectionKind::Saved),
Some(Action::SavedVideoBrowse) => self.open_browser(BrowserKind::SavedVideos),
Some(Action::WatchingBrowse) => self.browse_collection(CollectionKind::Watching),
Some(Action::WatchStatusWatching) => self.set_watch_status(WatchStatus::Watching),
Some(Action::WatchStatusWatched) => self.set_watch_status(WatchStatus::Watched),
Some(Action::SavedVideoBrowseCategory) => {
self.browse_category_collection(CollectionKind::Saved)
}
Some(Action::VideoPlay) => self.play(),
Some(Action::VideoEnqueue) => self.launch(true),
Some(Action::VideoToggle) => self.views[self.view_index].toggle_checked(),
Some(Action::VideoClear) => self.views[self.view_index].checked.clear(),
Some(Action::VideoOpen) => self.open_video_in_browser(),
Some(Action::VideoCopyUrl) => {
let view = &self.views[self.view_index];
if let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
{
self.status = ui::copy_video_url(&video.id);
}
}
Some(Action::VideoNext) => {
self.views[self.view_index].prefetch_requested = true;
self.views[self.view_index].selection.select_next();
}
Some(Action::VideoPrevious) => {
self.views[self.view_index].selection.select_previous()
}
Some(Action::VideoFirst) => self.views[self.view_index].selection.select_first(),
Some(Action::VideoLast) => {
self.views[self.view_index].prefetch_requested = true;
self.views[self.view_index].selection.select_last();
}
_ => {}
}
}
let view = &mut self.views[self.view_index];
if view.videos.is_empty() {
view.selection.select(None);
} else if let Some(index) = view.selection.selected() {
view.selection
.select(Some(index.min(view.videos.len() - 1)));
}
if self.preview.is_none() {
self.description_job = None;
} else if !preview_was_open
|| self.views[self.view_index].selection.selected() != selected_before
{
self.load_description();
}
if self.preview.is_some()
&& (!preview_was_open
|| self.views[self.view_index].selection.selected() != selected_before)
&& let Some(video) = self.views[self.view_index]
.selection
.selected()
.and_then(|index| self.views[self.view_index].videos.get(index))
{
self.thumbnail.select(&video.id);
}
false
}
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
self.draw_at(frame, elapsed, Instant::now());
}
fn draw_at(&mut self, frame: &mut Frame, elapsed: Duration, now: Instant) {
let spinner = ui::spinner(elapsed);
let area = frame.area();
let mut tab_lines = vec![Line::default()];
let mut row_width = 0;
let mut selected_row = 0;
for (index, view) in self.visible_views() {
let mut title = Span::raw(view.name.as_str());
let pinned = matches!(&view.source, ViewSource::Category(name)
if self.config.pinned_categories.contains(name));
let title_width = title.width() + 2 + if pinned { 2 } else { 0 }; // Padding and pin marker.
if row_width != 0 && row_width + 1 + title_width > usize::from(area.width) {
tab_lines.push(Line::default());
row_width = 0;
}
if index == self.view_index {
title.style = Style::new().fg(Color::Cyan).bold();
selected_row = tab_lines.len() - 1;
}
let row = tab_lines.last_mut().expect("at least one tab row");
if row_width != 0 {
row.spans.push("│".into());
}
row.spans.extend([" ".into(), title]);
if pinned {
row.spans.push(Span::styled(" p", Style::new().dim()));
}
row.spans.push(" ".into());
row_width += usize::from(row_width != 0) + title_width;
}
// Leave space for search, a usable results pane, status, and help.
let tab_height = tab_lines
.len()
.min(usize::from(area.height.saturating_sub(10).max(1)))
as u16;
let first_row = selected_row.saturating_sub(usize::from(tab_height).saturating_sub(1));
let [tabs, search, results, status, help] = ui::browser_layout(area, tab_height);
frame.render_widget(
Paragraph::new(tab_lines).scroll((first_row.min(u16::MAX as usize) as u16, 0)),
tabs,
);
let query_view = &self.views[self.query_view_index()];
let search_title = match query_view.source {
ViewSource::Category(_) | ViewSource::CategoryGroup(_) => " tubular / Category search ",
_ => " tubular / YouTube search ",
};
ui::search(
frame,
search,
search_title,
query_view.query.as_str(),
self.mode == Mode::Insert && self.category_dialog.is_none(),
);
let view = &mut self.views[self.view_index];
let rows = usize::from(results.height.saturating_sub(2) / 2);
let mut offset = view
.selection
.offset()
.min(view.videos.len().saturating_sub(rows));
if let Some(selected) = view
.selection
.selected()
.filter(|&index| index < view.videos.len())
{
if selected < offset {
offset = selected;
} else if rows > 0 && selected >= offset + rows {
offset = selected + 1 - rows;
}
}
*view.selection.offset_mut() = offset;
self.titles
.update(&mut view.videos, offset..offset.saturating_add(rows), now);
let table = Table::new(
view.videos.iter().map(|video| {
let tagged = self
.config
.categories
.values()
.any(|channels| video_in_channels(video, channels, &self.config));
let title = Span::raw(self.titles.title(video));
let channel = Span::raw(video.channel.as_deref().unwrap_or("Unknown"));
Row::new([
Cell::from(if view.checked.contains(&video.id) {
"*"
} else if self.video_state.is_watched(&video.id) {
"✓"
} else {
""
}),
Cell::from(Text::from(vec![
Line::from(
if self.video_state.contains(CollectionKind::Saved, &video.id) {
title.fg(Color::Green)
} else if self
.video_state
.contains(CollectionKind::WatchLater, &video.id)
{
title.fg(Color::Magenta)
} else {
title
},
),
Line::from(vec![
if tagged {
channel.fg(Color::Cyan)
} else {
channel
},
" · ".into(),
video
.publication_date
.as_deref()
.unwrap_or("Unknown")
.into(),
" · ".into(),
video
.duration
.filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
.map(|seconds| {
let seconds = seconds as u64;
if seconds >= 3600 {
format!(
"{}:{:02}:{:02}",
seconds / 3600,
seconds / 60 % 60,
seconds % 60
)
} else {
format!("{}:{:02}", seconds / 60, seconds % 60)
}
})
.unwrap_or_else(|| "Unknown duration".into())
.into(),
])
.dim(),
])),
])
.height(2)
}),
[Constraint::Length(1), Constraint::Fill(1)],
)
.block(
Block::bordered()
.title(loading_title(
if view.source == ViewSource::Search {
" Results "
} else {
view.title.as_str()
},
match view.source {
ViewSource::WatchLater(_) => self.key_hints.watch_later.as_str(),
ViewSource::SavedVideos(_) => self.key_hints.saved_videos.as_str(),
ViewSource::Watching => self.key_hints.watching.as_str(),
ViewSource::CategoryGroup(_) => self.key_hints.group_save.as_str(),
_ => "",
},
self.job.is_some().then_some(spinner),
))
.title_bottom(
Line::from(if view.checked.is_empty() {
String::new()
} else {
format!(" {} checked ", view.checked.len())
})
.right_aligned(),
),
)
.row_highlight_style(ui::SELECTED)
.highlight_symbol(ui::SELECTION_MARKER);
frame.render_stateful_widget(table, results, &mut view.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
.block(Block::bordered().title(" Status ")),
status,
);
let help_text = if self.collection_removal.is_some() {
"REMOVE COLLECTION · y confirm · n/Esc cancel"
} else {
self.key_hints.footer(self.key_context())
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
if let Some(mode) = self.preview
&& let Some(video) = self.views[self.view_index]
.selection
.selected()
.and_then(|index| self.views[self.view_index].videos.get(index))
{
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered().border_style(ui::ACCENT);
let inner = block.inner(popup);
let cached = cached_description(&self.descriptions, &video.id);
let text = match cached {
Some("") => "This video has no description.",
Some(description) => description,
None => self
.description_error
.as_deref()
.unwrap_or("Loading full description..."),
};
let metadata = [
Line::raw(video.title.as_str()).bold(),
Line::default(),
Line::from(vec![
video.channel.as_deref().unwrap_or("Unknown").into(),
" · ".into(),
video
.publication_date
.as_deref()
.unwrap_or("Unknown")
.into(),
])
.dim(),
];
if mode == PreviewMode::Description {
let details = inner.inner(ratatui::layout::Margin::new(1, 0));
let mut content = 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 viewport = (details.width, details.height);
if self.description_viewport != Some(viewport) {
self.description_max_scroll = if details.is_empty() {
0
} else {
paragraph
.line_count(viewport.0)
.saturating_sub(viewport.1 as usize)
.min(u16::MAX as usize) as u16
};
self.description_viewport = Some(viewport);
}
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
frame.render_widget(paragraph.scroll((self.description_scroll, 0)), details);
} else {
ui::info_preview(
frame,
inner,
Some(&mut self.thumbnail),
Vec::from(metadata),
text,
);
}
frame.render_widget(
block.title(loading_title(
if mode == PreviewMode::Info {
" Info "
} else {
" Description "
},
self.key_hints.preview[usize::from(self.current_collection().is_some()) * 2
+ usize::from(mode == PreviewMode::Description)]
.as_str(),
(self.description_job.is_some()
|| (mode == PreviewMode::Info && self.thumbnail.is_loading()))
.then_some(spinner),
)),
popup,
);
}
self.draw_category_dialog(frame);
self.draw_browser(frame);
self.draw_category_name_input(frame);
self.draw_help(frame);
self.draw_collection_removal(frame);
}
fn draw_collection_removal(&self, frame: &mut Frame) {
let Some(removal) = &self.collection_removal else {
return;
};
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(removal.text.as_str())
.wrap(Wrap { trim: true })
.block(
Block::bordered()
.title(" Remove collection ")
.border_style(Style::new().fg(Color::Yellow)),
),
popup,
);
}
fn draw_help(&mut self, frame: &mut Frame) {
let Some(help) = &mut self.help_popup else {
return;
};
let popup = preview_area(frame.area());
let block = Block::bordered()
.title(self.key_hints.help.as_str())
.border_style(ui::ACCENT);
let inner = block.inner(popup).inner(ratatui::layout::Margin::new(1, 0));
let viewport = (inner.width, inner.height);
if help.viewport != Some(viewport) {
help.max_scroll = if inner.is_empty() {
0
} else {
help.paragraph
.line_count(inner.width)
.saturating_sub(inner.height as usize)
.min(u16::MAX as usize) as u16
};
help.viewport = Some(viewport);
}
help.scroll = help.scroll.min(help.max_scroll);
frame.render_widget(Clear, popup);
help.paragraph = std::mem::take(&mut help.paragraph).scroll((help.scroll, 0));
frame.render_widget(&help.paragraph, inner);
frame.render_widget(block, popup);
}
fn draw_browser(&mut self, frame: &mut Frame) {
let Some(browser) = &mut self.browser else {
return;
};
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(match browser.kind {
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
BrowserKind::SavedVideos => self.key_hints.browser[3].as_str(),
})
.border_style(ui::ACCENT);
let [input, list, error] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(if browser.error.is_some() { 2 } else { 0 }),
])
.areas(block.inner(popup));
frame.render_widget(block, popup);
let input_block = Block::bordered().title(if browser.mode == Mode::Insert {
" Filter · INSERT "
} else {
" Filter · NORMAL "
});
let text_area = input_block.inner(input);
let text = Line::raw(browser.query.as_str());
let scroll = text
.width()
.saturating_sub(text_area.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(input_block),
input,
);
if browser.mode == Mode::Insert && text_area.width != 0 && text_area.height != 0 {
frame.set_cursor_position((text_area.x + cursor.min(text_area.width - 1), text_area.y));
}
if browser.choices.is_empty() {
frame.render_widget(
Paragraph::new(match browser.kind {
BrowserKind::Categories => {
"No categories yet. Use s on a video to save its channel."
}
BrowserKind::Channels => {
"No saved channels yet. Use s on a video to save its channel."
}
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
"All categories is always available."
}
})
.wrap(Wrap { trim: true })
.dim(),
list,
);
} else if browser.matches.is_empty() {
frame.render_widget(
Paragraph::new(match browser.kind {
BrowserKind::Categories => "No matching categories.",
BrowserKind::Channels => "No matching channels.",
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
"No matching categories or combined collection."
}
})
.dim(),
list,
);
} else {
let items = browser.matches.iter().map(|index| {
let (name, source) = &browser.choices[*index];
let mut line = Line::default();
if let ViewSource::Category(category) = source {
line.spans.push(
if browser.checked.contains(category) {
"[x] "
} else {
"[ ] "
}
.into(),
);
}
line.spans.push(Span::raw(name));
if let ViewSource::Category(category) = source
&& self.config.pinned_categories.contains(category)
{
line.spans.push(Span::styled(" p", Style::new().dim()));
}
line
});
frame.render_stateful_widget(
List::new(items)
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
list,
&mut browser.selection,
);
}
if let Some(message) = browser.error.as_deref() {
frame.render_widget(
Paragraph::new(message)
.wrap(Wrap { trim: true })
.style(Style::new().fg(Color::Red)),
error,
);
}
}
fn draw_category_name_input(&self, frame: &mut Frame) {
let Some(input) = &self.category_name_input else {
return;
};
let area = frame.area();
let width = area.width.saturating_sub(4).min(64);
let height = area.height.saturating_sub(4).min(10);
let popup = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
frame.render_widget(Clear, popup);
let (title, warning) = match &input.target {
CategoryNameTarget::SaveGroup { .. } => (
" Save combined view as category ",
"Copies the current channel union. Source categories and pins stay unchanged.",
),
CategoryNameTarget::Rename {
persisted: false, ..
} => (
" Rename category ",
"This new tag stays staged until Save tags.",
),
CategoryNameTarget::Rename { .. } if self.category_dialog.is_some() => (
" Rename category ",
"Submitting saves this rename immediately. Tag membership changes stay staged until Save tags.",
),
CategoryNameTarget::Rename { .. } => (
" Rename category ",
"Submitting saves this rename immediately; existing memberships and pins are preserved.",
),
};
let mut block = Block::bordered().title(title).border_style(ui::ACCENT);
if let CategoryNameTarget::Rename { old, .. } = &input.target {
block = block.title_bottom(format!(" {old} "));
}
let [body, warning_area, error, help] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(2),
Constraint::Length(if input.error.is_some() { 2 } else { 0 }),
Constraint::Length(1),
])
.areas(block.inner(popup));
frame.render_widget(block, popup);
let text = Line::raw(input.name.as_str());
let scroll = text
.width()
.saturating_sub(body.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text).scroll((0, scroll.min(u16::MAX as usize) as u16)),
body,
);
if body.width != 0 && body.height != 0 {
frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y));
}
frame.render_widget(
Paragraph::new(warning).wrap(Wrap { trim: true }).dim(),
warning_area,
);
if let Some(message) = &input.error {
frame.render_widget(
Paragraph::new(message.as_str())
.wrap(Wrap { trim: true })
.fg(Color::Red),
error,
);
}
frame.render_widget(
Paragraph::new(self.key_hints.footer(Context::CategoryNameInput)).dim(),
help,
);
}
fn draw_category_dialog(&mut self, frame: &mut Frame) {
let Some(dialog) = &mut self.category_dialog else {
return;
};
let area = frame.area();
let bulk = matches!(&dialog.target, CategoryTarget::Category { .. });
let width = area.width.saturating_sub(4).min(60);
let height = area.height.saturating_sub(4).min(if dialog.name.is_some() {
7
} else {
dialog
.tags
.len()
.saturating_add(if bulk { 10 } else { 8 })
.clamp(10, if bulk { 21 } else { 19 }) as u16
});
let popup = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(if dialog.name.is_some() {
" New tag "
} else if bulk {
" Bulk tags "
} else {
" Tags "
})
.title_bottom(dialog.label.as_str())
.border_style(ui::ACCENT);
let [input, body, warning, error, help] = Layout::vertical([
Constraint::Length(if dialog.name.is_none() { 3 } else { 0 }),
Constraint::Min(1),
Constraint::Length(if bulk && dialog.name.is_none() { 2 } else { 0 }),
Constraint::Length(if dialog.error.is_some() { 2 } else { 0 }),
Constraint::Length(1),
])
.areas(block.inner(popup));
frame.render_widget(block, popup);
if dialog.name.is_none() {
let input_block = Block::bordered().title(if dialog.mode == Mode::Insert {
" Filter · INSERT "
} else {
" Filter · NORMAL "
});
let text_area = input_block.inner(input);
let text = Line::raw(dialog.query.as_str());
let scroll = text
.width()
.saturating_sub(text_area.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(input_block),
input,
);
if dialog.mode == Mode::Insert && text_area.width != 0 && text_area.height != 0 {
frame.set_cursor_position((
text_area.x + cursor.min(text_area.width - 1),
text_area.y,
));
}
}
if bulk && dialog.name.is_none() {
frame.render_widget(Paragraph::new(
"Saving replaces all memberships. Only tags shared by every channel start checked."
).wrap(Wrap { trim: true }).dim(), warning);
}
if let Some(name) = &dialog.name {
let text = Line::raw(name.as_str());
let scroll = text
.width()
.saturating_sub(body.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text).scroll((0, scroll.min(u16::MAX as usize) as u16)),
body,
);
if body.width != 0 && body.height != 0 {
frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y));
}
} else if dialog.tags.is_empty() {
frame.render_widget(Paragraph::new(self.key_hints.tags_empty.as_str()), body);
} else if dialog.matches.is_empty() {
frame.render_widget(Paragraph::new("No matching tags.").dim(), body);
} else {
frame.render_stateful_widget(
List::new(
dialog
.tags
.iter()
.enumerate()
.filter(|(index, _)| dialog.matches.binary_search(index).is_ok())
.map(|(_, (name, checked))| {
Line::from(vec![
(if *checked { "[x] " } else { "[ ] " }).into(),
name.as_str().into(),
])
}),
)
.highlight_symbol(ui::SELECTION_MARKER)
.highlight_style(ui::SELECTED),
body,
&mut dialog.selection,
);
}
if let Some(message) = &dialog.error {
frame.render_widget(
Paragraph::new(message.as_str())
.wrap(Wrap { trim: true })
.fg(Color::Red),
error,
);
}
frame.render_widget(
Paragraph::new(if dialog.name.is_some() {
self.key_hints.tags[1].as_str()
} else if dialog.mode == Mode::Insert {
self.key_hints.tags[2].as_str()
} else {
self.key_hints.tags[0].as_str()
})
.dim(),
help,
);
}
fn run(
&mut self,
terminal: &mut DefaultTerminal,
initial: bool,
) -> io::Result<Option<BrowseProvider>> {
struct RestoreTerminalInput;
impl Drop for RestoreTerminalInput {
fn drop(&mut self) {
let _ = execute!(
io::stdout(),
DisableBracketedPaste,
SetCursorStyle::DefaultUserShape
);
}
}
let _terminal_input = RestoreTerminalInput;
execute!(
io::stdout(),
EnableBracketedPaste,
SetCursorStyle::SteadyBlock
)?;
self.thumbnail.query_terminal();
if initial && !self.views[self.view_index].query.trim().is_empty() {
self.search();
}
let started = Instant::now();
loop {
self.update();
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
let refresh = Duration::from_millis(if self.titles.active() { 20 } else { 100 });
if event::poll(refresh)? {
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if self.config.keys.resolve(self.key_context(), key)
== Some(Action::ProviderChoose)
{
return choose_provider(
terminal,
&self.config.keys,
Some(BrowseProvider::YouTube),
);
}
if self.key(key) {
return Ok(None);
}
}
Event::Paste(text) => self.paste(&text),
_ => {}
}
}
}
}
}
fn loading_title<'a>(title: &'a str, controls: &'a str, spinner: Option<&'static str>) -> Line<'a> {
let mut spans =
Vec::with_capacity(1 + usize::from(!controls.is_empty()) + usize::from(spinner.is_some()));
spans.push(title.into());
if let Some(spinner) = spinner {
spans.push(spinner.into());
}
if !controls.is_empty() {
spans.push(controls.into());
}
Line::from(spans)
}
fn preview_area(area: Rect) -> Rect {
let width = area
.width
.saturating_sub(4)
.min((u32::from(area.width) * 70 / 100) as u16)
.min(96);
let height = area
.height
.saturating_sub(4)
.min((u32::from(area.height) * 60 / 100) as u16)
.min(20);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
fn edit_input(text: &mut String, key: KeyEvent, action: Option<Action>) {
match action {
Some(Action::DeleteLine) => text.clear(),
Some(Action::DeleteWord) => {
let keep = text
.char_indices()
.rev()
.skip_while(|(_, c)| c.is_whitespace())
.find(|(_, c)| c.is_whitespace())
.map_or(0, |(index, c)| index + c.len_utf8());
text.truncate(keep);
}
Some(Action::DeleteCharacter) => {
text.pop();
}
None => {
if let KeyCode::Char(c) = key.code
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
text.push(c);
}
}
_ => {}
}
}
fn paste_input(input: &mut String, text: &str) {
// Inputs are single-line; pasted control characters must never become terminal commands.
if !text.chars().any(char::is_control) {
input.push_str(text);
} else {
input.extend(text.chars().filter_map(|c| match c {
'\r' | '\n' | '\t' => Some(' '),
_ => (!c.is_control()).then_some(c),
}));
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BrowseProvider {
YouTube,
AniList,
Miruro,
}
const PROVIDERS: [(&str, BrowseProvider); 3] = [
("YouTube", BrowseProvider::YouTube),
("AniList · on-demand sub", BrowseProvider::AniList),
("Miruro legacy · separate library", BrowseProvider::Miruro),
];
fn provider_selection(
selected: &mut usize,
action: Option<Action>,
) -> std::ops::ControlFlow<Option<BrowseProvider>> {
match action {
Some(Action::SelectorNext) => *selected = (*selected + 1).min(PROVIDERS.len() - 1),
Some(Action::SelectorPrevious) => *selected = selected.saturating_sub(1),
Some(Action::SelectorOpen) => {
return std::ops::ControlFlow::Break(Some(PROVIDERS[*selected].1));
}
Some(Action::SelectorClose | Action::Quit | Action::Interrupt) => {
return std::ops::ControlFlow::Break(None);
}
_ => {}
}
std::ops::ControlFlow::Continue(())
}
fn choose_provider(
terminal: &mut DefaultTerminal,
bindings: &keys::Bindings,
current: Option<BrowseProvider>,
) -> io::Result<Option<BrowseProvider>> {
let mut selected = current
.and_then(|current| {
PROVIDERS
.iter()
.position(|(_, provider)| *provider == current)
})
.unwrap_or(0);
let mut selection = ListState::default();
let hint = format!(
"{}/{} select · {} open · {} {}",
bindings.label(Action::SelectorNext),
bindings.label(Action::SelectorPrevious),
bindings.label(Action::SelectorOpen),
bindings.label(Action::SelectorClose),
if current.is_some() { "cancel" } else { "quit" },
);
let providers = List::new(PROVIDERS.map(|(name, _)| name))
.highlight_style(ui::SELECTED)
.highlight_symbol(ui::SELECTION_MARKER);
loop {
selection.select(Some(selected));
terminal.draw(|frame| {
let area = frame.area();
let width = area.width.min(48);
let height = area.height.min(7);
let popup = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
let block = Block::bordered()
.title(" Browse provider ")
.border_style(ui::ACCENT);
let [list, hints] = Layout::vertical([Constraint::Min(0), Constraint::Length(2)])
.areas(block.inner(popup));
frame.render_widget(block, popup);
frame.render_stateful_widget(&providers, list, &mut selection);
frame.render_widget(
Paragraph::new(hint.as_str()).wrap(Wrap { trim: false }),
hints,
);
})?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
{
let action = bindings.resolve(Context::SelectorNormal, key).or_else(|| {
bindings
.resolve(Context::Normal, key)
.filter(|action| *action == Action::Quit)
});
if current.is_some() && action == Some(Action::SelectorClose) {
return Ok(current);
}
if let std::ops::ControlFlow::Break(provider) =
provider_selection(&mut selected, action)
{
return Ok(provider);
}
}
}
}
fn main() -> io::Result<()> {
let mut options = Options::parse();
if options.default_config {
return std::io::Write::write_all(
&mut io::stdout().lock(),
keys::default_config().as_bytes(),
);
}
let config_path = config::config_path()?;
let config = config::load(&config_path)?;
ratatui::run(|terminal| {
let _keyboard = ui::KeyboardProtocol::enable()?;
let mut provider = if options.anilist {
BrowseProvider::AniList
} else if options.miruro {
BrowseProvider::Miruro
} else {
let Some(provider) = choose_provider(terminal, &config.keys, None)? else {
return Ok(());
};
provider
};
let mut youtube: Option<App> = None;
let mut anime: [Option<miruro_ui::Browser>; 2] = [None, None];
let mut previous = None;
loop {
options.anilist = provider == BrowseProvider::AniList;
options.miruro = provider == BrowseProvider::Miruro;
let anime_index = usize::from(provider == BrowseProvider::Miruro);
let first_youtube = youtube.is_none();
if provider == BrowseProvider::YouTube {
if youtube.is_none() {
youtube = Some(App::with_state(
options.clone(),
config_path.clone(),
config::load(&config_path)?,
video_state::State::open(&config_path)?,
));
}
} else if anime[anime_index].is_none() {
anime[anime_index] = Some(miruro_ui::Browser::new(
options.clone(),
config_path.clone(),
config::load(&config_path)?,
video_state::State::open(&config_path)?,
));
}
if let Some(previous) = previous.filter(|previous| *previous != provider) {
match (previous, provider) {
(BrowseProvider::YouTube, _) => std::mem::swap(
&mut youtube.as_mut().unwrap().video_state,
&mut anime[anime_index].as_mut().unwrap().state,
),
(_, BrowseProvider::YouTube) => std::mem::swap(
&mut anime[usize::from(previous == BrowseProvider::Miruro)]
.as_mut()
.unwrap()
.state,
&mut youtube.as_mut().unwrap().video_state,
),
_ => {
let (anilist, miruro) = anime.split_at_mut(1);
std::mem::swap(
&mut anilist[0].as_mut().unwrap().state,
&mut miruro[0].as_mut().unwrap().state,
);
}
}
}
let next = if provider == BrowseProvider::YouTube {
youtube.as_mut().unwrap().run(terminal, first_youtube)?
} else {
anime[anime_index].as_mut().unwrap().run(terminal)?
};
let Some(next) = next else {
return Ok(());
};
options.query = None;
previous = Some(provider);
provider = next;
}
})
}
#[cfg(test)]
#[path = "../tests/unit/app.rs"]
mod tests;