feat(wpf): rollback to WPF host, axe recording, fix settings pane
Some checks failed
CI / build-and-test (push) Failing after 29s

This commit is contained in:
Zac Gaetano 2026-05-14 06:02:40 -04:00
parent 426cf33dec
commit 1d1ce6a2a0
44 changed files with 79 additions and 5073 deletions

View file

@ -21,8 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\Tea
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {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} {A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
{14928B5A-E45C-4265-A5D7-D13B5ED18F84} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="TeamsISO.App.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!--
Tokens.xaml owns the color/typography/spacing primitives in
a ThemeDictionary so {ThemeResource} on the consumer side
auto-swaps when RequestedTheme flips. Controls.xaml owns
the actual Style targets (Button, TextBlock, etc.) and
references the tokens via {ThemeResource}.
-->
<ResourceDictionary Source="ms-appx:///Themes/Tokens.xaml"/>
<ResourceDictionary Source="ms-appx:///Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -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;
/// <summary>
/// WinUI 3 entry — brings up MainWindow and wires the engine pipeline.
/// </summary>
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<App>();
logger.LogInformation(
"TeamsISO.App.WinUI starting. Build: {Version}. PID: {Pid}.",
typeof(App).Assembly.GetName().Version,
Environment.ProcessId);
try
{
_interop = new NdiInteropPInvoke(_loggerFactory.CreateLogger<NdiInteropPInvoke>());
}
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<ConfigStore>());
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}");
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,56 +0,0 @@
using System.Collections.Generic;
namespace TeamsISO.App.WinUI.Models;
/// <summary>
/// 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.
/// </summary>
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<MockParticipant> Sample()
{
return new List<MockParticipant>
{
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,
},
};
}
}

View file

@ -1,63 +0,0 @@
using System;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.Windows.ApplicationModel.DynamicDependency;
namespace TeamsISO.App.WinUI;
/// <summary>
/// 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.
/// </summary>
public static class Program
{
/// <summary>WindowsAppSDK 1.8 major/minor packed as 0x00010008.</summary>
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;
}
}

View file

@ -1,398 +0,0 @@
using System.Diagnostics;
using System.Windows.Automation;
namespace TeamsISO.App.WinUI.Services;
/// <summary>
/// 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 <see cref="InvokePattern"/> or <see cref="TogglePattern"/>.
///
/// 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 <see cref="TeamsLauncher.HideWindows"/>) 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.
/// </summary>
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
"背景効果", "背景フィルター",
};
/// <summary>Result of attempting one of the in-call commands.</summary>
public enum InvokeResult
{
/// <summary>The control was found and invoked successfully.</summary>
Invoked,
/// <summary>Teams isn't running, or its automation root couldn't be located.</summary>
TeamsNotRunning,
/// <summary>Teams is running but the matching button isn't currently exposed (maybe not in a call).</summary>
ControlNotFound,
/// <summary>The button was found but didn't expose a usable invoke / toggle pattern.</summary>
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);
/// <summary>
/// Snapshot of the current call's local-user state. Read via a single
/// UIA traversal in <see cref="DetectCallState"/>; null sub-fields when
/// the call isn't active or the button isn't in the tree.
/// </summary>
public sealed record CallStateSnapshot(bool IsInCall, bool? IsMuted, bool? IsCameraOff);
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<string> 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;
}
/// <summary>
/// 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).
/// </summary>
private static List<AutomationElement> GetTeamsAutomationRoots()
{
var teamsPids = new HashSet<int>(
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<AutomationElement>();
// 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<AutomationElement>();
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; }
}
/// <summary>
/// Try Invoke first (most buttons), then Toggle (mute/camera are usually
/// toggle-pattern). Returns true if either succeeded.
/// </summary>
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;
}
}

View file

@ -1,665 +0,0 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
namespace TeamsISO.App.WinUI.Services;
/// <summary>
/// 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.
/// </summary>
public static class TeamsLauncher
{
/// <summary>
/// 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.
/// </summary>
private static readonly string[] TeamsProcessNames =
{
"ms-teams", // new MSTeams binary basename
"msteams", // alternate basename observed on some installs
"Teams", // classic Teams desktop client
};
/// <summary>
/// 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".
/// </summary>
public static bool IsRunning() =>
TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0);
/// <summary>
/// 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; <paramref name="errorMessage"/> includes the
/// reasons each attempt was rejected so the operator can see why.
///
/// Path order matters:
/// 1. <c>ms-teams:</c> URI — new Teams (MSTeams AppX) registers this
/// handler at install. Activates through the AppX shell so the
/// stub <c>ms-teams.exe</c> 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 <c>ms-teams.exe</c> WindowsApps
/// path: it's a 0-byte AppX placeholder that fails silently when invoked
/// without AppX activation context. Looked plausible, never worked.
/// </summary>
public static bool TryLaunch(out string? errorMessage)
{
errorMessage = null;
var attempts = new List<string>();
// 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;
}
/// <summary>
/// Asks every running Teams process to close gracefully via WM_CLOSE
/// (CloseMainWindow). Returns the count of processes that exited cleanly within
/// <paramref name="gracePeriod"/>. 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.
/// </summary>
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;
}
/// <summary>
/// Hand a meeting URL off to the Teams shell handler. Accepts both the
/// <c>https://teams.microsoft.com/l/meetup-join/...</c> web format and
/// the <c>msteams:/l/meetup-join/...</c> 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
private static (IntPtr OriginalParent, int OriginalStyle)? _embedSavedState;
private static IntPtr _embeddedHwnd = IntPtr.Zero;
/// <summary>True when a Teams window is currently parented inside a TeamsISO host.</summary>
public static bool IsEmbedded => _embeddedHwnd != IntPtr.Zero;
/// <summary>
/// Reparents Teams' most-recently-used top-level window into
/// <paramref name="hostHwnd"/>. 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
/// </summary>
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;
}
/// <summary>
/// Resize the currently-embedded Teams window to <paramref name="width"/>
/// × <paramref name="height"/>. Called when the host element resizes
/// (window resize, layout change, etc.). No-op if nothing is embedded.
/// </summary>
public static void ResizeEmbedded(int width, int height)
{
if (_embeddedHwnd == IntPtr.Zero || width <= 0 || height <= 0) return;
MoveWindow(_embeddedHwnd, 0, 0, width, height, true);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public static string GetActiveWindowTitle()
{
try
{
var teamsPids = new HashSet<uint>(
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; }
}
/// <summary>
/// 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.
/// </summary>
private static List<IntPtr> FindTeamsTopLevelWindows()
{
var teamsPids = new HashSet<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
if (teamsPids.Count == 0) return new List<IntPtr>();
var windows = new List<IntPtr>();
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;
}
/// <summary>
/// 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).
/// </summary>
public static int HideWindows()
{
var windows = FindTeamsTopLevelWindows();
foreach (var w in windows) ShowWindow(w, SW_HIDE);
return windows.Count;
}
/// <summary>
/// Fire-and-forget background watcher that polls every 250ms for up to
/// <paramref name="timeout"/> 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).
/// </summary>
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);
/// <summary>
/// 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 (<see cref="TeamsControlBridge"/>) when an equivalent
/// button exists.
/// </summary>
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;
}
/// <summary>
/// Restores every Teams top-level window from hidden state and brings the
/// most recently used one to the foreground. Returns the count shown.
/// </summary>
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<uint>(
TeamsProcessNames
.SelectMany(n => Process.GetProcessesByName(n))
.Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
var windows = new List<IntPtr>();
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;
}
}

View file

@ -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;
/// <summary>
/// Owns the active theme for the WinUI 3 host. Three preferences:
/// <c>System</c> follows the Windows app-mode setting (default for new
/// users); <c>Dark</c> and <c>Light</c> 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 <see cref="Themed"/> 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).
/// </summary>
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<ElementTheme>? Themed;
/// <summary>
/// Resolve the preference to an absolute <see cref="ElementTheme"/>
/// suitable for <see cref="FrameworkElement.RequestedTheme"/>.
/// <c>System</c> resolves to the OS app-mode.
/// </summary>
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);
/// <summary>
/// Cycle dark ↔ light from the title-bar toggle. If the current
/// preference is <c>System</c>, the cycle pins to the opposite of the
/// currently-resolved theme so the click has a visible effect.
/// </summary>
public ElementTheme Toggle()
{
var current = ResolveTheme();
Set(current == ElementTheme.Dark ? "Light" : "Dark");
return ResolveTheme();
}
/// <summary>Set the preference, persist to disk, broadcast the resolved theme.</summary>
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());
}
}
/// <summary>
/// Compute the AppWindow title-bar foreground for the given resolved
/// theme so the system min/max/close buttons stay readable.
/// </summary>
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);
}

View file

@ -1,85 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
namespace TeamsISO.App.WinUI.Services;
/// <summary>
/// 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.
/// </summary>
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 }
/// <summary>The on-disk shape. New fields added here become opt-in for older files via default values.</summary>
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<Prefs>(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.
}
}
/// <summary>Update just the Theme field without touching other prefs.</summary>
public static void SetTheme(string theme)
{
var current = Load();
Save(current with { Theme = theme });
}
}

View file

@ -1,146 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
TeamsISO WinUI 3 host. Coexists with the WPF project (src/TeamsISO.App)
during the redesign migration. Shares the engine (TeamsISO.Engine) and
the NDI interop assembly via ProjectReference. Once the WinUI 3 build is
feature-complete and tested against a real Teams meeting, the WPF
project is retired and this becomes the only shipping host.
Target framework choice: net8.0-windows10.0.19041.0 is the minimum the
Windows App SDK supports cleanly. Going higher (e.g. 22621) would lock
out Win10 1809+ operators, which is undesirable for a broadcast tool
that still has to run on hardware in working broadcast suites.
Packaging mode: WindowsPackageType=None for "unpackaged" — the .exe
drops directly into Program Files via the existing MSI rather than
going through MSIX. The Windows App Runtime install becomes a prereq
of the MSI (or bootstrapped at startup), which matches how operators
install NDI Runtime today.
-->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<RootNamespace>TeamsISO.App.WinUI</RootNamespace>
<AssemblyName>TeamsISO</AssemblyName>
<!--
Default app.manifest deferred: WinUI 3 emits its own manifest with the
DPI awareness + supportedOS GUIDs that match what we want. Our custom
manifest (kept in tree at app.manifest) describes the same intent but
doesn't currently merge cleanly with the framework-emitted manifest;
see docs/superpowers/plans/2026-05-12-winui3-migration.md for the
follow-up to reintroduce it via uap:VisualElements.
-->
<Platforms>x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType>
<EnableMsixTooling>true</EnableMsixTooling>
<!--
Pinning the Windows SDK projection package: WindowsAppSDK 1.6 requires
Microsoft.Windows.SDK.NET.Ref >= 10.0.19041.38, but the .NET 8.0.301
SDK installed here ships an older Ref. Setting this explicitly avoids
having to upgrade the .NET SDK on the build host.
-->
<WindowsSdkPackageVersion>10.0.19041.38</WindowsSdkPackageVersion>
<!--
Disable the XAML compiler's auto-generated Program.Main so we can write
one that bootstraps the Windows App Runtime explicitly. The default
generated Main calls Application.Start directly, with no Bootstrap
initialization step — that's fine for packaged MSIX apps but blocks
unpackaged launch on a machine where the runtime is installed only as
a framework package. Program.cs in this project takes ownership of
Main and calls Bootstrap.TryInitialize(0x00010006) before Start.
-->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
<!--
RuntimeIdentifier locks the build to win-x64 so runtime DLLs from
runtimes/win-x64/native (Microsoft.WindowsAppRuntime.Bootstrap.dll,
WebView2Loader.dll) flatten into the output dir alongside the .exe.
Without this, those DLLs sit in runtimes/win-x64/native/ and the
loader doesn't find them at activation time.
-->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!--
Suppress the auto-injected UndockedRegFreeWinRT ModuleInitializer.
The bundled initializer P/Invokes Microsoft.WindowsAppRuntime.dll
during module load, BEFORE our Program.Main has a chance to call
Bootstrap.TryInitialize. Since the runtime DLL lives in the framework
MSIX package (not the output dir) on a framework-dependent install,
the P/Invoke fails to locate it and the .exe dies with the generic
"this application could not be started" dialog — diagnosed by
following the Microsoft.WindowsAppSDK.UndockedRegFreeWinRT.CS.targets
chain and reading the auto-init source. Our Program.cs handles the
bootstrap explicitly in the right order.
-->
<WindowsAppSdkUndockedRegFreeWinRTInitialize>false</WindowsAppSdkUndockedRegFreeWinRTInitialize>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Assets\teamsiso.ico</ApplicationIcon>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!--
WindowsAppSDK 1.8 is the version whose DDLM is installed on the build
host (verified via Get-AppxPackage MicrosoftCorporationII.WinAppRuntime.
Main.1.8). 1.6's framework package is present, but its DDLM sibling
isn't — bootstrap returns MDD_E_BOOTSTRAP_INITIALIZE_DDLM_NOT_FOUND.
See docs/superpowers/work-log-2026-05-12.md for the full diagnosis.
DataGrid lives in the older 7.x Community Toolkit because the 8.x line
dropped it; 7.1.2 still works against WindowsAppSDK 1.8 and is the
only currently-maintained free DataGrid for this stack.
-->
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.250916003" />
<PackageReference Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TeamsISO.Engine\TeamsISO.Engine.csproj" />
<ProjectReference Include="..\TeamsISO.Engine.NdiInterop\TeamsISO.Engine.NdiInterop.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\teamsiso.ico" />
<Content Include="Assets\dragon-mark.png" />
<Content Include="Assets\wild-dragon-wordmark.png" />
<Content Include="Assets\Fonts\Inter.ttf" />
<Content Include="Assets\Fonts\JetBrainsMono.ttf" />
</ItemGroup>
<!--
Post-build runtimeconfig patch. .NET 8 SDK adds Microsoft.WindowsDesktop.App
as an implicit framework reference for any -windows target framework moniker.
WinUI 3 doesn't use that framework; including it in runtimeconfig.json
forces the .NET host to resolve and load WindowsDesktop.App at startup,
which contributes to the WindowsAppSDK activation chain on unpackaged
apps. This target rewrites the generated runtimeconfig.json to drop the
WindowsDesktop.App entry so the host loads only NETCore.App.
The target runs after the framework-dependent build copies the
runtimeconfig.json to the output dir, before the bin/Debug/.../win-x64/
directory is final. It's a string-level rewrite — sufficient because the
JSON shape is stable across SDK versions and the WindowsDesktop.App
entry has a deterministic indent + sibling structure.
-->
<Target Name="StripWindowsDesktopAppFromRuntimeConfig" AfterTargets="GenerateBuildRuntimeConfigurationFiles">
<PropertyGroup>
<_RuntimeConfigPath>$(OutDir)$(AssemblyName).runtimeconfig.json</_RuntimeConfigPath>
</PropertyGroup>
<PropertyGroup Condition="Exists('$(_RuntimeConfigPath)')">
<_RuntimeConfigContent>$([System.IO.File]::ReadAllText('$(_RuntimeConfigPath)'))</_RuntimeConfigContent>
<_PatchedContent>$([System.Text.RegularExpressions.Regex]::Replace($(_RuntimeConfigContent), ',\s*\{\s*&quot;name&quot;:\s*&quot;Microsoft\.WindowsDesktop\.App&quot;[^\}]*\}', ''))</_PatchedContent>
</PropertyGroup>
<WriteLinesToFile Condition="Exists('$(_RuntimeConfigPath)') and '$(_RuntimeConfigContent)' != '$(_PatchedContent)'"
File="$(_RuntimeConfigPath)"
Lines="$(_PatchedContent)"
Overwrite="true"/>
<Message Condition="Exists('$(_RuntimeConfigPath)') and '$(_RuntimeConfigContent)' != '$(_PatchedContent)'"
Text="Stripped Microsoft.WindowsDesktop.App from $(_RuntimeConfigPath)"
Importance="high"/>
</Target>
</Project>

