feat: add direct view shortcuts and category search
This commit is contained in:
@@ -60,7 +60,9 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
|
||||
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
||||
| Enter | Open the selected video in Terebi |
|
||||
| `g` / Home, `G` / End | Select the first / last loaded video |
|
||||
| `i` | Edit the search query |
|
||||
| `i` | Edit the current category/combined feed's search, or general Search from other views |
|
||||
| `/` | Go directly to general YouTube Search |
|
||||
| `F` (Shift-F) | Go directly to the subscription Feed |
|
||||
| `d` | Open video info; press again for the full description, again to return to info |
|
||||
| Esc | Close the preview; otherwise return to the previous view |
|
||||
| `-` | Return to the previous view |
|
||||
@@ -117,11 +119,19 @@ In terminals supporting bracketed paste, clipboard text is inserted in one opera
|
||||
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here.
|
||||
- Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row.
|
||||
|
||||
### Category search
|
||||
|
||||
Press `i` in a category or combined-category feed to search only its channels' histories—not just already loaded videos. Enter submits a changed query and returns to normal mode; an unchanged query keeps cached results and pagination. Clear the field and submit to restore the ordinary upload feed. Esc leaves the draft unsubmitted.
|
||||
|
||||
Each category and combined feed retains its own draft, submitted query, results, and pagination independently of general Search. Subsequent pages use the same scoped query and shared 60-video budget. Empty categories and failed scoped requests never fall back to global search. Search input in other views still opens general YouTube Search.
|
||||
|
||||
The direct Search and Feed jumps use the configurable `view.search` and `view.feed` actions. They preserve cached views and history, and do not run inside text inputs or selectors.
|
||||
|
||||
### Combined category feeds
|
||||
|
||||
Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories.
|
||||
|
||||
The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores cached rows, selection, and pagination.
|
||||
The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores its query, cached rows, selection, and pagination.
|
||||
|
||||
Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories.
|
||||
|
||||
@@ -286,7 +296,7 @@ Using the same binding for overview and description preserves the default cycle.
|
||||
|
||||
## Loading and previews
|
||||
|
||||
Search loads up to 60 results per page. Feed and category views share a **60-upload total request budget per load**, distributed fairly across active channels—not 60 per channel. Each channel retains its own offset; exhausted channels stop receiving requests, and feeds with more than 60 channels rotate through them across loads. Matching ranges are batched across at most four concurrent yt-dlp processes, with further batches queued. Individual channel views can use the full 60-row budget.
|
||||
General Search loads up to 60 results per page. Feed and category views, including scoped category searches, share a **60-video total request budget per load**, distributed fairly across active channels—not 60 per channel. Each channel retains its own offset; exhausted channels stop receiving requests, and feeds with more than 60 channels rotate through them across loads. Matching ranges are batched across at most four concurrent yt-dlp processes, with further batches queued. Individual channel views can use the full 60-row budget.
|
||||
|
||||
In normal mode, forward video navigation authorizes prefetch when 20 loaded rows remain. Each successful page clears that intent, so short, duplicate-only, or unavailable batches do not immediately chain additional loads at startup. Only one page request runs at a time; insert mode and open popups pause automatic prefetch. A feed page is accepted only after every batch succeeds. Switching views cancels running and queued batches; returning resumes the same unaccepted per-channel ranges. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
|
||||
|
||||
|
||||
+6
-2
@@ -110,7 +110,9 @@ actions! {
|
||||
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
||||
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
|
||||
DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"];
|
||||
SearchEdit, "search.edit", "Edit the video search", VIEW, ["i"];
|
||||
SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
|
||||
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
|
||||
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
|
||||
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"];
|
||||
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
|
||||
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
|
||||
@@ -474,7 +476,9 @@ impl Bindings {
|
||||
&[
|
||||
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||
(&[VideoPlay], "Play the selected video in Terebi"),
|
||||
(&[SearchEdit], "Edit the search query"),
|
||||
(&[SearchEdit], "Edit general or current-category search"),
|
||||
(&[ViewSearch], "Go to general YouTube Search"),
|
||||
(&[ViewFeed], "Go to the combined subscription Feed"),
|
||||
(
|
||||
&[InfoOverview, InfoDescription],
|
||||
"Info → full description → info",
|
||||
|
||||
+435
-93
File diff suppressed because one or more lines are too long
+44
-10
@@ -379,8 +379,34 @@ struct Extraction {
|
||||
completed: Option<String>,
|
||||
}
|
||||
|
||||
fn feed_commands(channels: &[String], requests: &[FeedRequest]) -> io::Result<Vec<Command>> {
|
||||
fn encode_query(query: &str) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
let unreserved =
|
||||
|byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~');
|
||||
let capacity = query
|
||||
.bytes()
|
||||
.map(|byte| if unreserved(byte) { 1 } else { 3 })
|
||||
.sum();
|
||||
let mut encoded = String::with_capacity(capacity);
|
||||
for byte in query.bytes() {
|
||||
if unreserved(byte) {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn feed_commands(
|
||||
channels: &[String],
|
||||
requests: &[FeedRequest],
|
||||
query: &str,
|
||||
) -> io::Result<Vec<Command>> {
|
||||
validate_feed_requests(requests)?;
|
||||
let query = (!query.is_empty()).then(|| encode_query(query));
|
||||
let mut commands = Vec::new();
|
||||
let mut bounds = None;
|
||||
let batch_size = requests.len().div_ceil(MAX_EXTRACTORS);
|
||||
@@ -417,10 +443,10 @@ fn feed_commands(channels: &[String], requests: &[FeedRequest]) -> io::Result<Ve
|
||||
bounds = Some((request.start, request.count));
|
||||
batch_len = 0;
|
||||
}
|
||||
commands
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.arg(format!("{channel}/videos"));
|
||||
commands.last_mut().unwrap().arg(match &query {
|
||||
Some(query) => format!("{channel}/search?query={query}"),
|
||||
None => format!("{channel}/videos"),
|
||||
});
|
||||
batch_len += 1;
|
||||
}
|
||||
Ok(commands)
|
||||
@@ -442,8 +468,8 @@ impl Job {
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn feed(channels: &[String], requests: &[FeedRequest]) -> io::Result<Self> {
|
||||
Self::spawn_all(feed_commands(channels, requests)?)
|
||||
pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result<Self> {
|
||||
Self::spawn_all(feed_commands(channels, requests, query)?)
|
||||
}
|
||||
|
||||
pub fn channel(id: &str) -> io::Result<Self> {
|
||||
@@ -1583,6 +1609,14 @@ mod tests {
|
||||
assert_eq!(exhausted.feed_progress[0].count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_search_query_preserves_utf8_and_isolates_url_parameters() {
|
||||
assert_eq!(
|
||||
encode_query("café 🦀 &next=1#?/+%20\t\n\0 AZaz09-._~"),
|
||||
"caf%C3%A9%20%F0%9F%A6%80%20%26next%3D1%23%3F%2F%2B%2520%09%0A%00%20AZaz09-._~"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_offsets_reject_zero_and_overflow_before_spawning() {
|
||||
assert_eq!(page_end(1).unwrap(), PAGE_SIZE);
|
||||
@@ -1595,12 +1629,12 @@ mod tests {
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
assert!(matches!(
|
||||
Job::feed(&channels, &[FeedRequest { start, ..ONE_CHANNEL[0] }]),
|
||||
Job::feed(&channels, &[FeedRequest { start, ..ONE_CHANNEL[0] }], ""),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
Job::feed(&[], &ONE_CHANNEL),
|
||||
Job::feed(&[], &ONE_CHANNEL, ""),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
@@ -1660,7 +1694,7 @@ mod tests {
|
||||
],
|
||||
] {
|
||||
assert!(matches!(
|
||||
Job::feed(&channels, &requests),
|
||||
Job::feed(&channels, &requests, ""),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user