nandi/jolt-nativepublic Fork 0
dce285fb5a5ec1f331b8afa7b2bdc4ed5e1bbd46
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

video.rs · 379 lines · 11.6 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! 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
7use std::hash::{Hash, Hasher};
8use std::sync::Arc;
9use std::time::Instant;
10
11use egui::{
12 Align2, Color32, ColorImage, CursorIcon, FontId, Pos2, Rect, Sense, Stroke, TextureHandle,
13 TextureOptions, Ui, Vec2,
14};
15
16use crate::Theme;
17
18#[cfg(feature = "video")]
19mod avcc;
20#[cfg(feature = "video")]
21mod decode;
22
23/// Options for [`video_player`].
24#[derive(Debug, Clone)]
25pub 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
36impl 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)]
49pub 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)]
58pub 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
73impl 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
85impl 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.
252pub 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}