using AvParser.Core.Proxies; using AvParser.Core.Settings; using AvParser.Infrastructure.Logging; using AvParser.Infrastructure.Storage; using AvParser.UI.Localization; using AvParser.UI.Responsive; using AvParser.UI.Services; using ReactiveUI; using ReactiveUI.Primitives; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.SourceGenerators; using Serilog.Core; namespace AvParser.UI.ViewModels; /// Theme, logging level and where the app keeps its files. public partial class SettingsViewModel : PageViewModel { private readonly ISettingsService _settings; private readonly IThemeService _theme; private readonly LoggingLevelSwitch _levelSwitch; private readonly IProxyPool _proxyPool; private readonly ILocalizationService _localization; /// Selected theme. Applied immediately, not on an OK button. [Reactive] public partial LocalizedOption SelectedTheme { get; set; } /// Selected UI language. Applied immediately. [Reactive] public partial LocalizedOption SelectedLanguage { get; set; } /// Selected Serilog level name. Takes effect immediately. [Reactive] public partial string SelectedLogLevel { get; set; } /// How the pool picks the next proxy. [Reactive] public partial LocalizedOption SelectedRotation { get; set; } /// When proxy liveness is verified. [Reactive] public partial LocalizedOption SelectedHealthCheck { get; set; } /// Whether the remote proxy feed is consulted. [Reactive] public partial bool UseProxyFeed { get; set; } /// URL fetched to decide whether a proxy works. [Reactive] public partial string ProxyProbeUrl { get; set; } /// Per-proxy probe timeout, in seconds. [Reactive] public partial int ProxyProbeTimeoutSeconds { get; set; } /// How many probes run at once during a sweep. [Reactive] public partial int ProxyProbeConcurrency { get; set; } /// How many working proxies the startup warm-up aims for. [Reactive] public partial int ProxyMinimumLive { get; set; } /// Whether network parsers may run with no proxy available. [Reactive] public partial bool AllowDirectConnection { get; set; } /// Creates the page. public SettingsViewModel( ISettingsService settings, IThemeService theme, IAppPaths paths, LoggingLevelSwitch levelSwitch, IProxyPool proxyPool, ILocalizationService localization, ISequencer? mainThread = null ) { _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _theme = theme ?? throw new ArgumentNullException(nameof(theme)); _levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch)); _proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool)); _localization = localization ?? throw new ArgumentNullException(nameof(localization)); ArgumentNullException.ThrowIfNull(paths); var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler; SettingsFile = paths.SettingsFile; LogDirectory = paths.LogDirectory; SelectedTheme = Option(Themes, theme.Current); SelectedLanguage = Option(Languages, localization.Current); SelectedLogLevel = settings.Current.MinimumLogLevel; var current = settings.Current; SelectedRotation = Option(Rotations, current.ProxyRotation); SelectedHealthCheck = Option(HealthChecks, current.ProxyHealthCheck); UseProxyFeed = current.ProxyUseFeed; ProxyProbeUrl = current.ProxyProbeUrl; ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds; ProxyProbeConcurrency = current.ProxyProbeConcurrency; ProxyMinimumLive = current.ProxyMinimumLive; AllowDirectConnection = current.AllowDirectConnection; this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(option => _theme.Apply(option.Value)); this.WhenAnyValue(x => x.SelectedLanguage) .ObserveOn(scheduler) .Subscribe(option => _localization.Apply(option.Value)); this.WhenAnyValue(x => x.SelectedLogLevel) .Where(static level => !string.IsNullOrEmpty(level)) .DistinctUntilChanged() .Subscribe(ApplyLogLevel); // Keep the picker honest when the theme is flipped from the title-bar button. theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = Option(Themes, value)); // Every proxy knob funnels through one handler: they all end up rebuilding the same // ProxyOptions, and applying them one at a time would reconfigure the pool six times. this.WhenAnyValue( x => x.SelectedRotation, x => x.SelectedHealthCheck, x => x.UseProxyFeed, x => x.ProxyProbeUrl, x => x.ProxyProbeTimeoutSeconds, x => x.ProxyProbeConcurrency, x => x.ProxyMinimumLive, x => x.AllowDirectConnection, (_, _, _, _, _, _, _, _) => RxVoid.Default ) .Throttle(TimeSpan.FromMilliseconds(200), scheduler) .ObserveOn(scheduler) .Subscribe(_ => ApplyProxySettings()); this.WhenAnyValue(x => x.SelectedRotation) .Subscribe(_ => this.RaisePropertyChanged(nameof(RotationDescription))); this.WhenAnyValue(x => x.SelectedHealthCheck) .Subscribe(_ => this.RaisePropertyChanged(nameof(HealthCheckDescription))); } /// public override string TitleKey => "Page.Settings"; /// public override string IconKey => "IconSettings"; /// Theme options offered by the picker. public IReadOnlyList> Themes { get; } = LocalizedOption.ForAll(); /// Language options offered by the picker. public IReadOnlyList> Languages { get; } = LocalizedOption.ForAll(); /// Serilog level names, most to least verbose. public IReadOnlyList LogLevels => AppLogging.AvailableLevels; /// Rotation strategies offered by the picker. public IReadOnlyList> Rotations { get; } = LocalizedOption.ForAll(); /// Liveness policies offered by the picker. public IReadOnlyList> HealthChecks { get; } = LocalizedOption.ForAll(); /// Explains the selected rotation in one line. public string RotationDescription => Localizer.Instance[$"Settings.Rotation.{SelectedRotation.Value}"]; /// Explains the selected health-check policy in one line. public string HealthCheckDescription => Localizer.Instance[$"Settings.HealthCheck.{SelectedHealthCheck.Value}"]; /// Describes the compact breakpoint band. public string CompactHint => Localizer.Instance.Format("Settings.Breakpoint.CompactHint", MediumBreakpoint); /// Describes the medium breakpoint band. public string MediumHint => Localizer.Instance.Format("Settings.Breakpoint.MediumHint", MediumBreakpoint, ExpandedBreakpoint); /// Describes the expanded breakpoint band. public string ExpandedHint => Localizer.Instance.Format("Settings.Breakpoint.ExpandedHint", ExpandedBreakpoint); /// Full path of the settings file. public string SettingsFile { get; } /// Directory holding rolling log files. public string LogDirectory { get; } /// Width in pixels at which the shell switches from compact to the icon rail. public double MediumBreakpoint => ResponsiveLayout.MediumMinWidth; /// Width in pixels at which the shell switches to the full sidebar. public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth; /// protected override void OnLanguageChanged() { base.OnLanguageChanged(); foreach ( var name in new[] { nameof(RotationDescription), nameof(HealthCheckDescription), nameof(CompactHint), nameof(MediumHint), nameof(ExpandedHint), } ) { this.RaisePropertyChanged(name); } } private static LocalizedOption Option(IReadOnlyList> options, TValue value) where TValue : struct, Enum => options.First(option => option.Value.Equals(value)); private void ApplyLogLevel(string level) { _levelSwitch.MinimumLevel = AppLogging.ParseLevel(level); _settings.Update(current => current with { MinimumLogLevel = level }); } private void ApplyProxySettings() { AppSettings? applied = null; _settings.Update(current => { applied = current with { ProxyRotation = SelectedRotation.Value, ProxyHealthCheck = SelectedHealthCheck.Value, ProxyUseFeed = UseProxyFeed, ProxyProbeUrl = ProxyProbeUrl, ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds, ProxyProbeConcurrency = ProxyProbeConcurrency, ProxyMinimumLive = ProxyMinimumLive, AllowDirectConnection = AllowDirectConnection, }; return applied; }); // Update() short-circuits a no-op change, so fall back to the stored value: the pool must // still be configured on the very first pass, when nothing has changed yet. _proxyPool.Configure((applied ?? _settings.Current).ToProxyOptions()); } }