nandi/jolt-nativepublic Fork 0
main
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 · 382 lines · 11.7 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::{
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago12 Align2, Color32, CursorIcon, FontId, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, Vec2,
Bring vidya in cfd3e36 nandi 19d ago13};
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago14// Only the texture upload uses these, and that is behind the same feature.
15#[cfg(feature = "video")]
16use egui::{ColorImage, TextureOptions};
Bring vidya in cfd3e36 nandi 19d ago17
18use crate::Theme;
19
20#[cfg(feature = "video")]
21mod avcc;
22#[cfg(feature = "video")]
23mod decode;
24
25/// Options for [`video_player`].
26#[derive(Debug, Clone)]
27pub 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
38impl 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)]
51pub 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)]
60pub 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
75impl 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
87impl 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 ago169 #[cfg(feature = "video")]
Bring vidya in cfd3e36 nandi 19d ago170 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.
255pub 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}