feat(game): Step 7 UI — opt-in toggle, roster Playing line, Settings
Final step of game detection. Functional, plain styling (to art-direct). - Settings 'Games' category: opt-in 'show my game' toggle (SetGamePresenceEnabled, persisted), manual override picker (Auto / None / Pin current), per-game background picker+remove (reuses process_background + hashed game_background_path), and a non-Steam process->name mapping editor (add/remove, pushes SetGameProcessMap). - Roster: each peer card shows 'Playing <game>' under their name when they broadcast one; our own self card shows it too, marked '(not shared)' when broadcasting is off. - Startup: seeds SetGamePresenceEnabled + SetGameProcessMap from config. - Updated the settings-category navigation test for the new category. 395 lib tests green, clippy --all-targets clean, binary builds, and an 8s smoke launch starts the core + detector thread with no panic (detector logs nothing by design — privacy). Feature complete on Linux end-to-end (pending a coordinated GOSSIP_PROTO 3 redeploy to field-test presence with peers). Windows FFI still needs its cross-build pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+335
-6
@@ -45,10 +45,11 @@ pub enum SettingsCategory {
|
||||
Appearance,
|
||||
Network,
|
||||
Notifications,
|
||||
Games,
|
||||
}
|
||||
|
||||
impl SettingsCategory {
|
||||
const ALL: [SettingsCategory; 7] = [
|
||||
const ALL: [SettingsCategory; 8] = [
|
||||
SettingsCategory::Audio,
|
||||
SettingsCategory::Hotkeys,
|
||||
SettingsCategory::Recording,
|
||||
@@ -56,6 +57,7 @@ impl SettingsCategory {
|
||||
SettingsCategory::Appearance,
|
||||
SettingsCategory::Network,
|
||||
SettingsCategory::Notifications,
|
||||
SettingsCategory::Games,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
@@ -67,6 +69,7 @@ impl SettingsCategory {
|
||||
SettingsCategory::Appearance => "Appearance",
|
||||
SettingsCategory::Network => "Network",
|
||||
SettingsCategory::Notifications => "Notifications",
|
||||
SettingsCategory::Games => "Games",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +82,7 @@ impl SettingsCategory {
|
||||
SettingsCategory::Appearance => "Layout and theme",
|
||||
SettingsCategory::Network => "Relay and privacy mode",
|
||||
SettingsCategory::Notifications => "Chimes and sounds",
|
||||
SettingsCategory::Games => "Detection, presence, backgrounds",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +346,47 @@ pub enum AppMessage {
|
||||
ShutdownCommandSent(bool),
|
||||
/// Fallback close if the core does not acknowledge shutdown promptly.
|
||||
ShutdownTimeout,
|
||||
// --- Game detection (W17/W18) ---
|
||||
/// Toggle broadcasting the detected game to peers (opt-in, default off).
|
||||
ToggleGamePresence(bool),
|
||||
/// Choose the manual detection override (Auto / None / the current game).
|
||||
GameOverrideSelected(GameOverrideChoice),
|
||||
/// Add-mapping form edits (executable basename → display name).
|
||||
GameMapExeChanged(String),
|
||||
GameMapNameChanged(String),
|
||||
/// Commit the add-mapping form into the process map.
|
||||
AddGameMapping,
|
||||
/// Remove a process→name mapping by its executable key.
|
||||
RemoveGameMapping(String),
|
||||
/// Open the native picker to set a per-game background for the given game id.
|
||||
PickGameBackground(String),
|
||||
/// Result of the per-game background picker: (game id, chosen bytes or None).
|
||||
GameBackgroundPicked(String, Option<Vec<u8>>),
|
||||
/// Clear a per-game background mapping by game id.
|
||||
RemoveGameBackground(String),
|
||||
}
|
||||
|
||||
/// The manual game-detection override as shown in the Settings picker. Maps to a
|
||||
/// [`crate::game::ManualOverride`] using the app's currently-detected game for the
|
||||
/// `Current` choice (so "force this game" carries the live id + name).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GameOverrideChoice {
|
||||
/// Trust auto-detection (default).
|
||||
Auto,
|
||||
/// Force "not playing" — never broadcast a game.
|
||||
None,
|
||||
/// Pin the game currently detected (only offered while something is detected).
|
||||
Current,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GameOverrideChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
GameOverrideChoice::Auto => "Auto-detect",
|
||||
GameOverrideChoice::None => "None (don't show a game)",
|
||||
GameOverrideChoice::Current => "Pin current game",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||
@@ -386,6 +431,13 @@ pub struct AppState {
|
||||
/// `None` = nothing detected. Independent of whether we broadcast it to peers
|
||||
/// (that's `config.game_presence_enabled`).
|
||||
current_game: Option<crate::game::DetectedGame>,
|
||||
/// Add-mapping form state in the Games settings: the executable basename and
|
||||
/// display name being entered for a new process→name mapping.
|
||||
game_map_exe_input: String,
|
||||
game_map_name_input: String,
|
||||
/// Current manual-override selection shown in the Games settings picker.
|
||||
/// Session-only (not persisted); defaults to Auto each launch.
|
||||
game_override: GameOverrideChoice,
|
||||
peers: HashMap<EndpointId, PeerState>,
|
||||
audio_levels: HashMap<EndpointId, f32>,
|
||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||
@@ -519,6 +571,10 @@ impl Default for AppState {
|
||||
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
||||
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
||||
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
|
||||
// Game detection (W17/W18): seed the opt-in broadcast flag + the user's
|
||||
// process→name mappings from config. The manual override starts at Auto.
|
||||
let _ = controller.send(CoreCommand::SetGamePresenceEnabled(config.game_presence_enabled));
|
||||
let _ = controller.send(CoreCommand::SetGameProcessMap(config.game_process_map.clone()));
|
||||
for (peer, settings) in &config.peer_eq {
|
||||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||||
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
|
||||
@@ -571,6 +627,9 @@ impl Default for AppState {
|
||||
config,
|
||||
background_image,
|
||||
current_game: None,
|
||||
game_map_exe_input: String::new(),
|
||||
game_map_name_input: String::new(),
|
||||
game_override: GameOverrideChoice::Auto,
|
||||
peers: HashMap::new(),
|
||||
audio_levels: HashMap::new(),
|
||||
locally_muted: HashSet::new(),
|
||||
@@ -1611,6 +1670,126 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.config.background_dim = dim.clamp(0.0, 1.0);
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::ToggleGamePresence(enabled) => {
|
||||
state.config.game_presence_enabled = enabled;
|
||||
state.config.save();
|
||||
// Core publishes/clears our game immediately (D8).
|
||||
let _ = state.controller.send(CoreCommand::SetGamePresenceEnabled(enabled));
|
||||
}
|
||||
AppMessage::GameOverrideSelected(choice) => {
|
||||
state.game_override = choice;
|
||||
let override_ = match choice {
|
||||
GameOverrideChoice::Auto => crate::game::ManualOverride::Auto,
|
||||
GameOverrideChoice::None => crate::game::ManualOverride::ForceNone,
|
||||
// "Pin current" carries the live detection; nothing detected = Auto.
|
||||
GameOverrideChoice::Current => match &state.current_game {
|
||||
Some(g) => crate::game::ManualOverride::Force(g.clone()),
|
||||
None => crate::game::ManualOverride::Auto,
|
||||
},
|
||||
};
|
||||
let _ = state.controller.send(CoreCommand::SetGameOverride(override_));
|
||||
}
|
||||
AppMessage::GameMapExeChanged(val) => {
|
||||
state.game_map_exe_input = val;
|
||||
}
|
||||
AppMessage::GameMapNameChanged(val) => {
|
||||
state.game_map_name_input = val;
|
||||
}
|
||||
AppMessage::AddGameMapping => {
|
||||
let exe = crate::game::normalize_exe(&state.game_map_exe_input);
|
||||
let name = state.game_map_name_input.trim().to_string();
|
||||
if !exe.is_empty() && !name.is_empty() {
|
||||
state.config.game_process_map.insert(exe, name);
|
||||
state.config.save();
|
||||
state.game_map_exe_input.clear();
|
||||
state.game_map_name_input.clear();
|
||||
let _ = state
|
||||
.controller
|
||||
.send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone()));
|
||||
}
|
||||
}
|
||||
AppMessage::RemoveGameMapping(exe) => {
|
||||
if state.config.game_process_map.remove(&exe).is_some() {
|
||||
state.config.save();
|
||||
let _ = state
|
||||
.controller
|
||||
.send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone()));
|
||||
}
|
||||
}
|
||||
AppMessage::PickGameBackground(game_id) => {
|
||||
// Native picker off the UI thread; result tagged with the game id.
|
||||
return Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
|
||||
.set_title("Choose a per-game background")
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Some(h.read().await),
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
move |bytes| AppMessage::GameBackgroundPicked(game_id, bytes),
|
||||
);
|
||||
}
|
||||
AppMessage::GameBackgroundPicked(game_id, picked) => {
|
||||
if let Some(bytes) = picked {
|
||||
match crate::background::process_background(&bytes) {
|
||||
Ok(png) => match AppConfig::game_background_path(&game_id) {
|
||||
Some(path) => {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
match std::fs::write(&path, &png) {
|
||||
Ok(()) => {
|
||||
state
|
||||
.config
|
||||
.game_backgrounds
|
||||
.insert(game_id.clone(), path.to_string_lossy().into_owned());
|
||||
state.config.save();
|
||||
// Apply at once if it's the running game.
|
||||
if state.current_game.as_ref().map(|g| g.id.as_str())
|
||||
== Some(game_id.as_str())
|
||||
{
|
||||
state.background_image = effective_background_bytes(
|
||||
&state.config,
|
||||
state.current_game.as_ref(),
|
||||
);
|
||||
}
|
||||
state.status_message = "Game background updated.".to_string();
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message =
|
||||
format!("Couldn't save game background: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
state.status_message =
|
||||
"Couldn't find a config directory to save the background."
|
||||
.to_string();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
state.status_message = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::RemoveGameBackground(game_id) => {
|
||||
if let Some(path) = AppConfig::game_background_path(&game_id) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
if state.config.game_backgrounds.remove(&game_id).is_some() {
|
||||
state.config.save();
|
||||
if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) {
|
||||
state.background_image =
|
||||
effective_background_bytes(&state.config, state.current_game.as_ref());
|
||||
}
|
||||
state.status_message = "Game background removed.".to_string();
|
||||
}
|
||||
}
|
||||
AppMessage::ToggleDrawerChat => {
|
||||
state.drawer_chat_open = !state.drawer_chat_open;
|
||||
}
|
||||
@@ -3236,6 +3415,126 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into(),
|
||||
SettingsCategory::Games => {
|
||||
// What's running right now (local detection), for context.
|
||||
let detected_label = match &state.current_game {
|
||||
Some(g) => match &g.name {
|
||||
Some(name) => format!("Currently detected: {name}"),
|
||||
None => "Currently detected: (a game, no name available)".to_string(),
|
||||
},
|
||||
None => "Currently detected: nothing".to_string(),
|
||||
};
|
||||
|
||||
// Manual override picker: Auto / None, plus "Pin current" when a
|
||||
// game is detected.
|
||||
let mut override_opts = vec![GameOverrideChoice::Auto, GameOverrideChoice::None];
|
||||
if state.current_game.is_some() {
|
||||
override_opts.push(GameOverrideChoice::Current);
|
||||
}
|
||||
|
||||
// Per-game background row for the running game (if any).
|
||||
let game_bg_section: Element<'_, AppMessage> = match &state.current_game {
|
||||
Some(g) => {
|
||||
let has_bg = state.config.game_backgrounds.contains_key(&g.id);
|
||||
let id_for_pick = g.id.clone();
|
||||
let mut controls = row![
|
||||
button(text("Set background for this game").size(13))
|
||||
.on_press(AppMessage::PickGameBackground(id_for_pick)),
|
||||
]
|
||||
.spacing(8);
|
||||
if has_bg {
|
||||
let id_for_remove = g.id.clone();
|
||||
controls = controls.push(
|
||||
button(text("Remove").size(13))
|
||||
.on_press(AppMessage::RemoveGameBackground(id_for_remove)),
|
||||
);
|
||||
}
|
||||
controls.into()
|
||||
}
|
||||
None => text("Start a game to set its background.")
|
||||
.size(12)
|
||||
.color(color_subtext)
|
||||
.into(),
|
||||
};
|
||||
|
||||
// The list of configured per-game backgrounds (by stable id).
|
||||
let mut bg_list = column![].spacing(4).width(iced::Length::Fill);
|
||||
for id in state.config.game_backgrounds.keys() {
|
||||
let id_owned = id.clone();
|
||||
bg_list = bg_list.push(
|
||||
row![
|
||||
text(id.clone()).size(12).width(iced::Length::Fill),
|
||||
button(text("Remove").size(12))
|
||||
.on_press(AppMessage::RemoveGameBackground(id_owned)),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(iced::Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
// The process→name mappings (non-Steam games), with an add form.
|
||||
let mut map_list = column![].spacing(4).width(iced::Length::Fill);
|
||||
for (exe, name) in &state.config.game_process_map {
|
||||
let exe_owned = exe.clone();
|
||||
map_list = map_list.push(
|
||||
row![
|
||||
text(format!("{exe} → {name}")).size(12).width(iced::Length::Fill),
|
||||
button(text("Remove").size(12))
|
||||
.on_press(AppMessage::RemoveGameMapping(exe_owned)),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(iced::Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
column![
|
||||
section_header("Game presence"),
|
||||
column![
|
||||
checkbox(state.config.game_presence_enabled)
|
||||
.label("Show the game I'm playing to people in the call")
|
||||
.on_toggle(AppMessage::ToggleGamePresence),
|
||||
text("Off by default. When on, your detected game appears next to your avatar for everyone in the room.")
|
||||
.size(11).color(color_subtext),
|
||||
vertical_space(6.0),
|
||||
text(detected_label).size(12).color(color_subtext),
|
||||
row![
|
||||
text("Override:").size(13),
|
||||
pick_list(
|
||||
override_opts,
|
||||
Some(state.game_override),
|
||||
AppMessage::GameOverrideSelected,
|
||||
),
|
||||
].spacing(8),
|
||||
].spacing(6).width(iced::Length::Fill),
|
||||
vertical_space(section_gap),
|
||||
section_header("Per-game background"),
|
||||
column![
|
||||
text("Give a game its own UI background; it switches automatically while you play. Falls back to your custom background (Appearance) otherwise.")
|
||||
.size(11).color(color_subtext),
|
||||
game_bg_section,
|
||||
bg_list,
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
vertical_space(section_gap),
|
||||
section_header("Non-Steam games"),
|
||||
column![
|
||||
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
||||
.size(11).color(color_subtext),
|
||||
row![
|
||||
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
||||
.on_input(AppMessage::GameMapExeChanged)
|
||||
.width(iced::Length::Fill),
|
||||
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
||||
.on_input(AppMessage::GameMapNameChanged)
|
||||
.width(iced::Length::Fill),
|
||||
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
map_list,
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
||||
@@ -3555,6 +3854,22 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
// Our own detected game (W17/W18). When we're broadcasting it, it
|
||||
// matches what peers see; otherwise it's marked "not shared".
|
||||
{
|
||||
let el: Element<'_, AppMessage> =
|
||||
match state.current_game.as_ref().and_then(|g| g.name.as_deref()) {
|
||||
Some(name) if state.config.game_presence_enabled => {
|
||||
text(format!("Playing {name}")).size(11).color(color_blue).into()
|
||||
}
|
||||
Some(name) => text(format!("Playing {name} (not shared)"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
.into(),
|
||||
None => text("").into(),
|
||||
};
|
||||
el
|
||||
},
|
||||
// Live "you're sharing" badge — only present while sharing.
|
||||
{
|
||||
let el: Element<'_, AppMessage> = if state.self_sharing {
|
||||
@@ -3702,10 +4017,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let mut card_content = column![
|
||||
row![
|
||||
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
||||
column![
|
||||
text(&peer.name).size(16).color(color_text),
|
||||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
||||
],
|
||||
{
|
||||
// Name + id, plus a "Playing <game>" line when the peer is
|
||||
// broadcasting a game (game presence, W17).
|
||||
let mut name_col = column![
|
||||
text(&peer.name).size(16).color(color_text),
|
||||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext),
|
||||
];
|
||||
if let Some(game) = &peer.game {
|
||||
name_col = name_col.push(
|
||||
text(format!("Playing {game}")).size(11).color(color_blue),
|
||||
);
|
||||
}
|
||||
name_col
|
||||
},
|
||||
add_friend_el,
|
||||
horizontal_space(),
|
||||
share_el,
|
||||
@@ -5544,10 +5869,14 @@ mod tests {
|
||||
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications"]
|
||||
vec![
|
||||
"Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network",
|
||||
"Notifications", "Games"
|
||||
]
|
||||
);
|
||||
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
||||
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
||||
assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user