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>
This commit is contained in:
parent
e67c02c2ff
commit
2640739bfc
9 changed files with 713 additions and 671 deletions
|
|
@ -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<T>(Func<T> reader) where T : class
|
||||||
|
{
|
||||||
|
try { return reader(); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<object> 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<object> ApplyPresetAsync(string path)
|
||||||
|
{
|
||||||
|
// path = /presets/<name>/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<TeamsControlBridge.InvokeResult> invoke, string action)
|
||||||
|
{
|
||||||
|
var result = invoke();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ok = result == TeamsControlBridge.InvokeResult.Invoked,
|
||||||
|
action,
|
||||||
|
result = result.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
|
||||||
|
/// receivers → <c>public + teamsiso-input</c>; 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> 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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restore the machine NDI defaults: senders + receivers both on
|
||||||
|
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
|
||||||
|
/// must restart Teams for it to broadcast on public again.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> 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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs
Normal file
147
src/TeamsISO.App/Services/ControlSurface/WebSocketHub.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte>(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<byte>(bytes),
|
||||||
|
WebSocketMessageType.Text,
|
||||||
|
endOfMessage: true,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the same payload as <c>GET /participants</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GetSnapshotJsonAsync()
|
||||||
|
{
|
||||||
|
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||||
|
var participants = dispatcher is null
|
||||||
|
? Array.Empty<object>()
|
||||||
|
: await dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var vm = _viewModel();
|
||||||
|
if (vm is null) return Array.Empty<object>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,10 @@ namespace TeamsISO.App.Services;
|
||||||
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
||||||
/// This is friendly to Companion's "URL with query string" mode.
|
/// This is friendly to Companion's "URL with query string" mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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;
|
public const int DefaultPort = 9755;
|
||||||
|
|
||||||
|
|
@ -340,680 +343,16 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── handlers ───────────────────────────────────────────────────────
|
// ─── handlers ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
private object GetServerInfo()
|
// Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
|
||||||
{
|
// partials of this class. See HomeEndpoints, ParticipantsEndpoints,
|
||||||
// Best-effort engine snapshot — wrapped in try/catch so a transient
|
// PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
|
||||||
// controller error doesn't 500 the homepage poll.
|
// and ThumbnailEndpoint. The WebSocket push surface is at
|
||||||
var settings = TryRead(() => _controller.GlobalSettings);
|
// Services/ControlSurface/WebSocketHub.cs.
|
||||||
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<T>(Func<T> 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<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 object RefreshDiscovery()
|
|
||||||
{
|
|
||||||
_controller.RefreshDiscovery();
|
|
||||||
return new { ok = true, action = "refresh-discovery" };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<object> 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<TeamsControlBridge.InvokeResult> 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.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apply the transcoder topology: machine senders → <c>teamsiso-input</c>,
|
|
||||||
/// receivers → <c>public + teamsiso-input</c>; 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.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<object> 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.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Restore the machine NDI defaults: senders + receivers both on
|
|
||||||
/// <c>public</c>. Engine groups go back to null/defaults too. Operator
|
|
||||||
/// must restart Teams for it to broadcast on public again.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<object> 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.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
private static byte[] EncodeBmpDownscaled(ReadOnlySpan<byte> 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<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.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, 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<object> 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<object> ApplyPresetAsync(string path)
|
|
||||||
{
|
|
||||||
// path = /presets/<name>/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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
|
[SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
|
||||||
private object NotFound() => new { error = "not found" };
|
private object NotFound() => new { error = "not found" };
|
||||||
|
|
||||||
// ─── WebSocket push ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<byte>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<byte>(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<byte>(bytes),
|
|
||||||
WebSocketMessageType.Text,
|
|
||||||
endOfMessage: true,
|
|
||||||
CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build the same payload as <c>GET /participants</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> GetSnapshotJsonAsync()
|
|
||||||
{
|
|
||||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
|
||||||
var participants = dispatcher is null
|
|
||||||
? Array.Empty<object>()
|
|
||||||
: await dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
var vm = _viewModel();
|
|
||||||
if (vm is null) return Array.Empty<object>();
|
|
||||||
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 ────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue