Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/media/video_renderer.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use egui::load::SizedTexture;
use futures::StreamExt;
use livekit::webrtc::native::yuv_helper;
use livekit::webrtc::prelude::*;
Expand Down Expand Up @@ -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<egui::TextureId> {
self.internal.lock().egui_texture
/// The latest frame as a sized texture, or `None` before the first frame.
pub fn texture(&self) -> Option<SizedTexture> {
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))
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/room/track_grid_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
));
Expand Down
60 changes: 51 additions & 9 deletions src/ui/video_tile.rs
Original file line number Diff line number Diff line change
@@ -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<egui::TextureId>,
texture: Option<SizedTexture>,
identity: &'a str,
speaking: bool,
}

impl<'a> VideoTile<'a> {
pub fn new(texture: Option<egui::TextureId>, identity: &'a str, speaking: bool) -> Self {
pub fn new(texture: Option<SizedTexture>, identity: &'a str, speaking: bool) -> Self {
Self {
texture,
identity,
Expand All @@ -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,
);
Expand All @@ -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);
}
}
Loading