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 _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 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(); 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(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); }