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.
This commit is contained in:
Zac Gaetano 2026-05-15 15:06:11 -04:00
parent 4944de5feb
commit 647deec304
6 changed files with 369 additions and 42 deletions

View file

@ -187,6 +187,25 @@ public partial class App : Application
() => _viewModel, () => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>()); _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. // DiskSpaceWatcher removed alongside the rest of the recording surface.
// Tray icon host. Disabled by default; the settings VM flips // Tray icon host. Disabled by default; the settings VM flips

View file

@ -3,14 +3,17 @@ namespace TeamsISO.App.Services;
/// <summary> /// <summary>
/// The HTML / CSS / JS for the embedded control panel served at /// The HTML / CSS / JS for the embedded control panel served at
/// <c>GET /ui</c>. Single self-contained string — no external CDN deps, no /// <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 /// build step, no React. Phone-friendly remote that connects via WebSocket
/// remote that connects via WebSocket to <c>/ws</c> and posts to the /// to <c>/ws</c> and posts to the existing REST endpoints.
/// existing REST endpoints.
/// ///
/// Visual language matches the WPF host: dark canvas, cyan accent, mono /// v2 additions:
/// font for codey labels. Keeping the styling minimal so a future iteration /// - Live preview tiles per participant via GET /participants/{id}/thumbnail.jpg
/// can swap in a fancier UI without breaking operator workflows that already /// (engine encodes the latest ProcessedFrame as a 192-wide JPEG; refreshed
/// bookmark the URL. /// ~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> /// </summary>
internal static class ControlPanelHtml internal static class ControlPanelHtml
{ {
@ -26,23 +29,27 @@ internal static class ControlPanelHtml
--surface: #141414; --surface: #141414;
--surface-elev: #1c1c1c; --surface-elev: #1c1c1c;
--border: #262626; --border: #262626;
--border-strong: #3a3b40;
--text: #f5f5f5; --text: #f5f5f5;
--text-2: #a3a3a3; --text-2: #a3a3a3;
--text-3: #6b6b6b; --text-3: #6b6b6b;
--cyan: #97edf0; --cyan: #97edf0;
--cyan-mute: #1b3537; --cyan-mute: #1b3537;
--cyan-text: #97edf0;
--coral: #fb819c; --coral: #fb819c;
--coral-bg: #3a1922;
--green: #4ade80; --green: #4ade80;
--amber: #fbbf24;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); html, body { margin: 0; background: var(--bg); color: var(--text);
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
body { padding: 16px; max-width: 720px; margin: 0 auto; } body { padding: 16px; max-width: 920px; margin: 0 auto; }
h1 { h1 {
font-size: 13px; letter-spacing: 0.12em; font-weight: 600; font-size: 11px; letter-spacing: 0.12em; font-weight: 600;
text-transform: uppercase; color: var(--text-3); margin: 0 0 18px; text-transform: uppercase; color: var(--text-3); margin: 0 0 14px;
} }
.card { .card {
background: var(--surface); border: 1px solid var(--border); 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 { display: flex; align-items: center; gap: 10px; }
.row + .row { margin-top: 10px; } .row + .row { margin-top: 10px; }
.grow { flex: 1; } .grow { flex: 1; min-width: 0; }
button { button {
background: var(--surface-elev); color: var(--text); border: 1px solid var(--border); background: var(--surface-elev); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 14px; cursor: pointer; border-radius: 8px; padding: 10px 14px; cursor: pointer;
font: inherit; font-size: 13px; font: inherit; font-size: 13px;
transition: background 80ms ease; transition: background 80ms ease, border-color 80ms ease;
} }
button:hover { background: #242424; } button:hover { background: #242424; border-color: var(--border-strong); }
button.primary { background: var(--cyan-mute); color: var(--cyan); border-color: var(--cyan); } button.primary { background: var(--cyan); color: #042830; border-color: var(--cyan); font-weight: 500; }
button.danger { background: #3a1922; color: var(--coral); border-color: var(--coral); } button.primary:hover { background: #b5f2f4; }
button.live { background: var(--cyan-mute); color: var(--cyan); border-color: var(--cyan); } button.danger { background: transparent; color: var(--coral); border-color: var(--coral); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 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.cyan { background: var(--cyan); }
.dot.coral { background: var(--coral); } .dot.coral { background: var(--coral); }
.dot.green { background: var(--green); } .dot.green { background: var(--green); }
.dot.amber { background: var(--amber); }
.dot.gray { background: var(--text-3); } .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; .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 { .status {
display: flex; gap: 16px; align-items: center; flex-wrap: wrap; display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
font-family: 'JetBrains Mono', 'Cascadia Mono', monospace; font-family: 'JetBrains Mono', 'Cascadia Mono', monospace;
@ -76,11 +89,25 @@ internal static class ControlPanelHtml
} }
.status .ok { color: var(--green); } .status .ok { color: var(--green); }
.status .err { color: var(--coral); } .status .err { color: var(--coral); }
.empty { color: var(--text-3); font-size: 12px; padding: 16px; text-align: center; } .empty { color: var(--text-3); font-size: 12px; padding: 18px; text-align: center; }
details summary { cursor: pointer; color: var(--text-2); font-size: 12px; }
details summary::marker { color: var(--text-3); }
.global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } .global-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (min-width: 480px) { .global-actions { grid-template-columns: repeat(4, 1fr); } } @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> </style>
</head> </head>
<body> <body>
@ -93,16 +120,31 @@ internal static class ControlPanelHtml
</div> </div>
</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='card'>
<div class='global-actions'> <div class='global-actions'>
<button onclick='post(""/teams/mute"")'>Mute</button> <button onclick='post(""/teams/mute"")'>Mute</button>
<button onclick='post(""/teams/camera"")'>Camera</button> <button onclick='post(""/teams/camera"")'>Camera</button>
<button onclick='post(""/teams/share"")'>Share</button> <button onclick='post(""/teams/share"")'>Share</button>
<button onclick='post(""/teams/leave"")'>Leave</button> <button onclick='post(""/teams/leave"")'>Leave</button>
<button onclick='post(""/recording/marker"")'>Marker</button>
<button onclick='dropNote()'>Note</button> <button onclick='dropNote()'>Note</button>
<button onclick='post(""/presets/refresh-discovery"")'>Refresh</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>
</div> </div>
@ -113,6 +155,10 @@ const list = document.getElementById('participants');
const conn = document.getElementById('conn'); const conn = document.getElementById('conn');
const connText = document.getElementById('conn-text'); const connText = document.getElementById('conn-text');
const count = document.getElementById('count'); 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) { function setConn(state, text) {
conn.className = 'dot ' + state; conn.className = 'dot ' + state;
@ -123,8 +169,9 @@ async function post(path, body) {
try { try {
const opts = { method: 'POST' }; const opts = { method: 'POST' };
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
await fetch(path, opts); const r = await fetch(path, opts);
} catch (e) { console.warn(e); } return r.ok ? await r.json().catch(() => null) : null;
} catch (e) { console.warn(e); return null; }
} }
function dropNote() { function dropNote() {
@ -132,28 +179,83 @@ function dropNote() {
if (text && text.trim()) post('/notes', { text: text.trim() }); 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) { function render(participants) {
if (!participants || participants.length === 0) { 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 = ''; count.textContent = '';
return; return;
} }
const live = participants.filter(p => p.isEnabled).length; const live = participants.filter(p => p.isEnabled).length;
count.textContent = live + ' / ' + participants.length + ' live'; count.textContent = live + ' / ' + participants.length + ' live';
list.innerHTML = ''; list.innerHTML = '';
const card = document.createElement('div');
card.className = 'card';
for (const p of participants) { for (const p of participants) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'card'; row.className = 'participant-row';
const dotColor = p.isEnabled ? 'cyan' : (p.isOnline ? 'gray' : 'coral'); 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 = row.innerHTML =
""<div class='row'>"" + ""<span class='dot "" + stateColor + ""'></span>"" +
""<span class='dot "" + dotColor + ""'></span>"" + ""<img class='preview' alt='' onerror=\""this.style.display='none'; this.nextElementSibling.style.display='flex';\"" >"" +
""<div class='grow'>"" + ""<div class='preview empty' style='display:none;'></div>"" +
""<div class='name'></div>"" + ""<div class='grow'>"" +
""<div class='sub'></div>"" + ""<div class='name'></div>"" +
""</div>"" + ""<div class='sub'></div>"" +
""<button class='"" + (p.isEnabled ? 'live' : '') + ""'></button>"" + ""</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('.name').textContent = p.displayName;
row.querySelector('.sub').textContent = row.querySelector('.sub').textContent =
(p.stateLabel || (p.isOnline ? 'online' : 'offline')) + (p.stateLabel || (p.isOnline ? 'online' : 'offline')) +
@ -164,15 +266,16 @@ function render(participants) {
displayName: p.displayName, displayName: p.displayName,
enabled: !p.isEnabled, enabled: !p.isEnabled,
}); });
list.appendChild(row); card.appendChild(row);
} }
list.appendChild(card);
} }
function connect() { function connect() {
setConn('gray', 'connecting'); setConn('gray', 'connecting');
const ws = new WebSocket( const ws = new WebSocket(
(location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
ws.onopen = () => setConn('green', 'live'); ws.onopen = () => { setConn('green', 'live'); fetchTopology(); };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
try { try {
const m = JSON.parse(ev.data); const m = JSON.parse(ev.data);
@ -187,6 +290,9 @@ function connect() {
} }
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> </script>
</body> </body>
</html> </html>

View file

@ -243,6 +243,37 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
return; 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 object? response = (req.HttpMethod, path) switch
{ {
("GET", "" or "/") => GetServerInfo(), ("GET", "" or "/") => GetServerInfo(),
@ -255,6 +286,12 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"), ("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"), ("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
// /recording routes removed alongside the rest of the recording surface. // /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", "/notes") => AppendNote(body, req.QueryString),
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString), ("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) _ 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. // 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) private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{ {
var text = TryGetString(body, query, "text"); var text = TryGetString(body, query, "text");

View file

@ -129,6 +129,36 @@ public static class NdiAccessManagerConfig
private static IReadOnlyList<string>? AsStringList(JsonNode? node) => private static IReadOnlyList<string>? AsStringList(JsonNode? node) =>
node is JsonArray arr ? arr.Select(n => n?.GetValue<string>() ?? string.Empty).ToArray() : null; 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() private static JsonObject LoadOrCreate()
{ {
if (File.Exists(ConfigPath)) 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 // from this on startup and persists back here on toggle. Default
// "System" matches DESIGN.md's "Follow Windows" choice — the // "System" matches DESIGN.md's "Follow Windows" choice — the
// operator who doesn't care gets whatever Windows is set to. // 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> /// <summary>Update just the Theme field without touching other prefs.</summary>
public static void SetTheme(string theme) public static void SetTheme(string theme)

View file

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