Public Access
358 lines
12 KiB
C#
358 lines
12 KiB
C#
#if WINDOWS
|
|
using System.Drawing;
|
|
using System.Windows;
|
|
using H.NotifyIcon.Core;
|
|
using MeetingAssistant.LaunchProfiles;
|
|
using MeetingAssistant.Recording;
|
|
using MeetingAssistant.Workflow;
|
|
|
|
namespace MeetingAssistant.Taskbar;
|
|
|
|
public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
|
{
|
|
private static readonly Guid TrayIconId = Guid.Parse("91F3D8B7-E61F-4E73-98F5-29A665991C67");
|
|
|
|
private readonly MeetingRecordingCoordinator coordinator;
|
|
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
|
private readonly IMicrophoneDeviceProvider microphones;
|
|
private readonly MicrophoneDeviceSelection microphoneSelection;
|
|
private readonly IWorkflowRulesEditorWindowService rulesEditorWindow;
|
|
private readonly IHostApplicationLifetime applicationLifetime;
|
|
private readonly ILogger<UnoTaskbarIconService> logger;
|
|
private readonly object sync = new();
|
|
private CancellationTokenSource? refreshCancellation;
|
|
private Task? refreshTask;
|
|
private TrayIconWithContextMenu? trayIcon;
|
|
private Icon? currentIcon;
|
|
private RecordingProcessState? currentState;
|
|
private string? currentProfilesSignature;
|
|
private string? currentMenuSignature;
|
|
|
|
public UnoTaskbarIconService(
|
|
MeetingRecordingCoordinator coordinator,
|
|
ILaunchProfileOptionsProvider launchProfiles,
|
|
IMicrophoneDeviceProvider microphones,
|
|
MicrophoneDeviceSelection microphoneSelection,
|
|
IWorkflowRulesEditorWindowService rulesEditorWindow,
|
|
IHostApplicationLifetime applicationLifetime,
|
|
ILogger<UnoTaskbarIconService> logger)
|
|
{
|
|
this.coordinator = coordinator;
|
|
this.launchProfiles = launchProfiles;
|
|
this.microphones = microphones;
|
|
this.microphoneSelection = microphoneSelection;
|
|
this.rulesEditorWindow = rulesEditorWindow;
|
|
this.applicationLifetime = applicationLifetime;
|
|
this.logger = logger;
|
|
}
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
logger.LogInformation("Taskbar icon is only supported on Windows");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var menu = BuildMenu();
|
|
currentMenuSignature = BuildMenuSignature(menu);
|
|
currentIcon = TaskbarIconRenderer.CreateIcon(menu.State);
|
|
currentState = menu.State;
|
|
trayIcon = new TrayIconWithContextMenu(TrayIconId)
|
|
{
|
|
Icon = currentIcon.Handle,
|
|
ToolTip = menu.Tooltip,
|
|
ContextMenu = BuildContextMenu(menu)
|
|
};
|
|
trayIcon.Created += (_, _) => logger.LogInformation(
|
|
"Taskbar icon native create event received: isCreated={IsCreated}",
|
|
trayIcon.IsCreated);
|
|
trayIcon.Removed += (_, _) => logger.LogInformation("Taskbar icon native remove event received");
|
|
trayIcon.Create();
|
|
trayIcon.Show();
|
|
logger.LogInformation(
|
|
"Taskbar icon created through H.NotifyIcon: isCreated={IsCreated}, state={State}, tooltip={Tooltip}",
|
|
trayIcon.IsCreated,
|
|
menu.State,
|
|
menu.Tooltip);
|
|
|
|
refreshCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
refreshTask = Task.Run(() => RefreshLoopAsync(refreshCancellation.Token), CancellationToken.None);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
refreshCancellation?.Cancel();
|
|
if (refreshTask is not null)
|
|
{
|
|
try
|
|
{
|
|
await refreshTask.WaitAsync(cancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
}
|
|
|
|
DisposeTrayIcon();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
refreshCancellation?.Cancel();
|
|
refreshCancellation?.Dispose();
|
|
DisposeTrayIcon();
|
|
}
|
|
|
|
private async Task RefreshLoopAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
|
{
|
|
try
|
|
{
|
|
RefreshVisualState();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Taskbar icon refresh failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshVisualState()
|
|
{
|
|
lock (sync)
|
|
{
|
|
if (trayIcon is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var menu = BuildMenu();
|
|
var menuSignature = BuildMenuSignature(menu);
|
|
if (currentState != menu.State)
|
|
{
|
|
var previousIcon = currentIcon;
|
|
var nextIcon = TaskbarIconRenderer.CreateIcon(menu.State);
|
|
if (!trayIcon.UpdateIcon(nextIcon.Handle))
|
|
{
|
|
logger.LogWarning("Taskbar icon update returned false for state {State}", menu.State);
|
|
}
|
|
|
|
currentIcon = nextIcon;
|
|
currentState = menu.State;
|
|
previousIcon?.Dispose();
|
|
logger.LogInformation("Taskbar icon state changed to {State}", menu.State);
|
|
}
|
|
|
|
trayIcon.UpdateToolTip(menu.Tooltip);
|
|
if (!string.Equals(currentMenuSignature, menuSignature, StringComparison.Ordinal))
|
|
{
|
|
trayIcon.ContextMenu = BuildContextMenu(menu);
|
|
currentMenuSignature = menuSignature;
|
|
logger.LogInformation("Taskbar menu changed: {MenuItems}", menuSignature);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DisposeTrayIcon()
|
|
{
|
|
lock (sync)
|
|
{
|
|
trayIcon?.Dispose();
|
|
trayIcon = null;
|
|
currentIcon?.Dispose();
|
|
currentIcon = null;
|
|
currentState = null;
|
|
currentMenuSignature = null;
|
|
}
|
|
}
|
|
|
|
private MeetingTaskbarMenu BuildMenu()
|
|
{
|
|
var profiles = launchProfiles.GetProfiles();
|
|
var activeOptions = GetActiveProfileOptions(profiles);
|
|
var microphoneSnapshot = microphones.GetMicrophoneSnapshot(activeOptions);
|
|
var profilesSignature = string.Join(
|
|
", ",
|
|
profiles.Select(profile => $"{profile.Name}:{profile.Options.Hotkey.Toggle}"));
|
|
if (!string.Equals(currentProfilesSignature, profilesSignature, StringComparison.Ordinal))
|
|
{
|
|
currentProfilesSignature = profilesSignature;
|
|
logger.LogInformation("Taskbar menu launch profiles: {LaunchProfiles}", profilesSignature);
|
|
}
|
|
|
|
return MeetingTaskbarMenuBuilder.Build(
|
|
coordinator.CurrentStatus,
|
|
profiles,
|
|
microphoneSnapshot.Available,
|
|
microphoneSnapshot.Current?.Id);
|
|
}
|
|
|
|
private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu)
|
|
{
|
|
var popupMenu = new PopupMenu();
|
|
for (var index = 0; index < menu.Items.Count; index++)
|
|
{
|
|
if (index == 1 ||
|
|
(menu.Items[index].Action == MeetingTaskbarAction.Exit &&
|
|
menu.Items[index - 1].Action != MeetingTaskbarAction.EditRules))
|
|
{
|
|
popupMenu.Items.Add(new PopupMenuSeparator());
|
|
}
|
|
|
|
var menuItem = menu.Items[index];
|
|
popupMenu.Items.Add(BuildPopupItem(menuItem));
|
|
}
|
|
|
|
return popupMenu;
|
|
}
|
|
|
|
private PopupItem BuildPopupItem(MeetingTaskbarMenuItem menuItem)
|
|
{
|
|
if (menuItem.Items is { Count: > 0 })
|
|
{
|
|
var submenu = new PopupSubMenu(menuItem.Text);
|
|
foreach (var child in menuItem.Items)
|
|
{
|
|
submenu.Items.Add(BuildPopupItem(child));
|
|
}
|
|
|
|
return submenu;
|
|
}
|
|
|
|
return new PopupMenuItem(
|
|
menuItem.Text,
|
|
(_, _) => _ = ExecuteMenuItemAsync(menuItem))
|
|
{
|
|
Checked = menuItem.IsChecked
|
|
};
|
|
}
|
|
|
|
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation(
|
|
"Taskbar menu action {Action} selected for launch profile {LaunchProfile}",
|
|
item.Action,
|
|
item.ProfileName);
|
|
|
|
switch (item.Action)
|
|
{
|
|
case MeetingTaskbarAction.EditRules:
|
|
rulesEditorWindow.Show();
|
|
break;
|
|
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;
|
|
case MeetingTaskbarAction.SelectMicrophone:
|
|
if (!string.IsNullOrWhiteSpace(item.MicrophoneDeviceId))
|
|
{
|
|
microphoneSelection.Select(item.MicrophoneDeviceId);
|
|
}
|
|
|
|
break;
|
|
case MeetingTaskbarAction.Exit:
|
|
ExitApplication();
|
|
break;
|
|
}
|
|
|
|
RefreshVisualState();
|
|
logger.LogInformation(
|
|
"Taskbar menu action {Action} completed for launch profile {LaunchProfile}",
|
|
item.Action,
|
|
item.ProfileName);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(
|
|
exception,
|
|
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
|
|
item.Action,
|
|
item.ProfileName);
|
|
}
|
|
}
|
|
|
|
private static string BuildMenuSignature(MeetingTaskbarMenu menu)
|
|
{
|
|
return string.Join(
|
|
"|",
|
|
FlattenMenuItems(menu.Items).Select(item =>
|
|
$"{item.Action}:{item.ProfileName}:{item.MicrophoneDeviceId}:{item.IsChecked}:{item.Text}"));
|
|
}
|
|
|
|
private static IEnumerable<MeetingTaskbarMenuItem> FlattenMenuItems(
|
|
IEnumerable<MeetingTaskbarMenuItem> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
yield return item;
|
|
if (item.Items is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var child in FlattenMenuItems(item.Items))
|
|
{
|
|
yield return child;
|
|
}
|
|
}
|
|
}
|
|
|
|
private MeetingAssistantOptions GetActiveProfileOptions(IReadOnlyList<LaunchProfile> profiles)
|
|
{
|
|
var activeProfile = coordinator.CurrentStatus.LaunchProfile;
|
|
return profiles.FirstOrDefault(profile => string.Equals(
|
|
profile.Name,
|
|
activeProfile,
|
|
StringComparison.OrdinalIgnoreCase))?.Options ??
|
|
profiles.FirstOrDefault(profile => string.Equals(
|
|
profile.Name,
|
|
"default",
|
|
StringComparison.OrdinalIgnoreCase))?.Options ??
|
|
profiles.FirstOrDefault()?.Options ??
|
|
new MeetingAssistantOptions();
|
|
}
|
|
|
|
private void ExitApplication()
|
|
{
|
|
var status = coordinator.CurrentStatus;
|
|
if (MeetingTaskbarExitPolicy.RequiresConfirmation(status.State) && !ConfirmExitDuringProcessing(status.State))
|
|
{
|
|
logger.LogInformation("Taskbar exit canceled while meeting state was {State}", status.State);
|
|
return;
|
|
}
|
|
|
|
logger.LogInformation("Taskbar exit requested while meeting state was {State}", status.State);
|
|
applicationLifetime.StopApplication();
|
|
}
|
|
|
|
private static bool ConfirmExitDuringProcessing(RecordingProcessState state)
|
|
{
|
|
var activity = state == RecordingProcessState.Recording
|
|
? "A meeting is still recording and transcribing."
|
|
: "A stopped meeting is still transcribing, recognizing speakers, or summarizing.";
|
|
var result = MessageBox.Show(
|
|
$"{activity}\n\nExit Meeting Assistant anyway?",
|
|
"Exit Meeting Assistant",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Warning,
|
|
MessageBoxResult.No);
|
|
|
|
return result == MessageBoxResult.Yes;
|
|
}
|
|
}
|
|
#endif
|