Skip to content
Open
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
1 change: 1 addition & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ words:
- texel
- theomonnom
- tsid
- unmultiplied
- unorm
- videotexture
- viewports
Expand Down
3 changes: 3 additions & 0 deletions resources/PlaceholderTileSquare.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 0 additions & 6 deletions src/media/video_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<egui::TextureId> {
self.internal.lock().egui_texture
Expand Down
77 changes: 53 additions & 24 deletions src/room/participants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand All @@ -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
Expand All @@ -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),
});
}
});
})
Expand All @@ -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!(
Expand All @@ -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<'_> {
Expand Down Expand Up @@ -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,
});
}
Expand All @@ -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 });
}
});
})
Expand Down
2 changes: 1 addition & 1 deletion src/room/status_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
96 changes: 64 additions & 32 deletions src/room/track_grid_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::<Vec<ParticipantIdentity>>();
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::<Vec<TrackSid>>();
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 {
Expand Down
1 change: 1 addition & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions src/ui/placeholder_tile.rs
Original file line number Diff line number Diff line change
@@ -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::<egui::TextureHandle>(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
}
}
Loading
Loading