feat: restore original YouTube titles with character transitions
This commit is contained in:
+29
-2
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user