483 lines
25 KiB
C#
483 lines
25 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Platform.Storage;
|
|
using Avalonia.Threading;
|
|
using Lumi.Companion.Abstractions;
|
|
using Lumi.Companion.SongOverlay;
|
|
|
|
namespace Lumi.Companion.App;
|
|
|
|
public partial class MainWindow : Window
|
|
{
|
|
private readonly CompanionRuntime _runtime;
|
|
private readonly CompanionSettingsStore _settings;
|
|
private readonly SongOverlayRuntime _songOverlay;
|
|
private readonly IReadOnlyList<ICompanionPluginContribution> _plugins;
|
|
private readonly Dictionary<CompanionPage, Button> _navigationButtons = [];
|
|
private bool _allowExit;
|
|
private bool _renderingSources;
|
|
private bool _renderingSongOverlay;
|
|
|
|
public MainWindow() : this(CreateDefaultServices()) { }
|
|
|
|
private MainWindow((CompanionRuntime Runtime, CompanionSettingsStore Settings, SongOverlayRuntime SongOverlay, IReadOnlyList<ICompanionPluginContribution> Plugins) services)
|
|
: this(services.Runtime, services.Settings, services.SongOverlay, services.Plugins) { }
|
|
|
|
public MainWindow(CompanionRuntime runtime, CompanionSettingsStore settings, SongOverlayRuntime songOverlay, IReadOnlyList<ICompanionPluginContribution> plugins)
|
|
{
|
|
_runtime = runtime;
|
|
_settings = settings;
|
|
_songOverlay = songOverlay;
|
|
_plugins = plugins;
|
|
InitializeComponent();
|
|
BuildPluginNavigation();
|
|
WireActions();
|
|
RenderState(runtime.State);
|
|
RenderTestStages(runtime.TestStages);
|
|
RenderBenchmark(runtime.Benchmark);
|
|
RenderSongOverlay();
|
|
Closing += OnClosing;
|
|
runtime.StateChanged += state => Dispatcher.UIThread.Post(() => { RenderState(state); RenderSongOverlay(); });
|
|
runtime.TestStagesChanged += stages => Dispatcher.UIThread.Post(() => RenderTestStages(stages));
|
|
runtime.ObsSourcesChanged += sources => Dispatcher.UIThread.Post(() => RenderSources(sources));
|
|
runtime.BenchmarkChanged += benchmark => Dispatcher.UIThread.Post(() => RenderBenchmark(benchmark));
|
|
runtime.VoiceLevelChanged += level => Dispatcher.UIThread.Post(() => RenderVoiceLevel(level));
|
|
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));
|
|
songOverlay.Changed += () => Dispatcher.UIThread.Post(RenderSongOverlay);
|
|
}
|
|
|
|
private void BuildPluginNavigation()
|
|
{
|
|
PluginNavigation.Children.Clear();
|
|
foreach (var plugin in _plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var children = new StackPanel { Spacing = 2, Margin = new Thickness(8, 3, 0, 6) };
|
|
foreach (var page in plugin.Pages.OrderBy(item => item.Order))
|
|
{
|
|
if (!Enum.TryParse<CompanionPage>(page.Key, out var parsed)) continue;
|
|
var button = new Button { Content = page.Label, Tag = page.Key, HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left };
|
|
button.Classes.Add("nav");
|
|
button.Click += OnNavigate;
|
|
children.Children.Add(button);
|
|
_navigationButtons[parsed] = button;
|
|
}
|
|
var expander = new Expander
|
|
{
|
|
Header = new TextBlock { Text = plugin.Descriptor.Name, FontWeight = FontWeight.SemiBold },
|
|
IsExpanded = true,
|
|
Content = children,
|
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch
|
|
};
|
|
expander.Classes.Add("pluginRoot");
|
|
PluginNavigation.Children.Add(expander);
|
|
}
|
|
_navigationButtons[CompanionPage.Overview] = OverviewNav;
|
|
_navigationButtons[CompanionPage.Connection] = ConnectionNav;
|
|
_navigationButtons[CompanionPage.Logs] = LogsNav;
|
|
_navigationButtons[CompanionPage.Settings] = SettingsNav;
|
|
}
|
|
|
|
private void WireActions()
|
|
{
|
|
OverviewNav.Click += OnNavigate;
|
|
foreach (var button in TechnicalNavigation.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);
|
|
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
|
|
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
|
|
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
|
|
SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync();
|
|
CheckUpdateButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.CheckForUpdatesAsync(), CheckUpdateButton);
|
|
ApplyUpdateButton.Click += async (_, _) => await ApplyUpdateAsync();
|
|
InstallBridgeButton.Click += async (_, _) => await InstallBridgeAsync();
|
|
RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync();
|
|
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
|
|
SaveSongOverlayButton.Click += async (_, _) => await SaveSongOverlayAsync();
|
|
SendSongOverlayStateButton.Click += async (_, _) => await RunSongOverlayActionAsync(() => _songOverlay.SendSnapshotAsync(), SendSongOverlayStateButton);
|
|
ConnectSongOverlaySpotifyButton.Click += async (_, _) =>
|
|
{
|
|
await SaveSongOverlayAsync(restartProvider: false);
|
|
await RunSongOverlayActionAsync(() => _songOverlay.ConnectSpotifyAsync(), ConnectSongOverlaySpotifyButton);
|
|
};
|
|
DisconnectSongOverlaySpotifyButton.Click += (_, _) => { _songOverlay.DisconnectSpotify(); RenderSongOverlay(); };
|
|
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.SongOverlay] = SongOverlayPage,
|
|
[CompanionPage.Connection] = ConnectionPage,
|
|
[CompanionPage.Logs] = LogsPage,
|
|
[CompanionPage.Settings] = SettingsPage
|
|
};
|
|
foreach (var item in pages) item.Value.IsVisible = item.Key == page;
|
|
foreach (var item in _navigationButtons)
|
|
item.Value.Classes.Set("selected", item.Key == page);
|
|
}
|
|
|
|
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 { RenderState(_runtime.State); }
|
|
}
|
|
|
|
private async Task InstallBridgeAsync()
|
|
{
|
|
InstallBridgeButton.IsEnabled = false;
|
|
BridgeStatusText.Text = "Checking the packaged integration and requesting Windows approval…";
|
|
try { await _runtime.InstallOrRepairObsBridgeAsync(); }
|
|
catch (Exception error)
|
|
{
|
|
AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}");
|
|
RenderState(_runtime.State);
|
|
var dialog = new DecisionWindow("OBS integration could not be installed", error.Message, "Close");
|
|
await dialog.ShowDialog<bool>(this);
|
|
}
|
|
finally { RenderState(_runtime.State); }
|
|
}
|
|
|
|
private async Task SaveSongOverlayAsync(bool restartProvider = true)
|
|
{
|
|
if (_renderingSongOverlay) return;
|
|
SaveSongOverlayButton.IsEnabled = false;
|
|
SongOverlayFeedback.Text = "Saving…";
|
|
try
|
|
{
|
|
var settings = _songOverlay.Settings;
|
|
settings.Enabled = SongOverlayEnabledToggle.IsChecked == true;
|
|
settings.ProviderId = "spotify";
|
|
settings.HeartbeatSeconds = Decimal.ToInt32(SongOverlayHeartbeatBox.Value ?? 30);
|
|
settings.SeekThresholdMilliseconds = Decimal.ToInt32(SongOverlaySeekBox.Value ?? 1500);
|
|
settings.SendCoverArt = SongOverlayCoverToggle.IsChecked == true;
|
|
settings.UseSearchLinkFallback = SongOverlaySearchLinkToggle.IsChecked == true;
|
|
settings.SpotifyClientId = SongOverlaySpotifyClientIdBox.Text?.Trim() ?? "";
|
|
await _songOverlay.SaveSettingsAsync(restartProvider);
|
|
SongOverlayFeedback.Text = "Song Overlay settings saved.";
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
SongOverlayFeedback.Text = $"Could not save Song Overlay: {error.Message}";
|
|
AddLog($"{DateTime.Now:HH:mm:ss} Song Overlay: {error.Message}");
|
|
}
|
|
finally
|
|
{
|
|
SaveSongOverlayButton.IsEnabled = true;
|
|
RenderSongOverlay();
|
|
}
|
|
}
|
|
|
|
private async Task RunSongOverlayActionAsync(Func<Task> action, Button button)
|
|
{
|
|
button.IsEnabled = false;
|
|
try
|
|
{
|
|
await action();
|
|
SongOverlayFeedback.Text = "Song Overlay action completed.";
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
SongOverlayFeedback.Text = error.Message;
|
|
AddLog($"{DateTime.Now:HH:mm:ss} Song Overlay: {error.Message}");
|
|
}
|
|
finally
|
|
{
|
|
RenderSongOverlay();
|
|
}
|
|
}
|
|
|
|
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 async Task ApplyUpdateAsync()
|
|
{
|
|
if (_runtime.State.RequiresQuitConfirmation)
|
|
{
|
|
var blocked = new DecisionWindow("Update after the stream?", "Companion updates never interrupt streaming or recording. Stop OBS output, then choose Update Companion again.", "Got it");
|
|
await blocked.ShowDialog<bool>(this);
|
|
return;
|
|
}
|
|
var dialog = new DecisionWindow("Update Lumi Companion?", $"Download, verify, and install {_runtime.State.AvailableVersion}. Companion will restart and keep your pairing and settings.", "Update Companion");
|
|
if (!await dialog.ShowDialog<bool>(this)) return;
|
|
await RunUiActionAsync(() => _runtime.ApplyUpdateAsync(), ApplyUpdateButton);
|
|
}
|
|
|
|
private async Task RemoveBridgeAsync()
|
|
{
|
|
var dialog = new DecisionWindow("Remove the OBS integration?", "This removes only the Companion-managed OBS plugin. Pairing, preferences, and Lumi Companion remain installed.", "Remove integration");
|
|
if (!await dialog.ShowDialog<bool>(this)) return;
|
|
await RunUiActionAsync(() => _runtime.RemoveObsBridgeAsync(), RemoveBridgeButton);
|
|
}
|
|
|
|
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 = state.PathTestValid ? "✓" : "○";
|
|
TestStepDetail.Text = state.PathTestDetail;
|
|
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 && !state.BenchmarkRunning;
|
|
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
|
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
|
BenchmarkStatusText.Text = state.BenchmarkDetail;
|
|
UpdatePanel.IsVisible = state.UpdateAvailable;
|
|
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
|
|
UpdateDetail.Text = state.UpdateDetail;
|
|
ApplyUpdateButton.IsEnabled = state.UpdateAvailable && !state.ObsStreaming && !state.ObsRecording;
|
|
CurrentVersionText.Text = CompanionRuntime.DisplayVersion;
|
|
UpdateStatusText.Text = state.UpdateDetail;
|
|
BridgeStatusText.Text = state.ObsBridgeDetail;
|
|
InstallBridgeButton.Content = state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration";
|
|
// The maintenance action performs a fresh package/process check and reports the
|
|
// actual blocker, so a stale OBS connection signal must never strand repair.
|
|
InstallBridgeButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
|
RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded;
|
|
|
|
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 = state.ObsBridgeDetail;
|
|
NextActionButton.Content = "Manage OBS integration";
|
|
}
|
|
else
|
|
{
|
|
NextActionTitle.Text = state.PathTestValid ? "Ready when you are" : "Run one short readiness check";
|
|
NextActionDetail.Text = state.PathTestValid ? state.PathTestDetail : "No speaking is required. The result remains valid until a relevant setup or model setting changes.";
|
|
NextActionButton.Content = state.PathTestValid ? "View status" : "Open voice-free check";
|
|
}
|
|
}
|
|
|
|
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 RenderSongOverlay()
|
|
{
|
|
_renderingSongOverlay = true;
|
|
try
|
|
{
|
|
var settings = _songOverlay.Settings;
|
|
SongOverlayStatusTitle.Text = _songOverlay.Status.Summary;
|
|
SongOverlayStatusDetail.Text = _songOverlay.ProviderStatus;
|
|
SongOverlayCurrentTrack.Text = _songOverlay.CurrentTrack is { Length: > 0 } current ? $"Current: {current}" : "No active song";
|
|
SongOverlayEnabledToggle.IsChecked = settings.Enabled;
|
|
SongOverlayLumiAuthenticationText.Text = _songOverlay.UsesCompanionAuthentication
|
|
? $"Authenticated through Companion · {_songOverlay.EffectiveLumiBaseUri}"
|
|
: "Pair Companion to enable Song Overlay delivery";
|
|
SongOverlayHeartbeatBox.Value = Math.Clamp(settings.HeartbeatSeconds, 15, 300);
|
|
SongOverlaySeekBox.Value = Math.Clamp(settings.SeekThresholdMilliseconds, 500, 10000);
|
|
SongOverlayCoverToggle.IsChecked = settings.SendCoverArt;
|
|
SongOverlaySearchLinkToggle.IsChecked = settings.UseSearchLinkFallback;
|
|
if (!SongOverlaySpotifyClientIdBox.IsFocused) SongOverlaySpotifyClientIdBox.Text = settings.SpotifyClientId;
|
|
SongOverlaySpotifyStatus.Text = _songOverlay.IsSpotifyEnrichmentConnected
|
|
? "Connected. Exact links, release year and official artwork can be enriched on song changes."
|
|
: "Not connected. Core playback detection still works through Windows.";
|
|
DisconnectSongOverlaySpotifyButton.IsEnabled = _songOverlay.IsSpotifyEnrichmentConnected;
|
|
SendSongOverlayStateButton.IsEnabled = _songOverlay.IsInitialized && settings.Enabled && _songOverlay.UsesCompanionAuthentication;
|
|
}
|
|
finally { _renderingSongOverlay = false; }
|
|
}
|
|
|
|
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) });
|
|
}
|
|
var currentPass = stages.Count > 0 && stages.All(stage => stage.State == TestStageState.Passed);
|
|
var valid = currentPass || _runtime.State.PathTestValid;
|
|
TestStepSymbol.Text = valid ? "✓" : "○";
|
|
TestStepDetail.Text = currentPass ? "Passed just now; this result will be reused while the relevant setup stays unchanged." : _runtime.State.PathTestDetail;
|
|
RunTestButton.Content = _runtime.State.TestRunning ? "Testing…" : "Run full path test";
|
|
RunTestButton.IsEnabled = !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning;
|
|
}
|
|
|
|
private void RenderBenchmark(BenchmarkSnapshot benchmark)
|
|
{
|
|
BenchmarkTranscript.Text = string.IsNullOrWhiteSpace(benchmark.Transcript) ? "Finalized speech will appear here." : benchmark.Transcript;
|
|
RenderMetric(LatencyStatsPanel, benchmark.Latency, value => $"{value:0} ms");
|
|
RenderMetric(ConfidenceStatsPanel, benchmark.Confidence, value => $"{value:P0}");
|
|
}
|
|
|
|
private void RenderVoiceLevel(double level)
|
|
{
|
|
var clamped = Math.Clamp(level, -60, 0);
|
|
VoiceLevelMeter.Value = clamped;
|
|
VoiceLevelText.Text = $"{clamped:0} dBFS";
|
|
var color = clamped >= -30 && clamped <= -12 ? "#23845B" :
|
|
clamped >= -40 && clamped <= -2 ? "#E58B2B" : "#BD4D4D";
|
|
VoiceLevelMeter.Foreground = new SolidColorBrush(Color.Parse(color));
|
|
}
|
|
|
|
private static void RenderMetric(Panel panel, MetricStatistics metric, Func<double, string> formatter)
|
|
{
|
|
panel.Children.Clear();
|
|
var values = new (string Label, double? Value)[]
|
|
{
|
|
("Minimum", metric.Minimum), ("Low 1% avg", metric.LowOnePercentAverage),
|
|
("Median", metric.Median), ("Average", metric.Average), ("P99", metric.P99),
|
|
("High 1% avg", metric.HighOnePercentAverage), ("Maximum", metric.Maximum)
|
|
};
|
|
foreach (var item in values)
|
|
{
|
|
var content = new StackPanel { Spacing = 2 };
|
|
content.Children.Add(new TextBlock { Text = item.Label, FontSize = 11, Foreground = new SolidColorBrush(Color.Parse("#5A6872")) });
|
|
content.Children.Add(new TextBlock { Text = item.Value.HasValue ? formatter(item.Value.Value) : "—", FontWeight = FontWeight.SemiBold });
|
|
panel.Children.Add(new Border { Classes = { "soft" }, Child = content, MinWidth = 96, Margin = new Thickness(0, 0, 8, 8), Padding = new Thickness(12, 9) });
|
|
}
|
|
}
|
|
|
|
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, SongOverlayRuntime, IReadOnlyList<ICompanionPluginContribution>) CreateDefaultServices()
|
|
{
|
|
var paths = CompanionPaths.ForCurrentUser();
|
|
var settings = new CompanionSettingsStore(paths.SettingsPath);
|
|
var runtime = new CompanionRuntime(paths, settings);
|
|
var songOverlay = new SongOverlayRuntime(
|
|
Path.Combine(paths.Root, "plugins", "now_playing"),
|
|
paths.LogsDirectory,
|
|
runtime.CreatePluginTransport("now_playing"));
|
|
ICompanionPluginContribution[] plugins = [new TranscriptionPluginContribution(runtime), songOverlay];
|
|
return (runtime, settings, songOverlay, plugins);
|
|
}
|
|
}
|