diff --git a/android/Cargo.lock b/android/Cargo.lock index cd6c9d2..1a539d8 100644 --- a/android/Cargo.lock +++ b/android/Cargo.lock @@ -4183,6 +4183,20 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "n0-error" version = "0.1.3" @@ -4601,6 +4615,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -8109,6 +8135,8 @@ name = "vidya" version = "0.1.0" dependencies = [ "egui", + "mp4", + "openh264", "png 0.17.16", "zip", ] diff --git a/android/Cargo.toml b/android/Cargo.toml index c0da446..8b4f3a5 100644 --- a/android/Cargo.toml +++ b/android/Cargo.toml @@ -27,7 +27,7 @@ path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [dependencies] -vidya = { path = "../../vidya" } +vidya = { path = "../../vidya", features = ["video"] } # Guest IRC + freeq events. Prefer aws-lc-rs to match moq-native / iroh # (aws-lc-sys is already required). Note: `ring` still builds via quinn / # rustls-webpki defaults — this only avoids enabling rustls' ring *provider* diff --git a/android/src/android_media.rs b/android/src/android_media.rs index 11aeec3..3dc5dbe 100644 --- a/android/src/android_media.rs +++ b/android/src/android_media.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use eframe::egui::Context; use winit::platform::android::activity::{AndroidApp, WindowManagerFlags}; -use crate::clipboard::PickImageResult; +use crate::clipboard::PickAttachResult; static ANDROID_APP: OnceLock = OnceLock::new(); @@ -937,23 +937,23 @@ enum RawPick { Failed(String), } -/// Open the system image picker and block until the user chooses or cancels. +/// Open the system image/video picker and block until the user chooses or cancels. /// /// Designed to run on a **background thread** (never the egui UI thread). -pub fn pick_image_file() -> PickImageResult { +pub fn pick_media_file() -> PickAttachResult { ensure_read_images_permission(); { let mut busy = PICK_BUSY .lock() - .map_err(|_| "Image picker lock poisoned".to_string())?; + .map_err(|_| "File picker lock poisoned".to_string())?; if *busy { - return Err("An image picker is already open".into()); + return Err("A file picker is already open".into()); } *busy = true; } - let finish = |outcome: PickImageResult| -> PickImageResult { + let finish = |outcome: PickAttachResult| -> PickAttachResult { let _ = PICK_BUSY.lock().map(|mut g| *g = false); outcome }; @@ -983,7 +983,7 @@ pub fn pick_image_file() -> PickImageResult { // Primary: helper Fragment statics (set from onActivityResult). match poll_fragment_pick_result() { Ok(RawPick::Image(bytes)) => { - return finish(crate::clipboard::load_image_from_bytes(&bytes).map(Some)); + return finish(crate::clipboard::load_attach_from_vec(bytes, None).map(Some)); } Ok(RawPick::Cancelled) => { return finish(Ok(None)); @@ -1000,7 +1000,7 @@ pub fn pick_image_file() -> PickImageResult { if !used_fragment { match scavenge_pick_result() { Ok(RawPick::Image(bytes)) => { - return finish(crate::clipboard::load_image_from_bytes(&bytes).map(Some)); + return finish(crate::clipboard::load_attach_from_vec(bytes, None).map(Some)); } Ok(RawPick::Cancelled) => { return finish(Ok(None)); @@ -1082,59 +1082,10 @@ macro_rules! activity_from_raw { fn build_picker_intent<'a>( env: &mut jni::Env<'a>, - activity: &jni::objects::JObject<'_>, + _activity: &jni::objects::JObject<'_>, ) -> Result, String> { - use jni::objects::JValue; - use jni::{jni_sig, jni_str}; - - // Prefer modern photo picker (API 33+); fall back to OPEN_DOCUMENT. - let action = env - .new_string("android.provider.action.PICK_IMAGES") - .map_err(|e| format!("{e}"))?; - let intent_cls = env - .find_class(jni_str!("android/content/Intent")) - .map_err(|e| format!("{e}"))?; - let trial = env.new_object( - &intent_cls, - jni_sig!((java.lang.String)), - &[JValue::Object(action.as_ref())], - ); - - if let Ok(trial) = trial { - let extra_type = env - .new_string("android.provider.extra.PICK_IMAGES_MAX") - .map_err(|e| format!("{e}"))?; - let _ = env.call_method( - &trial, - jni_str!("putExtra"), - jni_sig!((java.lang.String, jint) -> android.content.Intent), - &[JValue::Object(extra_type.as_ref()), JValue::Int(1)], - ); - let pm = env - .call_method( - activity, - jni_str!("getPackageManager"), - jni_sig!(() -> android.content.pm.PackageManager), - &[], - ) - .map_err(|e| format!("{e}"))? - .l() - .map_err(|e| format!("{e}"))?; - let resolved = env - .call_method( - &pm, - jni_str!("resolveActivity"), - jni_sig!((android.content.Intent, jint) -> android.content.pm.ResolveInfo), - &[JValue::Object(trial.as_ref()), JValue::Int(0)], - ) - .map_err(|e| format!("{e}"))? - .l() - .map_err(|e| format!("{e}"))?; - if !resolved.is_null() { - return Ok(trial); - } - } - + // Prefer OPEN_DOCUMENT with image + video MIME types. The modern photo + // picker (`PICK_IMAGES`) is images-only and would hide `.mp4` / `.webm`. build_open_document_intent(env).map_err(|e| format!("{e}")) } @@ -1364,7 +1315,7 @@ fn reset_pick_fragment_statics(env: &mut jni::Env<'_>, cls: &jni::objects::JClas fn build_open_document_intent<'a>( env: &mut jni::Env<'a>, ) -> jni::errors::Result> { - use jni::objects::JValue; + use jni::objects::{JObject, JValue}; use jni::{jni_sig, jni_str}; // Intent.ACTION_OPEN_DOCUMENT @@ -1385,7 +1336,7 @@ fn build_open_document_intent<'a>( &[JValue::Object(cat.as_ref())], )?; - let mime = env.new_string("image/*")?; + let mime = env.new_string("*/*")?; env.call_method( &intent, jni_str!("setType"), @@ -1393,6 +1344,33 @@ fn build_open_document_intent<'a>( &[JValue::Object(mime.as_ref())], )?; + // EXTRA_MIME_TYPES: images + freeq-allowed videos (mp4/webm/mov). + let string_cls = env.find_class(jni_str!("java/lang/String"))?; + let arr = env.new_object_array(4, &string_cls, jni::objects::JObject::null())?; + let m0 = env.new_string("image/*")?; + let m1 = env.new_string("video/mp4")?; + let m2 = env.new_string("video/webm")?; + let m3 = env.new_string("video/quicktime")?; + // Disambiguate `JString: AsRef<_>` (jni has multiple impls) like requestPermissions. + let m0_obj: &JObject = m0.as_ref(); + let m1_obj: &JObject = m1.as_ref(); + let m2_obj: &JObject = m2.as_ref(); + let m3_obj: &JObject = m3.as_ref(); + env.set_object_array_element(&arr, 0, m0_obj)?; + env.set_object_array_element(&arr, 1, m1_obj)?; + env.set_object_array_element(&arr, 2, m2_obj)?; + env.set_object_array_element(&arr, 3, m3_obj)?; + let extra = env.new_string("android.intent.extra.MIME_TYPES")?; + env.call_method( + &intent, + jni_str!("putExtra"), + jni_sig!((java.lang.String, [java.lang.String]) -> android.content.Intent), + &[ + JValue::Object(extra.as_ref()), + JValue::Object(arr.as_ref()), + ], + )?; + Ok(intent) } diff --git a/android/src/app.rs b/android/src/app.rs index c7e50bd..d34246c 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -354,6 +354,7 @@ impl SleekApp { for req in self.state.media.drain_pending() { match req { MediaFetch::Image(url) => self.net.send(NetCmd::FetchImage { url }), + MediaFetch::Video(url) => self.net.send(NetCmd::FetchVideo { url }), MediaFetch::LinkPreview(url) => self.net.send(NetCmd::FetchLinkPreview { url }), } } @@ -415,12 +416,12 @@ impl SleekApp { if let Some(err) = error { self.state.error = Some(err.clone()); self.state.show_toast(err); - // Keep compose_image so the user can retry Send. + // Keep compose_attach so the user can retry Send. } else { - self.state.compose_image = None; + self.state.compose_attach = None; self.state.compose.clear(); self.state.compose_nick_tab.clear(); - self.state.show_toast("Image sent"); + self.state.show_toast("Media sent"); if let Some(media) = sent { self.do_send_local_echo(&media.target, media.text); } @@ -440,6 +441,12 @@ impl SleekApp { NetEvent::ImageFetchFailed { url } => { self.state.media.set_image_failed(url); } + NetEvent::VideoFetched { url, bytes } => { + self.state.media.set_video_ready(url, bytes); + } + NetEvent::VideoFetchFailed { url } => { + self.state.media.set_video_failed(url); + } NetEvent::LinkPreviewFetched { url, title, @@ -839,7 +846,7 @@ impl SleekApp { self.state.media.touch_link(url); } } - None => {} + Some(preview::Embed::Video { .. }) | None => {} } let reactions = tags @@ -1767,10 +1774,10 @@ impl SleekApp { } fn do_send(&mut self, target: String, text: String) { - // Image attached: upload then PRIVMSG the freeq media URL. + // Media attached: upload then PRIVMSG the freeq media URL. // Slash commands don't apply when attaching media (caption is plain text). - if self.state.compose_image.is_some() { - self.do_send_with_image(target, text); + if self.state.compose_attach.is_some() { + self.do_send_with_attach(target, text); return; } @@ -1843,40 +1850,61 @@ impl SleekApp { } } - fn do_send_with_image(&mut self, target: String, caption: String) { + fn do_send_with_attach(&mut self, target: String, caption: String) { if self.state.compose_uploading { return; } - let Some(img) = self.state.compose_image.clone() else { + let Some(attach) = self.state.compose_attach.clone() else { return; }; let Some(did) = self.state.did.clone() else { self.state - .show_toast("Sign in with Bluesky to send images"); + .show_toast("Sign in with Bluesky to send media"); return; }; if self.state.connection != ConnectionState::Registered && self.state.connection != ConnectionState::Connected { - self.state.show_toast("Connect before sending images"); + self.state.show_toast("Connect before sending media"); return; } - // PNG encode on a worker — large pastes used to freeze the egui frame - // (immediate-mode loop) before the upload even started. self.state.compose_upload_id = self.state.compose_upload_id.wrapping_add(1); let upload_id = self.state.compose_upload_id; self.state.compose_uploading = true; self.state.compose_upload_started = Some(std::time::Instant::now()); - self.state.compose_encode_meta = Some(crate::state::ComposeEncodeMeta { - upload_id, - target, - caption, - did, - api_base: api_base_for_server(&self.state.server), - }); - self.state.compose_encode_rx = Some(clipboard::start_encode_png(img)); - self.state.show_toast("Uploading image…"); + + match attach { + crate::state::ComposeAttach::Image(img) => { + // PNG encode on a worker — large pastes used to freeze the egui + // frame before the upload even started. + self.state.compose_encode_meta = Some(crate::state::ComposeEncodeMeta { + upload_id, + target, + caption, + did, + api_base: api_base_for_server(&self.state.server), + }); + self.state.compose_encode_rx = Some(clipboard::start_encode_png(img)); + self.state.show_toast("Uploading image…"); + } + crate::state::ComposeAttach::Video(video) => { + // Videos keep original bytes — no re-encode. + self.state.compose_encode_meta = None; + self.state.compose_encode_rx = None; + let bytes = video.bytes.as_ref().to_vec(); + self.net.send(NetCmd::UploadAndSend { + upload_id, + target, + caption, + bytes, + content_type: video.content_type, + did, + api_base: api_base_for_server(&self.state.server), + }); + self.state.show_toast("Uploading video…"); + } + } } /// Drain background PNG encode → `UploadAndSend`. Keeps the UI thread free. diff --git a/android/src/clipboard.rs b/android/src/clipboard.rs index 5c872c5..617d2d1 100644 --- a/android/src/clipboard.rs +++ b/android/src/clipboard.rs @@ -1,4 +1,4 @@ -//! Clipboard + file helpers for the compose-bar image attachment. +//! Clipboard + file helpers for the compose-bar media attachment. use std::path::Path; use std::sync::Arc; @@ -7,12 +7,14 @@ use std::sync::{Mutex, OnceLock}; #[cfg(not(target_os = "android"))] use std::time::Duration; -use crate::state::ComposeImage; +use crate::state::{ComposeAttach, ComposeImage, ComposeVideo}; /// Max pixel dimension after load (keeps memory / upload reasonable). const MAX_DIM: u32 = 4096; /// Refuse raw RGBA pastes larger than this (~25 MP). const MAX_RGBA_BYTES: usize = 100_000_000; +/// Server + freeq-app upload cap (also used as a pick-time reject for video). +pub const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024; /// How long the UI thread will wait on a clipboard/image helper thread. /// Arboard/X11 conversion and file decode must never block egui's immediate @@ -298,22 +300,22 @@ fn is_likely_image_path(path: &Path) -> bool { } } -/// Result of a native image file dialog (`Ok(None)` = user cancelled). -pub type PickImageResult = Result, String>; +/// Result of a native media file dialog (`Ok(None)` = user cancelled). +pub type PickAttachResult = Result, String>; /// Open the **OS file picker** on a background thread and return a receiver. /// /// Never call the dialog on the egui UI thread — a modal `block_on` there /// freezes the whole app (often until the process is killed). Desktop uses -/// `rfd` (xdg-desktop-portal). Android uses the system document/photo Intent. -pub fn start_pick_image_file() -> std::sync::mpsc::Receiver { +/// `rfd` (xdg-desktop-portal). Android uses the system document Intent. +pub fn start_pick_media_file() -> std::sync::mpsc::Receiver { let (tx, rx) = std::sync::mpsc::channel(); #[cfg(not(target_os = "android"))] { std::thread::Builder::new() .name("sleek-file-pick".into()) .spawn(move || { - let result = pick_image_file_desktop(); + let result = pick_media_file_desktop(); let _ = tx.send(result); }) .expect("spawn file pick thread"); @@ -323,7 +325,7 @@ pub fn start_pick_image_file() -> std::sync::mpsc::Receiver { std::thread::Builder::new() .name("sleek-file-pick".into()) .spawn(move || { - let result = crate::android_media::pick_image_file(); + let result = crate::android_media::pick_media_file(); let _ = tx.send(result); }) .expect("spawn android file pick thread"); @@ -352,7 +354,7 @@ fn rfd_runtime() -> &'static tokio::runtime::Runtime { /// Desktop: native OS dialog via rfd (portal / platform backend). #[cfg(not(target_os = "android"))] -fn pick_image_file_desktop() -> PickImageResult { +fn pick_media_file_desktop() -> PickAttachResult { // Serialize portal requests: a UI timeout can clear `file_pick_rx` while // the first worker is still inside `pick_file`, and two concurrent // OpenFileRequests on ashpd's shared connection also hang. @@ -360,14 +362,19 @@ fn pick_image_file_desktop() -> PickImageResult { let _guard = PICK_LOCK .get_or_init(|| Mutex::new(())) .lock() - .map_err(|_| "Image picker lock poisoned".to_string())?; + .map_err(|_| "File picker lock poisoned".to_string())?; // rfd's xdg-portal backend (ashpd → zbus) needs a Tokio reactor. Drive it // from this worker thread only — never on the egui UI thread. let path = rfd_runtime().block_on(async { rfd::AsyncFileDialog::new() - .set_title("Attach image") + .set_title("Attach image or video") + .add_filter( + "Media", + &["png", "jpg", "jpeg", "gif", "webp", "bmp", "mp4", "m4v", "webm", "mov"], + ) .add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) + .add_filter("Videos", &["mp4", "m4v", "webm", "mov"]) .pick_file() .await .map(|handle| handle.path().to_path_buf()) @@ -376,16 +383,107 @@ fn pick_image_file_desktop() -> PickImageResult { let Some(path) = path else { return Ok(None); }; - Ok(Some(load_image_from_path(&path)?)) + Ok(Some(load_attach_from_path(&path)?)) +} + +/// Load an image or video file into a compose attachment. +pub fn load_attach_from_path(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("Could not read file: {e}"))?; + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("attach") + .to_string(); + load_attach_from_vec(bytes, Some(&name)) } /// Load and decode an image file into RGBA for the compose preview / upload. pub fn load_image_from_path(path: &Path) -> Result { - let bytes = std::fs::read(path).map_err(|e| format!("Could not read file: {e}"))?; - load_image_from_bytes(&bytes) + match load_attach_from_path(path)? { + ComposeAttach::Image(img) => Ok(img), + ComposeAttach::Video(_) => Err("Not an image file".into()), + } +} + +/// Build a compose attachment from raw bytes + optional filename hint. +/// +/// Prefer [`load_attach_from_vec`] when you already own the buffer so video +/// attachments can move into an `Arc` without a second copy. +pub fn load_attach_from_bytes(bytes: &[u8], filename: Option<&str>) -> Result { + load_attach_from_vec(bytes.to_vec(), filename) +} + +/// Like [`load_attach_from_bytes`], but takes ownership (no video double-copy). +pub fn load_attach_from_vec( + bytes: Vec, + filename: Option<&str>, +) -> Result { + if bytes.is_empty() { + return Err("Empty file".into()); + } + + let name = filename.unwrap_or("attach"); + let lower = name.to_ascii_lowercase(); + if let Some(ct) = + video_content_type_for_name(&lower).or_else(|| sniff_video_content_type(&bytes)) + { + if bytes.len() > MAX_UPLOAD_BYTES { + return Err("Video is too large (max 10MB)".into()); + } + let filename = if lower.ends_with(".mp4") + || lower.ends_with(".m4v") + || lower.ends_with(".webm") + || lower.ends_with(".mov") + { + name.to_string() + } else { + default_video_filename(ct) + }; + return Ok(ComposeAttach::Video(ComposeVideo::new( + Arc::<[u8]>::from(bytes), + ct, + filename, + ))); + } + + Ok(ComposeAttach::Image(load_image_from_bytes(&bytes)?)) } -/// Decode image bytes (png/jpeg/gif/webp/bmp) into a compose attachment. +fn video_content_type_for_name(name: &str) -> Option<&'static str> { + let base = name.split('?').next().unwrap_or(name); + let base = base.split('#').next().unwrap_or(base); + if base.ends_with(".mp4") || base.ends_with(".m4v") { + Some("video/mp4") + } else if base.ends_with(".webm") { + Some("video/webm") + } else if base.ends_with(".mov") { + Some("video/quicktime") + } else { + None + } +} + +fn sniff_video_content_type(bytes: &[u8]) -> Option<&'static str> { + // ISO BMFF (`….ftyp`) — mp4 / m4v / mov. + if bytes.len() >= 8 && &bytes[4..8] == b"ftyp" { + return Some("video/mp4"); + } + // EBML / Matroska / WebM + if bytes.len() >= 4 && bytes[0..4] == [0x1A, 0x45, 0xDF, 0xA3] { + return Some("video/webm"); + } + None +} + +fn default_video_filename(content_type: &str) -> String { + match content_type { + "video/webm" => "clip.webm".into(), + "video/quicktime" => "clip.mov".into(), + _ => "clip.mp4".into(), + } +} + +/// Decode image bytes (png/jpeg/gif/webp/bmp) into a compose attachment image. pub fn load_image_from_bytes(bytes: &[u8]) -> Result { if bytes.is_empty() { return Err("Empty file".into()); @@ -447,3 +545,32 @@ pub fn start_encode_png( .expect("spawn png encode thread"); rx } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_mp4_as_video_attach() { + // Minimal ISO BMFF header (ftyp) — enough for sniff + attach. + let mut bytes = vec![0u8; 32]; + bytes[4..8].copy_from_slice(b"ftyp"); + bytes[8..12].copy_from_slice(b"isom"); + let attach = load_attach_from_bytes(&bytes, Some("clip.mp4")).unwrap(); + match attach { + ComposeAttach::Video(v) => { + assert_eq!(v.content_type, "video/mp4"); + assert_eq!(v.filename, "clip.mp4"); + } + other => panic!("expected video, got {other:?}"), + } + } + + #[test] + fn rejects_huge_video() { + let mut bytes = vec![0u8; MAX_UPLOAD_BYTES + 1]; + bytes[4..8].copy_from_slice(b"ftyp"); + let err = load_attach_from_bytes(&bytes, Some("big.mp4")).unwrap_err(); + assert!(err.contains("10MB"), "{err}"); + } +} diff --git a/android/src/net.rs b/android/src/net.rs index ee4947f..d6b281c 100644 --- a/android/src/net.rs +++ b/android/src/net.rs @@ -70,6 +70,8 @@ pub enum NetCmd { }, /// Download an image for inline chat preview. FetchImage { url: String }, + /// Download a video for muted inline playback (vidya player). + FetchVideo { url: String }, /// Fetch Open Graph metadata for a link card. FetchLinkPreview { url: String }, /// Open a freeq AV call in `channel` (sends `av-start`). @@ -175,6 +177,14 @@ pub enum NetEvent { ImageFetchFailed { url: String, }, + /// Remote video bytes for muted inline playback (vidya player). + VideoFetched { + url: String, + bytes: std::sync::Arc<[u8]>, + }, + VideoFetchFailed { + url: String, + }, /// Open Graph metadata for a link card. LinkPreviewFetched { url: String, @@ -665,6 +675,20 @@ async fn apply_cmd( } }); } + NetCmd::FetchVideo { url } => { + let tx = event_tx.clone(); + tokio::spawn(async move { + match fetch_video_bytes(&url).await { + Ok(bytes) => { + let _ = tx.send(NetEvent::VideoFetched { url, bytes }); + } + Err(e) => { + log::debug!("video fetch {url}: {e}"); + let _ = tx.send(NetEvent::VideoFetchFailed { url }); + } + } + }); + } NetCmd::FetchLinkPreview { url } => { let tx = event_tx.clone(); tokio::spawn(async move { @@ -1010,10 +1034,10 @@ async fn upload_media( bytes: Vec, ) -> Result { if bytes.is_empty() { - return Err("Empty image".into()); + return Err("Empty file".into()); } if bytes.len() > 10 * 1024 * 1024 { - return Err("Image is too large (max 10MB)".into()); + return Err("File is too large (max 10MB)".into()); } let base = api_base.trim_end_matches('/'); @@ -1028,6 +1052,10 @@ async fn upload_media( "image/jpeg" => "paste.jpg", "image/gif" => "paste.gif", "image/webp" => "paste.webp", + "video/mp4" => "clip.mp4", + "video/webm" => "clip.webm", + "video/quicktime" => "clip.mov", + _ if content_type.starts_with("video/") => "clip.mp4", _ => "paste.png", }; @@ -1057,7 +1085,7 @@ async fn upload_media( let short = body.chars().take(120).collect::(); let msg = match status.as_u16() { 401 => "Not authorized — stay signed in and connected, then try again".into(), - 413 => "Image is too large".into(), + 413 => "File is too large".into(), _ => format!("Upload failed ({status}) {short}").trim().to_string(), }; return Err(msg); @@ -1138,3 +1166,59 @@ async fn fetch_image_bytes(url: &str) -> Result<(usize, usize, std::sync::Arc<[u let height = rgba.height() as usize; Ok((width, height, std::sync::Arc::from(rgba.into_raw()))) } + +/// Fetch remote video bytes for chat inline playback (SSRF-safe). +async fn fetch_video_bytes(url: &str) -> Result, String> { + use crate::preview::MAX_VIDEO_BYTES; + + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err("Only http(s) video URLs".into()); + } + + let parsed = url::Url::parse(url).map_err(|e| format!("Bad URL: {e}"))?; + let host = parsed + .host_str() + .ok_or_else(|| "URL has no host".to_string())? + .to_string(); + let port = parsed + .port() + .unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 }); + + let addrs = freeq_sdk::ssrf::resolve_and_check(&host, port) + .await + .map_err(|e| format!("SSRF check: {e}"))?; + + let mut builder = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(5)); + for addr in &addrs { + builder = builder.resolve(&host, *addr); + } + let client = builder.build().map_err(|e| format!("HTTP client: {e}"))?; + + let resp = client + .get(url) + .header("Accept", "video/*,*/*;q=0.8") + .header("User-Agent", "Sleek/0.1 (chat video preview)") + .send() + .await + .map_err(|e| format!("Request failed: {e}"))? + .error_for_status() + .map_err(|e| format!("HTTP error: {e}"))?; + + if let Some(len) = resp.content_length() { + if len > MAX_VIDEO_BYTES as u64 { + return Err("Video too large".into()); + } + } + + let bytes = resp + .bytes() + .await + .map_err(|e| format!("Body: {e}"))?; + if bytes.len() > MAX_VIDEO_BYTES { + return Err("Video too large".into()); + } + + Ok(std::sync::Arc::from(bytes.as_ref())) +} diff --git a/android/src/preview.rs b/android/src/preview.rs index 3c226ed..7367229 100644 --- a/android/src/preview.rs +++ b/android/src/preview.rs @@ -1,13 +1,16 @@ //! URL detection and media/link-preview helpers for chat messages. //! -//! Mirrors freeq-android `MessageContent` patterns: image URLs show as inline -//! thumbs; other http(s) links get an Open Graph card (title/description/thumb). +//! Mirrors freeq-app / freeq-macos patterns: image URLs show as inline thumbs; +//! video URLs (`.mp4`/`.webm`/…) get an inline video card; other http(s) links +//! get an Open Graph card (title/description/thumb). use freeq_sdk::media::{LinkPreview, MediaAttachment}; use std::collections::HashMap; /// Max image download size (bytes). pub const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; +/// Chat video preview download cap (muted H.264 MP4 decode in vidya). +pub const MAX_VIDEO_BYTES: usize = 25 * 1024 * 1024; /// Downscale long edge for chat thumbs (pixels). pub const MAX_IMAGE_DIM: u32 = 720; @@ -16,7 +19,9 @@ pub const MAX_IMAGE_DIM: u32 = 720; pub enum Embed { /// Direct image URL (or IRCv3 media attachment). Image { url: String }, - /// Non-image http(s) link — fetch Open Graph metadata. + /// Direct video URL (or IRCv3 `video/*` media attachment). + Video { url: String }, + /// Non-media http(s) link — fetch Open Graph metadata. Link { url: String }, } @@ -26,7 +31,10 @@ pub fn embed_for_message(text: &str, tags: &HashMap) -> Option) -> Option Option { + let mut first_video: Option = None; let mut first_link: Option = None; for url in extract_urls(text) { if is_image_url(&url) { return Some(Embed::Image { url }); } + if is_video_url(&url) { + if first_video.is_none() { + first_video = Some(url); + } + continue; + } if first_link.is_none() { first_link = Some(url); } } - first_link.map(|url| Embed::Link { url }) + first_video + .map(|url| Embed::Video { url }) + .or_else(|| first_link.map(|url| Embed::Link { url })) } /// A http(s) URL found in free-form text, with its byte range in the source. @@ -128,6 +145,31 @@ pub fn is_image_url(url: &str) -> bool { has_image_ext(file) } +/// True for URLs that look like videos (extension, freeq media path, blob proxy mime). +/// +/// Mirrors freeq-app `VIDEO_URL_RE` / `PROXY_VIDEO_RE` and freeq-macos video +/// extensions (`.mp4` / `.m4v` / `.mov` / `.webm`). +pub fn is_video_url(url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + // freeq blob proxy with an explicit video MIME hint. + if lower.contains("/api/v1/blob?") && lower.contains("mime=video%2f") { + return true; + } + // freeq private media: `/api/v1/media/{id}/{sig}/{filename}` + if let Some(path) = url_path(&lower) { + if path.contains("/api/v1/media/") { + if let Some(name) = path.rsplit('/').next() { + if has_video_ext(name) { + return true; + } + } + } + } + let path = url_path(&lower).unwrap_or(lower.as_str()); + let file = path.rsplit('/').next().unwrap_or(path); + has_video_ext(file) +} + fn has_image_ext(name: &str) -> bool { // Strip query fragment already handled; allow `foo.png` or `foo.png.1` rarely — // stick to standard suffixes. @@ -141,6 +183,29 @@ fn has_image_ext(name: &str) -> bool { || base.ends_with(".bmp") } +fn has_video_ext(name: &str) -> bool { + let base = name.split('?').next().unwrap_or(name); + let base = base.split('#').next().unwrap_or(base); + base.ends_with(".mp4") + || base.ends_with(".m4v") + || base.ends_with(".mov") + || base.ends_with(".webm") +} + +/// Filename (or last path segment) for video/media cards. +pub fn display_filename(url: &str) -> String { + let path = display_path(url); + if path.is_empty() { + return display_host(url); + } + let name = path.rsplit('/').next().unwrap_or(path.as_str()); + if name.is_empty() || name == "…" { + display_host(url) + } else { + name.to_string() + } +} + fn url_path(url: &str) -> Option<&str> { let after_scheme = url .strip_prefix("https://") @@ -216,6 +281,23 @@ mod tests { "https://irc.freeq.at/api/v1/media/abc/sig/paste.png" )); assert!(!is_image_url("https://example.com/page")); + assert!(!is_image_url("https://ex.com/clip.mp4")); + } + + #[test] + fn detects_video_urls() { + assert!(is_video_url("https://ex.com/clip.MP4?x=1")); + assert!(is_video_url("https://ex.com/a.webm")); + assert!(is_video_url("https://ex.com/a.mov")); + assert!(is_video_url("https://ex.com/a.m4v")); + assert!(is_video_url( + "https://irc.freeq.at/api/v1/media/abc/sig/paste.mp4" + )); + assert!(is_video_url( + "https://irc.freeq.at/api/v1/blob?url=https%3A%2F%2Fx&mime=video%2Fmp4" + )); + assert!(!is_video_url("https://example.com/page")); + assert!(!is_video_url("https://ex.com/a.png")); } #[test] @@ -225,4 +307,31 @@ mod tests { other => panic!("expected image, got {other:?}"), } } + + #[test] + fn prefers_video_over_generic_link() { + match embed_from_text("hi https://ex.com/clip.mp4 and https://ex.com/doc") { + Some(Embed::Video { url }) => assert!(url.ends_with("clip.mp4")), + other => panic!("expected video, got {other:?}"), + } + } + + #[test] + fn prefers_image_over_video() { + match embed_from_text("https://ex.com/a.png https://ex.com/a.mp4") { + Some(Embed::Image { url }) => assert!(url.ends_with("a.png")), + other => panic!("expected image, got {other:?}"), + } + } + + #[test] + fn media_attachment_video_tag() { + let mut tags = HashMap::new(); + tags.insert("content-type".into(), "video/mp4".into()); + tags.insert("media-url".into(), "https://ex.com/v.mp4".into()); + match embed_for_message("https://ex.com/v.mp4", &tags) { + Some(Embed::Video { url }) => assert_eq!(url, "https://ex.com/v.mp4"), + other => panic!("expected video, got {other:?}"), + } + } } diff --git a/android/src/state.rs b/android/src/state.rs index d93e4d9..44be575 100644 --- a/android/src/state.rs +++ b/android/src/state.rs @@ -206,6 +206,13 @@ impl ChatMessage { && !self.text.trim().contains(char::is_whitespace) { "📷 Image".into() + } else if matches!( + self.resolved_embed(), + Some(crate::preview::Embed::Video { .. }) + ) && self.text.trim().starts_with("http") + && !self.text.trim().contains(char::is_whitespace) + { + "🎬 Video".into() } else { self.text.clone() }; @@ -705,7 +712,7 @@ pub struct ComposeEncodeMeta { pub api_base: String, } -/// Image attached to the compose bar (clipboard paste). +/// Image attached to the compose bar (clipboard paste / file pick). #[derive(Clone)] pub struct ComposeImage { pub width: usize, @@ -752,6 +759,63 @@ impl ComposeImage { } } +/// Video file attached to the compose bar (original bytes, no re-encode). +#[derive(Clone)] +pub struct ComposeVideo { + pub bytes: Arc<[u8]>, + pub content_type: String, + pub filename: String, +} + +impl std::fmt::Debug for ComposeVideo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ComposeVideo") + .field("content_type", &self.content_type) + .field("filename", &self.filename) + .field("bytes", &self.bytes.len()) + .finish() + } +} + +impl ComposeVideo { + pub fn new(bytes: Arc<[u8]>, content_type: impl Into, filename: impl Into) -> Self { + Self { + bytes, + content_type: content_type.into(), + filename: filename.into(), + } + } + + pub fn size_label(&self) -> String { + let n = self.bytes.len(); + if n >= 1024 * 1024 { + format!("{:.1} MB", n as f32 / (1024.0 * 1024.0)) + } else { + format!("{} KB", (n / 1024).max(1)) + } + } +} + +/// Pending compose attachment — image (RGBA preview) or video (raw file). +#[derive(Debug, Clone)] +pub enum ComposeAttach { + Image(ComposeImage), + Video(ComposeVideo), +} + +impl ComposeAttach { + pub fn is_video(&self) -> bool { + matches!(self, Self::Video(_)) + } + + pub fn kind_label(&self) -> &'static str { + match self { + Self::Image(_) => "Image", + Self::Video(_) => "Video", + } + } +} + // ── Chat media / OG preview cache ────────────────────────────────────────── /// Decoded remote image ready for (or already on) the GPU. @@ -819,13 +883,35 @@ pub enum LinkState { #[derive(Debug, Clone)] pub enum MediaFetch { Image(String), + Video(String), LinkPreview(String), } +/// Remote video bytes for the vidya preview player. +#[derive(Clone)] +pub enum VideoState { + Loading, + Ready(std::sync::Arc<[u8]>), + Failed, +} + +impl std::fmt::Debug for VideoState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Loading => write!(f, "Loading"), + Self::Ready(b) => write!(f, "Ready({} bytes)", b.len()), + Self::Failed => write!(f, "Failed"), + } + } +} + /// In-memory cache of remote images and Open Graph cards, plus a pending queue. #[derive(Debug, Default)] pub struct MediaCache { pub images: HashMap, + pub videos: HashMap, + /// Per-bubble playback widget state (vidya), keyed by `url\\0msgid`. + pub video_players: HashMap, pub links: HashMap, pending: Vec, } @@ -843,6 +929,18 @@ impl MediaCache { self.images.get(url) } + /// Ensure a video fetch is in flight for muted inline playback. + pub fn touch_video(&mut self, url: &str) -> Option<&VideoState> { + if url.is_empty() || !(url.starts_with("https://") || url.starts_with("http://")) { + return None; + } + if !self.videos.contains_key(url) { + self.videos.insert(url.to_string(), VideoState::Loading); + self.pending.push(MediaFetch::Video(url.to_string())); + } + self.videos.get(url) + } + /// Ensure an OG fetch is in flight (unless already seeded). pub fn touch_link(&mut self, url: &str) -> Option<&LinkState> { if url.is_empty() || !(url.starts_with("https://") || url.starts_with("http://")) { @@ -881,6 +979,14 @@ impl MediaCache { self.images.insert(url, ImageState::Failed); } + pub fn set_video_ready(&mut self, url: String, bytes: std::sync::Arc<[u8]>) { + self.videos.insert(url, VideoState::Ready(bytes)); + } + + pub fn set_video_failed(&mut self, url: String) { + self.videos.insert(url, VideoState::Failed); + } + pub fn set_link_ready(&mut self, url: String, meta: LinkMeta) { if let Some(ref thumb) = meta.thumb_url { self.touch_image(thumb); @@ -899,6 +1005,7 @@ impl MediaCache { pub fn has_loading(&self) -> bool { self.images.values().any(|s| matches!(s, ImageState::Loading)) + || self.videos.values().any(|s| matches!(s, VideoState::Loading)) || self.links.values().any(|s| matches!(s, LinkState::Loading)) } } @@ -946,9 +1053,9 @@ pub struct AppState { pub tab: Tab, pub compose: String, - /// Pending image from clipboard paste (shown above the text field). - pub compose_image: Option, - /// True while a pasted image is uploading to freeq. + /// Pending image/video attachment (shown above the text field). + pub compose_attach: Option, + /// True while a pasted image/video is uploading to freeq. pub compose_uploading: bool, /// Monotonic id for the in-flight compose upload (cancel / stale finish). pub compose_upload_id: u64, @@ -962,8 +1069,8 @@ pub struct AppState { pub focus_compose: bool, /// Tab-cycle state for compose nick completion (IRC-style). pub compose_nick_tab: NickTabComplete, - /// In-flight OS image file dialog (attach button). Polled each frame. - pub file_pick_rx: Option>, + /// In-flight OS media file dialog (attach button). Polled each frame. + pub file_pick_rx: Option>, /// When `file_pick_rx` was set — used to unlock the compose bar if the OS /// dialog dies without a result (e.g. Android cancel with no activity result). pub file_pick_started: Option, @@ -1119,7 +1226,7 @@ impl AppState { route: Route::Tabs, tab: Tab::Chats, compose: String::new(), - compose_image: None, + compose_attach: None, compose_uploading: false, compose_upload_id: 0, compose_upload_started: None, @@ -1262,17 +1369,17 @@ impl AppState { self.toast_until = Some(Instant::now() + TOAST_DURATION); } - /// True while the OS attach-image dialog is open (or a chosen file is decoding). + /// True while the OS attach-media dialog is open (or a chosen file is decoding). pub fn file_pick_busy(&self) -> bool { self.file_pick_rx.is_some() } - /// Launch the OS image file picker (no-op if one is already open). + /// Launch the OS image/video file picker (no-op if one is already open). pub fn start_file_pick(&mut self) { if self.file_pick_rx.is_some() { return; } - self.file_pick_rx = Some(crate::clipboard::start_pick_image_file()); + self.file_pick_rx = Some(crate::clipboard::start_pick_media_file()); self.file_pick_started = Some(Instant::now()); } @@ -1293,10 +1400,10 @@ impl AppState { self.compose_upload_id = self.compose_upload_id.wrapping_add(1); } - /// Remove the attached image and unlock compose (✕ / Cancel). - pub fn clear_compose_image(&mut self) { + /// Remove the attached media and unlock compose (✕ / Cancel). + pub fn clear_compose_attach(&mut self) { self.cancel_compose_upload(); - self.compose_image = None; + self.compose_attach = None; } /// Apply a finished OS file-dialog result. Call once per frame from the app loop. @@ -1307,8 +1414,8 @@ impl AppState { return false; }; match rx.try_recv() { - Ok(Ok(Some(img))) => { - self.compose_image = Some(img); + Ok(Ok(Some(attach))) => { + self.compose_attach = Some(attach); self.focus_compose = true; self.clear_file_pick(); false @@ -1335,7 +1442,7 @@ impl AppState { .is_some_and(|t| t.elapsed() > PICK_UI_TIMEOUT) { self.clear_file_pick(); - self.show_toast("Image picker closed".to_string()); + self.show_toast("File picker closed".to_string()); false } else { true @@ -1729,7 +1836,7 @@ impl AppState { // Switching buffers: drop pending compose so we don't send into the wrong room. if self.active_channel.as_deref() != Some(key.as_str()) { self.compose.clear(); - self.clear_compose_image(); + self.clear_compose_attach(); self.compose_nick_tab.clear(); self.cancel_edit(); } @@ -1760,7 +1867,7 @@ impl AppState { self.scroll_to_msgid = None; self.clear_search_highlight(); self.compose.clear(); - self.clear_compose_image(); + self.clear_compose_attach(); self.compose_nick_tab.clear(); self.cancel_edit(); } @@ -1872,7 +1979,7 @@ impl AppState { pub fn begin_edit(&mut self, msgid: String, text: String) { self.editing_msgid = Some(msgid); self.compose = text; - self.clear_compose_image(); + self.clear_compose_attach(); self.compose_nick_tab.clear(); self.focus_compose = true; self.close_react_picker(); @@ -1924,7 +2031,7 @@ impl AppState { self.scroll_to_msgid = None; self.clear_search_highlight(); self.compose.clear(); - self.clear_compose_image(); + self.clear_compose_attach(); self.compose_nick_tab.clear(); self.local_call = None; self.clear_av_media(); @@ -2308,7 +2415,11 @@ mod tests { state.compose_upload_id = 7; state.compose_uploading = true; state.compose_upload_started = Some(Instant::now()); - state.compose_image = Some(ComposeImage::from_rgba(1, 1, std::sync::Arc::from([0, 0, 0, 255]))); + state.compose_attach = Some(ComposeAttach::Image(ComposeImage::from_rgba( + 1, + 1, + std::sync::Arc::from([0, 0, 0, 255]), + ))); let before = state.compose_upload_id; state.cancel_compose_upload(); @@ -2316,10 +2427,10 @@ mod tests { assert!(state.compose_upload_started.is_none()); assert_ne!(state.compose_upload_id, before); // Attachment kept so the user can retry after Cancel upload. - assert!(state.compose_image.is_some()); + assert!(state.compose_attach.is_some()); - state.clear_compose_image(); - assert!(state.compose_image.is_none()); + state.clear_compose_attach(); + assert!(state.compose_attach.is_none()); assert!(!state.compose_uploading); } } diff --git a/android/src/ui/chat.rs b/android/src/ui/chat.rs index 5a866e6..e6fbd29 100644 --- a/android/src/ui/chat.rs +++ b/android/src/ui/chat.rs @@ -7,7 +7,7 @@ use vidya::{ }; use crate::clipboard; -use crate::state::{AppState, NickTabComplete}; +use crate::state::{AppState, ComposeAttach, NickTabComplete}; use crate::ui::search::{message_search_panel, SearchAction}; use crate::ui::widgets::{ avatar_circle, empty_state, message_bubble, react_picker_overlay, MessageBubbleAction, @@ -461,13 +461,13 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: // Ui and shrinks available_height for what follows — so the scroll area // always gets the leftover strip. The earlier bottom_up + fixed-rect approach // left a huge dead zone under the input (compose sat under the first message). - try_paste_compose_image(ui, state); + try_paste_compose_attach(ui, state); let body_w = ui.available_width().max(80.0); - let has_image = state.compose_image.is_some(); + let has_attach = state.compose_attach.is_some(); let uploading = state.compose_uploading; - let can_send = !uploading && (!state.compose.trim().is_empty() || has_image); + let can_send = !uploading && (!state.compose.trim().is_empty() || has_attach); let pick_busy = state.file_pick_busy(); egui::TopBottomPanel::bottom("chat_compose") @@ -500,8 +500,8 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: &nick_candidates, ); - if has_image { - if let Some(send) = compose_image_composer(ui, th, state, compose_id) { + if has_attach { + if let Some(send) = compose_attach_composer(ui, th, state, compose_id) { action = ChatAction::Send { target: channel.to_string(), text: send, @@ -535,9 +535,9 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: let attach_tip = if pick_busy { "Opening file picker…" } else if cfg!(target_os = "android") { - "Attach image" + "Attach image or video" } else { - "Attach image (or paste with Ctrl+V)" + "Attach image or video (or paste image with Ctrl+V)" }; let (hint, action_label, action_w) = if is_editing { ("Edit message…", "Save", 72.0_f32) @@ -738,10 +738,10 @@ pub fn chat_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, channel: action } -/// Image attachment preview + caption/submit row (mirrors the normal compose bar). +/// Media attachment preview + caption/submit row (mirrors the normal compose bar). /// /// Returns `Some(caption)` when the user submits. -fn compose_image_composer( +fn compose_attach_composer( ui: &mut egui::Ui, th: &Theme, state: &mut AppState, @@ -750,36 +750,51 @@ fn compose_image_composer( let p = &th.palette; let sp = &th.spacing; - // Snapshot texture / dims first so we don't hold a mut borrow across clicks - // that clear `compose_image`. - let (tex_id, width, height, uploading) = { - let Some(img) = state.compose_image.as_mut() else { + enum Thumb { + Image { + tex_id: egui::TextureId, + width: usize, + height: usize, + dims: String, + }, + Video { + filename: String, + dims: String, + }, + } + + let (thumb, kind_label, uploading) = { + let Some(attach) = state.compose_attach.as_mut() else { return None; }; - let tex = img.texture(ui.ctx()).clone(); - (tex.id(), img.width, img.height, state.compose_uploading) + let uploading = state.compose_uploading; + let kind = attach.kind_label(); + let thumb = match attach { + ComposeAttach::Image(img) => { + let tex = img.texture(ui.ctx()).clone(); + let kb = (img.width.saturating_mul(img.height).saturating_mul(4) / 1024).max(1); + Thumb::Image { + tex_id: tex.id(), + width: img.width, + height: img.height, + dims: format!("{}×{} · ~{kb} KB", img.width, img.height), + } + } + ComposeAttach::Video(video) => Thumb::Video { + filename: video.filename.clone(), + dims: format!("{} · {}", video.content_type, video.size_label()), + }, + }; + (thumb, kind, uploading) }; - // Compact thumb — keep the bar short so messages stay visible. const THUMB: f32 = 48.0; - let scale = (THUMB / width.max(1) as f32) - .min(THUMB / height.max(1) as f32) - .min(1.0); - let img_size = Vec2::new( - (width as f32 * scale).max(1.0), - (height as f32 * scale).max(1.0), - ); - - let kb = (width.saturating_mul(height).saturating_mul(4) / 1024).max(1); - let dims = format!("{width}×{height} · ~{kb} KB"); - let mut clear = false; let mut submit = false; let pick_busy = state.file_pick_busy(); - let can_send = !uploading; // image alone is enough + let can_send = !uploading; let bar_w = ui.available_width(); - // Preview card only (caption lives on the shared compose row below). egui::Frame::new() .fill(p.view_bg) .stroke(egui::Stroke::new(1.0_f32, p.border_soft)) @@ -795,30 +810,68 @@ fn compose_image_composer( ui.allocate_exact_size(Vec2::splat(THUMB), Sense::hover()); ui.painter() .rect_filled(thumb_rect, sp.radius_sm, p.card_bg); - let img_rect = egui::Rect::from_center_size(thumb_rect.center(), img_size); - ui.put( - img_rect, - egui::Image::new((tex_id, img_size)) - .corner_radius(sp.radius_sm) - .sense(Sense::hover()), - ); + match &thumb { + Thumb::Image { + tex_id, + width, + height, + .. + } => { + let scale = (THUMB / (*width).max(1) as f32) + .min(THUMB / (*height).max(1) as f32) + .min(1.0); + let img_size = Vec2::new( + (*width as f32 * scale).max(1.0), + (*height as f32 * scale).max(1.0), + ); + let img_rect = egui::Rect::from_center_size(thumb_rect.center(), img_size); + ui.put( + img_rect, + egui::Image::new((*tex_id, img_size)) + .corner_radius(sp.radius_sm) + .sense(Sense::hover()), + ); + } + Thumb::Video { .. } => { + // Play glyph stand-in (matches chat video preview). + ui.painter().circle_filled( + thumb_rect.center(), + 12.0, + p.accent.gamma_multiply(0.92), + ); + let c = thumb_rect.center(); + ui.painter().add(egui::Shape::convex_polygon( + vec![ + egui::pos2(c.x + 6.0, c.y), + egui::pos2(c.x - 5.0, c.y + 5.5), + egui::pos2(c.x - 5.0, c.y - 5.5), + ], + egui::Color32::WHITE, + egui::Stroke::NONE, + )); + } + } ui.add_space(sp.sm); let dismiss_w = 32.0_f32; let meta_w = (ui.available_width() - dismiss_w - sp.xs).max(48.0); + let dims = match &thumb { + Thumb::Image { dims, .. } | Thumb::Video { dims, .. } => dims.clone(), + }; + let title = match &thumb { + Thumb::Video { filename, .. } if !uploading => filename.clone(), + _ if uploading => "Uploading…".into(), + _ => format!("{kind_label} attached"), + }; ui.vertical(|ui| { ui.set_width(meta_w); ui.add_space(2.0); ui.label( - RichText::new(if uploading { - "Uploading…" - } else { - "Image attached" - }) - .size(th.type_scale.body) - .color(if uploading { p.accent } else { p.text }) - .strong(), + RichText::new(title) + .size(th.type_scale.body) + .color(if uploading { p.accent } else { p.text }) + .strong(), ); ui.add_space(2.0); dim_label(ui, th, &dims); @@ -829,8 +882,6 @@ fn compose_image_composer( ui.spinner(); ui.add_space(sp.xs); } - // Always allow dismiss/cancel — a stuck upload used to leave - // the compose bar non-interactive (no paste, no ✕) until restart. let dismiss = ui .add_sized( Vec2::splat(28.0), @@ -846,7 +897,7 @@ fn compose_image_composer( .on_hover_text(if uploading { "Cancel upload" } else { - "Remove image" + "Remove attachment" }) .on_hover_cursor(CursorIcon::PointingHand); if dismiss.clicked() { @@ -858,16 +909,13 @@ fn compose_image_composer( ui.add_space(sp.sm); - // Same row shape as the no-image compose bar: attach | caption | Submit. let attach_tip = if pick_busy { "Opening file picker…" } else if cfg!(target_os = "android") { - "Replace image" + "Replace attachment" } else { - "Replace image (or paste with Ctrl+V)" + "Replace attachment (or paste image with Ctrl+V)" }; - // Keep the caption field interactive during upload so Ctrl+V / typing are - // never wedged by a slow encode or hung HTTP — only Submit is gated. let (resp, attach_clicked, send_clicked) = compose_input_row( ui, th, @@ -895,10 +943,9 @@ fn compose_image_composer( if clear { if uploading { - // Unlock compose; keep the thumb so Submit can retry. state.cancel_compose_upload(); } else { - state.clear_compose_image(); + state.clear_compose_attach(); } } @@ -1232,7 +1279,7 @@ fn try_nick_tab_complete( /// Clipboard image reads run with a short timeout off the UI thread (see /// [`clipboard::try_get_image`]) so a stuck X11/Wayland conversion cannot /// freeze egui's immediate-mode loop. -fn try_paste_compose_image(ui: &mut egui::Ui, state: &mut AppState) { +fn try_paste_compose_attach(ui: &mut egui::Ui, state: &mut AppState) { // Don't replace the attachment mid-upload; text paste still reaches the // interactive caption field via `Event::Paste`. if state.compose_uploading { @@ -1266,7 +1313,7 @@ fn try_paste_compose_image(ui: &mut egui::Ui, state: &mut AppState) { i.consume_key(egui::Modifiers::CTRL, egui::Key::V); }); - state.compose_image = Some(img); + state.compose_attach = Some(ComposeAttach::Image(img)); state.focus_compose = true; } diff --git a/android/src/ui/widgets.rs b/android/src/ui/widgets.rs index 1b989e1..e97bd61 100644 --- a/android/src/ui/widgets.rs +++ b/android/src/ui/widgets.rs @@ -755,6 +755,10 @@ pub fn message_bubble( }; let embed = msg.resolved_embed(); + let video_url = match &embed { + Some(Embed::Video { url }) => Some(url.clone()), + _ => None, + }; let can_mutate = !msg.id.is_empty() && !msg.id.starts_with("local-") && !msg.id.starts_with("sys-"); let can_react = can_mutate; @@ -888,24 +892,36 @@ pub fn message_bubble( }; } - if let Some(embed) = embed { - ui.add_space(sp.sm); + if let Some(embed) = embed.as_ref() { match embed { + // Video mounts outside the bubble frame (below) so play + // clicks are not stolen by later bubble interacts. + Embed::Video { .. } => {} Embed::Image { url } => { - if inline_image_preview(ui, th, media, &url) { - action = MessageBubbleAction::OpenImage { url }; + ui.add_space(sp.sm); + if inline_image_preview(ui, th, media, url) { + action = MessageBubbleAction::OpenImage { + url: url.clone(), + }; } } Embed::Link { url } => { + ui.add_space(sp.sm); let seed = msg.link_meta.clone(); // Salt with message id so two bubbles with the same URL // never share an interact / hover widget id. - og_link_preview(ui, th, media, &url, seed.as_ref(), &msg.id); + og_link_preview(ui, th, media, url, seed.as_ref(), &msg.id); } } } }); + // Video below the bubble — same layering rule as reaction chips. + if let Some(url) = video_url { + ui.add_space(sp.xs); + inline_video_preview(ui, th, media, &url, &msg.id); + } + // Store bubble rect for next-frame hover hit-testing (inline chip fade). if action_metrics.is_some() { ui.ctx().data_mut(|d| { @@ -1459,6 +1475,139 @@ fn inline_image_preview(ui: &mut egui::Ui, th: &Theme, media: &mut MediaCache, u } } +/// Inline video card via [`vidya::video_player`] (muted H.264-in-MP4). +/// +/// Unsupported formats (WebM, etc.) keep the play-card look and open externally. +fn inline_video_preview( + ui: &mut egui::Ui, + th: &Theme, + media: &mut MediaCache, + url: &str, + id_salt: &str, +) { + use vidya::{video_player, VideoPlayerAction, VideoPlayerOpts, VideoPlayerState}; + + media.touch_video(url); + + let sp = &th.spacing; + let max_w = ui.available_width().min(EMBED_MAX_W).max(120.0); + + match media.videos.get(url) { + Some(crate::state::VideoState::Loading) | None => { + egui::Frame::new() + .fill(th.palette.headerbar_bg) + .corner_radius(sp.radius_sm) + .inner_margin(egui::Margin::symmetric(12, 16)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spinner(); + ui.add_space(sp.sm); + dim_label(ui, th, "Loading video…"); + }); + }); + return; + } + Some(crate::state::VideoState::Failed) => { + // Fall back to the open-in-browser card without bytes. + video_open_fallback(ui, th, url, id_salt); + return; + } + Some(crate::state::VideoState::Ready(_)) => {} + } + + // Clone Arc so we can mutably borrow the player map separately. + let bytes = match media.videos.get(url) { + Some(crate::state::VideoState::Ready(b)) => b.clone(), + _ => return, + }; + + // Per-bubble player state — same URL in two messages must not share playhead. + let player_key = format!("{url}\0{id_salt}"); + let player = media + .video_players + .entry(player_key) + .or_insert_with(VideoPlayerState::new); + player.load_bytes(ui.ctx(), (url, id_salt), bytes); + + let opts = VideoPlayerOpts { + max_width: max_w, + max_height: EMBED_MAX_H, + title: Some(preview::display_filename(url)), + open_url_on_unsupported: Some(url.to_string()), + }; + let (_resp, action) = video_player(ui, th, player, &opts); + if action == VideoPlayerAction::OpenExternally { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } +} + +/// Play-card that only opens the URL (fetch/decode failed). +fn video_open_fallback(ui: &mut egui::Ui, th: &Theme, url: &str, id_salt: &str) { + let p = &th.palette; + let sp = &th.spacing; + + let max_w = ui.available_width().min(EMBED_MAX_W).max(120.0); + let height = (max_w * 9.0 / 16.0).min(EMBED_MAX_H).max(72.0); + let size = Vec2::new(max_w, height); + let card_id = ui.id().with("video_fallback").with(id_salt); + + let frame_resp = egui::Frame::new() + .fill(Color32::from_rgb(12, 12, 14)) + .stroke(Stroke::new(1.0_f32, p.border_soft)) + .corner_radius(sp.radius_sm) + .show(ui, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter() + .rect_filled(rect, sp.radius_sm, Color32::from_rgb(18, 18, 22)); + let play_r = (height * 0.18).clamp(16.0, 28.0); + let center = rect.center(); + ui.painter() + .circle_filled(center, play_r, p.accent.gamma_multiply(0.92)); + let tri_w = play_r * 0.7; + let tri_h = play_r * 0.85; + let tip = Pos2::new(center.x + tri_w * 0.55, center.y); + let top = Pos2::new(center.x - tri_w * 0.45, center.y - tri_h * 0.5); + let bot = Pos2::new(center.x - tri_w * 0.45, center.y + tri_h * 0.5); + ui.painter().add(egui::Shape::convex_polygon( + vec![tip, bot, top], + Color32::from_rgb(255, 255, 255), + Stroke::NONE, + )); + let name = preview::display_filename(url); + let foot_h = (th.type_scale.caption + 10.0).min(height * 0.28); + let foot = Rect::from_min_max( + Pos2::new(rect.left(), rect.bottom() - foot_h), + rect.right_bottom(), + ); + let r = sp.radius_sm as u8; + ui.painter().rect_filled( + foot, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: r, + se: r, + }, + Color32::from_rgba_unmultiplied(0, 0, 0, 160), + ); + ui.painter().text( + Pos2::new(foot.left() + 8.0, foot.center().y), + Align2::LEFT_CENTER, + name, + FontId::proportional(th.type_scale.caption), + Color32::from_rgb(230, 230, 235), + ); + }); + + let resp = ui + .interact(frame_resp.response.rect, card_id, Sense::click()) + .on_hover_text("Open video") + .on_hover_cursor(CursorIcon::PointingHand); + if resp.clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + } +} + fn og_link_preview( ui: &mut egui::Ui, th: &Theme, diff --git a/flake.lock b/flake.lock index 64453b0..648b335 100644 --- a/flake.lock +++ b/flake.lock @@ -132,17 +132,17 @@ "vidya": { "flake": false, "locked": { - "lastModified": 1785634073, - "narHash": "sha256-3lRMABykdWy7ZkHWzR7r+KhLq8QABsGY4SNGG/MARpA=", - "ref": "refs/heads/main", - "rev": "c50fcb45a22116c48b787230b295db202a1761fc", - "revCount": 23, - "type": "git", - "url": "https://tangled.org/nandi.uk/vidya" + "lastModified": 1785764010, + "narHash": "sha256-O+PwGhCXpt5CRkdF9PZSU0Kp7gYQ/cwP545rlINcL6s=", + "owner": "codegod100", + "repo": "vidya", + "rev": "7a24e2f6a340c79c6a7585e9ae092824de248848", + "type": "github" }, "original": { - "type": "git", - "url": "https://tangled.org/nandi.uk/vidya" + "owner": "codegod100", + "repo": "vidya", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index a87f930..3670fbd 100644 --- a/flake.nix +++ b/flake.nix @@ -17,8 +17,9 @@ }; # Path deps in Cargo.toml are ../../vidya and ../../freeq/freeq-sdk — # pin them as flake inputs so `nix build` works without a monorepo checkout. + # GitHub mirror of tangled.org/nandi.uk/vidya (includes video_player). vidya = { - url = "git+https://tangled.org/nandi.uk/vidya"; + url = "github:codegod100/vidya"; flake = false; }; freeq = { diff --git a/host/Cargo.lock b/host/Cargo.lock index 3b86904..778e777 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -4183,6 +4183,20 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "n0-error" version = "0.1.3" @@ -4601,6 +4615,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -8118,6 +8144,8 @@ name = "vidya" version = "0.1.0" dependencies = [ "egui", + "mp4", + "openh264", "png 0.17.16", "zip", ]