teamsiso/src/TeamsISO.App/Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs
Zac Gaetano 2640739bfc refactor(control-surface): split server into endpoint partials
ControlSurfaceServer.cs was 1061 lines / 47KB — a single class hosting
the HttpListener loop, the route dispatch, and every endpoint body in
between. Splits the class via partial-class into a thin host file plus
one partial per route group, all under Services/ControlSurface/.

* Services/ControlSurfaceServer.cs (was 1061L → now 400L) — kept here:
  Start / Stop / DisposeAsync (the listener lifecycle), AcceptLoopAsync,
  HandleRequestAsync (the route table itself, with its CORS preflight +
  WebSocket upgrade + JSON dispatch), the response helpers
  (ReadBodyAsync / WriteJsonAsync / TryGetBool / TryGetString), the
  NotFound switch-arm, and the JsonSerializerOptions singleton.
* Services/ControlSurface/Endpoints/HomeEndpoints.cs — GetServerInfo,
  TryRead helper.
* Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs (the
  biggest split) — GetParticipants, SetIsoOverrideByIdAsync,
  ClearIsoOverrideByIdAsync, TryParseEnum, ToggleIsoByIdAsync,
  ToggleIsoByNameAsync, ToggleByIdAsync. Together: every /participants/*
  handler.
* Services/ControlSurface/Endpoints/PresetsEndpoints.cs — RefreshDiscovery,
  StopAllAsync, ApplyPresetAsync.
* Services/ControlSurface/Endpoints/TeamsEndpoints.cs — InvokeTeams
  (the helper that maps a TeamsControlBridge result to the JSON body).
* Services/ControlSurface/Endpoints/TopologyEndpoints.cs — GetTopology,
  ApplyTopologyAsync, RestoreTopologyAsync.
* Services/ControlSurface/Endpoints/NotesEndpoints.cs — AppendNote.
* Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs —
  TryEncodeThumbnailJpeg (which is actually the BMP path now) +
  EncodeBmpDownscaled + the LE byte writers. The legacy
  TryEncodeThumbnailJpeg_WpfDeadCode helper that was dead-coded "for
  posterity" is gone — no call sites; we removed-comments-on-removed-
  code is the anti-pattern we wanted to fix.
* Services/ControlSurface/WebSocketHub.cs — HandleWebSocketAsync,
  PushSnapshotIfChangedAsync, SendAsync, GetSnapshotJsonAsync. The
  push-timer wiring stays in the host's Start() so the lifetime is
  obvious where the connection is opened.

No behavior change. The route table in HandleRequestAsync still
dispatches by (HttpMethod, path) — only the handler bodies moved.

Build clean; 56 + 104 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:48:03 -04:00

191 lines
9 KiB
C#

using System.Collections.Specialized;
using System.Text.Json;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.Services;
// /participants/* route handlers. Anything that reads or writes
// participant + per-pipeline state lives here.
//
// GET /participants → GetParticipants
// POST /participants/{id}/iso → ToggleIsoByIdAsync
// POST /participants/iso → ToggleIsoByNameAsync
// POST /participants/{id}/override → SetIsoOverrideByIdAsync
// DELETE /participants/{id}/override → ClearIsoOverrideByIdAsync
public sealed partial class ControlSurfaceServer
{
private object GetParticipants()
{
var vm = _viewModel();
if (vm is null) return new { participants = Array.Empty<object>() };
// Synchronously snapshot on the UI thread — ObservableCollection
// isn't safe to enumerate from this request handler's thread-pool
// task, and the ParticipantViewModel property reads chase
// data-binding state.
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null) return new { participants = Array.Empty<object>() };
var globals = _controller.GlobalSettings;
var list = dispatcher.Invoke(() => vm.Participants.Select(p => {
var ovr = _controller.GetIsoOverride(p.Id);
return (object)new
{
id = p.Id,
displayName = p.DisplayName,
isOnline = p.IsOnline,
isEnabled = p.IsEnabled,
customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
stateLabel = p.StateLabel,
// Effective settings = override if set, else globals. The
// web UI uses this to show the current per-row values
// without a separate round-trip to /global.
effective = new
{
framerate = (ovr ?? globals).Framerate.ToString(),
resolution = (ovr ?? globals).Resolution.ToString(),
aspect = (ovr ?? globals).Aspect.ToString(),
audio = (ovr ?? globals).Audio.ToString(),
isOverride = ovr is not null,
},
};
}).ToArray());
return new { participants = list, globals = new {
framerate = globals.Framerate.ToString(),
resolution = globals.Resolution.ToString(),
aspect = globals.Aspect.ToString(),
audio = globals.Audio.ToString(),
} };
}
/// <summary>
/// POST /participants/{id}/override — set or replace the per-pipeline
/// override. Body fields: framerate (enum string), resolution (enum
/// string), aspect (enum string), audio (enum string). All fields are
/// optional; missing fields fall back to the current global value.
/// </summary>
private async Task<object> SetIsoOverrideByIdAsync(string path, JsonElement body)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
return new { ok = false, error = "expected /participants/{id}/override" };
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
var g = _controller.GlobalSettings;
var framerate = TryParseEnum(body, "framerate", g.Framerate);
var resolution = TryParseEnum(body, "resolution", g.Resolution);
var aspect = TryParseEnum(body, "aspect", g.Aspect);
var audio = TryParseEnum(body, "audio", g.Audio);
var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio);
await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None);
return new { ok = true, id, effective = new
{
framerate = ovr.Framerate.ToString(),
resolution = ovr.Resolution.ToString(),
aspect = ovr.Aspect.ToString(),
audio = ovr.Audio.ToString(),
isOverride = true,
} };
}
/// <summary>DELETE /participants/{id}/override — pipeline reverts to global settings.</summary>
private async Task<object> ClearIsoOverrideByIdAsync(string path)
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override")
return new { ok = false, error = "expected /participants/{id}/override" };
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None);
return new { ok = true, id, cleared = true };
}
/// <summary>
/// Parse an enum value from a JSON body, falling back to a default when
/// the field is missing or the value doesn't match any enum member.
/// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four
/// FrameProcessingSettings enums.
/// </summary>
private static TEnum TryParseEnum<TEnum>(JsonElement body, string field, TEnum fallback)
where TEnum : struct, Enum
{
if (body.ValueKind != JsonValueKind.Object) return fallback;
if (!body.TryGetProperty(field, out var prop)) return fallback;
if (prop.ValueKind != JsonValueKind.String) return fallback;
var s = prop.GetString();
if (string.IsNullOrEmpty(s)) return fallback;
return Enum.TryParse<TEnum>(s, ignoreCase: true, out var result) ? result : fallback;
}
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, NameValueCollection query)
{
// path = /participants/<guid>/iso
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
return NotFound();
if (!Guid.TryParse(segments[1], out var id))
return new { ok = false, error = "invalid id" };
return await ToggleByIdAsync(id, body, query);
}
private async Task<object> ToggleIsoByNameAsync(JsonElement body, NameValueCollection query)
{
var displayName = TryGetString(body, query, "displayName");
if (string.IsNullOrWhiteSpace(displayName))
return new { ok = false, error = "displayName required" };
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
if (p is null) return new { ok = false, error = "participant not found", displayName };
return await ToggleByIdAsync(p.Id, body, query);
}
private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, NameValueCollection query)
{
var enabled = TryGetBool(body, query, "enabled");
var customName = TryGetString(body, query, "customName");
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
// Look up the VM and snapshot its current state on the UI thread —
// ObservableCollection enumeration and view-model property reads
// both need to happen there.
var lookup = await dispatcher.InvokeAsync(() =>
{
var p = vm.Participants.FirstOrDefault(x => x.Id == id);
return p is null
? null
: new { Pvm = p, p.IsEnabled, p.CustomName };
});
if (lookup is null) return new { ok = false, error = "participant not found", id };
var target = enabled ?? !lookup.IsEnabled;
var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;
if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };
// Apply CustomName change first (if any) on the UI thread so a
// subsequent EnableIsoAsync sees the new name.
if (!string.IsNullOrEmpty(customName))
await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);
if (target)
{
await _controller.EnableIsoAsync(id,
string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
CancellationToken.None);
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
}
else
{
await _controller.DisableIsoAsync(id, CancellationToken.None);
await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
}
return new { ok = true, id, enabled = target };
}
}