Something went wrong. Try again.
Streamplace AtmosphereConf 2026 VOD JAM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755mod catalog;mod chat;#[cfg(feature = "server")]mod chat_cache;mod comments;mod components;mod fonts;#[cfg(feature = "server")]mod identity;#[allow(dead_code)]mod lexicons;mod player;
use dioxus::prelude::*;use jacquard::deps::smol_str::{SmolStr, ToSmolStr};use jacquard::types::aturi::AtUri;
use catalog::{ CommentData, EnrichedVideo, filter_and_sort, get_comments, get_transcript, get_video_catalog, get_video_metadata,};use comments::{CommentTree, build_comment_tree};use components::{AtUriDisplay, VideoCard};
// Vancouver: UTC-7 (PDT). Hardcoded for now.pub const PDT: chrono::FixedOffset = match chrono::FixedOffset::west_opt(7 * 3600) { Some(o) => o, None => unreachable!(),};
#[cfg(feature = "server")]fn main() { dioxus::serve(|| async { use axum::routing::get;
Ok(dioxus::server::router(app).route("/api/thumbnail", get(catalog::thumbnail_handler))) });}
#[cfg(not(feature = "server"))]fn main() { #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { use tracing::Level; use tracing::subscriber::set_global_default; use tracing_subscriber::Registry; use tracing_subscriber::filter::EnvFilter; use tracing_subscriber::layer::SubscriberExt;
console_error_panic_hook::set_once();
let console_level = if cfg!(debug_assertions) { Level::TRACE } else { Level::INFO };
let wasm_layer = tracing_wasm::WASMLayer::new( tracing_wasm::WASMLayerConfigBuilder::new() .set_max_level(console_level) .build(), );
let filter = EnvFilter::new( "debug,vodplace=trace,jacquard_identity=info,jacquard_common=info,reqwest=warn", );
let reg = Registry::default().with(filter).with(wasm_layer);
let _ = set_global_default(reg); }
dioxus::launch(app)}
#[derive(Routable, Clone, PartialEq, Debug)]enum Route { #[route("/?:q")] Home { q: Option<SmolStr> }, #[route("/watch?:uri")] Watch { uri: SmolStr },}
const FAVICON: Asset = asset!("/assets/favicon.jpg");const THEME_CSS: Asset = asset!("/assets/styling/theme.css");const BASE_CSS: Asset = asset!("/assets/styling/base.css");const CARDS_CSS: Asset = asset!("/assets/styling/cards.css");const BROWSE_CSS: Asset = asset!("/assets/styling/browse.css");const PLAYER_CSS: Asset = asset!("/assets/styling/player.css");const SUBTITLES_CSS: Asset = asset!("/assets/styling/subtitles.css");const WATCH_CSS: Asset = asset!("/assets/styling/watch.css");const CHAT_CSS: Asset = asset!("/assets/styling/chat.css");const COMMENTS_CSS: Asset = asset!("/assets/styling/comments.css");const TABS_CSS: Asset = asset!("/assets/styling/tabs.css");const OG_SITE_IMAGE: Asset = asset!("/assets/og-site.png");
fn app() -> Element { rsx! { document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } document::Link { rel: "icon", href: FAVICON } document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } document::Link { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous" } document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Capriola&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" } Stylesheet { href: THEME_CSS } Stylesheet { href: BASE_CSS } Stylesheet { href: CARDS_CSS } Stylesheet { href: BROWSE_CSS } Router::<Route> {} }}
#[component]fn Home(q: Option<SmolStr>) -> Element { let mut catalog = use_server_future(move || async move { get_video_catalog().await })?;
let mut filter_text = use_signal(move || q.unwrap_or(SmolStr::default()));
let catalog_read = catalog.read(); let (videos, catalog_error) = match &*catalog_read { Some(Ok(v)) => (Some(v), None), Some(Err(e)) => (None, Some(format!("{e}"))), None => (None, None), };
let filtered: Vec<EnrichedVideo> = videos .map(|v| { let query = filter_text.read(); filter_and_sort(v, &query) }) .unwrap_or_default();
let site_og_image = format!("https://vod.place{OG_SITE_IMAGE}"); rsx! { document::Title { "vodplace" } document::Meta { property: "og:title", content: "vod.place" } document::Meta { property: "og:description", content: "the place for VODs" } document::Meta { property: "og:image", content: "{site_og_image}" } document::Meta { property: "og:type", content: "website" } document::Meta { property: "og:url", content: "https://vod.place" } document::Meta { name: "twitter:card", content: "summary_large_image" } document::Meta { name: "twitter:title", content: "vod.place" } document::Meta { name: "twitter:description", content: "the place for VODs" } document::Meta { name: "twitter:image", content: "{site_og_image}" } div { class: "browse-page", div { class: "browse-header", h1 { "vodplace" } input { class: "browse-filter", r#type: "text", placeholder: "filter by title or creator...", aria_label: "Filter videos by title or creator", autofocus: true, value: "{filter_text}", oninput: move |e| filter_text.set(e.value().to_smolstr()), } } div { class: "browse-grid", if let Some(err) = catalog_error { div { class: "browse-error", p { "Failed to load videos: {err}" } button { onclick: move |_| catalog.restart(), "Retry" } } } else { for video in filtered.iter() { VideoCard { key: "{video.at_uri}", video: video.clone(), on_filter: move |val: SmolStr| filter_text.set(val), } } if filtered.is_empty() { div { class: "browse-empty", "No videos found." } } } } } }}
#[component]fn Watch(uri: SmolStr) -> Element { use player::VodPlayer;
let uri = use_signal(move || uri);
let video_mounted: Signal<Option<MountedEvent>> = use_signal(|| None); let wrapper_mounted: Signal<Option<MountedEvent>> = use_signal(|| None); let current_playback_time: Signal<f64> = use_signal(|| 0.0); let mut subtitle_request: Signal<Option<player::component::SubtitleRequest>> = use_signal(|| None); let mut transcript_data: Signal<Option<vodplace_subtitle::TranscriptData>> = use_signal(|| None); let mut transcript_visible: Signal<bool> = use_signal(|| false); let mut comment_data: Signal<Option<Vec<CommentData>>> = use_signal(|| None); let anchor_source: Signal<Option<crate::chat::AnchorSource>> = use_signal(|| None); let mut metadata_collapsed: Signal<bool> = use_signal(|| false); let chat_visible: Signal<bool> = use_signal(|| true); // Lifted from TranscriptPanel so it's accessible to both the panel and tab badge logic. let comments_visible: Signal<bool> = 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<bool> = use_signal(|| false); #[cfg(all(target_family = "wasm", target_os = "unknown"))] { // Store the listener in a signal so it's dropped when the component unmounts, // preventing accumulation of leaked listeners across navigations. let mut _mql_listener: Signal<Option<gloo_events::EventListener>> = use_signal(|| None); 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, };
is_narrow.set(mql.matches());
let listener = gloo_events::EventListener::new( mql.unchecked_ref::<web_sys::EventTarget>(), "change", move |event| { if let Ok(mql_event) = event.clone().dyn_into::<web_sys::MediaQueryListEvent>() { let _ = is_narrow.try_write().map(|mut v| *v = mql_event.matches()); } }, ); _mql_listener.set(Some(listener)); // Dropped when Watch unmounts. }); }
// 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<usize> = { #[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;
let key = evt.key(); let handled = matches!( &key, Key::Character(c) if matches!(c.as_str(), " " | "k" | "K" | "m" | "M" | "f" | "F") ) || matches!( &key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown );
if !handled { return; } evt.prevent_default();
#[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; use web_sys::HtmlVideoElement;
let vid = video_mounted.read().as_ref().and_then(|e| { e.try_as_web_event() .map(|el| -> HtmlVideoElement { el.unchecked_into() }) });
if let Some(vid) = vid { match &key { Key::Character(c) if matches!(c.as_str(), " " | "k" | "K") => { if vid.paused() { match vid.play() { Ok(promise) => { wasm_bindgen_futures::spawn_local(async move { if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await { tracing::error!("playback rejected: {e:?}"); } }); } Err(e) => { tracing::error!("play() call failed: {e:?}"); } } } else { let _ = vid.pause(); } } Key::ArrowLeft => { vid.set_current_time((vid.current_time() - 5.0).max(0.0)); } Key::ArrowRight => { let dur = vid.duration(); let max = if dur.is_finite() { dur } else { f64::MAX }; vid.set_current_time((vid.current_time() + 5.0).min(max)); } Key::ArrowUp => { vid.set_volume((vid.volume() + 0.05).min(1.0)); } Key::ArrowDown => { vid.set_volume((vid.volume() - 0.05).max(0.0)); } Key::Character(c) if matches!(c.as_str(), "m" | "M") => { vid.set_muted(!vid.muted()); } Key::Character(c) if matches!(c.as_str(), "f" | "F") => { player::component::toggle_fullscreen(wrapper_mounted); } _ => {} } } } };
let metadata = use_server_future(move || async move { let u = uri(); if u.starts_with("at://") { if let Ok(at_uri) = u.parse::<AtUri>() { return get_video_metadata(at_uri).await.ok(); } } None })?;
// Fetch ionosphere transcript when a talk is available. // Non-blocking — page renders immediately, transcript appears when ready. { let _transcript_resource = use_resource(move || { async move { let talk_uri = match &*metadata.read() { Some(Some(meta)) => meta.talk_uri.clone(), _ => None, }; let Some(talk_uri) = talk_uri else { return; }; match get_transcript(talk_uri).await { Ok(Some(input)) => { if let Ok(data) = vodplace_subtitle::ionosphere_to_transcript_data(&input) { transcript_data.set(Some(data)); transcript_visible.set(true); } // 1920×1080 is the canonical PlayRes reference resolution. // The renderer scales to actual display dimensions via SubtitleResize. if let Ok(ass_text) = vodplace_subtitle::ionosphere_to_ass(&input, 1920, 1080) { subtitle_request.set(Some(player::component::SubtitleRequest { ass_text, source: player::component::SubtitleSource::Ionosphere, })); } } Ok(None) => {} Err(e) => { tracing::warn!("failed to fetch transcript: {e}"); } } } }); }
// Fetch ionosphere comments when a talk is available. // Non-blocking — comment fetch failure does not affect video or transcript. { let _comment_resource = use_resource(move || async move { let talk_uri = match &*metadata.read() { Some(Some(meta)) => meta.talk_uri.clone(), _ => None, }; let Some(talk_uri) = talk_uri else { return; }; // Pass None for transcript_uri — the server queries the talk URI which // covers the main case. Transcript URI is not available client-side. match get_comments(talk_uri, None).await { Ok(comments) => { comment_data.set(Some(comments)); } Err(e) => { tracing::warn!("failed to fetch comments: {e}"); } } }); }
// Build the comment tree client-side, mapping byte ranges to word indices. // Depends on both comment data and transcript data (both must be available for // anchored comments to be fully resolved). let comment_tree: Memo<Option<CommentTree>> = use_memo(move || { let comments = comment_data.read(); let transcript = transcript_data.read(); let comments = comments.as_ref()?; Some(build_comment_tree(comments, transcript.as_ref())) });
// Seek callback for transcript panel — directly sets video currentTime. #[allow(unused_variables)] let on_transcript_seek = { let video_mounted_for_seek = video_mounted; EventHandler::new(move |position: f64| { let _ = position; #[cfg(all(target_family = "wasm", target_os = "unknown"))] { use dioxus::web::WebEventExt; use wasm_bindgen::JsCast; if let Some(ref evt) = *video_mounted_for_seek.read() { if let Some(el) = evt.try_as_web_event() { let vid: web_sys::HtmlVideoElement = el.unchecked_into(); vid.set_current_time(position); } } } }) };
rsx! { Stylesheet { href: PLAYER_CSS } Stylesheet { href: SUBTITLES_CSS } Stylesheet { href: WATCH_CSS } Stylesheet { href: CHAT_CSS } Stylesheet { href: COMMENTS_CSS } Stylesheet { href: TABS_CSS } if let Some(Some(ref meta)) = *metadata.read() { document::Title { "{meta.title} - vodplace" } { let description = { let speakers = if meta.speakers.is_empty() { None } else { let names: Vec<&str> = meta.speakers.iter().map(|s| s.name.as_ref()).collect(); Some(names.join(", ")) }; let talk_desc: Option<&str> = meta.talk.as_ref().and_then(|t| t.description.as_ref()).map(|d| d.as_ref()); match (speakers, talk_desc) { (Some(s), Some(d)) => format!("By {s} - {d}"), (Some(s), None) => s, (None, Some(d)) => d.to_string(), (None, None) => "Watch on vod.place".to_string(), } }; let og_image = match &meta.thumbnail_url { Some(url) if url.starts_with("http") => url.to_string(), Some(url) => format!("https://vod.place{url}"), None => format!("https://vod.place{OG_SITE_IMAGE}"), }; let watch_url = format!("https://vod.place/watch?uri={}", meta.at_uri.as_str()); rsx! { document::Meta { property: "og:title", content: "{meta.title}" } document::Meta { property: "og:description", content: "{description}" } document::Meta { property: "og:image", content: "{og_image}" } document::Meta { property: "og:type", content: "video.other" } document::Meta { property: "og:url", content: "{watch_url}" } document::Meta { property: "og:site_name", content: "vod.place" } document::Meta { name: "twitter:card", content: "summary_large_image" } document::Meta { name: "twitter:title", content: "{meta.title}" } document::Meta { name: "twitter:description", content: "{description}" } document::Meta { name: "twitter:image", content: "{og_image}" } } } } div { class: { let has_chat = metadata.read().as_ref() .and_then(|m| m.as_ref()) .map_or(false, |m| m.livestream_uri.is_some()); if has_chat && *chat_visible.read() { "watch-page watch-page--chat-visible" } else { "watch-page" } }, tabindex: 0, onkeydown: on_keydown, div { class: "watch-sidebar-left" } div { class: "watch-video", Link { to: Route::Home { q: None }, class: "watch-back", "\u{2039}" } VodPlayer { uri: uri(), video_mounted, wrapper_mounted, current_playback_time, subtitle_request, } } if *metadata_collapsed.read() { button { class: "metadata-expand-btn", onclick: move |_| metadata_collapsed.set(false), "Show info" } } div { class: if *metadata_collapsed.read() { "watch-metadata collapsed" } else { "watch-metadata" }, if let Some(Some(ref meta)) = *metadata.read() { h1 { class: "metadata-title", "{meta.title}" }
// People row — creator + speakers left, duration + date right div { class: "metadata-people", div { class: "metadata-people-left", a { class: "metadata-author", href: "https://bsky.app/profile/{meta.creator.handle}", target: "_blank", if let Some(ref avatar) = meta.creator.avatar_url { img { class: "metadata-avatar", src: "{avatar}", alt: "" } } span { class: "metadata-author-info", if let Some(ref dn) = meta.creator.display_name { span { class: "metadata-author-name", "{dn}" } } span { class: "metadata-author-handle", "@{meta.creator.handle}" } } } for speaker in meta.speakers.iter() { { let author_inner = rsx! { if let Some(ref avatar) = speaker.avatar_url { img { class: "metadata-avatar", src: "{avatar}", alt: "" } } span { class: "metadata-author-info", span { class: "metadata-author-name", "{speaker.name}" } if let Some(ref handle) = speaker.handle { span { class: "metadata-author-handle", "@{handle}" } } } }; if let Some(ref handle) = speaker.handle { rsx! { a { class: "metadata-author", href: "https://bsky.app/profile/{handle}", target: "_blank", {author_inner} } } } else { rsx! { span { class: "metadata-author", {author_inner} } } } } } } div { class: "metadata-time-info", span { class: "metadata-duration", "{player::time_format::format_duration_human(meta.duration_ns)}" } { let display_date = meta.talk .as_ref() .and_then(|t| t.starts_at.as_ref()) .unwrap_or(&meta.created_at); let dt: &chrono::DateTime<chrono::FixedOffset> = display_date.as_ref(); let local = dt.with_timezone(&PDT); let formatted = local.format("%b %-d, %Y %-I:%M %p").to_string(); rsx! { span { class: "metadata-date", "{formatted}" } } } } }
// Talk metadata from ionosphere if let Some(ref talk) = meta.talk { if let Some(ref desc) = talk.description { p { class: "metadata-description", "{desc}" } }
{ let has_tags = talk.talk_type.is_some() || talk.category.is_some() || talk.room.is_some(); rsx! { if has_tags { div { class: "metadata-tags", if let Some(ref talk_type) = talk.talk_type { Link { to: Route::Home { q: talk_type.clone().into() }, class: "metadata-chip", "{talk_type}" } } if let Some(ref category) = talk.category { Link { to: Route::Home { q: category.clone().into() }, class: "metadata-chip", "{category}" } } if let Some(ref room) = talk.room { Link { to: Route::Home { q: room.clone().into() }, class: "metadata-chip", "{room}" } } } } } } }
// AT URI div { class: "metadata-uri", AtUriDisplay { uri: meta.at_uri.clone() } } } } // 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, on_user_scroll: move |_| { metadata_collapsed.set(true); }, } } div { class: "watch-sidebar-right", if let Some(Some(ref meta)) = *metadata.read() { if let Some(ref ls_uri) = meta.livestream_uri { { let video_uri = meta.at_uri.to_smolstr(); let talk_starts_at = meta.talk.as_ref().and_then(|t| t.starts_at.clone()); let talk_ends_at = meta.talk.as_ref().and_then(|t| t.ends_at.clone()); rsx! { player::chat_sidebar::ChatSidebar { current_playback_time, video_uri, livestream_uri: ls_uri.clone(), video_created_at: meta.created_at.clone(), video_duration_ns: meta.duration_ns, talk_starts_at, talk_ends_at, 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", onscroll: move |_| { metadata_collapsed.set(true); }, if *active_tab.read() == 0 { if let Some(Some(ref meta)) = *metadata.read() { if let Some(ref ls_uri) = meta.livestream_uri { { let video_uri = meta.at_uri.to_smolstr(); let talk_starts_at = meta.talk.as_ref().and_then(|t| t.starts_at.clone()); let talk_ends_at = meta.talk.as_ref().and_then(|t| t.ends_at.clone()); rsx! { player::chat_sidebar::ChatSidebar { current_playback_time, video_uri, livestream_uri: ls_uri.clone(), video_created_at: meta.created_at.clone(), video_duration_ns: meta.duration_ns, talk_starts_at, talk_ends_at, anchor_source, chat_visible, on_seek: on_transcript_seek, on_user_scroll: move |_| { metadata_collapsed.set(true); }, } } } } } } 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, on_user_scroll: move |_| { metadata_collapsed.set(true); }, } } } } } } } } } }}