Lumi/companion/src/Lumi.Companion.App/MainWindow.axaml.cs
2026-07-22 11:39:05 +02:00

254 lines
12 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
namespace Lumi.Companion.App;
public partial class MainWindow : Window
{
private readonly CompanionRuntime _runtime;
private readonly CompanionSettingsStore _settings;
private bool _allowExit;
private bool _testPassed;
private bool _renderingSources;
public MainWindow() : this(CreateDefaultServices()) { }
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings) services)
: this(services.Runtime, services.Settings) { }
public MainWindow(CompanionRuntime runtime, CompanionSettingsStore settings)
{
_runtime = runtime;
_settings = settings;
InitializeComponent();
WireActions();
RenderState(runtime.State);
RenderTestStages(runtime.TestStages);
Closing += OnClosing;
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => RenderState(state));
runtime.TestStagesChanged += stages => Dispatcher.UIThread.Post(() => RenderTestStages(stages));
runtime.ObsSourcesChanged += sources => Dispatcher.UIThread.Post(() => RenderSources(sources));
runtime.CaptionReceived += (text, simulated) => Dispatcher.UIThread.Post(() =>
{
if (simulated) SimulatedCaption.Text = text;
});
runtime.LogAdded += line => Dispatcher.UIThread.Post(() => AddLog(line));
settings.Changed += value => Dispatcher.UIThread.Post(() => RenderSettings(value));
}
private void WireActions()
{
foreach (var button in Navigation.Children.OfType<Button>()) button.Click += OnNavigate;
NextActionButton.Click += OnNextAction;
PairButton.Click += async (_, _) => await ChoosePairingPackageAsync();
ReconnectButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RetryConnectionAsync(), ReconnectButton);
OpenWebButton.Click += (_, _) => _runtime.OpenLumiWebUi();
OpenLogsButton.Click += (_, _) => _runtime.OpenLogsDirectory();
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync();
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
SourcePicker.SelectionChanged += async (_, _) =>
{
if (!_renderingSources && SourcePicker.SelectedItem is ObsSource source) await _runtime.SelectSourceAsync(source);
};
}
private void OnNavigate(object? sender, RoutedEventArgs args)
{
if (sender is Button { Tag: string tag } && Enum.TryParse<CompanionPage>(tag, out var page)) ShowPage(page);
}
public void ShowPage(CompanionPage page)
{
var pages = new Dictionary<CompanionPage, Control>
{
[CompanionPage.Overview] = OverviewPage, [CompanionPage.Transcription] = TranscriptionPage,
[CompanionPage.Test] = TestPage, [CompanionPage.Connection] = ConnectionPage,
[CompanionPage.Logs] = LogsPage, [CompanionPage.Settings] = SettingsPage
};
foreach (var item in pages) item.Value.IsVisible = item.Key == page;
foreach (var button in Navigation.Children.OfType<Button>())
{
button.Classes.Set("selected", string.Equals(button.Tag as string, page.ToString(), StringComparison.Ordinal));
}
}
private async void OnNextAction(object? sender, RoutedEventArgs args)
{
if (!_runtime.State.Paired) { await ChoosePairingPackageAsync(); return; }
if (!_runtime.State.Connected) { await RunUiActionAsync(() => _runtime.RetryConnectionAsync(), NextActionButton); return; }
if (!_runtime.State.ObsBridgeInstalled) { ShowPage(CompanionPage.Connection); return; }
ShowPage(CompanionPage.Test);
}
private async Task ChoosePairingPackageAsync()
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choose a Lumi pairing package",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("Lumi pairing package") { Patterns = ["*.lumi-pairing.json", "*.json"] }]
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path is null) return;
await RunUiActionAsync(() => _runtime.PairAsync(path), PairButton);
}
private async Task RunUiActionAsync(Func<Task> action, Button button)
{
button.IsEnabled = false;
try { await action(); }
catch (Exception error) { AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}"); }
finally { button.IsEnabled = true; }
}
private async Task SaveSettingsAsync()
{
SaveSettingsButton.IsEnabled = false;
SettingsFeedback.Text = "Saving…";
try
{
await _runtime.SaveSettingsAsync(_settings.Current with
{
AutoStartWithWindows = AutoStartToggle.IsChecked == true,
StartWithObs = StartWithObsToggle.IsChecked == true,
AdvancedMode = AdvancedToggle.IsChecked == true
});
SettingsFeedback.Text = "Preferences saved.";
}
catch (Exception error) { SettingsFeedback.Text = $"Could not save preferences: {error.Message}"; }
finally { SaveSettingsButton.IsEnabled = true; }
}
private async Task ForgetDeviceAsync()
{
var dialog = new DecisionWindow("Forget this device?", "This removes the protected credential from this computer. Pairing can be restored with a new package.", "Forget device");
if (!await dialog.ShowDialog<bool>(this)) return;
await _runtime.ForgetDeviceAsync();
}
private void RenderState(CompanionState state)
{
StatusLabel.Text = state.Summary;
StatusSymbol.Text = state.Health switch { TrayHealth.Ready => "✓ Ready", TrayHealth.Operating => "▶ Active", TrayHealth.Failed => "! Action required", _ => "△ Incomplete" };
StatusMark.Background = new SolidColorBrush(Color.Parse(state.Health switch { TrayHealth.Ready => "#176B75", TrayHealth.Operating => "#23845B", TrayHealth.Failed => "#BD4D4D", _ => "#A96612" }));
OverviewDetail.Text = state.Detail;
PairingStepSymbol.Text = state.Paired ? "✓" : "○";
PairingStepDetail.Text = state.Connected ? "Securely connected" : state.Paired ? "Paired, currently offline" : "Not paired";
ObsStepSymbol.Text = state.ObsConnected ? "✓" : state.ObsBridgeInstalled ? "◐" : "○";
ObsStepDetail.Text = state.ObsConnected ? "Connected to OBS" : state.ObsBridgeInstalled ? "Installed; waiting for OBS" : "Installation required";
TestStepSymbol.Text = _testPassed ? "✓" : "○";
TestStepDetail.Text = _testPassed ? "Passed" : "Not passed";
DeviceNameText.Text = state.DeviceName ?? "Not paired";
HostText.Text = state.Host ?? "—";
LastConnectedText.Text = state.LastConnectedAt?.ToString("g") ?? "Never";
OpenWebButton.IsEnabled = state.Host is not null;
ReconnectButton.IsEnabled = state.Paired && !state.Connected;
ForgetButton.IsEnabled = state.Paired;
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !state.TestRunning;
if (!state.Paired)
{
NextActionTitle.Text = "Pair this computer";
NextActionDetail.Text = "Use a one-time pairing package from Lumi to connect this streaming computer.";
NextActionButton.Content = "Choose pairing package";
}
else if (!state.Connected)
{
NextActionTitle.Text = "Reconnect to Lumi";
NextActionDetail.Text = "Your device is paired, but Lumi cannot currently be reached.";
NextActionButton.Content = "Retry connection";
}
else if (!state.ObsBridgeInstalled)
{
NextActionTitle.Text = "Install the OBS integration";
NextActionDetail.Text = "The native bridge package is not installed yet. The signed installer and repair service remain required.";
NextActionButton.Content = "View connection details";
}
else
{
NextActionTitle.Text = "Run a safe transcription test";
NextActionDetail.Text = "Check the real end-to-end path without sending captions to Twitch.";
NextActionButton.Content = "Open test";
}
}
private void RenderSettings(CompanionSettings settings)
{
AutoStartToggle.IsChecked = settings.AutoStartWithWindows;
StartWithObsToggle.IsChecked = settings.StartWithObs;
AdvancedToggle.IsChecked = settings.AdvancedMode;
AdvancedPanel.IsVisible = settings.AdvancedMode;
if (settings.PrimarySourceUuid is not null)
SourcePicker.SelectedItem = _runtime.ObsSources.FirstOrDefault(source => source.Uuid == settings.PrimarySourceUuid);
}
private void RenderSources(IReadOnlyList<ObsSource> sources)
{
_renderingSources = true;
SourcePicker.ItemsSource = sources;
SourcePicker.IsEnabled = sources.Count > 0;
SourcePicker.SelectedItem = sources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
SourceHelp.Text = sources.Count == 0 ? "OBS is connected, but no audio sources were reported." : "Choose the source that carries the primary speaker.";
_renderingSources = false;
}
private void RenderTestStages(IReadOnlyList<TestStage> stages)
{
TestStagesPanel.Children.Clear();
foreach (var stage in stages)
{
var symbol = stage.State switch { TestStageState.Passed => "✓", TestStageState.Running => "…", TestStageState.Blocked => "△", TestStageState.Failed => "!", _ => "○" };
var row = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("28,180,*"), ColumnSpacing = 10 };
row.Children.Add(new TextBlock { Text = symbol, FontSize = 17, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
var name = new TextBlock { Text = stage.Name, FontWeight = FontWeight.SemiBold, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center };
Grid.SetColumn(name, 1); row.Children.Add(name);
var detail = new TextBlock { Text = stage.Detail, Foreground = new SolidColorBrush(Color.Parse("#5A6872")), TextWrapping = TextWrapping.Wrap };
Grid.SetColumn(detail, 2); row.Children.Add(detail);
TestStagesPanel.Children.Add(new Border { Classes = { "soft" }, Child = row, Padding = new Thickness(14, 11) });
}
_testPassed = stages.Count > 0 && stages.All(stage => stage.State == TestStageState.Passed);
TestStepSymbol.Text = _testPassed ? "✓" : "○";
TestStepDetail.Text = _testPassed ? "Passed" : "Not passed";
RunTestButton.Content = _runtime.State.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !_runtime.State.TestRunning;
}
private void AddLog(string line)
{
if (RecentLogsPanel.Children.Count == 1 && RecentLogsPanel.Children[0] is TextBlock text && text.Text?.StartsWith("Waiting") == true) RecentLogsPanel.Children.Clear();
RecentLogsPanel.Children.Insert(0, new TextBlock { Text = line, Foreground = new SolidColorBrush(Color.Parse("#CFDADD")), FontFamily = new FontFamily("Consolas"), FontSize = 12, TextWrapping = TextWrapping.Wrap });
while (RecentLogsPanel.Children.Count > 20) RecentLogsPanel.Children.RemoveAt(RecentLogsPanel.Children.Count - 1);
}
private void OnClosing(object? sender, WindowClosingEventArgs args)
{
if (_allowExit) return;
args.Cancel = true;
Hide();
}
public async Task<bool> ConfirmQuitAsync()
{
if (!_runtime.State.RequiresQuitConfirmation) return true;
var dialog = new DecisionWindow("Quit while OBS is active?", _runtime.State.QuitWarning, "Quit Companion");
return await dialog.ShowDialog<bool>(this);
}
public void AllowExit() => _allowExit = true;
private static (CompanionRuntime, CompanionSettingsStore) CreateDefaultServices()
{
var paths = CompanionPaths.ForCurrentUser();
var settings = new CompanionSettingsStore(paths.SettingsPath);
return (new CompanionRuntime(paths, settings), settings);
}
}