View file

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Control styles. Where WPF needed full ControlTemplate overrides to
achieve the Wild Dragon look, WinUI 3's built-in VisualStateManager
handles hover / pressed / focus states cleanly, so most styles here
just set surface properties (background, foreground, padding, corner
radius) and the framework handles state.
The few exceptions — rail icon button, ISO toggle pill — re-template
because their interaction model (Teams-style hover wash; status-coded
pill that preserves background on hover) doesn't fit the default
Button template.
-->
<!-- ════════════ TextBlock (typographic ramp) ════════════ -->
<Style x:Key="TextDisplay" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextDisplaySize}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{ThemeResource FgPrimary}"/>
<Setter Property="LineHeight" Value="26"/>
</Style>
<Style x:Key="TextTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextTitleSize}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{ThemeResource FgPrimary}"/>
</Style>
<Style x:Key="TextHeading" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextHeadingSize}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{ThemeResource FgPrimary}"/>
</Style>
<Style x:Key="TextBody" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextBodySize}"/>
<Setter Property="Foreground" Value="{ThemeResource FgPrimary}"/>
</Style>
<Style x:Key="TextSubtle" TargetType="TextBlock" BasedOn="{StaticResource TextBody}">
<Setter Property="Foreground" Value="{ThemeResource FgSecondary}"/>
</Style>
<Style x:Key="TextCaption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextCaptionSize}"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{ThemeResource FgTertiary}"/>
<Setter Property="CharacterSpacing" Value="80"/>
</Style>
<Style x:Key="TextMono" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{ThemeResource FontMono}"/>
<Setter Property="FontSize" Value="{ThemeResource TextMonoSize}"/>
<Setter Property="Foreground" Value="{ThemeResource FgSecondary}"/>
</Style>
<!-- ════════════ Button hierarchy ════════════
Primary — single per surface, the brand action. Cyan fill, near-black text.
Secondary — common operator actions. Transparent + bordered.
Tertiary — inline dismissals, low-frequency. Text-only.
Destructive — Stop / Leave / Delete. Coral border + text.
-->
<Style x:Key="ButtonPrimary" TargetType="Button">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextBodySize}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="{ThemeResource AccentCyanSurface}"/>
<Setter Property="BorderBrush" Value="{ThemeResource AccentCyanSurface}"/>
<Setter Property="Foreground" Value="{ThemeResource FgOnAccent}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="CornerRadius" Value="{ThemeResource RadiusM}"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style x:Key="ButtonSecondary" TargetType="Button">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextBodySize}"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{ThemeResource BorderStrong}"/>
<Setter Property="Foreground" Value="{ThemeResource FgPrimary}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,7"/>
<Setter Property="CornerRadius" Value="{ThemeResource RadiusM}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style x:Key="ButtonTertiary" TargetType="Button">
<Setter Property="FontFamily" Value="{ThemeResource FontSans}"/>
<Setter Property="FontSize" Value="{ThemeResource TextBodySize}"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="Foreground" Value="{ThemeResource FgSecondary}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="CornerRadius" Value="{ThemeResource RadiusM}"/>
</Style>
<Style x:Key="ButtonDestructive" TargetType="Button" BasedOn="{StaticResource ButtonSecondary}">
<Setter Property="BorderBrush" Value="{ThemeResource AccentCoral}"/>
<Setter Property="Foreground" Value="{ThemeResource AccentCoral}"/>
</Style>
<!-- Title-bar caption buttons. 46x32 to match Windows 11 standard. The
close button gets a coral-red hover treatment via VSM override
(handled per-button inline since WinUI 3's default Close-button
red is the wrong red for our palette). -->
<Style x:Key="ButtonCaption" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{ThemeResource FgPrimary}"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<!-- Rail icon button. Vertical-square 48x48 with rounded fill on hover.
Used in the left rail; the icon (FontIcon) sits inside as Content. -->
<Style x:Key="ButtonRailIcon" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{ThemeResource FgSecondary}"/>
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Margin" Value="0,4"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="CornerRadius" Value="{ThemeResource RadiusM}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<!-- ════════════ Card / Pill / Status containers ════════════ -->
<Style x:Key="CardBorder" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource BgSurface}"/>
<Setter Property="BorderBrush" Value="{ThemeResource BorderSubtle}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="{ThemeResource RadiusL}"/>
<Setter Property="Padding" Value="16"/>
</Style>
<Style x:Key="PillBorder" TargetType="Border">
<Setter Property="Background" Value="{ThemeResource BgElevated}"/>
<Setter Property="CornerRadius" Value="{ThemeResource RadiusPill}"/>
<Setter Property="Padding" Value="10,4"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</ResourceDictionary>

View file

@ -1,194 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
TeamsISO design tokens — Wild Dragon brand × redesigned IA.
Color tokens live inside ResourceDictionary.ThemeDictionaries so
{ThemeResource} consumers swap automatically when RequestedTheme
flips (no app restart, no flicker). Brushes are paired per theme
because Color resolution is theme-scoped.
Spacing, radii, and typography tokens are theme-agnostic and live
at the top level.
Token naming mirrors DESIGN.md. The dark palette is the canonical
reference; the light palette inverts the value scale while
preserving brand recognition (cyan-tinted off-whites, not pure
white surfaces).
-->
<ResourceDictionary.ThemeDictionaries>
<!-- ════════════ DARK ════════════ -->
<ResourceDictionary x:Key="Default">
<!-- Surfaces -->
<Color x:Key="BgCanvasColor">#FF0A0A0A</Color>
<Color x:Key="BgRailColor">#FF080808</Color>
<Color x:Key="BgSurfaceColor">#FF141416</Color>
<Color x:Key="BgElevatedColor">#FF1C1C1F</Color>
<Color x:Key="BgHoverColor">#FF26272B</Color>
<Color x:Key="BgActiveColor">#FF33343A</Color>
<!-- Borders -->
<Color x:Key="BorderSubtleColor">#FF26272B</Color>
<Color x:Key="BorderStrongColor">#FF3A3B40</Color>
<!-- Text -->
<Color x:Key="FgPrimaryColor">#FFF4F4F6</Color>
<Color x:Key="FgSecondaryColor">#FFA3A4AA</Color>
<Color x:Key="FgTertiaryColor">#FF6B6C72</Color>
<Color x:Key="FgDisabledColor">#FF404145</Color>
<Color x:Key="FgOnAccentColor">#FF0A0A0A</Color>
<!-- Accents (context-aware) -->
<Color x:Key="AccentCyanSurfaceColor">#FF97EDF0</Color>
<Color x:Key="AccentCyanTextColor">#FF97EDF0</Color>
<Color x:Key="AccentCyanHoverColor">#FFB5F2F4</Color>
<Color x:Key="AccentCyanMutedColor">#FF1B3537</Color>
<Color x:Key="AccentCoralColor">#FFFB819C</Color>
<Color x:Key="AccentCoralBgColor">#FF3A1922</Color>
<Color x:Key="StatusLiveColor">#FF4ADE80</Color>
<Color x:Key="StatusLiveBgColor">#FF13261A</Color>
<Color x:Key="StatusWarnColor">#FFFBBF24</Color>
<Color x:Key="StatusWarnBgColor">#FF3A2E12</Color>
<!-- Brushes — one per Color, used by consumers via {ThemeResource} -->
<SolidColorBrush x:Key="BgCanvas" Color="{ThemeResource BgCanvasColor}"/>
<SolidColorBrush x:Key="BgRail" Color="{ThemeResource BgRailColor}"/>
<SolidColorBrush x:Key="BgSurface" Color="{ThemeResource BgSurfaceColor}"/>
<SolidColorBrush x:Key="BgElevated" Color="{ThemeResource BgElevatedColor}"/>
<SolidColorBrush x:Key="BgHover" Color="{ThemeResource BgHoverColor}"/>
<SolidColorBrush x:Key="BgActive" Color="{ThemeResource BgActiveColor}"/>
<SolidColorBrush x:Key="BorderSubtle" Color="{ThemeResource BorderSubtleColor}"/>
<SolidColorBrush x:Key="BorderStrong" Color="{ThemeResource BorderStrongColor}"/>
<SolidColorBrush x:Key="FgPrimary" Color="{ThemeResource FgPrimaryColor}"/>
<SolidColorBrush x:Key="FgSecondary" Color="{ThemeResource FgSecondaryColor}"/>
<SolidColorBrush x:Key="FgTertiary" Color="{ThemeResource FgTertiaryColor}"/>
<SolidColorBrush x:Key="FgDisabled" Color="{ThemeResource FgDisabledColor}"/>
<SolidColorBrush x:Key="FgOnAccent" Color="{ThemeResource FgOnAccentColor}"/>
<SolidColorBrush x:Key="AccentCyanSurface" Color="{ThemeResource AccentCyanSurfaceColor}"/>
<SolidColorBrush x:Key="AccentCyanText" Color="{ThemeResource AccentCyanTextColor}"/>
<SolidColorBrush x:Key="AccentCyanHover" Color="{ThemeResource AccentCyanHoverColor}"/>
<SolidColorBrush x:Key="AccentCyanMuted" Color="{ThemeResource AccentCyanMutedColor}"/>
<SolidColorBrush x:Key="AccentCoral" Color="{ThemeResource AccentCoralColor}"/>
<SolidColorBrush x:Key="AccentCoralBg" Color="{ThemeResource AccentCoralBgColor}"/>
<SolidColorBrush x:Key="StatusLive" Color="{ThemeResource StatusLiveColor}"/>
<SolidColorBrush x:Key="StatusLiveBg" Color="{ThemeResource StatusLiveBgColor}"/>
<SolidColorBrush x:Key="StatusWarn" Color="{ThemeResource StatusWarnColor}"/>
<SolidColorBrush x:Key="StatusWarnBg" Color="{ThemeResource StatusWarnBgColor}"/>
</ResourceDictionary>
<!-- ════════════ LIGHT ════════════ -->
<ResourceDictionary x:Key="Light">
<!-- Surfaces — cyan-tinted off-whites; not pure white -->
<Color x:Key="BgCanvasColor">#FFFAFAFB</Color>
<Color x:Key="BgRailColor">#FFF0F1F3</Color>
<Color x:Key="BgSurfaceColor">#FFFFFFFF</Color>
<Color x:Key="BgElevatedColor">#FFFFFFFF</Color>
<Color x:Key="BgHoverColor">#FFECEEF1</Color>
<Color x:Key="BgActiveColor">#FFE0E3E7</Color>
<!-- Borders -->
<Color x:Key="BorderSubtleColor">#FFE5E7EB</Color>
<Color x:Key="BorderStrongColor">#FFD1D5DA</Color>
<!-- Text -->
<Color x:Key="FgPrimaryColor">#FF0A0A0A</Color>
<Color x:Key="FgSecondaryColor">#FF4A4B50</Color>
<Color x:Key="FgTertiaryColor">#FF71747A</Color>
<Color x:Key="FgDisabledColor">#FFB3B6BC</Color>
<Color x:Key="FgOnAccentColor">#FF0A0A0A</Color>
<!-- Accents — surface fill stays bright cyan; text variant darkens for AA -->
<Color x:Key="AccentCyanSurfaceColor">#FF97EDF0</Color>
<Color x:Key="AccentCyanTextColor">#FF0E7C82</Color>
<Color x:Key="AccentCyanHoverColor">#FF0890A0</Color>
<Color x:Key="AccentCyanMutedColor">#FFE6F8F9</Color>
<Color x:Key="AccentCoralColor">#FFD43E5C</Color>
<Color x:Key="AccentCoralBgColor">#FFFDECF0</Color>
<Color x:Key="StatusLiveColor">#FF15803D</Color>
<Color x:Key="StatusLiveBgColor">#FFDCFCE7</Color>
<Color x:Key="StatusWarnColor">#FFB45309</Color>
<Color x:Key="StatusWarnBgColor">#FFFEF3C7</Color>
<SolidColorBrush x:Key="BgCanvas" Color="{ThemeResource BgCanvasColor}"/>
<SolidColorBrush x:Key="BgRail" Color="{ThemeResource BgRailColor}"/>
<SolidColorBrush x:Key="BgSurface" Color="{ThemeResource BgSurfaceColor}"/>
<SolidColorBrush x:Key="BgElevated" Color="{ThemeResource BgElevatedColor}"/>
<SolidColorBrush x:Key="BgHover" Color="{ThemeResource BgHoverColor}"/>
<SolidColorBrush x:Key="BgActive" Color="{ThemeResource BgActiveColor}"/>
<SolidColorBrush x:Key="BorderSubtle" Color="{ThemeResource BorderSubtleColor}"/>
<SolidColorBrush x:Key="BorderStrong" Color="{ThemeResource BorderStrongColor}"/>
<SolidColorBrush x:Key="FgPrimary" Color="{ThemeResource FgPrimaryColor}"/>
<SolidColorBrush x:Key="FgSecondary" Color="{ThemeResource FgSecondaryColor}"/>
<SolidColorBrush x:Key="FgTertiary" Color="{ThemeResource FgTertiaryColor}"/>
<SolidColorBrush x:Key="FgDisabled" Color="{ThemeResource FgDisabledColor}"/>
<SolidColorBrush x:Key="FgOnAccent" Color="{ThemeResource FgOnAccentColor}"/>
<SolidColorBrush x:Key="AccentCyanSurface" Color="{ThemeResource AccentCyanSurfaceColor}"/>
<SolidColorBrush x:Key="AccentCyanText" Color="{ThemeResource AccentCyanTextColor}"/>
<SolidColorBrush x:Key="AccentCyanHover" Color="{ThemeResource AccentCyanHoverColor}"/>
<SolidColorBrush x:Key="AccentCyanMuted" Color="{ThemeResource AccentCyanMutedColor}"/>
<SolidColorBrush x:Key="AccentCoral" Color="{ThemeResource AccentCoralColor}"/>
<SolidColorBrush x:Key="AccentCoralBg" Color="{ThemeResource AccentCoralBgColor}"/>
<SolidColorBrush x:Key="StatusLive" Color="{ThemeResource StatusLiveColor}"/>
<SolidColorBrush x:Key="StatusLiveBg" Color="{ThemeResource StatusLiveBgColor}"/>
<SolidColorBrush x:Key="StatusWarn" Color="{ThemeResource StatusWarnColor}"/>
<SolidColorBrush x:Key="StatusWarnBg" Color="{ThemeResource StatusWarnBgColor}"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<!-- ════════════ SPACING (8px grid) — theme-agnostic ════════════ -->
<x:Double x:Key="SpaceXS">4</x:Double>
<x:Double x:Key="SpaceS">8</x:Double>
<x:Double x:Key="SpaceM">12</x:Double>
<x:Double x:Key="SpaceL">16</x:Double>
<x:Double x:Key="SpaceXL">24</x:Double>
<x:Double x:Key="SpaceXXL">32</x:Double>
<x:Double x:Key="SpaceXXXL">48</x:Double>
<!-- ════════════ RADII ════════════ -->
<CornerRadius x:Key="RadiusS">6</CornerRadius>
<CornerRadius x:Key="RadiusM">8</CornerRadius>
<CornerRadius x:Key="RadiusL">12</CornerRadius>
<CornerRadius x:Key="RadiusPill">999</CornerRadius>
<!-- ════════════ TYPOGRAPHY ════════════ -->
<!--
WinUI 3 font URIs use ms-appx, not WPF's pack://. The "#Inter" suffix
is the font's family name as declared in the .ttf's name table —
without it, the font loads as a generic font and falls back to the
system default.
-->
<FontFamily x:Key="FontSans">ms-appx:///Assets/Fonts/Inter.ttf#Inter</FontFamily>
<FontFamily x:Key="FontMono">ms-appx:///Assets/Fonts/JetBrainsMono.ttf#JetBrains Mono</FontFamily>
<x:Double x:Key="TextDisplaySize">22</x:Double>
<x:Double x:Key="TextTitleSize">18</x:Double>
<x:Double x:Key="TextHeadingSize">14</x:Double>
<x:Double x:Key="TextBodySize">13</x:Double>
<x:Double x:Key="TextCaptionSize">11</x:Double>
<x:Double x:Key="TextMonoSize">12</x:Double>
</ResourceDictionary>

