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