diff --git a/src/media/video_renderer.rs b/src/media/video_renderer.rs index 07e0b72..092eb94 100644 --- a/src/media/video_renderer.rs +++ b/src/media/video_renderer.rs @@ -1,3 +1,4 @@ +use egui::load::SizedTexture; use futures::StreamExt; use livekit::webrtc::native::yuv_helper; use livekit::webrtc::prelude::*; @@ -102,9 +103,12 @@ impl VideoRenderer { } } - // Returns the texture id, can be used to draw the texture on the UI - pub fn texture_id(&self) -> Option { - self.internal.lock().egui_texture + /// The latest frame as a sized texture, or `None` before the first frame. + pub fn texture(&self) -> Option { + let internal = self.internal.lock(); + let id = internal.egui_texture?; + let size = egui::vec2(internal.width as f32, internal.height as f32); + Some(SizedTexture::new(id, size)) } } diff --git a/src/room/track_grid_view.rs b/src/room/track_grid_view.rs index 0b8cc92..35f5e90 100644 --- a/src/room/track_grid_view.rs +++ b/src/room/track_grid_view.rs @@ -76,12 +76,12 @@ impl egui::Widget for TrackGridView<'_> { else { continue; }; - if renderer.texture_id().is_none() { + let Some(texture) = renderer.texture() else { continue; // no frame decoded yet - } + }; ui.track_frame(|ui| { ui.add(VideoTile::new( - renderer.texture_id(), + Some(texture), identity.as_str(), speaking, )); diff --git a/src/ui/video_tile.rs b/src/ui/video_tile.rs index 8accc6b..b7f4ec1 100644 --- a/src/ui/video_tile.rs +++ b/src/ui/video_tile.rs @@ -1,15 +1,18 @@ -/// Widget: paints a video frame to fill the available space, with a speaking -/// border and a participant-identity overlay. Decoupled from any video source — -/// the caller passes the already-resolved texture id, so this stays a pure UI -/// primitive. +use egui::load::SizedTexture; + +/// Widget: paints a video frame into the available space, with a speaking +/// border and a participant-identity overlay. The frame keeps its aspect ratio: +/// it is scaled to fit and centered, and the rest of the cell shows the tile +/// background. Decoupled from any video source — the caller passes the +/// already-resolved texture, so this stays a pure UI primitive. pub struct VideoTile<'a> { - texture: Option, + texture: Option, identity: &'a str, speaking: bool, } impl<'a> VideoTile<'a> { - pub fn new(texture: Option, identity: &'a str, speaking: bool) -> Self { + pub fn new(texture: Option, identity: &'a str, speaking: bool) -> Self { Self { texture, identity, @@ -33,10 +36,16 @@ impl egui::Widget for VideoTile<'_> { ); } - if let Some(tex) = self.texture { + ui.painter().rect_filled( + inner_rect, + egui::CornerRadius::default(), + ui.style().visuals.code_bg_color, + ); + + if let Some(texture) = self.texture { ui.painter().image( - tex, - inner_rect, + texture.id, + fit_rect(inner_rect, texture.size), egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); @@ -53,3 +62,36 @@ impl egui::Widget for VideoTile<'_> { response } } + +/// The largest rect with `frame`'s aspect ratio that fits inside `cell`, centered. +fn fit_rect(cell: egui::Rect, frame: egui::Vec2) -> egui::Rect { + let scale = (cell.width() / frame.x).min(cell.height() / frame.y); + egui::Rect::from_center_size(cell.center(), frame * scale) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CELL: egui::Rect = + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(320.0, 180.0)); + + #[test] + fn portrait_frame_fits_height() { + let rect = fit_rect(CELL, egui::vec2(720.0, 1280.0)); + assert_eq!(rect.size(), egui::vec2(101.25, 180.0)); + assert_eq!(rect.center(), CELL.center()); + } + + #[test] + fn wide_frame_fits_width() { + let rect = fit_rect(CELL, egui::vec2(640.0, 200.0)); + assert_eq!(rect.size(), egui::vec2(320.0, 100.0)); + assert_eq!(rect.center(), CELL.center()); + } + + #[test] + fn matching_frame_fills_cell() { + assert_eq!(fit_rect(CELL, egui::vec2(1280.0, 720.0)), CELL); + } +}