Compare commits

..

3 commits

Author SHA1 Message Date
647deec304 feat(web): topology + thumbnail endpoints, redesigned /ui control panel
Some checks failed
CI / build-and-test (push) Failing after 30s
REST additions: GET /topology returns mode (hidden/public/unknown) + sender/receiver group lists. POST /topology/apply confines local senders to teamsiso-input + receivers to public+teamsiso-input. POST /topology/restore returns both to public defaults.

GET /participants/{id}/thumbnail.jpg encodes the latest engine ProcessedFrame as a 192-wide JPEG. 404 when no pipeline is running. Used by the /ui control panel for live preview tiles.

Settings: ControlSurfaceEnabled now persists across sessions via UIPreferences and auto-starts the server on app launch when previously enabled.

/ui control panel rebuilt: live thumbnail per row, topology toggle card with Hide/Restore buttons, removed dead recording marker button, larger layout (920px), participant rows in single card with hover affordances.
2026-05-15 15:06:11 -04:00
4944de5feb feat(wpf): v2 - restore live thumbnail preview column in participants table
96x54 thumbnail (16:9) fed from the engine's most recent ProcessedFrame. Em-dash placeholder when no pipeline is running. Same pattern as v1 - lifted Image binding to Thumbnail with HasThumbnail visibility flip. Sits between the state LED and the name+codec caption.
2026-05-15 14:33:00 -04:00
209b643cd5 fix(wpf): MainViewModel subscription via direct Subscribe + Dispatcher marshal
The .ObserveOn(SynchronizationContextScheduler(SyncContext.Current)) path captured a synchronization context at subscribe time that didn't pump subsequent OnNext emissions in WPF startup, leaving the Participants collection empty even though the engine's discovery was firing. Console probe confirmed engine sees Teams sources; only the GUI consumer was broken.

Switched to direct Subscribe + Dispatcher.InvokeAsync inside the callback (same pattern proven by Console.Program.cs). Subscribe-time context capture is gone; every emission marshals to the UI thread on its own.
2026-05-15 14:27:17 -04:00
8 changed files with 416 additions and 46 deletions

View file

@ -187,6 +187,25 @@ public partial class App : Application
() => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
// Auto-start the REST + WebSocket control surface if the operator
// turned it on in a previous session. The settings VM's setter
// also calls Start when the operator toggles it during a session;
// this block covers the "restart the app, expect it still on" case.
if (_viewModel.Settings.ControlSurfaceEnabled)
{
try
{
_controlSurface.Start(
_viewModel.Settings.ControlSurfacePort,
_viewModel.Settings.ControlSurfaceLanReachable);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger<App>().LogWarning(ex,
"Control surface auto-start failed; operator can retry via Settings.");
}
}
// DiskSpaceWatcher removed alongside the rest of the recording surface.
// Tray icon host. Disabled by default; the settings VM flips

View file

@ -494,7 +494,42 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 2 — Name + caption. Display name (Inter 13/Medium) above,
<!-- Col 2 — Live preview thumbnail. 96×54 (16:9). WriteableBitmap
fed from the engine's most recent ProcessedFrame at the 1Hz
stats tick. Em-dash placeholder when no pipeline is running. -->
<DataGridTemplateColumn Header="Preview" Width="106" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid VerticalAlignment="Center" HorizontalAlignment="Left" Margin="0,0,8,0">
<Border Width="96" Height="54"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
Visibility="{Binding HasThumbnail, Converter={StaticResource BoolToVisInverse}}">
<TextBlock Text="—"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<Border Width="96" Height="54"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="4"
ClipToBounds="True"
Visibility="{Binding HasThumbnail, Converter={StaticResource BoolToVis}}">
<Image Source="{Binding Thumbnail}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 3 — Name + caption. Display name (Inter 13/Medium) above,
source machine + state caption (Mono 11/Tertiary) below. -->
<DataGridTemplateColumn Header="Participant" Width="*" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>

View file

