From 85ae96190ca0e33ba5088f5a49a706bacf19c649 Mon Sep 17 00:00:00 2001 From: Captain-Vikram Date: Sat, 7 Nov 2026 23:03:22 +0530 Subject: [PATCH 1/5] feat(video): add storyboard-based video hover previews (#429) --- .../api/innertube/extractors/storyboard.rs | 141 ++++++++ src-tauri/src/commands/youtube.rs | 16 +- src-tauri/src/lib.rs | 6 +- src-tauri/src/services/youtube_service.rs | 8 +- src/components/settings/tabs/ContentTab.tsx | 12 + src/components/video/VideoCard.tsx | 33 ++ .../video/VideoThumbnailPreview.test.ts | 277 ++++++++++++++ .../video/VideoThumbnailPreview.tsx | 237 ++++++++++++ src/lib/api/youtube.ts | 7 + src/lib/i18n/strings.json | 2 + src/lib/settings/schema.ts | 3 + src/lib/useHoverPreview.test.ts | 305 ++++++++++++++++ src/lib/useHoverPreview.ts | 340 ++++++++++++++++++ src/store/useHoverPreviewStore.test.ts | 32 ++ src/store/useHoverPreviewStore.ts | 19 + 15 files changed, 1434 insertions(+), 4 deletions(-) create mode 100644 src/components/video/VideoThumbnailPreview.test.ts create mode 100644 src/components/video/VideoThumbnailPreview.tsx create mode 100644 src/lib/useHoverPreview.test.ts create mode 100644 src/lib/useHoverPreview.ts create mode 100644 src/store/useHoverPreviewStore.test.ts create mode 100644 src/store/useHoverPreviewStore.ts diff --git a/src-tauri/src/api/innertube/extractors/storyboard.rs b/src-tauri/src/api/innertube/extractors/storyboard.rs index 7bfab64b..00bc3332 100644 --- a/src-tauri/src/api/innertube/extractors/storyboard.rs +++ b/src-tauri/src/api/innertube/extractors/storyboard.rs @@ -6,10 +6,81 @@ //! is the template with `$L` as the level's index and `$N` as its name, plus //! its `sigh`; `$M` in the name numbers the sheets. +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + use serde_json::Value; +use crate::api::innertube::InnertubeClient; +use crate::api::innertube::core::clients; +use crate::api::innertube::core::player_requests::PlayerFlights; +use crate::errors::AppResult; use crate::models::video::StoryboardLevel; +const STORYBOARD_CACHE_CAPACITY: usize = 100; +const STORYBOARD_TTL: Duration = Duration::from_secs(3600); // 1 hour + +#[derive(Clone, Debug)] +struct CachedStoryboard { + levels: Vec, + resolved_at: Instant, +} + +fn storyboard_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn flights() -> &'static PlayerFlights>> { + static FLIGHTS: OnceLock>>> = OnceLock::new(); + FLIGHTS.get_or_init(PlayerFlights::default) +} + +fn cached_storyboard(video_id: &str) -> Option> { + let mut cache = storyboard_cache().lock().ok()?; + let entry = cache.get(video_id)?; + if entry.resolved_at.elapsed() >= STORYBOARD_TTL { + cache.remove(video_id); + return None; + } + Some(entry.levels.clone()) +} + +fn store_storyboard(video_id: &str, levels: Vec) { + let Ok(mut cache) = storyboard_cache().lock() else { + return; + }; + cache.retain(|_, entry| entry.resolved_at.elapsed() < STORYBOARD_TTL); + if cache.len() >= STORYBOARD_CACHE_CAPACITY { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, entry)| entry.resolved_at) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest); + } + } + cache.insert( + video_id.to_string(), + CachedStoryboard { + levels, + resolved_at: Instant::now(), + }, + ); +} + +fn extract_duration_seconds(response: &Value) -> Option { + response["videoDetails"]["lengthSeconds"] + .as_str() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + response["microformat"]["playerMicroformatRenderer"]["lengthSeconds"] + .as_str() + .and_then(|value| value.parse::().ok()) + }) +} + #[must_use] pub fn parse_storyboard(player: &Value, duration_seconds: Option) -> Vec { player["storyboards"]["playerStoryboardSpecRenderer"]["spec"] @@ -61,6 +132,50 @@ fn parse_spec(spec: &str, duration_seconds: Option) -> Vec .collect() } +impl InnertubeClient { + /// Fetches storyboard levels for hover preview or seek scrubbing. + /// Deduplicated via `PlayerFlights` and cached in-memory with a 1-hour TTL. + /// Unplayable videos or missing specs return an empty vector gracefully. + pub async fn get_video_storyboard(&self, video_id: &str) -> AppResult> { + let video_id_trimmed = video_id.trim(); + if video_id_trimmed.is_empty() { + return Ok(Vec::new()); + } + + if let Some(cached) = cached_storyboard(video_id_trimmed) { + return Ok(cached); + } + + let flight = flights().for_video(video_id_trimmed); + let outcome = flight + .get_or_init(|| async { + let mut payload = serde_json::json!({ + "videoId": video_id_trimmed, + "playbackContext": { + "contentPlaybackContext": { + "signatureTimestamp": clients::DEFAULT_SIGNATURE_TIMESTAMP, + } + }, + "contentCheckOk": true, + "racyCheckOk": true, + }); + + let response = self + .post_innertube("player", &clients::WEB, &mut payload) + .await + .ok()?; + + let duration_seconds = extract_duration_seconds(&response); + let levels = parse_storyboard(&response, duration_seconds); + store_storyboard(video_id_trimmed, levels.clone()); + Some(levels) + }) + .await; + + Ok(outcome.clone().unwrap_or_default()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -101,4 +216,30 @@ mod tests { assert!(parse_spec("https://x/$L|bad#level", Some(60)).is_empty()); assert!(parse_spec("https://x/$L/$N|48#27#100#10#10#0#default#s", None).is_empty()); } + + #[test] + fn storyboard_cache_stores_and_evicts_oldest() { + let test_levels = vec![StoryboardLevel { + width: 160, + height: 90, + count: 10, + columns: 5, + rows: 5, + interval_ms: 2000, + url_template: "https://example.com/sheet.jpg".to_string(), + }]; + + store_storyboard("test_vid_1", test_levels.clone()); + let cached = cached_storyboard("test_vid_1"); + assert!(cached.is_some()); + assert_eq!(cached.unwrap()[0].width, 160); + + // Fill past capacity to verify eviction + for i in 0..105 { + store_storyboard(&format!("fill_vid_{i}"), test_levels.clone()); + } + + let cache = storyboard_cache().lock().unwrap(); + assert!(cache.len() <= STORYBOARD_CACHE_CAPACITY); + } } diff --git a/src-tauri/src/commands/youtube.rs b/src-tauri/src/commands/youtube.rs index 46deba17..84df2538 100644 --- a/src-tauri/src/commands/youtube.rs +++ b/src-tauri/src/commands/youtube.rs @@ -12,7 +12,8 @@ use crate::models::explore::{ExploreGrid, ExplorePage, ExploreTarget}; use crate::models::playlist::PlaylistDetailsResponse; use crate::models::search::{SearchSuggestion, SearchVideosRequest, SearchVideosResponse}; use crate::models::video::{ - RelatedPage, StreamInfo, StreamVariant, VideoDetails, VideoMetadata, VideoSummary, + RelatedPage, StoryboardLevel, StreamInfo, StreamVariant, VideoDetails, VideoMetadata, + VideoSummary, }; use crate::security::validation::{ validate_browse_id, validate_channel_id, validate_page_token, validate_search_query, @@ -389,6 +390,19 @@ pub async fn get_video_category( .map_err(ErrorResponse::from) } +/// The storyboard specification for hover previews and seek thumbnails. +#[tauri::command] +pub async fn get_video_storyboard( + video_id: String, + youtube_service: State<'_, YoutubeService>, +) -> Result, ErrorResponse> { + validate_video_id(&video_id).map_err(ErrorResponse::from)?; + youtube_service + .get_video_storyboard(&video_id) + .await + .map_err(ErrorResponse::from) +} + #[tauri::command] pub async fn get_more_related_videos( continuation: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c25a81b4..7061ccde 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -145,8 +145,9 @@ use commands::youtube::{ get_playlist_details, get_post_comments, get_related_videos, get_return_youtube_dislike, get_sabr_debug_state, get_search_suggestions, get_sponsorblock_segments, get_stream_info, get_subscription_rotation_feed, get_subscription_rss_feed, get_unplayable_video_ids, - get_video_category, get_video_details, get_video_metadata, parse_subscription_export, - resolve_channel_id, search_videos, stream_subscription_rss_feed, submit_sponsorblock_segment, + get_video_category, get_video_details, get_video_metadata, get_video_storyboard, + parse_subscription_export, resolve_channel_id, search_videos, stream_subscription_rss_feed, + submit_sponsorblock_segment, }; use services::discord_presence::DiscordPresence; use services::music_service::MusicService; @@ -600,6 +601,7 @@ pub fn run() { get_more_related_videos, get_video_category, get_video_metadata, + get_video_storyboard, get_stream_info, get_unplayable_video_ids, get_channel_details, diff --git a/src-tauri/src/services/youtube_service.rs b/src-tauri/src/services/youtube_service.rs index 8123f202..35f8c6bb 100644 --- a/src-tauri/src/services/youtube_service.rs +++ b/src-tauri/src/services/youtube_service.rs @@ -10,7 +10,9 @@ use crate::models::live_chat::LiveChatResponse; use crate::models::playlist::PlaylistDetailsResponse; use crate::models::search::{SearchSuggestion, SearchVideosRequest, SearchVideosResponse}; use crate::models::shorts::{ShortItem, ShortsFeed}; -use crate::models::video::{RelatedPage, StreamInfo, VideoBasics, VideoDetails, VideoMetadata}; +use crate::models::video::{ + RelatedPage, StoryboardLevel, StreamInfo, VideoBasics, VideoDetails, VideoMetadata, +}; #[derive(Clone)] pub struct YoutubeService { @@ -46,6 +48,10 @@ impl YoutubeService { self.extractor.get_video_metadata(video_id).await } + pub async fn get_video_storyboard(&self, video_id: &str) -> AppResult> { + self.extractor.get_video_storyboard(video_id).await + } + pub async fn get_video_category(&self, video_id: &str) -> AppResult> { self.extractor.get_video_category(video_id).await } diff --git a/src/components/settings/tabs/ContentTab.tsx b/src/components/settings/tabs/ContentTab.tsx index 0f4bd208..e18d33e4 100644 --- a/src/components/settings/tabs/ContentTab.tsx +++ b/src/components/settings/tabs/ContentTab.tsx @@ -21,6 +21,7 @@ import { BlockedKeywordsPanel } from '../BlockedKeywordsPanel'; export function ContentTab() { const deepFlowActive = useAppSettingsStore((state) => state.values[SETTINGS.DEEP_FLOW_ACTIVE] === 'true'); const [titleMaxLines, setTitleMaxLines] = useNumberPref(SETTINGS.VIDEO_TITLE_MAX_LINES, 1); + const [hoverPreviews, setHoverPreviews] = useBoolPref(SETTINGS.VIDEO_HOVER_PREVIEWS_ENABLED, true); const [gridColumns, setGridColumns] = useNumberPref(SETTINGS.GRID_COLUMNS, GRID_COLUMNS_DEFAULT); const [homeFeed, setHomeFeed] = useBoolPref(SETTINGS.HOME_FEED_ENABLED, true); const [appLogo, setAppLogo] = useBoolPref(SETTINGS.SHOW_APP_LOGO_ICON, true); @@ -116,6 +117,17 @@ export function ContentTab() { { value: '0', label: getString('settings_option_unlimited') }, { value: '1', label: getString('settings_option_one_line') }, { value: '2', label: getString('settings_option_two_lines') }, { value: '3', label: getString('settings_option_three_lines') }, ]} /> + + + diff --git a/src/components/video/VideoCard.tsx b/src/components/video/VideoCard.tsx index 27197a81..e3c1d446 100644 --- a/src/components/video/VideoCard.tsx +++ b/src/components/video/VideoCard.tsx @@ -39,6 +39,8 @@ import { CardPills, ChannelLabel, CollabAvatars, UpcomingBadge } from "./CardMet import { collaboratorsByline } from "../../lib/collaborators"; import { backgroundTabClickProps, openedInBackgroundTab, routeForVideo } from '../../lib/openTarget'; import { openActions } from '../common/openActions'; +import { useHoverPreview } from '../../lib/useHoverPreview'; +import { VideoThumbnailPreview } from './VideoThumbnailPreview'; export interface VideoCardProps { video: VideoSummary; @@ -196,6 +198,19 @@ function VideoCardComponent({ ); const { src: displayThumbnail, onError: handleThumbnailError } = useImageFallback(thumbnailCandidates); + const preview = useHoverPreview({ + videoId: video.id, + isHovered, + durationSeconds: video.durationSeconds, + targetWidth: thumbnailRef.current?.offsetWidth + ? thumbnailRef.current.offsetWidth * (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) + : undefined, + targetHeight: thumbnailRef.current?.offsetHeight + ? thumbnailRef.current.offsetHeight * (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) + : undefined, + enabled: !isChannel && !isLiveVideo && !video.isUpcoming, + }); + const handleMouseEnter = useCallback(() => { setIsHovered(true); // getImageData is a synchronous pixel readback — cheap on GPU-composited @@ -581,6 +596,12 @@ function VideoCardComponent({ ) : (
)} + {isLiveVideo ? ( ) : video.isUpcoming ? ( @@ -686,6 +707,12 @@ function VideoCardComponent({ ) : (
)} + {isLiveVideo ? ( @@ -776,6 +803,12 @@ function VideoCardComponent({ ) : (
)} + {isLiveVideo ? ( diff --git a/src/components/video/VideoThumbnailPreview.test.ts b/src/components/video/VideoThumbnailPreview.test.ts new file mode 100644 index 00000000..99ceb3c8 --- /dev/null +++ b/src/components/video/VideoThumbnailPreview.test.ts @@ -0,0 +1,277 @@ +import { createElement, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { VideoThumbnailPreview } from "./VideoThumbnailPreview"; +import type { StoryboardFrame } from "../../lib/seekPreview"; +import type { StoryboardLevel } from "../../types/video"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockLevel: StoryboardLevel = { + width: 160, + height: 90, + count: 100, + columns: 5, + rows: 5, + intervalMs: 1000, + urlTemplate: "https://example.com/sheet_$M.jpg", +}; + +const mockFrame: StoryboardFrame = { + url: "https://example.com/sheet_0.jpg", + x: 160, + y: 90, + sheetWidth: 800, + sheetHeight: 450, +}; + +describe("VideoThumbnailPreview", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + host.remove(); + }); + + it("renders nothing when isActive is false", () => { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: false, + frame: mockFrame, + level: mockLevel, + }) + ); + }); + + expect(host.innerHTML).toBe(""); + }); + + it("renders nothing when frame or level is null", () => { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: null, + level: mockLevel, + }) + ); + }); + expect(host.innerHTML).toBe(""); + + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: mockFrame, + level: null, + }) + ); + }); + expect(host.innerHTML).toBe(""); + }); + + it("renders sprite image with exact percentage dimensions and offsets when active", () => { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: mockFrame, + level: mockLevel, + }) + ); + }); + + const img = host.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.src).toBe(mockFrame.url); + expect(img?.getAttribute("aria-hidden")).toBeNull(); + expect(img?.getAttribute("alt")).toBe(""); + expect(img?.getAttribute("tabindex")).toBe("-1"); + + // columns = 5 -> width: 500% + // rows = 5 -> height: 500% + // x = 160, width = 160 -> left: -100% + // y = 90, height = 90 -> top: -100% + expect(img?.style.width).toBe("500%"); + expect(img?.style.height).toBe("500%"); + expect(img?.style.left).toBe("-100%"); + expect(img?.style.top).toBe("-100%"); + + const wrapper = host.firstElementChild; + expect(wrapper?.getAttribute("aria-hidden")).toBe("true"); + expect(wrapper?.className).toContain("pointer-events-none"); + expect(wrapper?.className).toContain("overflow-hidden"); + }); + + it("double-buffers across sheet changes so previous frame covers base thumbnail", () => { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: mockFrame, + level: mockLevel, + }) + ); + }); + + const secondFrame: StoryboardFrame = { + url: "https://example.com/sheet_1.jpg", + x: 320, + y: 180, + sheetWidth: 800, + sheetHeight: 450, + }; + + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: secondFrame, + level: mockLevel, + }) + ); + }); + + const images = host.querySelectorAll("img"); + expect(images.length).toBe(2); + // Back buffer has first frame sheet + expect(images[0]?.src).toBe(mockFrame.url); + // Front buffer has second frame sheet + expect(images[1]?.src).toBe(secondFrame.url); + }); + + it("calculates exact pixel dimensions preserving vertical fit and aspect ratio", () => { + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = () => ({ + width: 320, + height: 180, + top: 0, + left: 0, + bottom: 180, + right: 320, + x: 0, + y: 0, + toJSON: () => {}, + }); + + try { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: mockFrame, + level: mockLevel, + }) + ); + }); + + const img = host.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.style.width).toBe("1600px"); + expect(img?.style.height).toBe("900px"); + expect(img?.style.left).toBe("-320px"); + expect(img?.style.top).toBe("-180px"); + + const viewport = img?.parentElement as HTMLDivElement; + expect(viewport).not.toBeNull(); + expect(viewport.style.overflow).toBe("hidden"); + expect(viewport.style.width).toBe("320px"); + expect(viewport.style.height).toBe("180px"); + expect(viewport.style.left).toBe("0px"); + } finally { + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + } + }); + + it("strictly isolates vertical (portrait) video frames within a centered single-tile viewport", () => { + const portraitLevel: StoryboardLevel = { + width: 100, + height: 200, + count: 9, + columns: 3, + rows: 3, + intervalMs: 2000, + urlTemplate: "https://example.com/portrait_$M.jpg", + }; + const portraitFrame: StoryboardFrame = { + url: "https://example.com/portrait_0.jpg", + x: 100, // column index 1 (center column) + y: 0, + sheetWidth: 300, + sheetHeight: 600, + }; + + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = () => ({ + width: 360, + height: 200, + top: 0, + left: 0, + bottom: 200, + right: 360, + x: 0, + y: 0, + toJSON: () => {}, + }); + + try { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: portraitFrame, + level: portraitLevel, + }) + ); + }); + + const img = host.querySelector("img"); + expect(img).not.toBeNull(); + // Tile height matches container height (200px), scale = 1.0 -> tileWidth = 100px + // Centered: tileLeft = (360 - 100) / 2 = 130px + const viewport = img?.parentElement as HTMLDivElement; + expect(viewport).not.toBeNull(); + expect(viewport.style.overflow).toBe("hidden"); + expect(viewport.style.width).toBe("100px"); + expect(viewport.style.height).toBe("200px"); + expect(viewport.style.left).toBe("130px"); + + // Sheet has 3 columns -> width: 300px, 3 rows -> height: 600px + // For column 1 (x = 100), img is offset by -100px inside the 100px-wide viewport, + // cleanly hiding column 0 (on the left) and column 2 (on the right). + expect(img?.style.width).toBe("300px"); + expect(img?.style.height).toBe("600px"); + expect(img?.style.left).toBe("-100px"); + expect(img?.style.top).toBe("0px"); + } finally { + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + } + }); + + it("renders a 2px progress bar reflecting current playback progress", () => { + act(() => { + root.render( + createElement(VideoThumbnailPreview, { + isActive: true, + frame: mockFrame, + level: mockLevel, + progressPercent: 40, + }) + ); + }); + + const progressBar = host.querySelector(".bg-primary"); + expect(progressBar).not.toBeNull(); + expect((progressBar as HTMLElement)?.style.width).toBe("40%"); + }); +}); diff --git a/src/components/video/VideoThumbnailPreview.tsx b/src/components/video/VideoThumbnailPreview.tsx new file mode 100644 index 00000000..90c1904e --- /dev/null +++ b/src/components/video/VideoThumbnailPreview.tsx @@ -0,0 +1,237 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import type { StoryboardFrame } from "../../lib/seekPreview"; +import type { StoryboardLevel } from "../../types/video"; +import { IS_LINUX_RUNTIME } from "../../lib/platform"; + +export interface VideoThumbnailPreviewProps { + frame: StoryboardFrame | null; + level: StoryboardLevel | null; + isActive: boolean; + progressPercent?: number; + className?: string; +} + +interface FrameTile { + key: string; + url: string; + x: number; + y: number; + levelWidth: number; + levelHeight: number; + columns: number; + rows: number; +} + +function getTileGeometry( + tile: FrameTile, + containerWidth: number, + containerHeight: number +) { + if (containerWidth <= 0 || containerHeight <= 0) { + return { + viewportStyle: { + position: "absolute" as const, + inset: 0, + overflow: "hidden" as const, + }, + imgStyle: { + position: "absolute" as const, + width: `${tile.columns * 100}%`, + height: `${tile.rows * 100}%`, + left: `-${(tile.x / tile.levelWidth) * 100}%`, + top: `-${(tile.y / tile.levelHeight) * 100}%`, + }, + }; + } + + // Exact card pixel dimension calculation: + // 1. Fits vertically: tileHeight always matches card container height (100% vertical fit) + const tileHeight = containerHeight; + + // 2. Proportional width: calculated from native aspect ratio (NO stretching or squishing!) + const tileWidth = + tile.levelHeight > 0 + ? (tileHeight * tile.levelWidth) / tile.levelHeight + : containerWidth; + + // 3. Center horizontally inside the card container + const tileLeft = (containerWidth - tileWidth) / 2; + + // 4. Full sprite sheet dimensions + const sheetWidth = tileWidth * tile.columns; + const sheetHeight = tileHeight * tile.rows; + + // 5. Image offset relative to the isolated single-tile viewport + const imgLeft = -((tile.x / tile.levelWidth) * tileWidth); + const imgTop = -((tile.y / tile.levelHeight) * tileHeight); + + return { + viewportStyle: { + position: "absolute" as const, + top: 0, + left: `${tileLeft}px`, + width: `${tileWidth}px`, + height: `${tileHeight}px`, + overflow: "hidden" as const, + }, + imgStyle: { + position: "absolute" as const, + width: `${sheetWidth}px`, + height: `${sheetHeight}px`, + left: `${imgLeft}px`, + top: `${imgTop}px`, + }, + }; +} + +export const VideoThumbnailPreview: React.FC = ({ + frame, + level, + isActive, + progressPercent = 0, + className = "", +}) => { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + const [isRendered, setIsRendered] = useState(false); + const [current, setCurrent] = useState(null); + const [previous, setPrevious] = useState(null); + + const setContainerNode = useCallback((el: HTMLDivElement | null) => { + containerRef.current = el; + if (el) { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setContainerSize({ width: rect.width, height: rect.height }); + } + } + }, []); + + useEffect(() => { + const el = containerRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) { + setContainerSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [isActive, Boolean(current)]); + + useEffect(() => { + if (isActive) { + const raf = requestAnimationFrame(() => setIsRendered(true)); + return () => { + cancelAnimationFrame(raf); + setIsRendered(false); + }; + } + setIsRendered(false); + return undefined; + }, [isActive]); + + useEffect(() => { + if (!isActive || !frame || !level) { + setCurrent(null); + setPrevious(null); + return; + } + + const nextTile: FrameTile = { + key: `${frame.url}#${frame.x},${frame.y}`, + url: frame.url, + x: frame.x, + y: frame.y, + levelWidth: level.width, + levelHeight: level.height, + columns: level.columns, + rows: level.rows, + }; + + setCurrent((prev) => { + if (!prev) return nextTile; + if (prev.key !== nextTile.key) { + setPrevious(prev); + return nextTile; + } + return prev; + }); + }, [isActive, frame?.url, frame?.x, frame?.y, level?.width, level?.height, level?.columns, level?.rows]); + + if (!isActive || (!frame && !current) || (!level && !current)) { + return null; + } + + const scaleClass = IS_LINUX_RUNTIME + ? "" + : " transition-transform duration-300 group-hover:scale-[1.03]"; + + const currentGeom = current + ? getTileGeometry(current, containerSize.width, containerSize.height) + : null; + const prevGeom = previous + ? getTileGeometry(previous, containerSize.width, containerSize.height) + : null; + + return ( + diff --git a/src/lib/useHoverPreview.test.ts b/src/lib/useHoverPreview.test.ts index 883e70aa..692c5d73 100644 --- a/src/lib/useHoverPreview.test.ts +++ b/src/lib/useHoverPreview.test.ts @@ -240,7 +240,7 @@ describe("useHoverPreview", () => { expect(getPreviewClusterCount(3600)).toBe(5); }); - it("plays clusters non-repetitively and reaches 100% progress before holding", async () => { + it("plays clusters in a continuous loop while hover is active", async () => { const mockLevels: StoryboardLevel[] = [ { width: 320, @@ -269,7 +269,7 @@ describe("useHoverPreview", () => { createElement(TestComponent, { videoId: "vid_short", isHovered: true, - durationSeconds: 20, // 1 cluster = 5 frames + durationSeconds: 20, // 1 cluster = 8 frames }) ); }); @@ -281,7 +281,7 @@ describe("useHoverPreview", () => { expect(lastHookResult?.isPreviewActive).toBe(true); expect(lastHookResult?.progressPercent).toBe(Math.round((1 / FRAMES_PER_CLUSTER) * 100)); - // Advance through remaining 4 frames + // Advance through remaining frames to reach 100% for (let i = 0; i < FRAMES_PER_CLUSTER - 1; i++) { act(() => { vi.advanceTimersByTime(FRAME_CYCLE_INTERVAL_MS); @@ -291,12 +291,12 @@ describe("useHoverPreview", () => { // Reached final frame: 100% progress expect(lastHookResult?.progressPercent).toBe(100); - // Advance further: should NOT loop back (non-repetitive) + // Advance one more interval: seamlessly loops back to frame 0 act(() => { - vi.advanceTimersByTime(FRAME_CYCLE_INTERVAL_MS * 2); + vi.advanceTimersByTime(FRAME_CYCLE_INTERVAL_MS); }); - expect(lastHookResult?.progressPercent).toBe(100); + expect(lastHookResult?.progressPercent).toBe(Math.round((1 / FRAMES_PER_CLUSTER) * 100)); expect(lastHookResult?.isPreviewActive).toBe(true); } finally { globalThis.Image = originalImage; diff --git a/src/lib/useHoverPreview.ts b/src/lib/useHoverPreview.ts index 0d45e61b..84f0eb39 100644 --- a/src/lib/useHoverPreview.ts +++ b/src/lib/useHoverPreview.ts @@ -287,16 +287,7 @@ export function useHoverPreview({ cycleIntervalRef.current = setInterval(() => { if (!isMountedRef.current) return; - frameIdx++; - if (frameIdx >= frames.length) { - // Non-repetitive: freeze on final frame - if (cycleIntervalRef.current) { - clearInterval(cycleIntervalRef.current); - cycleIntervalRef.current = null; - } - setProgressPercent(100); - return; - } + frameIdx = (frameIdx + 1) % frames.length; setCurrentFrame(frames[frameIdx] ?? null); setProgressPercent(Math.round(((frameIdx + 1) / frames.length) * 100)); }, FRAME_CYCLE_INTERVAL_MS); From 22c3dae9d64ce1cc55d52c4e01eb41f80a24a4d7 Mon Sep 17 00:00:00 2001 From: Captain-Vikram Date: Sun, 8 Nov 2026 13:01:26 +0530 Subject: [PATCH 3/5] feat(video): configure 3 video parts with 3-second sustained playback --- src/components/video/VideoThumbnailPreview.tsx | 2 +- src/lib/useHoverPreview.test.ts | 9 +++------ src/lib/useHoverPreview.ts | 11 +++++------ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/video/VideoThumbnailPreview.tsx b/src/components/video/VideoThumbnailPreview.tsx index 5673cc32..018d66f6 100644 --- a/src/components/video/VideoThumbnailPreview.tsx +++ b/src/components/video/VideoThumbnailPreview.tsx @@ -230,7 +230,7 @@ export const VideoThumbnailPreview: React.FC = ({ className="h-full bg-primary transition-all ease-linear" style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%`, - transitionDuration: isLoopReset ? "0ms" : "220ms", + transitionDuration: isLoopReset ? "0ms" : "250ms", }} />
diff --git a/src/lib/useHoverPreview.test.ts b/src/lib/useHoverPreview.test.ts index 692c5d73..36f14ec4 100644 --- a/src/lib/useHoverPreview.test.ts +++ b/src/lib/useHoverPreview.test.ts @@ -230,14 +230,11 @@ describe("useHoverPreview", () => { // 30s - 3m -> 2 clusters expect(getPreviewClusterCount(90)).toBe(2); expect(getPreviewClusterCount(180)).toBe(2); - // 3m - 10m -> 3 clusters + // > 3m -> max 3 clusters (3s per part) expect(getPreviewClusterCount(300)).toBe(3); expect(getPreviewClusterCount(600)).toBe(3); - // 10m - 30m -> 4 clusters - expect(getPreviewClusterCount(1200)).toBe(4); - expect(getPreviewClusterCount(1800)).toBe(4); - // 30m+ / 1hr+ -> 5 clusters - expect(getPreviewClusterCount(3600)).toBe(5); + expect(getPreviewClusterCount(1200)).toBe(3); + expect(getPreviewClusterCount(3600)).toBe(3); }); it("plays clusters in a continuous loop while hover is active", async () => { diff --git a/src/lib/useHoverPreview.ts b/src/lib/useHoverPreview.ts index 84f0eb39..72c397b9 100644 --- a/src/lib/useHoverPreview.ts +++ b/src/lib/useHoverPreview.ts @@ -9,20 +9,19 @@ import { proxiedImage } from "./imageProxy"; import { SETTINGS } from "./settings/schema"; const HOVER_INTENT_DELAY_MS = 500; -export const FRAME_CYCLE_INTERVAL_MS = 220; -export const FRAMES_PER_CLUSTER = 8; +export const FRAME_CYCLE_INTERVAL_MS = 250; +export const FRAMES_PER_CLUSTER = 12; // 12 frames * 250ms = 3.0 full seconds per video part const MAX_STORYBOARD_CACHE_ENTRIES = 100; /** * Determines the number of preview mini-clips (clusters) based on video duration. - * Short videos have 1 continuous clip; longer videos have up to 5 clusters spread across the timeline. + * Limited to at most 3 key video parts (e.g. beginning, middle, later) so each part + * gets a full 3 seconds of sustained playback without frequent jumping. */ export function getPreviewClusterCount(durationSeconds: number): number { if (durationSeconds <= 30) return 1; if (durationSeconds <= 180) return 2; - if (durationSeconds <= 600) return 3; - if (durationSeconds <= 1800) return 4; - return 5; + return 3; } const storyboardSpecCache = new Map(); From 17b42146d71fbe19ff65e6a3e9902e41d0420f9c Mon Sep 17 00:00:00 2001 From: Captain-Vikram Date: Sun, 8 Nov 2026 13:20:43 +0530 Subject: [PATCH 4/5] feat(video): optimize preview to 8 FPS (24 frames across 3 sections) at 400ms delay --- .../video/VideoThumbnailPreview.tsx | 2 +- src/lib/useHoverPreview.test.ts | 39 +++++++++---------- src/lib/useHoverPreview.ts | 16 ++++---- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/components/video/VideoThumbnailPreview.tsx b/src/components/video/VideoThumbnailPreview.tsx index 018d66f6..36b4eab1 100644 --- a/src/components/video/VideoThumbnailPreview.tsx +++ b/src/components/video/VideoThumbnailPreview.tsx @@ -230,7 +230,7 @@ export const VideoThumbnailPreview: React.FC = ({ className="h-full bg-primary transition-all ease-linear" style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%`, - transitionDuration: isLoopReset ? "0ms" : "250ms", + transitionDuration: isLoopReset ? "0ms" : "125ms", }} />
diff --git a/src/lib/useHoverPreview.test.ts b/src/lib/useHoverPreview.test.ts index 36f14ec4..34784e2b 100644 --- a/src/lib/useHoverPreview.test.ts +++ b/src/lib/useHoverPreview.test.ts @@ -5,7 +5,7 @@ import { pickHoverPreviewLevel, getPreviewClusterCount, useHoverPreview, - FRAMES_PER_CLUSTER, + TOTAL_PREVIEW_FRAMES, FRAME_CYCLE_INTERVAL_MS, } from "./useHoverPreview"; import { useHoverPreviewStore } from "../store/useHoverPreviewStore"; @@ -64,7 +64,7 @@ describe("useHoverPreview", () => { expect(lastHookResult?.currentFrame).toBeNull(); }); - it("does not activate before the 500ms hover intent delay", () => { + it("does not activate before the 400ms hover intent delay", () => { act(() => { root.render( createElement(TestComponent, { @@ -75,15 +75,15 @@ describe("useHoverPreview", () => { ); }); - // 400ms passed: still waiting for 500ms hover intent + // 300ms passed: still waiting for 400ms hover intent act(() => { - vi.advanceTimersByTime(400); + vi.advanceTimersByTime(300); }); expect(lastHookResult?.isPreviewActive).toBe(false); }); - it("cancels pending hover if unhovered before 500ms", () => { + it("cancels pending hover if unhovered before 400ms", () => { const spy = vi.spyOn(youtubeApi, "getVideoStoryboard"); act(() => { @@ -97,7 +97,7 @@ describe("useHoverPreview", () => { }); act(() => { - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(200); }); act(() => { @@ -111,7 +111,7 @@ describe("useHoverPreview", () => { }); act(() => { - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(400); }); expect(spy).not.toHaveBeenCalled(); @@ -224,25 +224,24 @@ describe("useHoverPreview", () => { }); it("adapts preview cluster count according to video duration", () => { - // <= 30s -> 1 continuous clip + // <= 30s -> 1 continuous section (24 frames) expect(getPreviewClusterCount(15)).toBe(1); expect(getPreviewClusterCount(30)).toBe(1); - // 30s - 3m -> 2 clusters + // 30s - 3m -> 2 sections (12 frames each) expect(getPreviewClusterCount(90)).toBe(2); expect(getPreviewClusterCount(180)).toBe(2); - // > 3m -> max 3 clusters (3s per part) + // > 3m -> 3 sections (Opening: 8, Middle: 8, Later: 8 frames) expect(getPreviewClusterCount(300)).toBe(3); expect(getPreviewClusterCount(600)).toBe(3); - expect(getPreviewClusterCount(1200)).toBe(3); expect(getPreviewClusterCount(3600)).toBe(3); }); - it("plays clusters in a continuous loop while hover is active", async () => { + it("distributes 24 frames @ 8 FPS across 3 sections in a continuous loop", async () => { const mockLevels: StoryboardLevel[] = [ { width: 320, height: 180, - count: 25, + count: 50, columns: 3, rows: 3, intervalMs: 2000, @@ -264,22 +263,22 @@ describe("useHoverPreview", () => { act(() => { root.render( createElement(TestComponent, { - videoId: "vid_short", + videoId: "vid_continuous", isHovered: true, - durationSeconds: 20, // 1 cluster = 8 frames + durationSeconds: 120, // 2-minute video -> 2 clusters * 12 = 24 frames }) ); }); await act(async () => { - vi.advanceTimersByTime(550); + vi.advanceTimersByTime(450); // 400ms hover delay + buffer }); expect(lastHookResult?.isPreviewActive).toBe(true); - expect(lastHookResult?.progressPercent).toBe(Math.round((1 / FRAMES_PER_CLUSTER) * 100)); + expect(lastHookResult?.progressPercent).toBe(Math.round((1 / TOTAL_PREVIEW_FRAMES) * 100)); - // Advance through remaining frames to reach 100% - for (let i = 0; i < FRAMES_PER_CLUSTER - 1; i++) { + // Advance through remaining 23 frames to reach 100% + for (let i = 0; i < TOTAL_PREVIEW_FRAMES - 1; i++) { act(() => { vi.advanceTimersByTime(FRAME_CYCLE_INTERVAL_MS); }); @@ -293,7 +292,7 @@ describe("useHoverPreview", () => { vi.advanceTimersByTime(FRAME_CYCLE_INTERVAL_MS); }); - expect(lastHookResult?.progressPercent).toBe(Math.round((1 / FRAMES_PER_CLUSTER) * 100)); + expect(lastHookResult?.progressPercent).toBe(Math.round((1 / TOTAL_PREVIEW_FRAMES) * 100)); expect(lastHookResult?.isPreviewActive).toBe(true); } finally { globalThis.Image = originalImage; diff --git a/src/lib/useHoverPreview.ts b/src/lib/useHoverPreview.ts index 72c397b9..357d4371 100644 --- a/src/lib/useHoverPreview.ts +++ b/src/lib/useHoverPreview.ts @@ -8,15 +8,14 @@ import { useImageProxyStore } from "../store/useImageProxyStore"; import { proxiedImage } from "./imageProxy"; import { SETTINGS } from "./settings/schema"; -const HOVER_INTENT_DELAY_MS = 500; -export const FRAME_CYCLE_INTERVAL_MS = 250; -export const FRAMES_PER_CLUSTER = 12; // 12 frames * 250ms = 3.0 full seconds per video part +const HOVER_INTENT_DELAY_MS = 400; +export const FRAME_CYCLE_INTERVAL_MS = 125; // 8 FPS (~125ms per frame) +export const TOTAL_PREVIEW_FRAMES = 24; // 24 frames * 125ms = 3.0 seconds exact duration const MAX_STORYBOARD_CACHE_ENTRIES = 100; /** - * Determines the number of preview mini-clips (clusters) based on video duration. - * Limited to at most 3 key video parts (e.g. beginning, middle, later) so each part - * gets a full 3 seconds of sustained playback without frequent jumping. + * Determines the number of preview timeline sections based on video duration. + * Distributes the 24 frames across up to 3 sections (Opening: 8 frames, Middle: 8 frames, Later: 8 frames). */ export function getPreviewClusterCount(durationSeconds: number): number { if (durationSeconds <= 30) return 1; @@ -245,14 +244,15 @@ export function useHoverPreview({ : (level.count * level.intervalMs) / 1000; const clusterCount = getPreviewClusterCount(totalDuration); + const framesPerCluster = Math.floor(TOTAL_PREVIEW_FRAMES / clusterCount); const stepSeconds = Math.max(1, Math.min(level.intervalMs / 1000, 3)); const frames: StoryboardFrame[] = []; for (let c = 0; c < clusterCount; c++) { const anchorRatio = clusterCount === 1 ? 0 : c / clusterCount + 0.05; - const maxAnchor = Math.max(0, totalDuration - FRAMES_PER_CLUSTER * stepSeconds); + const maxAnchor = Math.max(0, totalDuration - framesPerCluster * stepSeconds); const anchorTime = Math.min(totalDuration * anchorRatio, maxAnchor); - for (let f = 0; f < FRAMES_PER_CLUSTER; f++) { + for (let f = 0; f < framesPerCluster; f++) { const timestamp = Math.min(anchorTime + f * stepSeconds, totalDuration); const rawFrame = storyboardFrameAt(level, timestamp); const url = proxiedImage(rawFrame.url, proxyBase); From 55100a7f2603507fec1b1e94095493af2addaa41 Mon Sep 17 00:00:00 2001 From: Captain-Vikram Date: Sun, 8 Nov 2026 14:43:25 +0530 Subject: [PATCH 5/5] feat(video): add monotonic clock loop sync and subtle section crossfade --- src/App.css | 13 +++++++++++++ src/components/video/VideoThumbnailPreview.tsx | 7 +++++-- src/lib/useHoverPreview.ts | 18 +++++++++++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/App.css b/src/App.css index 904de112..6a4519c1 100644 --- a/src/App.css +++ b/src/App.css @@ -247,6 +247,19 @@ animation: fade-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; } +@keyframes section-crossfade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-section-crossfade { + animation: section-crossfade 0.12s ease-out forwards; +} + @keyframes shimmer { 100% { transform: translateX(100%); diff --git a/src/components/video/VideoThumbnailPreview.tsx b/src/components/video/VideoThumbnailPreview.tsx index 36b4eab1..3868a110 100644 --- a/src/components/video/VideoThumbnailPreview.tsx +++ b/src/components/video/VideoThumbnailPreview.tsx @@ -184,6 +184,7 @@ export const VideoThumbnailPreview: React.FC = ({ const prevGeom = previous ? getTileGeometry(previous, containerSize.width, containerSize.height) : null; + const isSectionBoundary = previous !== null && current !== null && previous.url !== current.url; return (
= ({
)} - {/* Front buffer: strictly isolated to 1 tile viewport */} + {/* Front buffer: strictly isolated to 1 tile viewport with subtle crossfade on section boundaries */} {current && currentGeom && (
= ({ alt="" tabIndex={-1} decoding="async" - className="pointer-events-none select-none max-w-none" + className={`pointer-events-none select-none max-w-none ${ + isSectionBoundary ? "animate-section-crossfade" : "" + }`} style={currentGeom.imgStyle} />
diff --git a/src/lib/useHoverPreview.ts b/src/lib/useHoverPreview.ts index 357d4371..9afbaac6 100644 --- a/src/lib/useHoverPreview.ts +++ b/src/lib/useHoverPreview.ts @@ -284,11 +284,23 @@ export function useHoverPreview({ setProgressPercent(Math.round((1 / frames.length) * 100)); setIsPreviewActive(true); + const startTime = typeof performance !== "undefined" ? performance.now() : Date.now(); + const totalDurationMs = frames.length * FRAME_CYCLE_INTERVAL_MS; + cycleIntervalRef.current = setInterval(() => { if (!isMountedRef.current) return; - frameIdx = (frameIdx + 1) % frames.length; - setCurrentFrame(frames[frameIdx] ?? null); - setProgressPercent(Math.round(((frameIdx + 1) / frames.length) * 100)); + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + const elapsed = Math.max(0, now - startTime); + const currentLoopTime = elapsed % totalDurationMs; + const nextIdx = Math.min( + frames.length - 1, + Math.floor(currentLoopTime / FRAME_CYCLE_INTERVAL_MS) + ); + if (nextIdx !== frameIdx) { + frameIdx = nextIdx; + setCurrentFrame(frames[frameIdx] ?? null); + setProgressPercent(Math.round(((frameIdx + 1) / frames.length) * 100)); + } }, FRAME_CYCLE_INTERVAL_MS); } catch { if (isMountedRef.current) {