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