diff --git a/cspell.yml b/cspell.yml index dc39d36..2d3080a 100644 --- a/cspell.yml +++ b/cspell.yml @@ -32,6 +32,7 @@ words: - texel - theomonnom - tsid + - unmultiplied - unorm - videotexture - viewports diff --git a/resources/PlaceholderTileSquare.png b/resources/PlaceholderTileSquare.png new file mode 100644 index 0000000..7590cfc --- /dev/null +++ b/resources/PlaceholderTileSquare.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d70fce6b4151c8f9cdd951ac425d5847b94d39a7acb2b436c14c9ca81084a1e +size 44672 diff --git a/src/media/video_renderer.rs b/src/media/video_renderer.rs index d25047c..07e0b72 100644 --- a/src/media/video_renderer.rs +++ b/src/media/video_renderer.rs @@ -102,12 +102,6 @@ impl VideoRenderer { } } - // Returns the last frame resolution - pub fn resolution(&self) -> (u32, u32) { - let internal = self.internal.lock(); - (internal.width, internal.height) - } - // 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 diff --git a/src/room/participants.rs b/src/room/participants.rs index 309df38..9680caf 100644 --- a/src/room/participants.rs +++ b/src/room/participants.rs @@ -3,7 +3,8 @@ use crate::service::AsyncCmd; use crate::ui::status_badge::StatusBadge; use livekit::{e2ee::EncryptionType, prelude::*, track::VideoQuality}; -/// Scrollable list of remote participants and their track publications. +/// Scrollable list of the local and remote participants and their track +/// publications. pub struct ParticipantsPanel<'a> { pub ctx: &'a RoomContext<'a>, } @@ -20,6 +21,13 @@ impl egui::Widget for ParticipantsPanel<'_> { egui::ScrollArea::vertical() .id_salt(ctx.id.with("participants_scroll")) .show(ui, |ui| { + // Local participant first, so "you" are always visible and + // stable at the top. + ui.add(ParticipantCard { + ctx, + participant: Participant::Local(room.local_participant()), + }); + // Iterate with sorted keys to avoid flickers (immediate-mode UI). let participants = room.remote_participants(); let mut sorted_participants = participants @@ -29,8 +37,11 @@ impl egui::Widget for ParticipantsPanel<'_> { sorted_participants.sort_by(|a, b| a.as_str().cmp(b.as_str())); for psid in sorted_participants { - let participant = participants.get(&psid).unwrap(); - ui.add(ParticipantCard { ctx, participant }); + let participant = participants.get(&psid).unwrap().clone(); + ui.add(ParticipantCard { + ctx, + participant: Participant::Remote(participant), + }); } }); }) @@ -42,15 +53,21 @@ impl egui::Widget for ParticipantsPanel<'_> { /// track publication in the body. struct ParticipantCard<'a> { ctx: &'a RoomContext<'a>, - participant: &'a RemoteParticipant, + participant: Participant, } impl egui::Widget for ParticipantCard<'_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { let ParticipantCard { ctx, participant } = self; + let is_local = matches!(participant, Participant::Local(_)); let identity = participant.identity().0; - egui::CollapsingHeader::new(identity.as_str()) - .id_salt(ctx.id.with(("participant", identity.as_str()))) + let title = if is_local { + format!("{} (You)", identity.as_str()) + } else { + identity.as_str().to_owned() + }; + egui::CollapsingHeader::new(title) + .id_salt(ctx.id.with(("participant", is_local, identity.as_str()))) .default_open(true) .show(ui, |ui| { ui.weak(format!( @@ -72,11 +89,12 @@ impl egui::Widget for ParticipantCard<'_> { } } -/// One track publication: encryption, name/source, simulcast + quality menu, -/// and muted/subscribed status with subscribe/unsubscribe controls. +/// One track publication: encryption, name/source, simulcast, and muted status. +/// The quality menu and subscribe/unsubscribe controls apply only to remote +/// tracks, so they are hidden for the local participant's own publications. struct TrackPublicationRow<'a> { ctx: &'a RoomContext<'a>, - publication: RemoteTrackPublication, + publication: TrackPublication, } impl egui::Widget for TrackPublicationRow<'_> { @@ -104,22 +122,25 @@ impl egui::Widget for TrackPublicationRow<'_> { ui.label("Simulcasted - "); let is_simulcasted = publication.simulcasted(); ui.label(if is_simulcasted { "Yes" } else { "No" }); - if is_simulcasted { + // The receiving-side quality selector only applies to a remote + // subscription. + if let TrackPublication::Remote(remote) = &publication + && is_simulcasted + { ui.menu_button("Set Quality", |ui| { - let publication = publication.clone(); if ui.button("Low").clicked() { let _ = ctx.service.send(AsyncCmd::SetVideoQuality { - publication, + publication: remote.clone(), quality: VideoQuality::Low, }); } else if ui.button("Medium").clicked() { let _ = ctx.service.send(AsyncCmd::SetVideoQuality { - publication, + publication: remote.clone(), quality: VideoQuality::Medium, }); } else if ui.button("High").clicked() { let _ = ctx.service.send(AsyncCmd::SetVideoQuality { - publication, + publication: remote.clone(), quality: VideoQuality::High, }); } @@ -132,18 +153,26 @@ impl egui::Widget for TrackPublicationRow<'_> { ui.add(StatusBadge::muted("Muted")); } - if publication.is_subscribed() { - ui.add(StatusBadge::ok("Subscribed")); - } else { - ui.add(StatusBadge::error("Unsubscribed")); - } + // Subscription is a remote-only concept; you always "have" your + // own local tracks. + if let TrackPublication::Remote(remote) = &publication { + if remote.is_subscribed() { + ui.add(StatusBadge::ok("Subscribed")); + } else { + ui.add(StatusBadge::error("Unsubscribed")); + } - if publication.is_subscribed() { - if ui.button("Unsubscribe").clicked() { - let _ = ctx.service.send(AsyncCmd::UnsubscribeTrack { publication }); + if remote.is_subscribed() { + if ui.button("Unsubscribe").clicked() { + let _ = ctx.service.send(AsyncCmd::UnsubscribeTrack { + publication: remote.clone(), + }); + } + } else if ui.button("Subscribe").clicked() { + let _ = ctx.service.send(AsyncCmd::SubscribeTrack { + publication: remote.clone(), + }); } - } else if ui.button("Subscribe").clicked() { - let _ = ctx.service.send(AsyncCmd::SubscribeTrack { publication }); } }); }) diff --git a/src/room/status_bar.rs b/src/room/status_bar.rs index 3a5304b..ec4effb 100644 --- a/src/room/status_bar.rs +++ b/src/room/status_bar.rs @@ -46,7 +46,7 @@ impl egui::Widget for StatusBar<'_> { ui.label(format!( "Connected to '{}' as '{}'", room.name(), - room.local_participant().name() + room.local_participant().identity() )); } else { ui.label("Disconnected"); diff --git a/src/room/track_grid_view.rs b/src/room/track_grid_view.rs index 3f742f8..95dcfeb 100644 --- a/src/room/track_grid_view.rs +++ b/src/room/track_grid_view.rs @@ -3,6 +3,7 @@ use crate::room::RoomContext; use crate::room::data_track::{ LocalDataTrackTile, LocalDataTrackWidget, RemoteDataTrackTile, RemoteDataTrackWidget, }; +use crate::ui::placeholder_tile::{PlaceholderTile, placeholder_texture}; use crate::ui::{track_grid::TrackGrid, video_tile::VideoTile}; use livekit::prelude::*; use std::collections::HashMap; @@ -23,49 +24,80 @@ impl egui::Widget for TrackGridView<'_> { remote_data_tracks, } = self; - let connected = ctx.room.is_some(); - let has_tiles = !video_renderers.is_empty() - || !local_data_tracks.is_empty() - || !remote_data_tracks.is_empty(); - ui.scope(|ui| { - if connected && !has_tiles { - ui.centered_and_justified(|ui| { - ui.label("No tracks subscribed"); - }); - return; - } - egui::ScrollArea::vertical() .id_salt(ctx.id.with("central_scroll")) .show(ui, |ui| { + // `TrackGrid::show` hands the closure a `TrackGridContext`, not + // an `egui::Ui`, so capture the egui context here (cheap Arc + // clone) to resolve the placeholder texture lazily below. + let egui_ctx = ui.ctx().clone(); TrackGrid::new(ctx.id.with("default_grid")) .max_columns(6) .show(ui, |ui| { if let Some(room) = ctx.room { - for ((participant_id, _), video_renderer) in video_renderers { - ui.track_frame(|ui| { - if let Some(p) = - room.remote_participants().get(participant_id) - { - let name = p.name(); + let placeholder = placeholder_texture(&egui_ctx); + + // Local participant first, then remotes sorted by + // identity for a stable order in immediate mode. + let mut participants = + vec![Participant::Local(room.local_participant())]; + let remotes = room.remote_participants(); + let mut ids = remotes + .keys() + .cloned() + .collect::>(); + ids.sort_by(|a, b| a.as_str().cmp(b.as_str())); + participants.extend( + ids.into_iter() + .map(|id| Participant::Remote(remotes[&id].clone())), + ); + + for participant in &participants { + let identity = participant.identity(); + let speaking = participant.is_speaking(); + + // Video tracks currently sending live frames: + // not muted and backed by a renderer with a frame. + let publications = participant.track_publications(); + let mut video_sids = publications + .iter() + .filter(|(_, p)| { + p.kind() == TrackKind::Video && !p.is_muted() + }) + .map(|(sid, _)| sid.clone()) + .collect::>(); + video_sids.sort_by(|a, b| a.as_str().cmp(b.as_str())); + + let mut rendered_video = false; + for sid in video_sids { + let Some(renderer) = + video_renderers.get(&(identity.clone(), sid)) + else { + continue; + }; + if renderer.texture_id().is_none() { + continue; // no frame decoded yet + } + ui.track_frame(|ui| { ui.add(VideoTile::new( - video_renderer.texture_id(), - video_renderer.resolution(), - name.as_str(), - p.is_speaking(), + renderer.texture_id(), + identity.as_str(), + speaking, )); - } else { - let lp = room.local_participant(); - let name = lp.name(); - ui.add(VideoTile::new( - video_renderer.texture_id(), - video_renderer.resolution(), - name.as_str(), - lp.is_speaking(), + }); + rendered_video = true; + } + + if !rendered_video { + ui.track_frame(|ui| { + ui.add(PlaceholderTile::new( + placeholder.id(), + identity.as_str(), + speaking, )); - } - }); + }); + } } for tile in &mut *local_data_tracks { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8415225..1d38a6d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,7 @@ pub mod data_chart; pub mod labeled_field; +pub mod placeholder_tile; pub mod prominent_button; pub mod status_badge; pub mod track_grid; diff --git a/src/ui/placeholder_tile.rs b/src/ui/placeholder_tile.rs new file mode 100644 index 0000000..559bad3 --- /dev/null +++ b/src/ui/placeholder_tile.rs @@ -0,0 +1,84 @@ +/// Cached placeholder texture, decoded once and shared across frames and windows +/// via egui temp-data (all viewports share one `Context`). Returns a handle whose +/// `.id()` feeds [`PlaceholderTile`]. +pub fn placeholder_texture(ctx: &egui::Context) -> egui::TextureHandle { + let id = egui::Id::new("placeholder_tile_texture"); + if let Some(handle) = ctx.data(|d| d.get_temp::(id)) { + return handle; + } + + let image = image::load_from_memory_with_format( + include_bytes!("../../resources/PlaceholderTileSquare.png"), + image::ImageFormat::Png, + ) + .expect("placeholder image is a valid PNG") + .to_rgba8(); + let size = [image.width() as usize, image.height() as usize]; + let color = egui::ColorImage::from_rgba_unmultiplied(size, image.as_raw()); + + let handle = ctx.load_texture("placeholder_tile", color, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, handle.clone())); + handle +} + +/// Widget: stands in for a participant that isn't sending live video. Paints a +/// centered silhouette on a dark cell, with a speaking border and a +/// participant-identity overlay. Mirrors [`crate::ui::video_tile::VideoTile`]. +pub struct PlaceholderTile<'a> { + texture: egui::TextureId, + identity: &'a str, + speaking: bool, +} + +impl<'a> PlaceholderTile<'a> { + pub fn new(texture: egui::TextureId, identity: &'a str, speaking: bool) -> Self { + Self { + texture, + identity, + speaking, + } + } +} + +impl egui::Widget for PlaceholderTile<'_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover()); + let inner_rect = rect.shrink(1.0); + + if self.speaking { + ui.painter().rect( + rect, + egui::CornerRadius::default(), + egui::Color32::GREEN, + egui::Stroke::NONE, + egui::StrokeKind::Inside, + ); + } + + ui.painter().rect_filled( + inner_rect, + egui::CornerRadius::default(), + ui.style().visuals.code_bg_color, + ); + + // The silhouette is square; fit it (contain) and center it in the cell. + let side = inner_rect.width().min(inner_rect.height()); + let image_rect = egui::Rect::from_center_size(inner_rect.center(), egui::Vec2::splat(side)); + ui.painter().image( + self.texture, + image_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + + ui.painter().text( + egui::pos2(rect.min.x + 5.0, rect.max.y - 5.0), + egui::Align2::LEFT_BOTTOM, + self.identity, + egui::FontId::default(), + egui::Color32::WHITE, + ); + + response + } +} diff --git a/src/ui/video_tile.rs b/src/ui/video_tile.rs index a060060..8accc6b 100644 --- a/src/ui/video_tile.rs +++ b/src/ui/video_tile.rs @@ -1,25 +1,18 @@ /// Widget: paints a video frame to fill the available space, with a speaking -/// border and a resolution/name overlay. Decoupled from any video source — the -/// caller passes the already-resolved texture id and resolution, so this stays -/// a pure UI primitive. +/// 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. pub struct VideoTile<'a> { texture: Option, - resolution: (u32, u32), - name: &'a str, + identity: &'a str, speaking: bool, } impl<'a> VideoTile<'a> { - pub fn new( - texture: Option, - resolution: (u32, u32), - name: &'a str, - speaking: bool, - ) -> Self { + pub fn new(texture: Option, identity: &'a str, speaking: bool) -> Self { Self { texture, - resolution, - name, + identity, speaking, } } @@ -49,11 +42,10 @@ impl egui::Widget for VideoTile<'_> { ); } - let (width, height) = self.resolution; ui.painter().text( egui::pos2(rect.min.x + 5.0, rect.max.y - 5.0), egui::Align2::LEFT_BOTTOM, - format!("{}x{} {}", width, height, self.name), + self.identity, egui::FontId::default(), egui::Color32::WHITE, );