Something went wrong. Try again.
Streamplace AtmosphereConf 2026 VOD JAM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476use 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<ActiveRange> { 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 `<span>`. 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<Option<TranscriptData>>, current_time: Signal<f64>, on_seek: EventHandler<f64>, comment_tree: ReadSignal<Option<CommentTree>>, /// 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<bool>, /// 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<Option<usize>> = use_signal(|| None); let mut container_mounted: Signal<Option<MountedEvent>> = use_signal(|| None); // Track which comment bodies are expanded on narrow viewports (click-to-expand). let mut expanded_comments: Signal<HashSet<String>> = 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<bool> = { 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<usize, Vec<&AnchoredComment>> = HashMap::new(); let mut anchored_word_ids: HashMap<usize, Vec<&str>> = 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<String> = 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); }
}