Add taskbar controls and summary oneliner

This commit is contained in:
2026-05-27 23:31:40 +02:00
parent 7777b349a5
commit 71f1e6a0b8
22 changed files with 720 additions and 9 deletions
@@ -0,0 +1,87 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Taskbar;
public enum MeetingTaskbarAction
{
StartRecording,
StopRecording,
AbortRecording,
SwitchProfile
}
public sealed record MeetingTaskbarMenu(
RecordingProcessState State,
string Tooltip,
IReadOnlyList<MeetingTaskbarMenuItem> Items);
public sealed record MeetingTaskbarMenuItem(
string Text,
MeetingTaskbarAction Action,
string? ProfileName = null);
public static class MeetingTaskbarMenuBuilder
{
public static MeetingTaskbarMenu Build(
RecordingStatus status,
IReadOnlyList<LaunchProfile> launchProfiles)
{
var items = new List<MeetingTaskbarMenuItem>();
if (status.IsRecording)
{
items.Add(new MeetingTaskbarMenuItem(
"Stop meeting recording and transcribe",
MeetingTaskbarAction.StopRecording));
items.Add(new MeetingTaskbarMenuItem(
"Cancel meeting recording and discard",
MeetingTaskbarAction.AbortRecording));
foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status)))
{
items.Add(new MeetingTaskbarMenuItem(
$"Switch to {profile.Name}",
MeetingTaskbarAction.SwitchProfile,
profile.Name));
}
}
else
{
foreach (var profile in launchProfiles)
{
items.Add(new MeetingTaskbarMenuItem(
$"Start meeting recording ({profile.Name})",
MeetingTaskbarAction.StartRecording,
profile.Name));
}
}
return new MeetingTaskbarMenu(
status.State,
BuildTooltip(status),
items);
}
private static string BuildTooltip(RecordingStatus status)
{
return status.State switch
{
RecordingProcessState.Recording => string.IsNullOrWhiteSpace(status.LaunchProfile)
? "Meeting Assistant - recording"
: $"Meeting Assistant - recording ({status.LaunchProfile})",
RecordingProcessState.Summarizing => "Meeting Assistant - summarizing",
_ => "Meeting Assistant - idle"
};
}
private static bool IsActiveProfile(
LaunchProfile profile,
RecordingStatus status)
{
return string.Equals(
profile.Name,
status.LaunchProfile,
StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,243 @@
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);
}