@ -3,14 +3,17 @@ namespace TeamsISO.App.Services;
/// <summary>
/// The HTML / CSS / JS for the embedded control panel served at
/// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no
/// build step, no React. Just enough to give operators a phone-friendly
/// remote that connects via WebSocket to <c>/ws</c> and posts to the
/// existing REST endpoints.
/// build step, no React. Phone-friendly remote that connects via WebSocket
/// to <c>/ws</c> and posts to the existing REST endpoints.
///
/// Visual language matches the WPF host: dark canvas, cyan accent, mono
/// font for codey labels. Keeping the styling minimal so a future iteration
/// can swap in a fancier UI without breaking operator workflows that already
/// bookmark the URL.
/// v2 additions:
/// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg
/// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed
/// ~1Hz alongside the WebSocket state push).
/// - Topology toggle card — shows whether raw Teams NDI sources are
/// hidden from the LAN, with Apply / Restore buttons that hit the
/// /topology/apply + /topology/restore REST endpoints. Operator still
/// has to restart Teams afterward, surfaced in a banner on apply.
/// </summary>
internal static class ControlPanelHtml
{
@ -26,23 +29,27 @@ internal static class ControlPanelHtml
--surface: #141414;
--surface-elev: #1c1c1c;
--border: #262626;
--border-strong: #3a3b40;
--text: #f5f5f5;
--text-2: #a3a3a3;
--text-3: #6b6b6b;
--cyan: #97edf0;
--cyan-mute: #1b3537;
--cyan-text: #97edf0;
--coral: #fb819c;
--coral-bg: #3a1922;
--green: #4ade80;
--amber: #fbbf24;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text);
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body { padding: 16px; max-width: 720px; margin: 0 auto; }
body { padding: 16px; max-width: 920px; margin: 0 auto; }
h1 {
font-size: 13px; letter-spacing: 0.12em; font-weight: 600;
text-transform: uppercase; color: var(--text-3); margin: 0 0 18px;
font-size: 11px; letter-spacing: 0.12em; font-weight: 600;
text-transform: uppercase; color: var(--text-3); margin: 0 0 14px;
}
.card {
background: var(--surface); border: 1px solid var(--border);
@ -50,25 +57,31 @@ internal static class ControlPanelHtml
}
.row { display: flex; align-items: center; gap: 10px; }
.row + .row { margin-top: 10px; }
.grow { flex: 1; }
.grow { flex: 1; min-width: 0; }
button {
background: var(--surface-elev); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 14px; cursor: pointer;
font: inherit; font-size: 13px;
transition: background 80ms ease;
transition: background 80ms ease, border-color 80ms ease;
}
button:hover { background: #242424; }
button.primary { background: var(--cyan-mute); color: var(--cyan); border-color: var(--cyan); }
button.danger { background: #3a1922; color: var(--coral); border-color: var(--coral); }
button.live { background: var(--cyan-mute); color: var(--cyan); border-color: var(--cyan); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
button:hover { background: #242424; border-color: var(--border-strong); }
button.primary { background: var(--cyan); color: #042830; border-color: var(--cyan); font-weight: 500; }
button.primary:hover { background: #b5f2f4; }
button.danger { background: transparent; color: var(--coral); border-color: var(--coral); }
button.danger:hover { background: var(--coral-bg); }
button.live { background: var(--cyan-mute); color: var(--cyan-text); border-color: var(--cyan); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.dot.cyan { background: var(--cyan); }
.dot.coral { background: var(--coral); }
.dot.green { background: var(--green); }
.dot.amber { background: var(--amber); }
.dot.gray { background: var(--text-3); }
.name { font-weight: 500; }
.name { font-weight: 500; font-size: 14px; }
.sub { color: var(--text-3); font-size: 11px;
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace; }
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label-caps { font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
color: var(--text-3); text-transform: uppercase; }
.status {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
@ -76,11 +89,25 @@ internal static class ControlPanelHtml
}
.status .ok { color: var(--green); }
.status .err { color: var(--coral); }
.empty { color: var(--text-3); font-size: 12px; padding: 16px; text-align: center; }
details summary { cursor: pointer; color: var(--text-2); font-size: 12px; }
details summary::marker { color: var(--text-3); }
.empty { color: var(--text-3); font-size: 12px; padding: 18px; text-align: center; }
.global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } }
.participant-row { display: flex; align-items: center; gap: 14px; padding: 10px; border-radius: 10px; }
.participant-row + .participant-row { margin-top: 6px; }
.participant-row.speaking { background: var(--cyan-mute); }
.preview {
width: 112px; height: 63px; flex-shrink: 0; border-radius: 6px;
background: var(--surface-elev); border: 1px solid var(--border);
object-fit: cover; display: block;
}
.preview.empty { display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-family: 'JetBrains Mono', monospace; font-size: 18px; }
.topology-card { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.topology-state { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 200px; }
.topology-state strong { font-size: 13px; color: var(--text); }
.topology-banner { margin: 10px 0 0; padding: 10px 12px; border-radius: 8px;
background: var(--cyan-mute); color: var(--cyan-text); font-size: 12px; display: none; }
.topology-banner.show { display: block; }
</style>
</head>
<body>
@ -93,16 +120,31 @@ internal static class ControlPanelHtml
</div>
</div>
<div class='card topology-card'>
<div class='topology-state'>
<span id='topo-dot' class='dot gray'></span>
<div>
<div class='label-caps'>Network topology</div>
<strong id='topo-label'></strong>
<div id='topo-detail' class='sub' style='margin-top: 2px;'></div>
</div>
</div>
<div style='display: flex; gap: 8px;'>
<button id='topo-apply' onclick='applyTopology()'>Hide Teams sources</button>
<button id='topo-restore' onclick='restoreTopology()'>Restore defaults</button>
</div>
</div>
<div id='topo-banner' class='topology-banner'></div>
<div class='card'>
<div class='global-actions'>
<button onclick='post(""/teams/mute"")'>Mute</button>
<button onclick='post(""/teams/camera"")'>Camera</button>
<button onclick='post(""/teams/share"")'>Share</button>
<button onclick='post(""/teams/leave"")'>Leave</button>
<button onclick='post(""/recording/marker"")'>Marker</button>
<button onclick='dropNote()'>Note</button>
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</button>
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all</button>
<button class='danger' onclick='post(""/presets/stop-all"")'>Stop all ISOs</button>
</div>
</div>
@ -113,6 +155,10 @@ const list = document.getElementById('participants');
const conn = document.getElementById('conn');
const connText = document.getElementById('conn-text');
const count = document.getElementById('count');
const topoDot = document.getElementById('topo-dot');
const topoLabel = document.getElementById('topo-label');
const topoDetail = document.getElementById('topo-detail');
const topoBanner = document.getElementById('topo-banner');
function setConn(state, text) {
conn.className = 'dot ' + state;
@ -123,8 +169,9 @@ async function post(path, body) {
try {
const opts = { method: 'POST' };
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
await fetch(path, opts);
} catch (e) { console.warn(e); }
const r = await fetch(path, opts);
return r.ok ? await r.json().catch(() => null) : null;
} catch (e) { console.warn(e); return null; }
}
function dropNote() {
@ -132,28 +179,83 @@ function dropNote() {
if (text && text.trim()) post('/notes', { text: text.trim() });
}
async function fetchTopology() {
try {
const r = await fetch('/topology');
if (!r.ok) return;
const t = await r.json();
paintTopology(t);
} catch (e) { console.warn(e); }
}
function paintTopology(t) {
if (!t) return;
if (t.mode === 'hidden') {
topoDot.className = 'dot cyan';
topoLabel.textContent = 'Teams hidden from LAN';
} else if (t.mode === 'public') {
topoDot.className = 'dot amber';
topoLabel.textContent = 'Public raw Teams visible';
} else {
topoDot.className = 'dot gray';
topoLabel.textContent = 'Unknown';
}
const sends = (t.senders || []).join(', ') || '—';
const recvs = (t.receivers || []).join(', ') || '—';
topoDetail.textContent = 'send: ' + sends + ' · recv: ' + recvs;
}
async function applyTopology() {
const r = await post('/topology/apply');
if (r && r.ok) {
topoBanner.textContent = ' ' + (r.note || 'Applied. Restart Microsoft Teams for it to take effect.');
topoBanner.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000);
}
fetchTopology();
}
async function restoreTopology() {
if (!confirm('Restore default NDI groups? Teams will broadcast on public again after you restart it.')) return;
const r = await post('/topology/restore');
if (r && r.ok) {
topoBanner.textContent = ' Defaults restored. Restart Microsoft Teams for it to take effect.';
topoBanner.classList.add('show');
setTimeout(() => topoBanner.classList.remove('show'), 8000);
}
fetchTopology();
}
function render(participants) {
if (!participants || participants.length === 0) {
list.innerHTML = ""<div class='card empty'>No participants visible. Discover or invite into a Teams meeting.</div>"";
list.innerHTML = ""<div class='card empty'>No participants visible. Open Teams and join a meeting; sources will populate within seconds.</div>"";
count.textContent = '';
return;
}
const live = participants.filter(p => p.isEnabled).length;
count.textContent = live + ' / ' + participants.length + ' live';
list.innerHTML = '';
const card = document.createElement('div');
card.className = 'card';
for (const p of participants) {
const row = document.createElement('div');
row.className = 'card';
const dotColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
row.className = 'participant-row';
const stateColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral');
// Live preview tile — cache-bust with a 1s-bucket query param so the
// browser refreshes the image without flickering on every WS message.
const bust = Math.floor(Date.now() / 1000);
const previewUrl = '/participants/' + p.id + '/thumbnail.jpg?t=' + bust;
row.innerHTML =
""<div class='row'>"" +
""<span class='dot "" + dotColor + ""'></span>"" +
""<div class='grow'>"" +
""<div class='name'></div>"" +
""<div class='sub'></div>"" +
""</div>"" +
""<button class='"" + (p.isEnabled ? 'live' : '') + ""'></button>"" +
""</div>"";
""<span class='dot "" + stateColor + ""'></span>"" +
""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" +
""<div class='preview empty' style='display:none;'></div>"" +
""<div class='grow'>"" +
""<div class='name'></div>"" +
""<div class='sub'></div>"" +
""</div>"" +
""<button class='"" + (p.isEnabled ? 'live' : '') + ""'></button>"";
const img = row.querySelector('img.preview');
img.src = previewUrl;
row.querySelector('.name').textContent = p.displayName;
row.querySelector('.sub').textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
@ -164,15 +266,16 @@ function render(participants) {
displayName: p.displayName,
enabled: !p.isEnabled,
});
list.appendChild(row);
card.appendChild(row);
}
list.appendChild(card);
}
function connect() {
setConn('gray', 'connecting');
const ws = new WebSocket(
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onopen = () => setConn('green', 'live');
ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
ws.onmessage = (ev) => {
try {
const m = JSON.parse(ev.data);
@ -187,6 +290,9 @@ function connect() {
}
connect();
// Re-poll topology every 30s in case the operator changes the machine NDI
// config externally (NDI Access Manager, manual edit). Cheap — one HTTP GET.
setInterval(fetchTopology, 30000);
</script>
</body>
</html>

View file

@ -243,6 +243,37 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
return;
}
// GET /participants/{id}/thumbnail.jpg — small JPEG of the latest
// processed frame. Returns 404 when no pipeline is running for
// this participant. The HTML control panel uses this URL with
// a cache-busting query param every ~1s to drive live preview
// tiles. JPEG (not PNG) for ~10x smaller payload at the size
// we serve; quality 60 is plenty for a 192-wide thumbnail.
if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal)
&& path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal))
{
var idSegment = path.AsSpan("/participants/".Length,
path.Length - "/participants/".Length - "/thumbnail.jpg".Length).ToString();
if (!Guid.TryParse(idSegment, out var thumbId))
{
res.StatusCode = 400;
await WriteJsonAsync(res, new { error = "invalid id" });
return;
}
var jpeg = TryEncodeThumbnailJpeg(thumbId);
if (jpeg is null)
{
res.StatusCode = 404;
await WriteJsonAsync(res, new { error = "no frame", id = thumbId });
return;
}
res.ContentType = "image/jpeg";
res.AddHeader("Cache-Control", "no-store, must-revalidate");
res.ContentLength64 = jpeg.Length;
await res.OutputStream.WriteAsync(jpeg);
return;
}
object? response = (req.HttpMethod, path) switch
{
("GET", "" or "/") => GetServerInfo(),
@ -255,6 +286,12 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
// /recording routes removed alongside the rest of the recording surface.
// Topology — read the machine NDI config to report whether raw
// Teams NDI sources are hidden from the LAN, and let the
// operator apply / restore without leaving the web UI.
("GET", "/topology") => GetTopology(),
("POST", "/topology/apply") => await ApplyTopologyAsync(),
("POST", "/topology/restore") => await RestoreTopologyAsync(),
("POST", "/notes") => AppendNote(body, req.QueryString),
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
@ -399,6 +436,127 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
// 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)
{
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));
// Use WPF imaging — bundled with the WindowsDesktop SDK, no extra refs.
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);
var scaled = new System.Windows.Media.Imaging.TransformedBitmap(
source,
new System.Windows.Media.ScaleTransform(
(double)targetWidth / frame.Width,
(double)targetHeight / frame.Height));
using var ms = new System.IO.MemoryStream();
var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 };
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(scaled));
encoder.Save(ms);
return ms.ToArray();
}
catch
{
return null;
}
}
private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var text = TryGetString(body, query, "text");

View file

@ -129,6 +129,36 @@ public static class NdiAccessManagerConfig
private static IReadOnlyList<string>? AsStringList(JsonNode? node) =>
node is JsonArray arr ? arr.Select(n => n?.GetValue<string>() ?? string.Empty).ToArray() : null;
/// <summary>
/// One-call shape for the control surface's <c>GET /topology</c>: returns
/// the current sender + receiver group lists alongside a computed
/// <c>mode</c> string. "hidden" when senders are confined to the private
/// transcoder-input group (raw Teams sources invisible on the LAN);
/// "public" when senders are on the default group; "unknown" when the
/// config file is missing or malformed (treated by callers as "public"
/// because NDI's runtime defaults to public when no config is present).
/// </summary>
public static (string Mode, IReadOnlyList<string> Senders, IReadOnlyList<string> Receivers) ReadCurrent()
{
var (send, recv) = ReadCurrentGroups();
var senders = send ?? Array.Empty<string>();
var receivers = recv ?? Array.Empty<string>();
string mode;
if (send is null)
{
mode = "unknown";
}
else if (send.Count == 1 && string.Equals(send[0], TranscoderInputGroup, StringComparison.OrdinalIgnoreCase))
{
mode = "hidden";
}
else
{
mode = "public";
}
return (mode, senders, receivers);
}
private static JsonObject LoadOrCreate()
{
if (File.Exists(ConfigPath))

View file

@ -58,7 +58,12 @@ public static class UIPreferences
// from this on startup and persists back here on toggle. Default
// "System" matches DESIGN.md's "Follow Windows" choice — the
// operator who doesn't care gets whatever Windows is set to.
string Theme = "System");
string Theme = "System",
// REST + WebSocket control surface auto-start. When true, the
// server starts on app launch instead of waiting for the operator
// to click the toggle in settings each session. The desktop GUI's
// settings checkbox writes here and re-reads on launch.
bool ControlSurfaceEnabled = false);
/// <summary>Update just the Theme field without touching other prefs.</summary>
public static void SetTheme(string theme)