View file

@ -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;
/// <summary>
/// Top-level view-model for the WinUI 3 host. Subscribes to
/// <see cref="IIsoController"/>'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.
/// </summary>
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<Guid, ParticipantViewModel> _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<ParticipantViewModel> 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<string> 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<string>(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;
}
}
});
}
/// <summary>
/// Kick off the engine controller's StartAsync. Awaited by App.OnLaunched
/// so the participants observable starts firing as soon as discovery's up.
/// </summary>
public Task InitializeAsync(CancellationToken ct) => _controller.StartAsync(ct);
private void OnParticipantsChanged(IReadOnlyList<Participant> snapshot)
{
// Reconcile: add new, update existing, remove gone. Identity is by Guid.
var present = new HashSet<Guid>();
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();
}
}

View file

@ -1,26 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace TeamsISO.App.WinUI.ViewModels;
/// <summary>
/// Minimal MVVM base implementing <see cref="INotifyPropertyChanged"/>.
/// Mirrors the WPF host's ObservableObject so view-model code reads the
/// same across hosts.
/// </summary>
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<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Display label for the ISO pill: LIVE / OFF / ERROR / NO SIGNAL / STARTING.</summary>
public string IsoStateLabel => _stateLabel;
/// <summary>
/// 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.
/// </summary>
public string OutputName
{
get => _outputName;
private set => SetField(ref _outputName, value);
}
public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Engine emits an updated Participant — refresh derived fields.</summary>
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_<firstname-lowercased-with-underscores>.
// 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";
}
/// <summary>
/// Called from MainViewModel's 1Hz stats tick. Updates audio level
/// with attack/decay, state label from the engine's IsoHealthStats.
/// </summary>
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;
}
}
}

View file

@ -1,86 +0,0 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace TeamsISO.App.WinUI.ViewModels;
/// <summary>
/// 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.
/// </summary>
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? 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);
}
/// <summary>Typed-parameter variant for hotkey-driven commands (NumPad 1-9).</summary>
public sealed class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Func<T, bool>? _canExecute;
public RelayCommand(Action<T> execute, Func<T, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(Convert<T>(parameter)) ?? true;
public void Execute(object? parameter) => _execute(Convert<T>(parameter));
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
private static TValue Convert<TValue>(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!; }
}
}
/// <summary>Async command that suppresses re-entrancy while in flight.</summary>
public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isRunning;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? 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);
}

View file

@ -1,75 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentDialog
x:Class="TeamsISO.App.WinUI.Views.AboutDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="About TeamsISO"
PrimaryButtonText="Close"
DefaultButton="Primary"
Background="{ThemeResource BgElevated}"
BorderBrush="{ThemeResource BorderStrong}">
<StackPanel Spacing="14" MinWidth="380">
<StackPanel Orientation="Horizontal" Spacing="14">
<Border Width="56" Height="56"
CornerRadius="{ThemeResource RadiusM}"
Background="{ThemeResource AccentCyanMuted}">
<TextBlock Text="W"
FontFamily="{ThemeResource FontSans}"
FontSize="28"
FontWeight="Bold"
Foreground="{ThemeResource AccentCyanText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center" Spacing="2">
<TextBlock Text="TeamsISO" Style="{StaticResource TextTitle}"/>
<TextBlock Text="Per-participant NDI ISO controller for Microsoft Teams"
Style="{StaticResource TextSubtle}"
TextWrapping="Wrap"
MaxWidth="280"/>
</StackPanel>
</StackPanel>
<Border Height="1" Background="{ThemeResource BorderSubtle}"/>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version" Style="{StaticResource TextSubtle}"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="1.0.0-alpha" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Host" Style="{StaticResource TextSubtle}" Margin="0,6,0,0"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="WinUI 3 (WindowsAppSDK 1.6)" Style="{StaticResource TextMono}" Margin="0,6,0,0"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Engine" Style="{StaticResource TextSubtle}" Margin="0,6,0,0"/>
<TextBlock Grid.Row="2" Grid.Column="1" Text=".NET 8 + NDI 5" Style="{StaticResource TextMono}" Margin="0,6,0,0"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Brand" Style="{StaticResource TextSubtle}" Margin="0,6,0,0"/>
<TextBlock Grid.Row="3" Grid.Column="1" Text="Wild Dragon · wilddragon.net" Style="{StaticResource TextMono}" Margin="0,6,0,0"/>
</Grid>
<Border Height="1" Background="{ThemeResource BorderSubtle}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Style="{StaticResource ButtonSecondary}"
Content="Open logs folder"
ToolTipService.ToolTip="%LOCALAPPDATA%\TeamsISO\Logs"/>
<Button Style="{StaticResource ButtonSecondary}"
Content="Open recordings"
ToolTipService.ToolTip="%USERPROFILE%\Videos\TeamsISO"/>
<Button Style="{StaticResource ButtonSecondary}"
Content="Check for updates"
ToolTipService.ToolTip="Query forge.wilddragon.net for a newer release"/>
</StackPanel>
<TextBlock Style="{StaticResource TextCaption}"
TextWrapping="Wrap"
Text="Proprietary © Wild Dragon LLC 2026."/>
</StackPanel>
</ContentDialog>

View file

@ -1,11 +0,0 @@
using Microsoft.UI.Xaml.Controls;
namespace TeamsISO.App.WinUI.Views;
public sealed partial class AboutDialog : ContentDialog
{
public AboutDialog()
{
InitializeComponent();
}
}

View file

@ -1,110 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentDialog
x:Class="TeamsISO.App.WinUI.Views.HelpDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Keyboard shortcuts"
PrimaryButtonText="Close"
DefaultButton="Primary"
Background="{ThemeResource BgElevated}"
BorderBrush="{ThemeResource BorderStrong}">
<ScrollViewer MaxHeight="540" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6" MinWidth="420">
<TextBlock Style="{StaticResource TextCaption}"
Text="GLOBAL"
Margin="0,4,0,4"/>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="F1" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Open this help dialog" Style="{StaticResource TextBody}"/>
</Grid>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Ctrl + K" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Open the command palette" Style="{StaticResource TextBody}"/>
</Grid>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Ctrl + Shift + S" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Stop every running ISO (panic)" Style="{StaticResource TextBody}"/>
</Grid>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Ctrl + R" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Refresh NDI discovery" Style="{StaticResource TextBody}"/>
</Grid>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Ctrl + M" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Drop a recording marker" Style="{StaticResource TextBody}"/>
</Grid>
<TextBlock Style="{StaticResource TextCaption}"
Text="PARTICIPANTS"
Margin="0,16,0,4"/>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="NumPad 1-9 / 1-9" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Toggle ISO for the Nth visible participant" Style="{StaticResource TextBody}"/>
</Grid>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Right-click row" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Open preview, rename output, restart pipeline" Style="{StaticResource TextBody}"/>
</Grid>
<TextBlock Style="{StaticResource TextCaption}"
Text="LOOK"
Margin="0,16,0,4"/>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Theme toggle" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Title-bar sun/moon icon swaps dark / light" Style="{StaticResource TextBody}"/>
</Grid>
<Grid Padding="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Active speaker" Style="{StaticResource TextMono}"/>
<TextBlock Grid.Column="1" Text="Row highlights with a cyan left border" Style="{StaticResource TextBody}"/>
</Grid>
<TextBlock Style="{StaticResource TextCaption}"
Text="CONTROL SURFACE"
Margin="0,16,0,4"/>
<TextBlock Style="{StaticResource TextBody}"
TextWrapping="Wrap">
External control via REST + WebSocket on 127.0.0.1:9755 and OSC on UDP 127.0.0.1:9000.
Self-contained HTML panel at /ui. Use Bitfocus Companion or TouchOSC. See
docs/CONTROL-SURFACE.md for the command vocabulary.
</TextBlock>
</StackPanel>
</ScrollViewer>
</ContentDialog>

View file

@ -1,11 +0,0 @@
using Microsoft.UI.Xaml.Controls;
namespace TeamsISO.App.WinUI.Views;
public sealed partial class HelpDialog : ContentDialog
{
public HelpDialog()
{
InitializeComponent();
}
}

View file

