This commit is contained in:
2026-09-26 12:11:20 +07:00
parent 7baeb0dfed
commit 6945e68060
23 changed files with 965 additions and 74 deletions
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application
x:Class="HearingProtector.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:HearingProtector"
xmlns:tb="using:H.NotifyIcon">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+90
View File
@@ -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<AssemblyInformationalVersionAttribute>()?
.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();
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

+66 -7
View File
@@ -1,18 +1,77 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>HearingProtector</RootNamespace>
<Platforms>x64</Platforms>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<LangVersion>latest</LangVersion>
<WindowsPackageType>None</WindowsPackageType>
<WinUISDKReferences>false</WinUISDKReferences>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StartupObject>HearingProtector.Program</StartupObject>
<!-- публикация -->
<PlatformTarget>x64</PlatformTarget>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<SelfContained>true</SelfContained>
<EnableMsixTooling>true</EnableMsixTooling>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- версия и информация -->
<Version>0.9.0</Version>
<AssemblyVersion>0.9.0.0</AssemblyVersion>
<FileVersion>0.9.0.0</FileVersion>
<InformationalVersion>0.9.0</InformationalVersion>
<Product>Hearing Protector</Product>
<AssemblyTitle>Hearing Protector</AssemblyTitle>
<Company>QwertyKez</Company>
<Copyright>Copyright © 2026</Copyright>
<!-- single instance application -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<!-- Manifest -->
<PropertyGroup>
<ApplicationManifest Condition="'$(Configuration)' != 'Debug'">app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<None Remove="MainPage.xaml" />
</ItemGroup>
<ItemGroup>
<Manifest Condition="'$(Configuration)' != 'Debug'" Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="H.NotifyIcon.WinUI" Version="2.4.1" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageReference Include="NAudio" Version="3.1.0" />
<PackageReference Include="NAudio.Wasapi" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<Page Update="MainPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<!-- Publish Properties -->
<PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<!--
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
-->
<PublishTrimmed>False</PublishTrimmed>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+11 -1
View File
@@ -1,3 +1,13 @@
<Solution>
<Project Path="HearingProtector.csproj" />
<Configurations>
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="HearingProtector.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
<Deploy />
</Project>
</Solution>
+109
View File
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="HearingProtector.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Padding="24,10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="1" Spacing="5">
<TextBlock Style="{ThemeResource SubtitleTextBlockStyle}" Text="Настройки" />
<!-- Минимальная громкость -->
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Grid.ColumnSpan="2"
Text="Минимальная громкость" />
<Slider
x:Name="MinVolumeSlider"
Grid.Row="1"
Grid.Column="0"
Maximum="25"
Minimum="0"
SnapsTo="StepValues"
StepFrequency="1"
ValueChanged="Config_ValueChanged" />
<TextBlock
x:Name="MinVolumeLabel"
Grid.Row="1"
Grid.Column="1"
VerticalAlignment="Center"
Text="1 %"
TextAlignment="Right" />
</Grid>
<!-- Время до минимума -->
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Grid.ColumnSpan="2"
Text="100% → минимум за" />
<Slider
x:Name="FullDecaySlider"
Grid.Row="1"
Grid.Column="0"
Maximum="360"
Minimum="30"
SnapsTo="StepValues"
StepFrequency="15"
ValueChanged="Config_ValueChanged" />
<TextBlock
x:Name="FullDecayLabel"
Grid.Row="1"
Grid.Column="1"
VerticalAlignment="Center"
Text="30 мин"
TextAlignment="Right" />
</Grid>
</StackPanel>
<StackPanel
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="8">
<TextBlock
x:Name="StatusText"
Style="{ThemeResource BodyStrongTextBlockStyle}"
Text="Запуск…" />
<ProgressBar
x:Name="VolumeBar"
Maximum="100"
Value="0" />
<TextBlock
x:Name="EtaText"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="" />
<TextBlock
x:Name="RateText"
FontSize="12"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
Text="" />
</StackPanel>
</Grid>
</Page>
+222
View File
@@ -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%";
});
}
}
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="HearingProtector.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:HearingProtector"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="HearingProtector"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<!-- ШАПКА -->
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition />
</Grid.RowDefinitions>
<!-- ШАПКА -->
<Grid x:Name="AppTitleBar">
<TextBlock
Margin="12,5,0,5"
FontFamily="Consolas"
Style="{ThemeResource TitleTextBlockStyle}"
Text="Hearing Protector"
TextWrapping="NoWrap" />
<TextBlock
x:Name="VersionText"
Margin="0,0,0,-12"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
FontSize="8"
Foreground="{ThemeResource TextFillColorTertiaryBrush}" />
</Grid>
<Rectangle
Grid.Row="1"
Height="1"
Fill="Gray" />
<Grid Grid.Row="2">
<!-- КОНТЕНТ -->
<local:MainPage />
<!-- КОНТЕНТ -->
</Grid>
</Grid>
</Window>
+62
View File
@@ -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();
}
}
+144
View File
@@ -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");
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10"
IgnorableNamespaces="uap rescap systemai">
<Identity
Name="d2a92e01-d227-4437-b2d5-208599349299"
Publisher="CN=QWERTY"
Version="1.0.0.0" />
<mp:PhoneIdentity PhoneProductId="d2a92e01-d227-4437-b2d5-208599349299" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>HearingProtector</DisplayName>
<PublisherDisplayName>QWERTY</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="HearingProtector"
Description="HearingProtector"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
</Capabilities>
</Package>
+35 -65
View File
@@ -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());
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"profiles": {
"HearingProtector": {
"commandName": "Project"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using System;
using System.Windows.Input;
namespace HearingProtector;
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? 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);
}
+62
View File
@@ -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,
};
}
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="HearingProtector.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>