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/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +