diff --git a/Cargo.lock b/Cargo.lock index 309434b..c49b1d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1454,6 +1454,7 @@ dependencies = [ "time", "toml_edit", "unicode-normalization", + "unicode-segmentation", "ureq", ] diff --git a/Cargo.toml b/Cargo.toml index f390b22..8ed47b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ serde_json = { version = "1.0.151", features = ["raw_value"] } toml_edit = "0.25.17" time = "0.3.55" unicode-normalization = "0.1.25" +unicode-segmentation = "1.13.3" ureq = { version = "3.4.2", default-features = false, features = ["rustls"] } [target."cfg(unix)".dependencies] diff --git a/README.md b/README.md index eac2914..3885a4a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ After selection, the browser starts in **normal mode**. Press `i` to type a sear Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `βœ“` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata. YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable. +YouTube keeps fast flat listings, then resolves visible videos' original-preferred titles asynchronously, one metadata request at a time. Titles replace the listing text left to right, one user-perceived character about every 20 ms; shorter titles erase the old suffix with spaces before dropping that padding. Offscreen changes apply immediately, and selection stays on the same video. Results are cached by video ID for the session; failures keep the listing title without automatic retries. This adds background yt-dlp requests, and opening video info can make a separate metadata request. β€œOriginal-preferred” uses the full extractor response, not a guarantee that YouTube will never return localized text. + Selecting a YouTube video launches: ```sh diff --git a/src/main.rs b/src/main.rs index 82b407a..0cc1191 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,6 +122,7 @@ mod keys; mod miruro_browser; mod miruro_ui; mod thumbnail; +mod titles; mod ui; mod video_state; mod youtube; @@ -939,6 +940,7 @@ struct App { preview: Option, help_popup: Option, thumbnail: thumbnail::Thumbnail, + titles: titles::Titles, description_job: Option<(String, Job)>, description_error: Option, // ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows. @@ -998,6 +1000,7 @@ impl App { preview: None, help_popup: None, thumbnail: thumbnail::Thumbnail::new(), + titles: titles::Titles::default(), description_job: None, description_error: None, descriptions: std::collections::VecDeque::with_capacity(DESCRIPTION_CACHE_SIZE), @@ -2926,6 +2929,10 @@ impl App { } fn draw(&mut self, frame: &mut Frame, elapsed: Duration) { + self.draw_at(frame, elapsed, Instant::now()); + } + + fn draw_at(&mut self, frame: &mut Frame, elapsed: Duration, now: Instant) { let spinner = ui::spinner(elapsed); let area = frame.area(); let mut tab_lines = vec![Line::default()]; @@ -2979,6 +2986,25 @@ impl App { self.mode == Mode::Insert && self.category_dialog.is_none(), ); let view = &mut self.views[self.view_index]; + let rows = usize::from(results.height.saturating_sub(2) / 2); + let mut offset = view + .selection + .offset() + .min(view.videos.len().saturating_sub(rows)); + if let Some(selected) = view + .selection + .selected() + .filter(|&index| index < view.videos.len()) + { + if selected < offset { + offset = selected; + } else if rows > 0 && selected >= offset + rows { + offset = selected + 1 - rows; + } + } + *view.selection.offset_mut() = offset; + self.titles + .update(&mut view.videos, offset..offset.saturating_add(rows), now); let table = Table::new( view.videos.iter().map(|video| { let tagged = self @@ -2986,7 +3012,7 @@ impl App { .categories .values() .any(|channels| video_in_channels(video, channels, &self.config)); - let title = Span::raw(video.title.as_str()); + let title = Span::raw(self.titles.title(video)); let channel = Span::raw(video.channel.as_deref().unwrap_or("Unknown")); Row::new([ Cell::from(if self.video_state.is_watched(&video.id) { @@ -3555,7 +3581,8 @@ impl App { loop { self.update(); terminal.draw(|frame| self.draw(frame, started.elapsed()))?; - if event::poll(Duration::from_millis(100))? { + let refresh = Duration::from_millis(if self.titles.active() { 20 } else { 100 }); + if event::poll(refresh)? { match event::read()? { Event::Key(key) if key.kind != KeyEventKind::Release => { if self.key(key) { diff --git a/src/titles.rs b/src/titles.rs new file mode 100644 index 0000000..2123fb7 --- /dev/null +++ b/src/titles.rs @@ -0,0 +1,370 @@ +use std::{ + collections::VecDeque, + ops::Range, + time::{Duration, Instant}, +}; + +use serde::Deserialize; +use unicode_segmentation::UnicodeSegmentation; + +use crate::youtube::{self, Job, Video}; + +const CACHE_SIZE: usize = 256; +const STEP: Duration = Duration::from_millis(20); + +struct CachedTitle { + id: String, + // Cache failures too, so a failed extractor is not restarted every frame. + title: Option, +} + +struct Animation { + id: String, + target: String, + display: String, + old_widths: Vec, + target_offsets: Vec, + replaced: usize, + started: Instant, +} + +impl Animation { + fn new(id: String, old: String, target: String, now: Instant) -> Self { + let old_widths = old.graphemes(true).map(str::len).collect(); + let mut target_offsets: Vec<_> = target + .grapheme_indices(true) + .map(|(index, _)| index) + .collect(); + target_offsets.push(target.len()); + Self { + id, + target, + display: old, + old_widths, + target_offsets, + replaced: 0, + started: now, + } + } + + // Return false at the final step: the caller then displays the unpadded canonical title. + fn advance(&mut self, now: Instant) -> bool { + let target_len = self.target_offsets.len() - 1; + let total = self.old_widths.len().max(target_len); + let steps = (now.saturating_duration_since(self.started).as_nanos() / STEP.as_nanos()) + .min(total as u128) as usize; + if steps == total { + return false; + } + for index in self.replaced..steps { + let (cursor, replacement) = if index < target_len { + let start = self.target_offsets[index]; + let end = self.target_offsets[index + 1]; + (start, &self.target[start..end]) + } else { + (self.target.len() + index - target_len, " ") + }; + let width = self.old_widths.get(index).copied().unwrap_or(0); + self.display + .replace_range(cursor..cursor + width, replacement); + } + self.replaced = steps; + true + } +} + +fn full_title(output: &str, expected_id: &str) -> Option { + #[derive(Deserialize)] + struct Details { + id: String, + title: String, + } + let mut details: Details = serde_json::from_str(output).ok()?; + if !youtube::valid_video_id(expected_id) || details.id != expected_id { + return None; + } + // Match listing extraction's terminal-safety rules, then reject unusable metadata. + details.title.retain(|character| !character.is_control()); + (!details.title.trim().is_empty()).then_some(details.title) +} + +#[derive(Default)] +pub struct Titles { + fetch: Option<(String, Job)>, + cached: VecDeque, + animations: Vec, +} + +impl Titles { + /// Poll metadata, apply canonical titles, and animate only the current visible rows. + pub fn update(&mut self, videos: &mut [Video], visible: Range, now: Instant) { + #[cfg(not(test))] + self.update_with(videos, visible, now, Job::description); + // Ordinary app render tests must never launch an external network extractor. + #[cfg(test)] + self.update_with(videos, visible, now, |_| { + Err(std::io::ErrorKind::NotFound.into()) + }); + } + + fn update_with( + &mut self, + videos: &mut [Video], + visible: Range, + now: Instant, + mut start_job: impl FnMut(&str) -> std::io::Result, + ) { + let start = visible.start.min(videos.len()); + let visible = start..visible.end.min(videos.len()).max(start); + self.animations.retain_mut(|animation| { + videos[visible.clone()] + .iter() + .any(|video| video.id == animation.id && video.title == animation.target) + && animation.advance(now) + }); + + if let Some(result) = self.fetch.as_mut().and_then(|(_, job)| job.poll()) { + let (id, _) = self.fetch.take().unwrap(); + self.finish(id, result); + } + + for (index, video) in videos.iter_mut().enumerate() { + let Some(title) = self + .cached + .iter() + .find(|entry| entry.id == video.id) + .and_then(|entry| entry.title.as_ref()) + else { + continue; + }; + if video.title == *title { + continue; + } + let old = std::mem::replace(&mut video.title, title.clone()); + if visible.contains(&index) { + self.animations.retain(|animation| animation.id != video.id); + self.animations + .push(Animation::new(video.id.clone(), old, title.clone(), now)); + } + } + + if self.fetch.is_none() + && let Some(video) = videos[visible.clone()].iter().find(|video| { + youtube::valid_video_id(&video.id) + && !self.cached.iter().any(|entry| entry.id == video.id) + }) + { + match start_job(&video.id) { + Ok(job) => self.fetch = Some((video.id.clone(), job)), + Err(error) => self.finish(video.id.clone(), Err(error.to_string())), + } + } + + // Keep visible successes and failures pinned; even a very tall viewport cannot thrash. + let capacity = CACHE_SIZE.max(visible.len()); + while self.cached.len() > capacity { + let index = self + .cached + .iter() + .position(|entry| { + !videos[visible.clone()] + .iter() + .any(|video| video.id == entry.id) + }) + .unwrap(); + self.cached.remove(index); + } + } + + fn finish(&mut self, id: String, output: Result) { + let title = output.ok().and_then(|output| full_title(&output, &id)); + self.cached.push_back(CachedTitle { id, title }); + } + + pub fn title<'a>(&'a self, video: &'a Video) -> &'a str { + self.animations + .iter() + .find(|animation| animation.id == video.id) + .map_or(&video.title, |animation| animation.display.as_str()) + } + + /// Pending extraction or visible animation requires another event-loop tick. + pub fn active(&self) -> bool { + self.fetch.is_some() || !self.animations.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "abcdefghijk"; + const OTHER_ID: &str = "lmnopqrstuv"; + + fn video(id: &str, title: &str) -> Video { + serde_json::from_value(serde_json::json!({ "id": id, "title": title })).unwrap() + } + + fn metadata(id: &str, title: &str) -> String { + serde_json::json!({ "id": id, "title": title }).to_string() + } + + fn seeded(old: &str, target: &str, now: Instant) -> (Titles, Vec