feat(web): topology + thumbnail endpoints, redesigned /ui control panel
Some checks failed
CI / build-and-test (push) Failing after 30s
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:
parent
4944de5feb
commit
647deec304
6 changed files with 369 additions and 42 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue