Files
meeting-assistant/MeetingAssistant/Taskbar/WindowsTaskbarIconService.cs
T

244 lines
7.4 KiB
C#

using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Taskbar;
public sealed class WindowsTaskbarIconService : IHostedService, IDisposable
{
private readonly MeetingRecordingCoordinator coordinator;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly ILogger<WindowsTaskbarIconService> logger;
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
private Thread? uiThread;
private SynchronizationContext? uiContext;
private NotifyIcon? notifyIcon;
private ContextMenuStrip? contextMenu;
private System.Windows.Forms.Timer? refreshTimer;
private Icon? currentIcon;
private RecordingProcessState? currentState;
public WindowsTaskbarIconService(
MeetingRecordingCoordinator coordinator,
ILaunchProfileOptionsProvider launchProfiles,
ILogger<WindowsTaskbarIconService> logger)
{
this.coordinator = coordinator;
this.launchProfiles = launchProfiles;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindows())
{
logger.LogInformation("Taskbar icon is only supported on Windows");
return Task.CompletedTask;
}
uiThread = new Thread(RunMessageLoop)
{
IsBackground = true,
Name = "Meeting Assistant Taskbar Icon"
};
uiThread.SetApartmentState(ApartmentState.STA);
uiThread.Start();
return started.Task.WaitAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
var context = uiContext;
if (context is not null)
{
context.Post(_ => Application.ExitThread(), null);
}
if (uiThread is not null)
{
while (uiThread.IsAlive && !cancellationToken.IsCancellationRequested)
{
await Task.Delay(50, cancellationToken);
}
}
}
public void Dispose()
{
currentIcon?.Dispose();
contextMenu?.Dispose();
notifyIcon?.Dispose();
refreshTimer?.Dispose();
}
private void RunMessageLoop()
{
try
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(uiContext);
contextMenu = new ContextMenuStrip();
contextMenu.Opening += (_, _) => RebuildMenu();
notifyIcon = new NotifyIcon
{
ContextMenuStrip = contextMenu,
Visible = true
};
refreshTimer = new System.Windows.Forms.Timer
{
Interval = 1000
};
refreshTimer.Tick += (_, _) => RefreshVisualState();
refreshTimer.Start();
RefreshVisualState();
started.TrySetResult();
Application.Run();
}
catch (Exception exception)
{
started.TrySetException(exception);
logger.LogError(exception, "Taskbar icon service failed");
}
finally
{
refreshTimer?.Stop();
if (notifyIcon is not null)
{
notifyIcon.Visible = false;
}
Dispose();
}
}
private void RefreshVisualState()
{
if (notifyIcon is null)
{
return;
}
var menu = BuildMenu();
if (currentState != menu.State)
{
var nextIcon = CreateIcon(menu.State);
notifyIcon.Icon = nextIcon;
currentIcon?.Dispose();
currentIcon = nextIcon;
currentState = menu.State;
}
notifyIcon.Text = TruncateTooltip(menu.Tooltip);
}
private void RebuildMenu()
{
if (contextMenu is null)
{
return;
}
var menu = BuildMenu();
contextMenu.Items.Clear();
foreach (var menuItem in menu.Items)
{
var item = new ToolStripMenuItem(menuItem.Text)
{
Tag = menuItem
};
item.Click += (_, _) => _ = ExecuteMenuItemAsync(menuItem);
contextMenu.Items.Add(item);
}
}
private MeetingTaskbarMenu BuildMenu()
{
return MeetingTaskbarMenuBuilder.Build(
coordinator.CurrentStatus,
launchProfiles.GetProfiles());
}
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
{
try
{
switch (item.Action)
{
case MeetingTaskbarAction.StartRecording:
await coordinator.StartAsync(item.ProfileName, CancellationToken.None);
break;
case MeetingTaskbarAction.StopRecording:
await coordinator.StopAsync(CancellationToken.None);
break;
case MeetingTaskbarAction.AbortRecording:
await coordinator.AbortAsync(CancellationToken.None);
break;
case MeetingTaskbarAction.SwitchProfile:
await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None);
break;
}
}
catch (Exception exception)
{
logger.LogError(
exception,
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
}
}
private static string TruncateTooltip(string tooltip)
{
return tooltip.Length <= 63 ? tooltip : tooltip[..63];
}
private static Icon CreateIcon(RecordingProcessState state)
{
var (color, text) = state switch
{
RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"),
RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"),
_ => (Color.FromArgb(75, 85, 99), "I")
};
using var bitmap = new Bitmap(16, 16);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.Transparent);
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
using var brush = new SolidBrush(color);
graphics.FillEllipse(brush, 1, 1, 14, 14);
using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel);
using var textBrush = new SolidBrush(Color.White);
var size = graphics.MeasureString(text, font);
graphics.DrawString(
text,
font,
textBrush,
(16 - size.Width) / 2,
(16 - size.Height) / 2);
}
var handle = bitmap.GetHicon();
try
{
return (Icon)Icon.FromHandle(handle).Clone();
}
finally
{
DestroyIcon(handle);
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
}