@ -1,433 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="TeamsISO.App.WinUI.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:TeamsISO.App.WinUI.Models"
xmlns:views="using:TeamsISO.App.WinUI.Views">
<!--
TeamsISO MainWindow — redesigned IA per the approved shape brief.
Structure:
[64 rail] [content]
[44 title bar — drag region]
[section header]
[participants list — hero]
[in-call control — conditional]
[32 status bar]
The rail's bottom puck opens the engine-status popover that absorbs
what used to live in the WPF footer (logs path, version, control
surface URL details). The title bar absorbs the live state pills
(session timer · REC · disk free) so the operator's at-a-glance
read stays in peripheral vision regardless of scroll position.
Settings is a right-side drawer (opened from the rail settings icon)
rather than a permanent 380px panel — the participants list claims
the full content width when settings aren't being actively edited.
-->
<Grid Background="{ThemeResource BgCanvas}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="64"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Drawer slide storyboards removed temporarily to isolate the
XAML parse error blocking launch on WindowsAppSDK 1.8. -->
<!-- ═══════════════════════ LEFT RAIL ═══════════════════════ -->
<Border Grid.Column="0"
Background="{ThemeResource BgRail}"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="0,0,1,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Spacing="2" Padding="0,12,0,0">
<!-- Wild Dragon brand mark -->
<Button x:Name="BrandButton"
Style="{StaticResource ButtonRailIcon}"
Width="48" Height="56"
Margin="8,0,8,8"
ToolTipService.ToolTip="About TeamsISO">
<Border Width="40" Height="40"
CornerRadius="{ThemeResource RadiusM}"
Background="{ThemeResource AccentCyanMuted}">
<TextBlock Text="W"
FontFamily="{ThemeResource FontSans}"
FontSize="22"
FontWeight="Bold"
Foreground="{ThemeResource AccentCyanText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Button>
<Border Height="1"
Background="{ThemeResource BorderSubtle}"
Margin="14,4,14,12"/>
<!-- Participants / Home (active) -->
<Button Style="{StaticResource ButtonRailIcon}"
Margin="8,0"
ToolTipService.ToolTip="Participants">
<Grid>
<Border Width="48" Height="48"
CornerRadius="{ThemeResource RadiusM}"
Background="{ThemeResource AccentCyanMuted}"/>
<FontIcon Glyph="&#xE716;"
FontSize="20"
Foreground="{ThemeResource AccentCyanText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</Button>
<!-- Launch / surface Teams -->
<Button Style="{StaticResource ButtonRailIcon}"
Margin="8,0"
Click="OnLaunchTeamsClick"
ToolTipService.ToolTip="Launch Microsoft Teams (or surface its window)">
<FontIcon Glyph="&#xE714;"
FontSize="20"
Foreground="{ThemeResource FgSecondary}"/>
</Button>
<!-- Hide / show Teams windows -->
<Button Style="{StaticResource ButtonRailIcon}"
Margin="8,0"
Click="OnToggleTeamsWindowsClick"
ToolTipService.ToolTip="Hide / show Microsoft Teams windows">
<FontIcon Glyph="&#xE7B3;"
FontSize="20"
Foreground="{ThemeResource FgSecondary}"/>
</Button>
<!-- Settings drawer trigger -->
<Button x:Name="SettingsButton"
Style="{StaticResource ButtonRailIcon}"
Margin="8,0"
Click="OnSettingsClick"
ToolTipService.ToolTip="Settings">
<FontIcon Glyph="&#xE713;"
FontSize="20"
Foreground="{ThemeResource FgSecondary}"/>
</Button>
</StackPanel>
<!-- Engine status puck — opens the status popover -->
<Button x:Name="StatusPuckButton"
Grid.Row="1"
Style="{StaticResource ButtonRailIcon}"
Width="48" Height="48"
Margin="8,12"
CornerRadius="24"
Background="{ThemeResource StatusLiveBg}"
ToolTipService.ToolTip="Engine status">
<Ellipse Width="10" Height="10"
Fill="{ThemeResource StatusLive}"/>
</Button>
</Grid>
</Border>
<!-- ═══════════════════════ CONTENT ═══════════════════════ -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="32"/>
</Grid.RowDefinitions>
<!-- ─── Title bar ─── -->
<!--
AppTitleBar is the drag region. Window.SetTitleBar(AppTitleBar)
in code-behind makes this element the operating-system-defined
drag area. The system Min/Max/Close buttons render to the right
of this element automatically (their colors come from
AppWindow.TitleBar.ButtonForegroundColor etc.); we draw
everything else.
-->
<Grid x:Name="AppTitleBar"
Grid.Row="0"
Background="{ThemeResource BgCanvas}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="12"
Padding="24,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="TeamsISO"
Style="{StaticResource TextHeading}"
VerticalAlignment="Center"/>
<TextBlock x:Name="VersionLabel"
Text="v1.0.0-alpha"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource FgTertiary}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Live pills (session timer / REC count / disk) live in the
title bar so peripheral-vision status reads from the same
place whether the operator is scrolled, settings-drawer
open, or in-call. Conditionally shown via code-behind. -->
<StackPanel x:Name="LivePillsPanel"
Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center"
Padding="0,0,12,0">
<Border CornerRadius="{ThemeResource RadiusPill}"
Background="{ThemeResource StatusLiveBg}"
Padding="10,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Width="7" Height="7"
Fill="{ThemeResource StatusLive}"
VerticalAlignment="Center"/>
<TextBlock x:Name="SessionTimerText"
Text="live · 00:14:32"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource StatusLive}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border CornerRadius="{ThemeResource RadiusPill}"
Background="{ThemeResource AccentCoralBg}"
Padding="10,4">
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Width="7" Height="7"
Fill="{ThemeResource AccentCoral}"
VerticalAlignment="Center"/>
<TextBlock x:Name="RecPillText"
Text="rec 3 · 00:11:08"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource AccentCoral}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border CornerRadius="{ThemeResource RadiusPill}"
Background="{ThemeResource BgSurface}"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="1"
Padding="10,4">
<TextBlock x:Name="DiskFreeText"
Text="482 GB free"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource FgSecondary}"
VerticalAlignment="Center"/>
</Border>
</StackPanel>
<!-- Theme toggle — single-click cycle between Dark and Light.
Persisted to UIPreferences.Theme on click. -->
<Button x:Name="ThemeToggleButton"
Grid.Column="3"
Style="{StaticResource ButtonCaption}"
Click="OnThemeToggleClick"
ToolTipService.ToolTip="Toggle theme (dark / light)">
<FontIcon x:Name="ThemeToggleIcon"
Glyph="&#xE706;"
FontSize="14"/>
</Button>
<!-- The Min / Max / Close buttons that follow in Grid.Column 4,5
are NOT drawn here — the WindowsAppSDK title-bar API draws
them itself, overlaid on the drag region we've defined.
The reserved columns 4 and 5 are just visual placeholders
in this layout to remind future readers where they land. -->
<Border Grid.Column="4" Width="138" Background="Transparent"/>
</Grid>
<!-- ─── Section header ─── -->
<Grid Grid.Row="1" Padding="32,18,32,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="12"
VerticalAlignment="Center">
<TextBlock Text="Participants"
Style="{StaticResource TextDisplay}"/>
<Border CornerRadius="{ThemeResource RadiusPill}"
Background="{ThemeResource BgSurface}"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="1"
Padding="10,3"
VerticalAlignment="Center">
<TextBlock x:Name="ParticipantCountText"
Text="4"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource FgSecondary}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<TextBox x:Name="FilterInput"
PlaceholderText="Filter"
Width="200"
VerticalAlignment="Center"/>
<Button x:Name="RefreshButton"
Style="{StaticResource ButtonSecondary}"
Content="Refresh"
ToolTipService.ToolTip="Refresh NDI discovery"/>
<Button x:Name="StopAllButton"
Style="{StaticResource ButtonSecondary}"
Content="Stop all"
ToolTipService.ToolTip="Stop every running ISO"/>
<Button x:Name="EnableAllButton"
Style="{StaticResource ButtonPrimary}"
Content="Enable all online"
ToolTipService.ToolTip="Enable ISOs for every online participant"/>
</StackPanel>
</Grid>
<!-- ─── Participants list (hero) ─── -->
<!--
ItemsRepeater + DataTemplate with bindings deferred to the
view-model-wiring commit (Phase 4 of the migration plan).
For now, a single stub row renders so the layout's vertical
rhythm is verifiable even without real data. The full row
template (with active-speaker accent, avatar circle, signal
lock, audio meter, ISO toggle) is captured in the HTML
preview at docs/preview/redesigned-mainwindow.html and the
git history of this file shows the binding-heavy version
we'll re-introduce once the engine is wired in.
-->
<Grid x:Name="ParticipantsHost" Grid.Row="2" Padding="32,0,32,0">
<StackPanel x:Name="ParticipantsStub"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Spacing="8">
<TextBlock Text="Participants list renders here"
Style="{StaticResource TextHeading}"
HorizontalAlignment="Center"/>
<TextBlock Text="View-model wiring queued for the next session."
Style="{StaticResource TextSubtle}"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- ─── In-call control (conditional) ─── -->
<Border Grid.Row="3"
Padding="32,12,32,12"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="0,1,0,0"
Background="{ThemeResource BgCanvas}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="IN-CALL"
Style="{StaticResource TextCaption}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<Button Style="{StaticResource ButtonDestructive}"
Click="OnMuteClick"
ToolTipService.ToolTip="Toggle microphone mute in Teams">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE74F;" FontSize="14"/>
<TextBlock Text="Mute"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonSecondary}"
Click="OnCameraClick"
ToolTipService.ToolTip="Toggle camera in Teams">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE714;" FontSize="14"/>
<TextBlock Text="Camera"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonSecondary}"
Click="OnShareClick"
ToolTipService.ToolTip="Open Teams share tray">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE72D;" FontSize="14"/>
<TextBlock Text="Share"/>
</StackPanel>
</Button>
<Button x:Name="MarkerButton"
Style="{StaticResource ButtonSecondary}"
ToolTipService.ToolTip="Drop a timestamped marker into every active recording">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon Glyph="&#xE735;" FontSize="14"/>
<TextBlock Text="Marker"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonDestructive}"
Click="OnLeaveClick"
ToolTipService.ToolTip="Leave the Teams call">
<TextBlock Text="Leave"/>
</Button>
</StackPanel>
</Border>
<!-- ─── Settings drawer ─── -->
<views:SettingsDrawer x:Name="SettingsDrawerHost"
Grid.Row="0"
Grid.RowSpan="4"
HorizontalAlignment="Right"
Width="400"
Visibility="Collapsed"/>
<!-- ─── Status bar ─── -->
<Grid Grid.Row="4"
Padding="32,8,32,8"
Background="{ThemeResource BgCanvas}"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="0,1,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Ellipse Width="6" Height="6"
Fill="{ThemeResource AccentCyanText}"
VerticalAlignment="Center"/>
<TextBlock x:Name="StatusBarText"
Text="control surface · 127.0.0.1:9755"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource FgSecondary}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Grid.Column="2"
Text="F1 help · Ctrl+M marker · Ctrl+Shift+S panic stop · Ctrl+K command palette"
Style="{StaticResource TextMono}"
FontSize="11"
Foreground="{ThemeResource FgTertiary}"
VerticalAlignment="Center"/>
</Grid>
</Grid>
</Grid>
</Window>

View file

@ -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();
}
/// <summary>
/// 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.
/// </summary>
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<Microsoft.UI.Xaml.Input.KeyboardAccelerator,
Microsoft.UI.Xaml.Input.KeyboardAcceleratorInvokedEventArgs> 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}";
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Full rich row template — replaces BuildSimpleRow once we've verified the simple version doesn't crash.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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",
};
/// <summary>Launch Teams if not running, else show its windows.</summary>
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}";
}
}
}
/// <summary>Toggle Teams window visibility — invisible/visible flip.</summary>
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 ? "" : "";
}
}

View file

@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentDialog
x:Class="TeamsISO.App.WinUI.Views.OnboardingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Welcome to TeamsISO"
PrimaryButtonText="Get started"
SecondaryButtonText="Skip"
DefaultButton="Primary"
Background="{ThemeResource BgElevated}"
BorderBrush="{ThemeResource BorderStrong}">
<!--
First-launch only. Three sections, one pane deep — no carousel,
no celebration. Operator-tone copy ("Pick your NDI groups" not
"Welcome to TeamsISO!"). Skippable from the first frame.
Suppressed after dismissal via UIPreferences (Phase 7).
-->
<StackPanel Spacing="20" MinWidth="500" MaxWidth="540">
<TextBlock Style="{StaticResource TextSubtle}" TextWrapping="Wrap">
TeamsISO sits between Microsoft Teams' NDI broadcast and your live-production switcher.
One-time setup gets you to the participants table.
</TextBlock>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Width="28" Height="28"
CornerRadius="14"
Background="{ThemeResource AccentCyanMuted}">
<TextBlock Text="1"
Style="{StaticResource TextBody}"
FontWeight="SemiBold"
Foreground="{ThemeResource AccentCyanText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Install the NDI Runtime"
Style="{StaticResource TextHeading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Style="{StaticResource TextSubtle}"
Margin="38,0,0,0"
TextWrapping="Wrap">
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.
</TextBlock>
</StackPanel>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Width="28" Height="28"
CornerRadius="14"
Background="{ThemeResource AccentCyanMuted}">
<TextBlock Text="2"
Style="{StaticResource TextBody}"
FontWeight="SemiBold"
Foreground="{ThemeResource AccentCyanText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Enable Teams NDI broadcast"
Style="{StaticResource TextHeading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Style="{StaticResource TextSubtle}"
Margin="38,0,0,0"
TextWrapping="Wrap">
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.
</TextBlock>
</StackPanel>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Width="28" Height="28"
CornerRadius="14"
Background="{ThemeResource AccentCyanMuted}">
<TextBlock Text="3"
Style="{StaticResource TextBody}"
FontWeight="SemiBold"
Foreground="{ThemeResource AccentCyanText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Pick your transcoder topology"
Style="{StaticResource TextHeading}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Style="{StaticResource TextSubtle}"
Margin="38,0,0,0"
TextWrapping="Wrap">
Defaults to 1920×1080 at 30 fps with letterbox aspect. Adjust under Settings → Routing.
Recording outputs land in %USERPROFILE%\Videos\TeamsISO\&lt;date&gt;\.
</TextBlock>
</StackPanel>
<CheckBox x:Name="DontShowAgain"
Content="Don't show this again"
IsChecked="True"/>
</StackPanel>
</ContentDialog>

View file

