feat(ui): system tray icon + WinForms/WPF namespace disambiguation
This commit is contained in:
parent
832aad6a14
commit
fdd1d1bbfc
3 changed files with 228 additions and 0 deletions
14
src/TeamsISO.App/GlobalUsings.cs
Normal file
14
src/TeamsISO.App/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Project-wide using aliases.
|
||||||
|
//
|
||||||
|
// Why: enabling <UseWindowsForms>true</UseWindowsForms> 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;
|
||||||
140
src/TeamsISO.App/Services/TrayIconHost.cs
Normal file
140
src/TeamsISO.App/Services/TrayIconHost.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> 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 <c>App.OnStartup</c> after the main
|
||||||
|
/// window exists; dispose from <c>App.OnExit</c>. The host hooks the main
|
||||||
|
/// window's <c>StateChanged</c> to detect minimize and toggles
|
||||||
|
/// <c>WindowState.Minimized</c> + <c>ShowInTaskbar=false</c> + <c>Hide()</c>.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="UIPreferences"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/TeamsISO.App/Services/UIPreferences.cs
Normal file
74
src/TeamsISO.App/Services/UIPreferences.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TeamsISO.App.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent UI-side toggles that don't belong in <see cref="TeamsISO.Engine.Persistence.ConfigStore"/>
|
||||||
|
/// (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
|
||||||
|
/// <c>%LOCALAPPDATA%\TeamsISO\ui-prefs.json</c>. 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.
|
||||||
|
/// </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");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
|
||||||
|
/// and matches the engine's discovery order (operators with custom Stream Deck
|
||||||
|
/// layouts sometimes prefer Alphabetical for stability across meetings).
|
||||||
|
/// </summary>
|
||||||
|
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst }
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue