274 lines
11 KiB
C#
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);
|
|
}
|