diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs index 7cdf4ab..d9763fb 100644 --- a/src/TeamsISO.App/App.xaml.cs +++ b/src/TeamsISO.App/App.xaml.cs @@ -187,6 +187,25 @@ public partial class App : Application () => _viewModel, _loggerFactory.CreateLogger()); + // 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().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 diff --git a/src/TeamsISO.App/Services/ControlPanelHtml.cs b/src/TeamsISO.App/Services/ControlPanelHtml.cs index 242ed1c..a7aa9a3 100644 --- a/src/TeamsISO.App/Services/ControlPanelHtml.cs +++ b/src/TeamsISO.App/Services/ControlPanelHtml.cs @@ -3,14 +3,17 @@ namespace TeamsISO.App.Services; /// /// The HTML / CSS / JS for the embedded control panel served at /// GET /ui. 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 /ws and posts to the -/// existing REST endpoints. +/// build step, no React. Phone-friendly remote that connects via WebSocket +/// to /ws 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. /// 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; } @@ -93,16 +120,31 @@ internal static class ControlPanelHtml +
+
+ +
+
Network topology
+ +
+
+
+
+ + +
+
+
+
- - +
@@ -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 = ""
No participants visible. Discover or invite into a Teams meeting.
""; + list.innerHTML = ""
No participants visible. Open Teams and join a meeting; sources will populate within seconds.
""; 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 = - ""
"" + - """" + - ""
"" + - ""
"" + - ""
"" + - ""
"" + - """" + - ""
""; + """" + + """" + + """" + + ""
"" + + ""
"" + + ""
"" + + ""
"" + + """"; + 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); diff --git a/src/TeamsISO.App/Services/ControlSurfaceServer.cs b/src/TeamsISO.App/Services/ControlSurfaceServer.cs index f746b87..658e938 100644 --- a/src/TeamsISO.App/Services/ControlSurfaceServer.cs +++ b/src/TeamsISO.App/Services/ControlSurfaceServer.cs @@ -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. + /// + /// 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). + /// + 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 }; + } + } + + /// + /// Apply the transcoder topology: machine senders → teamsiso-input, + /// receivers → public + teamsiso-input; 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. + /// + private async Task 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.", + }; + } + + /// + /// Restore the machine NDI defaults: senders + receivers both on + /// public. Engine groups go back to null/defaults too. Operator + /// must restart Teams for it to broadcast on public again. + /// + private async Task 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.", + }; + } + + /// + /// 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. + /// + 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"); diff --git a/src/TeamsISO.App/Services/NdiAccessManagerConfig.cs b/src/TeamsISO.App/Services/NdiAccessManagerConfig.cs index 151c5d0..62a3b65 100644 --- a/src/TeamsISO.App/Services/NdiAccessManagerConfig.cs +++ b/src/TeamsISO.App/Services/NdiAccessManagerConfig.cs @@ -129,6 +129,36 @@ public static class NdiAccessManagerConfig private static IReadOnlyList? AsStringList(JsonNode? node) => node is JsonArray arr ? arr.Select(n => n?.GetValue() ?? string.Empty).ToArray() : null; + /// + /// One-call shape for the control surface's GET /topology: returns + /// the current sender + receiver group lists alongside a computed + /// mode 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). + /// + public static (string Mode, IReadOnlyList Senders, IReadOnlyList Receivers) ReadCurrent() + { + var (send, recv) = ReadCurrentGroups(); + var senders = send ?? Array.Empty(); + var receivers = recv ?? Array.Empty(); + 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)) diff --git a/src/TeamsISO.App/Services/UIPreferences.cs b/src/TeamsISO.App/Services/UIPreferences.cs index e460d04..1571903 100644 --- a/src/TeamsISO.App/Services/UIPreferences.cs +++ b/src/TeamsISO.App/Services/UIPreferences.cs @@ -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); /// Update just the Theme field without touching other prefs. public static void SetTheme(string theme) diff --git a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs index 3cf981c..4439602 100644 --- a/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs +++ b/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs @@ -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). /// - 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)); + } /// /// 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);