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,
|
() => _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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue