0.9.0
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 432 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 637 B |
|
After Width: | Height: | Size: 283 B |
|
After Width: | Height: | Size: 456 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 401 KiB |
@@ -1,18 +1,77 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net10.0-windows</TargetFramework>
|
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<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>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Manifest -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<ApplicationManifest Condition="'$(Configuration)' != 'Debug'">app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<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" Version="3.1.0" />
|
||||||
<PackageReference Include="NAudio.Wasapi" Version="3.1.0" />
|
<PackageReference Include="NAudio.Wasapi" Version="3.1.0" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
<Solution>
|
<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>
|
</Solution>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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%";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
namespace HearingProtector;
|
||||||
|
|
||||||
internal static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
// ─── Настройки — только то, что интересно пользователю ───────────────
|
[STAThread]
|
||||||
private const double MinVolumePercent = 1.0; // до какого % убавляем
|
static void Main(string[] ___)
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
using var enumerator = new MMDeviceEnumerator();
|
var mainInstance = AppInstance.FindOrRegisterForKey("HearingProtector_Main");
|
||||||
var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
|
||||||
var volume = device.AudioEndpointVolume;
|
|
||||||
|
|
||||||
// ── Показываем пользователю, что мы из его настроек вывели ──
|
if (!mainInstance.IsCurrent)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
float actual = volume.MasterVolumeLevelScalar;
|
// Мы не первый экземпляр — просим основной обработать активацию и выходим.
|
||||||
|
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
|
||||||
// 1) Реакция на ручное изменение громкости.
|
mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait();
|
||||||
if (Math.Abs(actual - lastSet) > UserChangeEps)
|
return;
|
||||||
{
|
|
||||||
var dir = actual > lastSet ? "повышение" : "понижение";
|
|
||||||
Console.WriteLine(
|
|
||||||
$"[{DateTime.Now:HH:mm:ss}] Ручное изменение ({dir}): " +
|
|
||||||
$"{lastSet * 100:F1}% → {actual * 100:F1}%");
|
|
||||||
current = actual;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Один экспоненциальный шаг вниз.
|
// Подписываемся на активации от будущих попыток запуска.
|
||||||
current = (float)Math.Max(MinVolume, current * DecayPerTick);
|
mainInstance.Activated += OnInstanceActivated;
|
||||||
|
|
||||||
// 3) Применяем, если значение реально отличается.
|
// Стандартный запуск WinUI с правильным SynchronizationContext.
|
||||||
if (Math.Abs(volume.MasterVolumeLevelScalar - current) > 0.0005f)
|
Application.Start(__ =>
|
||||||
volume.MasterVolumeLevelScalar = current;
|
|
||||||
|
|
||||||
lastSet = current;
|
|
||||||
|
|
||||||
// 4) Редкий лог с ETA.
|
|
||||||
if (++tick % LogEveryTicks == 0)
|
|
||||||
{
|
{
|
||||||
double etaSec = Math.Log(MinVolume / current) / Math.Log(DecayPerTick)
|
var context = new DispatcherQueueSynchronizationContext(
|
||||||
* PollMs / 1000.0;
|
DispatcherQueue.GetForCurrentThread());
|
||||||
var eta = TimeSpan.FromSeconds(Math.Max(0, etaSec));
|
SynchronizationContext.SetSynchronizationContext(context);
|
||||||
Console.WriteLine(
|
_ = new App();
|
||||||
$"[{DateTime.Now:HH:mm:ss}] " +
|
});
|
||||||
$"Громкость: {current * 100:F1}% " +
|
|
||||||
$"(до минимума ~{eta:hh\\:mm\\:ss})");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(PollMs);
|
// Срабатывает, когда кто-то пытается запустить второй экземпляр.
|
||||||
|
private static void OnInstanceActivated(object? sender, AppActivationArguments e)
|
||||||
|
{
|
||||||
|
// ВАЖНО: событие приходит из НЕ-UI потока, поэтому нужно маршалить.
|
||||||
|
var app = App.CurrentApp;
|
||||||
|
var window = app?.MainWindow;
|
||||||
|
|
||||||
|
if (window is MainWindow mw)
|
||||||
|
{
|
||||||
|
window.DispatcherQueue.TryEnqueue(() => mw.RestoreFromTray());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"HearingProtector": {
|
||||||
|
"commandName": "Project"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||