feat: REST control surface + lift preset-apply into PresetApplier
This commit is contained in:
parent
b5fcc98d40
commit
83224dbd9b
4 changed files with 1533 additions and 0 deletions
276
docs/CONTROL-SURFACE.md
Normal file
276
docs/CONTROL-SURFACE.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# TeamsISO Control Surface — REST API
|
||||
|
||||
TeamsISO can expose a localhost HTTP server so external controllers
|
||||
(Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom
|
||||
node-RED flows, command-line scripts) can drive it without a UI binding.
|
||||
|
||||
## Enabling
|
||||
|
||||
1. Open TeamsISO → Settings → DISPLAY tab.
|
||||
2. Tick "Control surface (Stream Deck / Companion)".
|
||||
3. Default port is **9755**; change it via the port textbox if needed.
|
||||
4. The server binds to `127.0.0.1` only — it is NOT reachable from the LAN.
|
||||
If you need LAN access (e.g. a Stream Deck on a separate control PC),
|
||||
front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge.
|
||||
|
||||
When enabled, the toast confirms `Control surface listening on
|
||||
http://127.0.0.1:9755/`.
|
||||
|
||||
## Authentication
|
||||
|
||||
None. The localhost-only bind is the security model. Any process on the
|
||||
operator's machine can hit these endpoints, which is the same threat model
|
||||
as a Stream Deck's USB connection.
|
||||
|
||||
## Response shape
|
||||
|
||||
All responses are `application/json` with `Access-Control-Allow-Origin: *`
|
||||
so a browser-based control panel served from another origin can call the
|
||||
endpoints. Most successful responses include `"ok": true` plus operation-
|
||||
specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /ui`
|
||||
|
||||
Self-contained HTML control panel. Open this in a browser to drive
|
||||
TeamsISO from a phone, tablet, or second monitor. Lists participants live
|
||||
via the same `/ws` WebSocket the rest of the doc describes, and posts to
|
||||
the REST endpoints when you click. Single page, no external dependencies,
|
||||
loads in <50KB.
|
||||
|
||||
### `GET /`
|
||||
|
||||
Returns server info and an endpoint summary. Useful for "is the surface
|
||||
alive?" probes.
|
||||
|
||||
```json
|
||||
{
|
||||
"product": "TeamsISO",
|
||||
"version": "1.0.0.0",
|
||||
"endpoints": ["GET /participants", "POST /participants/{id}/iso", ...]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /participants`
|
||||
|
||||
Snapshot of the current participant list as the UI sees it.
|
||||
|
||||
```json
|
||||
{
|
||||
"participants": [
|
||||
{
|
||||
"id": "1c3e2a8b-...-...",
|
||||
"displayName": "Jane",
|
||||
"isOnline": true,
|
||||
"isEnabled": false,
|
||||
"customName": null,
|
||||
"stateLabel": "—"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /participants/{id}/iso`
|
||||
|
||||
Enable or disable an ISO by participant Id. Body or query string:
|
||||
|
||||
```json
|
||||
{ "enabled": true, "customName": "Host" }
|
||||
```
|
||||
|
||||
`enabled` is optional — omitting it toggles the current state. `customName`
|
||||
is optional and overrides the auto-generated NDI output name.
|
||||
|
||||
```sh
|
||||
curl -X POST 'http://127.0.0.1:9755/participants/1c3e2a8b-.../iso?enabled=true&customName=Host'
|
||||
```
|
||||
|
||||
### `POST /participants/iso`
|
||||
|
||||
Same as above but resolves by display name instead of Id. The Id varies
|
||||
across meetings; the display name is the operator-stable identifier.
|
||||
|
||||
```json
|
||||
{ "displayName": "Jane", "enabled": true }
|
||||
```
|
||||
|
||||
### `POST /presets/{name}/apply`
|
||||
|
||||
Apply a saved preset to the live participant list. Walks every participant
|
||||
in the meeting, matches by display name, sets the custom output name, and
|
||||
reconciles each enable/disable via the engine. Same code path as the
|
||||
Presets dialog and the auto-apply-on-launch flow (`PresetApplier.ApplyAsync`).
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"name": "Friday Show",
|
||||
"matched": 4,
|
||||
"changed": 2,
|
||||
"skipped": 1
|
||||
}
|
||||
```
|
||||
|
||||
`matched` is how many participants in the preset were live in the meeting;
|
||||
`changed` is how many actually flipped state; `skipped` is preset entries
|
||||
with no live counterpart.
|
||||
|
||||
### `POST /presets/refresh-discovery`
|
||||
|
||||
Force NDI discovery to rebuild its finder. Useful after Apply Transcoder
|
||||
Topology or when Teams restarts mid-show. Returns immediately; the rebuild
|
||||
happens on the next poll tick.
|
||||
|
||||
```sh
|
||||
curl -X POST http://127.0.0.1:9755/presets/refresh-discovery
|
||||
```
|
||||
|
||||
### `POST /presets/stop-all`
|
||||
|
||||
Disable every running ISO. Equivalent to clicking "Stop all ISOs" in the
|
||||
header. Returns the count that were running.
|
||||
|
||||
### `POST /teams/mute` / `/camera` / `/share` / `/leave` / `/raise-hand`
|
||||
|
||||
Drive the corresponding Microsoft Teams in-call control via UIAutomation.
|
||||
Returns one of `Invoked` / `TeamsNotRunning` / `ControlNotFound` /
|
||||
`InvokeFailed` in the `result` field.
|
||||
|
||||
```sh
|
||||
curl -X POST http://127.0.0.1:9755/teams/mute
|
||||
```
|
||||
|
||||
### `POST /recording`
|
||||
|
||||
Toggle per-output recording on or off. Body or query string:
|
||||
|
||||
```json
|
||||
{ "enabled": true, "directory": "D:/recordings/show-2026-05-09" }
|
||||
```
|
||||
|
||||
`directory` is optional when `enabled=false`. Already-running ISOs are not
|
||||
retroactively recorded — the operator should disable + re-enable a
|
||||
participant to start recording it.
|
||||
|
||||
### `POST /recording/marker`
|
||||
|
||||
Drop a timestamped marker into every active recording. Body or query string
|
||||
optionally carries a `label`; if omitted, the label defaults to
|
||||
`Marker @ HH:mm:ss`. Markers land in each recording's `manifest.json` under
|
||||
the `markers[]` array as `{ "offsetMs": 12345.6, "label": "Guest answer" }`.
|
||||
|
||||
```sh
|
||||
curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer'
|
||||
```
|
||||
|
||||
### `POST /notes`
|
||||
|
||||
Append a timestamped line to today's show-notes file at
|
||||
`%LOCALAPPDATA%\TeamsISO\Notes\<YYYY-MM-DD>.md`. Body or query string carries
|
||||
`text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so
|
||||
it renders nicely in any editor.
|
||||
|
||||
```sh
|
||||
curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts'
|
||||
```
|
||||
|
||||
### `POST /recording/roll`
|
||||
|
||||
Roll every active recording into a new chunk. Each running pipeline is
|
||||
disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re-
|
||||
enabled (recorder opens a fresh subdirectory keyed by display name +
|
||||
timestamp). Useful for chaptering between show segments — a Stream Deck
|
||||
button mapped to this gives operators "next segment" without losing the
|
||||
already-recorded footage.
|
||||
|
||||
```sh
|
||||
curl -X POST http://127.0.0.1:9755/recording/roll
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "ok": true, "action": "roll-recording", "rolled": 4 }
|
||||
```
|
||||
|
||||
## WebSocket — live state push
|
||||
|
||||
For controllers that want to light a button when an ISO goes LIVE without
|
||||
polling, connect to:
|
||||
|
||||
```
|
||||
ws://127.0.0.1:9755/ws
|
||||
```
|
||||
|
||||
On connect, the server sends a participants snapshot. Whenever the snapshot
|
||||
changes (participant joins/leaves, ISO toggled, custom name edited), a fresh
|
||||
snapshot is pushed within 250ms. Format:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "participants",
|
||||
"participants": [
|
||||
{ "id": "...", "displayName": "Jane", "isOnline": true,
|
||||
"isEnabled": true, "customName": "Host", "stateLabel": "LIVE" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Client→server messages are ignored for v1 — all commands go through REST.
|
||||
|
||||
## OSC over UDP
|
||||
|
||||
Same command surface, different transport. Enable the OSC bridge in the
|
||||
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
|
||||
`127.0.0.1` only.
|
||||
|
||||
Address vocabulary:
|
||||
|
||||
```
|
||||
/teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name
|
||||
/teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id
|
||||
/teamsiso/preset "Name" — apply preset
|
||||
/teamsiso/teams/mute — UIA toggle mute
|
||||
/teamsiso/teams/camera — UIA toggle camera
|
||||
/teamsiso/teams/leave — UIA leave
|
||||
/teamsiso/teams/share — UIA share tray
|
||||
/teamsiso/teams/raise-hand — UIA raise hand
|
||||
/teamsiso/refresh-discovery — rebuild NDI finder
|
||||
/teamsiso/stop-all — disable every ISO
|
||||
/teamsiso/recording {0|1} — recording on/off (default dir)
|
||||
/teamsiso/recording/marker "Label" — drop a marker on every active recording
|
||||
/teamsiso/recording/roll — roll every active recording into a new chunk
|
||||
/teamsiso/notes "Free-form note" — append a timestamped line to today's notes
|
||||
```
|
||||
|
||||
Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button
|
||||
press to e.g. `/teamsiso/iso "Jane" 1`. TouchOSC layouts can use the same
|
||||
addresses on the same UDP port.
|
||||
|
||||
## Bitfocus Companion recipe
|
||||
|
||||
Companion ships a generic HTTP module. Configure a button:
|
||||
|
||||
- **Action:** `HTTP: HTTP POST request`
|
||||
- **URL:** `http://127.0.0.1:9755/teams/mute`
|
||||
- **Body type:** None
|
||||
|
||||
Or for a participant-specific toggle:
|
||||
|
||||
- **URL:** `http://127.0.0.1:9755/participants/iso?displayName=Jane&enabled=true`
|
||||
|
||||
## Stream Deck XL recipe (without Companion)
|
||||
|
||||
Use the "Web Requests" plugin (or any equivalent). Set the action to a POST
|
||||
on the appropriate endpoint above.
|
||||
|
||||
## Future work
|
||||
|
||||
- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same
|
||||
command surface, different transport. Adapter sits in front of the REST
|
||||
handlers.
|
||||
- **Bidirectional state** via WebSocket — push `participants` updates so
|
||||
controllers can light a button when an ISO is live without polling.
|
||||
- **REST apply-preset** — duplicate the dialog's apply logic into
|
||||
`IIsoController.ApplyPreset(name)` so the `/presets/{name}/apply`
|
||||
endpoint becomes a real action.
|
||||
426
src/TeamsISO.App/PresetsDialog.xaml.cs
Normal file
426
src/TeamsISO.App/PresetsDialog.xaml.cs
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using TeamsISO.App.Services;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog for saving and loading operator presets. Owned by
|
||||
/// <see cref="MainWindow"/>; given a snapshot of the current
|
||||
/// <see cref="ParticipantViewModel"/> list and the
|
||||
/// <see cref="IIsoController"/> so it can re-apply assignments
|
||||
/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine).
|
||||
/// </summary>
|
||||
public partial class PresetsDialog : Window
|
||||
{
|
||||
private readonly IIsoController _controller;
|
||||
private readonly IReadOnlyList<ParticipantViewModel> _participants;
|
||||
private readonly ToastViewModel? _toast;
|
||||
|
||||
/// <summary>
|
||||
/// Display-side wrapper for an <see cref="OperatorPresetStore.Preset"/>.
|
||||
/// Adds derived presentation-only properties so the ListBox template can
|
||||
/// render without inline converters or value-conversion logic.
|
||||
/// </summary>
|
||||
public sealed class PresetRow
|
||||
{
|
||||
public OperatorPresetStore.Preset Preset { get; }
|
||||
public string Name => Preset.Name;
|
||||
public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
|
||||
public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled);
|
||||
public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset;
|
||||
}
|
||||
|
||||
public ObservableCollection<PresetRow> Rows { get; } = new();
|
||||
|
||||
public PresetsDialog(
|
||||
IIsoController controller,
|
||||
IReadOnlyList<ParticipantViewModel> participants,
|
||||
ToastViewModel? toast = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_controller = controller;
|
||||
_participants = participants;
|
||||
_toast = toast;
|
||||
PresetsList.ItemsSource = Rows;
|
||||
ReloadPresets();
|
||||
}
|
||||
|
||||
/// <summary>Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock.</summary>
|
||||
private void ReloadPresets()
|
||||
{
|
||||
Rows.Clear();
|
||||
foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt))
|
||||
Rows.Add(new PresetRow(p));
|
||||
EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
var hasSelection = PresetsList.SelectedItem is PresetRow;
|
||||
ApplyButton.IsEnabled = hasSelection;
|
||||
DeleteButton.IsEnabled = hasSelection;
|
||||
DuplicateButton.IsEnabled = hasSelection;
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is PresetRow row)
|
||||
{
|
||||
// Mirror the selected name into the textbox so a re-save overwrites
|
||||
// by default; operator can still type a new name to fork.
|
||||
NameBox.Text = row.Name;
|
||||
}
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void OnSave(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var name = NameBox.Text?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
_toast?.Warn("Enter a name for the preset");
|
||||
NameBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
var assignments = _participants
|
||||
.Select(p => new OperatorPresetStore.Assignment(
|
||||
DisplayName: p.DisplayName,
|
||||
CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||
Enabled: p.IsEnabled))
|
||||
.ToList();
|
||||
|
||||
var existing = OperatorPresetStore.Find(name);
|
||||
if (existing is not null)
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"A preset named \"{name}\" already exists. Overwrite it?",
|
||||
"TeamsISO — Overwrite preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||
Name: name,
|
||||
SavedAt: DateTimeOffset.Now,
|
||||
Assignments: assignments));
|
||||
_toast?.Show($"Saved preset \"{name}\"");
|
||||
ReloadPresets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not save preset.\n\n{ex.Message}",
|
||||
"TeamsISO — Save preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the selected preset: walks the current participants list, matching
|
||||
/// by display name (the only stable join key across meetings — Ids are
|
||||
/// regenerated each meeting). For each match, set the custom output name and
|
||||
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
|
||||
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
|
||||
/// current meeting are silently skipped (and reported in the toast).
|
||||
/// </summary>
|
||||
private async void OnApply(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||
|
||||
ApplyButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
// PresetApplier owns the apply loop — same code path the REST control
|
||||
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
|
||||
// since OnApply already runs on the UI thread.
|
||||
var result = await PresetApplier.ApplyAsync(
|
||||
row.Preset, _participants, _controller, dispatcher: null);
|
||||
|
||||
var summary = result.Skipped > 0
|
||||
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
|
||||
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
|
||||
_toast?.Show(summary);
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ApplyButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Duplicate the selected preset under a new name. We auto-suggest
|
||||
/// "<original> (copy)" but pop a tiny input dialog so the operator
|
||||
/// can pick something meaningful. WPF doesn't ship an InputBox; we
|
||||
/// use a quick custom prompt below.
|
||||
/// </summary>
|
||||
private void OnDuplicate(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||
|
||||
var defaultName = SuggestCopyName(row.Name);
|
||||
var newName = PromptForName("Duplicate preset", "New name:", defaultName);
|
||||
if (string.IsNullOrWhiteSpace(newName)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var existing = OperatorPresetStore.Find(newName);
|
||||
if (existing is not null)
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"A preset named \"{newName}\" already exists. Overwrite it?",
|
||||
"TeamsISO — Duplicate preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
}
|
||||
|
||||
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
|
||||
// semantics handle the name-collision case cleanly.
|
||||
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
||||
Name: newName,
|
||||
SavedAt: DateTimeOffset.Now,
|
||||
Assignments: row.Preset.Assignments));
|
||||
_toast?.Show($"Duplicated to \"{newName}\"");
|
||||
ReloadPresets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not duplicate preset.\n\n{ex.Message}",
|
||||
"TeamsISO — Duplicate preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
|
||||
/// Bumps the digit if the operator iterates from a copy.
|
||||
/// </summary>
|
||||
private static string SuggestCopyName(string original)
|
||||
{
|
||||
if (!original.EndsWith(")", StringComparison.Ordinal))
|
||||
return original + " (copy)";
|
||||
var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$");
|
||||
if (!match.Success) return original + " (copy)";
|
||||
var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2;
|
||||
return original[..(original.Length - match.Length)] + $" (copy {n})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick input dialog for a single string. WPF doesn't ship one, so we
|
||||
/// build a minimal modal here. Keeps the dialog dependency-free.
|
||||
/// </summary>
|
||||
private string? PromptForName(string title, string prompt, string defaultValue)
|
||||
{
|
||||
var dlg = new System.Windows.Window
|
||||
{
|
||||
Title = title,
|
||||
Owner = this,
|
||||
Width = 400,
|
||||
Height = 170,
|
||||
WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner,
|
||||
ResizeMode = System.Windows.ResizeMode.NoResize,
|
||||
Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"),
|
||||
};
|
||||
var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) };
|
||||
stack.Children.Add(new System.Windows.Controls.TextBlock
|
||||
{
|
||||
Text = prompt,
|
||||
Margin = new System.Windows.Thickness(0, 0, 0, 8),
|
||||
Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"),
|
||||
});
|
||||
var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) };
|
||||
stack.Children.Add(tb);
|
||||
var buttons = new System.Windows.Controls.StackPanel
|
||||
{
|
||||
Orientation = System.Windows.Controls.Orientation.Horizontal,
|
||||
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
|
||||
Margin = new System.Windows.Thickness(0, 16, 0, 0),
|
||||
};
|
||||
var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") };
|
||||
var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") };
|
||||
ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); };
|
||||
buttons.Children.Add(cancel);
|
||||
buttons.Children.Add(ok);
|
||||
stack.Children.Add(buttons);
|
||||
dlg.Content = stack;
|
||||
tb.Focus();
|
||||
tb.SelectAll();
|
||||
var result = dlg.ShowDialog();
|
||||
return result == true ? tb.Text.Trim() : null;
|
||||
}
|
||||
|
||||
private void OnDelete(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PresetsList.SelectedItem is not PresetRow row) return;
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"Delete preset \"{row.Name}\"? This cannot be undone.",
|
||||
"TeamsISO — Delete preset",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
MessageBoxResult.No);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
||||
try
|
||||
{
|
||||
OperatorPresetStore.Delete(row.Name);
|
||||
_toast?.Show($"Deleted preset \"{row.Name}\"");
|
||||
ReloadPresets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not delete preset.\n\n{ex.Message}",
|
||||
"TeamsISO — Delete preset",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save every preset as a single .json bundle to a path the user picks via
|
||||
/// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't
|
||||
/// drag in WinForms; the WPF host doesn't ship a built-in alternative.
|
||||
/// </summary>
|
||||
private void OnExport(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||
{
|
||||
Title = "Export TeamsISO presets",
|
||||
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
|
||||
Filter = "TeamsISO preset bundle (*.json)|*.json",
|
||||
DefaultExt = "json",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = OperatorPresetStore.ExportAllAsJson();
|
||||
System.IO.File.WriteAllText(dlg.FileName, json);
|
||||
_toast?.Show($"Exported {Rows.Count} preset(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not export presets.\n\n{ex.Message}",
|
||||
"TeamsISO — Export presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load a bundle from a path the user picks. On name collision we ask once
|
||||
/// (covering all collisions) whether to overwrite — a per-preset prompt would
|
||||
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
|
||||
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
|
||||
/// </summary>
|
||||
private void OnImport(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Import TeamsISO presets",
|
||||
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
|
||||
};
|
||||
if (dlg.ShowDialog(this) != true) return;
|
||||
|
||||
string json;
|
||||
try { json = System.IO.File.ReadAllText(dlg.FileName); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not read the file.\n\n{ex.Message}",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick parse to sniff for collisions before asking the operator anything.
|
||||
OperatorPresetStore.Bundle? bundle;
|
||||
try { bundle = System.Text.Json.JsonSerializer.Deserialize<OperatorPresetStore.Bundle>(json); }
|
||||
catch
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"That file isn't a valid TeamsISO preset bundle.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"The bundle is empty.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var existingNames = OperatorPresetStore.LoadAll()
|
||||
.Select(p => p.Name)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name));
|
||||
|
||||
var overwrite = false;
|
||||
if (collisions > 0)
|
||||
{
|
||||
var choice = MessageBox.Show(
|
||||
this,
|
||||
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
|
||||
"Yes = overwrite local copies with the bundle's versions.\n" +
|
||||
"No = keep local copies; only import new presets.",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.YesNoCancel,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No);
|
||||
if (choice == MessageBoxResult.Cancel) return;
|
||||
overwrite = choice == MessageBoxResult.Yes;
|
||||
}
|
||||
|
||||
var result = OperatorPresetStore.ImportBundle(json, overwrite);
|
||||
if (result.Error is not null)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Import failed.\n\n{result.Error}",
|
||||
"TeamsISO — Import presets",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = $"Imported — {result.Added} new";
|
||||
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
|
||||
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
|
||||
_toast?.Show(summary);
|
||||
ReloadPresets();
|
||||
}
|
||||
}
|
||||
727
src/TeamsISO.App/Services/ControlSurfaceServer.cs
Normal file
727
src/TeamsISO.App/Services/ControlSurfaceServer.cs
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
|
||||
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
|
||||
/// etc.) drive TeamsISO without needing to embed a UI binding.
|
||||
///
|
||||
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
|
||||
/// the typical operator workflow is "Stream Deck on the same machine as TeamsISO".
|
||||
/// If a future user needs LAN access, add a token check + bind to a configurable
|
||||
/// address; both are deliberately punted for v1.
|
||||
///
|
||||
/// Endpoints (all return application/json):
|
||||
///
|
||||
/// GET / — server info + endpoint list
|
||||
/// GET /participants — list of {id, displayName, isOnline, isEnabled}
|
||||
/// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?}
|
||||
/// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name)
|
||||
/// POST /presets/{name}/apply — apply a saved preset
|
||||
/// POST /presets/refresh-discovery — rebuild NDI finder
|
||||
/// POST /presets/stop-all — disable every running ISO
|
||||
/// POST /teams/mute — toggle mute via UIA
|
||||
/// POST /teams/camera — toggle camera via UIA
|
||||
/// POST /teams/leave — leave the call via UIA
|
||||
/// POST /teams/share — open share tray via UIA
|
||||
/// POST /teams/raise-hand — toggle raise hand via UIA
|
||||
/// POST /recording — body {"enabled":bool,"directory":string?}
|
||||
///
|
||||
/// All POST bodies are optional — endpoints that take parameters accept them
|
||||
/// either via JSON body or via query string (?enabled=true&customName=Host).
|
||||
/// This is friendly to Companion's "URL with query string" mode.
|
||||
/// </summary>
|
||||
public sealed class ControlSurfaceServer : IAsyncDisposable
|
||||
{
|
||||
public const int DefaultPort = 9755;
|
||||
|
||||
private readonly IIsoController _controller;
|
||||
private readonly Func<MainViewModel?> _viewModel;
|
||||
private readonly ILogger<ControlSurfaceServer>? _logger;
|
||||
private HttpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _acceptTask;
|
||||
private DispatcherTimer? _pushTimer;
|
||||
private readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
|
||||
private string _lastPushedSnapshot = string.Empty;
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
public int Port { get; private set; } = DefaultPort;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options shared across all responses. Camel-case property
|
||||
/// naming matches Companion's request shape and what most JS clients expect.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public ControlSurfaceServer(
|
||||
IIsoController controller,
|
||||
Func<MainViewModel?> viewModel,
|
||||
ILogger<ControlSurfaceServer>? logger = null)
|
||||
{
|
||||
_controller = controller;
|
||||
_viewModel = viewModel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start listening on the given port. Idempotent: if already running on a
|
||||
/// different port, stop + restart on the new one.
|
||||
/// </summary>
|
||||
public void Start(int port)
|
||||
{
|
||||
if (IsRunning && Port == port) return;
|
||||
Stop();
|
||||
|
||||
Port = port;
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
try
|
||||
{
|
||||
_listener.Start();
|
||||
}
|
||||
catch (HttpListenerException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Could not start control surface on port {Port}.", port);
|
||||
_listener = null;
|
||||
return;
|
||||
}
|
||||
_cts = new CancellationTokenSource();
|
||||
_acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
|
||||
// Drive the WebSocket push loop on the UI dispatcher so we can read the
|
||||
// ObservableCollection-backed Participants list without thread races. 4Hz
|
||||
// is fast enough that operators see immediate feedback when they flip an
|
||||
// ISO on the Stream Deck without us spamming the wire when nothing's
|
||||
// changing — the snapshot serializer dedupes against the previous push.
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher is not null)
|
||||
{
|
||||
_pushTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
};
|
||||
_pushTimer.Tick += async (_, _) => await PushSnapshotIfChangedAsync();
|
||||
_pushTimer.Start();
|
||||
}
|
||||
|
||||
IsRunning = true;
|
||||
_logger?.LogInformation("Control surface listening on http://127.0.0.1:{Port}/ (REST + ws)", port);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
try { _pushTimer?.Stop(); } catch { /* ignore */ }
|
||||
_pushTimer = null;
|
||||
// Close + drop every connected WebSocket; clients will reconnect when the
|
||||
// operator re-enables the surface.
|
||||
foreach (var (id, ws) in _clients.ToArray())
|
||||
{
|
||||
try { ws.Abort(); } catch { /* ignore */ }
|
||||
try { ws.Dispose(); } catch { /* ignore */ }
|
||||
_clients.TryRemove(id, out _);
|
||||
}
|
||||
try { _cts?.Cancel(); } catch { /* ignore */ }
|
||||
try { _listener?.Stop(); } catch { /* ignore */ }
|
||||
try { _listener?.Close(); } catch { /* ignore */ }
|
||||
try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
|
||||
_listener = null;
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
_acceptTask = null;
|
||||
IsRunning = false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Stop();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _listener is not null && _listener.IsListening)
|
||||
{
|
||||
HttpListenerContext ctx;
|
||||
try { ctx = await _listener.GetContextAsync(); }
|
||||
catch (HttpListenerException) { break; } // listener stopped
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (InvalidOperationException) { break; }
|
||||
|
||||
// Each request gets its own task so a slow handler doesn't head-of-line block
|
||||
// others. Handlers are short (no I/O beyond the controller call) so this is
|
||||
// fine without explicit concurrency limits.
|
||||
_ = Task.Run(() => HandleRequestAsync(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext ctx)
|
||||
{
|
||||
var req = ctx.Request;
|
||||
var res = ctx.Response;
|
||||
// Tracks whether we should call res.Close() in the finally. WebSocket
|
||||
// upgrades transfer ownership of the connection to the WebSocket
|
||||
// instance — closing the response here would tear down the freshly-
|
||||
// upgraded socket immediately. So we skip the finally close on that
|
||||
// path.
|
||||
var closeResponseInFinally = true;
|
||||
try
|
||||
{
|
||||
res.Headers["Access-Control-Allow-Origin"] = "*";
|
||||
if (req.HttpMethod == "OPTIONS")
|
||||
{
|
||||
res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
|
||||
res.Headers["Access-Control-Allow-Headers"] = "Content-Type";
|
||||
res.StatusCode = 204;
|
||||
return;
|
||||
}
|
||||
|
||||
var path = req.Url?.AbsolutePath?.TrimEnd('/') ?? "";
|
||||
|
||||
// WebSocket upgrade: live state push for controllers that don't want
|
||||
// to poll. Returns immediately after upgrading; HandleWebSocketAsync
|
||||
// owns the connection until the client disconnects.
|
||||
if (req.IsWebSocketRequest && path == "/ws")
|
||||
{
|
||||
var wsContext = await ctx.AcceptWebSocketAsync(subProtocol: null);
|
||||
closeResponseInFinally = false;
|
||||
_ = Task.Run(() => HandleWebSocketAsync(wsContext.WebSocket));
|
||||
return;
|
||||
}
|
||||
|
||||
var body = await ReadBodyAsync(req);
|
||||
|
||||
// GET /ui — embedded HTML control panel. Served as text/html
|
||||
// rather than JSON so a browser renders it directly.
|
||||
if (req.HttpMethod == "GET" && path == "/ui")
|
||||
{
|
||||
res.ContentType = "text/html; charset=utf-8";
|
||||
var html = ControlPanelHtml.Get();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(html);
|
||||
res.ContentLength64 = bytes.Length;
|
||||
await res.OutputStream.WriteAsync(bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
object? response = (req.HttpMethod, path) switch
|
||||
{
|
||||
("GET", "" or "/") => GetServerInfo(),
|
||||
("GET", "/participants") => GetParticipants(),
|
||||
("POST", "/presets/refresh-discovery") => RefreshDiscovery(),
|
||||
("POST", "/presets/stop-all") => await StopAllAsync(),
|
||||
("POST", "/teams/mute") => InvokeTeams(TeamsControlBridge.ToggleMute, "mute"),
|
||||
("POST", "/teams/camera") => InvokeTeams(TeamsControlBridge.ToggleCamera, "camera"),
|
||||
("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
|
||||
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
|
||||
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
|
||||
("POST", "/recording") => SetRecording(body, req.QueryString),
|
||||
("POST", "/recording/marker") => DropMarker(body, req.QueryString),
|
||||
("POST", "/recording/roll") => await RollRecordingAsync(),
|
||||
("POST", "/notes") => AppendNote(body, req.QueryString),
|
||||
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
|
||||
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
|
||||
=> await ToggleIsoByIdAsync(path, body, req.QueryString),
|
||||
_ when req.HttpMethod == "POST" && path.StartsWith("/presets/", StringComparison.Ordinal)
|
||||
&& path.EndsWith("/apply", StringComparison.Ordinal)
|
||||
=> await ApplyPresetAsync(path),
|
||||
_ => NotFound(),
|
||||
};
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
res.StatusCode = 404;
|
||||
await WriteJsonAsync(res, new { error = "not found" });
|
||||
return;
|
||||
}
|
||||
await WriteJsonAsync(res, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Control surface request failed: {Path}", req.Url?.AbsolutePath);
|
||||
try
|
||||
{
|
||||
res.StatusCode = 500;
|
||||
await WriteJsonAsync(res, new { error = ex.Message });
|
||||
}
|
||||
catch { /* defensive */ }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (closeResponseInFinally)
|
||||
{
|
||||
try { res.Close(); } catch { /* defensive */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 = new
|
||||
{
|
||||
enabled = _controller.RecordingEnabled,
|
||||
directory = _controller.RecordingDirectory,
|
||||
},
|
||||
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 /recording (body: enabled + directory)",
|
||||
"POST /recording/marker (body: label)",
|
||||
"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 list = dispatcher.Invoke(() => 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 new { participants = list };
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
private object SetRecording(JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
var enabled = TryGetBool(body, query, "enabled") ?? false;
|
||||
var directory = TryGetString(body, query, "directory");
|
||||
_controller.SetRecording(enabled, directory);
|
||||
return new { ok = true, recording = new { enabled, directory } };
|
||||
}
|
||||
|
||||
private object DropMarker(JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
var label = TryGetString(body, query, "label")
|
||||
?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
|
||||
_controller.AddRecordingMarker(label);
|
||||
return new { ok = true, action = "marker", label };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roll every active recording into a new chunk. Same code path as the UI's
|
||||
/// RollRecordingCommand — disable + brief delay + re-enable each pipeline.
|
||||
/// We marshal the participants snapshot through the dispatcher because
|
||||
/// ObservableCollection isn't safe to enumerate from the request thread.
|
||||
/// </summary>
|
||||
private async Task<object> RollRecordingAsync()
|
||||
{
|
||||
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 enabled = await dispatcher.InvokeAsync(() =>
|
||||
vm.Participants.Where(p => p.IsEnabled).ToArray());
|
||||
var rolled = 0;
|
||||
foreach (var p in enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
|
||||
await Task.Delay(150);
|
||||
var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
|
||||
? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
|
||||
: p.CustomName;
|
||||
bool? recordOverride = p.RecordToDisk ? null : false;
|
||||
await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
|
||||
rolled++;
|
||||
}
|
||||
catch { /* per-pipeline best-effort */ }
|
||||
}
|
||||
return new { ok = true, action = "roll-recording", rolled };
|
||||
}
|
||||
|
||||
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.")]
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
|
||||
{
|
||||
if (req.HttpMethod != "POST" || req.ContentLength64 == 0) return default;
|
||||
using var sr = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8);
|
||||
var raw = await sr.ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return default;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<JsonElement>(raw);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync(HttpListenerResponse res, object payload)
|
||||
{
|
||||
res.ContentType = "application/json; charset=utf-8";
|
||||
var json = JsonSerializer.Serialize(payload, JsonOpts);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
res.ContentLength64 = bytes.Length;
|
||||
await res.OutputStream.WriteAsync(bytes);
|
||||
}
|
||||
|
||||
private static bool? TryGetBool(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
|
||||
{
|
||||
if (body.ValueKind == JsonValueKind.Object &&
|
||||
body.TryGetProperty(key, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
return v.GetBoolean();
|
||||
var q = query[key];
|
||||
if (q is null) return null;
|
||||
return q.Equals("true", StringComparison.OrdinalIgnoreCase) || q == "1";
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
|
||||
{
|
||||
if (body.ValueKind == JsonValueKind.Object &&
|
||||
body.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
|
||||
return v.GetString();
|
||||
return query[key];
|
||||
}
|
||||
}
|
||||
104
src/TeamsISO.App/Services/PresetApplier.cs
Normal file
104
src/TeamsISO.App/Services/PresetApplier.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using System.Windows.Threading;
|
||||
using TeamsISO.App.ViewModels;
|
||||
using TeamsISO.Engine.Controller;
|
||||
|
||||
namespace TeamsISO.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared preset-application logic. Originally lived inline in
|
||||
/// <c>PresetsDialog.OnApply</c>; lifted out so the REST control surface
|
||||
/// (<see cref="ControlSurfaceServer"/>) and the auto-apply-on-launch path
|
||||
/// (<see cref="MainViewModel.TryAutoApplyPendingPreset"/>) can call the same
|
||||
/// implementation. Single source of truth for "what does Apply mean."
|
||||
///
|
||||
/// Application proceeds participant-by-participant, matching by display name
|
||||
/// (the only stable join key across meetings since Ids regen each session).
|
||||
/// For each match, the custom output name is updated and IsEnabled is
|
||||
/// reconciled with the preset's value via <see cref="IIsoController.EnableIsoAsync"/>
|
||||
/// / <see cref="IIsoController.DisableIsoAsync"/>. Per-participant failures are
|
||||
/// caught and counted; one bad row never aborts applying the rest.
|
||||
/// </summary>
|
||||
public static class PresetApplier
|
||||
{
|
||||
/// <summary>Result counts from an apply pass.</summary>
|
||||
public sealed record ApplyResult(int Matched, int Changed, int Skipped);
|
||||
|
||||
/// <summary>
|
||||
/// Apply <paramref name="preset"/> to the live <paramref name="participants"/>
|
||||
/// list. <paramref name="dispatcher"/>, when supplied, is used to marshal
|
||||
/// IsEnabled / CustomName property writes onto the UI thread; pass null in
|
||||
/// contexts that already run on the UI thread (e.g. the dialog's button click).
|
||||
/// </summary>
|
||||
public static async Task<ApplyResult> ApplyAsync(
|
||||
Services.OperatorPresetStore.Preset preset,
|
||||
IReadOnlyList<ParticipantViewModel> participants,
|
||||
IIsoController controller,
|
||||
Dispatcher? dispatcher = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Build the lookup once, case-insensitive — Teams display names are
|
||||
// human-typed, so "Jane" and "jane" should match the same row.
|
||||
var byName = preset.Assignments.ToDictionary(
|
||||
a => a.DisplayName,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matched = 0;
|
||||
var changed = 0;
|
||||
|
||||
foreach (var p in participants)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!byName.TryGetValue(p.DisplayName, out var assignment)) continue;
|
||||
matched++;
|
||||
|
||||
await SetOnUiAsync(dispatcher, () => p.CustomName = assignment.CustomOutputName ?? string.Empty);
|
||||
|
||||
if (assignment.Enabled && !p.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await controller.EnableIsoAsync(
|
||||
p.Id,
|
||||
string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
||||
cancellationToken);
|
||||
await SetOnUiAsync(dispatcher, () => p.IsEnabled = true);
|
||||
changed++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Per-participant best-effort: the rest still get applied.
|
||||
}
|
||||
}
|
||||
else if (!assignment.Enabled && p.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await controller.DisableIsoAsync(p.Id, cancellationToken);
|
||||
await SetOnUiAsync(dispatcher, () => p.IsEnabled = false);
|
||||
changed++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* defensive */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark applied so auto-apply-on-launch picks the right preset next time.
|
||||
try { Services.OperatorPresetStore.MarkApplied(preset.Name); }
|
||||
catch { /* preference write is best-effort */ }
|
||||
|
||||
var skipped = preset.Assignments.Count - matched;
|
||||
return new ApplyResult(matched, changed, skipped);
|
||||
}
|
||||
|
||||
private static Task SetOnUiAsync(Dispatcher? dispatcher, Action action)
|
||||
{
|
||||
if (dispatcher is null || dispatcher.CheckAccess())
|
||||
{
|
||||
action();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
return dispatcher.InvokeAsync(action).Task;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue