use std::collections::{HashMap, HashSet}; use dioxus::prelude::*; use vodplace_subtitle::{TranscriptData, TranscriptWord}; use crate::comments::{AnchoredComment, CommentTree}; use crate::components::comment_body::CommentBody; use crate::components::comment_connectors::CommentConnectors; /// Range of highlighted words in the transcript. #[derive(Clone, Copy, Debug, PartialEq)] struct ActiveRange { /// Core range: words sharing the same timing interval. start: usize, end: usize, /// Fuzz range: one extra word on each side for a soft transition. fuzz_start: usize, fuzz_end: usize, } /// Find the range of words active at the given time (milliseconds). /// /// Returns all words that share the same timing interval as the word at /// `current_ms`, plus one word of fuzz on each side. Returns `None` if /// no word covers the given time. fn find_active_range(words: &[TranscriptWord], current_ms: u64) -> Option { if words.is_empty() { return None; } // Binary search: find the rightmost word where start_ms <= current_ms. let idx = match words.binary_search_by_key(¤t_ms, |w| w.start_ms) { Ok(i) => i, Err(0) => return None, Err(i) => i - 1, }; if current_ms >= words[idx].end_ms { return None; } // Expand to all words sharing the same timing interval. let interval_start = words[idx].start_ms; let interval_end = words[idx].end_ms; let mut start = idx; while start > 0 && words[start - 1].start_ms == interval_start && words[start - 1].end_ms == interval_end { start -= 1; } let mut end = idx; while end + 1 < words.len() && words[end + 1].start_ms == interval_start && words[end + 1].end_ms == interval_end { end += 1; } // Fuzz: one word on each side. let fuzz_start = start.saturating_sub(1); let fuzz_end = (end + 1).min(words.len() - 1); Some(ActiveRange { start, end, fuzz_start, fuzz_end }) } /// Count top-level comments in a `CommentTree` (anchored + non-anchored, excluding replies). /// Used consistently for both the toggle button and tab badge. fn count_top_level_comments(tree: &CommentTree) -> usize { tree.anchored.len() + tree.non_anchored.len() } /// Scrolling transcript panel with word-level highlighting and click-to-seek. /// /// Renders each word as a clickable ``. The word whose time range /// contains `current_time` receives the `transcript-word-active` class and is /// auto-scrolled into view unless the user has manually scrolled. /// /// If `comment_tree` is provided, anchored comments highlight the covered word /// spans with the `comment-anchor` class, and inline placeholder bodies are /// rendered after the last word of each anchor. Non-anchored comments appear in /// a header section above the transcript words. #[component] pub fn TranscriptPanel( transcript_data: Signal>, current_time: Signal, on_seek: EventHandler, comment_tree: ReadSignal>, /// Master toggle: when false, all comment UI is hidden. /// Lifted to the Watch component so it's accessible to both the panel and tab badge. comments_visible: Signal, /// Fired when the user scrolls inside the transcript panel. #[props(default)] on_user_scroll: EventHandler<()>, ) -> Element { let mut user_scrolled = use_signal(|| false); let mut last_active_start: Signal> = use_signal(|| None); let mut container_mounted: Signal> = use_signal(|| None); // Track which comment bodies are expanded on narrow viewports (click-to-expand). let mut expanded_comments: Signal> = use_signal(HashSet::new); let data = transcript_data.read(); let Some(ref transcript) = *data else { return rsx! {}; }; let current_ms = (current_time.read().max(0.0) * 1000.0) as u64; let active_range = find_active_range(&transcript.words, current_ms); // Detect when active range changes for auto-scroll triggering. let new_start = active_range.map(|r| r.start); let active_changed = new_start != *last_active_start.read(); if active_changed { last_active_start.set(new_start); } // Auto-scroll effect: when the active word changes and the user hasn't // manually scrolled, scroll the active word into view. On non-WASM targets // this is a no-op. #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use_effect(move || { // Subscribe to last_active_start (drives the effect). // Peek user_scrolled to avoid read-write loop. let Some(idx) = *last_active_start.read() else { return; }; if *user_scrolled.peek() { // Check if the active word is visible — if so, resume auto-scroll. let doc = web_sys::window().and_then(|w| w.document()); if let Some(doc) = doc { let selector = format!("[data-word-index=\"{idx}\"]"); if let Ok(Some(el)) = doc.query_selector(&selector) { let container = el.parent_element(); if let Some(container) = container { let container_rect = container.get_bounding_client_rect(); let el_rect = el.get_bounding_client_rect(); let visible = el_rect.top() >= container_rect.top() && el_rect.bottom() <= container_rect.bottom(); if visible { user_scrolled.set(false); } } } } return; } let doc = web_sys::window().and_then(|w| w.document()); if let Some(doc) = doc { let selector = format!("[data-word-index=\"{idx}\"]"); if let Ok(Some(el)) = doc.query_selector(&selector) { // scrollIntoView with smooth behavior and nearest block alignment // to avoid jarring jumps. let opts = web_sys::ScrollIntoViewOptions::new(); opts.set_behavior(web_sys::ScrollBehavior::Smooth); opts.set_block(web_sys::ScrollLogicalPosition::Nearest); el.scroll_into_view_with_scroll_into_view_options(&opts); } } }); } // Pre-compute paragraph break positions. A break occurs after a word // that ends with sentence punctuation or before a >500ms timing gap, // but only if the resulting fragment has at least 3 words. let break_after: Vec = { let words = &transcript.words; let mut breaks = vec![false; words.len()]; let mut fragment_len = 0u32; for i in 0..words.len() { fragment_len += 1; let text = words[i].text.as_str(); let ends_sentence = text.ends_with('.') || text.ends_with('!') || text.ends_with('?'); // Timing-based heuristics — commented out pending better tuning. // let gap_ms = words.get(i + 1) // .map(|next| next.start_ms.saturating_sub(words[i].end_ms)) // .unwrap_or(0); // let has_long_gap = gap_ms > 500; // let capital_after_pause = gap_ms > 200 // && words.get(i + 1) // .and_then(|w| w.text.chars().next()) // .is_some_and(|c| c.is_uppercase()); if ends_sentence && fragment_len >= 3 { breaks[i] = true; fragment_len = 0; } } breaks }; // Read comment tree once for this render pass. let tree_read = comment_tree.read(); let tree_ref: Option<&CommentTree> = tree_read.as_ref(); // Build a map: word_index → anchored comments whose range ends at that word. // An `AnchoredComment` with `word_start=3, word_end=5` covers words 3, 4, 5. // The placeholder body is rendered after the LAST word of each anchor (word_end). // // Also build a set of word indices that are inside any anchor range, for CSS // class annotation. We keep a Vec<&AnchoredComment> per word_end index so we // can emit all comment bodies after the final covered word. let mut anchor_end_map: HashMap> = HashMap::new(); let mut anchored_word_ids: HashMap> = HashMap::new(); if let Some(tree) = tree_ref { for anchored in &tree.anchored { // Register the comment URI at every word index in the range. for word_idx in anchored.word_start..=anchored.word_end { anchored_word_ids .entry(word_idx) .or_default() .push(anchored.comment_node.comment.uri.as_str()); } // Register the comment at its ending word for body rendering. anchor_end_map .entry(anchored.word_end) .or_default() .push(anchored); } } // Snapshot of non-anchored comments for rendering above the transcript. let non_anchored: Vec<_> = tree_ref .map(|t| t.non_anchored.as_slice()) .unwrap_or_default() .iter() .collect(); // Total comment count (anchored + non-anchored + all replies recursively). let comment_count = tree_ref.map(count_top_level_comments).unwrap_or(0); // Panel class includes the hidden modifier when the master toggle is off. let panel_class = if *comments_visible.read() { "transcript-panel".to_string() } else { "transcript-panel transcript-panel--comments-hidden".to_string() }; rsx! { div { class: "{panel_class}", onmounted: move |evt| container_mounted.set(Some(evt)), onscroll: move |_| { user_scrolled.set(true); on_user_scroll.call(()); }, CommentConnectors { comment_tree, container_ref: container_mounted, } // Master toggle button — only shown when there are comments. if comment_count > 0 { div { class: "transcript-comments-toggle", button { class: "transcript-toggle-btn", onclick: move |_| { let current = *comments_visible.read(); comments_visible.set(!current); }, "Comments ({comment_count})" } } } // Inner container — text flows in the left portion, comment bodies // float right into the column reserved by the spacer. div { class: "transcript-text", // Non-anchored comments appear at the top of the transcript panel. if !non_anchored.is_empty() { div { class: "transcript-comments-header", for (i, node) in non_anchored.iter().enumerate() { CommentBody { node: (*node).clone(), is_last_sibling: i + 1 == non_anchored.len(), } } } } for (i, word) in transcript.words.iter().enumerate() { { // Base class from active-range state. let base_class = match active_range { Some(r) if i >= r.start && i <= r.end => { "transcript-word transcript-word-active" } Some(r) if i >= r.fuzz_start && i <= r.fuzz_end => { "transcript-word transcript-word-near" } _ => "transcript-word", }; // Determine if this word is covered by any comment anchor. let anchor_ids = anchored_word_ids.get(&i); let is_anchored = anchor_ids.is_some(); // Build the final CSS class string. let class = if is_anchored { format!("{base_class} comment-anchor") } else { base_class.to_string() }; // data-comment-id: space-separated list of all comment URIs // covering this word (supports multiple overlapping anchors). let comment_id_attr: String = anchor_ids .map(|ids| ids.join(" ")) .unwrap_or_default(); // Clone URIs for use in the onclick closure (toggle expanded state // on narrow viewports in addition to seeking). let anchor_uris: Vec = anchor_ids .map(|ids| ids.iter().map(|s| s.to_string()).collect()) .unwrap_or_default(); let start_sec = word.start_ms as f64 / 1000.0; let text = &word.text; let should_break = break_after[i]; // Comments whose range ends at this word — emit placeholder bodies. let ending_anchors = anchor_end_map.get(&i); rsx! { span { class: "{class}", "data-word-index": "{i}", "data-comment-id": "{comment_id_attr}", onclick: move |_| { on_seek.call(start_sec); // Also toggle expanded state for narrow-viewport // click-to-expand behaviour. if !anchor_uris.is_empty() { let mut set = expanded_comments.write(); for uri in &anchor_uris { if set.contains(uri) { set.remove(uri); } else { set.insert(uri.clone()); } } } }, "{text} " } if should_break { div { class: "transcript-break" } } // Comment bodies for anchors ending at this word. if let Some(anchors) = ending_anchors { for (ai, anchored) in anchors.iter().enumerate() { { let uri = anchored.comment_node.comment.uri.as_str().to_string(); let is_expanded = expanded_comments.read().contains(&uri); let margin_class = if is_expanded { "comment-body-margin comment-expanded" } else { "comment-body-margin" }; rsx! { div { class: "{margin_class}", CommentBody { node: anchored.comment_node.clone(), is_last_sibling: ai + 1 == anchors.len(), } } } } } } } } } if transcript.words.is_empty() { span { class: "transcript-empty", "No transcript available." } } } // close div.transcript-text } } } #[cfg(test)] mod tests { use super::*; fn tw(text: &str, start_ms: u64, end_ms: u64) -> TranscriptWord { TranscriptWord { text: text.into(), start_ms, end_ms, byte_offset: 0, byte_len: text.len(), } } #[test] fn empty() { assert!(find_active_range(&[], 1000).is_none()); } #[test] fn before_first() { let words = vec![tw("hello", 500, 1000)]; assert!(find_active_range(&words, 100).is_none()); } #[test] fn exact_start() { let words = vec![tw("hello", 500, 1000)]; let r = find_active_range(&words, 500).unwrap(); assert_eq!(r.start, 0); assert_eq!(r.end, 0); } #[test] fn mid_range() { let words = vec![tw("hello", 0, 500), tw("world", 500, 1000)]; let r = find_active_range(&words, 250).unwrap(); assert_eq!(r.start, 0); assert_eq!(r.end, 0); let r = find_active_range(&words, 750).unwrap(); assert_eq!(r.start, 1); assert_eq!(r.end, 1); } #[test] fn in_gap() { let words = vec![tw("hello", 0, 400), tw("world", 600, 1000)]; assert!(find_active_range(&words, 450).is_none()); } #[test] fn end_ms_exclusive() { let words = vec![tw("hello", 0, 500)]; assert!(find_active_range(&words, 500).is_none()); } #[test] fn past_all() { let words = vec![tw("hello", 0, 500)]; assert!(find_active_range(&words, 5000).is_none()); } #[test] fn shared_interval_expands() { // Three words sharing the same timing slot. let words = vec![ tw("one", 0, 1000), tw("two", 0, 1000), tw("three", 0, 1000), tw("four", 1000, 2000), ]; let r = find_active_range(&words, 500).unwrap(); assert_eq!(r.start, 0); assert_eq!(r.end, 2); // Fuzz extends one past the core range. assert_eq!(r.fuzz_start, 0); // Can't go below 0. assert_eq!(r.fuzz_end, 3); } #[test] fn fuzz_both_sides() { let words = vec![ tw("before", 0, 500), tw("active", 500, 1000), tw("after", 1000, 1500), ]; let r = find_active_range(&words, 750).unwrap(); assert_eq!(r.start, 1); assert_eq!(r.end, 1); assert_eq!(r.fuzz_start, 0); assert_eq!(r.fuzz_end, 2); } }