feat: restore original YouTube titles with character transitions

This commit is contained in:
2026-10-19 18:42:19 +02:00
parent 78b0c92e9b
commit a44d95bb71
6 changed files with 474 additions and 2 deletions
Generated
+1
View File
@@ -1454,6 +1454,7 @@ dependencies = [
"time",
"toml_edit",
"unicode-normalization",
"unicode-segmentation",
"ureq",
]
+1
View File
@@ -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]
+2
View File
@@ -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
+29 -2
View File
@@ -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<PreviewMode>,
help_popup: Option<HelpPopup>,
thumbnail: thumbnail::Thumbnail,
titles: titles::Titles,
description_job: Option<(String, Job)>,
description_error: Option<String>,
// ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows.
@@ -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) {
+370
View File
@@ -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<String>,
}
struct Animation {
id: String,
target: String,
display: String,
old_widths: Vec<usize>,
target_offsets: Vec<usize>,
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<String> {
#[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<CachedTitle>,
animations: Vec<Animation>,
}
impl Titles {
/// Poll metadata, apply canonical titles, and animate only the current visible rows.
pub fn update(&mut self, videos: &mut [Video], visible: Range<usize>, 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<usize>,
now: Instant,
mut start_job: impl FnMut(&str) -> std::io::Result<Job>,
) {
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<String, String>) {
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<Video>) {
let mut titles = Titles::default();
titles.finish(ID.into(), Ok(metadata(ID, target)));
let mut videos = vec![video(ID, old)];
titles.update(&mut videos, 0..1, now);
(titles, videos)
}
#[test]
fn transition_uses_injected_time_and_updates_canonical_title_immediately() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("abcd", "WXYZ", now);
assert_eq!(videos[0].title, "WXYZ");
assert_eq!(videos[0].id, ID);
assert_eq!(titles.title(&videos[0]), "abcd");
titles.update(&mut videos, 0..1, now + Duration::from_millis(19));
assert_eq!(titles.title(&videos[0]), "abcd");
titles.update(&mut videos, 0..1, now + STEP);
assert_eq!(titles.title(&videos[0]), "Wbcd");
titles.update(&mut videos, 0..1, now + STEP * 3);
assert_eq!(titles.title(&videos[0]), "WXYd");
assert!(titles.active());
titles.update(&mut videos, 0..1, now + STEP * 4);
assert_eq!(titles.title(&videos[0]), "WXYZ");
assert!(!titles.active());
}
#[test]
fn shorter_title_blanks_old_graphemes_then_discards_padding() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("abcde", "XY", now);
for (step, expected) in [
(1, "Xbcde"),
(2, "XYcde"),
(3, "XY de"),
(4, "XY e"),
(5, "XY"),
] {
titles.update(&mut videos, 0..1, now + STEP * step);
assert_eq!(titles.title(&videos[0]), expected);
}
assert!(!titles.active());
}
#[test]
fn longer_title_appends_one_grapheme_per_step() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("ab", "WXYZ", now);
for (step, expected) in [(1, "Wb"), (2, "WX"), (3, "WXY"), (4, "WXYZ")] {
titles.update(&mut videos, 0..1, now + STEP * step);
assert_eq!(titles.title(&videos[0]), expected);
}
}
#[test]
fn combining_marks_and_joined_emoji_are_single_steps() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("a\u{301}👨‍👩‍👧‍👦🇩🇰!", "e\u{301}👩🏽‍💻🇯🇵?", now);
for (step, expected) in [
(1, "e\u{301}👨‍👩‍👧‍👦🇩🇰!"),
(2, "e\u{301}👩🏽‍💻🇩🇰!"),
(3, "e\u{301}👩🏽‍💻🇯🇵!"),
(4, "e\u{301}👩🏽‍💻🇯🇵?"),
] {
titles.update(&mut videos, 0..1, now + STEP * step);
assert_eq!(titles.title(&videos[0]), expected);
}
}
#[test]
fn offscreen_completion_and_scrolling_finish_immediately() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("old", "Original", now);
titles.update(&mut videos, 0..0, now + STEP);
assert_eq!(titles.title(&videos[0]), "Original");
assert!(!titles.active());
titles.finish(OTHER_ID.into(), Ok(metadata(OTHER_ID, "Offscreen")));
videos.push(video(OTHER_ID, "flat"));
titles.update(&mut videos, 0..1, now + STEP * 2);
assert_eq!(videos[1].title, "Offscreen");
assert_eq!(titles.title(&videos[1]), "Offscreen");
titles.update(&mut videos, 1..2, now + STEP * 3);
assert!(!titles.active());
}
#[test]
fn invalid_metadata_and_extractor_errors_preserve_flat_title_without_retries() {
let now = Instant::now();
for output in [
Err("extractor failed".into()),
Ok("not json".into()),
Ok(metadata(OTHER_ID, "Wrong video")),
Ok(metadata(ID, "\u{1b}\n\t")),
Ok(metadata(ID, " ")),
Ok(serde_json::json!({"id": ID}).to_string()),
] {
let mut titles = Titles::default();
titles.finish(ID.into(), output);
let mut videos = vec![video(ID, "Keep this")];
titles.update(&mut videos, 0..1, now);
titles.update(&mut videos, 0..1, now + STEP * 100);
assert_eq!(videos[0].title, "Keep this");
assert_eq!(titles.title(&videos[0]), "Keep this");
assert!(!titles.active());
}
assert_eq!(
full_title(&metadata(ID, "Safe\n\u{1b} title"), ID).as_deref(),
Some("Safe title")
);
}
#[test]
fn cache_is_bounded_and_visible_failures_are_not_evicted() {
let now = Instant::now();
let mut titles = Titles::default();
titles.finish(ID.into(), Err("failed".into()));
for index in 0..CACHE_SIZE {
titles.finish(format!("{index:011}"), Err("failed".into()));
}
let mut videos = vec![video(ID, "Keep this")];
titles.update(&mut videos, 0..1, now);
assert_eq!(titles.cached.len(), CACHE_SIZE);
assert!(titles.cached.iter().any(|entry| entry.id == ID));
assert!(!titles.active());
}
#[test]
fn invalid_ids_and_empty_or_out_of_bounds_viewports_never_fetch() {
let now = Instant::now();
let mut titles = Titles::default();
let mut videos = vec![video("not/youtube", "Local")];
titles.update(&mut videos, 0..1, now);
titles.update(&mut videos, 20..30, now);
titles.update(&mut videos, 1..0, now);
assert!(!titles.active());
assert_eq!(titles.title(&videos[0]), "Local");
}
#[test]
fn scheduling_only_attempts_visible_valid_uncached_ids_once_after_failure() {
let now = Instant::now();
let mut titles = Titles::default();
let mut videos = vec![
video(ID, "Offscreen"),
video("not/youtube", "Local"),
video(OTHER_ID, "Visible"),
];
let mut requested = Vec::new();
for step in 0..3 {
titles.update_with(&mut videos, 1..3, now + STEP * step, |id| {
requested.push(id.to_owned());
Err(std::io::ErrorKind::NotFound.into())
});
}
assert_eq!(requested, vec![OTHER_ID.to_owned()]);
assert_eq!(videos[2].title, "Visible");
assert!(!titles.active());
}
}
+71
View File
@@ -67,6 +67,77 @@ fn app(args: &[&str]) -> App {
)
}
#[test]
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
let mut app = app(&["tubular"]);
let template =
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Listing title"}]}"#)
.unwrap()
.videos
.remove(0);
app.views[0].videos = (0..12)
.map(|index| {
let mut video = template.clone();
// Non-YouTube IDs keep this rendering check entirely offline.
video.id = format!("offline:{index}");
video.title = format!("Row {index:02}");
video
})
.collect();
app.views[0].selection.select(Some(9));
let now = Instant::now();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw_at(frame, Duration::ZERO, now))
.unwrap();
assert_eq!(app.views[0].selection.offset(), 3);
assert_eq!(
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
"offline:9"
);
assert!(!app.titles.active());
terminal.backend_mut().resize(80, 17);
terminal
.draw(|frame| {
app.draw_at(
frame,
Duration::from_millis(20),
now + Duration::from_millis(20),
)
})
.unwrap();
assert_eq!(app.views[0].selection.offset(), 7);
assert_eq!(
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
"offline:9"
);
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("Row 09"));
app.views[0].selection.select(Some(0));
terminal
.draw(|frame| {
app.draw_at(
frame,
Duration::from_millis(40),
now + Duration::from_millis(40),
)
})
.unwrap();
assert_eq!(app.views[0].selection.offset(), 0);
assert_eq!(
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
"offline:0"
);
}
#[test]
fn youtube_collections_isolate_mixed_provider_rows_and_removal_ids() {
let mut app = app(&["tubular"]);