From fce3d8571696eaa72f94f6cbd637717d46dd0d26 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 13 Apr 2026 01:12:23 +0000 Subject: [PATCH] feat: responsive tabbed layout for narrow viewports with comment toggle --- Cargo.toml | 2 +- src/main.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ assets/styling/watch.css | 26 ++++++++++++++++++++++++-- src/player/transcript_panel.rs | 5 +++-- 4 file(s) changed, 169 insertion(s)(+), 23 deletion(s)(-) diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "=0.2.117" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "ErrorEvent", "console", "Document", "Element", "HtmlImageElement", "HtmlCanvasElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList", "Performance", "MediaSource", "MediaSourceReadyState", "SourceBuffer", "SourceBufferList", "HtmlVideoElement", "HtmlMediaElement", "Url", "TimeRanges", "Worker", "WorkerOptions", "WorkerType", "OffscreenCanvas", "ResizeObserver", "ResizeObserverEntry", "ResizeObserverSize"] } +web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "ErrorEvent", "console", "Document", "Element", "HtmlImageElement", "HtmlCanvasElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList", "Performance", "MediaSource", "MediaSourceReadyState", "SourceBuffer", "SourceBufferList", "HtmlVideoElement", "HtmlMediaElement", "Url", "TimeRanges", "Worker", "WorkerOptions", "WorkerType", "OffscreenCanvas", "ResizeObserver", "ResizeObserverEntry", "ResizeObserverSize", "MediaQueryList", "MediaQueryListEvent"] } js-sys = "0.3" gloo-events = "0.3" gloo-storage = "0.3" diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -154,6 +154,68 @@ let mut comment_data: Signal>> = use_signal(|| None); let anchor_source: Signal> = use_signal(|| None); let chat_visible: Signal = use_signal(|| true); + // Lifted from TranscriptPanel so it's accessible to both the panel and tab badge logic. + let comments_visible: Signal = use_signal(|| true); + + // Narrow viewport detection — drives conditional rendering for tabs vs sidebars. + // On non-wasm targets (server, native tests) this always reads as false (wide). + #[allow(unused_mut)] + let mut is_narrow: Signal = use_signal(|| false); + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + use_effect(move || { + use wasm_bindgen::JsCast; + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let mql = match window.match_media("(max-width: 1200px)") { + Ok(Some(mql)) => mql, + _ => return, + }; + + // Set initial value. + is_narrow.set(mql.matches()); + + // Listen for changes via gloo_events (FnMut-compatible, manages lifetime). + let listener = gloo_events::EventListener::new( + mql.unchecked_ref::(), + "change", + move |event| { + if let Ok(mql_event) = event.clone().dyn_into::() { + is_narrow.set(mql_event.matches()); + } + }, + ); + // Leak the listener — it must live for the lifetime of the page. + listener.forget(); + }); + } + + // Active tab state for narrow viewports — 0 = Chat, 1 = Transcript+Comments. + // Persisted to SessionStorage so it survives navigations within the same tab. + let active_tab: Signal = { + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + use gloo_storage::{SessionStorage, Storage}; + let initial: usize = SessionStorage::get("vodplace:watch:active_tab").unwrap_or(0); + use_signal(move || initial) + } + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + { + use_signal(|| 0usize) + } + }; + // Persist active_tab changes to SessionStorage. + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + use_effect(move || { + use gloo_storage::{SessionStorage, Storage}; + let tab = *active_tab.read(); + let _ = SessionStorage::set("vodplace:watch:active_tab", tab); + }); + } let on_keydown = move |evt: KeyboardEvent| { use dioxus::prelude::Key; @@ -491,26 +553,87 @@ } } } - if *transcript_visible.read() { - player::transcript_panel::TranscriptPanel { - transcript_data, - current_time: current_playback_time, - on_seek: on_transcript_seek, - comment_tree, + // Wide viewport: transcript in centre column, chat in right sidebar. + if !*is_narrow.read() { + if *transcript_visible.read() { + player::transcript_panel::TranscriptPanel { + transcript_data, + current_time: current_playback_time, + on_seek: on_transcript_seek, + comment_tree, + comments_visible, + } + } + div { class: "watch-sidebar-right", + if let Some(Some(ref meta)) = *metadata.read() { + if meta.livestream_uri.is_some() { + { + let video_uri = meta.at_uri.to_smolstr(); + rsx! { + player::chat_sidebar::ChatSidebar { + current_playback_time, + video_uri, + anchor_source, + chat_visible, + on_seek: on_transcript_seek, + } + } + } + } + } } } - div { class: "watch-sidebar-right", - if let Some(Some(ref meta)) = *metadata.read() { - if meta.livestream_uri.is_some() { - { - let video_uri = meta.at_uri.to_smolstr(); - rsx! { - player::chat_sidebar::ChatSidebar { - current_playback_time, - video_uri, - anchor_source, - chat_visible, - on_seek: on_transcript_seek, + // Narrow viewport: tabbed interface with Chat and Transcript+Comments. + if *is_narrow.read() { + { + use crate::components::tab_bar::{TabBar, TabDef}; + + // Comment count for the Transcript tab badge. + // Hidden when comments_visible is false (badge would be misleading). + let badge_count = if *comments_visible.read() { + let tree_read = comment_tree.read(); + tree_read.as_ref().map(|t| t.anchored.len() + t.non_anchored.len()).filter(|&c| c > 0) + } else { + None + }; + + let tabs = vec![ + TabDef { label: "Chat".into(), badge: None }, + TabDef { label: "Transcript".into(), badge: badge_count }, + ]; + + rsx! { + div { class: "watch-tabs", + TabBar { tabs, active_tab } + div { class: "watch-tab-content", + if *active_tab.read() == 0 { + if let Some(Some(ref meta)) = *metadata.read() { + if meta.livestream_uri.is_some() { + { + let video_uri = meta.at_uri.to_smolstr(); + rsx! { + player::chat_sidebar::ChatSidebar { + current_playback_time, + video_uri, + anchor_source, + chat_visible, + on_seek: on_transcript_seek, + } + } + } + } + } + } + if *active_tab.read() == 1 { + if *transcript_visible.read() { + player::transcript_panel::TranscriptPanel { + transcript_data, + current_time: current_playback_time, + on_seek: on_transcript_seek, + comment_tree, + comments_visible, + } + } } } } diff --git a/assets/styling/watch.css b/assets/styling/watch.css --- a/assets/styling/watch.css +++ b/assets/styling/watch.css @@ -277,7 +277,7 @@ grid-template-areas: "video" "metadata" - "transcript"; + "tabs"; grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; padding: 0; @@ -322,7 +322,7 @@ grid-template-areas: "video" "metadata" - "transcript"; + "tabs"; grid-template-columns: minmax(0, 960px); grid-template-rows: auto auto 1fr; justify-content: center; @@ -333,4 +333,26 @@ display: none; } +} + +/* Narrow viewport tab container */ + +@media (max-width: 1200px) { + .watch-tabs { + grid-area: tabs; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .watch-tab-content { + flex: 1; + overflow-y: auto; + } +} + +@media (min-width: 1201px) { + .watch-tabs { + display: none; + } } diff --git a/src/player/transcript_panel.rs b/src/player/transcript_panel.rs --- a/src/player/transcript_panel.rs +++ b/src/player/transcript_panel.rs @@ -95,14 +95,15 @@ 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, ) -> 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); - // Master toggle: when false, all comment UI is hidden. - let mut comments_visible: Signal = use_signal(|| true); let data = transcript_data.read(); let Some(ref transcript) = *data else { -- tangled.sh