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">
|
||||
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||