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>
191 lines
9 KiB
C#
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 };
|
|
}
|
|
}
|