From 585907f8764d143e57223f00e09da92cbc4ad423 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 16:02:39 +0200 Subject: [PATCH] feat: queue videos in running player --- README.md | 6 +- docs/terebi-url-playback-handoff.md | 24 +++++++- src/keys.rs | 5 ++ src/main.rs | 22 ++++++-- src/miruro_ui.rs | 22 +++++++- src/youtube.rs | 11 +++- tests/unit/app.rs | 53 ++++++++++-------- tests/unit/keys.rs | 33 +++++++++++ tests/unit/miruro_ui.rs | 86 +++++++++++++++++------------ tests/unit/video_state.rs | 4 +- 10 files changed, 193 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 490317a..ac743a5 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \ --progress-file '/private/session/progress.jsonl' ``` -Each submitted selection opens one independent player instance without replacing existing playback. Tubular creates a private reporting session per video and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running. +Plain Enter opens one independent player instance without replacing existing playback. Cmd/Ctrl+Enter appends to a running player's queue instead. Tubular creates a private reporting session per video and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running. ### Multiple video selection @@ -71,6 +71,8 @@ Checks belong to each cached view/screen and follow stable video identities thro Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff). +**Cmd+Return or Ctrl+Return** (`video.enqueue`) sends the same checked rows, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. Both bindings are available on every platform; the terminal must transmit the modified Return, or you can rebind the action. Enqueue always uses `terebi --enqueue --queue-file '/private/session/queue.json'`, even for one video, preserving independent per-item progress. If Terebi is not running, it starts with that queue. Plain Return still opens a new instance. These shortcuts do nothing on Miruro catalog titles or saved shows and do not submit text inputs. + ### Miruro browser Choose **Miruro** at startup or use `--miruro` to start directly. Its interface is **catalog → series → episodes**, with a separate saved-show library organized by overlapping category tags, without YouTube's Search/Feed tabs or channel controls. @@ -84,6 +86,7 @@ Catalog autocomplete returns at most eight anime titles, without pagination. Tit | `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode | | Space / Esc | Check/uncheck the hovered episode / discard episode checks | | Enter | Open a series, or play checked episodes in displayed order (hovered episode when none are checked) | +| Cmd+Return / Ctrl+Return | Append checked episodes, or the hovered episode, to a running Terebi queue (`video.enqueue`) | | `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) | | `-` / `+` | Back / forward through cached screens, preserving query and selection | | `w` / `v` | Save the selected episode to watch later / saved episodes | @@ -120,6 +123,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | `j` / `k`, ↓ / ↑ | Select the next / previous video | | Space / Esc | Check/uncheck the hovered video / discard checks | | Enter | Play checked videos in displayed order, or the hovered video when none are checked | +| Cmd+Return / Ctrl+Return | Append checked videos, or the hovered video, to a running Terebi queue (`video.enqueue`) | | `p` | Reopen the provider chooser | | `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) | | `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) | diff --git a/docs/terebi-url-playback-handoff.md b/docs/terebi-url-playback-handoff.md index 4313aa1..43f62f3 100644 --- a/docs/terebi-url-playback-handoff.md +++ b/docs/terebi-url-playback-handoff.md @@ -11,7 +11,7 @@ Terebi already accepts native Miruro episode URLs, resolves sources and supports ## URL and canonical-route handling -Tubular uses `PlayerLaunch::start(&videos, player, &mut state)` for both providers; the launcher allocates and binds private reporting sessions through `State`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs: +Tubular uses `PlayerLaunch::start(&videos, player, &mut state, enqueue)` for both providers; the launcher allocates and binds private reporting sessions through `State`. Plain Enter passes `false`, retaining a separate player; `video.enqueue` passes `true`. For Miruro, `Identity::from_key(id)?.watch_url()?` validates the stable identity and constructs: ```sh terebi --new-instance \ @@ -98,7 +98,7 @@ The version-1 manifest is: } ``` -The manifest contains 1–4,096 entries. Tubular uses it only for batches of two or more; a single selected item retains `--url` and `--progress-file`. `--queue-file` requires `--new-instance` and an absolute manifest path, and cannot be combined with another media argument or global `--progress-file`. Terebi validates the entire manifest before launch and again at GUI startup: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry. +The manifest contains 1–4,096 entries. Plain Enter uses it for batches of two or more; a single item retains `--new-instance --url` and `--progress-file`. Enqueue uses a manifest even for one item. `--queue-file` requires either `--new-instance` or `--enqueue`, an absolute manifest path, and no other media argument or global `--progress-file`; those two launch modes cannot be combined. Terebi validates the entire manifest before delivery and again in the receiving application: supported page URLs, distinct stable identities, distinct absolute new report destinations, and plain owner-only report directories. POSIX-canonical report paths deduplicate macOS `/var` and `/private/var` aliases. Invalid batches fail rather than silently playing only their first entry. Tubular validates all identities, snapshots and available session capacity before allocating a batch. It creates one existing-format snapshot/reporting session and identity marker per item; it never creates Terebi's report files. The manifest is `0600` inside the first retained `0700` session directory and contains no source URLs or transport credentials. Retention and the shared 4,096-session cap are unchanged; inactive queue entries count toward the cap too. @@ -112,3 +112,23 @@ Each selected entry reports only to its own registered destination. Switching dr - Both actual terminal interfaces were exercised with offline saved rows and a capture launcher: checking in reverse order produced a private two-item manifest in displayed order; successful submission cleared checks and the next Enter used single-video URL playback. This fixture did not run Terebi. - Terebi: `make check-cli check-queue check-reporting check-resolved all` passed. Headless native playback exercised mixed YouTube/Miruro queues, stale-result cancellation, manual navigation, natural-end advancement, fresh resolution/resume on revisits and isolated per-item reports. - The packaged GUI smoke did not complete: native state snapshots stalled. Temporary tracing and probe files were removed. Further UI testing was stopped at the user's request and remains user-owned; no complete GUI smoke or visual-layout pass is claimed. The TODO remains open for that confirmation. + +## Append to a running player + +```sh +terebi --enqueue --queue-file '/private/session/queue.json' +``` + +`video.enqueue` defaults to both Super/Cmd+Return and Ctrl+Return. It shares plain Enter's checked-display-order selection and hovered-row fallback in video/episode lists, previews and offline collections. Miruro catalog/show rows and text inputs do not enqueue. Plain Enter remains a new-instance launch. + +The CLI delivers a manifest reference through an explicit-app native URL-open event, so later submissions reach an already running player rather than relying on startup arguments. The receiver appends in supplied order without restarting current playback, changing paused/playing intent, cancelling current resolution or resolving appended items early. An empty or newly launched player starts the first queued item. OS-level delivery to a running packaged app still needs user confirmation; headless receiver checks are not LaunchServices proof. + +The complete request is rejected on an identity already in the current queue, a retained report identity, a retained report path, or the original single-video report identity. Removing a row does not release its registration. Registrations are atomic; failed batches do not reserve their valid prefix. The original single-video report remains independently navigable, and unregistered identities cannot leak into it once queue reporting is active. Errors after OS handoff appear in Terebi; Tubular's checks retain the existing clear-on-successful-launcher-spawn policy. + +### Enqueue verification + +- Tubular: `cargo fmt --all`, `cargo test --locked` (**233 passed**), `cargo build --locked` and built `--help`; Rust launcher diagnostics were clean. +- Actual YouTube and Miruro terminal interfaces: reverse-checked rows produced display-ordered enqueue manifests, cleared checks fell back to the hovered row, Ctrl/Super+Return and an F12 override worked, and plain Return still used `--new-instance --url`. Six handoffs used private `0600` manifests in `0700` session directories with distinct report destinations. The capture launcher did not run Terebi. +- Terebi: `check-cli`, `check-queue`, `check-reporting` and `make all` passed. A regression check first failed on rebinding the original single-video identity and passed after binding it independently of appended reports. +- Headless native enqueue smoke with local video-only HLS passed: repeated delivery while paused/playing, unchanged current selection/revision, lazy mixed-provider append, natural-end advance, navigation/revisits, atomic rejection/rollback, isolated original/appended reports and empty-player startup. +- Full `make check-resolved` stopped in the pre-existing mixed-queue natural-end scenario at `Could not start Core Audio (560227702)` before reaching enqueue. No full A/V-suite pass is claimed. No packaged-GUI/LaunchServices smoke or visual-layout confirmation was performed; the app was built, not installed. Temporary terminal/server fixtures were removed. diff --git a/src/keys.rs b/src/keys.rs index 47f3725..4d3c9e2 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -120,6 +120,7 @@ actions! { ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"]; ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"]; VideoPlay, "video.play", "Play checked videos in displayed order, or the selected video", VIEW, ["enter"]; + VideoEnqueue, "video.enqueue", "Append checked videos in displayed order, or the selected video, to Terebi's queue", VIEW, ["super+enter", "ctrl+enter"]; VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"]; VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"]; VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"]; @@ -487,6 +488,10 @@ impl Bindings { &[VideoPlay], "Play checked videos in displayed order, or the selected video", ), + ( + &[VideoEnqueue], + "Append checked videos, or the selected video, to Terebi's queue", + ), ( &[VideoToggle], "Check / uncheck the selected video for playback", diff --git a/src/main.rs b/src/main.rs index 9012172..41d172f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ //! 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 displayed order or the hovered video. +//! Super/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. @@ -165,7 +166,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube or Miruro 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.\nYouTube browsing requires yt-dlp; YouTube thumbnails also use curl. 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 displayed 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; one submitted batch opens one independent player without replacing existing playback.\nStartup selects independent YouTube or Miruro interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --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 Miruro interface, input searches its catalog; 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 displayed order, or the hovered video when none are checked. These controls also work in collection views and previews. Configure video.select.toggle and video.select.clear. 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.\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.\nYouTube browsing requires yt-dlp; YouTube thumbnails also use curl. 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 displayed 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. Super/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 or Miruro interfaces through a centered provider chooser using configured selector keys; Esc/q quits. --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 Miruro interface, input searches its catalog; 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 displayed 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; Super/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. @@ -644,10 +645,11 @@ impl KeyHints { fn new(bindings: &keys::Bindings) -> Self { let key = |action| bindings.label(action); let playback = format!( - "{} check · {} clear · {} play", + "{} check · {} clear · {} play · {} enqueue", key(Action::VideoToggle), key(Action::VideoClear), - key(Action::VideoPlay) + key(Action::VideoPlay), + key(Action::VideoEnqueue) ); let status = format!( "{} watching · {} watched", @@ -2564,6 +2566,10 @@ impl App { } 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(); @@ -2571,7 +2577,12 @@ impl App { return; } self.player_launch = None; - match PlayerLaunch::start(&videos, &self.options.player, &mut self.video_state) { + match PlayerLaunch::start( + &videos, + &self.options.player, + &mut self.video_state, + enqueue, + ) { Ok(launch) => { view.checked.clear(); let title = if videos.len() == 1 { @@ -2580,7 +2591,7 @@ impl App { format!("{} videos", videos.len()) }; self.status = format!( - "Opening {title} in {}...", + "Handing {title} to {}...", self.options.player.to_string_lossy() ); self.player_launch = Some(launch); @@ -2934,6 +2945,7 @@ impl App { 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(), diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index ad40d9b..22b5cee 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -578,6 +578,11 @@ impl Browser { self.push(Screen::new(Content::Series { media, data: None })); self.load_series(); } else { + self.play(false); + } + } + fn play(&mut self, enqueue: bool) { + if self.screen().playable() { let videos = self.screen().playback_videos(); if videos.is_empty() { return; @@ -586,15 +591,16 @@ impl Browser { self.screen_mut().error = None; // Dropping the launcher cancels only its CLI child, not the native player. self.player = None; - let result = PlayerLaunch::start(&videos, &self.options.player, &mut self.state); + let result = + PlayerLaunch::start(&videos, &self.options.player, &mut self.state, enqueue); match result { Ok(player) => { self.player = Some(player); self.screen_mut().checked.clear(); self.status = if videos.len() == 1 { - format!("Opening {} in Terebi…", safe(&videos[0].title)) + format!("Handing {} to Terebi…", safe(&videos[0].title)) } else { - format!("Opening {} episodes in Terebi…", videos.len()) + format!("Handing {} episodes to Terebi…", videos.len()) }; } Err(error) => self.fail(error), @@ -1210,6 +1216,7 @@ impl Browser { Some(Action::ViewNext) => self.history(true), Some(Action::ViewRefresh) => self.refresh(), Some(Action::VideoPlay) => self.open(), + Some(Action::VideoEnqueue) => self.play(true), Some(Action::VideoToggle) => self.screen_mut().toggle_checked(), Some(Action::VideoClear) => self.screen_mut().checked.clear(), Some(Action::VideoCopyUrl) if self.screen().playable() => { @@ -1375,6 +1382,10 @@ impl Browser { Content::Series { .. } | Content::Collection { .. } ) { bindings.extend([ + ( + Action::VideoEnqueue, + "Append checked or selected episodes to Terebi's queue", + ), (Action::VideoToggle, "Toggle checked episode"), (Action::VideoClear, "Clear checked episodes"), (Action::VideoCopyUrl, "Copy selected episode URL"), @@ -1445,6 +1456,7 @@ impl Browser { ]; if self.screen().playable() { actions.extend([ + (Action::VideoEnqueue, "enqueue"), (Action::VideoToggle, "check"), (Action::VideoClear, "clear checks"), ]); @@ -1498,6 +1510,7 @@ impl Browser { &[ (Action::InfoDescription, "description"), (Action::VideoPlay, "play"), + (Action::VideoEnqueue, "enqueue"), (Action::VideoToggle, "check"), (Action::VideoClear, "clear checks"), (Action::InfoHide, "close"), @@ -1510,6 +1523,7 @@ impl Browser { &[ (Action::InfoOverview, "info"), (Action::VideoPlay, "play"), + (Action::VideoEnqueue, "enqueue"), (Action::VideoToggle, "check"), (Action::VideoClear, "clear checks"), (Action::InfoHide, "close"), @@ -1528,6 +1542,7 @@ impl Browser { (Action::Help, "help"), (Action::ProviderChoose, "provider"), (Action::VideoPlay, "play"), + (Action::VideoEnqueue, "enqueue"), (Action::InfoOverview, "info"), (Action::WatchStatusWatching, "mark watching"), (Action::WatchStatusWatched, "mark watched"), @@ -1553,6 +1568,7 @@ impl Browser { "play" }, ), + (Action::VideoEnqueue, "enqueue"), (Action::InfoOverview, "info"), (Action::WatchStatusWatching, "mark watching"), (Action::WatchStatusWatched, "mark watched"), diff --git a/src/youtube.rs b/src/youtube.rs index be564c2..a2843e4 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -929,6 +929,7 @@ impl PlayerLaunch { videos: &[Video], player: &OsStr, state: &mut crate::video_state::State, + enqueue: bool, ) -> io::Result { if videos.is_empty() { return Err(io::Error::new( @@ -937,8 +938,14 @@ impl PlayerLaunch { )); } let mut command = isolated_command(player); - command.arg("--new-instance"); - if let [video] = videos { + command.arg(if enqueue { + "--enqueue" + } else { + "--new-instance" + }); + if let [video] = videos + && !enqueue + { let url = video_url(&video.id)?; let report = state.progress_file(video)?; crate::video_state::register_identity(&report, &video.id)?; diff --git a/tests/unit/app.rs b/tests/unit/app.rs index d0661f0..c53b553 100644 --- a/tests/unit/app.rs +++ b/tests/unit/app.rs @@ -193,9 +193,10 @@ fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() #[test] fn playback_checks_render_independently_and_live_hints_use_configured_keys() { let mut app = app(&["tubular"]); - let document = "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n" - .parse::() - .unwrap(); + let document = + "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n[keys.video]\nenqueue = 'f22'\n" + .parse::() + .unwrap(); app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); app.key_hints = KeyHints::new(&app.config.keys); for context in [ @@ -206,7 +207,7 @@ fn playback_checks_render_independently_and_live_hints_use_configured_keys() { Context::CollectionInfo, Context::CollectionDescription, ] { - for key in ["F20", "F21"] { + for key in ["F20", "F21", "F22"] { assert!(app.key_hints.footer(context).contains(key)); assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key))); assert!(app.config.keys.help_text().to_string().contains(key)); @@ -238,27 +239,33 @@ fn playback_checks_render_independently_and_live_hints_use_configured_keys() { #[test] fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() { - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( + for modifiers in [ + KeyModifiers::NONE, + KeyModifiers::SUPER, + KeyModifiers::CONTROL, + ] { + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#, ).unwrap().videos; - app.views[0].selection.select(Some(1)); - assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); - app.views[0].toggle_checked(); - app.views[0].selection.select(Some(0)); - app.options.player = "/nonexistent/tubular-test-player".into(); - app.play(); - assert!(app.player_launch.is_none()); - assert!(app.views[0].checked.contains("rQ_J9WH6CGk")); - assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); - app.options.player = "/usr/bin/true".into(); - app.play(); - assert!(app.player_launch.is_some()); - assert!(app.views[0].checked.is_empty()); - assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw"); - app.player_launch = None; - app.play(); - assert!(app.player_launch.is_some()); + app.views[0].selection.select(Some(1)); + assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); + app.views[0].toggle_checked(); + app.views[0].selection.select(Some(0)); + app.options.player = "/nonexistent/tubular-test-player".into(); + app.key(KeyEvent::new(KeyCode::Enter, modifiers)); + assert!(app.player_launch.is_none()); + assert!(app.views[0].checked.contains("rQ_J9WH6CGk")); + assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); + app.options.player = "/usr/bin/true".into(); + app.key(KeyEvent::new(KeyCode::Enter, modifiers)); + assert!(app.player_launch.is_some()); + assert!(app.views[0].checked.is_empty()); + assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw"); + app.player_launch = None; + app.key(KeyEvent::new(KeyCode::Enter, modifiers)); + assert!(app.player_launch.is_some()); + } } #[test] diff --git a/tests/unit/keys.rs b/tests/unit/keys.rs index 6849bf5..55209e9 100644 --- a/tests/unit/keys.rs +++ b/tests/unit/keys.rs @@ -60,6 +60,39 @@ fn playback_checks_are_configurable_in_views_without_stealing_modal_space() { ); } +#[test] +fn enqueue_is_configurable_and_only_active_in_video_views() { + let defaults = Bindings::default(); + let overrides = configured("[keys.video]\nenqueue = 'f22'\n").unwrap(); + for &context in ALL { + let expected = VIEW.contains(&context).then_some(Action::VideoEnqueue); + for modifiers in [KeyModifiers::SUPER, KeyModifiers::CONTROL] { + assert_eq!( + press(&defaults, context, KeyCode::Enter, modifiers), + expected + ); + assert_ne!( + press(&overrides, context, KeyCode::Enter, modifiers), + Some(Action::VideoEnqueue), + ); + } + assert_eq!( + press(&overrides, context, KeyCode::F(22), KeyModifiers::NONE), + expected, + ); + } + assert_eq!( + press( + &defaults, + Context::Normal, + KeyCode::Enter, + KeyModifiers::NONE + ), + Some(Action::VideoPlay), + ); + assert!(configured("[keys.video]\nenqueue = 'enter'\n").is_err()); +} + #[test] fn provider_choice_is_configurable_and_normal_only() { let defaults = Bindings::default(); diff --git a/tests/unit/miruro_ui.rs b/tests/unit/miruro_ui.rs index 939bd77..ea82aed 100644 --- a/tests/unit/miruro_ui.rs +++ b/tests/unit/miruro_ui.rs @@ -647,20 +647,30 @@ fn catalog_and_saved_show_rows_cannot_be_checked() { let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let generation = browser.generation; + browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)); + assert_eq!(browser.generation, generation); + assert!(matches!(browser.screen().content, Content::Catalog { .. })); + assert!(browser.player.is_none()); assert!(browser.screen().checked.is_empty()); assert!(browser.screen().playback_videos().is_empty()); - assert!( - !browser - .bindings() - .iter() - .any(|(action, _)| { matches!(action, Action::VideoToggle | Action::VideoClear) }) - ); + assert!(!browser.bindings().iter().any(|(action, _)| { + matches!( + action, + Action::VideoToggle | Action::VideoClear | Action::VideoEnqueue + ) + })); browser .state .save_show(&media("first"), &BTreeSet::new()) .unwrap(); browser.library(BTreeSet::new()); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let generation = browser.generation; + browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SUPER)); + assert_eq!(browser.generation, generation); + assert!(matches!(browser.screen().content, Content::Library { .. })); + assert!(browser.player.is_none()); assert!(browser.screen().checked.is_empty()); assert!(browser.screen().playback_videos().is_empty()); enter_series(&mut browser); @@ -726,33 +736,39 @@ fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() { #[cfg(unix)] #[test] fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() { - let directory = Directory::new(); - let mut browser = directory.browser(Some("anime")); - catalog_results(&mut browser); - enter_series(&mut browser); - let first = browser.screen().selected_video().unwrap(); - browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - browser.select(Action::VideoNext); - let hovered = browser.screen().selected_video().unwrap(); - browser.save(CollectionKind::Saved); - browser.set_watch_status(WatchStatus::Watching); - assert!(!browser.state.contains(CollectionKind::Saved, &first.id)); - assert!(!browser.state.contains(CollectionKind::Watching, &first.id)); - assert!(browser.state.contains(CollectionKind::Saved, &hovered.id)); - assert!( - browser - .state - .contains(CollectionKind::Watching, &hovered.id) - ); - browser.open(); - assert!(browser.screen().error.is_some()); - assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()])); - assert_eq!(browser.screen().playback_videos()[0].id, first.id); - browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - browser.options.player = "/usr/bin/true".into(); - browser.open(); - assert!(browser.screen().error.is_none()); - assert!(browser.player.is_some()); - assert!(browser.screen().checked.is_empty()); - assert_eq!(browser.screen().playback_videos()[0].id, hovered.id); + for modifiers in [ + KeyModifiers::NONE, + KeyModifiers::SUPER, + KeyModifiers::CONTROL, + ] { + let directory = Directory::new(); + let mut browser = directory.browser(Some("anime")); + catalog_results(&mut browser); + enter_series(&mut browser); + let first = browser.screen().selected_video().unwrap(); + browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + browser.select(Action::VideoNext); + let hovered = browser.screen().selected_video().unwrap(); + browser.save(CollectionKind::Saved); + browser.set_watch_status(WatchStatus::Watching); + assert!(!browser.state.contains(CollectionKind::Saved, &first.id)); + assert!(!browser.state.contains(CollectionKind::Watching, &first.id)); + assert!(browser.state.contains(CollectionKind::Saved, &hovered.id)); + assert!( + browser + .state + .contains(CollectionKind::Watching, &hovered.id) + ); + browser.key(KeyEvent::new(KeyCode::Enter, modifiers)); + assert!(browser.screen().error.is_some()); + assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()])); + assert_eq!(browser.screen().playback_videos()[0].id, first.id); + browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + browser.options.player = "/usr/bin/true".into(); + browser.key(KeyEvent::new(KeyCode::Enter, modifiers)); + assert!(browser.screen().error.is_none()); + assert!(browser.player.is_some()); + assert!(browser.screen().checked.is_empty()); + assert_eq!(browser.screen().playback_videos()[0].id, hovered.id); + } } diff --git a/tests/unit/video_state.rs b/tests/unit/video_state.rs index 120c2e3..81406e9 100644 --- a/tests/unit/video_state.rs +++ b/tests/unit/video_state.rs @@ -1417,13 +1417,13 @@ fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_ let mut invalid_video = video(); invalid_video.id = id.into(); assert!(matches!( - PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state), + PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state, false), Err(error) if error.kind() == io::ErrorKind::InvalidInput )); } for videos in [vec![], vec![video(), video()]] { assert!(matches!( - PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state), + PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state, false), Err(error) if error.kind() == io::ErrorKind::InvalidInput )); }