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
+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());
}
}