@ -1,18 +0,0 @@
using Microsoft.UI.Xaml.Controls;
namespace TeamsISO.App.WinUI.Views;
public sealed partial class OnboardingDialog : ContentDialog
{
public OnboardingDialog()
{
InitializeComponent();
}
/// <summary>
/// True when the user wants the dialog suppressed on subsequent launches.
/// Caller persists this to UIPreferences alongside the existing
/// "shown welcome" flag.
/// </summary>
public bool SuppressFutureLaunches => DontShowAgain.IsChecked == true;
}

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="TeamsISO.App.WinUI.Views.SettingsDrawer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Settings drawer — slides in from the right over the participants
table when the operator clicks the rail's settings icon. Esc dismiss.
Hosted inline (not as a separate Window) so the drawer feels like
part of the main surface rather than a satellite. Width fixed at
400px to give every setting a 320px input field after padding.
The five tabs mirror the WPF host's settings groups so the operator
finds the same toggles in the same places. The Appearance tab is
new — tri-state Theme picker (System / Dark / Light) plus a peek at
the accent palette so the operator can verify Wild Dragon brand is
respected on a light desk.
-->
<Grid Background="{ThemeResource BgSurface}"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="1,0,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="56"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0"
Padding="20,0,12,0"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="0,0,0,1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Settings"
Style="{StaticResource TextTitle}"
VerticalAlignment="Center"/>
<Button x:Name="CloseButton"
Grid.Column="1"
Style="{StaticResource ButtonCaption}"
Click="OnCloseClick"
ToolTipService.ToolTip="Close (Esc)">
<FontIcon Glyph="&#xE8BB;" FontSize="12"/>
</Button>
</Grid>
<!-- Body. NavigationView swapped for a simpler horizontal tab
button strip; the WinUI 3 NavigationView's resource
dictionary expansion was crashing the XAML parser at
SettingsDrawer construction time. -->
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
x:Name="TabStrip"
Orientation="Horizontal"
Spacing="4"
Padding="12,8"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="0,0,0,1"/>
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Padding="20">
<StackPanel x:Name="TabContent" Spacing="16"/>
</ScrollViewer>
</Grid>
<!-- Footer: Apply / Reset -->
<Grid Grid.Row="2"
Padding="16,12"
BorderBrush="{ThemeResource BorderSubtle}"
BorderThickness="0,1,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
x:Name="DirtyHint"
Text="Changes apply on close."
Style="{StaticResource TextCaption}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource ButtonTertiary}"
Content="Reset to defaults"
Margin="0,0,8,0"
Click="OnResetClick"/>
<Button Grid.Column="2"
Style="{StaticResource ButtonPrimary}"
Content="Apply"
Click="OnApplyClick"/>
</Grid>
</Grid>
</UserControl>

View file

@ -1,275 +0,0 @@
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using TeamsISO.App.WinUI.Services;
namespace TeamsISO.App.WinUI.Views;
public sealed partial class SettingsDrawer : UserControl
{
/// <summary>
/// Raised when the operator clicks the close button or hits Esc. The host
/// (MainWindow) handles the actual collapse animation since drawer
/// hosting lives at that level — the drawer just signals intent.
/// </summary>
public event EventHandler? CloseRequested;
public SettingsDrawer()
{
InitializeComponent();
BuildTabStrip();
SelectTab("Appearance");
}
private string _currentTab = "Appearance";
/// <summary>Build the tab buttons imperatively so the parse doesn't
/// trigger a SelectionChanged before the code-behind is ready.</summary>
private void BuildTabStrip()
{
foreach (var (label, key) in new[]
{
("Appearance", "Appearance"),
("Routing", "Routing"),
("Display", "Display"),
("Control", "Control"),
("Advanced", "Advanced"),
})
{
var btn = new Button
{
Content = label,
Tag = key,
Style = (Style)Application.Current.Resources["ButtonTertiary"],
};
btn.Click += (s, _) =>
{
if (s is Button b && b.Tag is string k) SelectTab(k);
};
TabStrip.Children.Add(btn);
}
}
private void SelectTab(string key)
{
_currentTab = key;
foreach (var child in TabStrip.Children)
{
if (child is Button b)
{
var isActive = (b.Tag as string) == key;
b.Foreground = (Microsoft.UI.Xaml.Media.SolidColorBrush)Application.Current.Resources[
isActive ? "AccentCyanText" : "FgSecondary"];
}
}
RebuildTabContent(key);
}
private void RebuildTabContent(string key)
{
TabContent.Children.Clear();
switch (key)
{
case "Appearance": BuildAppearanceTab(); break;
case "Routing": BuildRoutingTab(); break;
case "Display": BuildDisplayTab(); break;
case "Control": BuildControlTab(); break;
case "Advanced": BuildAdvancedTab(); break;
}
}
private void BuildRoutingTab()
{
TabContent.Children.Add(SettingHeader("Routing"));
TabContent.Children.Add(SettingRow("Framerate", "30 fps", "Target framerate the engine normalizes incoming NDI feeds to."));
TabContent.Children.Add(SettingRow("Resolution", "1920 x 1080", "Output resolution applied to every routed participant."));
TabContent.Children.Add(SettingRow("Aspect mode", "Letterbox", "How sources whose aspect ratio doesn't match the target are framed."));
TabContent.Children.Add(SettingRow("Audio routing", "Per-participant", "Embed each participant's audio in their own NDI source."));
TabContent.Children.Add(SettingNote("Settings wired to the engine in a follow-up commit."));
}
private void BuildDisplayTab()
{
TabContent.Children.Add(SettingHeader("Display & participants"));
TabContent.Children.Add(SettingRow("Hide local self", "Yes", "Filter the operator's own preview from the participants table."));
TabContent.Children.Add(SettingRow("Auto-disable on departure", "No", "Keep routing alive when a participant goes offline."));
TabContent.Children.Add(SettingRow("Participant sort", "Join order", "Default sort for the participants table."));
TabContent.Children.Add(SettingRow("Minimize to tray", "No", "Keep TeamsISO running in the system tray when the window is minimized."));
TabContent.Children.Add(SettingRow("Launch Teams on startup", "No", "Start Teams in the background when TeamsISO opens."));
TabContent.Children.Add(SettingRow("Auto-hide Teams windows", "No", "Hide every visible Teams window after launch."));
TabContent.Children.Add(SettingRow("Auto-record on call", "No", "Start recording every active ISO when Teams enters a call."));
}
private void BuildControlTab()
{
TabContent.Children.Add(SettingHeader("External control surface"));
TabContent.Children.Add(SettingRow("REST + WebSocket", "127.0.0.1:9755", "HTTP control surface for Companion / Stream Deck."));
TabContent.Children.Add(SettingRow("OSC bridge", "127.0.0.1:9000", "UDP OSC for TouchOSC / hardware surfaces."));
TabContent.Children.Add(SettingRow("LAN reachable", "No", "Bind the REST surface to all interfaces (warning: no auth)."));
TabContent.Children.Add(SettingNote("Control surface protocol unchanged from the WPF host. See docs/CONTROL-SURFACE.md."));
}
private void BuildAdvancedTab()
{
TabContent.Children.Add(SettingHeader("Advanced"));
TabContent.Children.Add(SettingRow("Embed Teams window", "No", "Experimental SetParent reparent of Teams' main window."));
TabContent.Children.Add(SettingRow("Logs", "%LOCALAPPDATA%\\TeamsISO\\Logs", "Where rolling daily Serilog files write."));
TabContent.Children.Add(SettingRow("Recordings", "%USERPROFILE%\\Videos\\TeamsISO", "Default per-show recording directory."));
TabContent.Children.Add(SettingRow("Diagnostic bundle", "Export", "Zip logs + config + presets for a bug report."));
}
private void OnCloseClick(object sender, RoutedEventArgs e) => CloseRequested?.Invoke(this, EventArgs.Empty);
private void OnApplyClick(object sender, RoutedEventArgs e)
{
// Tabs already persist on change; Apply is here for affordance
// consistency with the WPF host. Could surface a toast in the future.
CloseRequested?.Invoke(this, EventArgs.Empty);
}
private void OnResetClick(object sender, RoutedEventArgs e)
{
// Defaults restoration plumbing follows in the view-model wiring
// commit. For now, just clear the dirty hint.
DirtyHint.Text = "Reset queued — apply or close to commit.";
}
/// <summary>
/// Appearance tab — theme picker, accent palette peek. The theme picker
/// is the only setting that affects the UI immediately (others apply on
/// the engine layer once wired). Selection writes to UIPreferences and
/// flips Window.Content.RequestedTheme synchronously.
/// </summary>
private void BuildAppearanceTab()
{
TabContent.Children.Add(SettingHeader("Appearance"));
var themeLabel = new TextBlock
{
Style = (Style)Application.Current.Resources["TextBody"],
Text = "Theme",
Margin = new Thickness(0, 0, 0, 6),
};
TabContent.Children.Add(themeLabel);
var themeRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
foreach (var (label, value) in new[]
{
("System", "System"),
("Dark", "Dark"),
("Light", "Light"),
})
{
var btn = new RadioButton
{
Content = label,
Tag = value,
GroupName = "Theme",
IsChecked = ThemeManager.Current.PreferenceMatches(value),
};
btn.Checked += (s, _) =>
{
if (s is RadioButton rb && rb.Tag is string v)
{
ThemeManager.Current.Set(v);
}
};
themeRow.Children.Add(btn);
}
TabContent.Children.Add(themeRow);
TabContent.Children.Add(SettingNote(
"Dark is the default for the 1:50am operator scene; light is for daytime " +
"production. System follows the Windows app-mode preference."));
TabContent.Children.Add(SettingDivider());
TabContent.Children.Add(SettingHeader("Accent peek"));
var accentRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 };
accentRow.Children.Add(AccentSwatch("Cyan", "AccentCyanSurface"));
accentRow.Children.Add(AccentSwatch("Coral", "AccentCoral"));
accentRow.Children.Add(AccentSwatch("Live", "StatusLive"));
accentRow.Children.Add(AccentSwatch("Warn", "StatusWarn"));
TabContent.Children.Add(accentRow);
TabContent.Children.Add(SettingNote(
"These accents work in both themes. Cyan stays bright as a surface fill " +
"(text on top is near-black regardless of theme). For inline text use, the " +
"light palette substitutes a darker cyan automatically."));
}
// ──────────────────── helpers ──────────────────────────────────────────
private static TextBlock SettingHeader(string text) => new()
{
Style = (Style)Application.Current.Resources["TextHeading"],
Text = text,
Margin = new Thickness(0, 0, 0, 8),
};
private static Grid SettingRow(string label, string value, string? tooltip = null)
{
var g = new Grid { Margin = new Thickness(0, 0, 0, 6) };
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(180) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var l = new TextBlock
{
Style = (Style)Application.Current.Resources["TextBody"],
Text = label,
VerticalAlignment = VerticalAlignment.Center,
};
var v = new TextBlock
{
Style = (Style)Application.Current.Resources["TextSubtle"],
Text = value,
VerticalAlignment = VerticalAlignment.Center,
};
if (tooltip is not null)
{
ToolTipService.SetToolTip(l, tooltip);
}
Grid.SetColumn(l, 0);
Grid.SetColumn(v, 1);
g.Children.Add(l);
g.Children.Add(v);
return g;
}
private static TextBlock SettingNote(string text) => new()
{
Style = (Style)Application.Current.Resources["TextCaption"],
Text = text,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 12),
};
private static Border SettingDivider() => new()
{
Height = 1,
Background = (SolidColorBrush)Application.Current.Resources["BorderSubtle"],
Margin = new Thickness(0, 12, 0, 12),
};
private static Border AccentSwatch(string label, string brushKey)
{
var inner = new StackPanel { Orientation = Orientation.Vertical, Spacing = 4 };
var swatch = new Border
{
Width = 80,
Height = 32,
CornerRadius = new Microsoft.UI.Xaml.CornerRadius(6),
Background = (SolidColorBrush)Application.Current.Resources[brushKey],
};
var caption = new TextBlock
{
Style = (Style)Application.Current.Resources["TextCaption"],
Text = label,
HorizontalAlignment = HorizontalAlignment.Center,
};
inner.Children.Add(swatch);
inner.Children.Add(caption);
return new Border { Child = inner };
}
}

View file

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="TeamsISO.App.WinUI"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- TeamsISO is a normal-trust desktop app; no UAC elevation needed.
Network listens (control surface :9755 and OSC :9000) bind to
127.0.0.1 only, which doesn't require admin. -->
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Win10 1809 (17763) is the floor — same as TargetPlatformMinVersion. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<!-- Crisp text on high-DPI broadcast monitors. -->
<gdiScaling xmlns="http://schemas.microsoft.com/SMI/2017/WindowsSettings">true</gdiScaling>
</windowsSettings>
</application>
</assembly>

View file

@ -147,12 +147,6 @@
Padding="14,6" Padding="14,6"
Margin="0,0,8,0" Margin="0,0,8,0"
ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/> ToolTip="Open %LOCALAPPDATA%\TeamsISO\Logs in Explorer"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Content="Recordings"
Click="OnOpenRecordings"
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Open the configured recording directory in Explorer (defaults to %USERPROFILE%\Videos\TeamsISO)"/>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Content="Notes" Content="Notes"
Click="OnOpenNotes" Click="OnOpenNotes"

View file

