diff --git a/TeamsISO.sln b/TeamsISO.sln
index 32c24bc..3093684 100644
--- a/TeamsISO.sln
+++ b/TeamsISO.sln
@@ -21,8 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\Tea
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.WinUI", "src\TeamsISO.App.WinUI\TeamsISO.App.WinUI.csproj", "{14928B5A-E45C-4265-A5D7-D13B5ED18F84}"
-EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -60,10 +58,6 @@ Global
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
- {14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Debug|Any CPU.ActiveCfg = Debug|x64
- {14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Debug|Any CPU.Build.0 = Debug|x64
- {14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Release|Any CPU.ActiveCfg = Release|x64
- {14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
@@ -74,6 +68,5 @@ Global
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
- {14928B5A-E45C-4265-A5D7-D13B5ED18F84} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
EndGlobalSection
EndGlobal
diff --git a/src/TeamsISO.App.WinUI/App.xaml b/src/TeamsISO.App.WinUI/App.xaml
deleted file mode 100644
index 09782b0..0000000
--- a/src/TeamsISO.App.WinUI/App.xaml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/App.xaml.cs b/src/TeamsISO.App.WinUI/App.xaml.cs
deleted file mode 100644
index 0827dcb..0000000
--- a/src/TeamsISO.App.WinUI/App.xaml.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
-using Microsoft.UI.Dispatching;
-using Microsoft.UI.Xaml;
-using TeamsISO.App.WinUI.ViewModels;
-using TeamsISO.App.WinUI.Views;
-using TeamsISO.Engine.Controller;
-using TeamsISO.Engine.Interop;
-using TeamsISO.Engine.Logging;
-using TeamsISO.Engine.NdiInterop;
-using TeamsISO.Engine.Persistence;
-using TeamsISO.Engine.Pipeline;
-
-namespace TeamsISO.App.WinUI;
-
-///
-/// WinUI 3 entry — brings up MainWindow and wires the engine pipeline.
-///
-public partial class App : Application
-{
- private Window? _mainWindow;
- private ILoggerFactory? _loggerFactory;
- private NdiInteropPInvoke? _interop;
- private IsoController? _controller;
- private MainViewModel? _viewModel;
-
- public App()
- {
- InitializeComponent();
- }
-
- internal Window? MainWindow => _mainWindow;
- internal MainViewModel? ViewModel => _viewModel;
-
- protected override void OnLaunched(LaunchActivatedEventArgs args)
- {
- _mainWindow = new MainWindow();
- _mainWindow.Activate();
-
- _ = WireEngineAsync();
- }
-
- private async Task WireEngineAsync()
- {
- try
- {
- _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information);
- var logger = _loggerFactory.CreateLogger();
- logger.LogInformation(
- "TeamsISO.App.WinUI starting. Build: {Version}. PID: {Pid}.",
- typeof(App).Assembly.GetName().Version,
- Environment.ProcessId);
-
- try
- {
- _interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger());
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "NDI runtime preflight failed");
- return;
- }
-
- var configPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "TeamsISO", "config.json");
- var configStore = new ConfigStore(configPath, _loggerFactory.CreateLogger());
-
- var probe = new NdiRuntimeProbe(_interop, NdiVersion.ExpectedRuntimeVersionPrefix);
- var scaler = new ManagedNearestNeighborFrameScaler();
-
- var loggerFactoryRef = _loggerFactory;
- var interopRef = _interop;
- IsoPipeline PipelineFactory(IsoPipelineConfig config)
- {
- var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz);
- return new IsoPipeline(
- config, interopRef, scaler, clock,
- ExponentialBackoff.Default,
- (delay, ct) => Task.Delay(delay, ct),
- loggerFactoryRef);
- }
-
- _controller = new IsoController(
- _interop, PipelineFactory, configStore, probe, _loggerFactory);
-
- var dispatcher = DispatcherQueue.GetForCurrentThread();
- _viewModel = new MainViewModel(_controller, dispatcher);
-
- if (_mainWindow is MainWindow mw)
- {
- mw.AttachViewModel(_viewModel);
- }
-
- await _viewModel.InitializeAsync(CancellationToken.None);
-
- logger.LogInformation("Engine wired. Discovery active. Awaiting Teams participants.");
- }
- catch (Exception ex)
- {
- System.Diagnostics.Debug.WriteLine($"Engine wiring failed: {ex}");
- }
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Assets/Fonts/Inter.ttf b/src/TeamsISO.App.WinUI/Assets/Fonts/Inter.ttf
deleted file mode 100644
index 1cb674b..0000000
Binary files a/src/TeamsISO.App.WinUI/Assets/Fonts/Inter.ttf and /dev/null differ
diff --git a/src/TeamsISO.App.WinUI/Assets/Fonts/JetBrainsMono.ttf b/src/TeamsISO.App.WinUI/Assets/Fonts/JetBrainsMono.ttf
deleted file mode 100644
index b60e77f..0000000
Binary files a/src/TeamsISO.App.WinUI/Assets/Fonts/JetBrainsMono.ttf and /dev/null differ
diff --git a/src/TeamsISO.App.WinUI/Assets/dragon-mark.png b/src/TeamsISO.App.WinUI/Assets/dragon-mark.png
deleted file mode 100644
index d78b7cf..0000000
Binary files a/src/TeamsISO.App.WinUI/Assets/dragon-mark.png and /dev/null differ
diff --git a/src/TeamsISO.App.WinUI/Assets/teamsiso.ico b/src/TeamsISO.App.WinUI/Assets/teamsiso.ico
deleted file mode 100644
index b7be1cf..0000000
Binary files a/src/TeamsISO.App.WinUI/Assets/teamsiso.ico and /dev/null differ
diff --git a/src/TeamsISO.App.WinUI/Assets/wild-dragon-wordmark.png b/src/TeamsISO.App.WinUI/Assets/wild-dragon-wordmark.png
deleted file mode 100644
index ed0804c..0000000
Binary files a/src/TeamsISO.App.WinUI/Assets/wild-dragon-wordmark.png and /dev/null differ
diff --git a/src/TeamsISO.App.WinUI/Models/MockParticipant.cs b/src/TeamsISO.App.WinUI/Models/MockParticipant.cs
deleted file mode 100644
index 88c8e06..0000000
--- a/src/TeamsISO.App.WinUI/Models/MockParticipant.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using System.Collections.Generic;
-
-namespace TeamsISO.App.WinUI.Models;
-
-///
-/// Stand-in for the real ParticipantViewModel until the view-model bindings
-/// migrate over from the WPF host. Lets the redesigned MainWindow render with
-/// representative data so the visual design can be validated independently
-/// of the engine layer. Removed in the view-model wiring commit.
-///
-public sealed class MockParticipant
-{
- public string DisplayName { get; init; } = "";
- public string Initials { get; init; } = "";
- public string SourceCodec { get; init; } = "MS Teams · 1920x1080 · 30fps";
- public string SignalState { get; init; } = "locked"; // locked | degraded | offline
- public string OutputName { get; init; } = "";
- public string IsoState { get; init; } = "OFF"; // LIVE | OFF | ERROR
- public double AudioLevel { get; init; } = 0.0; // 0..1
- public bool IsActiveSpeaker { get; init; } = false;
-
- public static List Sample()
- {
- return new List
- {
- new()
- {
- DisplayName = "Maya Rodriguez", Initials = "MA",
- SourceCodec = "MS Teams · 1920x1080 · 30fps",
- SignalState = "locked", OutputName = "TEAMSISO_maya",
- IsoState = "LIVE", AudioLevel = 0.82, IsActiveSpeaker = true,
- },
- new()
- {
- DisplayName = "Daniel Chen", Initials = "DC",
- SourceCodec = "MS Teams · 1280x720 · 30fps",
- SignalState = "locked", OutputName = "TEAMSISO_daniel",
- IsoState = "LIVE", AudioLevel = 0.35,
- },
- new()
- {
- DisplayName = "Aicha Kone", Initials = "AK",
- SourceCodec = "MS Teams · 1920x1080 · 30fps",
- SignalState = "degraded", OutputName = "TEAMSISO_aicha",
- IsoState = "OFF", AudioLevel = 0.12,
- },
- new()
- {
- DisplayName = "Sam Park", Initials = "SP",
- SourceCodec = "MS Teams · 1920x1080 · 30fps",
- SignalState = "locked", OutputName = "TEAMSISO_sam",
- IsoState = "LIVE", AudioLevel = 0.48,
- },
- };
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Program.cs b/src/TeamsISO.App.WinUI/Program.cs
deleted file mode 100644
index 36087bd..0000000
--- a/src/TeamsISO.App.WinUI/Program.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System;
-using Microsoft.UI.Dispatching;
-using Microsoft.UI.Xaml;
-using Microsoft.Windows.ApplicationModel.DynamicDependency;
-
-namespace TeamsISO.App.WinUI;
-
-///
-/// Custom Main for the unpackaged WinUI 3 host.
-///
-/// The default XAML-compiler-generated Main (disabled here via the
-/// DISABLE_XAML_GENERATED_MAIN compile constant) calls Application.Start
-/// directly. That's fine for MSIX-packaged WinUI 3 apps where the OS
-/// activates the package and the runtime is found via the package
-/// dependency, but for an unpackaged .exe the Windows App Runtime has to
-/// be bootstrapped explicitly before any WinUI 3 type is touched.
-///
-/// Bootstrap.TryInitialize(0x00010006) targets WindowsAppSDK 1.6 (the LTS
-/// branch we ship against). The major nibble 0x0001 is the runtime major;
-/// the minor 0x0006 is the runtime minor. If the user's machine has a
-/// compatible 1.6.x framework installed (the broadcast-tool installer
-/// will eventually ensure that as a prereq), Bootstrap connects and the
-/// rest of the WinUI 3 surface comes alive. If not, the call returns a
-/// negative HResult that we surface via Environment.Exit so the .exe
-/// dies cleanly rather than throwing an opaque "this application could
-/// not be started" dialog.
-///
-public static class Program
-{
- /// WindowsAppSDK 1.8 major/minor packed as 0x00010008.
- private const uint WindowsAppSdkMajorMinor = 0x00010008;
-
- [STAThread]
- public static int Main(string[] args)
- {
- if (!Bootstrap.TryInitialize(WindowsAppSdkMajorMinor, out int hr))
- {
- // The runtime isn't installed (or this build's bootstrap can't
- // find a compatible package version). Surface a Win32 error code
- // so postmortem inspection of the launch failure has more than
- // "application could not be started" to go on.
- return hr;
- }
-
- try
- {
- WinRT.ComWrappersSupport.InitializeComWrappers();
- Application.Start(p =>
- {
- var context = new DispatcherQueueSynchronizationContext(
- DispatcherQueue.GetForCurrentThread());
- System.Threading.SynchronizationContext.SetSynchronizationContext(context);
- _ = new App();
- });
- }
- finally
- {
- Bootstrap.Shutdown();
- }
-
- return 0;
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs b/src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs
deleted file mode 100644
index dffa072..0000000
--- a/src/TeamsISO.App.WinUI/Services/TeamsControlBridge.cs
+++ /dev/null
@@ -1,398 +0,0 @@
-using System.Diagnostics;
-using System.Windows.Automation;
-
-namespace TeamsISO.App.WinUI.Services;
-
-///
-/// Phase E.3 — UIAutomation bridge for the in-call controls (mute, camera,
-/// leave, share screen). Walks Teams' automation tree to locate the relevant
-/// buttons and invokes their or .
-///
-/// This is intentionally tolerant of Teams' UI volatility: we search by a
-/// chain of (AutomationId, Name, LocalizedControlType) candidates rather than
-/// pinning to a single identifier. When Teams ships a new build that renames a
-/// button, the operator gets a clear "control not found" toast rather than a
-/// crash, and we add the new identifier to the candidate list.
-///
-/// Limitations:
-/// - Requires Teams' main window to be present (not minimized to the system tray
-/// in a way that detaches its automation peers; minimized to taskbar is fine).
-/// - Some Teams builds host the call UI in a separate WebView2-backed top-level
-/// window; we enumerate every top-level window owned by every Teams process,
-/// so we'll find it wherever it lives.
-/// - Hidden windows (after ) are still
-/// traversable by UIAutomation — the buttons exist in the automation tree
-/// even when their HWND is SW_HIDDEN. This is what makes the "hide Teams,
-/// drive it from TeamsISO" workflow viable.
-///
-public static class TeamsControlBridge
-{
- // ────────────────────────────────────────────────────────────────────
- // Localized candidate-name lists.
- //
- // Teams localizes the AutomationElement.Name we match against. The lookup
- // strategy is: ALL candidate strings across all locales are tried for each
- // command, and the first match wins. This gives us a single binary that
- // works regardless of the Teams UI language without needing to detect it
- // — at the cost of a slightly broader match surface (a non-mute button
- // with the German word "Stumm" in its name would false-positive). In
- // practice Teams' button Names are highly distinctive and we haven't seen
- // false positives during development.
- //
- // Adding a locale: append the localized strings to each command's array.
- // Order doesn't matter for correctness; for performance we put the most
- // common locales first since the array is iterated in order.
- // ────────────────────────────────────────────────────────────────────
-
- private static readonly string[] MuteCandidates =
- {
- // English (US/UK)
- "Mute", "Unmute", "Microphone", "Toggle mute",
- // German
- "Stumm", "Stummschaltung", "Stummschalten", "Stummschaltung aufheben", "Mikrofon",
- // Spanish
- "Silenciar", "Activar audio", "Micrófono",
- // French
- "Désactiver le micro", "Activer le micro", "Micro", "Microphone",
- // Portuguese
- "Desativar áudio", "Ativar áudio", "Microfone",
- // Japanese
- "ミュート", "ミュート解除", "マイク",
- };
-
- private static readonly string[] CameraCandidates =
- {
- "Camera", "Turn camera on", "Turn camera off", "Video",
- // German
- "Kamera", "Kamera einschalten", "Kamera ausschalten", "Video",
- // Spanish
- "Cámara", "Activar cámara", "Desactivar cámara", "Vídeo",
- // French
- "Caméra", "Activer la caméra", "Désactiver la caméra", "Vidéo",
- // Portuguese
- "Câmera", "Ativar câmera", "Desativar câmera",
- // Japanese
- "カメラ", "ビデオ",
- };
-
- private static readonly string[] LeaveCandidates =
- {
- "Leave", "Hang up", "End call", "Leave call",
- // German
- "Verlassen", "Auflegen", "Anruf beenden",
- // Spanish
- "Salir", "Colgar", "Finalizar llamada",
- // French
- "Quitter", "Raccrocher", "Terminer l'appel",
- // Portuguese
- "Sair", "Desligar", "Encerrar chamada",
- // Japanese
- "退出", "通話を終了",
- };
-
- private static readonly string[] ShareCandidates =
- {
- "Share", "Share content", "Share screen", "Open share tray",
- // German
- "Teilen", "Inhalt teilen", "Bildschirm teilen",
- // Spanish
- "Compartir", "Compartir contenido", "Compartir pantalla",
- // French
- "Partager", "Partager du contenu", "Partager l'écran",
- // Portuguese
- "Compartilhar", "Compartilhar conteúdo", "Compartilhar tela",
- // Japanese
- "共有", "コンテンツの共有", "画面を共有",
- };
-
- private static readonly string[] RaiseHandCandidates =
- {
- "Raise", "Raise hand", "Lower hand",
- // German
- "Hand heben", "Hand senken",
- // Spanish
- "Levantar la mano", "Bajar la mano",
- // French
- "Lever la main", "Baisser la main",
- // Portuguese
- "Levantar a mão", "Abaixar a mão",
- // Japanese
- "手を挙げる", "手を下ろす",
- };
-
- private static readonly string[] ToggleChatCandidates =
- {
- "Show conversation", "Hide conversation", "Chat", "Show chat",
- // German
- "Unterhaltung anzeigen", "Unterhaltung ausblenden", "Chat",
- // Spanish
- "Mostrar conversación", "Ocultar conversación", "Chat",
- // French
- "Afficher la conversation", "Masquer la conversation", "Conversation",
- // Portuguese
- "Mostrar conversa", "Ocultar conversa", "Chat",
- // Japanese
- "会話を表示", "会話を非表示", "チャット",
- };
-
- private static readonly string[] BackgroundBlurCandidates =
- {
- "Background effects", "Apply background effects", "Background filters",
- // German
- "Hintergrundeffekte", "Hintergrundfilter",
- // Spanish
- "Efectos de fondo", "Filtros de fondo",
- // French
- "Effets d'arrière-plan", "Filtres d'arrière-plan",
- // Portuguese
- "Efeitos de plano de fundo", "Filtros de plano de fundo",
- // Japanese
- "背景効果", "背景フィルター",
- };
-
- /// Result of attempting one of the in-call commands.
- public enum InvokeResult
- {
- /// The control was found and invoked successfully.
- Invoked,
- /// Teams isn't running, or its automation root couldn't be located.
- TeamsNotRunning,
- /// Teams is running but the matching button isn't currently exposed (maybe not in a call).
- ControlNotFound,
- /// The button was found but didn't expose a usable invoke / toggle pattern.
- InvokeFailed,
- }
-
- public static InvokeResult ToggleMute() => InvokeFirstMatch(MuteCandidates);
- public static InvokeResult ToggleCamera() => InvokeFirstMatch(CameraCandidates);
- public static InvokeResult LeaveCall() => InvokeFirstMatch(LeaveCandidates);
- public static InvokeResult OpenShareTray() => InvokeFirstMatch(ShareCandidates);
- public static InvokeResult ToggleRaiseHand() => InvokeFirstMatch(RaiseHandCandidates);
- public static InvokeResult ToggleChat() => InvokeFirstMatch(ToggleChatCandidates);
- public static InvokeResult OpenBackgroundEffects() => InvokeFirstMatch(BackgroundBlurCandidates);
-
- ///
- /// Snapshot of the current call's local-user state. Read via a single
- /// UIA traversal in ; null sub-fields when
- /// the call isn't active or the button isn't in the tree.
- ///
- public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
-
- ///
- /// One-shot UIA probe of Teams' in-call controls. The Mute and Camera
- /// buttons toggle their Name between "Mute"/"Unmute" and "Turn camera
- /// on"/"Turn camera off" depending on state, so reading the Name tells
- /// us whether the operator is currently muted / camera-off.
- ///
- /// Returns IsInCall=false if Teams isn't running or no Leave button
- /// exists. Returns IsMuted/IsCameraOff as null if those buttons aren't
- /// found in this build (defensive — Teams sometimes uses different
- /// candidate names across locales).
- ///
- public static CallStateSnapshot DetectCallState()
- {
- var roots = GetTeamsAutomationRoots();
- if (roots.Count == 0) return new CallStateSnapshot(false, null, null);
-
- var inCall = false;
- bool? muted = null;
- bool? camOff = null;
-
- foreach (var root in roots)
- {
- AutomationElementCollection allButtons;
- try
- {
- allButtons = root.FindAll(
- TreeScope.Descendants,
- new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
- }
- catch { continue; }
-
- foreach (AutomationElement btn in allButtons)
- {
- var name = SafeGetName(btn);
- if (string.IsNullOrEmpty(name)) continue;
- var lower = name.ToLowerInvariant();
-
- if (!inCall && LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
- inCall = true;
-
- // Mute button: name is "Mute" when active-can-mute, "Unmute"
- // when currently muted. Detect by checking for "unmute" first
- // (more specific) before falling to "mute" (more general).
- if (muted is null)
- {
- if (lower.Contains("unmute") || lower.Contains("stummschaltung aufheben") ||
- lower.Contains("activar audio") || lower.Contains("activer le micro") ||
- lower.Contains("ativar áudio") || lower.Contains("ミュート解除"))
- muted = true;
- else if (lower.Contains("mute") || lower.Contains("stummschalten") ||
- lower.Contains("silenciar") || lower.Contains("désactiver le micro") ||
- lower.Contains("desativar áudio") || lower.Contains("ミュート"))
- muted = false;
- }
-
- // Camera button: name is "Turn camera off" when on, "Turn
- // camera on" when off.
- if (camOff is null)
- {
- if (lower.Contains("turn camera on") || lower.Contains("kamera einschalten") ||
- lower.Contains("activar cámara") || lower.Contains("activer la caméra") ||
- lower.Contains("ativar câmera"))
- camOff = true;
- else if (lower.Contains("turn camera off") || lower.Contains("kamera ausschalten") ||
- lower.Contains("desactivar cámara") || lower.Contains("désactiver la caméra") ||
- lower.Contains("desativar câmera"))
- camOff = false;
- }
- }
- }
- return new CallStateSnapshot(inCall, muted, camOff);
- }
-
- ///
- /// True if Teams is currently in an active call. The Leave / Hang-up
- /// button only exists in the automation tree when a call is in progress,
- /// so its presence is a reliable in-call signal across Teams versions.
- /// Returns false if Teams isn't running, isn't in a call, or the call
- /// UI is in a state we don't recognize.
- ///
- /// This is the "tell me what Teams is doing without me having to look
- /// at it" probe — operators using auto-hide Teams want a status pill
- /// that says "In call · ready" without having to restore the Teams
- /// window. Safe to call from any thread (UIA traversal is thread-safe);
- /// not free (walks the descendant tree) so callers should poll at most
- /// a few times per second.
- ///
- public static bool IsInCall()
- {
- var roots = GetTeamsAutomationRoots();
- if (roots.Count == 0) return false;
-
- foreach (var root in roots)
- {
- AutomationElementCollection allButtons;
- try
- {
- allButtons = root.FindAll(
- TreeScope.Descendants,
- new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
- }
- catch
- {
- // Window died mid-traversal; try the next root.
- continue;
- }
-
- foreach (AutomationElement btn in allButtons)
- {
- var name = SafeGetName(btn);
- if (string.IsNullOrEmpty(name)) continue;
- if (LeaveCandidates.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
- return true;
- }
- }
- return false;
- }
-
- private static InvokeResult InvokeFirstMatch(IReadOnlyList candidateNames)
- {
- var roots = GetTeamsAutomationRoots();
- if (roots.Count == 0) return InvokeResult.TeamsNotRunning;
-
- foreach (var root in roots)
- {
- // Search by Name first (most common case for Teams). Use a NameProperty
- // contains-style match by collecting all Buttons in the subtree and then
- // filtering manually — Condition only supports equality, and Teams'
- // labels can include trailing state ("(unmuted)") that breaks equality.
- var allButtons = root.FindAll(
- TreeScope.Descendants,
- new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
-
- foreach (AutomationElement btn in allButtons)
- {
- var name = SafeGetName(btn);
- if (string.IsNullOrEmpty(name)) continue;
- if (!candidateNames.Any(c => name.Contains(c, StringComparison.OrdinalIgnoreCase)))
- continue;
-
- if (TryInvoke(btn)) return InvokeResult.Invoked;
- }
- }
- return InvokeResult.ControlNotFound;
- }
-
- ///
- /// Returns the AutomationElement root for every top-level window owned by
- /// any running Teams process. Multiple roots is the normal case for new
- /// MSTeams (which uses one window per call/chat).
- ///
- private static List GetTeamsAutomationRoots()
- {
- var teamsPids = new HashSet(
- Process.GetProcessesByName("ms-teams")
- .Concat(Process.GetProcessesByName("msteams"))
- .Concat(Process.GetProcessesByName("Teams"))
- .Select(p => { try { return p.Id; } finally { p.Dispose(); } }));
-
- if (teamsPids.Count == 0) return new List();
-
- // Filter the desktop's children to windows whose ProcessId matches.
- var desktop = AutomationElement.RootElement;
- var allWindows = desktop.FindAll(
- TreeScope.Children,
- new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
-
- var roots = new List();
- foreach (AutomationElement w in allWindows)
- {
- try
- {
- var pid = (int)w.Current.ProcessId;
- if (teamsPids.Contains(pid)) roots.Add(w);
- }
- catch
- {
- // Window died between enumeration and property read; skip.
- }
- }
- return roots;
- }
-
- private static string SafeGetName(AutomationElement el)
- {
- try { return el.Current.Name ?? string.Empty; }
- catch { return string.Empty; }
- }
-
- ///
- /// Try Invoke first (most buttons), then Toggle (mute/camera are usually
- /// toggle-pattern). Returns true if either succeeded.
- ///
- private static bool TryInvoke(AutomationElement el)
- {
- try
- {
- if (el.TryGetCurrentPattern(InvokePattern.Pattern, out var invoke))
- {
- ((InvokePattern)invoke).Invoke();
- return true;
- }
- if (el.TryGetCurrentPattern(TogglePattern.Pattern, out var toggle))
- {
- ((TogglePattern)toggle).Toggle();
- return true;
- }
- }
- catch
- {
- // ElementNotEnabledException, ElementNotAvailableException — Teams
- // disabled the button mid-traversal (e.g. mute is disabled before
- // joining a call). Treat as "found but couldn't invoke" so the
- // caller can surface a useful message.
- }
- return false;
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs b/src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs
deleted file mode 100644
index 9bc7142..0000000
--- a/src/TeamsISO.App.WinUI/Services/TeamsLauncher.cs
+++ /dev/null
@@ -1,665 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using System.Runtime.InteropServices;
-
-namespace TeamsISO.App.WinUI.Services;
-
-///
-/// Small Win32 wrapper that launches the Microsoft Teams desktop client as a
-/// subprocess of TeamsISO. First step toward Phase E.1 of the embedded-Teams
-/// roadmap (see docs/superpowers/specs/2026-05-08-embedded-teams-orchestration.md):
-/// the operator can launch Teams from within TeamsISO so they don't have to
-/// switch apps to start a meeting.
-///
-/// The launcher tries (in order):
-/// 1. ms-teams: URI (works for both classic and new Teams)
-/// 2. MSTeams.exe in %LOCALAPPDATA%\Microsoft\WindowsApps\
-/// 3. Teams.exe in %LOCALAPPDATA%\Microsoft\Teams\Update.exe (legacy)
-///
-/// Group-routing automation (writing NDI Access Manager config so Teams
-/// broadcasts on a private group) is deferred to a follow-up — for v1.0 we
-/// document the manual steps in RELEASING.md and trust the operator to set
-/// them once per machine.
-///
-public static class TeamsLauncher
-{
- ///
- /// Heuristic process-name candidates we'll consider as "the Teams process" when
- /// the rail toggle wants to find a running instance. New MSTeams comes first.
- ///
- private static readonly string[] TeamsProcessNames =
- {
- "ms-teams", // new MSTeams binary basename
- "msteams", // alternate basename observed on some installs
- "Teams", // classic Teams desktop client
- };
-
- ///
- /// True if any process matching the known Teams binary basenames is running.
- /// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams".
- ///
- public static bool IsRunning() =>
- TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
-
- ///
- /// Launches Teams. Returns true if a launch was started successfully (the
- /// process may take a few seconds to actually appear). False if every
- /// fallback path failed; includes the
- /// reasons each attempt was rejected so the operator can see why.
- ///
- /// Path order matters:
- /// 1. ms-teams: URI — new Teams (MSTeams AppX) registers this
- /// handler at install. Activates through the AppX shell so the
- /// stub ms-teams.exe in WindowsApps gets the right context.
- /// 2. AppsFolder shell verb — direct AppX activation. Belt-and-braces
- /// fallback if a misconfigured registry breaks the URI handler.
- /// 3. Classic Teams Update.exe — pre-2024 Teams installations.
- /// We deliberately DON'T try the bare ms-teams.exe WindowsApps
- /// path: it's a 0-byte AppX placeholder that fails silently when invoked
- /// without AppX activation context. Looked plausible, never worked.
- ///
- public static bool TryLaunch(out string? errorMessage)
- {
- errorMessage = null;
- var attempts = new List();
-
- // Path 1: URI scheme. The shell handler picks the registered Teams
- // (new MSTeams takes priority on modern Windows). UseShellExecute=true
- // is required — Win32 Process creation can't open URIs directly.
- if (TryStart("ms-teams:", useShell: true, out var err1)) return true;
- attempts.Add($"ms-teams: URI → {err1}");
-
- // Path 2: AppX activation via the explorer.exe shell. Modern Teams
- // ships as MSTeams_8wekyb3d8bbwe; if other code on the box has
- // clobbered the URI registration, this still works because it goes
- // through the AppsFolder verb the OS itself uses for Start menu launches.
- if (TryStart("explorer.exe", false, out var err2,
- arguments: "shell:appsFolder\\MSTeams_8wekyb3d8bbwe!MSTeams"))
- return true;
- attempts.Add($"AppsFolder shell → {err2}");
-
- // Path 3: classic Teams Update.exe with --processStart hands off to
- // the actual Teams.exe via Squirrel.
- var classicUpdater = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "Microsoft", "Teams", "Update.exe");
- if (File.Exists(classicUpdater))
- {
- try
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = classicUpdater,
- Arguments = "--processStart \"Teams.exe\"",
- UseShellExecute = false,
- CreateNoWindow = true,
- });
- return true;
- }
- catch (Exception ex)
- {
- attempts.Add($"classic Update.exe → {ex.Message}");
- }
- }
- else
- {
- attempts.Add($"classic Update.exe → not found at {classicUpdater}");
- }
-
- errorMessage = "No Microsoft Teams installation could be launched. " +
- "Install Teams from https://www.microsoft.com/microsoft-teams and try again.\n\n" +
- "Attempts:\n • " + string.Join("\n • ", attempts);
- return false;
- }
-
- ///
- /// Asks every running Teams process to close gracefully via WM_CLOSE
- /// (CloseMainWindow). Returns the count of processes that exited cleanly within
- /// . Stragglers are NOT force-killed — Teams' own
- /// "are you sure" prompt may legitimately keep a process alive briefly, and we
- /// don't want to nuke the user's call mid-transition.
- ///
- public static int StopAll(TimeSpan? gracePeriod = null)
- {
- var grace = gracePeriod ?? TimeSpan.FromSeconds(3);
- var deadline = DateTime.UtcNow + grace;
- var asked = 0;
- foreach (var name in TeamsProcessNames)
- {
- foreach (var p in Process.GetProcessesByName(name))
- {
- try
- {
- if (p.HasExited) { p.Dispose(); continue; }
- if (p.MainWindowHandle != IntPtr.Zero)
- {
- p.CloseMainWindow();
- asked++;
- }
- }
- catch { /* defensive: process may have died between enumeration and signal */ }
- finally { p.Dispose(); }
- }
- }
- // Best-effort wait so the rail can flip its icon promptly.
- while (DateTime.UtcNow < deadline && IsRunning())
- Thread.Sleep(150);
- return asked;
- }
-
- ///
- /// Hand a meeting URL off to the Teams shell handler. Accepts both the
- /// https://teams.microsoft.com/l/meetup-join/... web format and
- /// the msteams:/l/meetup-join/... deep-link form (either causes
- /// Teams to launch + join the meeting in one shot — the OS shell maps
- /// teams.microsoft.com URLs to the registered ms-teams: handler).
- ///
- /// Use case: operator pastes a meeting link they got over email / chat
- /// into TeamsISO's quick-join field instead of opening Teams,
- /// hunting down the calendar entry, and clicking Join. With auto-hide
- /// on, the Teams window flashes briefly then disappears; the operator
- /// is now in the meeting, driving routing from TeamsISO.
- ///
- /// Returns true if the shell accepted the URL; false if URL is malformed
- /// or rejected. errorMessage populated on failure.
- ///
- public static bool TryJoinMeeting(string url, out string? errorMessage)
- {
- errorMessage = null;
- if (string.IsNullOrWhiteSpace(url))
- {
- errorMessage = "URL is empty.";
- return false;
- }
-
- var trimmed = url.Trim();
-
- // Defensive sanity-check: only accept URLs that obviously target
- // Teams. We don't want to invoke arbitrary shell handlers from a
- // clipboard paste — if someone pastes "calc.exe" into the input we
- // shouldn't launch it. Specifically: http(s) URLs must contain
- // "teams.microsoft.com" or "teams.live.com"; otherwise must start
- // with "msteams:".
- var lower = trimmed.ToLowerInvariant();
- var looksLikeTeams =
- lower.StartsWith("msteams:") ||
- (lower.StartsWith("http://") || lower.StartsWith("https://")) &&
- (lower.Contains("teams.microsoft.com") || lower.Contains("teams.live.com"));
- if (!looksLikeTeams)
- {
- errorMessage = "Not a Microsoft Teams meeting URL. " +
- "Expected a https://teams.microsoft.com/l/meetup-join/... " +
- "or msteams:/l/meetup-join/... link.";
- return false;
- }
-
- if (TryStart(trimmed, useShell: true, out var err))
- return true;
- errorMessage = err;
- return false;
- }
-
- private static bool TryStart(string target, bool useShell, out string error, string? arguments = null)
- {
- error = string.Empty;
- try
- {
- var info = new ProcessStartInfo
- {
- FileName = target,
- UseShellExecute = useShell,
- CreateNoWindow = true,
- };
- if (arguments is not null) info.Arguments = arguments;
- Process.Start(info);
- return true;
- }
- catch (Exception ex)
- {
- error = ex.Message;
- return false;
- }
- }
-
- // ════════════════════════════════════════════════════════════════════════
- // Phase E.2 — window orchestration
- //
- // Once Teams is running, we want to be able to hide its main window so the
- // operator only sees TeamsISO. We do this by enumerating top-level windows,
- // matching against each Teams process Id, and ShowWindow(SW_HIDE)-ing each
- // match. To restore we ShowWindow(SW_SHOW) and SetForegroundWindow.
- //
- // We deliberately don't use the Process.MainWindowHandle convenience because
- // new MSTeams (WebView2-hosted) creates several top-level windows per
- // process and Process picks an inconsistent one across launches; iterating
- // via EnumWindows + GetWindowThreadProcessId catches every visible window
- // owned by the process.
- // ════════════════════════════════════════════════════════════════════════
-
- private const int SW_HIDE = 0;
- private const int SW_SHOWNORMAL = 1;
- private const int SW_SHOW = 5;
- private const int SW_RESTORE = 9;
-
- private const uint GW_OWNER = 4;
-
- [DllImport("user32.dll")]
- private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
-
- [DllImport("user32.dll", SetLastError = true)]
- private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool IsWindowVisible(IntPtr hWnd);
-
- [DllImport("user32.dll")]
- private static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool SetForegroundWindow(IntPtr hWnd);
-
- [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount);
-
- [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern int GetWindowTextLengthW(IntPtr hWnd);
-
- private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
-
- // ────────────────────────────────────────────────────────────────────
- // Phase E.4 — Embedded Teams via SetParent.
- //
- // Reparents Teams' main top-level window into a TeamsISO-owned host
- // (typically a Border element's HWND). The Win32 behavior is well
- // understood for classic Win32 apps but modern Teams runs WebView2 in
- // its main window; WebView2's renderer is sensitive to parent changes
- // and may flash white frames during reparent, drop input focus, or
- // refuse to redraw until forced.
- //
- // We mark the feature experimental and provide a clean restore path
- // (SetParent back to desktop + restore the original window styles)
- // so operators can fall back to auto-hide mode if embedding misbehaves
- // on their specific Teams build.
- // ────────────────────────────────────────────────────────────────────
-
- [DllImport("user32.dll", SetLastError = true)]
- private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent);
-
- [DllImport("user32.dll", SetLastError = true)]
- private static extern int GetWindowLongPtr(IntPtr hWnd, int nIndex);
-
- [DllImport("user32.dll", SetLastError = true)]
- private static extern int SetWindowLongPtr(IntPtr hWnd, int nIndex, int dwNewLong);
-
- [DllImport("user32.dll", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, [MarshalAs(UnmanagedType.Bool)] bool bRepaint);
-
- [DllImport("user32.dll", SetLastError = true)]
- private static extern IntPtr GetDesktopWindow();
-
- [DllImport("user32.dll", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
-
- private const int GWL_STYLE = -16;
- private const long WS_CHILD = 0x40000000;
- private const long WS_POPUP = unchecked((long)0x80000000);
- private const long WS_CAPTION = 0x00C00000;
- private const long WS_THICKFRAME = 0x00040000;
- private const long WS_BORDER = 0x00800000;
- private const long WS_DLGFRAME = 0x00400000;
- private const uint SWP_FRAMECHANGED = 0x0020;
- private const uint SWP_NOMOVE = 0x0002;
- private const uint SWP_NOSIZE = 0x0001;
- private const uint SWP_NOZORDER = 0x0004;
- private const uint SWP_NOACTIVATE = 0x0010;
-
- ///
- /// Captures the original parent + window style so embedding can be
- /// reversed cleanly. Tracked per-HWND so multiple consecutive
- /// embed/unembed cycles don't lose the original chrome.
- ///
- private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
- private static IntPtr _embeddedHwnd = IntPtr.Zero;
-
- /// True when a Teams window is currently parented inside a TeamsISO host.
- public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
-
- ///
- /// Reparents Teams' most-recently-used top-level window into
- /// . Strips Teams' caption + thick frame so
- /// it integrates flush with the host. Returns true on success, false
- /// if no Teams window could be found.
- ///
- /// The host HWND is typically obtained via:
- /// var src = (System.Windows.Interop.HwndSource)
- /// PresentationSource.FromVisual(MyHostBorder);
- /// src.Handle // → IntPtr suitable for hostHwnd
- ///
- public static bool EmbedTeamsInto(IntPtr hostHwnd, int width, int height)
- {
- if (hostHwnd == IntPtr.Zero) return false;
- var teamsWindows = FindTeamsTopLevelWindows();
- if (teamsWindows.Count == 0) return false;
-
- // Pick the longest-title window as the "main" one — same heuristic
- // GetActiveWindowTitle uses; matches the call/meeting window.
- IntPtr best = IntPtr.Zero;
- int bestLen = -1;
- foreach (var w in teamsWindows)
- {
- var len = GetWindowTextLengthW(w);
- if (len > bestLen) { bestLen = len; best = w; }
- }
- if (best == IntPtr.Zero) return false;
-
- // Already embedded? Unembed first to clean state.
- if (_embeddedHwnd != IntPtr.Zero) RestoreEmbed();
-
- // Save original style + parent so we can fully reverse later.
- var originalStyle = GetWindowLongPtr(best, GWL_STYLE);
- var originalParent = SetParent(best, hostHwnd); // returns old parent
-
- _embedSavedState = (originalParent, originalStyle);
- _embeddedHwnd = best;
-
- // Strip top-level decorations + add WS_CHILD so the OS treats it
- // as a child window of the host.
- var newStyle = originalStyle;
- unchecked
- {
- newStyle &= ~(int)WS_CAPTION;
- newStyle &= ~(int)WS_THICKFRAME;
- newStyle &= ~(int)WS_BORDER;
- newStyle &= ~(int)WS_DLGFRAME;
- newStyle &= ~(int)WS_POPUP;
- newStyle |= (int)WS_CHILD;
- }
- SetWindowLongPtr(best, GWL_STYLE, newStyle);
-
- // Force a non-client recalculation so the style change takes effect.
- SetWindowPos(best, IntPtr.Zero, 0, 0, 0, 0,
- SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
-
- // Place at top-left of host, full host size.
- MoveWindow(best, 0, 0, width, height, true);
- return true;
- }
-
- ///
- /// Resize the currently-embedded Teams window to
- /// × . Called when the host element resizes
- /// (window resize, layout change, etc.). No-op if nothing is embedded.
- ///
- public static void ResizeEmbedded(int width, int height)
- {
- if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
- MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
- }
-
- ///
- /// Reverse an active embed: SetParent back to desktop + restore the
- /// original window style so Teams looks/behaves like a normal top-level
- /// window again. Safe to call when nothing is embedded — no-op.
- ///
- public static void RestoreEmbed()
- {
- if (_embeddedHwnd == IntPtr.Zero || _embedSavedState is null) return;
- var (origParent, origStyle) = _embedSavedState.Value;
- try
- {
- // Restore original style FIRST so when we reparent the window's
- // top-level decorations come back correctly.
- SetWindowLongPtr(_embeddedHwnd, GWL_STYLE, origStyle);
- // SetParent(hwnd, Zero) returns to desktop. We could pass
- // origParent verbatim but for Teams that's always the desktop
- // anyway, and IntPtr.Zero is documented as "reparent to desktop".
- SetParent(_embeddedHwnd, IntPtr.Zero);
- SetWindowPos(_embeddedHwnd, IntPtr.Zero, 0, 0, 0, 0,
- SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
- }
- catch { /* defensive — restore must never throw */ }
- finally
- {
- _embedSavedState = null;
- _embeddedHwnd = IntPtr.Zero;
- }
- }
-
- ///
- /// Returns the title bar text of Teams' most-recently-used top-level
- /// window, or empty string if Teams isn't running. Modern Teams puts
- /// the meeting title in the window title while in a call ("Meeting with
- /// Alice | Microsoft Teams"), so this is the cheapest way to surface
- /// meeting context to TeamsISO's UI without burning a UIA traversal.
- ///
- /// Includes hidden windows — operators using auto-hide still get the
- /// title surfaced, which is the whole point.
- ///
- public static string GetActiveWindowTitle()
- {
- try
- {
- var teamsPids = new HashSet(
- TeamsProcessNames
- .SelectMany(n => Process.GetProcessesByName(n))
- .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
- if (teamsPids.Count == 0) return string.Empty;
-
- string longestTitle = string.Empty;
- EnumWindows((hWnd, _) =>
- {
- if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
- GetWindowThreadProcessId(hWnd, out var pid);
- if (!teamsPids.Contains(pid)) return true;
-
- var len = GetWindowTextLengthW(hWnd);
- if (len <= 0) return true;
- var sb = new System.Text.StringBuilder(len + 1);
- GetWindowTextW(hWnd, sb, sb.Capacity);
- var title = sb.ToString();
- // Teams creates a few top-level windows per process; the
- // call/meeting window has the longest title (other windows
- // tend to just be "Microsoft Teams"). Pick the longest one
- // as a heuristic for "most informative".
- if (title.Length > longestTitle.Length) longestTitle = title;
- return true;
- }, IntPtr.Zero);
- return longestTitle;
- }
- catch { return string.Empty; }
- }
-
- ///
- /// Enumerate every visible top-level window owned by any running Teams
- /// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
- /// not a tooltip or popup of another). Used by Hide/Show.
- ///
- private static List FindTeamsTopLevelWindows()
- {
- var teamsPids = new HashSet(
- TeamsProcessNames
- .SelectMany(n => Process.GetProcessesByName(n))
- .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
- if (teamsPids.Count == 0) return new List();
-
- var windows = new List();
- EnumWindows((hWnd, _) =>
- {
- if (!IsWindowVisible(hWnd)) return true;
- if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true; // not top-level
- GetWindowThreadProcessId(hWnd, out var pid);
- if (teamsPids.Contains(pid)) windows.Add(hWnd);
- return true;
- }, IntPtr.Zero);
- return windows;
- }
-
- ///
- /// Hides every visible top-level Teams window. Returns the count hidden;
- /// 0 means Teams isn't running or has no visible windows yet (it can take
- /// a couple seconds after launch for the splash to materialize).
- ///
- public static int HideWindows()
- {
- var windows = FindTeamsTopLevelWindows();
- foreach (var w in windows) ShowWindow(w, SW_HIDE);
- return windows.Count;
- }
-
- ///
- /// Fire-and-forget background watcher that polls every 250ms for up to
- /// and hides any visible top-level Teams
- /// windows it finds. Used after launch so the operator never sees the
- /// Teams UI flash on screen — Teams takes 2-5s to splash + render its
- /// main window, and the splash arrives separately from the main window
- /// (so we keep polling past the first hide to catch follow-up windows).
- ///
- /// Returns the Task so callers can await completion if they want, but
- /// production code should fire-and-forget. Exceptions are swallowed —
- /// failure to hide is harmless (user just sees Teams briefly).
- ///
- public static Task AutoHideAfterLaunchAsync(TimeSpan? timeout = null, CancellationToken ct = default)
- {
- var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
- return Task.Run(async () =>
- {
- try
- {
- var hiddenAny = false;
- while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
- {
- // Poll for visible windows. Each iteration may catch new
- // ones — Teams sometimes opens a small splash, then a
- // larger main window 1-2s later, then a "What's new"
- // banner. Keep hiding until we've gone a full second
- // with nothing new appearing.
- var hidden = HideWindows();
- if (hidden > 0)
- {
- hiddenAny = true;
- // Settling delay: after we hide windows, wait a beat
- // before polling again so we don't busy-loop while
- // Teams' window manager catches up.
- await Task.Delay(750, ct).ConfigureAwait(false);
- }
- else if (hiddenAny)
- {
- // We hid at least once; if the next poll finds
- // nothing, Teams has settled. Bail early.
- return;
- }
- else
- {
- // Teams hasn't materialized yet; keep waiting.
- await Task.Delay(250, ct).ConfigureAwait(false);
- }
- }
- }
- catch (OperationCanceledException) { /* expected on cancel */ }
- catch { /* defensive — auto-hide is best-effort, never breaks the app */ }
- }, ct);
- }
-
- // ────────────────────────────────────────────────────────────────────
- // Keyboard-shortcut forwarding (PostMessage path).
- //
- // UIAutomation (TeamsControlBridge) is our preferred way to drive Teams
- // because it works regardless of foreground/visibility state. PostMessage
- // is a fallback for shortcuts that don't have a stable UIA-discoverable
- // button — chat scroll, custom keymap actions, etc. Note: WebView2-hosted
- // Teams (the modern client) frequently ignores PostMessage(WM_KEYDOWN) at
- // its app-shortcut layer because shortcut routing happens after focus
- // changes, not on raw key messages. Treat this as best-effort.
- // ────────────────────────────────────────────────────────────────────
-
- private const uint WM_KEYDOWN = 0x0100;
- private const uint WM_KEYUP = 0x0101;
- private const uint WM_CHAR = 0x0102;
- private const uint WM_SYSKEYDOWN = 0x0104;
- private const uint WM_SYSKEYUP = 0x0105;
-
- [Flags]
- public enum ShortcutModifiers
- {
- None = 0,
- Ctrl = 1,
- Shift = 2,
- Alt = 4,
- }
-
- [DllImport("user32.dll")]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
-
- ///
- /// Sends a synthesized key press (modifier-down, key-down, key-up,
- /// modifier-up) to the most recently used top-level Teams window via
- /// PostMessage. Returns true if a window was found to send to. Note that
- /// returning true doesn't guarantee Teams reacted — modern WebView2-based
- /// Teams sometimes ignores synthesized key messages at the app-shortcut
- /// layer. Prefer UIA () when an equivalent
- /// button exists.
- ///
- public static bool SendShortcut(ShortcutModifiers modifiers, int virtualKey)
- {
- var windows = FindTeamsTopLevelWindows();
- if (windows.Count == 0) return false;
- var hwnd = windows[^1];
-
- // Modifier key downs
- if ((modifiers & ShortcutModifiers.Ctrl) != 0)
- PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x11, IntPtr.Zero); // VK_CONTROL
- if ((modifiers & ShortcutModifiers.Shift) != 0)
- PostMessage(hwnd, WM_KEYDOWN, (IntPtr)0x10, IntPtr.Zero); // VK_SHIFT
- if ((modifiers & ShortcutModifiers.Alt) != 0)
- PostMessage(hwnd, WM_SYSKEYDOWN, (IntPtr)0x12, IntPtr.Zero); // VK_MENU
-
- // Main key down + up
- PostMessage(hwnd, WM_KEYDOWN, (IntPtr)virtualKey, IntPtr.Zero);
- PostMessage(hwnd, WM_KEYUP, (IntPtr)virtualKey, IntPtr.Zero);
-
- // Modifier key ups (reverse order)
- if ((modifiers & ShortcutModifiers.Alt) != 0)
- PostMessage(hwnd, WM_SYSKEYUP, (IntPtr)0x12, IntPtr.Zero);
- if ((modifiers & ShortcutModifiers.Shift) != 0)
- PostMessage(hwnd, WM_KEYUP, (IntPtr)0x10, IntPtr.Zero);
- if ((modifiers & ShortcutModifiers.Ctrl) != 0)
- PostMessage(hwnd, WM_KEYUP, (IntPtr)0x11, IntPtr.Zero);
-
- return true;
- }
-
- ///
- /// Restores every Teams top-level window from hidden state and brings the
- /// most recently used one to the foreground. Returns the count shown.
- ///
- public static int ShowWindows()
- {
- // To find hidden windows too we still enumerate, but our IsWindowVisible
- // filter would skip them. Re-implement here with the visible check off.
- var teamsPids = new HashSet(
- TeamsProcessNames
- .SelectMany(n => Process.GetProcessesByName(n))
- .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
- var windows = new List();
- EnumWindows((hWnd, _) =>
- {
- if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
- GetWindowThreadProcessId(hWnd, out var pid);
- if (teamsPids.Contains(pid)) windows.Add(hWnd);
- return true;
- }, IntPtr.Zero);
-
- foreach (var w in windows) ShowWindow(w, SW_SHOW);
- if (windows.Count > 0) SetForegroundWindow(windows[^1]);
- return windows.Count;
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Services/ThemeManager.cs b/src/TeamsISO.App.WinUI/Services/ThemeManager.cs
deleted file mode 100644
index 5e4880d..0000000
--- a/src/TeamsISO.App.WinUI/Services/ThemeManager.cs
+++ /dev/null
@@ -1,127 +0,0 @@
-using System;
-using Microsoft.UI;
-using Microsoft.UI.Xaml;
-using Windows.UI;
-using Windows.UI.ViewManagement;
-
-namespace TeamsISO.App.WinUI.Services;
-
-///
-/// Owns the active theme for the WinUI 3 host. Three preferences:
-/// System follows the Windows app-mode setting (default for new
-/// users); Dark and Light pin one regardless of the OS choice.
-/// The persistence path will land alongside the existing UIPreferences in
-/// the next commit — for now state lives in-process.
-///
-/// All public mutations push to subscribers so the
-/// host (MainWindow) can update the AppWindow title-bar button colors
-/// (system buttons aren't part of the visual tree and need a separate
-/// poke when ElementTheme changes).
-///
-public sealed class ThemeManager
-{
- public static ThemeManager Current { get; } = new();
-
- private ThemeManager()
- {
- _uiSettings = new UISettings();
- _uiSettings.ColorValuesChanged += OnSystemColorsChanged;
-
- // Hydrate the preference from disk so the operator's choice
- // survives across launches. Defaults to "System" if the prefs
- // file is missing or unreadable (Load() catches its own errors).
- try
- {
- var prefs = UIPreferences.Load();
- if (prefs.Theme == "System" || prefs.Theme == "Dark" || prefs.Theme == "Light")
- {
- _preference = prefs.Theme;
- }
- }
- catch
- {
- // Defensive — ThemeManager.Current is a static singleton; a
- // throw here would prevent the app from getting any theme.
- }
- }
-
- private readonly UISettings _uiSettings;
- private string _preference = "System";
-
- public string Preference => _preference;
-
- public event EventHandler? Themed;
-
- ///
- /// Resolve the preference to an absolute
- /// suitable for .
- /// System resolves to the OS app-mode.
- ///
- public ElementTheme ResolveTheme() => _preference switch
- {
- "Dark" => ElementTheme.Dark,
- "Light" => ElementTheme.Light,
- _ => IsSystemDark() ? ElementTheme.Dark : ElementTheme.Light,
- };
-
- public bool PreferenceMatches(string value) => string.Equals(_preference, value, StringComparison.Ordinal);
-
- ///
- /// Cycle dark ↔ light from the title-bar toggle. If the current
- /// preference is System, the cycle pins to the opposite of the
- /// currently-resolved theme so the click has a visible effect.
- ///
- public ElementTheme Toggle()
- {
- var current = ResolveTheme();
- Set(current == ElementTheme.Dark ? "Light" : "Dark");
- return ResolveTheme();
- }
-
- /// Set the preference, persist to disk, broadcast the resolved theme.
- public void Set(string preference)
- {
- if (preference != "System" && preference != "Dark" && preference != "Light")
- {
- throw new ArgumentException("Preference must be System, Dark, or Light.", nameof(preference));
- }
-
- _preference = preference;
- try { UIPreferences.SetTheme(preference); }
- catch { /* persistence is best-effort */ }
- Themed?.Invoke(this, ResolveTheme());
- }
-
- private bool IsSystemDark()
- {
- // UISettings.GetColorValue(UIColorType.Background) returns
- // black-ish in dark mode, white-ish in light mode — the most
- // reliable cross-version check for app mode on desktop WinUI 3.
- var bg = _uiSettings.GetColorValue(UIColorType.Background);
- return ((5 * bg.G) + (2 * bg.R) + bg.B) < 8 * 128;
- }
-
- private void OnSystemColorsChanged(UISettings sender, object args)
- {
- // Only re-broadcast if the operator hasn't pinned a preference —
- // otherwise the explicit choice wins regardless of what the OS does.
- if (_preference == "System")
- {
- Themed?.Invoke(this, ResolveTheme());
- }
- }
-
- ///
- /// Compute the AppWindow title-bar foreground for the given resolved
- /// theme so the system min/max/close buttons stay readable.
- ///
- public static Color TitleBarForegroundFor(ElementTheme theme) =>
- theme == ElementTheme.Dark
- ? Color.FromArgb(0xFF, 0xF4, 0xF4, 0xF6)
- : Color.FromArgb(0xFF, 0x0A, 0x0A, 0x0A);
-
- public static Color TitleBarHoverBgFor(ElementTheme theme) =>
- theme == ElementTheme.Dark
- ? Color.FromArgb(0xFF, 0x33, 0x34, 0x3A)
- : Color.FromArgb(0xFF, 0xEC, 0xEE, 0xF1);
-}
diff --git a/src/TeamsISO.App.WinUI/Services/UIPreferences.cs b/src/TeamsISO.App.WinUI/Services/UIPreferences.cs
deleted file mode 100644
index da5a717..0000000
--- a/src/TeamsISO.App.WinUI/Services/UIPreferences.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using System;
-using System.IO;
-using System.Text.Json;
-
-namespace TeamsISO.App.WinUI.Services;
-
-///
-/// Persistent UI-side toggles shared between hosts via JSON at
-/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. The WPF host's copy at
-/// src/TeamsISO.App/Services/UIPreferences.cs reads/writes the same
-/// file with the same record shape — new fields added in one host's
-/// copy degrade gracefully in the other (JSON deserializer ignores
-/// unknown fields, missing fields fall back to defaults).
-///
-/// The WinUI copy adds the Theme field which the WPF host doesn't
-/// know about yet; when the WPF host's UIPreferences gets the same
-/// field, both hosts will share the operator's theme choice across
-/// host swaps.
-///
-public static class UIPreferences
-{
- private static readonly object _gate = new();
-
- private static string PrefsPath =>
- Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "TeamsISO", "ui-prefs.json");
-
- public enum SortMode { JoinOrder, Alphabetical, OnlineFirst, LoudestFirst }
-
- /// The on-disk shape. New fields added here become opt-in for older files via default values.
- public sealed record Prefs(
- bool HideLocalSelf = true,
- bool AutoDisableOnDeparture = false,
- SortMode ParticipantSort = SortMode.JoinOrder,
- bool MinimizeToTray = false,
- bool ControlSurfaceLanReachable = false,
- bool LaunchTeamsOnStartup = false,
- bool AutoHideTeamsWindows = false,
- bool AutoRecordOnCall = false,
- bool EmbedTeamsWindow = false,
- // Theme preference: "System" (follow OS app-mode), "Dark", or
- // "Light". WinUI host owns the value for now; WPF host gets the
- // same field in its UIPreferences when its theme system lands.
- string Theme = "System");
-
- public static Prefs Load()
- {
- try
- {
- if (!File.Exists(PrefsPath)) return new Prefs();
- var json = File.ReadAllText(PrefsPath);
- return JsonSerializer.Deserialize(json) ?? new Prefs();
- }
- catch
- {
- return new Prefs();
- }
- }
-
- public static void Save(Prefs prefs)
- {
- try
- {
- lock (_gate)
- {
- var dir = Path.GetDirectoryName(PrefsPath);
- if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
- var json = JsonSerializer.Serialize(prefs, new JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(PrefsPath, json);
- }
- }
- catch
- {
- // Disk full / permission denied — in-memory state still holds for this session.
- }
- }
-
- /// Update just the Theme field without touching other prefs.
- public static void SetTheme(string theme)
- {
- var current = Load();
- Save(current with { Theme = theme });
- }
-}
diff --git a/src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj b/src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj
deleted file mode 100644
index c618b05..0000000
--- a/src/TeamsISO.App.WinUI/TeamsISO.App.WinUI.csproj
+++ /dev/null
@@ -1,146 +0,0 @@
-
-
-
-
- WinExe
- net8.0-windows10.0.19041.0
- 10.0.17763.0
- 10.0.17763.0
- TeamsISO.App.WinUI
- TeamsISO
-
- x64;ARM64
- win-x64;win-arm64
- true
- None
- true
-
- 10.0.19041.38
-
- $(DefineConstants);DISABLE_XAML_GENERATED_MAIN
-
- win-x64
-
- false
- enable
- enable
- Assets\teamsiso.ico
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <_RuntimeConfigPath>$(OutDir)$(AssemblyName).runtimeconfig.json
-
-
- <_RuntimeConfigContent>$([System.IO.File]::ReadAllText('$(_RuntimeConfigPath)'))
- <_PatchedContent>$([System.Text.RegularExpressions.Regex]::Replace($(_RuntimeConfigContent), ',\s*\{\s*"name":\s*"Microsoft\.WindowsDesktop\.App"[^\}]*\}', ''))
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Themes/Controls.xaml b/src/TeamsISO.App.WinUI/Themes/Controls.xaml
deleted file mode 100644
index 3c3bb59..0000000
--- a/src/TeamsISO.App.WinUI/Themes/Controls.xaml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Themes/Tokens.xaml b/src/TeamsISO.App.WinUI/Themes/Tokens.xaml
deleted file mode 100644
index 0b35022..0000000
--- a/src/TeamsISO.App.WinUI/Themes/Tokens.xaml
+++ /dev/null
@@ -1,194 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- #FF0A0A0A
- #FF080808
- #FF141416
- #FF1C1C1F
- #FF26272B
- #FF33343A
-
-
- #FF26272B
- #FF3A3B40
-
-
- #FFF4F4F6
- #FFA3A4AA
- #FF6B6C72
- #FF404145
- #FF0A0A0A
-
-
- #FF97EDF0
- #FF97EDF0
- #FFB5F2F4
- #FF1B3537
-
- #FFFB819C
- #FF3A1922
-
- #FF4ADE80
- #FF13261A
- #FFFBBF24
- #FF3A2E12
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- #FFFAFAFB
- #FFF0F1F3
- #FFFFFFFF
- #FFFFFFFF
- #FFECEEF1
- #FFE0E3E7
-
-
- #FFE5E7EB
- #FFD1D5DA
-
-
- #FF0A0A0A
- #FF4A4B50
- #FF71747A
- #FFB3B6BC
- #FF0A0A0A
-
-
- #FF97EDF0
- #FF0E7C82
- #FF0890A0
- #FFE6F8F9
-
- #FFD43E5C
- #FFFDECF0
-
- #FF15803D
- #FFDCFCE7
- #FFB45309
- #FFFEF3C7
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 4
- 8
- 12
- 16
- 24
- 32
- 48
-
-
- 6
- 8
- 12
- 999
-
-
-
- ms-appx:///Assets/Fonts/Inter.ttf#Inter
- ms-appx:///Assets/Fonts/JetBrainsMono.ttf#JetBrains Mono
-
- 22
- 18
- 14
- 13
- 11
- 12
-
-
diff --git a/src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs b/src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs
deleted file mode 100644
index d7aae9b..0000000
--- a/src/TeamsISO.App.WinUI/ViewModels/MainViewModel.cs
+++ /dev/null
@@ -1,282 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Linq;
-using System.Reactive.Concurrency;
-using System.Reactive.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.UI.Dispatching;
-using TeamsISO.Engine.Controller;
-using TeamsISO.Engine.Domain;
-
-namespace TeamsISO.App.WinUI.ViewModels;
-
-///
-/// Top-level view-model for the WinUI 3 host. Subscribes to
-/// 's observables and marshals updates onto
-/// the WinUI 3 dispatcher queue (the WinUI equivalent of WPF Dispatcher).
-///
-/// Slim version of the WPF host's MainViewModel — drops the WPF-specific
-/// ICollectionView filter/sort, preset auto-apply, snapshot dialog, and
-/// help/notes window orchestration. Those land in subsequent commits.
-///
-public sealed class MainViewModel : ObservableObject, IDisposable
-{
- private readonly IIsoController _controller;
- private readonly DispatcherQueue _dispatcher;
- private readonly DispatcherQueueTimer _statsTimer;
- private readonly IDisposable _participantsSub;
- private readonly IDisposable _alertsSub;
- private readonly Dictionary _byId = new();
- private string _statusText = "Starting…";
- private bool _isSessionActive;
- private DateTimeOffset? _sessionStartedAt;
- private string _sessionElapsed = "00:00:00";
- private int _activeRecordingCount;
- private string _participantCountText = "0";
-
- public ObservableCollection Participants { get; } = new();
-
- public string StatusText
- {
- get => _statusText;
- private set => SetField(ref _statusText, value);
- }
-
- public string ParticipantCountText
- {
- get => _participantCountText;
- private set => SetField(ref _participantCountText, value);
- }
-
- public bool IsSessionActive
- {
- get => _isSessionActive;
- private set => SetField(ref _isSessionActive, value);
- }
-
- public string SessionElapsed
- {
- get => _sessionElapsed;
- private set => SetField(ref _sessionElapsed, value);
- }
-
- public int ActiveRecordingCount
- {
- get => _activeRecordingCount;
- private set => SetField(ref _activeRecordingCount, value);
- }
-
- public bool IsRecording => ActiveRecordingCount > 0;
-
- public AsyncRelayCommand EnableAllOnlineCommand { get; }
- public AsyncRelayCommand StopAllIsosCommand { get; }
- public RelayCommand RefreshDiscoveryCommand { get; }
- public RelayCommand DropRecordingMarkerCommand { get; }
- public RelayCommand ToggleByIndexCommand { get; }
-
- public MainViewModel(IIsoController controller, DispatcherQueue dispatcher)
- {
- _controller = controller;
- _dispatcher = dispatcher;
-
- // Subscribe to engine observables. ObserveOn the WinUI dispatcher
- // queue's SynchronizationContext so handlers run on the UI thread
- // and SetField is safe.
- var ctx = new DispatcherQueueSynchronizationContext(_dispatcher);
- _participantsSub = controller.Participants
- .ObserveOn(new SynchronizationContextScheduler(ctx))
- .Subscribe(OnParticipantsChanged);
-
- _alertsSub = controller.Alerts
- .ObserveOn(new SynchronizationContextScheduler(ctx))
- .Subscribe(alert =>
- {
- StatusText = $"Alert · {alert.Message}";
- });
-
- // 1 Hz stats tick — pulls live frame counters off the engine and
- // pushes them into per-participant view-models. Runs on the WinUI
- // dispatcher so SetField is safe.
- _statsTimer = _dispatcher.CreateTimer();
- _statsTimer.Interval = TimeSpan.FromSeconds(1);
- _statsTimer.Tick += OnStatsTick;
- _statsTimer.Start();
-
- EnableAllOnlineCommand = new AsyncRelayCommand(
- EnableAllOnlineAsync,
- () => Participants.Any(p => p.IsOnline && !p.IsEnabled));
-
- StopAllIsosCommand = new AsyncRelayCommand(
- StopAllIsosAsync,
- () => Participants.Any(p => p.IsEnabled));
-
- RefreshDiscoveryCommand = new RelayCommand(() =>
- {
- _controller.RefreshDiscovery();
- StatusText = "Refreshing NDI discovery…";
- });
-
- DropRecordingMarkerCommand = new RelayCommand(() =>
- {
- var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
- _controller.AddRecordingMarker(label);
- StatusText = $"Marker dropped: {label}";
- });
-
- ToggleByIndexCommand = new RelayCommand(s =>
- {
- if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return;
- var i = 0;
- foreach (var p in Participants)
- {
- if (++i == idx)
- {
- if (p.ToggleIsoCommand.CanExecute(null))
- p.ToggleIsoCommand.Execute(null);
- break;
- }
- }
- });
- }
-
- ///
- /// Kick off the engine controller's StartAsync. Awaited by App.OnLaunched
- /// so the participants observable starts firing as soon as discovery's up.
- ///
- public Task InitializeAsync(CancellationToken ct) => _controller.StartAsync(ct);
-
- private void OnParticipantsChanged(IReadOnlyList snapshot)
- {
- // Reconcile: add new, update existing, remove gone. Identity is by Guid.
- var present = new HashSet();
- foreach (var p in snapshot)
- {
- present.Add(p.Id);
- if (_byId.TryGetValue(p.Id, out var vm))
- {
- vm.Update(p);
- }
- else
- {
- vm = new ParticipantViewModel(_controller, p);
- _byId[p.Id] = vm;
- Participants.Add(vm);
- }
- }
- for (var i = Participants.Count - 1; i >= 0; i--)
- {
- var vm = Participants[i];
- if (!present.Contains(vm.Id))
- {
- _byId.Remove(vm.Id);
- Participants.RemoveAt(i);
- }
- }
-
- ParticipantCountText = Participants.Count.ToString();
- StatusText = Participants.Count == 0
- ? "Waiting for Teams participants…"
- : $"{Participants.Count} participants · {Participants.Count(p => p.IsEnabled)} routing";
-
- EnableAllOnlineCommand.RaiseCanExecuteChanged();
- StopAllIsosCommand.RaiseCanExecuteChanged();
- }
-
- private void OnStatsTick(DispatcherQueueTimer sender, object args)
- {
- // Pull stats + update active-speaker highlight.
- foreach (var vm in Participants)
- {
- try
- {
- var stats = _controller.GetStats(vm.Id);
- vm.UpdateStats(stats);
- }
- catch
- {
- // Stats are advisory; transient read failures shouldn't
- // tear down the timer.
- }
- }
-
- // Active speaker: loudest among the enabled, threshold 0.05 to
- // prevent flicker between near-silent participants.
- ParticipantViewModel? loudest = null;
- double loudestLevel = 0.05;
- foreach (var p in Participants)
- {
- if (!p.IsEnabled) continue;
- if (p.DisplayedAudioLevel > loudestLevel)
- {
- loudest = p;
- loudestLevel = p.DisplayedAudioLevel;
- }
- }
- foreach (var p in Participants)
- {
- var shouldHighlight = ReferenceEquals(p, loudest);
- if (p.IsActiveSpeaker != shouldHighlight)
- p.IsActiveSpeaker = shouldHighlight;
- }
-
- // Session timer + recording count.
- var enabledCount = Participants.Count(p => p.IsEnabled);
- if (enabledCount > 0 && !IsSessionActive)
- {
- IsSessionActive = true;
- _sessionStartedAt = DateTimeOffset.UtcNow;
- }
- else if (enabledCount == 0 && IsSessionActive)
- {
- IsSessionActive = false;
- _sessionStartedAt = null;
- }
- if (_sessionStartedAt is { } start)
- {
- var elapsed = DateTimeOffset.UtcNow - start;
- SessionElapsed = elapsed.ToString(@"hh\:mm\:ss");
- }
-
- ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
- OnPropertyChanged(nameof(IsRecording));
-
- EnableAllOnlineCommand.RaiseCanExecuteChanged();
- StopAllIsosCommand.RaiseCanExecuteChanged();
- }
-
- private async Task EnableAllOnlineAsync()
- {
- foreach (var p in Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray())
- {
- if (p.ToggleIsoCommand.CanExecute(null))
- {
- p.ToggleIsoCommand.Execute(null);
- await Task.Delay(50); // small gap so we don't hammer the engine
- }
- }
- }
-
- private async Task StopAllIsosAsync()
- {
- foreach (var p in Participants.Where(p => p.IsEnabled).ToArray())
- {
- try
- {
- await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
- }
- catch
- {
- // Continue with remaining; we'll re-sync on the next observable tick.
- }
- }
- }
-
- public void Dispose()
- {
- _statsTimer.Stop();
- _participantsSub.Dispose();
- _alertsSub.Dispose();
- }
-}
diff --git a/src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs b/src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs
deleted file mode 100644
index c4faaf8..0000000
--- a/src/TeamsISO.App.WinUI/ViewModels/ObservableObject.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System.Collections.Generic;
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
-
-namespace TeamsISO.App.WinUI.ViewModels;
-
-///
-/// Minimal MVVM base implementing .
-/// Mirrors the WPF host's ObservableObject so view-model code reads the
-/// same across hosts.
-///
-public abstract class ObservableObject : INotifyPropertyChanged
-{
- public event PropertyChangedEventHandler? PropertyChanged;
-
- protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
-
- protected bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null)
- {
- if (EqualityComparer.Default.Equals(field, value)) return false;
- field = value;
- OnPropertyChanged(propertyName);
- return true;
- }
-}
diff --git a/src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs
deleted file mode 100644
index a2db339..0000000
--- a/src/TeamsISO.App.WinUI/ViewModels/ParticipantViewModel.cs
+++ /dev/null
@@ -1,209 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using TeamsISO.Engine.Controller;
-using TeamsISO.Engine.Domain;
-
-namespace TeamsISO.App.WinUI.ViewModels;
-
-///
-/// Slim per-row view model for the WinUI 3 host. Drops the WPF-specific
-/// surfaces (thumbnail WriteableBitmap, clipboard, PreviewWindow, snapshot
-/// PNG encoder) that don't translate without a separate composition path.
-/// The essential operator-facing properties stay: display name, source
-/// codec, signal state, audio level, ISO toggle.
-///
-/// Thumbnails and per-row snapshot/preview come back in a follow-up
-/// commit alongside Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap +
-/// SoftwareBitmap encoding.
-///
-public sealed class ParticipantViewModel : ObservableObject
-{
- private readonly IIsoController _controller;
- private Participant _participant;
- private bool _isEnabled;
- private bool _isProcessing;
- private bool _isActiveSpeaker;
- private double _displayedAudioLevel;
- private string _stateLabel = "—";
- private string _outputName = string.Empty;
- private string _sourceCodec = string.Empty;
-
- public ParticipantViewModel(IIsoController controller, Participant participant)
- {
- _controller = controller;
- _participant = participant;
- ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
- RefreshFromParticipant();
- }
-
- public Guid Id => _participant.Id;
-
- public string DisplayName => string.IsNullOrWhiteSpace(_participant.DisplayName)
- ? _participant.Id.ToString().Substring(0, 8)
- : _participant.DisplayName;
-
- public string Initials
- {
- get
- {
- var name = DisplayName;
- if (string.IsNullOrWhiteSpace(name)) return "??";
- var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- if (parts.Length == 0) return name.Substring(0, Math.Min(2, name.Length)).ToUpperInvariant();
- if (parts.Length == 1) return parts[0].Substring(0, Math.Min(2, parts[0].Length)).ToUpperInvariant();
- return (parts[0][0].ToString() + parts[^1][0]).ToUpperInvariant();
- }
- }
-
- public string SourceCodec
- {
- get => _sourceCodec;
- private set => SetField(ref _sourceCodec, value);
- }
-
- public bool IsOnline => _participant.CurrentSource is not null;
-
- public bool IsEnabled
- {
- get => _isEnabled;
- private set
- {
- if (SetField(ref _isEnabled, value))
- {
- OnPropertyChanged(nameof(IsoStateLabel));
- }
- }
- }
-
- public bool IsProcessing
- {
- get => _isProcessing;
- private set
- {
- if (SetField(ref _isProcessing, value))
- ToggleIsoCommand.RaiseCanExecuteChanged();
- }
- }
-
- public bool IsActiveSpeaker
- {
- get => _isActiveSpeaker;
- internal set => SetField(ref _isActiveSpeaker, value);
- }
-
- public double DisplayedAudioLevel
- {
- get => _displayedAudioLevel;
- private set => SetField(ref _displayedAudioLevel, value);
- }
-
- /// Display label for the ISO pill: LIVE / OFF / ERROR / NO SIGNAL / STARTING.
- public string IsoStateLabel => _stateLabel;
-
- ///
- /// Output name resolved from the operator's template. For now, a
- /// deterministic TEAMSISO_{firstname_lowercase} fallback to keep
- /// the demo running without the OutputNameTemplate plumbing.
- ///
- public string OutputName
- {
- get => _outputName;
- private set => SetField(ref _outputName, value);
- }
-
- public AsyncRelayCommand ToggleIsoCommand { get; }
-
- /// Engine emits an updated Participant — refresh derived fields.
- public void Update(Participant updated)
- {
- _participant = updated;
- RefreshFromParticipant();
- OnPropertyChanged(nameof(DisplayName));
- OnPropertyChanged(nameof(Initials));
- OnPropertyChanged(nameof(IsOnline));
- OnPropertyChanged(nameof(OutputName));
- }
-
- private void RefreshFromParticipant()
- {
- // Output name: TEAMSISO_.
- // Mirrors the engine's default template loosely. Will replace
- // with the operator's template once OutputNameTemplate moves
- // into the engine layer (currently it's WPF-host-side).
- var name = DisplayName.Replace(' ', '_').ToLowerInvariant();
- OutputName = "TEAMSISO_" + name;
-
- // Source codec line: "MS Teams · WxH · fps". When no source is
- // attached yet, show a placeholder.
- var src = _participant.CurrentSource;
- SourceCodec = src is null
- ? "Offline"
- : "MS Teams · awaiting frame";
- }
-
- ///
- /// Called from MainViewModel's 1Hz stats tick. Updates audio level
- /// with attack/decay, state label from the engine's IsoHealthStats.
- ///
- public void UpdateStats(IsoHealthStats stats)
- {
- if (stats.PeakAudioLevel > _displayedAudioLevel)
- {
- _displayedAudioLevel = stats.PeakAudioLevel;
- }
- else
- {
- _displayedAudioLevel *= 0.7;
- if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
- }
- OnPropertyChanged(nameof(DisplayedAudioLevel));
-
- var newLabel = stats.State switch
- {
- IsoState.Receiving => "LIVE",
- IsoState.Sending => "LIVE",
- IsoState.NoSignal => "NO SIGNAL",
- IsoState.Error => "ERROR",
- IsoState.Idle => IsEnabled ? "STARTING" : "OFF",
- _ => "OFF",
- };
-
- if (stats.IncomingWidth > 0 && stats.IncomingHeight > 0)
- {
- SourceCodec = $"MS Teams · {stats.IncomingWidth}×{stats.IncomingHeight} · {stats.IncomingFps:0} fps";
- }
-
- if (_stateLabel != newLabel)
- {
- _stateLabel = newLabel;
- OnPropertyChanged(nameof(IsoStateLabel));
- }
- }
-
- private async Task ToggleIsoAsync()
- {
- IsProcessing = true;
- try
- {
- if (IsEnabled)
- {
- await _controller.DisableIsoAsync(Id, CancellationToken.None);
- IsEnabled = false;
- }
- else
- {
- await _controller.EnableIsoAsync(Id, OutputName, CancellationToken.None);
- IsEnabled = true;
- }
- }
- catch
- {
- // Failures surface in the engine alerts stream; don't crash the UI here.
- }
- finally
- {
- IsProcessing = false;
- }
- }
-}
diff --git a/src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs b/src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs
deleted file mode 100644
index a7951df..0000000
--- a/src/TeamsISO.App.WinUI/ViewModels/RelayCommand.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using System.Windows.Input;
-
-namespace TeamsISO.App.WinUI.ViewModels;
-
-///
-/// Synchronous command — same shape as the WPF host's RelayCommand.
-/// System.Windows.Input.ICommand is the shared base type across both
-/// hosts (lives in System.ObjectModel.dll on .NET 8), so no rewrite
-/// is needed beyond the namespace.
-///
-public sealed class RelayCommand : ICommand
-{
- private readonly Action _execute;
- private readonly Func? _canExecute;
-
- public RelayCommand(Action execute, Func? canExecute = null)
- {
- _execute = execute;
- _canExecute = canExecute;
- }
-
- public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
- public void Execute(object? parameter) => _execute();
- public event EventHandler? CanExecuteChanged;
- public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
-}
-
-/// Typed-parameter variant for hotkey-driven commands (NumPad 1-9).
-public sealed class RelayCommand : ICommand
-{
- private readonly Action _execute;
- private readonly Func? _canExecute;
-
- public RelayCommand(Action execute, Func? canExecute = null)
- {
- _execute = execute;
- _canExecute = canExecute;
- }
-
- public bool CanExecute(object? parameter) => _canExecute?.Invoke(Convert(parameter)) ?? true;
- public void Execute(object? parameter) => _execute(Convert(parameter));
- public event EventHandler? CanExecuteChanged;
- public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
-
- private static TValue Convert(object? value)
- {
- if (value is null) return default!;
- if (value is TValue typed) return typed;
- try { return (TValue)System.Convert.ChangeType(value, typeof(TValue)); }
- catch { return default!; }
- }
-}
-
-/// Async command that suppresses re-entrancy while in flight.
-public sealed class AsyncRelayCommand : ICommand
-{
- private readonly Func _execute;
- private readonly Func? _canExecute;
- private bool _isRunning;
-
- public AsyncRelayCommand(Func execute, Func? canExecute = null)
- {
- _execute = execute;
- _canExecute = canExecute;
- }
-
- public bool CanExecute(object? parameter) => !_isRunning && (_canExecute?.Invoke() ?? true);
-
- public async void Execute(object? parameter)
- {
- if (_isRunning) return;
- _isRunning = true;
- RaiseCanExecuteChanged();
- try { await _execute(); }
- finally
- {
- _isRunning = false;
- RaiseCanExecuteChanged();
- }
- }
-
- public event EventHandler? CanExecuteChanged;
- public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
-}
diff --git a/src/TeamsISO.App.WinUI/Views/AboutDialog.xaml b/src/TeamsISO.App.WinUI/Views/AboutDialog.xaml
deleted file mode 100644
index 143430d..0000000
--- a/src/TeamsISO.App.WinUI/Views/AboutDialog.xaml
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Views/AboutDialog.xaml.cs b/src/TeamsISO.App.WinUI/Views/AboutDialog.xaml.cs
deleted file mode 100644
index 89dbb47..0000000
--- a/src/TeamsISO.App.WinUI/Views/AboutDialog.xaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Microsoft.UI.Xaml.Controls;
-
-namespace TeamsISO.App.WinUI.Views;
-
-public sealed partial class AboutDialog : ContentDialog
-{
- public AboutDialog()
- {
- InitializeComponent();
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Views/HelpDialog.xaml b/src/TeamsISO.App.WinUI/Views/HelpDialog.xaml
deleted file mode 100644
index f03d7a2..0000000
--- a/src/TeamsISO.App.WinUI/Views/HelpDialog.xaml
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- External control via REST + WebSocket on 127.0.0.1:9755 and OSC on UDP 127.0.0.1:9000.
- Self-contained HTML panel at /ui. Use Bitfocus Companion or TouchOSC. See
- docs/CONTROL-SURFACE.md for the command vocabulary.
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Views/HelpDialog.xaml.cs b/src/TeamsISO.App.WinUI/Views/HelpDialog.xaml.cs
deleted file mode 100644
index 25fc188..0000000
--- a/src/TeamsISO.App.WinUI/Views/HelpDialog.xaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Microsoft.UI.Xaml.Controls;
-
-namespace TeamsISO.App.WinUI.Views;
-
-public sealed partial class HelpDialog : ContentDialog
-{
- public HelpDialog()
- {
- InitializeComponent();
- }
-}
diff --git a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
deleted file mode 100644
index 30a3dee..0000000
--- a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml
+++ /dev/null
@@ -1,433 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs b/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs
deleted file mode 100644
index 22e6443..0000000
--- a/src/TeamsISO.App.WinUI/Views/MainWindow.xaml.cs
+++ /dev/null
@@ -1,633 +0,0 @@
-using Microsoft.UI;
-using Microsoft.UI.Windowing;
-using Microsoft.UI.Xaml;
-using TeamsISO.App.WinUI.Services;
-using TeamsISO.App.WinUI.ViewModels;
-using Windows.Graphics;
-using Windows.UI;
-
-namespace TeamsISO.App.WinUI.Views;
-
-public sealed partial class MainWindow : Window
-{
- private MainViewModel? _viewModel;
-
- public MainWindow()
- {
- InitializeComponent();
-
- Title = "TeamsISO";
-
- ExtendsContentIntoTitleBar = true;
- SetTitleBar(AppTitleBar);
-
- AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
- AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
- AppWindow.TitleBar.ButtonHoverForegroundColor = Colors.White;
-
- AppWindow.Resize(new SizeInt32(1280, 780));
-
- ThemeManager.Current.Themed += (_, theme) => ApplyResolvedTheme(theme);
- ApplyResolvedTheme(ThemeManager.Current.ResolveTheme());
-
- WireKeyboardShortcuts();
- }
-
- ///
- /// Attaches the window-scoped keyboard accelerators that match the
- /// WPF host's KeyBindings: F1 help, Ctrl+M marker, Ctrl+Shift+S
- /// panic stop, Ctrl+R refresh, 1-9 + NumPad 1-9 toggle ISO by visible
- /// row index. WinUI 3 KeyboardAccelerators live on UIElement, so we
- /// attach them to the content root.
- ///
- private void WireKeyboardShortcuts()
- {
- if (Content is not Microsoft.UI.Xaml.UIElement root) return;
-
- void Bind(Windows.System.VirtualKey key,
- Windows.System.VirtualKeyModifiers mods,
- Windows.Foundation.TypedEventHandler handler)
- {
- var acc = new Microsoft.UI.Xaml.Input.KeyboardAccelerator
- {
- Key = key,
- Modifiers = mods,
- ScopeOwner = root,
- };
- acc.Invoked += handler;
- root.KeyboardAccelerators.Add(acc);
- }
-
- // F1 — help dialog
- Bind(Windows.System.VirtualKey.F1,
- Windows.System.VirtualKeyModifiers.None,
- (s, e) => { e.Handled = true; _ = ShowHelpDialogAsync(); });
-
- // Ctrl+M — drop a recording marker
- Bind(Windows.System.VirtualKey.M,
- Windows.System.VirtualKeyModifiers.Control,
- (s, e) =>
- {
- e.Handled = true;
- if (_viewModel?.DropRecordingMarkerCommand.CanExecute(null) == true)
- _viewModel.DropRecordingMarkerCommand.Execute(null);
- });
-
- // Ctrl+Shift+S — panic stop all ISOs
- Bind(Windows.System.VirtualKey.S,
- Windows.System.VirtualKeyModifiers.Control | Windows.System.VirtualKeyModifiers.Shift,
- (s, e) =>
- {
- e.Handled = true;
- if (_viewModel?.StopAllIsosCommand.CanExecute(null) == true)
- _viewModel.StopAllIsosCommand.Execute(null);
- });
-
- // Ctrl+R — refresh NDI discovery
- Bind(Windows.System.VirtualKey.R,
- Windows.System.VirtualKeyModifiers.Control,
- (s, e) =>
- {
- e.Handled = true;
- if (_viewModel?.RefreshDiscoveryCommand.CanExecute(null) == true)
- _viewModel.RefreshDiscoveryCommand.Execute(null);
- });
-
- // 1-9 and NumPad1-9 — toggle ISO for the Nth visible participant
- for (var i = 1; i <= 9; i++)
- {
- var idx = i.ToString();
- Bind((Windows.System.VirtualKey)(0x30 + i), // VK '1'..'9'
- Windows.System.VirtualKeyModifiers.None,
- (s, e) =>
- {
- e.Handled = true;
- if (_viewModel?.ToggleByIndexCommand.CanExecute(idx) == true)
- _viewModel.ToggleByIndexCommand.Execute(idx);
- });
- Bind((Windows.System.VirtualKey)(0x60 + i), // VK NumPad1..NumPad9
- Windows.System.VirtualKeyModifiers.None,
- (s, e) =>
- {
- e.Handled = true;
- if (_viewModel?.ToggleByIndexCommand.CanExecute(idx) == true)
- _viewModel.ToggleByIndexCommand.Execute(idx);
- });
- }
-
- // Esc — dismiss the settings drawer if open
- Bind(Windows.System.VirtualKey.Escape,
- Windows.System.VirtualKeyModifiers.None,
- (s, e) =>
- {
- if (_drawerOpen)
- {
- e.Handled = true;
- OnDrawerCloseRequested(this, System.EventArgs.Empty);
- }
- });
- }
-
- private async System.Threading.Tasks.Task ShowHelpDialogAsync()
- {
- try
- {
- var dlg = new HelpDialog
- {
- XamlRoot = (Content as Microsoft.UI.Xaml.FrameworkElement)?.XamlRoot,
- };
- await dlg.ShowAsync();
- }
- catch (System.Exception ex)
- {
- StatusBarText.Text = $"Help failed: {ex.Message}";
- }
- }
-
- ///
- /// Hook the engine view-model in. Replaces the placeholder StackPanel
- /// inside ParticipantsHost with a live ListView. Rather than fight WinUI
- /// 3's DataTemplate compilation, we subscribe to the Participants
- /// collection and rebuild a simple StackPanel of row controls on
- /// every change. Less efficient than a virtualized ListView for huge
- /// lists, fine for the operator's ~10 max participants.
- ///
- public void AttachViewModel(MainViewModel viewModel)
- {
- _viewModel = viewModel;
-
- // Section header + in-call buttons → view-model commands.
- // The buttons exist in MainWindow.xaml with the matching x:Names.
- RefreshButton.Command = viewModel.RefreshDiscoveryCommand;
- EnableAllButton.Command = viewModel.EnableAllOnlineCommand;
- StopAllButton.Command = viewModel.StopAllIsosCommand;
- MarkerButton.Command = viewModel.DropRecordingMarkerCommand;
-
- // Status bar + participant count text refresh on VM property changes.
- ParticipantCountText.Text = viewModel.ParticipantCountText;
- StatusBarText.Text = viewModel.StatusText;
- viewModel.PropertyChanged += (_, e) =>
- {
- DispatcherQueue.TryEnqueue(() =>
- {
- switch (e.PropertyName)
- {
- case nameof(MainViewModel.ParticipantCountText):
- ParticipantCountText.Text = viewModel.ParticipantCountText;
- break;
- case nameof(MainViewModel.StatusText):
- StatusBarText.Text = viewModel.StatusText;
- break;
- }
- });
- };
-
- ParticipantsHost.Children.Clear();
- var stack = new Microsoft.UI.Xaml.Controls.StackPanel
- {
- HorizontalAlignment = HorizontalAlignment.Stretch,
- VerticalAlignment = VerticalAlignment.Top,
- };
- var scroll = new Microsoft.UI.Xaml.Controls.ScrollViewer
- {
- VerticalScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility.Auto,
- Content = stack,
- };
- ParticipantsHost.Children.Add(scroll);
-
- void Rebuild()
- {
- stack.Children.Clear();
- foreach (var p in viewModel.Participants)
- {
- stack.Children.Add(BuildSimpleRow(p));
- }
- }
-
- viewModel.Participants.CollectionChanged += (_, _) =>
- {
- DispatcherQueue.TryEnqueue(Rebuild);
- };
- Rebuild();
- }
-
- ///
- /// Participant row — name + ISO state pill, with the cyan-accent
- /// left border when the participant is the active speaker, and the
- /// pill background flipping green/coral/amber based on ISO state.
- /// Imperative construction so we sidestep the WinUI 3 DataTemplate
- /// parser path that crashes on this build host.
- ///
- private static Microsoft.UI.Xaml.Controls.Grid BuildSimpleRow(ParticipantViewModel p)
- {
- var grid = new Microsoft.UI.Xaml.Controls.Grid
- {
- Height = 56,
- Padding = new Thickness(0, 0, 20, 0),
- };
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(3) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(20) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1, Microsoft.UI.Xaml.GridUnitType.Star) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(140) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto });
-
- // Active-speaker left accent. Visible only when IsActiveSpeaker
- // flips on (driven by MainViewModel.OnStatsTick at 1Hz).
- var accent = new Microsoft.UI.Xaml.Controls.Border
- {
- Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanText"],
- Visibility = p.IsActiveSpeaker ? Visibility.Visible : Visibility.Collapsed,
- };
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(accent, 0);
- grid.Children.Add(accent);
-
- var nameStack = new Microsoft.UI.Xaml.Controls.StackPanel
- {
- VerticalAlignment = VerticalAlignment.Center,
- Spacing = 2,
- };
- var nameText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.DisplayName,
- FontSize = 14,
- FontWeight = Microsoft.UI.Text.FontWeights.Medium,
- };
- var codecText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.SourceCodec,
- FontSize = 11,
- Opacity = 0.6,
- };
- nameStack.Children.Add(nameText);
- nameStack.Children.Add(codecText);
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(nameStack, 2);
- grid.Children.Add(nameStack);
-
- var outputText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.OutputName,
- FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
- FontSize = 12,
- VerticalAlignment = VerticalAlignment.Center,
- };
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(outputText, 3);
- grid.Children.Add(outputText);
-
- var pillText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.IsoStateLabel,
- FontSize = 11,
- FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
- var pill = new Microsoft.UI.Xaml.Controls.Button
- {
- Command = p.ToggleIsoCommand,
- MinWidth = 80,
- Padding = new Thickness(14, 6, 14, 6),
- CornerRadius = new CornerRadius(999),
- VerticalAlignment = VerticalAlignment.Center,
- Content = pillText,
- };
- ApplyIsoPillStyling(pill, pillText, p.IsoStateLabel);
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(pill, 4);
- grid.Children.Add(pill);
-
- p.PropertyChanged += (_, e) =>
- {
- grid.DispatcherQueue.TryEnqueue(() =>
- {
- switch (e.PropertyName)
- {
- case nameof(ParticipantViewModel.DisplayName):
- nameText.Text = p.DisplayName;
- break;
- case nameof(ParticipantViewModel.SourceCodec):
- codecText.Text = p.SourceCodec;
- break;
- case nameof(ParticipantViewModel.OutputName):
- outputText.Text = p.OutputName;
- break;
- case nameof(ParticipantViewModel.IsoStateLabel):
- case nameof(ParticipantViewModel.IsEnabled):
- pillText.Text = p.IsoStateLabel;
- ApplyIsoPillStyling(pill, pillText, p.IsoStateLabel);
- break;
- case nameof(ParticipantViewModel.IsActiveSpeaker):
- accent.Visibility = p.IsActiveSpeaker
- ? Visibility.Visible
- : Visibility.Collapsed;
- break;
- }
- });
- };
-
- return grid;
- }
-
- ///
- /// Re-color the ISO pill based on the engine's reported state.
- /// LIVE = green on green-tinted background. ERROR = coral on coral-
- /// tinted background. STARTING = amber. NO SIGNAL = amber. OFF =
- /// neutral surface. Mirrors the WPF host's IsoToggle data-trigger
- /// behavior but built imperatively from theme-resource brush keys.
- ///
- private static void ApplyIsoPillStyling(
- Microsoft.UI.Xaml.Controls.Button pill,
- Microsoft.UI.Xaml.Controls.TextBlock pillText,
- string state)
- {
- Microsoft.UI.Xaml.Media.Brush bg, border, fg;
- switch (state)
- {
- case "LIVE":
- bg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StatusLiveBg"];
- border = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StatusLive"];
- fg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StatusLive"];
- break;
- case "ERROR":
- bg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCoralBg"];
- border = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCoral"];
- fg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCoral"];
- break;
- case "NO SIGNAL":
- case "STARTING":
- bg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StatusWarnBg"];
- border = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StatusWarn"];
- fg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["StatusWarn"];
- break;
- default: // OFF / —
- bg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BgSurface"];
- border = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderStrong"];
- fg = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"];
- break;
- }
- pill.Background = bg;
- pill.BorderBrush = border;
- pill.BorderThickness = new Thickness(1);
- pillText.Foreground = fg;
- }
-
- /// Full rich row template — replaces BuildSimpleRow once we've verified the simple version doesn't crash.
- private static Microsoft.UI.Xaml.Controls.Grid BuildParticipantRow(ParticipantViewModel p)
- {
- var grid = new Microsoft.UI.Xaml.Controls.Grid
- {
- Height = 64,
- Padding = new Thickness(14, 0, 12, 0),
- BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderSubtle"],
- BorderThickness = new Thickness(0, 0, 0, 1),
- };
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(44) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(2, Microsoft.UI.Xaml.GridUnitType.Star) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1.4, Microsoft.UI.Xaml.GridUnitType.Star) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = new Microsoft.UI.Xaml.GridLength(1.6, Microsoft.UI.Xaml.GridUnitType.Star) });
- grid.ColumnDefinitions.Add(new Microsoft.UI.Xaml.Controls.ColumnDefinition { Width = Microsoft.UI.Xaml.GridLength.Auto });
-
- // Avatar
- var avatar = new Microsoft.UI.Xaml.Controls.Border
- {
- Width = 36, Height = 36,
- CornerRadius = new CornerRadius(18),
- Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanMuted"],
- VerticalAlignment = VerticalAlignment.Center,
- };
- var initialsText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.Initials,
- FontSize = 13,
- FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
- Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["AccentCyanText"],
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- };
- avatar.Child = initialsText;
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(avatar, 0);
- grid.Children.Add(avatar);
-
- // Name + codec
- var nameStack = new Microsoft.UI.Xaml.Controls.StackPanel
- {
- Margin = new Thickness(12, 0, 0, 0),
- VerticalAlignment = VerticalAlignment.Center,
- Spacing = 2,
- };
- var nameText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.DisplayName,
- FontSize = 13,
- FontWeight = Microsoft.UI.Text.FontWeights.Medium,
- Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
- };
- var codecText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.SourceCodec,
- FontSize = 11,
- Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgSecondary"],
- };
- nameStack.Children.Add(nameText);
- nameStack.Children.Add(codecText);
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(nameStack, 1);
- grid.Children.Add(nameStack);
-
- // Audio meter
- var meter = new Microsoft.UI.Xaml.Controls.ProgressBar
- {
- Maximum = 1.0,
- Value = p.DisplayedAudioLevel,
- Height = 4,
- Margin = new Thickness(12, 0, 12, 0),
- VerticalAlignment = VerticalAlignment.Center,
- };
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(meter, 2);
- grid.Children.Add(meter);
-
- // Output name
- var outputText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.OutputName,
- FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
- FontSize = 12,
- Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
- VerticalAlignment = VerticalAlignment.Center,
- };
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(outputText, 3);
- grid.Children.Add(outputText);
-
- // ISO toggle pill
- var pillText = new Microsoft.UI.Xaml.Controls.TextBlock
- {
- Text = p.IsoStateLabel,
- FontSize = 11,
- FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
- Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["FgPrimary"],
- HorizontalAlignment = HorizontalAlignment.Center,
- };
- var pill = new Microsoft.UI.Xaml.Controls.Button
- {
- Command = p.ToggleIsoCommand,
- MinWidth = 80,
- Padding = new Thickness(14, 6, 14, 6),
- Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BgSurface"],
- BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["BorderStrong"],
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(999),
- VerticalAlignment = VerticalAlignment.Center,
- Content = pillText,
- };
- Microsoft.UI.Xaml.Controls.Grid.SetColumn(pill, 4);
- grid.Children.Add(pill);
-
- // Per-row property-change subscription — refresh text as the
- // engine pushes updates.
- p.PropertyChanged += (_, e) =>
- {
- grid.DispatcherQueue.TryEnqueue(() =>
- {
- switch (e.PropertyName)
- {
- case nameof(ParticipantViewModel.DisplayName):
- nameText.Text = p.DisplayName;
- initialsText.Text = p.Initials;
- break;
- case nameof(ParticipantViewModel.SourceCodec):
- codecText.Text = p.SourceCodec;
- break;
- case nameof(ParticipantViewModel.DisplayedAudioLevel):
- meter.Value = p.DisplayedAudioLevel;
- break;
- case nameof(ParticipantViewModel.OutputName):
- outputText.Text = p.OutputName;
- break;
- case nameof(ParticipantViewModel.IsoStateLabel):
- case nameof(ParticipantViewModel.IsEnabled):
- pillText.Text = p.IsoStateLabel;
- break;
- }
- });
- };
-
- return grid;
- }
-
- private void OnThemeToggleClick(object sender, RoutedEventArgs e)
- {
- ThemeManager.Current.Toggle();
- }
-
- private bool _drawerOpen;
-
- private void OnSettingsClick(object sender, RoutedEventArgs e)
- {
- _drawerOpen = !_drawerOpen;
- SettingsDrawerHost.Visibility = _drawerOpen
- ? Visibility.Visible
- : Visibility.Collapsed;
- if (_drawerOpen)
- {
- SettingsDrawerHost.CloseRequested -= OnDrawerCloseRequested;
- SettingsDrawerHost.CloseRequested += OnDrawerCloseRequested;
- }
- }
-
- private void OnDrawerCloseRequested(object? sender, System.EventArgs e)
- {
- _drawerOpen = false;
- SettingsDrawerHost.Visibility = Visibility.Collapsed;
- }
-
- ///
- /// Teams orchestration — mute. Drives the Teams app's in-call mute button
- /// via UIAutomation (TeamsControlBridge does the localized-name search).
- /// Surface failures via the status bar so the operator gets feedback
- /// without a popup.
- ///
- private void OnMuteClick(object sender, RoutedEventArgs e)
- {
- var result = Services.TeamsControlBridge.ToggleMute();
- StatusBarText.Text = DescribeBridgeResult("Mute", result);
- }
-
- private void OnCameraClick(object sender, RoutedEventArgs e)
- {
- var result = Services.TeamsControlBridge.ToggleCamera();
- StatusBarText.Text = DescribeBridgeResult("Camera", result);
- }
-
- private void OnShareClick(object sender, RoutedEventArgs e)
- {
- var result = Services.TeamsControlBridge.OpenShareTray();
- StatusBarText.Text = DescribeBridgeResult("Share", result);
- }
-
- private void OnLeaveClick(object sender, RoutedEventArgs e)
- {
- var result = Services.TeamsControlBridge.LeaveCall();
- StatusBarText.Text = DescribeBridgeResult("Leave", result);
- }
-
- private static string DescribeBridgeResult(string action, Services.TeamsControlBridge.InvokeResult r) =>
- r switch
- {
- Services.TeamsControlBridge.InvokeResult.Invoked => $"{action} invoked",
- Services.TeamsControlBridge.InvokeResult.TeamsNotRunning => $"{action} failed — Teams isn't running",
- Services.TeamsControlBridge.InvokeResult.ControlNotFound => $"{action} failed — control not visible (not in a call?)",
- Services.TeamsControlBridge.InvokeResult.InvokeFailed => $"{action} failed — Teams refused the invoke",
- _ => $"{action} failed",
- };
-
- /// Launch Teams if not running, else show its windows.
- private void OnLaunchTeamsClick(object sender, RoutedEventArgs e)
- {
- if (Services.TeamsLauncher.IsRunning())
- {
- var shown = Services.TeamsLauncher.ShowWindows();
- StatusBarText.Text = $"Showed {shown} Teams window{(shown == 1 ? "" : "s")}";
- }
- else
- {
- if (Services.TeamsLauncher.TryLaunch(out var err))
- {
- StatusBarText.Text = "Teams launched";
- }
- else
- {
- StatusBarText.Text = $"Teams launch failed: {err}";
- }
- }
- }
-
- /// Toggle Teams window visibility — invisible/visible flip.
- private bool _teamsHidden;
- private void OnToggleTeamsWindowsClick(object sender, RoutedEventArgs e)
- {
- if (_teamsHidden)
- {
- var n = Services.TeamsLauncher.ShowWindows();
- _teamsHidden = false;
- StatusBarText.Text = $"Showed {n} Teams window{(n == 1 ? "" : "s")}";
- }
- else
- {
- var n = Services.TeamsLauncher.HideWindows();
- _teamsHidden = true;
- StatusBarText.Text = $"Hid {n} Teams window{(n == 1 ? "" : "s")}";
- }
- }
-
- private void ApplyResolvedTheme(ElementTheme theme)
- {
- if (Content is FrameworkElement root)
- {
- root.RequestedTheme = theme;
- }
-
- AppWindow.TitleBar.ButtonForegroundColor = ThemeManager.TitleBarForegroundFor(theme);
- AppWindow.TitleBar.ButtonHoverBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme);
- AppWindow.TitleBar.ButtonPressedBackgroundColor = ThemeManager.TitleBarHoverBgFor(theme);
-
- ThemeToggleIcon.Glyph = theme == ElementTheme.Light ? "" : "";
- }
-
-}
diff --git a/src/TeamsISO.App.WinUI/Views/OnboardingDialog.xaml b/src/TeamsISO.App.WinUI/Views/OnboardingDialog.xaml
deleted file mode 100644
index a4eedd5..0000000
--- a/src/TeamsISO.App.WinUI/Views/OnboardingDialog.xaml
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
- TeamsISO sits between Microsoft Teams' NDI broadcast and your live-production switcher.
- One-time setup gets you to the participants table.
-
-
-
-
-
-
-
-
-
-
- From https://ndi.video/tools/. TeamsISO won't start without it — the engine relies on
- NDI 5 for discovery and routing. If the runtime is missing, you'll see a launch error;
- install it then relaunch.
-
-
-
-
-
-
-
-
-
-
-
- Teams admin must enable NDI broadcast for your tenant. In Teams, Settings → Devices →
- "Allow NDI usage." Per-participant streams will appear as TeamsISO discovers them.
-
-
-
-
-
-
-
-
-
-
-
- Defaults to 1920×1080 at 30 fps with letterbox aspect. Adjust under Settings → Routing.
- Recording outputs land in %USERPROFILE%\Videos\TeamsISO\<date>\.
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Views/OnboardingDialog.xaml.cs b/src/TeamsISO.App.WinUI/Views/OnboardingDialog.xaml.cs
deleted file mode 100644
index f75b860..0000000
--- a/src/TeamsISO.App.WinUI/Views/OnboardingDialog.xaml.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using Microsoft.UI.Xaml.Controls;
-
-namespace TeamsISO.App.WinUI.Views;
-
-public sealed partial class OnboardingDialog : ContentDialog
-{
- public OnboardingDialog()
- {
- InitializeComponent();
- }
-
- ///
- /// True when the user wants the dialog suppressed on subsequent launches.
- /// Caller persists this to UIPreferences alongside the existing
- /// "shown welcome" flag.
- ///
- public bool SuppressFutureLaunches => DontShowAgain.IsChecked == true;
-}
diff --git a/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml b/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml
deleted file mode 100644
index 058e780..0000000
--- a/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml
+++ /dev/null
@@ -1,100 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml.cs b/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml.cs
deleted file mode 100644
index 8fb316b..0000000
--- a/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml.cs
+++ /dev/null
@@ -1,275 +0,0 @@
-using System;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Media;
-using TeamsISO.App.WinUI.Services;
-
-namespace TeamsISO.App.WinUI.Views;
-
-public sealed partial class SettingsDrawer : UserControl
-{
- ///
- /// Raised when the operator clicks the close button or hits Esc. The host
- /// (MainWindow) handles the actual collapse animation since drawer
- /// hosting lives at that level — the drawer just signals intent.
- ///
- public event EventHandler? CloseRequested;
-
- public SettingsDrawer()
- {
- InitializeComponent();
- BuildTabStrip();
- SelectTab("Appearance");
- }
-
- private string _currentTab = "Appearance";
-
- /// Build the tab buttons imperatively so the parse doesn't
- /// trigger a SelectionChanged before the code-behind is ready.
- private void BuildTabStrip()
- {
- foreach (var (label, key) in new[]
- {
- ("Appearance", "Appearance"),
- ("Routing", "Routing"),
- ("Display", "Display"),
- ("Control", "Control"),
- ("Advanced", "Advanced"),
- })
- {
- var btn = new Button
- {
- Content = label,
- Tag = key,
- Style = (Style)Application.Current.Resources["ButtonTertiary"],
- };
- btn.Click += (s, _) =>
- {
- if (s is Button b && b.Tag is string k) SelectTab(k);
- };
- TabStrip.Children.Add(btn);
- }
- }
-
- private void SelectTab(string key)
- {
- _currentTab = key;
- foreach (var child in TabStrip.Children)
- {
- if (child is Button b)
- {
- var isActive = (b.Tag as string) == key;
- b.Foreground = (Microsoft.UI.Xaml.Media.SolidColorBrush)Application.Current.Resources[
- isActive ? "AccentCyanText" : "FgSecondary"];
- }
- }
- RebuildTabContent(key);
- }
-
- private void RebuildTabContent(string key)
- {
- TabContent.Children.Clear();
- switch (key)
- {
- case "Appearance": BuildAppearanceTab(); break;
- case "Routing": BuildRoutingTab(); break;
- case "Display": BuildDisplayTab(); break;
- case "Control": BuildControlTab(); break;
- case "Advanced": BuildAdvancedTab(); break;
- }
- }
-
- private void BuildRoutingTab()
- {
- TabContent.Children.Add(SettingHeader("Routing"));
- TabContent.Children.Add(SettingRow("Framerate", "30 fps", "Target framerate the engine normalizes incoming NDI feeds to."));
- TabContent.Children.Add(SettingRow("Resolution", "1920 x 1080", "Output resolution applied to every routed participant."));
- TabContent.Children.Add(SettingRow("Aspect mode", "Letterbox", "How sources whose aspect ratio doesn't match the target are framed."));
- TabContent.Children.Add(SettingRow("Audio routing", "Per-participant", "Embed each participant's audio in their own NDI source."));
- TabContent.Children.Add(SettingNote("Settings wired to the engine in a follow-up commit."));
- }
-
- private void BuildDisplayTab()
- {
- TabContent.Children.Add(SettingHeader("Display & participants"));
- TabContent.Children.Add(SettingRow("Hide local self", "Yes", "Filter the operator's own preview from the participants table."));
- TabContent.Children.Add(SettingRow("Auto-disable on departure", "No", "Keep routing alive when a participant goes offline."));
- TabContent.Children.Add(SettingRow("Participant sort", "Join order", "Default sort for the participants table."));
- TabContent.Children.Add(SettingRow("Minimize to tray", "No", "Keep TeamsISO running in the system tray when the window is minimized."));
- TabContent.Children.Add(SettingRow("Launch Teams on startup", "No", "Start Teams in the background when TeamsISO opens."));
- TabContent.Children.Add(SettingRow("Auto-hide Teams windows", "No", "Hide every visible Teams window after launch."));
- TabContent.Children.Add(SettingRow("Auto-record on call", "No", "Start recording every active ISO when Teams enters a call."));
- }
-
- private void BuildControlTab()
- {
- TabContent.Children.Add(SettingHeader("External control surface"));
- TabContent.Children.Add(SettingRow("REST + WebSocket", "127.0.0.1:9755", "HTTP control surface for Companion / Stream Deck."));
- TabContent.Children.Add(SettingRow("OSC bridge", "127.0.0.1:9000", "UDP OSC for TouchOSC / hardware surfaces."));
- TabContent.Children.Add(SettingRow("LAN reachable", "No", "Bind the REST surface to all interfaces (warning: no auth)."));
- TabContent.Children.Add(SettingNote("Control surface protocol unchanged from the WPF host. See docs/CONTROL-SURFACE.md."));
- }
-
- private void BuildAdvancedTab()
- {
- TabContent.Children.Add(SettingHeader("Advanced"));
- TabContent.Children.Add(SettingRow("Embed Teams window", "No", "Experimental SetParent reparent of Teams' main window."));
- TabContent.Children.Add(SettingRow("Logs", "%LOCALAPPDATA%\\TeamsISO\\Logs", "Where rolling daily Serilog files write."));
- TabContent.Children.Add(SettingRow("Recordings", "%USERPROFILE%\\Videos\\TeamsISO", "Default per-show recording directory."));
- TabContent.Children.Add(SettingRow("Diagnostic bundle", "Export", "Zip logs + config + presets for a bug report."));
- }
-
- private void OnCloseClick(object sender, RoutedEventArgs e) => CloseRequested?.Invoke(this, EventArgs.Empty);
-
- private void OnApplyClick(object sender, RoutedEventArgs e)
- {
- // Tabs already persist on change; Apply is here for affordance
- // consistency with the WPF host. Could surface a toast in the future.
- CloseRequested?.Invoke(this, EventArgs.Empty);
- }
-
- private void OnResetClick(object sender, RoutedEventArgs e)
- {
- // Defaults restoration plumbing follows in the view-model wiring
- // commit. For now, just clear the dirty hint.
- DirtyHint.Text = "Reset queued — apply or close to commit.";
- }
-
-
- ///
- /// Appearance tab — theme picker, accent palette peek. The theme picker
- /// is the only setting that affects the UI immediately (others apply on
- /// the engine layer once wired). Selection writes to UIPreferences and
- /// flips Window.Content.RequestedTheme synchronously.
- ///
- private void BuildAppearanceTab()
- {
- TabContent.Children.Add(SettingHeader("Appearance"));
-
- var themeLabel = new TextBlock
- {
- Style = (Style)Application.Current.Resources["TextBody"],
- Text = "Theme",
- Margin = new Thickness(0, 0, 0, 6),
- };
- TabContent.Children.Add(themeLabel);
-
- var themeRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
- foreach (var (label, value) in new[]
- {
- ("System", "System"),
- ("Dark", "Dark"),
- ("Light", "Light"),
- })
- {
- var btn = new RadioButton
- {
- Content = label,
- Tag = value,
- GroupName = "Theme",
- IsChecked = ThemeManager.Current.PreferenceMatches(value),
- };
- btn.Checked += (s, _) =>
- {
- if (s is RadioButton rb && rb.Tag is string v)
- {
- ThemeManager.Current.Set(v);
- }
- };
- themeRow.Children.Add(btn);
- }
- TabContent.Children.Add(themeRow);
-
- TabContent.Children.Add(SettingNote(
- "Dark is the default for the 1:50am operator scene; light is for daytime " +
- "production. System follows the Windows app-mode preference."));
-
- TabContent.Children.Add(SettingDivider());
- TabContent.Children.Add(SettingHeader("Accent peek"));
-
- var accentRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 };
- accentRow.Children.Add(AccentSwatch("Cyan", "AccentCyanSurface"));
- accentRow.Children.Add(AccentSwatch("Coral", "AccentCoral"));
- accentRow.Children.Add(AccentSwatch("Live", "StatusLive"));
- accentRow.Children.Add(AccentSwatch("Warn", "StatusWarn"));
- TabContent.Children.Add(accentRow);
-
- TabContent.Children.Add(SettingNote(
- "These accents work in both themes. Cyan stays bright as a surface fill " +
- "(text on top is near-black regardless of theme). For inline text use, the " +
- "light palette substitutes a darker cyan automatically."));
- }
-
- // ──────────────────── helpers ──────────────────────────────────────────
-
- private static TextBlock SettingHeader(string text) => new()
- {
- Style = (Style)Application.Current.Resources["TextHeading"],
- Text = text,
- Margin = new Thickness(0, 0, 0, 8),
- };
-
- private static Grid SettingRow(string label, string value, string? tooltip = null)
- {
- var g = new Grid { Margin = new Thickness(0, 0, 0, 6) };
- g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(180) });
- g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- var l = new TextBlock
- {
- Style = (Style)Application.Current.Resources["TextBody"],
- Text = label,
- VerticalAlignment = VerticalAlignment.Center,
- };
- var v = new TextBlock
- {
- Style = (Style)Application.Current.Resources["TextSubtle"],
- Text = value,
- VerticalAlignment = VerticalAlignment.Center,
- };
- if (tooltip is not null)
- {
- ToolTipService.SetToolTip(l, tooltip);
- }
- Grid.SetColumn(l, 0);
- Grid.SetColumn(v, 1);
- g.Children.Add(l);
- g.Children.Add(v);
- return g;
- }
-
- private static TextBlock SettingNote(string text) => new()
- {
- Style = (Style)Application.Current.Resources["TextCaption"],
- Text = text,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 4, 0, 12),
- };
-
- private static Border SettingDivider() => new()
- {
- Height = 1,
- Background = (SolidColorBrush)Application.Current.Resources["BorderSubtle"],
- Margin = new Thickness(0, 12, 0, 12),
- };
-
- private static Border AccentSwatch(string label, string brushKey)
- {
- var inner = new StackPanel { Orientation = Orientation.Vertical, Spacing = 4 };
- var swatch = new Border
- {
- Width = 80,
- Height = 32,
- CornerRadius = new Microsoft.UI.Xaml.CornerRadius(6),
- Background = (SolidColorBrush)Application.Current.Resources[brushKey],
- };
- var caption = new TextBlock
- {
- Style = (Style)Application.Current.Resources["TextCaption"],
- Text = label,
- HorizontalAlignment = HorizontalAlignment.Center,
- };
- inner.Children.Add(swatch);
- inner.Children.Add(caption);
- return new Border { Child = inner };
- }
-}
diff --git a/src/TeamsISO.App.WinUI/app.manifest b/src/TeamsISO.App.WinUI/app.manifest
deleted file mode 100644
index 5dbdde4..0000000
--- a/src/TeamsISO.App.WinUI/app.manifest
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- true/PM
- PerMonitorV2, PerMonitor
-
- true
-
-
-
diff --git a/src/TeamsISO.App/AboutWindow.xaml b/src/TeamsISO.App/AboutWindow.xaml
index 99c8a17..2a06d13 100644
--- a/src/TeamsISO.App/AboutWindow.xaml
+++ b/src/TeamsISO.App/AboutWindow.xaml
@@ -147,12 +147,6 @@
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
-