| Bring vidya in cfd3e36 nandi 19d ago | 1 | //! Inline video preview / muted H.264-in-MP4 player for egui. |
| 2 | //! |
| 3 | //! Host apps fetch bytes (SSRF-safe), then mount [`video_player`] with a |
| 4 | //! [`VideoPlayerState`]. Decode is behind the `video` cargo feature so default |
| 5 | //! `vidya` stays light (theme-only). |
| 6 | |
| 7 | use std::hash::{Hash, Hasher}; |
| 8 | use std::sync::Arc; |
| 9 | use std::time::Instant; |
| 10 | |
| 11 | use egui::{ |
| 12 | Align2, Color32, ColorImage, CursorIcon, FontId, Pos2, Rect, Sense, Stroke, TextureHandle, |
| 13 | TextureOptions, Ui, Vec2, |
| 14 | }; |
| 15 | |
| 16 | use crate::Theme; |
| 17 | |
| 18 | #[cfg(feature = "video")] |
| 19 | mod avcc; |
| 20 | #[cfg(feature = "video")] |
| 21 | mod decode; |
| 22 | |
| 23 | /// Options for [`video_player`]. |
| 24 | #[derive(Debug, Clone)] |
| 25 | pub struct VideoPlayerOpts { |
| 26 | /// Max width of the player surface. |
| 27 | pub max_width: f32, |
| 28 | /// Max height of the player surface. |
| 29 | pub max_height: f32, |
| 30 | /// Footer label (filename / host). |
| 31 | pub title: Option<String>, |
| 32 | /// When set, a failed / unsupported decode falls back to opening this URL. |
| 33 | pub open_url_on_unsupported: Option<String>, |
| 34 | } |
| 35 | |
| 36 | impl Default for VideoPlayerOpts { |
| 37 | fn default() -> Self { |
| 38 | Self { |
| 39 | max_width: 320.0, |
| 40 | max_height: 288.0, |
| 41 | title: None, |
| 42 | open_url_on_unsupported: None, |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Outcome of interacting with the player this frame. |
| 48 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 49 | pub enum VideoPlayerAction { |
| 50 | #[default] |
| 51 | None, |
| 52 | /// User asked to open the media externally (unsupported codec / explicit). |
| 53 | OpenExternally, |
| 54 | } |
| 55 | |
| 56 | /// Per-embed playback state. Store one of these per video URL (or message id). |
| 57 | #[derive(Default)] |
| 58 | pub struct VideoPlayerState { |
| 59 | content_id: u64, |
| 60 | #[cfg(feature = "video")] |
| 61 | session: Option<decode::DecodeSession>, |
| 62 | texture: Option<TextureHandle>, |
| 63 | tex_size: (u32, u32), |
| 64 | playing: bool, |
| 65 | play_started: Option<Instant>, |
| 66 | /// Elapsed media time when paused. |
| 67 | paused_at: f64, |
| 68 | error: Option<String>, |
| 69 | unsupported: bool, |
| 70 | loaded: bool, |
| 71 | } |
| 72 | |
| 73 | impl std::fmt::Debug for VideoPlayerState { |
| 74 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 75 | f.debug_struct("VideoPlayerState") |
| 76 | .field("content_id", &self.content_id) |
| 77 | .field("playing", &self.playing) |
| 78 | .field("error", &self.error) |
| 79 | .field("unsupported", &self.unsupported) |
| 80 | .field("loaded", &self.loaded) |
| 81 | .finish() |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | impl VideoPlayerState { |
| 86 | pub fn new() -> Self { |
| 87 | Self::default() |
| 88 | } |
| 89 | |
| 90 | pub fn is_loaded(&self) -> bool { |
| 91 | self.loaded |
| 92 | } |
| 93 | |
| 94 | pub fn is_playing(&self) -> bool { |
| 95 | self.playing |
| 96 | } |
| 97 | |
| 98 | pub fn error(&self) -> Option<&str> { |
| 99 | self.error.as_deref() |
| 100 | } |
| 101 | |
| 102 | pub fn unsupported(&self) -> bool { |
| 103 | self.unsupported |
| 104 | } |
| 105 | |
| 106 | /// Load (or reload) MP4 bytes. Idempotent when `id` matches the current content. |
| 107 | pub fn load_bytes(&mut self, ctx: &egui::Context, id: impl Hash, bytes: Arc<[u8]>) { |
| 108 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); |
| 109 | id.hash(&mut hasher); |
| 110 | // Also mix length so same path with new bytes reloads. |
| 111 | bytes.len().hash(&mut hasher); |
| 112 | let content_id = hasher.finish(); |
| 113 | if self.loaded && self.content_id == content_id { |
| 114 | return; |
| 115 | } |
| 116 | self.reset(); |
| 117 | self.content_id = content_id; |
| 118 | |
| 119 | #[cfg(feature = "video")] |
| 120 | { |
| 121 | match decode::DecodeSession::open(bytes) { |
| 122 | Ok(mut session) => { |
| 123 | if let Some((w, h, rgba)) = session.frame.take() { |
| 124 | self.upload_frame(ctx, w, h, &rgba); |
| 125 | session.frame = Some((w, h, rgba)); |
| 126 | } |
| 127 | self.session = Some(session); |
| 128 | self.loaded = true; |
| 129 | } |
| 130 | Err(e) => { |
| 131 | self.unsupported = true; |
| 132 | self.error = Some(e); |
| 133 | self.loaded = true; |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | #[cfg(not(feature = "video"))] |
| 139 | { |
| 140 | let _ = (ctx, bytes); |
| 141 | self.unsupported = true; |
| 142 | self.error = Some("Video decode feature not enabled".into()); |
| 143 | self.loaded = true; |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | pub fn clear(&mut self) { |
| 148 | self.reset(); |
| 149 | } |
| 150 | |
| 151 | fn reset(&mut self) { |
| 152 | #[cfg(feature = "video")] |
| 153 | { |
| 154 | self.session = None; |
| 155 | } |
| 156 | self.texture = None; |
| 157 | self.tex_size = (0, 0); |
| 158 | self.playing = false; |
| 159 | self.play_started = None; |
| 160 | self.paused_at = 0.0; |
| 161 | self.error = None; |
| 162 | self.unsupported = false; |
| 163 | self.loaded = false; |
| 164 | self.content_id = 0; |
| 165 | } |
| 166 | |
| 167 | fn upload_frame(&mut self, ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) { |
| 168 | let color = ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba); |
| 169 | match &mut self.texture { |
| 170 | Some(tex) if self.tex_size == (w, h) => { |
| 171 | tex.set(color, TextureOptions::LINEAR); |
| 172 | } |
| 173 | _ => { |
| 174 | let name = format!("vidya-video-{}", self.content_id); |
| 175 | self.texture = Some(ctx.load_texture(name, color, TextureOptions::LINEAR)); |
| 176 | self.tex_size = (w, h); |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | fn media_time(&self) -> f64 { |
| 182 | if self.playing { |
| 183 | let started = self.play_started.unwrap_or_else(Instant::now); |
| 184 | self.paused_at + started.elapsed().as_secs_f64() |
| 185 | } else { |
| 186 | self.paused_at |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | fn toggle_play(&mut self, ctx: &egui::Context) { |
| 191 | if self.unsupported { |
| 192 | return; |
| 193 | } |
| 194 | if self.playing { |
| 195 | self.paused_at = self.media_time(); |
| 196 | self.playing = false; |
| 197 | self.play_started = None; |
| 198 | } else { |
| 199 | #[cfg(feature = "video")] |
| 200 | { |
| 201 | if let Some(session) = self.session.as_ref() { |
| 202 | if self.paused_at >= session.duration.as_secs_f64() { |
| 203 | self.paused_at = 0.0; |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | self.playing = true; |
| 208 | self.play_started = Some(Instant::now()); |
| 209 | ctx.request_repaint(); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | #[cfg(feature = "video")] |
| 214 | fn tick_decode(&mut self, ctx: &egui::Context) { |
| 215 | if !self.playing { |
| 216 | return; |
| 217 | } |
| 218 | let t = self.paused_at |
| 219 | + self |
| 220 | .play_started |
| 221 | .map(|s| s.elapsed().as_secs_f64()) |
| 222 | .unwrap_or(0.0); |
| 223 | |
| 224 | let Some(session) = self.session.as_mut() else { |
| 225 | return; |
| 226 | }; |
| 227 | if let Err(e) = session.seek_playhead(t) { |
| 228 | self.error = Some(e); |
| 229 | self.playing = false; |
| 230 | return; |
| 231 | } |
| 232 | let frame = session.frame.clone(); |
| 233 | let ended = session.ended(t); |
| 234 | let duration = session.duration.as_secs_f64(); |
| 235 | |
| 236 | if let Some((w, h, rgba)) = frame { |
| 237 | self.upload_frame(ctx, w, h, &rgba); |
| 238 | } |
| 239 | if ended { |
| 240 | self.playing = false; |
| 241 | self.play_started = None; |
| 242 | self.paused_at = duration; |
| 243 | } else { |
| 244 | ctx.request_repaint(); |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | /// Draw a 16:9 (or source-aspect) video surface with play/pause. |
| 250 | /// |
| 251 | /// Call [`VideoPlayerState::load_bytes`] first when media bytes are ready. |
| 252 | pub fn video_player( |
| 253 | ui: &mut Ui, |
| 254 | theme: &Theme, |
| 255 | state: &mut VideoPlayerState, |
| 256 | opts: &VideoPlayerOpts, |
| 257 | ) -> (egui::Response, VideoPlayerAction) { |
| 258 | let p = &theme.palette; |
| 259 | let sp = &theme.spacing; |
| 260 | |
| 261 | #[cfg(feature = "video")] |
| 262 | state.tick_decode(ui.ctx()); |
| 263 | |
| 264 | let max_w = ui.available_width().min(opts.max_width).max(120.0); |
| 265 | let (src_w, src_h) = if state.tex_size.0 > 0 && state.tex_size.1 > 0 { |
| 266 | (state.tex_size.0 as f32, state.tex_size.1 as f32) |
| 267 | } else { |
| 268 | (16.0, 9.0) |
| 269 | }; |
| 270 | let height = (max_w * src_h / src_w).min(opts.max_height).max(72.0); |
| 271 | let size = Vec2::new(max_w, height); |
| 272 | |
| 273 | let mut action = VideoPlayerAction::None; |
| 274 | |
| 275 | let frame_resp = egui::Frame::new() |
| 276 | .fill(Color32::from_rgb(12, 12, 14)) |
| 277 | .stroke(Stroke::new(1.0_f32, p.border_soft)) |
| 278 | .corner_radius(sp.radius_sm) |
| 279 | .show(ui, |ui| { |
| 280 | let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); |
| 281 | |
| 282 | if let Some(tex) = state.texture.as_ref() { |
| 283 | egui::Image::new((tex.id(), size)).paint_at(ui, rect); |
| 284 | } else { |
| 285 | ui.painter() |
| 286 | .rect_filled(rect, sp.radius_sm, Color32::from_rgb(18, 18, 22)); |
| 287 | } |
| 288 | |
| 289 | // Dim + play/pause affordance when not playing (or never started). |
| 290 | if !state.playing { |
| 291 | ui.painter().rect_filled( |
| 292 | rect, |
| 293 | sp.radius_sm, |
| 294 | Color32::from_rgba_unmultiplied(0, 0, 0, 48), |
| 295 | ); |
| 296 | let play_r = (height * 0.18).clamp(16.0, 28.0); |
| 297 | let center = rect.center(); |
| 298 | ui.painter() |
| 299 | .circle_filled(center, play_r, p.accent.gamma_multiply(0.92)); |
| 300 | let tri_w = play_r * 0.7; |
| 301 | let tri_h = play_r * 0.85; |
| 302 | let tip = Pos2::new(center.x + tri_w * 0.55, center.y); |
| 303 | let top = Pos2::new(center.x - tri_w * 0.45, center.y - tri_h * 0.5); |
| 304 | let bot = Pos2::new(center.x - tri_w * 0.45, center.y + tri_h * 0.5); |
| 305 | ui.painter().add(egui::Shape::convex_polygon( |
| 306 | vec![tip, bot, top], |
| 307 | Color32::from_rgb(255, 255, 255), |
| 308 | Stroke::NONE, |
| 309 | )); |
| 310 | } |
| 311 | |
| 312 | let footer = if let Some(err) = state.error.as_deref() { |
| 313 | Some(err.to_string()) |
| 314 | } else if state.playing { |
| 315 | Some("Playing…".into()) |
| 316 | } else { |
| 317 | opts.title.clone() |
| 318 | }; |
| 319 | if let Some(name) = footer { |
| 320 | let foot_h = (theme.type_scale.caption + 10.0).min(height * 0.28); |
| 321 | let foot = Rect::from_min_max( |
| 322 | Pos2::new(rect.left(), rect.bottom() - foot_h), |
| 323 | rect.right_bottom(), |
| 324 | ); |
| 325 | let r = sp.radius_sm as u8; |
| 326 | ui.painter().rect_filled( |
| 327 | foot, |
| 328 | egui::CornerRadius { |
| 329 | nw: 0, |
| 330 | ne: 0, |
| 331 | sw: r, |
| 332 | se: r, |
| 333 | }, |
| 334 | Color32::from_rgba_unmultiplied(0, 0, 0, 160), |
| 335 | ); |
| 336 | ui.painter().text( |
| 337 | Pos2::new(foot.left() + 8.0, foot.center().y), |
| 338 | Align2::LEFT_CENTER, |
| 339 | name, |
| 340 | FontId::proportional(theme.type_scale.caption), |
| 341 | Color32::from_rgb(230, 230, 235), |
| 342 | ); |
| 343 | } |
| 344 | }); |
| 345 | |
| 346 | let tip = if state.unsupported { |
| 347 | state |
| 348 | .error |
| 349 | .as_deref() |
| 350 | .unwrap_or("Video not supported — open externally") |
| 351 | } else if state.playing { |
| 352 | "Pause" |
| 353 | } else { |
| 354 | "Play" |
| 355 | }; |
| 356 | |
| 357 | // Same pattern as sleek's OG / fallback cards: interact on the frame rect |
| 358 | // so parent bubble layout cannot swallow the click. |
| 359 | let resp = ui |
| 360 | .interact( |
| 361 | frame_resp.response.rect, |
| 362 | ui.id().with("vidya_video").with(state.content_id), |
| 363 | Sense::click(), |
| 364 | ) |
| 365 | .on_hover_text(tip) |
| 366 | .on_hover_cursor(CursorIcon::PointingHand); |
| 367 | |
| 368 | if resp.clicked() { |
| 369 | if state.unsupported { |
| 370 | if opts.open_url_on_unsupported.is_some() { |
| 371 | action = VideoPlayerAction::OpenExternally; |
| 372 | } |
| 373 | } else { |
| 374 | state.toggle_play(ui.ctx()); |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | (resp, action) |
| 379 | } |