@ -89,17 +89,7 @@ public partial class AboutWindow : Window
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "Logs")); "TeamsISO", "Logs"));
private void OnOpenRecordings(object sender, RoutedEventArgs e) // OnOpenRecordings removed — recording feature axed.
{
// Default to the user-Videos folder. Operator can navigate into the
// current session's date subfolder from there. We don't reach into
// the engine for the live recording path because exposing the
// controller through App would be a wider plumbing change for a
// shortcut button.
OpenInExplorer(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
"TeamsISO"));
}
private void OnOpenNotes(object sender, RoutedEventArgs e) => private void OnOpenNotes(object sender, RoutedEventArgs e) =>
OpenInExplorer(Path.Combine( OpenInExplorer(Path.Combine(

View file

@ -37,7 +37,7 @@ public partial class App : Application
private MainViewModel? _viewModel; private MainViewModel? _viewModel;
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface; private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
private TeamsISO.App.Services.OscBridge? _oscBridge; private TeamsISO.App.Services.OscBridge? _oscBridge;
private TeamsISO.App.Services.DiskSpaceWatcher? _diskSpaceWatcher; // _diskSpaceWatcher removed — only existed to auto-disable recording at low free space.
private TeamsISO.App.Services.TrayIconHost? _trayIcon; private TeamsISO.App.Services.TrayIconHost? _trayIcon;
/// <summary> /// <summary>
@ -181,11 +181,7 @@ public partial class App : Application
() => _viewModel, () => _viewModel,
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>()); _loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
// Disk space watcher: polls the recording drive every 5s while // DiskSpaceWatcher removed alongside the rest of the recording surface.
// recording is on. Auto-disables recording at <1GB free so an
// unattended long show doesn't crash the host on disk-full.
_diskSpaceWatcher = new TeamsISO.App.Services.DiskSpaceWatcher(
_controller, _viewModel.Toast, Dispatcher);
// Tray icon host. Disabled by default; the settings VM flips // Tray icon host. Disabled by default; the settings VM flips
// Enabled when the operator toggles the DISPLAY checkbox. Hosting // Enabled when the operator toggles the DISPLAY checkbox. Hosting
@ -398,7 +394,6 @@ public partial class App : Application
try try
{ {
_trayIcon?.Dispose(); _trayIcon?.Dispose();
_diskSpaceWatcher?.Dispose();
if (_controlSurface is not null) if (_controlSurface is not null)
await _controlSurface.DisposeAsync(); await _controlSurface.DisposeAsync();
if (_oscBridge is not null) if (_oscBridge is not null)

View file

@ -51,7 +51,6 @@
--> -->
<Window.InputBindings> <Window.InputBindings>
<KeyBinding Key="F1" Command="{Binding ShowHelpCommand}"/> <KeyBinding Key="F1" Command="{Binding ShowHelpCommand}"/>
<KeyBinding Key="M" Modifiers="Ctrl" Command="{Binding DropRecordingMarkerCommand}"/>
<KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/> <KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/>
<KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/> <KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
<!-- NumPad 1-9 toggles ISO for the Nth visible participant (sort + filter aware). <!-- NumPad 1-9 toggles ISO for the Nth visible participant (sort + filter aware).
@ -80,7 +79,13 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="72"/> <!-- Left rail --> <ColumnDefinition Width="72"/> <!-- Left rail -->
<ColumnDefinition Width="*"/> <!-- Main content --> <ColumnDefinition Width="*"/> <!-- Main content -->
<ColumnDefinition Width="380"/> <!-- Settings panel --> <!--
Settings panel column. Width is bound by name so the rail
Settings button can collapse it (Width=0) and re-expand it
(Width=380) without touching the panel's own Visibility,
which would unload children and lose scroll position.
-->
<ColumnDefinition x:Name="SettingsColumn" Width="380"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- ════════════════════════════════════════════════════════════════ <!-- ════════════════════════════════════════════════════════════════
@ -181,10 +186,14 @@
Stretch="Uniform"/> Stretch="Uniform"/>
</Button> </Button>
<!-- Nav: Settings (placeholder — opens panel on right; not toggled in this build) --> <!-- Nav: Settings — toggles the 380px right pane open / collapsed.
<Button DockPanel.Dock="Top" The pane carries OUTPUT / NETWORK / DISPLAY tabs; when closed,
the participants list claims the full content width. -->
<Button x:Name="SettingsRailButton"
DockPanel.Dock="Top"
Style="{StaticResource Wd.Button.RailIcon}" Style="{StaticResource Wd.Button.RailIcon}"
ToolTip="Settings"> Click="OnSettingsToggleClick"
ToolTip="Show / hide the settings pane">
<Path Data="M 14,4 L 14,7 M 14,21 L 14,24 M 4,14 L 7,14 M 21,14 L 24,14 M 7.5,7.5 L 9.5,9.5 M 18.5,18.5 L 20.5,20.5 M 7.5,20.5 L 9.5,18.5 M 18.5,9.5 L 20.5,7.5 M 14,11 C 15.7,11 17,12.3 17,14 C 17,15.7 15.7,17 14,17 C 12.3,17 11,15.7 11,14 C 11,12.3 12.3,11 14,11" <Path Data="M 14,4 L 14,7 M 14,21 L 14,24 M 4,14 L 7,14 M 21,14 L 24,14 M 7.5,7.5 L 9.5,9.5 M 18.5,18.5 L 20.5,20.5 M 7.5,20.5 L 9.5,18.5 M 18.5,9.5 L 20.5,7.5 M 14,11 C 15.7,11 17,12.3 17,14 C 17,15.7 15.7,17 14,17 C 12.3,17 11,15.7 11,14 C 11,12.3 12.3,11 14,11"
Stroke="{DynamicResource Wd.Text.Secondary}" Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.5" StrokeThickness="1.5"
@ -372,49 +381,11 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
<!-- Recording badge — coral dot + count when recording is on --> <!-- Recording badge intentionally removed — recording is no longer
<StackPanel Grid.Column="3" a TeamsISO feature; the REC badge, marker key binding, settings
Orientation="Horizontal" toggles, and recorder wiring were all stripped together. The
VerticalAlignment="Center" engine still exposes the raw plumbing for future re-introduction
Margin="0,0,16,0" but the operator UI surface is gone. -->
Visibility="{Binding IsRecording, Converter={StaticResource BoolToVis}}"
ToolTip="One or more ISOs are being recorded to disk.">
<Border Width="8" Height="8"
CornerRadius="4"
Background="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,6,0"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Coral}"
VerticalAlignment="Center"
Margin="0,0,8,0">
<Run Text="REC "/>
<Run Text="{Binding ActiveRecordingCount, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding RecordingElapsed, Mode=OneWay}"/>
</TextBlock>
<!-- Free disk space on the recording drive. Coral
tint kicks in below 10GB; existing DiskSpaceWatcher
auto-disables recording at 1GB. -->
<TextBlock VerticalAlignment="Center"
ToolTip="Free disk space on the recording drive. Recording auto-disables when free space drops below 1GB.">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource Wd.Text.Mono}">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Tertiary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsLowDiskSpace}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Wd.Accent.Coral}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
<Run Text="· "/>
<Run Text="{Binding RecordingFreeSpace, Mode=OneWay}"/>
<Run Text=" free"/>
</TextBlock>
</StackPanel>
<!-- Control surface badge — cyan dot + REST/OSC string when active --> <!-- Control surface badge — cyan dot + REST/OSC string when active -->
<StackPanel Grid.Column="4" <StackPanel Grid.Column="4"
@ -644,26 +615,8 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Style="{StaticResource Wd.Button.Ghost}" <!-- Recording marker button removed alongside the rest of the
Command="{Binding DropRecordingMarkerCommand}" recording feature surface. -->
Padding="14,6"
Margin="0,0,8,0"
ToolTip="Drop a timestamped marker into every active recording. Useful for chaptering in post — 'guest answer starts here', 'highlight clip', etc. Markers land in each recording's manifest.json.">
<StackPanel Orientation="Horizontal">
<Path Data="M 4,1 L 4,15 M 4,1 L 13,1 L 11,4 L 13,7 L 4,7"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.4"
Fill="Transparent"
Width="14" Height="16"
Stretch="None"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Text="Marker"
Style="{StaticResource Wd.Text.Body}"
FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Wd.Button.Ghost}" <Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding ShowNotesCommand}" Command="{Binding ShowNotesCommand}"
Padding="14,6" Padding="14,6"
@ -962,9 +915,6 @@
<MenuItem Header="Save current frame…" <MenuItem Header="Save current frame…"
Command="{Binding SaveSnapshotCommand}" Command="{Binding SaveSnapshotCommand}"
ToolTip="Save the most recent processed frame as a PNG under %USERPROFILE%\Pictures\TeamsISO. Useful for highlight reels, social posts, or attaching to a bug report."/> ToolTip="Save the most recent processed frame as a PNG under %USERPROFILE%\Pictures\TeamsISO. Useful for highlight reels, social posts, or attaching to a bug report."/>
<MenuItem Header="Record this participant"
IsCheckable="True"
IsChecked="{Binding RecordToDisk}"/>
<Separator/> <Separator/>
<MenuItem Header="Copy NDI source name" <MenuItem Header="Copy NDI source name"
Command="{Binding CopySourceNameCommand}" Command="{Binding CopySourceNameCommand}"
@ -1108,22 +1058,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<!-- <!-- "Rec" per-row column removed — recording feature stripped. -->
Per-participant recording opt-out. When global recording is on,
only participants with this checkbox checked get a recorder when
their ISO starts. Read at EnableIsoAsync time so toggling on a
running pipeline has no effect — operator must disable + re-enable.
-->
<DataGridTemplateColumn Header="Rec" Width="60">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding RecordToDisk}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ToolTip="When on, this participant's ISO is recorded to disk while global recording is enabled. Toggle while ISO is OFF — changes don't apply to a running pipeline."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="ISO" Width="130"> <DataGridTemplateColumn Header="ISO" Width="130">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
@ -1430,12 +1365,9 @@
<CheckBox Content="Auto-hide Teams windows when launched" <CheckBox Content="Auto-hide Teams windows when launched"
IsChecked="{Binding Settings.AutoHideTeamsWindows}" IsChecked="{Binding Settings.AutoHideTeamsWindows}"
Margin="0,8,0,0" Margin="0,8,0,0"
ToolTip="When checked, Teams' windows are hidden as soon as they materialize after launch. Use the eye-icon button in the rail to restore them when needed. Drives Teams via the IN-CALL bar (mute / camera / share / leave / marker) and the participants DataGrid for ISO routing."/> ToolTip="When checked, Teams' windows are hidden as soon as they materialize after launch. Use the eye-icon button in the rail to restore them when needed. Drive Teams via the IN-CALL bar (mute / camera / share / leave) and the participants DataGrid for ISO routing."/>
<CheckBox Content="Auto-record when Teams joins a meeting" <!-- Auto-record-on-call removed alongside the rest of the recording surface. -->
IsChecked="{Binding Settings.AutoRecordOnCall}"
Margin="0,8,0,0"
ToolTip="When checked, recording auto-starts the moment Teams transitions into a call (IN CALL pill goes cyan) and auto-stops when the call ends. Removes the manual Record toggle step from unattended-show workflows."/>
<!-- Phase E.4 experimental — SetParent embed. WebView2 in modern <!-- Phase E.4 experimental — SetParent embed. WebView2 in modern
Teams can render weirdly after reparent; if so, untick and Teams can render weirdly after reparent; if so, untick and
@ -1458,24 +1390,8 @@
<Separator Margin="0,16,0,8"/> <Separator Margin="0,16,0,8"/>
<CheckBox Content="Record ISOs to disk" <!-- "Record ISOs to disk" + recording directory removed
IsChecked="{Binding Settings.RecordIsosToDisk}" alongside the rest of the recording surface. -->
ToolTip="When checked, each newly-enabled ISO writes raw BGRA frames + manifest.json + convert.cmd to its own subdirectory. Run convert.cmd to produce H.264 .mkv via FFmpeg. Recording starts on the next ISO enable; already-running ISOs aren't retroactively captured."/>
<TextBlock Text="Output directory"
Style="{StaticResource Wd.Text.Subtle}"
Margin="0,12,0,4"
IsEnabled="{Binding Settings.RecordIsosToDisk}"/>
<TextBox Text="{Binding Settings.RecordingDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Settings.RecordIsosToDisk}"
ToolTip="Root directory for recordings. Each participant gets a subdirectory under this path. Default: %USERPROFILE%\Videos\TeamsISO\&lt;date&gt;."/>
<TextBlock Text="Recordings live under this path. Each ISO writes raw BGRA + manifest.json. Double-click the convert.cmd inside to produce a final H.264 .mkv."
Style="{StaticResource Wd.Text.Body}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
<Separator Margin="0,16,0,8"/>
<CheckBox Content="Control surface (Stream Deck / Companion / web)" <CheckBox Content="Control surface (Stream Deck / Companion / web)"
IsChecked="{Binding Settings.ControlSurfaceEnabled}" IsChecked="{Binding Settings.ControlSurfaceEnabled}"
@ -1534,7 +1450,7 @@
Foreground="{DynamicResource Wd.Text.Tertiary}" Foreground="{DynamicResource Wd.Text.Tertiary}"
TextWrapping="Wrap" TextWrapping="Wrap"
Margin="0,4,0,0" Margin="0,4,0,0"
Text="Endpoints: GET /participants, POST /participants/{id}/iso, /presets/{name}/apply, /presets/refresh-discovery, /presets/stop-all, /teams/mute|camera|leave|share|raise-hand, /recording. WebSocket /ws pushes live state. See docs/CONTROL-SURFACE.md."/> Text="Endpoints: GET /participants, POST /participants/{id}/iso, /presets/{name}/apply, /presets/refresh-discovery, /presets/stop-all, /teams/mute|camera|leave|share|raise-hand. WebSocket /ws pushes live state. See docs/CONTROL-SURFACE.md."/>
<CheckBox Content="OSC bridge (UDP)" <CheckBox Content="OSC bridge (UDP)"
IsChecked="{Binding Settings.OscBridgeEnabled}" IsChecked="{Binding Settings.OscBridgeEnabled}"

View file

@ -49,6 +49,21 @@ public partial class MainWindow : Window
/// <summary>Custom close button.</summary> /// <summary>Custom close button.</summary>
private void OnClose(object sender, RoutedEventArgs e) => Close(); private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// Toggle the right-hand settings pane. Collapses the 380px column to 0
/// so the participants list claims the full content width, then restores
/// it on the next click. The pane's children stay loaded — we move the
/// grid column, not the panel's Visibility — so scroll position and any
/// in-flight edits survive the toggle.
/// </summary>
private void OnSettingsToggleClick(object sender, RoutedEventArgs e)
{
if (SettingsColumn is null) return;
SettingsColumn.Width = SettingsColumn.Width.Value > 0
? new System.Windows.GridLength(0)
: new System.Windows.GridLength(380);
}
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary> /// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
private void OnAboutClick(object sender, RoutedEventArgs e) private void OnAboutClick(object sender, RoutedEventArgs e)
{ {

View file

@ -254,9 +254,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"), ("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"), ("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"), ("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
("POST", "/recording") => SetRecording(body, req.QueryString), // /recording routes removed alongside the rest of the recording surface.
("POST", "/recording/marker") => DropMarker(body, req.QueryString),
("POST", "/recording/roll") => await RollRecordingAsync(),
("POST", "/notes") => AppendNote(body, req.QueryString), ("POST", "/notes") => AppendNote(body, req.QueryString),
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString), ("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
@ -315,11 +313,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
discoveryGroups = groups?.DiscoveryGroups, discoveryGroups = groups?.DiscoveryGroups,
outputGroups = groups?.OutputGroups, outputGroups = groups?.OutputGroups,
}, },
recording = new // recording status fields removed alongside the rest of the recording surface.
{
enabled = _controller.RecordingEnabled,
directory = _controller.RecordingDirectory,
},
endpoints = new[] endpoints = new[]
{ {
"GET / (this)", "GET / (this)",
@ -332,8 +326,6 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
"POST /presets/refresh-discovery", "POST /presets/refresh-discovery",
"POST /presets/stop-all", "POST /presets/stop-all",
"POST /teams/mute, /camera, /leave, /share, /raise-hand", "POST /teams/mute, /camera, /leave, /share, /raise-hand",
"POST /recording (body: enabled + directory)",
"POST /recording/marker (body: label)",
"POST /notes (body: text)", "POST /notes (body: text)",
}, },
}; };
@ -405,21 +397,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
}; };
} }
private object SetRecording(JsonElement body, System.Collections.Specialized.NameValueCollection query) // SetRecording and DropMarker methods removed alongside the rest of the recording surface.
{
var enabled = TryGetBool(body, query, "enabled") ?? false;
var directory = TryGetString(body, query, "directory");
_controller.SetRecording(enabled, directory);
return new { ok = true, recording = new { enabled, directory } };
}
private object DropMarker(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{
var label = TryGetString(body, query, "label")
?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
return new { ok = true, action = "marker", label };
}
private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query) private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query)
{ {
@ -430,39 +408,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
return new { ok, action = "note", path = NotesService.TodayPath }; return new { ok, action = "note", path = NotesService.TodayPath };
} }
/// <summary> // RollRecordingAsync handler removed alongside the rest of the recording surface.
/// Roll every active recording into a new chunk. Same code path as the UI's
/// RollRecordingCommand — disable + brief delay + re-enable each pipeline.
/// We marshal the participants snapshot through the dispatcher because
/// ObservableCollection isn't safe to enumerate from the request thread.
/// </summary>
private async Task<object> RollRecordingAsync()
{
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null)
return new { ok = false, error = "view-model not ready" };
var enabled = await dispatcher.InvokeAsync(() =>
vm.Participants.Where(p => p.IsEnabled).ToArray());
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
rolled++;
}
catch { /* per-pipeline best-effort */ }
}
return new { ok = true, action = "roll-recording", rolled };
}
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query) private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query)
{ {

View file

@ -1,100 +0,0 @@
using System.IO;
using System.Windows.Threading;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
namespace TeamsISO.App.Services;
/// <summary>
/// Polls the recording drive's free space every few seconds while recording is
/// on. Surfaces a coral warning toast at the soft threshold, and at the hard
/// threshold auto-disables recording so a long unattended show doesn't fill
/// the disk and crash the host's logger and write paths.
///
/// Lifecycle is tied to the MainViewModel — instantiate after the VM is wired,
/// dispose with the host.
/// </summary>
public sealed class DiskSpaceWatcher : IDisposable
{
/// <summary>Below this, toast a warning each tick.</summary>
public static readonly long SoftWarnBytes = 10L * 1024 * 1024 * 1024; // 10 GB
/// <summary>Below this, auto-disable recording to save the show.</summary>
public static readonly long HardStopBytes = 1L * 1024 * 1024 * 1024; // 1 GB
private readonly IIsoController _controller;
private readonly ToastViewModel _toast;
private readonly DispatcherTimer _timer;
private DateTimeOffset _lastWarnAt = DateTimeOffset.MinValue;
public DiskSpaceWatcher(IIsoController controller, ToastViewModel toast, Dispatcher dispatcher)
{
_controller = controller;
_toast = toast;
_timer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
{
Interval = TimeSpan.FromSeconds(5),
};
_timer.Tick += OnTick;
_timer.Start();
}
private void OnTick(object? sender, EventArgs e)
{
if (!_controller.RecordingEnabled) return;
var dir = _controller.RecordingDirectory;
if (string.IsNullOrEmpty(dir)) return;
long freeBytes;
try
{
// DriveInfo wants a drive letter / mount root. Walk up the path
// until we hit a directory that exists (the recording dir might
// not have been created yet by the first ISO).
var probe = dir;
while (!Directory.Exists(probe))
{
var parent = Path.GetDirectoryName(probe);
if (string.IsNullOrEmpty(parent) || parent == probe) break;
probe = parent;
}
if (!Directory.Exists(probe)) return;
var drive = new DriveInfo(Path.GetPathRoot(probe) ?? probe);
freeBytes = drive.AvailableFreeSpace;
}
catch
{
// If the drive query fails (network share dropped, weird path),
// skip this tick rather than spam the toast with errors.
return;
}
if (freeBytes < HardStopBytes)
{
_controller.SetRecording(false, dir);
_toast.Warn($"Recording AUTO-STOPPED — drive has only {FormatBytes(freeBytes)} free");
_lastWarnAt = DateTimeOffset.UtcNow;
}
else if (freeBytes < SoftWarnBytes)
{
// Throttle the soft warning so we don't toast every 5s for the
// last hour of disk space.
if (DateTimeOffset.UtcNow - _lastWarnAt < TimeSpan.FromMinutes(2)) return;
_toast.Warn($"Recording drive has {FormatBytes(freeBytes)} free — winding down soon");
_lastWarnAt = DateTimeOffset.UtcNow;
}
}
private static string FormatBytes(long bytes)
{
if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):F1} GB";
if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):F0} MB";
return $"{bytes} bytes";
}
public void Dispose()
{
_timer.Stop();
_timer.Tick -= OnTick;
}
}