View file

@ -66,6 +66,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
// AutoRecordOnCall removed — recording surface axed.
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
_controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
@ -241,7 +242,11 @@ public sealed class GlobalSettingsViewModel : ObservableObject
/// failures don't surface to the operator (the in-memory state still
/// reflects their click for this session).
/// </summary>
private void PersistUiPrefs() =>
private void PersistUiPrefs()
{
// Theme isn't owned by this VM — read whatever ThemeManager has
// persisted (or default) so we don't clobber it on save.
var existing = UIPreferences.Load();
UIPreferences.Save(new UIPreferences.Prefs(
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
@ -250,7 +255,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
ControlSurfaceLanReachable: _controlSurfaceLanReachable,
LaunchTeamsOnStartup: _launchTeamsOnStartup,
AutoHideTeamsWindows: _autoHideTeamsWindows,
EmbedTeamsWindow: _embedTeamsWindow));
EmbedTeamsWindow: _embedTeamsWindow,
Theme: existing.Theme,
ControlSurfaceEnabled: _controlSurfaceEnabled));
}
/// <summary>
/// Auto-launch the Microsoft Teams desktop client when TeamsISO starts.
@ -315,6 +323,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
set
{
if (!SetField(ref _controlSurfaceEnabled, value)) return;
PersistUiPrefs();
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);

View file

@ -346,10 +346,18 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// Apply the operator's saved sort preference, if any.
ApplySortFromPrefs();
// Subscribe directly (no ObserveOn) and marshal to the UI thread inside
// the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
// SynchronizationContextScheduler) path captured SynchronizationContext
// .Current at subscribe time — fragile in WPF startup ordering, where
// the UI thread's SyncContext can be in a transitional state during
// App.OnStartup and the captured context never pumps subsequent
// OnNext calls. Direct subscribe + explicit dispatcher marshal is the
// pattern proven by Console.Program.cs (engine emits, consumer marshals).
_participantsSub = controller.Participants
.ObserveOn(new SynchronizationContextScheduler(
System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
.Subscribe(OnParticipantsChanged);
.Subscribe(snapshot => _dispatcher.InvokeAsync(
() => OnParticipantsChanged(snapshot),
DispatcherPriority.Background));
_alertsSub = controller.Alerts
.ObserveOn(new SynchronizationContextScheduler(