From 2640739bfc4df9a0bab1d68c63b145f3262e8ea4 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 15 May 2026 19:48:03 -0400 Subject: [PATCH] refactor(control-surface): split server into endpoint partials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ControlSurface/Endpoints/HomeEndpoints.cs | 49 ++ .../Endpoints/NotesEndpoints.cs | 19 + .../Endpoints/ParticipantsEndpoints.cs | 191 +++++ .../Endpoints/PresetsEndpoints.cs | 71 ++ .../Endpoints/TeamsEndpoints.cs | 22 + .../Endpoints/ThumbnailEndpoint.cs | 113 +++ .../Endpoints/TopologyEndpoints.cs | 91 +++ .../Services/ControlSurface/WebSocketHub.cs | 147 ++++ .../Services/ControlSurfaceServer.cs | 681 +----------------- 9 files changed, 713 insertions(+), 671 deletions(-) create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/HomeEndpoints.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/NotesEndpoints.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/PresetsEndpoints.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/TeamsEndpoints.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/Endpoints/TopologyEndpoints.cs create mode 100644 src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/HomeEndpoints.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/HomeEndpoints.cs new file mode 100644 index 0000000..bacab7e --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/HomeEndpoints.cs @@ -0,0 +1,49 @@ +namespace TeamsISO.App.Services; + +// GET / — server info + endpoint catalogue. Returned as the JSON +// homepage when a Companion / Stream Deck plugin first probes the +// surface; humans see it via curl http://127.0.0.1:9755/. +public sealed partial class ControlSurfaceServer +{ + private object GetServerInfo() + { + // Best-effort engine snapshot — wrapped in TryRead so a transient + // controller error doesn't 500 the homepage poll. + var settings = TryRead(() => _controller.GlobalSettings); + var groups = TryRead(() => _controller.GroupSettings); + return new + { + product = "TeamsISO", + version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown", + engine = new + { + framerateHz = settings?.FramerateHz, + targetResolution = settings?.Resolution.ToString(), + aspectMode = settings?.Aspect.ToString(), + audioMode = settings?.Audio.ToString(), + discoveryGroups = groups?.DiscoveryGroups, + outputGroups = groups?.OutputGroups, + }, + endpoints = new[] + { + "GET / (this)", + "GET /ui (HTML control panel)", + "GET /participants", + "GET /ws (WebSocket: live participant snapshots)", + "POST /participants/{id}/iso", + "POST /participants/iso (body: displayName + enabled)", + "POST /presets/{name}/apply", + "POST /presets/refresh-discovery", + "POST /presets/stop-all", + "POST /teams/mute, /camera, /leave, /share, /raise-hand", + "POST /notes (body: text)", + }, + }; + } + + private static T? TryRead(Func reader) where T : class + { + try { return reader(); } + catch { return null; } + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/NotesEndpoints.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/NotesEndpoints.cs new file mode 100644 index 0000000..3060ffd --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/NotesEndpoints.cs @@ -0,0 +1,19 @@ +using System.Collections.Specialized; +using System.Text.Json; + +namespace TeamsISO.App.Services; + +// /notes/* route handlers — append-only operator show-notes file. +// +// POST /notes (body: { "text": "..." }) → AppendNote +public sealed partial class ControlSurfaceServer +{ + private object AppendNote(JsonElement body, NameValueCollection query) + { + var text = TryGetString(body, query, "text"); + if (string.IsNullOrWhiteSpace(text)) + return new { ok = false, error = "text required" }; + var ok = NotesService.Append(text); + return new { ok, action = "note", path = NotesService.TodayPath }; + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs new file mode 100644 index 0000000..4c7232d --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs @@ -0,0 +1,191 @@ +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() }; + // 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() }; + 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(), + } }; + } + + /// + /// 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. + /// + private async Task 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, + } }; + } + + /// DELETE /participants/{id}/override — pipeline reverts to global settings. + private async Task 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 }; + } + + /// + /// 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. + /// + private static TEnum TryParseEnum(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(s, ignoreCase: true, out var result) ? result : fallback; + } + + private async Task ToggleIsoByIdAsync(string path, JsonElement body, NameValueCollection query) + { + // path = /participants//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 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 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 }; + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/PresetsEndpoints.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/PresetsEndpoints.cs new file mode 100644 index 0000000..09ebdc1 --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/PresetsEndpoints.cs @@ -0,0 +1,71 @@ +namespace TeamsISO.App.Services; + +// /presets/* route handlers. +// +// POST /presets/refresh-discovery → RefreshDiscovery +// POST /presets/stop-all → StopAllAsync +// POST /presets/{name}/apply → ApplyPresetAsync +public sealed partial class ControlSurfaceServer +{ + private object RefreshDiscovery() + { + _controller.RefreshDiscovery(); + return new { ok = true, action = "refresh-discovery" }; + } + + private async Task StopAllAsync() + { + var vm = _viewModel(); + if (vm is null) return new { ok = false, error = "view-model not ready" }; + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" }; + + // Snapshot the enabled set on the UI thread — ObservableCollection + // isn't safe to enumerate from a thread-pool task, and reading the + // IsEnabled property indirectly walks the data-binding system. + var enabled = await dispatcher.InvokeAsync(() => + vm.Participants.Where(p => p.IsEnabled).ToArray()); + + foreach (var p in enabled) + { + try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); } + catch { /* defensive */ } + await dispatcher.InvokeAsync(() => p.IsEnabled = false); + } + return new { ok = true, action = "stop-all", count = enabled.Length }; + } + + private async Task ApplyPresetAsync(string path) + { + // path = /presets//apply + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply") + return NotFound(); + var name = Uri.UnescapeDataString(segments[1]); + var preset = OperatorPresetStore.Find(name); + if (preset is null) return new { ok = false, error = "preset not found", name }; + + 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" }; + + // Snapshot participants on the UI thread — ObservableCollection + // enumeration and ParticipantViewModel state reads both need to + // happen there. PresetApplier marshals subsequent property writes + // via the dispatcher. + var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList()); + + var result = await PresetApplier.ApplyAsync( + preset, snapshot, _controller, dispatcher); + + return new + { + ok = true, + name = preset.Name, + matched = result.Matched, + changed = result.Changed, + skipped = result.Skipped, + }; + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/TeamsEndpoints.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/TeamsEndpoints.cs new file mode 100644 index 0000000..12068b8 --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/TeamsEndpoints.cs @@ -0,0 +1,22 @@ +namespace TeamsISO.App.Services; + +// /teams/* route handlers — UIAutomation-driven in-call controls. +// +// POST /teams/mute → InvokeTeams(ToggleMute, "mute") +// POST /teams/camera → InvokeTeams(ToggleCamera, "camera") +// POST /teams/leave → InvokeTeams(LeaveCall, "leave") +// POST /teams/share → InvokeTeams(OpenShareTray, "share") +// POST /teams/raise-hand → InvokeTeams(ToggleRaiseHand, "raise-hand") +public sealed partial class ControlSurfaceServer +{ + private object InvokeTeams(Func invoke, string action) + { + var result = invoke(); + return new + { + ok = result == TeamsControlBridge.InvokeResult.Invoked, + action, + result = result.ToString(), + }; + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs new file mode 100644 index 0000000..ba07c4e --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; + +namespace TeamsISO.App.Services; + +// GET /participants/{id}/thumbnail.bmp — small BMP of the latest +// processed frame. Used by the embedded HTML control panel for live +// preview tiles with a cache-busting query param at ~1Hz. +// +// BMP (not JPEG) because the System.Windows.Media.Imaging path NREs on +// non-UI threads and marshaling 1Hz JPEG encodes through the WPF +// dispatcher hurts responsiveness. ~40KB at 192-wide compresses fine +// over LAN gzip. +public sealed partial class ControlSurfaceServer +{ + /// + /// Encode the engine's most recent processed frame for the given + /// participant as a BMP. Returns null when no pipeline is running for + /// this participant or the frame can't be encoded. + /// + private byte[]? TryEncodeThumbnailJpeg(Guid participantId) + { + try + { + var frame = _controller.GetLatestProcessedFrame(participantId); + if (frame is null) + { + _logger?.LogDebug("Thumbnail: no frame for {Id}", participantId); + return null; + } + if (frame.Pixels.Length == 0) + { + _logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height); + return null; + } + + // Nearest-neighbor downscale to ~192 wide. Source is BGRA32. + const int targetWidth = 192; + var ratio = (double)frame.Height / frame.Width; + var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); + return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId); + return null; + } + } + + /// + /// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp + /// top-down BMP. Returns the full BMP file bytes including the 14-byte + /// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly + /// (no JPEG / PNG codec needed in-process). + /// + private static byte[] EncodeBmpDownscaled(ReadOnlySpan srcBgra, int srcW, int srcH, int dstW, int dstH) + { + var pixelBytes = dstW * dstH * 4; + var bmp = new byte[54 + pixelBytes]; + + // BMP file header (14 bytes): 'BM', file size, reserved, pixel offset. + bmp[0] = (byte)'B'; bmp[1] = (byte)'M'; + WriteUInt32LE(bmp, 2, (uint)bmp.Length); + WriteUInt32LE(bmp, 6, 0); + WriteUInt32LE(bmp, 10, 54); + + // DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down. + WriteUInt32LE(bmp, 14, 40); + WriteInt32LE(bmp, 18, dstW); + WriteInt32LE(bmp, 22, -dstH); + WriteUInt16LE(bmp, 26, 1); + WriteUInt16LE(bmp, 28, 32); + WriteUInt32LE(bmp, 30, 0); + WriteUInt32LE(bmp, 34, (uint)pixelBytes); + WriteUInt32LE(bmp, 38, 2835); + WriteUInt32LE(bmp, 42, 2835); + WriteUInt32LE(bmp, 46, 0); + WriteUInt32LE(bmp, 50, 0); + + // Nearest-neighbor downscale, top-down (matches negative-height header). + var srcStride = srcW * 4; + var dstOffset = 54; + for (var dy = 0; dy < dstH; dy++) + { + var sy = (int)((long)dy * srcH / dstH); + for (var dx = 0; dx < dstW; dx++) + { + var sx = (int)((long)dx * srcW / dstW); + var si = sy * srcStride + sx * 4; + bmp[dstOffset++] = srcBgra[si]; + bmp[dstOffset++] = srcBgra[si + 1]; + bmp[dstOffset++] = srcBgra[si + 2]; + bmp[dstOffset++] = srcBgra[si + 3]; + } + } + return bmp; + } + + private static void WriteUInt16LE(byte[] buf, int offset, ushort value) + { + buf[offset] = (byte)(value & 0xFF); + buf[offset + 1] = (byte)((value >> 8) & 0xFF); + } + + private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value); + + private static void WriteUInt32LE(byte[] buf, int offset, uint value) + { + buf[offset] = (byte)(value & 0xFF); + buf[offset + 1] = (byte)((value >> 8) & 0xFF); + buf[offset + 2] = (byte)((value >> 16) & 0xFF); + buf[offset + 3] = (byte)((value >> 24) & 0xFF); + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/Endpoints/TopologyEndpoints.cs b/src/TeamsISO.App/Services/ControlSurface/Endpoints/TopologyEndpoints.cs new file mode 100644 index 0000000..c4a1b1a --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/Endpoints/TopologyEndpoints.cs @@ -0,0 +1,91 @@ +namespace TeamsISO.App.Services; + +// /topology/* route handlers — read + apply / restore the machine NDI +// access-manager config so the operator can flip transcoder topology +// without leaving the web UI. +// +// GET /topology → GetTopology +// POST /topology/apply → ApplyTopologyAsync +// POST /topology/restore → RestoreTopologyAsync +public sealed partial class ControlSurfaceServer +{ + /// + /// Report the current NDI machine topology. "mode" is "hidden" when + /// local senders are confined to the private group (raw Teams sources + /// invisible to the rest of the LAN), "public" otherwise. Reads the + /// machine NDI config file directly — no caching, so the result + /// reflects whatever state the file is in right now (including + /// manual edits). + /// + private object GetTopology() + { + try + { + var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent(); + return new + { + mode, + senders = sends, + receivers = recvs, + configPath = NdiAccessManagerConfig.ConfigPath, + }; + } + catch (Exception ex) + { + return new { ok = false, error = ex.Message }; + } + } + + /// + /// Apply the transcoder topology: machine senders → teamsiso-input, + /// receivers → public + teamsiso-input; engine groups updated to + /// match (discover from teamsiso-input, broadcast on public). Operator + /// MUST restart Teams afterward for it to read the new NDI config. + /// + private async Task ApplyTopologyAsync() + { + var result = NdiAccessManagerConfig.ApplyTranscoderTopology(); + if (!result.Success) + { + return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; + } + // Mirror what the WPF settings VM does so the engine groups + + // machine config stay in lockstep. + var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( + DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup, + OutputGroups: "public"); + await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); + return new + { + ok = true, + mode = "hidden", + backupPath = result.BackupPath, + note = "Restart Microsoft Teams for the new NDI config to take effect there.", + }; + } + + /// + /// Restore the machine NDI defaults: senders + receivers both on + /// public. Engine groups go back to null/defaults too. Operator + /// must restart Teams for it to broadcast on public again. + /// + private async Task RestoreTopologyAsync() + { + var result = NdiAccessManagerConfig.RestoreDefaults(); + if (!result.Success) + { + return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; + } + var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( + DiscoveryGroups: null, + OutputGroups: null); + await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); + return new + { + ok = true, + mode = "public", + backupPath = result.BackupPath, + note = "Restart Microsoft Teams for the new NDI config to take effect there.", + }; + } +} diff --git a/src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs b/src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs new file mode 100644 index 0000000..bcdad60 --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs @@ -0,0 +1,147 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace TeamsISO.App.Services; + +// GET /ws — upgrades to WebSocket and pushes participant-list snapshots +// at 4Hz with diffing (no push when nothing changed). Lets controllers +// stay live-synced without polling /participants. +// +// Lifecycle: +// • Server's accept loop upgrades the request and hands the socket here. +// • HandleWebSocketAsync owns the connection until the client closes. +// • The Start() method wires a 4Hz DispatcherTimer that calls +// PushSnapshotIfChangedAsync to fan out to every connected client. +public sealed partial class ControlSurfaceServer +{ + /// + /// Owns a single client connection until it closes. Sends an immediate + /// snapshot on connect (so the client doesn't have to wait up to 250ms + /// for the next push tick), then sits in a receive loop draining any + /// incoming text — we ignore client→server messages for v1 since all + /// commands are REST. The receive loop is the canonical way to detect + /// graceful close: when WebSocket.ReceiveAsync returns CloseReceived, + /// we close back and remove the client. + /// + private async Task HandleWebSocketAsync(WebSocket ws) + { + var clientId = Guid.NewGuid(); + _clients[clientId] = ws; + _logger?.LogInformation("WebSocket client {Id} connected.", clientId); + + try + { + // Initial snapshot — fetch synchronously on the UI thread so the + // ObservableCollection isn't enumerated cross-thread. + await SendAsync(ws, await GetSnapshotJsonAsync()); + + var buf = new byte[1024]; + while (ws.State == WebSocketState.Open) + { + var result = await ws.ReceiveAsync(new ArraySegment(buf), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); + break; + } + // Ignore any client-sent messages for now; future bidirectional + // commands could route through here. + } + } + catch (WebSocketException) { /* client crashed; drop */ } + catch (ObjectDisposedException) { /* Stop() aborted us */ } + catch (OperationCanceledException) { /* server shutting down */ } + finally + { + _clients.TryRemove(clientId, out _); + // Don't double-dispose: Stop() already disposed the WebSocket if + // it's tearing us down. Aborting an already-disposed socket is a + // no-op throw which we catch + ignore. + try { ws.Dispose(); } catch { /* defensive */ } + _logger?.LogInformation("WebSocket client {Id} disconnected.", clientId); + } + } + + /// + /// Dispatcher-tick handler. Reads the current participants snapshot, + /// and if it differs from what we last pushed, broadcasts the new + /// JSON to every connected client. Diffing on the JSON string is + /// cheap and saves wire bytes when nothing's actually changing — + /// typical operator workflow has long periods of no state churn + /// between meetings. + /// + private async Task PushSnapshotIfChangedAsync() + { + if (_clients.IsEmpty) return; + + string snapshot; + try { snapshot = await GetSnapshotJsonAsync(); } + catch { return; } + + if (snapshot == _lastPushedSnapshot) return; + _lastPushedSnapshot = snapshot; + + var bytes = Encoding.UTF8.GetBytes(snapshot); + foreach (var (id, ws) in _clients.ToArray()) + { + if (ws.State != WebSocketState.Open) + { + _clients.TryRemove(id, out _); + continue; + } + try + { + await ws.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + CancellationToken.None); + } + catch + { + _clients.TryRemove(id, out _); + try { ws.Dispose(); } catch { /* defensive */ } + } + } + } + + private static async Task SendAsync(WebSocket ws, string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + await ws.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Text, + endOfMessage: true, + CancellationToken.None); + } + + /// + /// Build the same payload as GET /participants but as a JSON + /// string for direct WebSocket Send. Reads the ObservableCollection + /// via the UI dispatcher because WPF's ObservableCollection isn't + /// thread-safe to enumerate from a non-UI thread. + /// + private async Task GetSnapshotJsonAsync() + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + var participants = dispatcher is null + ? Array.Empty() + : await dispatcher.InvokeAsync(() => + { + var vm = _viewModel(); + if (vm is null) return Array.Empty(); + return vm.Participants.Select(p => (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, + }).ToArray(); + }); + return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts); + } +} diff --git a/src/TeamsISO.App/Services/ControlSurfaceServer.cs b/src/TeamsISO.App/Services/ControlSurfaceServer.cs index 48051ce..6421e9a 100644 --- a/src/TeamsISO.App/Services/ControlSurfaceServer.cs +++ b/src/TeamsISO.App/Services/ControlSurfaceServer.cs @@ -44,7 +44,10 @@ namespace TeamsISO.App.Services; /// either via JSON body or via query string (?enabled=true&customName=Host). /// This is friendly to Companion's "URL with query string" mode. /// -public sealed class ControlSurfaceServer : IAsyncDisposable +// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/. +// This file holds the host: listener lifecycle, accept loop, dispatch table, +// response helpers, and the WebSocket push loop. +public sealed partial class ControlSurfaceServer : IAsyncDisposable { public const int DefaultPort = 9755; @@ -340,680 +343,16 @@ public sealed class ControlSurfaceServer : IAsyncDisposable } // ─── handlers ─────────────────────────────────────────────────────── - - private object GetServerInfo() - { - // Best-effort engine snapshot — wrapped in try/catch so a transient - // controller error doesn't 500 the homepage poll. - var settings = TryRead(() => _controller.GlobalSettings); - var groups = TryRead(() => _controller.GroupSettings); - return new - { - product = "TeamsISO", - version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown", - engine = new - { - framerateHz = settings?.FramerateHz, - targetResolution = settings?.Resolution.ToString(), - aspectMode = settings?.Aspect.ToString(), - audioMode = settings?.Audio.ToString(), - discoveryGroups = groups?.DiscoveryGroups, - outputGroups = groups?.OutputGroups, - }, - // recording status fields removed alongside the rest of the recording surface. - endpoints = new[] - { - "GET / (this)", - "GET /ui (HTML control panel)", - "GET /participants", - "GET /ws (WebSocket: live participant snapshots)", - "POST /participants/{id}/iso", - "POST /participants/iso (body: displayName + enabled)", - "POST /presets/{name}/apply", - "POST /presets/refresh-discovery", - "POST /presets/stop-all", - "POST /teams/mute, /camera, /leave, /share, /raise-hand", - "POST /notes (body: text)", - }, - }; - } - - private static T? TryRead(Func reader) where T : class - { - try { return reader(); } - catch { return null; } - } - - private object GetParticipants() - { - var vm = _viewModel(); - if (vm is null) return new { participants = Array.Empty() }; - // 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() }; - 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(), - } }; - } - - /// - /// 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. - /// - private async Task 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, - } }; - } - - /// DELETE /participants/{id}/override — pipeline reverts to global settings. - private async Task 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 }; - } - - /// - /// 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. - /// - private static TEnum TryParseEnum(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(s, ignoreCase: true, out var result) ? result : fallback; - } - - private object RefreshDiscovery() - { - _controller.RefreshDiscovery(); - return new { ok = true, action = "refresh-discovery" }; - } - - private async Task StopAllAsync() - { - var vm = _viewModel(); - if (vm is null) return new { ok = false, error = "view-model not ready" }; - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" }; - - // Snapshot the enabled set on the UI thread — ObservableCollection isn't - // safe to enumerate from a thread-pool task, and reading the IsEnabled - // property indirectly walks the data-binding system. - var enabled = await dispatcher.InvokeAsync(() => - vm.Participants.Where(p => p.IsEnabled).ToArray()); - - foreach (var p in enabled) - { - try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); } - catch { /* defensive */ } - await dispatcher.InvokeAsync(() => p.IsEnabled = false); - } - return new { ok = true, action = "stop-all", count = enabled.Length }; - } - - private object InvokeTeams(Func invoke, string action) - { - var result = invoke(); - return new - { - ok = result == TeamsControlBridge.InvokeResult.Invoked, - action, - result = result.ToString(), - }; - } - - // SetRecording and DropMarker methods removed alongside the rest of the recording surface. - - /// - /// Report the current NDI machine topology. "mode" is "hidden" when local - /// senders are confined to the private group (raw Teams sources invisible - /// to the rest of the LAN), "public" otherwise. Reads the machine NDI - /// config file directly — no caching, so the result reflects whatever - /// state the file is in right now (including manual edits). - /// - private object GetTopology() - { - try - { - var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent(); - return new - { - mode, - senders = sends, - receivers = recvs, - configPath = NdiAccessManagerConfig.ConfigPath, - }; - } - catch (Exception ex) - { - return new { ok = false, error = ex.Message }; - } - } - - /// - /// Apply the transcoder topology: machine senders → teamsiso-input, - /// receivers → public + teamsiso-input; engine groups updated to - /// match (discover from teamsiso-input, broadcast on public). Operator - /// MUST restart Teams afterward for it to read the new NDI config. - /// - private async Task ApplyTopologyAsync() - { - var result = NdiAccessManagerConfig.ApplyTranscoderTopology(); - if (!result.Success) - { - return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; - } - // Mirror what the WPF settings VM does so the engine groups + machine - // config stay in lockstep. - var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( - DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup, - OutputGroups: "public"); - await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); - return new - { - ok = true, - mode = "hidden", - backupPath = result.BackupPath, - note = "Restart Microsoft Teams for the new NDI config to take effect there.", - }; - } - - /// - /// Restore the machine NDI defaults: senders + receivers both on - /// public. Engine groups go back to null/defaults too. Operator - /// must restart Teams for it to broadcast on public again. - /// - private async Task RestoreTopologyAsync() - { - var result = NdiAccessManagerConfig.RestoreDefaults(); - if (!result.Success) - { - return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; - } - var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( - DiscoveryGroups: null, - OutputGroups: null); - await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); - return new - { - ok = true, - mode = "public", - backupPath = result.BackupPath, - note = "Restart Microsoft Teams for the new NDI config to take effect there.", - }; - } - - /// - /// Encode the engine's most recent processed frame for the given - /// participant as a JPEG. Returns null when no pipeline is running for - /// this participant or the frame can't be encoded for any reason. - /// - private byte[]? TryEncodeThumbnailJpeg(Guid participantId) - { - // Encode as a raw 32-bpp BMP. BMP is trivial to write byte-by-byte - // and every browser decodes it. JPEG would be smaller, but the - // System.Windows.Media.Imaging path NREs on non-UI threads and - // marshaling 1Hz JPEG encodes through the WPF dispatcher hurts - // responsiveness. ~40KB per 192-wide BMP is fine over LAN. - try - { - var frame = _controller.GetLatestProcessedFrame(participantId); - if (frame is null) - { - _logger?.LogDebug("Thumbnail: no frame for {Id}", participantId); - return null; - } - if (frame.Pixels.Length == 0) - { - _logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height); - return null; - } - - // Nearest-neighbor downscale to ~192 wide. Source is BGRA32. - const int targetWidth = 192; - var ratio = (double)frame.Height / frame.Width; - var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); - return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId); - return null; - } - } - - /// - /// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp - /// top-down BMP. Returns the full BMP file bytes including the 14-byte - /// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly - /// (no JPEG / PNG codec needed in-process). - /// - private static byte[] EncodeBmpDownscaled(ReadOnlySpan srcBgra, int srcW, int srcH, int dstW, int dstH) - { - var pixelBytes = dstW * dstH * 4; - var bmp = new byte[54 + pixelBytes]; - - // BMP file header (14 bytes): 'BM', file size, reserved, pixel offset. - bmp[0] = (byte)'B'; bmp[1] = (byte)'M'; - WriteUInt32LE(bmp, 2, (uint)bmp.Length); - WriteUInt32LE(bmp, 6, 0); - WriteUInt32LE(bmp, 10, 54); - - // DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down. - WriteUInt32LE(bmp, 14, 40); - WriteInt32LE(bmp, 18, dstW); - WriteInt32LE(bmp, 22, -dstH); - WriteUInt16LE(bmp, 26, 1); - WriteUInt16LE(bmp, 28, 32); - WriteUInt32LE(bmp, 30, 0); - WriteUInt32LE(bmp, 34, (uint)pixelBytes); - WriteUInt32LE(bmp, 38, 2835); - WriteUInt32LE(bmp, 42, 2835); - WriteUInt32LE(bmp, 46, 0); - WriteUInt32LE(bmp, 50, 0); - - // Nearest-neighbor downscale, top-down (matches negative-height header). - var srcStride = srcW * 4; - var dstOffset = 54; - for (var dy = 0; dy < dstH; dy++) - { - var sy = (int)((long)dy * srcH / dstH); - for (var dx = 0; dx < dstW; dx++) - { - var sx = (int)((long)dx * srcW / dstW); - var si = sy * srcStride + sx * 4; - bmp[dstOffset++] = srcBgra[si]; - bmp[dstOffset++] = srcBgra[si + 1]; - bmp[dstOffset++] = srcBgra[si + 2]; - bmp[dstOffset++] = srcBgra[si + 3]; - } - } - return bmp; - } - - private static void WriteUInt16LE(byte[] buf, int offset, ushort value) - { - buf[offset] = (byte)(value & 0xFF); - buf[offset + 1] = (byte)((value >> 8) & 0xFF); - } - - private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value); - - private static void WriteUInt32LE(byte[] buf, int offset, uint value) - { - buf[offset] = (byte)(value & 0xFF); - buf[offset + 1] = (byte)((value >> 8) & 0xFF); - buf[offset + 2] = (byte)((value >> 16) & 0xFF); - buf[offset + 3] = (byte)((value >> 24) & 0xFF); - } - - // Legacy WPF-imaging path kept dead-coded for posterity. The BMP path - // above is what's wired through the endpoint. If we ever want JPEG - // again, marshal this to the dispatcher and call from there. - private byte[]? TryEncodeThumbnailJpeg_WpfDeadCode(Guid participantId) - { - try - { - var frame = _controller.GetLatestProcessedFrame(participantId); - if (frame is null) return null; - // 192-wide thumbnail at the source aspect. BGRA32 input. - const int targetWidth = 192; - var ratio = (double)frame.Height / frame.Width; - var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); - - // WPF imaging is NOT free-threaded by default: BitmapSource and - // friends own DispatcherObject affinity until Freeze() drops it. - // The control surface handler runs on an HttpListener thread (NOT - // the UI dispatcher), so every intermediate bitmap MUST be frozen - // before the next call touches it — otherwise we get a NRE deep - // in MIL when JpegBitmapEncoder.Save tries to walk the frame - // chain across thread boundaries. - var stride = frame.Width * 4; - var source = System.Windows.Media.Imaging.BitmapSource.Create( - frame.Width, frame.Height, - 96, 96, - System.Windows.Media.PixelFormats.Bgra32, - null, - frame.Pixels.ToArray(), - stride); - if (source.CanFreeze) source.Freeze(); - - var transform = new System.Windows.Media.ScaleTransform( - (double)targetWidth / frame.Width, - (double)targetHeight / frame.Height); - if (transform.CanFreeze) transform.Freeze(); - - var scaled = new System.Windows.Media.Imaging.TransformedBitmap(source, transform); - if (scaled.CanFreeze) scaled.Freeze(); - - var bitmapFrame = System.Windows.Media.Imaging.BitmapFrame.Create(scaled); - if (bitmapFrame.CanFreeze) bitmapFrame.Freeze(); - - using var ms = new System.IO.MemoryStream(); - var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 }; - encoder.Frames.Add(bitmapFrame); - encoder.Save(ms); - return ms.ToArray(); - } - catch (Exception ex) - { - _logger?.LogDebug(ex, "Thumbnail encode failed for {Id}", participantId); - return null; - } - } - - private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query) - { - var text = TryGetString(body, query, "text"); - if (string.IsNullOrWhiteSpace(text)) - return new { ok = false, error = "text required" }; - var ok = NotesService.Append(text); - return new { ok, action = "note", path = NotesService.TodayPath }; - } - - // RollRecordingAsync handler removed alongside the rest of the recording surface. - - private async Task ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query) - { - // path = /participants//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 ToggleIsoByNameAsync(JsonElement body, System.Collections.Specialized.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 ToggleByIdAsync(Guid id, JsonElement body, System.Collections.Specialized.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 }; - } - - private async Task ApplyPresetAsync(string path) - { - // path = /presets//apply - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply") - return NotFound(); - var name = Uri.UnescapeDataString(segments[1]); - var preset = OperatorPresetStore.Find(name); - if (preset is null) return new { ok = false, error = "preset not found", name }; - - 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" }; - - // Snapshot participants on the UI thread — ObservableCollection enumeration - // and ParticipantViewModel state reads both need to happen there. - // PresetApplier marshals subsequent property writes via the dispatcher. - var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList()); - - var result = await PresetApplier.ApplyAsync( - preset, snapshot, _controller, dispatcher); - - return new - { - ok = true, - name = preset.Name, - matched = result.Matched, - changed = result.Changed, - skipped = result.Skipped, - }; - } + // + // Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as + // partials of this class. See HomeEndpoints, ParticipantsEndpoints, + // PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints, + // and ThumbnailEndpoint. The WebSocket push surface is at + // Services/ControlSurface/WebSocketHub.cs. [SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")] private object NotFound() => new { error = "not found" }; - // ─── WebSocket push ───────────────────────────────────────────────── - - /// - /// Owns a single client connection until it closes. Sends an immediate - /// snapshot on connect (so the client doesn't have to wait up to 250ms - /// for the next push tick), then sits in a receive loop draining any - /// incoming text — we ignore client→server messages for v1 since all - /// commands are REST. The receive loop is the canonical way to detect - /// graceful close: when WebSocket.ReceiveAsync returns CloseReceived, - /// we close back and remove the client. - /// - private async Task HandleWebSocketAsync(WebSocket ws) - { - var clientId = Guid.NewGuid(); - _clients[clientId] = ws; - _logger?.LogInformation("WebSocket client {Id} connected.", clientId); - - try - { - // Initial snapshot — fetch synchronously on the UI thread so the - // ObservableCollection isn't enumerated cross-thread. - await SendAsync(ws, await GetSnapshotJsonAsync()); - - var buf = new byte[1024]; - while (ws.State == WebSocketState.Open) - { - var result = await ws.ReceiveAsync(new ArraySegment(buf), CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) - { - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); - break; - } - // Ignore any client-sent messages for now; future bidirectional - // commands could route through here. - } - } - catch (WebSocketException) { /* client crashed; drop */ } - catch (ObjectDisposedException) { /* Stop() aborted us */ } - catch (OperationCanceledException) { /* server shutting down */ } - finally - { - _clients.TryRemove(clientId, out _); - // Don't double-dispose: Stop() already disposed the WebSocket if it's - // tearing us down. Aborting an already-disposed socket is a no-op - // throw which we catch + ignore. - try { ws.Dispose(); } catch { /* defensive */ } - _logger?.LogInformation("WebSocket client {Id} disconnected.", clientId); - } - } - - /// - /// Dispatcher-tick handler. Reads the current participants snapshot, and if - /// it differs from what we last pushed, broadcasts the new JSON to every - /// connected client. Diffing on the JSON string is cheap and saves wire - /// bytes when nothing's actually changing — typical operator workflow has - /// long periods of no state churn between meetings. - /// - private async Task PushSnapshotIfChangedAsync() - { - if (_clients.IsEmpty) return; - - string snapshot; - try { snapshot = await GetSnapshotJsonAsync(); } - catch { return; } - - if (snapshot == _lastPushedSnapshot) return; - _lastPushedSnapshot = snapshot; - - var bytes = Encoding.UTF8.GetBytes(snapshot); - foreach (var (id, ws) in _clients.ToArray()) - { - if (ws.State != WebSocketState.Open) - { - _clients.TryRemove(id, out _); - continue; - } - try - { - await ws.SendAsync( - new ArraySegment(bytes), - WebSocketMessageType.Text, - endOfMessage: true, - CancellationToken.None); - } - catch - { - _clients.TryRemove(id, out _); - try { ws.Dispose(); } catch { /* defensive */ } - } - } - } - - private static async Task SendAsync(WebSocket ws, string text) - { - var bytes = Encoding.UTF8.GetBytes(text); - await ws.SendAsync( - new ArraySegment(bytes), - WebSocketMessageType.Text, - endOfMessage: true, - CancellationToken.None); - } - - /// - /// Build the same payload as GET /participants but as a JSON string - /// for direct WebSocket Send. Reads the ObservableCollection via the UI - /// dispatcher because WPF's ObservableCollection isn't thread-safe to - /// enumerate from a non-UI thread. - /// - private async Task GetSnapshotJsonAsync() - { - var dispatcher = System.Windows.Application.Current?.Dispatcher; - var participants = dispatcher is null - ? Array.Empty() - : await dispatcher.InvokeAsync(() => - { - var vm = _viewModel(); - if (vm is null) return Array.Empty(); - return vm.Participants.Select(p => (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, - }).ToArray(); - }); - return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts); - } - // ─── helpers ──────────────────────────────────────────────────────── private static async Task ReadBodyAsync(HttpListenerRequest req)