using System.Collections.Specialized; using System.Text.Json; using DragonISO.Engine.Domain; namespace DragonISO.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 }; } }