From a6557047febff8dba8cd5ed224223b8a0fbeec96 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 02:05:35 +0200 Subject: [PATCH] feat: track currently watching videos --- README.md | 26 +- src/keys.rs | 9 +- src/main.rs | 208 +++++++++++++- src/video_state.rs | 680 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 857 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index ee268c5..80dbaf1 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,12 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | `W` | Choose combined or category watch later from any video view or preview | | `Alt-w` | Open the current category's watch later | | `f` | In watch later, cycle All → Unwatched → Watched | -| `x` | In watch-later or saved-video lists, remove the selected video | -| `X` (Shift-X) | In either collection list, confirm bulk removal of the current category/filter subset | +| `x` | In watch-later, saved-video, or currently-watching lists, remove the selected video | +| `X` (Shift-X) | In collection lists, confirm bulk removal of the current list/category/filter subset | | `v` | Save the selected video to saved videos (also works in previews) | | `V` | Choose combined or category saved videos from any video view or preview | | `Alt-v` | Open the current category's saved videos | +| `C` (Shift-C) | Open the combined Currently watching list | | Tab / Shift-Tab | Switch between visible views | | `r` | Reload configuration and refresh the current view | | `?` | Open / close the scrollable keybinding reference | @@ -231,8 +232,9 @@ Files are siblings of `config.toml`: - `watch-later.toml`: versioned saved-video metadata. - `saved-videos.toml`: independent versioned saved-video metadata. +- `currently-watching.toml`: versioned metadata for started, unfinished videos. - `watch-state.toml`: versioned global watched video IDs. -- `playback-sessions/`: private per-launch progress streams and their expected video identities. +- `playback-sessions/`: private per-launch progress streams, expected video identities, video snapshots, and durable admission markers. State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB. @@ -250,15 +252,25 @@ Saved titles are **green**, taking precedence over watch-later magenta when a vi The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure saving and the collection selector with `video.saved.save` and `view.saved.browse`; direct category browsing uses `view.saved.category`. The corresponding watch-later navigation actions are `view.watch_later.browse` and `view.watch_later.category`. Selectors use the shared `selector.*` and `input.*` bindings. The live hints and `?` reference reflect overrides. Alt shortcuts require the terminal to transmit Alt/Meta; if macOS Option inserts symbols, enable Option-as-Meta or remap the actions. +## Currently watching + +`C` opens one combined, offline list of started but unfinished videos. It uses the shared temporary tab and back/forward navigation; configure the shortcut with `view.watching.browse`. + +A validated, acknowledged **positive playback position** adds the video—not merely launching Terebi or receiving position zero. Reaching the existing eligible **95% watched threshold** automatically removes it. Already-watched videos never enter. Unknown-duration or ineligible playback can appear but stays unfinished unless a later eligible report marks it watched or you remove it. + +Entries and their title/creator/publication metadata survive restarts. Reports written while Tubular is closed are recovered on the next launch. Older sessions without video snapshots can populate the list when matching watch-later/saved metadata exists; otherwise they continue updating watched status without inventing missing metadata. + +Use `x` for individual removal or `X` for bulk confirmation. Removal neither marks the video watched nor changes watch later/saved videos. Continued reports from existing sessions cannot re-add a removed entry, including after restart; deliberately starting a new session can re-add it if still unfinished. + ## Collection removal -Removal controls apply **only in watch-later and saved-video views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls. +Removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls. -`x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list. An empty list does not open confirmation. +`x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list; Currently watching uses its entire combined list. An empty list does not open confirmation. -The prompt names the collection, category, count, and applicable filter. **`y` confirms; `n` or Esc cancels.** Enter never confirms. Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open. +The prompt names the collection, category, count, and applicable filter. **`y` or Enter confirms; `n` or Esc cancels.** Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open. -Removal changes only the active collection. Matching category views update, while the other collection and global watched status remain untouched. +Removal changes only the active collection. Matching views update, while other collections and global watched status remain untouched. The old collection-specific removal settings are no longer accepted. Replace them with: diff --git a/src/keys.rs b/src/keys.rs index 89ba9a1..08bcb0c 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -142,6 +142,7 @@ actions! { SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"]; SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"]; SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"]; + WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"]; SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"]; SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"]; SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"]; @@ -538,16 +539,20 @@ impl Bindings { ), ], ), + ( + "CURRENTLY WATCHING", + &[(&[WatchingBrowse], "Browse started, unfinished videos")], + ), ( "COLLECTION LISTS", &[ ( &[CollectionRemoveSelected], - "Remove selected from current watch-later / saved list only", + "Remove selected from the current collection only", ), ( &[CollectionRemoveBulk], - "Confirm removal of current category / filtered subset", + "Confirm removal of current list / category / filtered subset", ), ( &[], diff --git a/src/main.rs b/src/main.rs index 636b1d7..5b6ab85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,12 +103,13 @@ //! 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. -//! Only watch-later and saved-video views allow removal: x removes their selected video, including previews. -//! Both collections store offline metadata beside config.toml; current channel tags determine category lists. +//! C opens combined currently watching: acknowledged started videos leave automatically once watched. +//! 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 acknowledged 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 confirms bulk, n/Esc cancels, never Enter. +//! Configure view.collection.remove.selected and view.collection.remove.bulk; y/Enter confirms bulk, n/Esc cancels. mod config; mod keys; @@ -148,7 +149,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube 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.\nRequires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file for an independent private reporting stream and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\nAlways start in NORMAL. A CLI query is searched 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. Other views still 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.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; 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.\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.\nSearch 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; Esc or - 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.\nSelecting another video opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" + 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.\nRequires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file for an independent private reporting stream and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\nAlways start in NORMAL. A CLI query is searched 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. Other views still 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.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; 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 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.\nSearch 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; Esc or - 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.\nSelecting another video opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable; must support --new-instance, --url, and --progress-file. @@ -190,6 +191,7 @@ enum ViewSource { Channel(String), WatchLater(Option), SavedVideos(Option), + Watching, } impl ViewSource { @@ -200,7 +202,7 @@ impl ViewSource { group: &'a [String], ) -> &'a [String] { match self { - Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) => &[], + Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[], Self::Feed => feed, Self::Category(name) => &config.categories[name], Self::CategoryGroup(_) => group, @@ -573,6 +575,7 @@ struct KeyHints { help: String, watch_later: String, saved_videos: String, + watching: String, group_save: String, } @@ -866,6 +869,12 @@ impl KeyHints { 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)), } } @@ -1004,7 +1013,7 @@ impl App { ViewSource::Search | ViewSource::Feed => true, ViewSource::Category(name) => self.config.pinned_categories.contains(name), ViewSource::CategoryGroup(_) | ViewSource::Channel(_) - | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) => false, + | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false, }) .chain(self.temporary_view.as_ref().and_then(|source| { self.views.iter().enumerate().find(|(_, view)| { @@ -1381,6 +1390,7 @@ impl App { | 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()); @@ -1404,7 +1414,7 @@ impl App { self.close_preview(); if matches!( self.views[self.view_index].source, - ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching ) { self.rebuild_collections(); self.status = self.view_status(); @@ -1420,7 +1430,7 @@ impl App { } if matches!( self.views[self.view_index].source, - ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching ) { self.rebuild_collections(); self.status = self.view_status(); @@ -1485,7 +1495,10 @@ impl App { view.watch_filter.label() ); } - if matches!(view.source, ViewSource::SavedVideos(_)) { + if matches!( + view.source, + ViewSource::SavedVideos(_) | ViewSource::Watching + ) { return format!( "{} videos in {}. Saved metadata is available offline.", view.videos.len(), @@ -1560,6 +1573,7 @@ impl App { 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) { @@ -1576,7 +1590,7 @@ impl App { .videos(kind) .iter() .filter(|video| { - (matches!(kind, CollectionKind::Saved) + (!matches!(kind, CollectionKind::WatchLater) || view .watch_filter .includes(self.video_state.is_watched(&video.id))) @@ -1632,6 +1646,7 @@ impl App { let label = match kind { CollectionKind::WatchLater => "Watch later", CollectionKind::Saved => "Saved videos", + CollectionKind::Watching => "Currently watching", }; let name = category .as_ref() @@ -1639,11 +1654,15 @@ impl App { 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 @@ -1662,6 +1681,9 @@ impl App { (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(); } @@ -1683,6 +1705,7 @@ impl App { match self.views[self.view_index].source { ViewSource::WatchLater(_) => Some(CollectionKind::WatchLater), ViewSource::SavedVideos(_) => Some(CollectionKind::Saved), + ViewSource::Watching => Some(CollectionKind::Watching), _ => None, } } @@ -1704,6 +1727,7 @@ impl App { 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; @@ -1715,6 +1739,7 @@ impl App { 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(); } @@ -1729,6 +1754,7 @@ impl App { 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() { @@ -1744,12 +1770,13 @@ impl App { 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; the other collection and watched state are unchanged.\n\ny confirms · n/Esc cancels", + "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"), ); @@ -1827,6 +1854,7 @@ impl App { 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) })); @@ -1846,7 +1874,10 @@ impl App { } if matches!( &self.views[self.view_index].source, - ViewSource::CategoryGroup(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + 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()); @@ -2133,6 +2164,7 @@ impl App { | ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + | ViewSource::Watching ) }) .unwrap_or(self.views.len()); @@ -2452,7 +2484,7 @@ impl App { return; }; self.player_launch = None; - let progress_file = match self.video_state.progress_file() { + let progress_file = match self.video_state.progress_file(video) { Ok(path) => path, Err(error) => { self.status = error.to_string(); @@ -2525,7 +2557,7 @@ impl App { self.rebuild_collections(); if matches!( self.views[self.view_index].source, - ViewSource::WatchLater(_) + ViewSource::WatchLater(_) | ViewSource::Watching ) { self.status = self.view_status(); } @@ -2636,6 +2668,7 @@ impl App { 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()); @@ -2780,6 +2813,7 @@ impl App { 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::SavedVideoBrowseCategory) => { self.browse_category_collection(CollectionKind::Saved) } @@ -2961,6 +2995,7 @@ impl App { 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(), _ => "", }, @@ -3631,6 +3666,150 @@ mod tests { ) } + #[test] + fn watching_direct_bindings_respect_modals_and_offline_history() { + for (label, overrides, binding) in [ + ("default", "", Some(KeyCode::Char('C'))), + ( + "remapped", + "[keys]\nview.watching.browse = 'f8'\n", + Some(KeyCode::F(8)), + ), + ("disabled", "[keys]\nview.watching.browse = []\n", None), + ] { + let mut app = rename_app(&format!("watching-{label}"), overrides); + for view in &mut app.views { + view.loaded = true; + } + app.mode = Mode::Insert; + rename_press(&mut app, KeyCode::Char('C')); + assert_eq!(app.views[0].query, "C"); + rename_press(&mut app, KeyCode::Esc); + app.open_browser(BrowserKind::Categories); + rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C'))); + assert!(app.browser.is_some()); + assert_eq!(app.view_index, 0); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char('?')); + rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C'))); + assert!(app.help_popup.is_some()); + assert_eq!(app.view_index, 0); + rename_press(&mut app, KeyCode::Esc); + if binding != Some(KeyCode::Char('C')) { + rename_press(&mut app, KeyCode::Char('C')); + assert_eq!(app.view_index, 0); + } + if let Some(binding) = binding { + rename_press(&mut app, binding); + let index = app.view_index; + assert_eq!(app.views[index].source, ViewSource::Watching); + assert_eq!(app.temporary_view, Some(ViewSource::Watching)); + assert_eq!(app.key_context(), Context::CollectionNormal); + assert!(app.browser.is_none()); + app.load_page(); + let config = std::mem::take(&mut app.config); + app.apply_config(config); + assert_eq!(app.views[app.view_index].source, ViewSource::Watching); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.view_index, index); + rename_press(&mut app, KeyCode::Char('+')); + assert_eq!(app.view_index, 0); + rename_press(&mut app, binding); + assert_eq!(app.view_index, index); + assert!(app.job.is_none()); + assert!(!app.views[index].has_more); + } + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + } + + #[test] + fn watching_progress_preserves_selection_and_removal_is_collection_only() { + use std::io::Write; + fn report(path: &std::path::Path, id: &str, position: u32) { + let mut options = std::fs::OpenOptions::new(); + options.append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + writeln!( + options.open(path).unwrap(), + "{{\"version\":1,\"event\":\"progress\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":true}}" + ).unwrap(); + } + let mut app = rename_app("watching-progress", ""); + for view in &mut app.views { + view.loaded = true; + } + let videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Third","uploader_id":"@third"}]}"#, + ).unwrap().videos; + let reports: Vec<_> = videos + .iter() + .map(|video| { + app.video_state.save(CollectionKind::Saved, video).unwrap(); + app.video_state + .save(CollectionKind::WatchLater, video) + .unwrap(); + let path = app.video_state.progress_file(video).unwrap(); + video_state::register_identity(&path, &video.id).unwrap(); + report(&path, &video.id, 1); + path + }) + .collect(); + app.update(); + rename_press(&mut app, KeyCode::Char('C')); + assert_eq!(app.views[app.view_index].videos.len(), 3); + let second = app.views[app.view_index] + .videos + .iter() + .position(|video| video.id == videos[1].id) + .unwrap(); + app.views[app.view_index].selection.select(Some(second)); + app.preview = Some(PreviewMode::Description); + report(&reports[0], &videos[0].id, 95); + app.update(); + let view = &app.views[app.view_index]; + assert_eq!(view.videos.len(), 2); + assert_eq!( + view.videos[view.selection.selected().unwrap()].id, + videos[1].id + ); + assert_eq!(app.preview, Some(PreviewMode::Description)); + report(&reports[1], &videos[1].id, 95); + app.update(); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert!(app.preview.is_none()); + assert!(app.status.contains("1 videos")); + rename_press(&mut app, KeyCode::Char('x')); + assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); + report(&reports[2], &videos[2].id, 2); + app.update(); + assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); + let fresh = app.video_state.progress_file(&videos[2]).unwrap(); + video_state::register_identity(&fresh, &videos[2].id).unwrap(); + report(&fresh, &videos[2].id, 1); + app.update(); + rename_press(&mut app, KeyCode::Char('X')); + assert_eq!( + app.collection_removal.as_ref().unwrap().ids, + [videos[2].id.clone()] + ); + rename_press(&mut app, KeyCode::Enter); + assert!(app.collection_removal.is_some()); + rename_press(&mut app, KeyCode::Char('y')); + assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 3); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3); + assert!(app.video_state.is_watched(&videos[0].id)); + assert!(app.video_state.is_watched(&videos[1].id)); + assert!(!app.video_state.is_watched(&videos[2].id)); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + #[test] fn direct_jumps_reuse_cached_views_and_navigation_with_configured_bindings() { for (document, search, feed) in [ @@ -3686,6 +3865,7 @@ mod tests { ViewSource::Channel("creator".into()), ViewSource::SavedVideos(None), ViewSource::WatchLater(None), + ViewSource::Watching, ] { app.views[2].source = source; for preview in [ diff --git a/src/video_state.rs b/src/video_state.rs index ba27fd8..505c3fe 100644 --- a/src/video_state.rs +++ b/src/video_state.rs @@ -59,28 +59,35 @@ impl StoredFile { pub enum CollectionKind { WatchLater, Saved, + Watching, } struct Collection { file: StoredFile, videos: Vec