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.
+ }
+ }
+}