Lumi/companion/src/Lumi.Companion.App/App.axaml.cs
2026-07-26 22:06:34 +02:00

274 lines
11 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Lumi.Companion.Abstractions;
using Lumi.Companion.SongOverlay;
using Lumi.Companion.Overlay;
namespace Lumi.Companion.App;
public partial class App : Application
{
private TrayIcon? _trayIcon;
private MainWindow? _mainWindow;
private SongOverlayRuntime? _songOverlay;
private LumiOverlayRuntime? _lumiOverlay;
private IReadOnlyList<ICompanionPluginContribution> _plugins = [];
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var paths = CompanionPaths.ForCurrentUser();
var settings = new CompanionSettingsStore(paths.SettingsPath);
var runtime = new CompanionRuntime(paths, settings);
_songOverlay = new SongOverlayRuntime(
Path.Combine(paths.Root, "plugins", "now_playing"),
paths.LogsDirectory,
runtime.CreatePluginTransport("now_playing"));
_lumiOverlay = new LumiOverlayRuntime(
Path.Combine(paths.Root, "plugins", "lumi_overlay"),
runtime.CreatePluginTransport("lumi_overlay"));
_plugins = [new TranscriptionPluginContribution(runtime), _lumiOverlay, _songOverlay];
_mainWindow = new MainWindow(runtime, settings, _songOverlay, _lumiOverlay, _plugins);
desktop.MainWindow = _mainWindow;
ConfigureTray(desktop, runtime, _plugins);
runtime.UpdateRestartRequested += async () =>
{
_mainWindow.AllowExit();
_trayIcon?.Dispose();
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync();
await runtime.DisposeAsync();
desktop.Shutdown();
};
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
if (!Program.LaunchInBackground) _mainWindow.Show();
_ = InitializeRuntimesAsync(runtime, _songOverlay, _lumiOverlay);
}
base.OnFrameworkInitializationCompleted();
}
private static async Task InitializeRuntimesAsync(CompanionRuntime runtime, SongOverlayRuntime songOverlay, LumiOverlayRuntime lumiOverlay)
{
await runtime.InitializeAsync();
try { await songOverlay.InitializeAsync(); }
catch { /* The plugin publishes its own actionable status without taking down the shell. */ }
try { await lumiOverlay.InitializeAsync(); }
catch { /* Native overlay failures remain isolated and visible in the plugin status. */ }
}
private void ConfigureTray(IClassicDesktopStyleApplicationLifetime desktop, CompanionRuntime runtime, IReadOnlyList<ICompanionPluginContribution> plugins)
{
var open = new NativeMenuItem("Open Lumi Companion");
var web = new NativeMenuItem("Open Lumi WebUI");
open.Click += (_, _) => ShowMainWindow();
web.Click += (_, _) => runtime.OpenLumiWebUi();
var menu = new NativeMenu();
menu.Items.Add(open);
menu.Items.Add(web);
menu.Items.Add(new NativeMenuItemSeparator());
menu.Items.Add(new NativeMenuItem("Plugins") { IsEnabled = false });
var pluginViews = new List<PluginTrayView>();
foreach (var plugin in plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
{
var submenu = new NativeMenu();
var firstPage = plugin.Pages.OrderBy(item => item.Order).FirstOrDefault();
if (firstPage is not null)
{
var openPlugin = new NativeMenuItem($"Open {plugin.Descriptor.Name}");
openPlugin.Click += (_, _) => ShowPluginPage(firstPage.Key);
submenu.Items.Add(openPlugin);
if (plugin.Actions.Count > 0) submenu.Items.Add(new NativeMenuItemSeparator());
}
var actionViews = new List<(CompanionPluginAction Action, NativeMenuItem Item)>();
foreach (var action in plugin.Actions.OrderBy(item => item.Order))
{
var actionItem = new NativeMenuItem(action.Label());
actionItem.Click += async (_, _) => await RunPluginActionAsync(plugin, action);
submenu.Items.Add(actionItem);
actionViews.Add((action, actionItem));
}
if (submenu.Items.Count > 0) submenu.Items.Add(new NativeMenuItemSeparator());
var status = new NativeMenuItem($"Status: {plugin.Status.Summary}") { IsEnabled = false };
submenu.Items.Add(status);
var root = new NativeMenuItem(plugin.Descriptor.Name) { Menu = submenu };
menu.Items.Add(root);
var view = new PluginTrayView(plugin, root, status, actionViews);
pluginViews.Add(view);
plugin.Changed += () => Dispatcher.UIThread.Post(() => RefreshPluginTray(view));
}
menu.Items.Add(new NativeMenuItemSeparator());
var health = new NativeMenuItem("Health: Starting") { IsEnabled = false };
var update = new NativeMenuItem("Updates: Checking…") { IsEnabled = false };
var logs = new NativeMenuItem("Logs & diagnostics");
var settings = new NativeMenuItem("Settings");
var quit = new NativeMenuItem("Quit");
logs.Click += (_, _) => { runtime.OpenLogsDirectory(); };
settings.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Settings); };
update.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Overview); };
quit.Click += async (_, _) =>
{
if (runtime.State.RequiresQuitConfirmation) ShowMainWindow();
if (_mainWindow is null || !await _mainWindow.ConfirmQuitAsync()) return;
_mainWindow.AllowExit();
_trayIcon?.Dispose();
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync();
await runtime.DisposeAsync();
desktop.Shutdown();
};
menu.Items.Add(health);
menu.Items.Add(update);
menu.Items.Add(logs);
menu.Items.Add(settings);
menu.Items.Add(new NativeMenuItemSeparator());
menu.Items.Add(quit);
_trayIcon = new TrayIcon
{
Icon = LumiIconFactory.Create(TrayHealth.Ready),
ToolTipText = "Lumi Companion — starting",
IsVisible = true,
Menu = menu
};
_trayIcon.Clicked += (_, _) => ShowMainWindow();
TrayIcon.SetIcons(this, new TrayIcons { _trayIcon });
var renderedHealth = TrayHealth.Ready;
var renderedToolTip = _trayIcon.ToolTipText;
var renderedHealthHeader = health.Header?.ToString();
var renderedUpdateHeader = update.Header?.ToString();
var renderedUpdateEnabled = update.IsEnabled;
var refreshGate = new object();
CompanionState? pendingState = null;
var refreshQueued = false;
void ApplyTrayState(CompanionState state)
{
if (_trayIcon is null) return;
if (state.Health != renderedHealth)
{
_trayIcon.Icon = LumiIconFactory.Create(state.Health);
renderedHealth = state.Health;
}
var nextToolTip = state.UpdateAvailable
? $"Lumi Companion — update {state.AvailableVersion} available"
: $"Lumi Companion — {state.Summary}";
if (!string.Equals(nextToolTip, renderedToolTip, StringComparison.Ordinal))
{
_trayIcon.ToolTipText = nextToolTip;
renderedToolTip = nextToolTip;
}
var nextHealthHeader = $"Health: {state.Summary}";
if (!string.Equals(nextHealthHeader, renderedHealthHeader, StringComparison.Ordinal))
{
health.Header = nextHealthHeader;
renderedHealthHeader = nextHealthHeader;
}
var nextUpdateHeader = state.UpdateAvailable
? $"Update available: {state.AvailableVersion}"
: "Updates: Current";
if (!string.Equals(nextUpdateHeader, renderedUpdateHeader, StringComparison.Ordinal))
{
update.Header = nextUpdateHeader;
renderedUpdateHeader = nextUpdateHeader;
}
if (state.UpdateAvailable != renderedUpdateEnabled)
{
update.IsEnabled = state.UpdateAvailable;
renderedUpdateEnabled = state.UpdateAvailable;
}
}
void DrainTrayRefresh()
{
CompanionState? latest;
lock (refreshGate)
{
latest = pendingState;
pendingState = null;
}
if (latest is not null) ApplyTrayState(latest);
lock (refreshGate)
{
if (pendingState is null)
{
refreshQueued = false;
return;
}
}
Dispatcher.UIThread.Post(DrainTrayRefresh, DispatcherPriority.Background);
}
runtime.StateChanged += state =>
{
lock (refreshGate)
{
pendingState = state;
if (refreshQueued) return;
refreshQueued = true;
}
Dispatcher.UIThread.Post(DrainTrayRefresh, DispatcherPriority.Background);
};
foreach (var view in pluginViews) RefreshPluginTray(view);
}
private async Task RunPluginActionAsync(ICompanionPluginContribution plugin, CompanionPluginAction action)
{
try
{
await action.Execute(CancellationToken.None);
}
catch
{
var page = plugin.Pages.OrderBy(item => item.Order).FirstOrDefault();
if (page is not null) ShowPluginPage(page.Key);
}
}
private static void RefreshPluginTray(PluginTrayView view)
{
view.Root.Header = view.Plugin.Descriptor.Name;
view.Status.Header = $"Status: {view.Plugin.Status.Summary}";
foreach (var (action, item) in view.Actions)
{
item.Header = action.Label();
item.IsEnabled = action.CanExecute?.Invoke() ?? true;
}
}
private void ShowPluginPage(string pageKey)
{
ShowMainWindow();
if (Enum.TryParse<CompanionPage>(pageKey, out var page)) _mainWindow?.ShowPage(page);
}
private void ShowMainWindow()
{
if (_mainWindow is null) return;
_mainWindow.Show();
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.Activate();
}
private sealed record PluginTrayView(
ICompanionPluginContribution Plugin,
NativeMenuItem Root,
NativeMenuItem Status,
IReadOnlyList<(CompanionPluginAction Action, NativeMenuItem Item)> Actions);
}