feat(wpf): rollback to WPF host, axe recording, fix settings pane
Some checks failed
CI / build-and-test (push) Failing after 29s
Some checks failed
CI / build-and-test (push) Failing after 29s
This commit is contained in:
parent
426cf33dec
commit
1d1ce6a2a0
44 changed files with 79 additions and 5073 deletions
|
|
@ -21,8 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\Tea
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.Tests", "src\tests\TeamsISO.App.Tests\TeamsISO.App.Tests.csproj", "{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App.WinUI", "src\TeamsISO.App.WinUI\TeamsISO.App.WinUI.csproj", "{14928B5A-E45C-4265-A5D7-D13B5ED18F84}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -60,10 +58,6 @@ Global
|
|||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{14928B5A-E45C-4265-A5D7-D13B5ED18F84}.Release|Any CPU.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
|
|
@ -74,6 +68,5 @@ Global
|
|||
{A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
{B5A6F1E7-3D2C-4F89-9A55-7E1B2A4C8D6F} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848}
|
||||
{14928B5A-E45C-4265-A5D7-D13B5ED18F84} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
Binary file not shown.
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 |
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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*"name":\s*"Microsoft\.WindowsDesktop\.App"[^\}]*\}', ''))</_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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace TeamsISO.App.WinUI.Views;
|
||||
|
||||
public sealed partial class AboutDialog : ContentDialog
|
||||
{
|
||||
public AboutDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace TeamsISO.App.WinUI.Views;
|
||||
|
||||
public sealed partial class HelpDialog : ContentDialog
|
||||
{
|
||||
public HelpDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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="" 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="" 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="" 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="" 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>
|
||||
|
|
@ -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 ? "" : "";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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\<date>\.
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<CheckBox x:Name="DontShowAgain"
|
||||
Content="Don't show this again"
|
||||
IsChecked="True"/>
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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="" 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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -147,12 +147,6 @@
|
|||
Padding="14,6"
|
||||
Margin="0,0,8,0"
|
||||
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}"
|
||||
Content="Notes"
|
||||
Click="OnOpenNotes"
|
||||
|
|
|
|||
|
|
@ -89,17 +89,7 @@ public partial class AboutWindow : Window
|
|||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"TeamsISO", "Logs"));
|
||||
|
||||
private void OnOpenRecordings(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// 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"));
|
||||
}
|
||||
// OnOpenRecordings removed — recording feature axed.
|
||||
|
||||
private void OnOpenNotes(object sender, RoutedEventArgs e) =>
|
||||
OpenInExplorer(Path.Combine(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public partial class App : Application
|
|||
private MainViewModel? _viewModel;
|
||||
private TeamsISO.App.Services.ControlSurfaceServer? _controlSurface;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -181,11 +181,7 @@ public partial class App : Application
|
|||
() => _viewModel,
|
||||
_loggerFactory.CreateLogger<TeamsISO.App.Services.OscBridge>());
|
||||
|
||||
// Disk space watcher: polls the recording drive every 5s while
|
||||
// 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);
|
||||
// DiskSpaceWatcher removed alongside the rest of the recording surface.
|
||||
|
||||
// Tray icon host. Disabled by default; the settings VM flips
|
||||
// Enabled when the operator toggles the DISPLAY checkbox. Hosting
|
||||
|
|
@ -398,7 +394,6 @@ public partial class App : Application
|
|||
try
|
||||
{
|
||||
_trayIcon?.Dispose();
|
||||
_diskSpaceWatcher?.Dispose();
|
||||
if (_controlSurface is not null)
|
||||
await _controlSurface.DisposeAsync();
|
||||
if (_oscBridge is not null)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@
|
|||
-->
|
||||
<Window.InputBindings>
|
||||
<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="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
|
||||
<!-- NumPad 1-9 toggles ISO for the Nth visible participant (sort + filter aware).
|
||||
|
|
@ -80,7 +79,13 @@
|
|||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="72"/> <!-- Left rail -->
|
||||
<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>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
|
|
@ -181,10 +186,14 @@
|
|||
Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<!-- Nav: Settings (placeholder — opens panel on right; not toggled in this build) -->
|
||||
<Button DockPanel.Dock="Top"
|
||||
<!-- Nav: Settings — toggles the 380px right pane open / collapsed.
|
||||
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}"
|
||||
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"
|
||||
Stroke="{DynamicResource Wd.Text.Secondary}"
|
||||
StrokeThickness="1.5"
|
||||
|
|
@ -372,49 +381,11 @@
|
|||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Recording badge — coral dot + count when recording is on -->
|
||||
<StackPanel Grid.Column="3"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0"
|
||||
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>
|
||||
<!-- Recording badge intentionally removed — recording is no longer
|
||||
a TeamsISO feature; the REC badge, marker key binding, settings
|
||||
toggles, and recorder wiring were all stripped together. The
|
||||
engine still exposes the raw plumbing for future re-introduction
|
||||
but the operator UI surface is gone. -->
|
||||
|
||||
<!-- Control surface badge — cyan dot + REST/OSC string when active -->
|
||||
<StackPanel Grid.Column="4"
|
||||
|
|
@ -644,26 +615,8 @@
|
|||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||
Command="{Binding DropRecordingMarkerCommand}"
|
||||
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>
|
||||
<!-- Recording marker button removed alongside the rest of the
|
||||
recording feature surface. -->
|
||||
<Button Style="{StaticResource Wd.Button.Ghost}"
|
||||
Command="{Binding ShowNotesCommand}"
|
||||
Padding="14,6"
|
||||
|
|
@ -962,9 +915,6 @@
|
|||
<MenuItem Header="Save current frame…"
|
||||
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."/>
|
||||
<MenuItem Header="Record this participant"
|
||||
IsCheckable="True"
|
||||
IsChecked="{Binding RecordToDisk}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Copy NDI source name"
|
||||
Command="{Binding CopySourceNameCommand}"
|
||||
|
|
@ -1108,22 +1058,7 @@
|
|||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<!--
|
||||
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>
|
||||
<!-- "Rec" per-row column removed — recording feature stripped. -->
|
||||
|
||||
<DataGridTemplateColumn Header="ISO" Width="130">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
|
|
@ -1430,12 +1365,9 @@
|
|||
<CheckBox Content="Auto-hide Teams windows when launched"
|
||||
IsChecked="{Binding Settings.AutoHideTeamsWindows}"
|
||||
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"
|
||||
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."/>
|
||||
<!-- Auto-record-on-call removed alongside the rest of the recording surface. -->
|
||||
|
||||
<!-- Phase E.4 experimental — SetParent embed. WebView2 in modern
|
||||
Teams can render weirdly after reparent; if so, untick and
|
||||
|
|
@ -1458,24 +1390,8 @@
|
|||
|
||||
<Separator Margin="0,16,0,8"/>
|
||||
|
||||
<CheckBox Content="Record ISOs to disk"
|
||||
IsChecked="{Binding Settings.RecordIsosToDisk}"
|
||||
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\<date>."/>
|
||||
<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"/>
|
||||
<!-- "Record ISOs to disk" + recording directory removed
|
||||
alongside the rest of the recording surface. -->
|
||||
|
||||
<CheckBox Content="Control surface (Stream Deck / Companion / web)"
|
||||
IsChecked="{Binding Settings.ControlSurfaceEnabled}"
|
||||
|
|
@ -1534,7 +1450,7 @@
|
|||
Foreground="{DynamicResource Wd.Text.Tertiary}"
|
||||
TextWrapping="Wrap"
|
||||
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)"
|
||||
IsChecked="{Binding Settings.OscBridgeEnabled}"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,21 @@ public partial class MainWindow : Window
|
|||
/// <summary>Custom close button.</summary>
|
||||
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>
|
||||
private void OnAboutClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -254,9 +254,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
|||
("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
|
||||
("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
|
||||
("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
|
||||
("POST", "/recording") => SetRecording(body, req.QueryString),
|
||||
("POST", "/recording/marker") => DropMarker(body, req.QueryString),
|
||||
("POST", "/recording/roll") => await RollRecordingAsync(),
|
||||
// /recording routes removed alongside the rest of the recording surface.
|
||||
("POST", "/notes") => AppendNote(body, req.QueryString),
|
||||
("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString),
|
||||
_ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
|
||||
|
|
@ -315,11 +313,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
|||
discoveryGroups = groups?.DiscoveryGroups,
|
||||
outputGroups = groups?.OutputGroups,
|
||||
},
|
||||
recording = new
|
||||
{
|
||||
enabled = _controller.RecordingEnabled,
|
||||
directory = _controller.RecordingDirectory,
|
||||
},
|
||||
// recording status fields removed alongside the rest of the recording surface.
|
||||
endpoints = new[]
|
||||
{
|
||||
"GET / (this)",
|
||||
|
|
@ -332,8 +326,6 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
|||
"POST /presets/refresh-discovery",
|
||||
"POST /presets/stop-all",
|
||||
"POST /teams/mute, /camera, /leave, /share, /raise-hand",
|
||||
"POST /recording (body: enabled + directory)",
|
||||
"POST /recording/marker (body: label)",
|
||||
"POST /notes (body: text)",
|
||||
},
|
||||
};
|
||||
|
|
@ -405,21 +397,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
|
|||
};
|
||||
}
|
||||
|
||||
private object SetRecording(JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
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 };
|
||||
}
|
||||
// SetRecording and DropMarker methods removed alongside the rest of the recording surface.
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 };
|
||||
}
|
||||
// RollRecordingAsync handler removed alongside the rest of the recording surface.
|
||||
|
||||
private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -151,9 +151,7 @@ public sealed class OscBridge : IAsyncDisposable
|
|||
case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return;
|
||||
case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return;
|
||||
case "/teamsiso/stop-all": await StopAllAsync(); return;
|
||||
case "/teamsiso/recording": SetRecording(msg); return;
|
||||
case "/teamsiso/recording/marker": DropMarker(msg); return;
|
||||
case "/teamsiso/recording/roll": await RollRecordingAsync(); return;
|
||||
// /teamsiso/recording routes removed alongside the rest of the recording surface.
|
||||
case "/teamsiso/notes": AppendNote(msg); return;
|
||||
case "/teamsiso/iso": await ToggleByNameAsync(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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
// SetRecording / DropMarker / RollRecordingAsync handlers removed alongside
|
||||
// the rest of the recording surface.
|
||||
|
||||
private static void AppendNote(OscMessage msg)
|
||||
{
|
||||
|
|
@ -204,31 +191,6 @@ public sealed class OscBridge : IAsyncDisposable
|
|||
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)
|
||||
{
|
||||
var name = msg.GetStringArg(0);
|
||||
|
|
|
|||
|
|
@ -48,11 +48,6 @@ public static class UIPreferences
|
|||
// All control happens via the IN-CALL bar + participants DataGrid.
|
||||
bool LaunchTeamsOnStartup = 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
|
||||
// into a TeamsISO-owned host. WebView2 in modern Teams can render
|
||||
// weirdly after reparent; if so the operator unticks and falls
|
||||
|
|
|
|||
|
|
@ -652,7 +652,14 @@
|
|||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<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>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
private bool _hideLocalSelf = true;
|
||||
private bool _autoDisableOnDeparture = false;
|
||||
private bool _autoApplyLastPreset;
|
||||
private bool _recordIsosToDisk;
|
||||
private string _recordingDirectory = string.Empty;
|
||||
// Recording-related fields removed alongside the rest of the recording surface.
|
||||
private bool _controlSurfaceEnabled;
|
||||
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
|
||||
private bool _oscBridgeEnabled;
|
||||
|
|
@ -36,7 +35,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
private bool _controlSurfaceLanReachable;
|
||||
private bool _launchTeamsOnStartup;
|
||||
private bool _autoHideTeamsWindows;
|
||||
private bool _autoRecordOnCall;
|
||||
// _autoRecordOnCall removed — recording surface axed.
|
||||
private bool _embedTeamsWindow;
|
||||
|
||||
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
|
||||
|
|
@ -65,7 +64,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
|
||||
_launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
|
||||
_autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
|
||||
_autoRecordOnCall = uiPrefs.AutoRecordOnCall;
|
||||
// AutoRecordOnCall removed — recording surface axed.
|
||||
_embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
|
||||
|
||||
// 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; }
|
||||
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
|
||||
|
||||
// Default recording directory: %USERPROFILE%\Videos\TeamsISO\<today's date>.
|
||||
// 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;
|
||||
// Recording-directory init removed alongside the rest of the recording surface.
|
||||
|
||||
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
|
||||
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
|
||||
|
|
@ -260,7 +250,6 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
ControlSurfaceLanReachable: _controlSurfaceLanReachable,
|
||||
LaunchTeamsOnStartup: _launchTeamsOnStartup,
|
||||
AutoHideTeamsWindows: _autoHideTeamsWindows,
|
||||
AutoRecordOnCall: _autoRecordOnCall,
|
||||
EmbedTeamsWindow: _embedTeamsWindow));
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -295,20 +284,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When Teams transitions into a call, automatically enable recording;
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
// AutoRecordOnCall / RecordIsosToDisk / RecordingDirectory properties
|
||||
// removed alongside the rest of the recording surface.
|
||||
|
||||
/// <summary>
|
||||
/// 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\<date></c>.
|
||||
/// </summary>
|
||||
public string RecordingDirectory
|
||||
{
|
||||
get => _recordingDirectory;
|
||||
set
|
||||
{
|
||||
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
|
||||
_controller.SetRecording(true, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REST control surface — Stream Deck / Companion / thin-client controllers.
|
||||
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
|
||||
|
|
|
|||
|
|
@ -153,13 +153,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
public RelayCommand LeaveCallCommand { get; }
|
||||
public RelayCommand OpenShareTrayCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
// Recording-marker and roll-recording commands removed — recording feature axed.
|
||||
|
||||
/// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
|
||||
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>
|
||||
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>
|
||||
public RelayCommand JoinMeetingCommand { get; }
|
||||
|
||||
|
|
@ -208,61 +194,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
set => SetField(ref _statusText, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recording status badge — true when at least one ISO is being recorded.
|
||||
/// Polled at the existing 1Hz stats tick rather than via a dedicated change
|
||||
/// 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;
|
||||
// Recording-status properties (IsRecording, ActiveRecordingCount,
|
||||
// RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
|
||||
// recording feature was axed.
|
||||
|
||||
/// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
|
||||
public bool IsControlSurfaceRunning
|
||||
|
|
@ -396,13 +330,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
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(() =>
|
||||
{
|
||||
// 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(
|
||||
label: "Mute",
|
||||
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
|
||||
/// still sub-second total. Failures are swallowed (best-effort emergency stop).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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");
|
||||
}
|
||||
// RollRecordingAsync removed — recording feature axed.
|
||||
|
||||
/// <summary>
|
||||
/// Enable ISOs for every online + non-enabled participant in parallel-ish
|
||||
|
|
@ -574,8 +458,9 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
p.Id,
|
||||
p.DisplayName)
|
||||
: p.CustomName;
|
||||
bool? recordOverride = p.RecordToDisk ? null : false;
|
||||
await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
|
||||
// 3-arg overload (no recordOverride) — recording surface axed,
|
||||
// so the engine's per-pipeline recorder sink stays unattached.
|
||||
await _controller.EnableIsoAsync(p.Id, resolvedName, CancellationToken.None);
|
||||
p.IsEnabled = true;
|
||||
enabled++;
|
||||
}
|
||||
|
|
@ -685,22 +570,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
: $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a byte count as a human-readable string with 0-1 decimal
|
||||
/// 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";
|
||||
}
|
||||
// FormatBytes removed — its only caller was the recording free-space footer
|
||||
// label, which went away with the rest of the recording surface.
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
var totalParticipants = Participants.Count;
|
||||
var enabledCount = Participants.Count(p => p.IsEnabled);
|
||||
ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
|
||||
IsRecording = ActiveRecordingCount > 0;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Recording-elapsed timer + disk-free polling removed alongside the rest
|
||||
// of the recording surface.
|
||||
|
||||
// Session timer — start on first ISO going live, reset when none are
|
||||
// live anymore. Subsequent enables after a full-zero gap restart the
|
||||
|
|
@ -876,14 +701,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
? "1 participant 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
|
||||
{
|
||||
StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
|
||||
|
|
@ -920,7 +737,6 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var previousInCall = IsTeamsInCall;
|
||||
IsTeamsInCall = inCall;
|
||||
TeamsMeetingState = inCall
|
||||
? (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.
|
||||
IsLocalMuted = inCall && (snap.IsMuted ?? false);
|
||||
IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);
|
||||
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
// Auto-record-on-call hook removed alongside recording feature.
|
||||
});
|
||||
}
|
||||
catch { /* UIA flakiness shouldn't crash the stats tick */ }
|
||||
|
|
|
|||
Loading…
Reference in a new issue