From fdd1d1bbfc0ab3192febe0425b46b8ba6d7253a6 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:41:33 -0400 Subject: [PATCH] feat(ui): system tray icon + WinForms/WPF namespace disambiguation --- src/TeamsISO.App/GlobalUsings.cs | 14 +++ src/TeamsISO.App/Services/TrayIconHost.cs | 140 +++++++++++++++++++++ src/TeamsISO.App/Services/UIPreferences.cs | 74 +++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/TeamsISO.App/GlobalUsings.cs create mode 100644 src/TeamsISO.App/Services/TrayIconHost.cs create mode 100644 src/TeamsISO.App/Services/UIPreferences.cs diff --git a/src/TeamsISO.App/GlobalUsings.cs b/src/TeamsISO.App/GlobalUsings.cs new file mode 100644 index 0000000..823d197 --- /dev/null +++ b/src/TeamsISO.App/GlobalUsings.cs @@ -0,0 +1,14 @@ +// Project-wide using aliases. +// +// Why: enabling true for the system-tray +// NotifyIcon brings in System.Windows.Forms.Application and +// System.Windows.Forms.MessageBox, both of which collide with their WPF +// counterparts (System.Windows.*). Every existing call site was written +// for the WPF type. Aliasing globally here is one declaration that keeps +// all the call sites compiling without per-file pollution. +// +// If you ever need the WinForms types, qualify them explicitly as +// `System.Windows.Forms.MessageBox` etc. + +global using Application = System.Windows.Application; +global using MessageBox = System.Windows.MessageBox; diff --git a/src/TeamsISO.App/Services/TrayIconHost.cs b/src/TeamsISO.App/Services/TrayIconHost.cs new file mode 100644 index 0000000..2605ac1 --- /dev/null +++ b/src/TeamsISO.App/Services/TrayIconHost.cs @@ -0,0 +1,140 @@ +using System.Drawing; +using System.IO; +using System.Reflection; +using System.Runtime.Versioning; +using System.Windows; +using WinForms = System.Windows.Forms; + +namespace TeamsISO.App.Services; + +/// +/// Wraps a WinForms so the WPF host can +/// minimize-to-tray during long shows. Operators with a Stream Deck setup +/// often want TeamsISO running but invisible — the tray icon keeps the +/// process alive (and the engine routing live) while the window stays +/// hidden. +/// +/// Lifecycle pattern: instantiate from App.OnStartup after the main +/// window exists; dispose from App.OnExit. The host hooks the main +/// window's StateChanged to detect minimize and toggles +/// WindowState.Minimized + ShowInTaskbar=false + Hide(). +/// +[SupportedOSPlatform("windows")] +public sealed class TrayIconHost : IDisposable +{ + private readonly Window _mainWindow; + private readonly WinForms.NotifyIcon _notifyIcon; + private bool _enabled; + + public TrayIconHost(Window mainWindow) + { + _mainWindow = mainWindow; + _notifyIcon = new WinForms.NotifyIcon + { + Text = "TeamsISO", + Icon = LoadEmbeddedIcon(), + Visible = false, + }; + _notifyIcon.DoubleClick += (_, _) => RestoreFromTray(); + _notifyIcon.ContextMenuStrip = BuildMenu(); + } + + /// + /// Toggles the minimize-to-tray behavior. When on, minimizing the window + /// hides it and shows a tray icon; when off, minimize is normal Windows + /// behavior. Read by the operator's checkbox in DISPLAY settings; the + /// setting persists via . + /// + public bool Enabled + { + get => _enabled; + set + { + if (_enabled == value) return; + _enabled = value; + if (value) + { + _mainWindow.StateChanged += OnMainWindowStateChanged; + } + else + { + _mainWindow.StateChanged -= OnMainWindowStateChanged; + // If we're currently minimized + hidden, restore so the user + // doesn't lose the window when they disable the setting. + RestoreFromTray(); + _notifyIcon.Visible = false; + } + } + } + + private void OnMainWindowStateChanged(object? sender, EventArgs e) + { + if (_mainWindow.WindowState != WindowState.Minimized) return; + // Hide from taskbar + hide the window, show the tray icon. + _mainWindow.ShowInTaskbar = false; + _mainWindow.Hide(); + _notifyIcon.Visible = true; + _notifyIcon.ShowBalloonTip( + timeout: 1500, + tipTitle: "TeamsISO is still running", + tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.", + tipIcon: WinForms.ToolTipIcon.Info); + } + + private void RestoreFromTray() + { + _mainWindow.Show(); + _mainWindow.WindowState = WindowState.Normal; + _mainWindow.ShowInTaskbar = true; + _mainWindow.Activate(); + _notifyIcon.Visible = false; + } + + private WinForms.ContextMenuStrip BuildMenu() + { + var menu = new WinForms.ContextMenuStrip(); + menu.Items.Add("Show TeamsISO", null, (_, _) => RestoreFromTray()); + menu.Items.Add("-"); + menu.Items.Add("Stop all ISOs", null, (_, _) => + { + // Reach into the VM via the main window. Using string-keyed + // command lookup would be more decoupled but adds overhead. + if (_mainWindow.DataContext is ViewModels.MainViewModel vm + && vm.StopAllIsosCommand.CanExecute(null)) + { + vm.StopAllIsosCommand.Execute(null); + } + }); + menu.Items.Add("-"); + menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown()); + return menu; + } + + /// + /// Load the bundled teamsiso.ico from this assembly's resources. We use + /// the embedded resource rather than the file-system path because the + /// app may be run from any CWD (via the MSI install or a developer dotnet run). + /// + private static Icon LoadEmbeddedIcon() + { + try + { + var asm = Assembly.GetExecutingAssembly(); + var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico"); + using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream; + if (stream is not null) return new Icon(stream); + } + catch + { + // Fall through to the OS default + } + return SystemIcons.Application; + } + + public void Dispose() + { + try { _mainWindow.StateChanged -= OnMainWindowStateChanged; } catch { /* ignore */ } + try { _notifyIcon.Visible = false; } catch { /* ignore */ } + try { _notifyIcon.Dispose(); } catch { /* ignore */ } + } +} diff --git a/src/TeamsISO.App/Services/UIPreferences.cs b/src/TeamsISO.App/Services/UIPreferences.cs new file mode 100644 index 0000000..027d007 --- /dev/null +++ b/src/TeamsISO.App/Services/UIPreferences.cs @@ -0,0 +1,74 @@ +using System.IO; +using System.Text.Json; + +namespace TeamsISO.App.Services; + +/// +/// Persistent UI-side toggles that don't belong in +/// (which is the engine's domain model — framerate, NDI groups, ISO assignments). +/// +/// Each toggle is a property on a single record persisted as JSON at +/// %LOCALAPPDATA%\TeamsISO\ui-prefs.json. Defaults match the original +/// in-memory behavior: HideLocalSelf=true (filter the operator's own preview +/// out of the participants list) and AutoDisableOnDeparture=false (a participant +/// going offline doesn't tear down their pipeline by default — operators +/// usually want to keep the routing in case they reconnect). +/// +/// Centralizing these here means the settings VM doesn't have to plumb +/// individual Set methods to dedicated services for every new bool. +/// +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"); + + /// + /// Sort modes for the participants DataGrid. is the default + /// and matches the engine's discovery order (operators with custom Stream Deck + /// layouts sometimes prefer Alphabetical for stability across meetings). + /// + public enum SortMode { JoinOrder, Alphabetical, OnlineFirst } + + /// The on-disk shape. New fields added here become opt-in for older files via default values. + public sealed record Prefs( + bool HideLocalSelf = true, + bool AutoDisableOnDeparture = false, + SortMode ParticipantSort = SortMode.JoinOrder, + bool MinimizeToTray = false); + + public static Prefs Load() + { + try + { + if (!File.Exists(PrefsPath)) return new Prefs(); + var json = File.ReadAllText(PrefsPath); + return JsonSerializer.Deserialize(json) ?? new Prefs(); + } + catch + { + return new Prefs(); + } + } + + public static void Save(Prefs prefs) + { + try + { + lock (_gate) + { + var dir = Path.GetDirectoryName(PrefsPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(prefs, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(PrefsPath, json); + } + } + catch + { + // Disk full / permission denied — in-memory state still holds for this session. + } + } +}