feat(avatars): 6 selectable preset avatars over presence — W4 Phase 2
Adds bundled preset avatars on top of the monogram foundation. - New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded; 6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/ — swap for real designs later). +3 unit tests. - `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`); `AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join + new SetAvatar command) → every self-state announce, so it reaches the room incl. late joiners, and changing it mid-call re-announces live. - Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted. - Rendering: `avatar_view` draws the chosen preset image (iced `image` feature, now enabled) else the monogram, in the self card, peer rows, and chat (chat looks up the sender's avatar from presence by id). ⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the signature is recomputed by re-serializing the parsed struct, so a new PeerState field means old and new builds can't verify each other's presence — ALL peers must run a build >= this one (same as the S2 change). Redeploy dopedart before 2-machine testing. Build + clippy clean, 222 lib + integration tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+673
-20
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,7 @@ async-trait = "0.1.89"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
iced = { version = "0.14.0", features = ["canvas"] }
|
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||||
iroh = "1.0.0-rc.0"
|
iroh = "1.0.0-rc.0"
|
||||||
iroh-gossip = "0.99.0"
|
iroh-gossip = "0.99.0"
|
||||||
opus = "0.3.1"
|
opus = "0.3.1"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
+98
-3
@@ -163,6 +163,8 @@ pub enum AppMessage {
|
|||||||
SelectRoomLayout(RoomLayout),
|
SelectRoomLayout(RoomLayout),
|
||||||
/// Choose a UI theme (applied live + persisted).
|
/// Choose a UI theme (applied live + persisted).
|
||||||
SelectTheme(AppTheme),
|
SelectTheme(AppTheme),
|
||||||
|
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
|
||||||
|
SelectAvatar(crate::avatar::Avatar),
|
||||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||||
ToggleDrawerChat,
|
ToggleDrawerChat,
|
||||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||||
@@ -465,6 +467,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
input_device,
|
input_device,
|
||||||
output_device,
|
output_device,
|
||||||
echo_cancellation: state.config.echo_cancellation_enabled,
|
echo_cancellation: state.config.echo_cancellation_enabled,
|
||||||
|
avatar: state.config.avatar.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,6 +486,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
input_device,
|
input_device,
|
||||||
output_device,
|
output_device,
|
||||||
echo_cancellation: state.config.echo_cancellation_enabled,
|
echo_cancellation: state.config.echo_cancellation_enabled,
|
||||||
|
avatar: state.config.avatar.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
AppMessage::LeavePressed => {
|
AppMessage::LeavePressed => {
|
||||||
@@ -780,6 +784,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.config.theme = theme;
|
state.config.theme = theme;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
}
|
}
|
||||||
|
AppMessage::SelectAvatar(avatar) => {
|
||||||
|
state.config.avatar = avatar.clone();
|
||||||
|
state.config.save();
|
||||||
|
// Re-announce to the room if we're in a call (no-op otherwise).
|
||||||
|
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
||||||
|
}
|
||||||
AppMessage::ToggleDrawerChat => {
|
AppMessage::ToggleDrawerChat => {
|
||||||
state.drawer_chat_open = !state.drawer_chat_open;
|
state.drawer_chat_open = !state.drawer_chat_open;
|
||||||
}
|
}
|
||||||
@@ -1216,6 +1226,51 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(iced::Length::Fill);
|
.width(iced::Length::Fill);
|
||||||
|
|
||||||
|
// Inline avatar chooser (W4): the monogram fallback plus the bundled
|
||||||
|
// presets, each a clickable tile. Same SelectAvatar message, applied live
|
||||||
|
// + persisted (and re-announced to the room).
|
||||||
|
let avatar_choice = |a: crate::avatar::Avatar, label: String| -> Element<'_, AppMessage> {
|
||||||
|
let selected = state.config.avatar == a;
|
||||||
|
let preview = avatar_view(&a, &state.name, &state.self_id, 52.0);
|
||||||
|
column![
|
||||||
|
button(
|
||||||
|
container(preview)
|
||||||
|
.center_x(iced::Length::Fixed(60.0))
|
||||||
|
.center_y(iced::Length::Fixed(60.0))
|
||||||
|
)
|
||||||
|
.on_press(AppMessage::SelectAvatar(a))
|
||||||
|
.padding(2)
|
||||||
|
.style(b_style(
|
||||||
|
if selected { color_surface } else { Color::TRANSPARENT },
|
||||||
|
color_surface,
|
||||||
|
color_text,
|
||||||
|
8.0,
|
||||||
|
)),
|
||||||
|
text(label)
|
||||||
|
.size(11)
|
||||||
|
.color(if selected { color_blue } else { color_subtext }),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
let mut avatar_tiles: Vec<Element<'_, AppMessage>> =
|
||||||
|
vec![avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())];
|
||||||
|
for i in 0..crate::avatar::PRESET_COUNT {
|
||||||
|
avatar_tiles.push(avatar_choice(
|
||||||
|
crate::avatar::Avatar::Preset(i),
|
||||||
|
format!("Preset {}", i + 1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let avatar_section = column![
|
||||||
|
iced::widget::Row::with_children(avatar_tiles).spacing(12),
|
||||||
|
text("Shown next to your name in the room and chat. Applies live.")
|
||||||
|
.size(11)
|
||||||
|
.color(color_subtext),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.width(iced::Length::Fill);
|
||||||
|
|
||||||
// Reusable category header: a coloured title with a thin full-width
|
// Reusable category header: a coloured title with a thin full-width
|
||||||
// divider beneath, so each class of settings reads as its own section.
|
// divider beneath, so each class of settings reads as its own section.
|
||||||
let section_header = |title: &'static str| -> Element<'_, AppMessage> {
|
let section_header = |title: &'static str| -> Element<'_, AppMessage> {
|
||||||
@@ -1335,6 +1390,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
theme_section,
|
theme_section,
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
|
|
||||||
|
// --- Avatar ---
|
||||||
|
section_header("Avatar"),
|
||||||
|
avatar_section,
|
||||||
|
vertical_space(section_gap),
|
||||||
|
|
||||||
// --- Notifications & Sounds ---
|
// --- Notifications & Sounds ---
|
||||||
section_header("Notifications & Sounds"),
|
section_header("Notifications & Sounds"),
|
||||||
column![
|
column![
|
||||||
@@ -1602,7 +1662,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let self_card = container(
|
let self_card = container(
|
||||||
column![
|
column![
|
||||||
row![
|
row![
|
||||||
avatar_badge(&state.name, &state.self_id, 34.0),
|
avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0),
|
||||||
text(format!("{} (You)", &state.name)).size(16).color(color_text),
|
text(format!("{} (You)", &state.name)).size(16).color(color_text),
|
||||||
horizontal_space(),
|
horizontal_space(),
|
||||||
if state.is_muted {
|
if state.is_muted {
|
||||||
@@ -1729,7 +1789,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
let mut card_content = column![
|
let mut card_content = column![
|
||||||
row![
|
row![
|
||||||
avatar_badge(&peer.name, &peer_id.to_string(), 38.0),
|
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
||||||
column![
|
column![
|
||||||
text(&peer.name).size(16).color(color_text),
|
text(&peer.name).size(16).color(color_text),
|
||||||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
||||||
@@ -1940,11 +2000,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.width(iced::Length::Fill);
|
.width(iced::Length::Fill);
|
||||||
// Small avatar keyed on the sender's id (falls back to name); the
|
// Small avatar keyed on the sender's id (falls back to name); the
|
||||||
// " (You)" suffix on our own echoes is stripped for clean initials.
|
// " (You)" suffix on our own echoes is stripped for clean initials.
|
||||||
|
// Resolve the sender's chosen avatar: our own from config, a peer's
|
||||||
|
// from their current presence (looked up by id), else monogram.
|
||||||
let av_key = m.from.as_deref().unwrap_or(m.name.as_str());
|
let av_key = m.from.as_deref().unwrap_or(m.name.as_str());
|
||||||
let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str());
|
let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str());
|
||||||
|
let av = if m.mine {
|
||||||
|
state.config.avatar.clone()
|
||||||
|
} else {
|
||||||
|
m.from
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|f| {
|
||||||
|
state
|
||||||
|
.peers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.to_string() == f)
|
||||||
|
.map(|(_, v)| v.avatar.clone())
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
chat_col = chat_col.push(
|
chat_col = chat_col.push(
|
||||||
row![
|
row![
|
||||||
avatar_badge(av_name, av_key, 22.0),
|
avatar_view(&av, av_name, av_key, 22.0),
|
||||||
text(format!("{}:", m.name)).size(12).color(name_color),
|
text(format!("{}:", m.name)).size(12).color(name_color),
|
||||||
body,
|
body,
|
||||||
]
|
]
|
||||||
@@ -2781,6 +2857,25 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage>
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a participant's avatar (W4): the chosen preset image if any, else the
|
||||||
|
/// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter.
|
||||||
|
fn avatar_view<'a>(
|
||||||
|
avatar: &crate::avatar::Avatar,
|
||||||
|
name: &str,
|
||||||
|
key: &str,
|
||||||
|
size: f32,
|
||||||
|
) -> Element<'a, AppMessage> {
|
||||||
|
match avatar.preset_png() {
|
||||||
|
Some(png) => iced::widget::image(
|
||||||
|
iced::widget::image::Handle::from_bytes(bytes::Bytes::from_static(png)),
|
||||||
|
)
|
||||||
|
.width(iced::Length::Fixed(size))
|
||||||
|
.height(iced::Length::Fixed(size))
|
||||||
|
.into(),
|
||||||
|
None => avatar_badge(name, key, size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Centered "icon + label" content for a full-width control-panel button. The
|
/// Centered "icon + label" content for a full-width control-panel button. The
|
||||||
/// icon takes the button's foreground colour so it matches the label.
|
/// icon takes the button's foreground colour so it matches the label.
|
||||||
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {
|
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {
|
||||||
|
|||||||
+68
-1
@@ -1,4 +1,5 @@
|
|||||||
//! Avatar helpers: deterministic monogram + colour for a participant.
|
//! Avatar helpers: a participant's chosen [`Avatar`] plus the deterministic
|
||||||
|
//! monogram + colour used as the fallback.
|
||||||
//!
|
//!
|
||||||
//! Every participant gets a visual avatar (W4). When they haven't chosen a preset
|
//! Every participant gets a visual avatar (W4). When they haven't chosen a preset
|
||||||
//! or uploaded an image, we fall back to a *monogram*: their initial(s) on a
|
//! or uploaded an image, we fall back to a *monogram*: their initial(s) on a
|
||||||
@@ -6,6 +7,46 @@
|
|||||||
//! name where the id isn't available, e.g. a chat line). Pure + dependency-free
|
//! name where the id isn't available, e.g. a chat line). Pure + dependency-free
|
||||||
//! so it's unit-testable and needs no image decoding for the common case.
|
//! so it's unit-testable and needs no image decoding for the common case.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Number of bundled preset avatars (`preset1.png`..`preset6.png`).
|
||||||
|
pub const PRESET_COUNT: u8 = 6;
|
||||||
|
|
||||||
|
/// The raw PNG bytes of each bundled preset, embedded so they're available
|
||||||
|
/// offline and identical on every peer. Index 0 = preset 1, etc.
|
||||||
|
pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [
|
||||||
|
include_bytes!("../assets/avatars/preset1.png"),
|
||||||
|
include_bytes!("../assets/avatars/preset2.png"),
|
||||||
|
include_bytes!("../assets/avatars/preset3.png"),
|
||||||
|
include_bytes!("../assets/avatars/preset4.png"),
|
||||||
|
include_bytes!("../assets/avatars/preset5.png"),
|
||||||
|
include_bytes!("../assets/avatars/preset6.png"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`)
|
||||||
|
/// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered
|
||||||
|
/// from name + a colour key, no image). `Preset(i)` selects a bundled preset by
|
||||||
|
/// zero-based index. (Custom uploaded images are W4 Phase 3 — a future variant.)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum Avatar {
|
||||||
|
/// Initials-on-colour fallback.
|
||||||
|
#[default]
|
||||||
|
Monogram,
|
||||||
|
/// A bundled preset by zero-based index; out-of-range falls back to monogram.
|
||||||
|
Preset(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Avatar {
|
||||||
|
/// The embedded PNG bytes for this avatar, or `None` when it's a monogram
|
||||||
|
/// (or an out-of-range preset index, which renders as a monogram).
|
||||||
|
pub fn preset_png(&self) -> Option<&'static [u8]> {
|
||||||
|
match self {
|
||||||
|
Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
|
||||||
|
Avatar::Monogram => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Curated palette the monogram background is chosen from. Picked to be distinct
|
/// Curated palette the monogram background is chosen from. Picked to be distinct
|
||||||
/// and legible under the app's (mostly dark) themes; the matching text colour is
|
/// and legible under the app's (mostly dark) themes; the matching text colour is
|
||||||
/// decided per-background by [`use_dark_text_on`]. Order is part of the stable
|
/// decided per-background by [`use_dark_text_on`]. Order is part of the stable
|
||||||
@@ -92,6 +133,32 @@ mod tests {
|
|||||||
let _ = color_for_key("");
|
let _ = color_for_key("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avatar_default_is_monogram() {
|
||||||
|
assert_eq!(Avatar::default(), Avatar::Monogram);
|
||||||
|
assert!(Avatar::default().preset_png().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_png_in_range_and_out_of_range() {
|
||||||
|
// Every declared preset index resolves to embedded bytes.
|
||||||
|
for i in 0..PRESET_COUNT {
|
||||||
|
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing");
|
||||||
|
}
|
||||||
|
// Out-of-range index gracefully yields None (→ monogram fallback).
|
||||||
|
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
||||||
|
assert!(Avatar::Preset(250).preset_png().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avatar_serde_round_trips_and_defaults() {
|
||||||
|
// Round-trips through JSON (it rides presence + persists in config).
|
||||||
|
for a in [Avatar::Monogram, Avatar::Preset(0), Avatar::Preset(5)] {
|
||||||
|
let s = serde_json::to_string(&a).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<Avatar>(&s).unwrap(), a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dark_text_chosen_on_light_backgrounds() {
|
fn dark_text_chosen_on_light_backgrounds() {
|
||||||
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
|
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_muted: false,
|
is_muted: false,
|
||||||
addr: endpoint_a.addr(),
|
addr: endpoint_a.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
|
avatar: Default::default(),
|
||||||
};
|
};
|
||||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||||
println!("Node A joined topic.");
|
println!("Node A joined topic.");
|
||||||
@@ -80,6 +81,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_muted: false,
|
is_muted: false,
|
||||||
addr: endpoint_b.addr(),
|
addr: endpoint_b.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
|
avatar: Default::default(),
|
||||||
};
|
};
|
||||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||||
println!("Node B joined topic.");
|
println!("Node B joined topic.");
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ pub struct AppConfig {
|
|||||||
/// Chosen UI colour theme.
|
/// Chosen UI colour theme.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub theme: AppTheme,
|
pub theme: AppTheme,
|
||||||
|
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatar: crate::avatar::Avatar,
|
||||||
/// What a call recording captures (mixed / per-peer stems / both).
|
/// What a call recording captures (mixed / per-peer stems / both).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recording_mode: RecordingMode,
|
pub recording_mode: RecordingMode,
|
||||||
@@ -233,6 +236,7 @@ impl Default for AppConfig {
|
|||||||
chat_drawer_width: default_chat_drawer_width(),
|
chat_drawer_width: default_chat_drawer_width(),
|
||||||
room_layout: RoomLayout::default(),
|
room_layout: RoomLayout::default(),
|
||||||
theme: AppTheme::default(),
|
theme: AppTheme::default(),
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
recording_mode: RecordingMode::default(),
|
recording_mode: RecordingMode::default(),
|
||||||
custom_sound_self_join: None,
|
custom_sound_self_join: None,
|
||||||
custom_sound_peer_join: None,
|
custom_sound_peer_join: None,
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ use iroh::EndpointId;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum CoreCommand {
|
pub enum CoreCommand {
|
||||||
Join { name: String, ticket: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool },
|
Join { name: String, ticket: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
||||||
Leave,
|
Leave,
|
||||||
ToggleMute,
|
ToggleMute,
|
||||||
|
/// Change our avatar (W4) and re-announce it to the room over presence.
|
||||||
|
SetAvatar(crate::avatar::Avatar),
|
||||||
ToggleDeafen,
|
ToggleDeafen,
|
||||||
SetPttMode(bool),
|
SetPttMode(bool),
|
||||||
SetPttActive(bool),
|
SetPttActive(bool),
|
||||||
|
|||||||
+25
-1
@@ -454,6 +454,9 @@ async fn run_core_loop(
|
|||||||
// Peers locally muted by us: decoded for level metering but not mixed.
|
// Peers locally muted by us: decoded for level metering but not mixed.
|
||||||
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
||||||
let mut current_name = "Anonymous".to_string();
|
let mut current_name = "Anonymous".to_string();
|
||||||
|
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included
|
||||||
|
// in every self-state we announce over presence.
|
||||||
|
let mut current_avatar = crate::avatar::Avatar::default();
|
||||||
let mut network_mode = NetworkMode::default();
|
let mut network_mode = NetworkMode::default();
|
||||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
// Pixelpass binary override (config), and the ticket of our own active screen
|
||||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
// share (rides our presence so the room — incl. late joiners — can watch).
|
||||||
@@ -466,8 +469,9 @@ async fn run_core_loop(
|
|||||||
|
|
||||||
while let Some(cmd) = cmd_rx.recv().await {
|
while let Some(cmd) = cmd_rx.recv().await {
|
||||||
match cmd {
|
match cmd {
|
||||||
CoreCommand::Join { name, ticket, input_device, output_device, echo_cancellation } => {
|
CoreCommand::Join { name, ticket, input_device, output_device, echo_cancellation, avatar } => {
|
||||||
current_name = name.clone();
|
current_name = name.clone();
|
||||||
|
current_avatar = avatar;
|
||||||
|
|
||||||
// Finalize any recording before tearing down the old session — its
|
// Finalize any recording before tearing down the old session — its
|
||||||
// capture/mixer feeders are about to stop.
|
// capture/mixer feeders are about to stop.
|
||||||
@@ -561,6 +565,7 @@ async fn run_core_loop(
|
|||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
is_muted: is_muted.load(Ordering::Relaxed),
|
||||||
addr: endpoint.addr(),
|
addr: endpoint.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
|
avatar: current_avatar.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retain peers across leave so a rejoin can dial them (A8). A
|
// Retain peers across leave so a rejoin can dial them (A8). A
|
||||||
@@ -1066,6 +1071,23 @@ async fn run_core_loop(
|
|||||||
is_muted: new_state,
|
is_muted: new_state,
|
||||||
addr: session.endpoint.addr(),
|
addr: session.endpoint.addr(),
|
||||||
sharing: current_sharing.clone(),
|
sharing: current_sharing.clone(),
|
||||||
|
avatar: current_avatar.clone(),
|
||||||
|
};
|
||||||
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreCommand::SetAvatar(avatar) => {
|
||||||
|
current_avatar = avatar;
|
||||||
|
// Re-announce presence so the room (incl. late joiners, via the
|
||||||
|
// retained presence) picks up the new avatar (W4).
|
||||||
|
if let Some(session) = &active_session {
|
||||||
|
let self_state = PeerState {
|
||||||
|
name: current_name.clone(),
|
||||||
|
is_muted: is_muted.load(Ordering::Relaxed),
|
||||||
|
addr: session.endpoint.addr(),
|
||||||
|
sharing: current_sharing.clone(),
|
||||||
|
avatar: current_avatar.clone(),
|
||||||
};
|
};
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
@@ -1274,6 +1296,7 @@ async fn run_core_loop(
|
|||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
is_muted: is_muted.load(Ordering::Relaxed),
|
||||||
addr: session.endpoint.addr(),
|
addr: session.endpoint.addr(),
|
||||||
sharing: Some(ticket),
|
sharing: Some(ticket),
|
||||||
|
avatar: current_avatar.clone(),
|
||||||
};
|
};
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
||||||
@@ -1298,6 +1321,7 @@ async fn run_core_loop(
|
|||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
is_muted: is_muted.load(Ordering::Relaxed),
|
||||||
addr: session.endpoint.addr(),
|
addr: session.endpoint.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
|
avatar: current_avatar.clone(),
|
||||||
};
|
};
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ mod tests {
|
|||||||
is_muted: true,
|
is_muted: true,
|
||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ pub struct PeerState {
|
|||||||
/// older configs / peers that predate the field still deserialize.
|
/// older configs / peers that predate the field still deserialize.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sharing: Option<String>,
|
pub sharing: Option<String>,
|
||||||
|
/// This peer's chosen avatar (W4). Rides presence so it reaches everyone
|
||||||
|
/// (incl. late joiners) with no server, like `sharing`. Defaulted so older
|
||||||
|
/// peers/configs that predate the field still deserialize (→ monogram).
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatar: crate::avatar::Avatar,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -180,6 +185,7 @@ mod tests {
|
|||||||
is_muted: false,
|
is_muted: false,
|
||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user