View file

@ -151,9 +151,7 @@ public sealed class OscBridge : IAsyncDisposable
case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return; case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return; case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return;
case "/teamsiso/stop-all": await StopAllAsync(); return; case "/teamsiso/stop-all": await StopAllAsync(); return;
case "/teamsiso/recording": SetRecording(msg); return; // /teamsiso/recording routes removed alongside the rest of the recording surface.
case "/teamsiso/recording/marker": DropMarker(msg); return;
case "/teamsiso/recording/roll": await RollRecordingAsync(); return;
case "/teamsiso/notes": AppendNote(msg); return; case "/teamsiso/notes": AppendNote(msg); return;
case "/teamsiso/iso": await ToggleByNameAsync(msg); return; case "/teamsiso/iso": await ToggleByNameAsync(msg); return;
case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return; case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return;
@ -184,19 +182,8 @@ public sealed class OscBridge : IAsyncDisposable
} }
} }
private void SetRecording(OscMessage msg) // SetRecording / DropMarker / RollRecordingAsync handlers removed alongside
{ // the rest of the recording surface.
var enabled = msg.GetBoolArg(0) ?? false;
// OSC doesn't carry a directory string in this minimal protocol; let the
// recording directory remain whatever the UI / REST surface set last.
_controller.SetRecording(enabled, _controller.RecordingDirectory);
}
private void DropMarker(OscMessage msg)
{
var label = msg.GetStringArg(0) ?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
}
private static void AppendNote(OscMessage msg) private static void AppendNote(OscMessage msg)
{ {
@ -204,31 +191,6 @@ public sealed class OscBridge : IAsyncDisposable
if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text); if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text);
} }
/// <summary>Roll every active recording into a new chunk. Same path as REST /recording/roll.</summary>
private async Task RollRecordingAsync()
{
var vm = _viewModel();
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (vm is null || dispatcher is null) return;
var enabled = await dispatcher.InvokeAsync(() =>
vm.Participants.Where(p => p.IsEnabled).ToArray());
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
}
catch { /* per-pipeline best-effort */ }
}
}
private async Task ToggleByNameAsync(OscMessage msg) private async Task ToggleByNameAsync(OscMessage msg)
{ {
var name = msg.GetStringArg(0); var name = msg.GetStringArg(0);

View file

@ -48,11 +48,6 @@ public static class UIPreferences
// All control happens via the IN-CALL bar + participants DataGrid. // All control happens via the IN-CALL bar + participants DataGrid.
bool LaunchTeamsOnStartup = false, bool LaunchTeamsOnStartup = false,
bool AutoHideTeamsWindows = false, bool AutoHideTeamsWindows = false,
// Auto-record on meeting start: when Teams transitions into a call
// (UIA Leave button appears), TeamsISO flips global recording on;
// when the call ends, recording stops. Pairs naturally with the
// headless workflow — operator never touches the recording toggle.
bool AutoRecordOnCall = false,
// Experimental Phase E.4. SetParent-reparents Teams' main window // Experimental Phase E.4. SetParent-reparents Teams' main window
// into a TeamsISO-owned host. WebView2 in modern Teams can render // into a TeamsISO-owned host. WebView2 in modern Teams can render
// weirdly after reparent; if so the operator unticks and falls // weirdly after reparent; if so the operator unticks and falls

View file

@ -652,7 +652,14 @@
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="TabItem"> <ControlTemplate TargetType="TabItem">
<Grid> <!--
Transparent Background is REQUIRED for the whole header area to be
hit-testable. Without it, only clicks landing exactly on the rendered
glyphs of the header text fire IsSelected — clicks on the padding
around the text fall through and the tab never switches. The template
shipped without this for months; this is the fix.
-->
<Grid Background="Transparent">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>

View file

@ -23,8 +23,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private bool _hideLocalSelf = true; private bool _hideLocalSelf = true;
private bool _autoDisableOnDeparture = false; private bool _autoDisableOnDeparture = false;
private bool _autoApplyLastPreset; private bool _autoApplyLastPreset;
private bool _recordIsosToDisk; // Recording-related fields removed alongside the rest of the recording surface.
private string _recordingDirectory = string.Empty;
private bool _controlSurfaceEnabled; private bool _controlSurfaceEnabled;
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort; private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
private bool _oscBridgeEnabled; private bool _oscBridgeEnabled;
@ -36,7 +35,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private bool _controlSurfaceLanReachable; private bool _controlSurfaceLanReachable;
private bool _launchTeamsOnStartup; private bool _launchTeamsOnStartup;
private bool _autoHideTeamsWindows; private bool _autoHideTeamsWindows;
private bool _autoRecordOnCall; // _autoRecordOnCall removed — recording surface axed.
private bool _embedTeamsWindow; private bool _embedTeamsWindow;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null) public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
@ -65,7 +64,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable; _controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup; _launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows; _autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
_autoRecordOnCall = uiPrefs.AutoRecordOnCall; // AutoRecordOnCall removed — recording surface axed.
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow; _embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
// Bring the auto-apply flag in from the presets store so the checkbox // Bring the auto-apply flag in from the presets store so the checkbox
@ -73,16 +72,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; } try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
catch { /* best-effort — disk read failures shouldn't block UI startup */ } catch { /* best-effort — disk read failures shouldn't block UI startup */ }
// Default recording directory: %USERPROFILE%\Videos\TeamsISO\<today's date>. // Recording-directory init removed alongside the rest of the recording surface.
// Operator can override via the textbox. Date in the path keeps recordings
// from a long-running show day organized without us having to scan + rotate.
_recordingDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
"TeamsISO",
DateTimeOffset.Now.ToString("yyyy-MM-dd"));
_recordIsosToDisk = controller.RecordingEnabled;
if (!string.IsNullOrEmpty(controller.RecordingDirectory))
_recordingDirectory = controller.RecordingDirectory;
ApplyCommand = new AsyncRelayCommand(ApplyAsync); ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync); ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
@ -260,7 +250,6 @@ public sealed class GlobalSettingsViewModel : ObservableObject
ControlSurfaceLanReachable: _controlSurfaceLanReachable, ControlSurfaceLanReachable: _controlSurfaceLanReachable,
LaunchTeamsOnStartup: _launchTeamsOnStartup, LaunchTeamsOnStartup: _launchTeamsOnStartup,
AutoHideTeamsWindows: _autoHideTeamsWindows, AutoHideTeamsWindows: _autoHideTeamsWindows,
AutoRecordOnCall: _autoRecordOnCall,
EmbedTeamsWindow: _embedTeamsWindow)); EmbedTeamsWindow: _embedTeamsWindow));
/// <summary> /// <summary>
@ -295,20 +284,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
} }
} }
/// <summary> // AutoRecordOnCall / RecordIsosToDisk / RecordingDirectory properties
/// When Teams transitions into a call, automatically enable recording; // removed alongside the rest of the recording surface.
/// when the call ends, stop. Removes the manual record-toggle step from
/// the headless workflow. Recording state changes are surfaced via the
/// existing toast + footer badge so the operator knows what's happening.
/// </summary>
public bool AutoRecordOnCall
{
get => _autoRecordOnCall;
set
{
if (SetField(ref _autoRecordOnCall, value)) PersistUiPrefs();
}
}
/// <summary> /// <summary>
/// EXPERIMENTAL: SetParent-reparents Teams' main window into a TeamsISO- /// EXPERIMENTAL: SetParent-reparents Teams' main window into a TeamsISO-
@ -327,42 +304,6 @@ public sealed class GlobalSettingsViewModel : ObservableObject
} }
} }
/// <summary>
/// Record each newly-enabled ISO's normalized output to disk under
/// <see cref="RecordingDirectory"/>. Already-running ISOs are not retroactively
/// recorded — the operator should disable + re-enable them. Outputs raw BGRA
/// + manifest.json + convert.cmd; running convert.cmd produces a final
/// H.264 .mkv via FFmpeg.
/// </summary>
public bool RecordIsosToDisk
{
get => _recordIsosToDisk;
set
{
if (SetField(ref _recordIsosToDisk, value))
{
_controller.SetRecording(value, _recordingDirectory);
_toast?.Show(value
? "Recording on — newly-enabled ISOs will write to disk"
: "Recording off");
}
}
}
/// <summary>
/// Output root for recorder files. Each ISO writes a subdirectory keyed by
/// participant display name. Default: <c>%USERPROFILE%\Videos\TeamsISO\&lt;date&gt;</c>.
/// </summary>
public string RecordingDirectory
{
get => _recordingDirectory;
set
{
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
_controller.SetRecording(true, value);
}
}
/// <summary> /// <summary>
/// REST control surface — Stream Deck / Companion / thin-client controllers. /// REST control surface — Stream Deck / Companion / thin-client controllers.
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>: /// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:

