diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..0b84abf --- /dev/null +++ b/App.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..06e6476 --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,90 @@ +using H.NotifyIcon; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using System; +using System.IO; +using System.Reflection; + +namespace HearingProtector; + +public partial class App : Application +{ + // ← ДОБАВИТЬ: ссылка на единственный экземпляр App + public static App? CurrentApp { get; private set; } + + public Window? MainWindow { get; private set; } + + private TaskbarIcon? _trayIcon; + + public App() + { + CurrentApp = this; // ← ДОБАВИТЬ до InitializeComponent() + InitializeComponent(); + } + + public static string Version { get; private set; } = "?"; + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + Version = typeof(App).Assembly + .GetCustomAttribute()? + .InformationalVersion ?? "?"; + + MainWindow = new MainWindow(); + + var openItem = new MenuFlyoutItem + { + Text = "Открыть", + Command = new RelayCommand(OpenFromTray) + }; + + var exitItem = new MenuFlyoutItem + { + Text = "Выход", + Command = new RelayCommand(ExitApp) + }; + + var menu = new MenuFlyout(); + menu.Items.Add(openItem); + menu.Items.Add(new MenuFlyoutSeparator()); + menu.Items.Add(exitItem); + + _trayIcon = new TaskbarIcon + { + ToolTipText = $"Hearing Protector v{Version}", + IconSource = new BitmapImage(new Uri( + Path.Combine(AppContext.BaseDirectory, "Assets", "app.ico"))), + ContextFlyout = menu, + MenuActivation = H.NotifyIcon.Core.PopupActivationMode.RightClick, + NoLeftClickDelay = true, + DoubleClickCommand = new RelayCommand(OpenFromTray) + }; + + _trayIcon.ForceCreate(); + + MainWindow.Activate(); + } + + private void OpenFromTray() + { + MainWindow?.DispatcherQueue.TryEnqueue(() => + { + (MainWindow as MainWindow)?.RestoreFromTray(); + }); + } + + private void ExitApp() + { + MainWindow?.DispatcherQueue.TryEnqueue(() => + { + if (MainWindow is MainWindow mw) + mw.ReadyToClose = true; + + _trayIcon?.Dispose(); + _trayIcon = null; + + Exit(); + }); + } +} \ No newline at end of file diff --git a/Assets/LockScreenLogo.scale-200.png b/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000..7440f0d Binary files /dev/null and b/Assets/LockScreenLogo.scale-200.png differ diff --git a/Assets/SplashScreen.scale-200.png b/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000..32f486a Binary files /dev/null and b/Assets/SplashScreen.scale-200.png differ diff --git a/Assets/Square150x150Logo.scale-200.png b/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000..53ee377 Binary files /dev/null and b/Assets/Square150x150Logo.scale-200.png differ diff --git a/Assets/Square44x44Logo.scale-200.png b/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000..f713bba Binary files /dev/null and b/Assets/Square44x44Logo.scale-200.png differ diff --git a/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..dc9f5be Binary files /dev/null and b/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/Assets/StoreLogo.png b/Assets/StoreLogo.png new file mode 100644 index 0000000..a4586f2 Binary files /dev/null and b/Assets/StoreLogo.png differ diff --git a/Assets/Wide310x150Logo.scale-200.png b/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000..8b4a5d0 Binary files /dev/null and b/Assets/Wide310x150Logo.scale-200.png differ diff --git a/Assets/app.ico b/Assets/app.ico new file mode 100644 index 0000000..1376cc0 Binary files /dev/null and b/Assets/app.ico differ diff --git a/HearingProtector.csproj b/HearingProtector.csproj index 33b21d0..2d8004e 100644 --- a/HearingProtector.csproj +++ b/HearingProtector.csproj @@ -1,18 +1,77 @@  - - Exe - net10.0-windows - enable + WinExe + net10.0-windows10.0.17763.0 + 10.0.17763.0 + HearingProtector + x64 + win-x64 + true + latest + None + false enable - true - true - HearingProtector.Program + + + x64 + true + true + true + true + true + true + + + 0.9.0 + 0.9.0.0 + 0.9.0.0 + 0.9.0 + Hearing Protector + Hearing Protector + QwertyKez + Copyright © 2026 + + + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + + app.manifest + + + + + + + + + + + - + + + MSBuild:Compile + + + + + + False + True + + False + + true + + Assets\app.ico + + \ No newline at end of file diff --git a/HearingProtector.slnx b/HearingProtector.slnx index 57f5810..2060d3b 100644 --- a/HearingProtector.slnx +++ b/HearingProtector.slnx @@ -1,3 +1,13 @@ - + + + + + + + + + + + diff --git a/MainPage.xaml b/MainPage.xaml new file mode 100644 index 0000000..ab6cf7f --- /dev/null +++ b/MainPage.xaml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MainPage.xaml.cs b/MainPage.xaml.cs new file mode 100644 index 0000000..cac4ec7 --- /dev/null +++ b/MainPage.xaml.cs @@ -0,0 +1,222 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; // RangeBaseValueChangedEventArgs +using NAudio.CoreAudioApi; +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace HearingProtector; + +public sealed partial class MainPage : Page +{ + // ─── Константы ──────────────────────────────────────────────────────── + private const int PollMs = 1000; + private const float UserChangeEps = 0.005f; + private const float WriteEps = 0.0005f; + + // Технический пол для экспоненциальной формулы. Нужен только когда + // пользователь выбрал целевой минимум 0% (иначе exp не работает). + private const double FormulaFloor = 0.001; // 0.1% + + // ─── Config ─────────────────────────────────────────────────────────── + private sealed record Config(double MinPercent, double FullDecayMinutes) + { + public double MinVolume => MinPercent / 100.0; // реальная цель + public double FormulaMin => Math.Max(MinVolume, FormulaFloor);// >= 0.1% + public double TotalTicks => FullDecayMinutes * 60_000.0 / PollMs; + public double DecayPerTick => Math.Pow(FormulaMin, 1.0 / TotalTicks); + public double DbPerTick => 20.0 * Math.Log10(DecayPerTick); + } + + // ─── Поля ───────────────────────────────────────────────────────────── + private volatile Config? _config; + private CancellationTokenSource? _cts; + private bool _initialized; + + // ─── Конструктор ────────────────────────────────────────────────────── + public MainPage() + { + InitializeComponent(); + + ApplySettings(SettingsStore.Load()); + UpdateLabels(); // слайдеры могут сами не дёрнуть ValueChanged после загрузки + _initialized = true; + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + // ─── Жизненный цикл ─────────────────────────────────────────────────── + private void OnLoaded(object sender, RoutedEventArgs e) + { + _config ??= BuildConfig(); + + if (_cts is not null) return; + _cts = new CancellationTokenSource(); + _ = RunAsync(_cts.Token); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + // ─── Настройки ──────────────────────────────────────────────────────── + private void ApplySettings(AppSettings s) + { + MinVolumeSlider.Value = s.MinPercent; + FullDecaySlider.Value = s.FullDecayMinutes; + } + + private AppSettings CurrentSettings() => + new(MinVolumeSlider.Value, FullDecaySlider.Value); + + private void Config_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (!_initialized) return; + UpdateLabels(); + _config = BuildConfig(); + SettingsStore.Save(CurrentSettings()); + } + + private void UpdateLabels() + { + if (MinVolumeLabel is null || FullDecayLabel is null) return; + + MinVolumeLabel.Text = $"{MinVolumeSlider.Value:F0} %"; + + int m = (int)FullDecaySlider.Value; + FullDecayLabel.Text = m switch + { + < 60 => $"{m} мин", + _ when m % 60 == 0 => $"{m / 60} ч", + _ => $"{m / 60} ч {m % 60} мин", + }; + } + + private Config BuildConfig() + { + var s = CurrentSettings(); + double min = s.MinPercent; + double mins = s.FullDecayMinutes; + + if (double.IsNaN(min) || min < 0) min = 1; + if (double.IsNaN(mins) || mins < 30) mins = 30; + + return new Config(min, mins); + } + + // ─── Рабочий цикл ───────────────────────────────────────────────────── + private async Task RunAsync(CancellationToken ct) + { + using var enumerator = new MMDeviceEnumerator(); + using var device = enumerator.GetDefaultAudioEndpoint( + DataFlow.Render, Role.Multimedia); + var volume = device.AudioEndpointVolume; + + float baseline = volume.MasterVolumeLevelScalar; + float current = baseline; + + while (!ct.IsCancellationRequested) + { + var cfg = _config; + if (cfg is null) + { + try { await Task.Delay(PollMs, ct); } + catch (OperationCanceledException) { break; } + continue; + } + + double etaSec = double.NaN; + bool muted = false; + bool atFloor = false; + double percent = current * 100; + + try + { + float actual = volume.MasterVolumeLevelScalar; + + // Ручное вмешательство → синхронизация + if (Math.Abs(actual - baseline) > UserChangeEps) + { + current = actual; + baseline = actual; + } + + muted = volume.Mute; + atFloor = current <= cfg.MinVolume + 0.0005f; + + if (!muted && !atFloor) + { + if (current > cfg.FormulaMin + 0.0005f) + // Экспоненциальный шаг до технического пола + current = (float)Math.Max(cfg.FormulaMin, + current * cfg.DecayPerTick); + else + // Пол достигнут, а цель ниже (только кейс MinPercent = 0) + current = (float)cfg.MinVolume; + + if (Math.Abs(actual - current) > WriteEps) + volume.MasterVolumeLevelScalar = current; + + baseline = current; + + var logK = Math.Log(cfg.DecayPerTick); + if (logK < 0 && current > cfg.MinVolume) + etaSec = Math.Log(cfg.MinVolume / current) + / logK * PollMs / 1000.0; + } + + percent = current * 100; + } + catch { /* переживаем транзиентные COM-сбои */ } + + UpdateStatus(percent, muted, atFloor, etaSec, cfg); + + try { await Task.Delay(PollMs, ct); } + catch (OperationCanceledException) { break; } + } + } + + // ─── UI ─────────────────────────────────────────────────────────────── + private void UpdateStatus(double percent, bool muted, bool atFloor, + double etaSec, Config cfg) + { + DispatcherQueue.TryEnqueue(() => + { + VolumeBar.Value = percent; + + if (muted) + StatusText.Text = "🔇 Звук выключен — пауза"; + else if (atFloor && cfg.MinPercent == 0) + StatusText.Text = "🔇 Достигнута тишина"; + else if (atFloor) + StatusText.Text = $"Уровень достиг минимума ({cfg.MinPercent:F0}%)"; + else + StatusText.Text = $"Громкость: {percent:F2}%"; + + if (muted) + EtaText.Text = "Снимите мьют — цикл продолжится"; + else if (atFloor) + EtaText.Text = "Увеличьте громкость, чтобы возобновить цикл"; + else if (!double.IsNaN(etaSec)) + { + var t = TimeSpan.FromSeconds(Math.Max(0, etaSec)); + EtaText.Text = t.TotalHours >= 1 + ? $"До минимума ≈ {(int)t.TotalHours}ч {t.Minutes:D2}м" + : $"До минимума ≈ {t.Minutes:D2}м {t.Seconds:D2}с"; + } + else + EtaText.Text = ""; + + RateText.Text = + $"Темп: {cfg.DbPerTick:F4} дБ/тик · " + + $"период {PollMs} мс · цель {cfg.MinPercent:F0}% за " + + $"{cfg.FullDecayMinutes:F0} мин от 100%"; + }); + } +} \ No newline at end of file diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..f717a6b --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs new file mode 100644 index 0000000..148db58 --- /dev/null +++ b/MainWindow.xaml.cs @@ -0,0 +1,62 @@ +using HearingProtector.Extensions; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; + +namespace HearingProtector; + +public sealed partial class MainWindow : Window +{ + // Флаг: true, когда пользователь выбрал «Выход» в трее + internal bool ReadyToClose { get; set; } + + public MainWindow() + { + InitializeComponent(); + this.SetMicaStyle(AppTitleBar, new(400, 300), false, false); + + VersionText.Text = $"v{App.Version}"; + + AppWindow.Changed += AppWindow_Changed; + AppWindow.Closing += AppWindow_Closing; + } + + private void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs args) + { + if (!args.DidPresenterChange) return; + if (sender.Presenter is OverlappedPresenter p && + p.State == OverlappedPresenterState.Minimized) + { + MinimizeToTray(); + } + } + + private void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args) + { + // Если пользователь явно выбрал «Выход» — пропускаем закрытие насквозь. + if (ReadyToClose) return; + + // Иначе крестик/Alt+F4 сворачивают окно в трей, а не закрывают приложение. + args.Cancel = true; + MinimizeToTray(); + } + + public void MinimizeToTray() + { + AppWindow.IsShownInSwitchers = false; + AppWindow.Hide(); + } + + public void RestoreFromTray() + { + AppWindow.Show(); + AppWindow.IsShownInSwitchers = true; + + if (AppWindow.Presenter is OverlappedPresenter p && + p.State == OverlappedPresenterState.Minimized) + { + p.Restore(); + } + + Activate(); + } +} \ No newline at end of file diff --git a/MicaWindowExt.cs b/MicaWindowExt.cs new file mode 100644 index 0000000..30e5b91 --- /dev/null +++ b/MicaWindowExt.cs @@ -0,0 +1,144 @@ +using Microsoft.UI; +using Microsoft.UI.Composition; +using Microsoft.UI.Composition.SystemBackdrops; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Windows.Graphics; +using WinRT.Interop; + +namespace HearingProtector.Extensions; + +#pragma warning disable SYSLIB1054 + +public static class MicaWindowExt +{ + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); + [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } + + + public static void SetMicaStyle(this Window window, UIElement titleBar, SizeInt32? size = null, bool canResize = true, bool canMinMax = true) + { + window.SetSystemBackdrop(); + + window.ExtendsContentIntoTitleBar = true; + window.SetTitleBar(titleBar); + + // Устанавливаем место появления окна и начальный размер + window.CenterWindowOnCursorScreen(size, canResize, canMinMax); + + // Выводим окно на передний план + SetForegroundWindow(WindowNative.GetWindowHandle(window)); + } + + + + static void SetSystemBackdrop(this Window window) + { + // Проверяем версию Windows + if (Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent + ("Windows.Foundation.UniversalApiContract", 13)) // Версия, в которой появился MicaBackdrop + { + // Используем Mica на Windows 11 + window.SystemBackdrop = new MicaBackdrop() + { + Kind = MicaKind.Base // Или MicaKind.Alt для Mica Alt + }; + } + else + { + // Используем Acrylic на Windows 10 + //SystemBackdrop = new DesktopAcrylicBackdrop(); + window.SystemBackdrop = new Windows10AcrylicBackdrop(); + } + } + + + static void CenterWindowOnCursorScreen(this Window window, SizeInt32? size, bool canResize = true, bool canMinMax = true) + { + // Получаем AppWindow текущего окна + IntPtr hWnd = WindowNative.GetWindowHandle(window); + WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd); + AppWindow appWindow = AppWindow.GetFromWindowId(windowId); + + // Получаем позицию курсора + GetCursorPos(out POINT cursorPos); + + // Находим дисплей с курсором + DisplayArea displayArea = DisplayArea.GetFromPoint( + new PointInt32(cursorPos.X, cursorPos.Y), + DisplayAreaFallback.Nearest); + + if (!size.HasValue) + size = appWindow.Size; + + // Центрируем окно на этом дисплее + appWindow.MoveAndResize(new RectInt32( + displayArea.WorkArea.X + (displayArea.WorkArea.Width - size.Value.Width) / 2, + displayArea.WorkArea.Y + (displayArea.WorkArea.Height - size.Value.Height) / 2, + size.Value.Width, size.Value.Height)); + + // Запрещаем пользовательское изменение размера + if (!canResize && appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = false; + // При желании можно также запретить сворачивание/разворачивание: + if (!canMinMax) + { + presenter.IsMaximizable = false; + presenter.IsMinimizable = false; + } + } + } +} + +// Кастомный Backdrop для Windows 10 +public partial class Windows10AcrylicBackdrop : SystemBackdrop +{ + DesktopAcrylicController? controller; + + protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot) + { + // Call the base method to initialize the default configuration object. + base.OnTargetConnected(connectedTarget, xamlRoot); + + // This example does not support sharing MicaSystemBackdrop instances. + if (controller is not null) + { + throw new Exception("This controller cannot be shared"); + } + + controller = new DesktopAcrylicController(); + // Set configuration. + SystemBackdropConfiguration defaultConfig = GetDefaultSystemBackdropConfiguration(connectedTarget, xamlRoot); + controller.SetSystemBackdropConfiguration(defaultConfig); + // Add target. + controller.AddSystemBackdropTarget(connectedTarget); + + controller.TintOpacity = 0.8f; + controller.LuminosityOpacity = 0.9f; + } + + protected override void OnTargetDisconnected(ICompositionSupportsSystemBackdrop disconnectedTarget) + { + base.OnTargetDisconnected(disconnectedTarget); + + controller?.RemoveSystemBackdropTarget(disconnectedTarget); + controller = default; + } +} + +public static class ReadTextExt +{ + public static string[] FileReadAllLines(this string filePath, Encoding? enc = null) + { + using var reader = new StreamReader(filePath, enc ?? Encoding.GetEncoding(1251), detectEncodingFromByteOrderMarks: true); + string content = reader.ReadToEnd().Replace("\r", ""); + return content.Split("\n"); + } +} \ No newline at end of file diff --git a/Package.appxmanifest b/Package.appxmanifest new file mode 100644 index 0000000..494cea8 --- /dev/null +++ b/Package.appxmanifest @@ -0,0 +1,53 @@ + + + + + + + + + + HearingProtector + QWERTY + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Program.cs b/Program.cs index 0298498..2be98c7 100644 --- a/Program.cs +++ b/Program.cs @@ -1,79 +1,49 @@ -using NAudio.CoreAudioApi; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; +using System; +using System.Threading; namespace HearingProtector; -internal static class Program +public static class Program { - // ─── Настройки — только то, что интересно пользователю ─────────────── - private const double MinVolumePercent = 1.0; // до какого % убавляем - private const double FullDecayMinutes = 30.0; // за сколько минут 100% → MinVolume - private const int PollMs = 1000; // шаг цикла - private const float UserChangeEps = 0.005f;// порог «ручное изменение» - private const int LogEveryTicks = 30; // лог раз в 30 сек - - // ─── Производные величины — считаются автоматически ────────────────── - private static readonly double MinVolume = MinVolumePercent / 100.0; - private static readonly double TotalTicks = FullDecayMinutes * 60_000.0 / PollMs; - private static readonly double DecayPerTick = Math.Pow(MinVolume, 1.0 / TotalTicks); - private static readonly double DbPerTick = 20.0 * Math.Log10(DecayPerTick); - - private static async Task Main() + [STAThread] + static void Main(string[] ___) { - using var enumerator = new MMDeviceEnumerator(); - var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); - var volume = device.AudioEndpointVolume; + var mainInstance = AppInstance.FindOrRegisterForKey("HearingProtector_Main"); - // ── Показываем пользователю, что мы из его настроек вывели ── - Console.WriteLine($"Диапазон: 100% → {MinVolumePercent:F1}%"); - Console.WriteLine($"Заданное время: {FullDecayMinutes:F1} мин " + - $"({TotalTicks:F0} тиков по {PollMs} мс)"); - Console.WriteLine($"Множитель/тик: ×{DecayPerTick:F6} " + - $"= {DbPerTick:F4} дБ"); - Console.WriteLine(); - - float current = volume.MasterVolumeLevelScalar; - float lastSet = current; - - Console.WriteLine($"Стартуем с {current * 100:F1}%. Ctrl+C — выход."); - Console.WriteLine(); - - int tick = 0; - while (true) + if (!mainInstance.IsCurrent) { - float actual = volume.MasterVolumeLevelScalar; + // Мы не первый экземпляр — просим основной обработать активацию и выходим. + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); + return; + } - // 1) Реакция на ручное изменение громкости. - if (Math.Abs(actual - lastSet) > UserChangeEps) - { - var dir = actual > lastSet ? "повышение" : "понижение"; - Console.WriteLine( - $"[{DateTime.Now:HH:mm:ss}] Ручное изменение ({dir}): " + - $"{lastSet * 100:F1}% → {actual * 100:F1}%"); - current = actual; - } + // Подписываемся на активации от будущих попыток запуска. + mainInstance.Activated += OnInstanceActivated; - // 2) Один экспоненциальный шаг вниз. - current = (float)Math.Max(MinVolume, current * DecayPerTick); + // Стандартный запуск WinUI с правильным SynchronizationContext. + Application.Start(__ => + { + var context = new DispatcherQueueSynchronizationContext( + DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + _ = new App(); + }); + } - // 3) Применяем, если значение реально отличается. - if (Math.Abs(volume.MasterVolumeLevelScalar - current) > 0.0005f) - volume.MasterVolumeLevelScalar = current; + // Срабатывает, когда кто-то пытается запустить второй экземпляр. + private static void OnInstanceActivated(object? sender, AppActivationArguments e) + { + // ВАЖНО: событие приходит из НЕ-UI потока, поэтому нужно маршалить. + var app = App.CurrentApp; + var window = app?.MainWindow; - lastSet = current; - - // 4) Редкий лог с ETA. - if (++tick % LogEveryTicks == 0) - { - double etaSec = Math.Log(MinVolume / current) / Math.Log(DecayPerTick) - * PollMs / 1000.0; - var eta = TimeSpan.FromSeconds(Math.Max(0, etaSec)); - Console.WriteLine( - $"[{DateTime.Now:HH:mm:ss}] " + - $"Громкость: {current * 100:F1}% " + - $"(до минимума ~{eta:hh\\:mm\\:ss})"); - } - - await Task.Delay(PollMs); + if (window is MainWindow mw) + { + window.DispatcherQueue.TryEnqueue(() => mw.RestoreFromTray()); } } } \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..5fb59f2 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "HearingProtector": { + "commandName": "Project" + } + } +} \ No newline at end of file diff --git a/RelayCommand.cs b/RelayCommand.cs new file mode 100644 index 0000000..5412503 --- /dev/null +++ b/RelayCommand.cs @@ -0,0 +1,21 @@ +using System; +using System.Windows.Input; + +namespace HearingProtector; + +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; + public void Execute(object? parameter) => _execute(); + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} \ No newline at end of file diff --git a/SettingsStore.cs b/SettingsStore.cs new file mode 100644 index 0000000..c4c34d0 --- /dev/null +++ b/SettingsStore.cs @@ -0,0 +1,62 @@ +using Microsoft.Win32; + +namespace HearingProtector; + +internal sealed record AppSettings(double MinPercent, double FullDecayMinutes) +{ + public static AppSettings Default => new(1.0, 30.0); +} + +internal static class SettingsStore +{ + private const string KeyPath = @"Software\HearingProtector"; + private const string ValueMin = "MinPercent"; + private const string ValueDecay = "FullDecayMinutes"; + + public static AppSettings Load() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(KeyPath); + if (key is null) return AppSettings.Default; + + double min = ReadDouble(key, ValueMin, AppSettings.Default.MinPercent); + double decay = ReadDouble(key, ValueDecay, AppSettings.Default.FullDecayMinutes); + + if (min < 0 || min > 50) min = AppSettings.Default.MinPercent; + if (decay < 30 || decay > 360) decay = AppSettings.Default.FullDecayMinutes; + + return new AppSettings(min, decay); + } + catch + { + return AppSettings.Default; + } + } + + public static void Save(AppSettings s) + { + try + { + using var key = Registry.CurrentUser.CreateSubKey(KeyPath, writable: true); + if (key is null) return; + + key.SetValue(ValueMin, s.MinPercent, RegistryValueKind.String); + key.SetValue(ValueDecay, s.FullDecayMinutes, RegistryValueKind.String); + key.DeleteValue("PollMs", throwOnMissingValue: false); + } + catch { } + } + + private static double ReadDouble(RegistryKey key, string name, double fallback) + { + var raw = key.GetValue(name); + return raw switch + { + string s when double.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, + out var d) => d, + double d => d, + _ => fallback, + }; + } +} \ No newline at end of file diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..25d5318 --- /dev/null +++ b/app.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + PerMonitorV2 + + + \ No newline at end of file