- Rename solution files: TeamsISO.sln/slnf -> Dragon-ISO.sln/slnf - Rename all src/TeamsISO.* directories and project files to src/Dragon-ISO.* equivalents - Update .gitignore to exclude build/test output logs - Update ci.yml, CHANGELOG.md, build-and-test.ps1, docs references Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
9.1 KiB
C#
191 lines
9.1 KiB
C#
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<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 };
|
|
}
|
|
}
|