View file

@ -153,13 +153,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
public RelayCommand LeaveCallCommand { get; } public RelayCommand LeaveCallCommand { get; }
public RelayCommand OpenShareTrayCommand { get; } public RelayCommand OpenShareTrayCommand { get; }
/// <summary> // Recording-marker and roll-recording commands removed — recording feature axed.
/// Drop a timestamped marker into every active recording. Bound to a button
/// in the IN-CALL bar; eventually wireable to a global hotkey. The marker
/// label is auto-generated as "Marker @ HH:mm:ss" — operators who want
/// custom labels can edit manifest.json after the fact.
/// </summary>
public RelayCommand DropRecordingMarkerCommand { get; }
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary> /// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
public RelayCommand ShowHelpCommand { get; } public RelayCommand ShowHelpCommand { get; }
@ -167,14 +161,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary> /// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; } public RelayCommand ShowNotesCommand { get; }
/// <summary>
/// Roll-recording: disable + re-enable every currently-recording pipeline,
/// starting a fresh recording chunk in a new subdirectory. Operator-friendly
/// chaptering between show segments without losing already-recorded footage
/// (the previous chunk is finalized on disable, the next chunk starts clean).
/// </summary>
public AsyncRelayCommand RollRecordingCommand { get; }
/// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary> /// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
public RelayCommand JoinMeetingCommand { get; } public RelayCommand JoinMeetingCommand { get; }
@ -208,61 +194,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
set => SetField(ref _statusText, value); set => SetField(ref _statusText, value);
} }
/// <summary> // Recording-status properties (IsRecording, ActiveRecordingCount,
/// Recording status badge — true when at least one ISO is being recorded. // RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
/// Polled at the existing 1Hz stats tick rather than via a dedicated change // recording feature was axed.
/// event, since recording state shifts on enable/disable transitions and
/// the stats poll already reads each pipeline's state.
/// </summary>
public bool IsRecording
{
get => _isRecording;
private set => SetField(ref _isRecording, value);
}
private bool _isRecording;
/// <summary>Number of pipelines currently writing to the recorder.</summary>
public int ActiveRecordingCount
{
get => _activeRecordingCount;
private set => SetField(ref _activeRecordingCount, value);
}
private int _activeRecordingCount;
/// <summary>
/// Elapsed time since recording started this session, formatted as
/// "MM:SS" (or "HH:MM:SS" past an hour). Empty when nothing is
/// recording. Resets when all recordings stop, restarts on the next
/// rec-on transition. Useful for operators tracking "how long has the
/// show been rolling".
/// </summary>
public string RecordingElapsed
{
get => _recordingElapsed;
private set => SetField(ref _recordingElapsed, value);
}
private string _recordingElapsed = string.Empty;
private DateTimeOffset? _recordingStartedAt;
/// <summary>
/// Free disk space on the recording drive, formatted as "245 GB" /
/// "1.2 TB" / "8.4 GB". Empty when recording isn't enabled or the
/// recording path is invalid. Polled at the existing 1Hz stats tick.
/// </summary>
public string RecordingFreeSpace
{
get => _recordingFreeSpace;
private set => SetField(ref _recordingFreeSpace, value);
}
private string _recordingFreeSpace = string.Empty;
/// <summary>True when free disk space drops below 10GB — UI cues coral.</summary>
public bool IsLowDiskSpace
{
get => _isLowDiskSpace;
private set => SetField(ref _isLowDiskSpace, value);
}
private bool _isLowDiskSpace;
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary> /// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
public bool IsControlSurfaceRunning public bool IsControlSurfaceRunning
@ -396,13 +330,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Toast.Show("Refreshing NDI discovery…"); Toast.Show("Refreshing NDI discovery…");
}); });
DropRecordingMarkerCommand = new RelayCommand(() =>
{
var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
_controller.AddRecordingMarker(label);
Toast.Show($"Marker dropped: {label}");
});
ShowHelpCommand = new RelayCommand(() => ShowHelpCommand = new RelayCommand(() =>
{ {
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't // Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
@ -459,9 +386,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
} }
}); });
RollRecordingCommand = new AsyncRelayCommand(RollRecordingAsync,
() => _controller.RecordingEnabled && Participants.Any(p => p.IsEnabled));
ToggleMuteCommand = MakeTeamsCommand( ToggleMuteCommand = MakeTeamsCommand(
label: "Mute", label: "Mute",
invoke: TeamsControlBridge.ToggleMute, invoke: TeamsControlBridge.ToggleMute,
@ -513,47 +437,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// in parallel and trip channel-completion races; for ~10 participants this is /// in parallel and trip channel-completion races; for ~10 participants this is
/// still sub-second total. Failures are swallowed (best-effort emergency stop). /// still sub-second total. Failures are swallowed (best-effort emergency stop).
/// </summary> /// </summary>
/// <summary> // RollRecordingAsync removed — recording feature axed.
/// Roll every active recording into a new chunk: disable + re-enable every
/// pipeline that's currently running. The recorder finalizes its
/// manifest.json on disable and a fresh subdirectory is created on the
/// next enable (RawBgraRecorderSink uses the participant display name +
/// the timestamp template so consecutive rolls don't collide on disk).
///
/// Per-participant best-effort: one bad pipeline doesn't abort the rest.
/// </summary>
private async Task RollRecordingAsync()
{
var enabled = Participants.Where(p => p.IsEnabled).ToArray();
if (enabled.Length == 0)
{
Toast.Show("No active ISOs to roll");
return;
}
var rolled = 0;
foreach (var p in enabled)
{
try
{
await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
await Task.Delay(150);
var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
p.Id,
p.DisplayName)
: p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false;
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
rolled++;
}
catch
{
// Per-pipeline best-effort
}
}
Toast.Show($"Rolled {rolled} recording(s) into a new chunk");
}
/// <summary> /// <summary>
/// Enable ISOs for every online + non-enabled participant in parallel-ish /// Enable ISOs for every online + non-enabled participant in parallel-ish
@ -574,8 +458,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
p.Id, p.Id,
p.DisplayName) p.DisplayName)
: p.CustomName; : p.CustomName;
bool? recordOverride = p.RecordToDisk ? null : false; // 3-arg overload (no recordOverride) — recording surface axed,
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None); // so the engine's per-pipeline recorder sink stays unattached.
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
p.IsEnabled = true; p.IsEnabled = true;
enabled++; enabled++;
} }
@ -685,22 +570,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"); : $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
} }
/// <summary> // FormatBytes removed — its only caller was the recording free-space footer
/// Format a byte count as a human-readable string with 0-1 decimal // label, which went away with the rest of the recording surface.
/// places — e.g. "8.4 GB", "245 GB", "1.2 TB". Optimized for footer
/// readability over precision: nobody needs to know it's 245.34 GB.
/// </summary>
private static string FormatBytes(long bytes)
{
if (bytes < 0) return "—";
const long GB = 1024L * 1024 * 1024;
const long TB = GB * 1024;
if (bytes >= TB) return $"{bytes / (double)TB:0.0} TB";
if (bytes >= GB) return bytes >= 100 * GB
? $"{bytes / GB} GB" // 100+ GB: no decimal — clutter
: $"{bytes / (double)GB:0.0} GB"; // < 100GB: one decimal for the low-warning case
return $"{bytes / 1024 / 1024} MB";
}
/// <summary> /// <summary>
/// Pull the meaningful "meeting title" out of Teams' raw window title. /// Pull the meaningful "meeting title" out of Teams' raw window title.
@ -793,54 +664,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// ISO count; close enough for an at-a-glance footer. // ISO count; close enough for an at-a-glance footer.
var totalParticipants = Participants.Count; var totalParticipants = Participants.Count;
var enabledCount = Participants.Count(p => p.IsEnabled); var enabledCount = Participants.Count(p => p.IsEnabled);
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0; // Recording-elapsed timer + disk-free polling removed alongside the rest
IsRecording = ActiveRecordingCount > 0; // of the recording surface.
// Recording session timer — independent of the global session timer
// since recording can start AFTER the meeting begins (or vice versa)
// and operators want to know exactly how long the archive copy has
// been rolling. Resets to null when all recordings stop, so the
// next rec-on transition starts the timer from 00:00.
if (IsRecording)
{
_recordingStartedAt ??= DateTimeOffset.UtcNow;
var recElapsed = DateTimeOffset.UtcNow - _recordingStartedAt.Value;
RecordingElapsed = recElapsed.TotalHours >= 1
? $"{(int)recElapsed.TotalHours:D2}:{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}"
: $"{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}";
// Disk free on the recording drive. DriveInfo is cheap (a
// single GetDiskFreeSpaceEx call). We tolerate any failure
// path silently — disk-space display is a comfort feature, not
// a critical signal, and the existing DiskSpaceWatcher already
// handles the auto-disable-at-1GB safety net.
try
{
var dir = Settings.RecordingDirectory;
if (!string.IsNullOrEmpty(dir))
{
var root = System.IO.Path.GetPathRoot(dir);
if (!string.IsNullOrEmpty(root))
{
var drive = new System.IO.DriveInfo(root);
if (drive.IsReady)
{
var freeBytes = drive.AvailableFreeSpace;
RecordingFreeSpace = FormatBytes(freeBytes);
IsLowDiskSpace = freeBytes < 10L * 1024 * 1024 * 1024; // <10GB
}
}
}
}
catch { /* defensive — drive enumeration occasionally throws on network paths */ }
}
else
{
_recordingStartedAt = null;
RecordingElapsed = string.Empty;
RecordingFreeSpace = string.Empty;
IsLowDiskSpace = false;
}
// Session timer — start on first ISO going live, reset when none are // Session timer — start on first ISO going live, reset when none are
// live anymore. Subsequent enables after a full-zero gap restart the // live anymore. Subsequent enables after a full-zero gap restart the
@ -876,14 +701,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
? "1 participant visible" ? "1 participant visible"
: $"{totalParticipants} participants visible"; : $"{totalParticipants} participants visible";
} }
else if (ActiveRecordingCount > 0 && ActiveRecordingCount != enabledCount)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · {ActiveRecordingCount} recording";
}
else if (ActiveRecordingCount > 0)
{
StatusText = $"{enabledCount}/{totalParticipants} ISOs live · all recording";
}
else else
{ {
StatusText = $"{enabledCount}/{totalParticipants} ISOs live"; StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
@ -920,7 +737,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null; var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() => _dispatcher.InvokeAsync(() =>
{ {
var previousInCall = IsTeamsInCall;
IsTeamsInCall = inCall; IsTeamsInCall = inCall;
TeamsMeetingState = inCall TeamsMeetingState = inCall
? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}") ? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
@ -928,34 +744,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
// Mute / camera state — only meaningful in-call. // Mute / camera state — only meaningful in-call.
IsLocalMuted = inCall && (snap.IsMuted ?? false); IsLocalMuted = inCall && (snap.IsMuted ?? false);
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false); IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
// Auto-record-on-call hook removed alongside recording feature.
// Auto-record on meeting transitions. False→True
// turns recording on; True→False turns it off.
// Guarded against repeating on every poll by
// gating on the actual transition.
if (Settings.AutoRecordOnCall && previousInCall != inCall)
{
if (inCall && !_controller.RecordingEnabled)
{
try
{
_controller.SetRecording(true, Settings.RecordingDirectory);
Settings.RecordIsosToDisk = true;
Toast.Show("Auto-record: meeting started — recording ON");
}
catch { /* defensive — recording shouldn't block other state */ }
}
else if (!inCall && _controller.RecordingEnabled)
{
try
{
_controller.SetRecording(false, null);
Settings.RecordIsosToDisk = false;
Toast.Show("Auto-record: meeting ended — recording OFF");
}
catch { /* defensive */ }
}
}
}); });
} }
catch { /* UIA flakiness shouldn't crash the stats tick */ } catch { /* UIA flakiness shouldn't crash the stats tick */ }