From d562157689879d082247dbd4e86927e57c63914e Mon Sep 17 00:00:00 2001 From: dantheman4700 Date: Mon, 12 Jan 2026 15:35:12 -0600 Subject: [PATCH 01/15] Feature: Configurable volume step snapping (1-50%) - Add VolumeStepAmount setting (1-50%, default 2%) - Add UseRangeSnapping setting to snap scroll wheel to volume grid - Add UseSliderSnap setting to snap slider drag to volume steps - Snapping features are disabled when UseLogarithmicVolume is ON - Update VolumeSlider with snap point calculation logic - Add UI controls in Community Settings page - Add localized resource strings --- EarTrumpet/AppSettings.cs | 18 ++++++ EarTrumpet/Properties/Resources.Designer.cs | 27 ++++++++ EarTrumpet/Properties/Resources.resx | 9 +++ EarTrumpet/UI/Controls/VolumeSlider.cs | 63 +++++++++++++++++-- ...arTrumpetCommunitySettingsPageViewModel.cs | 39 ++++++++++++ EarTrumpet/UI/Views/SettingsWindow.xaml | 25 ++++++++ 6 files changed, 176 insertions(+), 5 deletions(-) diff --git a/EarTrumpet/AppSettings.cs b/EarTrumpet/AppSettings.cs index 310cad2d5..76d882dcb 100644 --- a/EarTrumpet/AppSettings.cs +++ b/EarTrumpet/AppSettings.cs @@ -178,6 +178,24 @@ public float LogarithmicVolumeMinDb } } + public int VolumeStepAmount + { + get => _settings.Get("VolumeStepAmount", 2); + set => _settings.Set("VolumeStepAmount", Math.Max(1, Math.Min(50, value))); + } + + public bool UseRangeSnapping + { + get => _settings.Get("UseRangeSnapping", true); + set => _settings.Set("UseRangeSnapping", value); + } + + public bool UseSliderSnap + { + get => _settings.Get("UseSliderSnap", false); + set => _settings.Set("UseSliderSnap", value); + } + public WINDOWPLACEMENT? FullMixerWindowPlacement { get => _settings.Get("FullMixerWindowPlacement", default(WINDOWPLACEMENT?)); diff --git a/EarTrumpet/Properties/Resources.Designer.cs b/EarTrumpet/Properties/Resources.Designer.cs index e3aef0abd..0e214b1b5 100644 --- a/EarTrumpet/Properties/Resources.Designer.cs +++ b/EarTrumpet/Properties/Resources.Designer.cs @@ -1755,5 +1755,32 @@ public static string WindowsLegacyMenuText { return ResourceManager.GetString("WindowsLegacyMenuText", resourceCulture); } } + + /// + /// Looks up a localized string similar to Volume step amount. + /// + public static string SettingsVolumeStepAmount { + get { + return ResourceManager.GetString("SettingsVolumeStepAmount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Snap scroll wheel to volume step grid. + /// + public static string SettingsUseRangeSnapping { + get { + return ResourceManager.GetString("SettingsUseRangeSnapping", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Snap slider to volume steps when dragging. + /// + public static string SettingsUseSliderSnap { + get { + return ResourceManager.GetString("SettingsUseSliderSnap", resourceCulture); + } + } } } diff --git a/EarTrumpet/Properties/Resources.resx b/EarTrumpet/Properties/Resources.resx index 97f1fb8d9..a9fb7a35e 100644 --- a/EarTrumpet/Properties/Resources.resx +++ b/EarTrumpet/Properties/Resources.resx @@ -685,4 +685,13 @@ Open [https://eartrumpet.app/jmp/fixstartup] to resolve this? dB + + Volume step amount + + + Snap scroll wheel to volume step grid + + + Snap slider to volume steps when dragging + \ No newline at end of file diff --git a/EarTrumpet/UI/Controls/VolumeSlider.cs b/EarTrumpet/UI/Controls/VolumeSlider.cs index 2f5cf5efc..4eacbfff8 100644 --- a/EarTrumpet/UI/Controls/VolumeSlider.cs +++ b/EarTrumpet/UI/Controls/VolumeSlider.cs @@ -202,17 +202,48 @@ private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseWheel(object sender, MouseWheelEventArgs e) { - var amount = Math.Sign(e.Delta) * (App.Settings.UseLogarithmicVolume ? 0.2 : 2.0); - ChangePositionByAmount(amount); + if (App.Settings.UseLogarithmicVolume) + { + var amount = Math.Sign(e.Delta) * 0.2; + ChangePositionByAmount(amount); + } + else + { + var step = App.Settings.VolumeStepAmount; + if (App.Settings.UseRangeSnapping) + { + var newValue = (e.Delta > 0) + ? GetNextSnapPoint(Value, step) + : GetPrevSnapPoint(Value, step); + Value = Bound(newValue); + } + else + { + ChangePositionByAmount(Math.Sign(e.Delta) * step); + } + } e.Handled = true; } public void SetPositionByControlPoint(Point point) { var percent = point.X / ActualWidth; - Value = App.Settings.UseLogarithmicVolume - ? Bound(Math.Round(Minimum + percent * (Maximum - Minimum), 1)) - : Bound(Minimum + (Maximum - Minimum) * percent); + if (App.Settings.UseLogarithmicVolume) + { + Value = Bound(Math.Round(Minimum + percent * (Maximum - Minimum), 1)); + } + else + { + var rawValue = (Maximum - Minimum) * percent; + if (App.Settings.UseSliderSnap) + { + Value = Bound(GetNearestSnapPoint(rawValue, App.Settings.VolumeStepAmount)); + } + else + { + Value = Bound(rawValue); + } + } } public void ChangePositionByAmount(double amount) @@ -224,4 +255,26 @@ public double Bound(double val) { return Math.Max(Minimum, Math.Min(Maximum, val)); } + + private static double GetNextSnapPoint(double current, int step) + { + var target = Math.Floor(current / step) * step + step; + return (target > 100) ? 100 : target; + } + + private static double GetPrevSnapPoint(double current, int step) + { + var target = Math.Ceiling(current / step) * step - step; + return (target < 0) ? 0 : target; + } + + private static double GetNearestSnapPoint(double current, int step) + { + var snap = Math.Round(current / step) * step; + if (Math.Abs(100 - current) < Math.Abs(snap - current)) + { + return 100; + } + return Math.Min(100, Math.Max(0, snap)); + } } diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs index 0abba004a..389771612 100644 --- a/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs +++ b/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs @@ -35,6 +35,45 @@ public bool ShowFullMixerWindowOnStartup set => _settings.ShowFullMixerWindowOnStartup = value; } + public int VolumeStepAmount + { + get => _settings.VolumeStepAmount; + set + { + if (_settings.VolumeStepAmount != value) + { + _settings.VolumeStepAmount = value; + RaisePropertyChanged(nameof(VolumeStepAmount)); + } + } + } + + public bool UseRangeSnapping + { + get => _settings.UseRangeSnapping; + set + { + if (_settings.UseRangeSnapping != value) + { + _settings.UseRangeSnapping = value; + RaisePropertyChanged(nameof(UseRangeSnapping)); + } + } + } + + public bool UseSliderSnap + { + get => _settings.UseSliderSnap; + set + { + if (_settings.UseSliderSnap != value) + { + _settings.UseSliderSnap = value; + RaisePropertyChanged(nameof(UseSliderSnap)); + } + } + } + public EarTrumpetCommunitySettingsPageViewModel(AppSettings settings) : base(null) { _settings = settings; diff --git a/EarTrumpet/UI/Views/SettingsWindow.xaml b/EarTrumpet/UI/Views/SettingsWindow.xaml index b72d72c62..0890a0d7c 100644 --- a/EarTrumpet/UI/Views/SettingsWindow.xaml +++ b/EarTrumpet/UI/Views/SettingsWindow.xaml @@ -208,6 +208,31 @@ + + + + + + + + + From c23b729fbd19b12935b21c88da0c7a5e192ce85a Mon Sep 17 00:00:00 2001 From: dantheman4700 Date: Thu, 27 Aug 2026 12:54:30 -0500 Subject: [PATCH 02/15] Feature: apply the volume step settings to every volume control The configurable step and snapping settings only reached the flyout sliders. Everything else still moved the volume by a hardcoded 2%, so the setting appeared to do nothing depending on how the volume was adjusted. Add VolumeStepper as the single place that decides how far one nudge moves and where it lands, and route the remaining controls through it: - the tray icon scroll wheel - the global mouse wheel hook, which also ignored logarithmic volume - the absolute volume up/down hotkeys VolumeSlider now shares that helper rather than carrying its own copy of the snap maths. Logarithmic volume keeps its existing per-control step, since the step and snapping settings are defined in percent and do not apply to decibels. Action-configuration sliders (UseCustomRange) are likewise left alone - they carry their own units and bounds, so snapping them to a 0-100 grid was wrong. Also clamp VolumeStepAmount when reading it. The setter already bounded the value, but a step of zero written straight to the registry would have reached the snap maths and divided by zero. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YT7kv6fxQcdQqWcvZt2kKd --- EarTrumpet/App.xaml.cs | 11 +-- EarTrumpet/AppSettings.cs | 8 ++- EarTrumpet/UI/Controls/VolumeSlider.cs | 57 ++++----------- EarTrumpet/UI/Helpers/VolumeStepper.cs | 79 +++++++++++++++++++++ EarTrumpet/UI/ViewModels/FlyoutViewModel.cs | 3 +- 5 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 EarTrumpet/UI/Helpers/VolumeStepper.cs diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index ee4a2952b..dd0d068f4 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -188,8 +188,11 @@ private void TrayIconScrolled(object _, int wheelDelta) { if (Settings.UseScrollWheelInTray && (!Settings.UseGlobalMouseWheelHook || _flyoutViewModel.State == FlyoutViewState.Hidden)) { - CollectionViewModel.Default?.IncrementVolume( - Math.Sign(wheelDelta) * (Settings.UseLogarithmicVolume ? 0.2f : 2.0f)); + var device = CollectionViewModel.Default; + if (device != null) + { + device.Volume = (float)VolumeStepper.Step(device.Volume, Math.Sign(wheelDelta)); + } } } @@ -411,7 +414,7 @@ private void AbsoluteVolumeIncrement() foreach (var device in CollectionViewModel.AllDevices.Where(d => !d.IsMuted || d.IsAbsMuted)) { device.IsAbsMuted = false; - device.IncrementVolume(2); + device.Volume = (float)VolumeStepper.Step(device.Volume, 1, VolumeStepper.LogarithmicHotkeyStep); } } @@ -420,7 +423,7 @@ private void AbsoluteVolumeDecrement() foreach (var device in CollectionViewModel.AllDevices.Where(d => !d.IsMuted)) { var wasMuted = device.IsMuted; - device.Volume -= 2; + device.Volume = (float)VolumeStepper.Step(device.Volume, -1, VolumeStepper.LogarithmicHotkeyStep); if (!wasMuted == (device.Volume <= 0)) { diff --git a/EarTrumpet/AppSettings.cs b/EarTrumpet/AppSettings.cs index 76d882dcb..b349e172e 100644 --- a/EarTrumpet/AppSettings.cs +++ b/EarTrumpet/AppSettings.cs @@ -180,10 +180,14 @@ public float LogarithmicVolumeMinDb public int VolumeStepAmount { - get => _settings.Get("VolumeStepAmount", 2); - set => _settings.Set("VolumeStepAmount", Math.Max(1, Math.Min(50, value))); + // Clamped on the way out as well as in: a value written directly to the registry + // would otherwise reach the step maths, where zero divides. + get => BoundVolumeStep(_settings.Get("VolumeStepAmount", 2)); + set => _settings.Set("VolumeStepAmount", BoundVolumeStep(value)); } + private static int BoundVolumeStep(int value) => Math.Max(1, Math.Min(50, value)); + public bool UseRangeSnapping { get => _settings.Get("UseRangeSnapping", true); diff --git a/EarTrumpet/UI/Controls/VolumeSlider.cs b/EarTrumpet/UI/Controls/VolumeSlider.cs index 4eacbfff8..192e46f5e 100644 --- a/EarTrumpet/UI/Controls/VolumeSlider.cs +++ b/EarTrumpet/UI/Controls/VolumeSlider.cs @@ -1,4 +1,5 @@ -using System; +using EarTrumpet.UI.Helpers; +using System; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; @@ -202,25 +203,14 @@ private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseWheel(object sender, MouseWheelEventArgs e) { - if (App.Settings.UseLogarithmicVolume) + var direction = Math.Sign(e.Delta); + if (UsesVolumeStepSettings) { - var amount = Math.Sign(e.Delta) * 0.2; - ChangePositionByAmount(amount); + Value = Bound(VolumeStepper.Step(Value, direction)); } else { - var step = App.Settings.VolumeStepAmount; - if (App.Settings.UseRangeSnapping) - { - var newValue = (e.Delta > 0) - ? GetNextSnapPoint(Value, step) - : GetPrevSnapPoint(Value, step); - Value = Bound(newValue); - } - else - { - ChangePositionByAmount(Math.Sign(e.Delta) * step); - } + ChangePositionByAmount(direction * (App.Settings.UseLogarithmicVolume ? 0.2 : 2.0)); } e.Handled = true; } @@ -235,14 +225,9 @@ public void SetPositionByControlPoint(Point point) else { var rawValue = (Maximum - Minimum) * percent; - if (App.Settings.UseSliderSnap) - { - Value = Bound(GetNearestSnapPoint(rawValue, App.Settings.VolumeStepAmount)); - } - else - { - Value = Bound(rawValue); - } + Value = Bound(UsesVolumeStepSettings && App.Settings.UseSliderSnap + ? VolumeStepper.NearestSnapPoint(rawValue, App.Settings.VolumeStepAmount) + : rawValue); } } @@ -256,25 +241,7 @@ public double Bound(double val) return Math.Max(Minimum, Math.Min(Maximum, val)); } - private static double GetNextSnapPoint(double current, int step) - { - var target = Math.Floor(current / step) * step + step; - return (target > 100) ? 100 : target; - } - - private static double GetPrevSnapPoint(double current, int step) - { - var target = Math.Ceiling(current / step) * step - step; - return (target < 0) ? 0 : target; - } - - private static double GetNearestSnapPoint(double current, int step) - { - var snap = Math.Round(current / step) * step; - if (Math.Abs(100 - current) < Math.Abs(snap - current)) - { - return 100; - } - return Math.Min(100, Math.Max(0, snap)); - } + // Action-configuration sliders carry their own units and bounds (they can be decibels), so + // the user's percentage-based step and snapping settings do not apply to them. + private bool UsesVolumeStepSettings => !UseCustomRange; } diff --git a/EarTrumpet/UI/Helpers/VolumeStepper.cs b/EarTrumpet/UI/Helpers/VolumeStepper.cs new file mode 100644 index 000000000..8ddb79757 --- /dev/null +++ b/EarTrumpet/UI/Helpers/VolumeStepper.cs @@ -0,0 +1,79 @@ +using System; + +namespace EarTrumpet.UI.Helpers; + +/// +/// Single source of truth for how far one volume nudge moves and where it lands, so that the +/// tray icon, the global mouse wheel hook, the flyout sliders and the volume hotkeys all agree +/// with the step size the user configured. +/// +/// +/// Values are a percentage (0-100). Logarithmic volume works in decibels and has its own fixed +/// step, so the user's step and snapping settings do not apply to it; callers pass the step that +/// mode should use and bound the result themselves. +/// +public static class VolumeStepper +{ + /// Step used by scroll wheels while logarithmic volume is on. + public const double LogarithmicWheelStep = 0.2; + + /// Step used by the volume hotkeys while logarithmic volume is on. + public const double LogarithmicHotkeyStep = 2.0; + + /// + /// The volume one nudge away from , honouring the configured step + /// size and snap-to-grid behaviour. + /// + /// Current volume, as a percentage (or decibels while logarithmic). + /// Positive to raise, negative to lower. + /// Step to use while logarithmic volume is on. + public static double Step(double current, int direction, double logarithmicStep = LogarithmicWheelStep) + { + var sign = Math.Sign(direction); + if (sign == 0) + { + return current; + } + + if (App.Settings.UseLogarithmicVolume) + { + // Decibels: the caller owns the bounds, which differ per slider. + return current + sign * logarithmicStep; + } + + var step = App.Settings.VolumeStepAmount; + if (App.Settings.UseRangeSnapping) + { + return sign > 0 ? NextSnapPoint(current, step) : PreviousSnapPoint(current, step); + } + + return Bound(current + sign * step); + } + + /// First point on the step grid above . + public static double NextSnapPoint(double current, int step) + { + return Math.Min(100, Math.Floor(current / step) * step + step); + } + + /// First point on the step grid below . + public static double PreviousSnapPoint(double current, int step) + { + return Math.Max(0, Math.Ceiling(current / step) * step - step); + } + + /// + /// Closest point on the step grid. 100 stays reachable even when it does not sit on the grid, + /// so that a step size like 3 can still reach full volume. + /// + public static double NearestSnapPoint(double current, int step) + { + var snap = Math.Round(current / step) * step; + return Math.Abs(100 - current) < Math.Abs(snap - current) ? 100 : Bound(snap); + } + + private static double Bound(double value) + { + return Math.Max(0, Math.Min(100, value)); + } +} diff --git a/EarTrumpet/UI/ViewModels/FlyoutViewModel.cs b/EarTrumpet/UI/ViewModels/FlyoutViewModel.cs index c8ee6dafc..c71f72e6a 100644 --- a/EarTrumpet/UI/ViewModels/FlyoutViewModel.cs +++ b/EarTrumpet/UI/ViewModels/FlyoutViewModel.cs @@ -326,7 +326,8 @@ private int OnMouseWheelEvent(object sender, System.Windows.Forms.MouseEventArgs { if (!_winRect.Contains(new Point(e.X, e.Y))) { - existing.IncrementVolume(Math.Sign(e.Delta) * 2); + existing.Volume = (float)VolumeStepper.Step( + existing.Volume, Math.Sign(e.Delta), VolumeStepper.LogarithmicHotkeyStep); return -1; } } From 9c9764aba0ca1061d0613dba3a4fb755b9f37422 Mon Sep 17 00:00:00 2001 From: Junior Tan Date: Sun, 27 Sep 2026 18:01:11 +0800 Subject: [PATCH 03/15] Fix Debug build: implement new volume methods on mock audio classes The logarithmic volume rework added GetVolumeScalar, GetVolumeLogarithmic, SetVolumeScalar and SetVolumeLogarithmic to IStreamWithVolumeControl, but the DEBUG-only mocks in DataModel/Audio/Mocks were not updated, so Debug builds fail with CS0535. Release is unaffected because the mocks are compiled out. Co-Authored-By: Claude Opus 5.5 --- EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs | 6 ++++++ .../DataModel/Audio/Mocks/AudioDeviceSession.cs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs b/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs index 18b218e99..35cc5fd9f 100644 --- a/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs +++ b/EarTrumpet/DataModel/Audio/Mocks/AudioDevice.cs @@ -1,5 +1,6 @@ using EarTrumpet.DataModel.WindowsAudio; using EarTrumpet.DataModel.WindowsAudio.Internal; +using EarTrumpet.Extensions; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -52,6 +53,11 @@ public float Volume } } + public float GetVolumeScalar() => _volume; + public float GetVolumeLogarithmic() => _volume.LinearToLog(); + public void SetVolumeScalar(float value) => Volume = value.Bound(0, 1f); + public void SetVolumeLogarithmic(float value) => Volume = value.LogToLinear().Bound(0, 1f); + public float PeakValue1 { get; set; } public float PeakValue2 { get; set; } public IEnumerable Channels { get; } = new List(); diff --git a/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs b/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs index 8b32c54a2..797640d98 100644 --- a/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs +++ b/EarTrumpet/DataModel/Audio/Mocks/AudioDeviceSession.cs @@ -67,6 +67,21 @@ public float Volume } } + public float GetVolumeScalar() => _volume; + public float GetVolumeLogarithmic() => _volume.LinearToLog(); + public void SetVolumeScalar(float value) => SetVolumeInternal(value.Bound(0, 1f)); + public void SetVolumeLogarithmic(float value) => SetVolumeInternal(value.LogToLinear().Bound(0, 1f)); + + // Bypasses the Volume setter, which converts to log when UseLogarithmicVolume is on. + private void SetVolumeInternal(float value) + { + if (_volume != value) + { + _volume = value; + RaisePropertyChanged(nameof(Volume)); + } + } + public float PeakValue1 { get; set; } public float PeakValue2 { get; set; } From c972edcd9dec419e4bc9410b5c1b0562b31ed2a7 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Wed, 30 Sep 2026 19:50:23 -0600 Subject: [PATCH 04/15] Add setting to disable scroll wheel in volume sliders (from #1814) --- EarTrumpet/AppSettings.cs | 6 ++++++ EarTrumpet/Properties/Resources.Designer.cs | 9 +++++++++ EarTrumpet/Properties/Resources.resx | 3 +++ EarTrumpet/UI/Controls/VolumeSlider.cs | 5 +++++ .../ViewModels/EarTrumpetMouseSettingsPageViewModel.cs | 6 ++++++ EarTrumpet/UI/Views/SettingsWindow.xaml | 3 +++ 6 files changed, 32 insertions(+) diff --git a/EarTrumpet/AppSettings.cs b/EarTrumpet/AppSettings.cs index b349e172e..5d404a1db 100644 --- a/EarTrumpet/AppSettings.cs +++ b/EarTrumpet/AppSettings.cs @@ -146,6 +146,12 @@ public bool UseGlobalMouseWheelHook set => _settings.Set("UseGlobalMouseWheelHook", value); } + public bool UseScrollWheelInSliders + { + get => _settings.Get("UseScrollWheelInSliders", true); + set => _settings.Set("UseScrollWheelInSliders", value); + } + public bool HasShownFirstRun { get => _settings.HasKey("hasShownFirstRun"); diff --git a/EarTrumpet/Properties/Resources.Designer.cs b/EarTrumpet/Properties/Resources.Designer.cs index 0e214b1b5..d3e04af3e 100644 --- a/EarTrumpet/Properties/Resources.Designer.cs +++ b/EarTrumpet/Properties/Resources.Designer.cs @@ -1486,6 +1486,15 @@ public static string SettingsUseLogarithmicVolume { } } + /// + /// Looks up a localized string similar to Use the scroll wheel to change volume while hovering over the EarTrumpet icon. + /// + public static string SettingsUseScrollWheelInSliders { + get { + return ResourceManager.GetString("SettingsUseScrollWheelInSliders", resourceCulture); + } + } + /// /// Looks up a localized string similar to Use the scroll wheel to change volume while hovering over the EarTrumpet icon. /// diff --git a/EarTrumpet/Properties/Resources.resx b/EarTrumpet/Properties/Resources.resx index a9fb7a35e..19e9b8d7e 100644 --- a/EarTrumpet/Properties/Resources.resx +++ b/EarTrumpet/Properties/Resources.resx @@ -664,6 +664,9 @@ Open [https://eartrumpet.app/jmp/fixstartup] to resolve this? Use the scroll wheel to change volume while hovering over the EarTrumpet icon + + Use the scroll wheel to change volume while hovering over a volume slider + Use the scroll wheel to change volume while the flyout is open diff --git a/EarTrumpet/UI/Controls/VolumeSlider.cs b/EarTrumpet/UI/Controls/VolumeSlider.cs index 192e46f5e..f93683f65 100644 --- a/EarTrumpet/UI/Controls/VolumeSlider.cs +++ b/EarTrumpet/UI/Controls/VolumeSlider.cs @@ -203,6 +203,11 @@ private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseWheel(object sender, MouseWheelEventArgs e) { + if (!App.Settings.UseScrollWheelInSliders) + { + return; + } + var direction = Math.Sign(e.Delta); if (UsesVolumeStepSettings) { diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs index fb678952a..67a021138 100644 --- a/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs +++ b/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs @@ -16,6 +16,12 @@ public bool UseGlobalMouseWheelHook set => _settings.UseGlobalMouseWheelHook = value; } + public bool UseScrollWheelInSliders + { + get => _settings.UseScrollWheelInSliders; + set => _settings.UseScrollWheelInSliders = value; + } + private readonly AppSettings _settings; public EarTrumpetMouseSettingsPageViewModel(AppSettings settings) : base(null) diff --git a/EarTrumpet/UI/Views/SettingsWindow.xaml b/EarTrumpet/UI/Views/SettingsWindow.xaml index 0890a0d7c..b96c8dda1 100644 --- a/EarTrumpet/UI/Views/SettingsWindow.xaml +++ b/EarTrumpet/UI/Views/SettingsWindow.xaml @@ -172,6 +172,9 @@ + From 4bd94d5a4e0ac990e3f6edea108725f939662160 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Wed, 30 Sep 2026 19:50:28 -0600 Subject: [PATCH 05/15] Fix resource doc comment --- EarTrumpet/Properties/Resources.Designer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EarTrumpet/Properties/Resources.Designer.cs b/EarTrumpet/Properties/Resources.Designer.cs index d3e04af3e..c2d8007b8 100644 --- a/EarTrumpet/Properties/Resources.Designer.cs +++ b/EarTrumpet/Properties/Resources.Designer.cs @@ -1487,7 +1487,7 @@ public static string SettingsUseLogarithmicVolume { } /// - /// Looks up a localized string similar to Use the scroll wheel to change volume while hovering over the EarTrumpet icon. + /// Looks up a localized string similar to Use the scroll wheel to change volume while hovering over the EarTrumpet volume slider. /// public static string SettingsUseScrollWheelInSliders { get { From 330634bc1b46f266ae6f7889cdb85f22983bd68f Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Wed, 30 Sep 2026 20:00:00 -0600 Subject: [PATCH 06/15] Settings: merge pages into General, Windows 11 style cards with titles and descriptions --- EarTrumpet/App.xaml.cs | 2 - EarTrumpet/Properties/Resources.Designer.cs | 135 ++++++++++++++ EarTrumpet/Properties/Resources.resx | 45 +++++ EarTrumpet/UI/Controls/SettingsCard.cs | 25 +++ ...arTrumpetCommunitySettingsPageViewModel.cs | 26 ++- .../EarTrumpetLegacySettingsPageViewModel.cs | 21 --- .../EarTrumpetMouseSettingsPageViewModel.cs | 33 ---- EarTrumpet/UI/Views/SettingsWindow.xaml | 166 ++++++++++-------- 8 files changed, 322 insertions(+), 131 deletions(-) create mode 100644 EarTrumpet/UI/Controls/SettingsCard.cs delete mode 100644 EarTrumpet/UI/ViewModels/EarTrumpetLegacySettingsPageViewModel.cs delete mode 100644 EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index dd0d068f4..6525e1cd7 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -369,9 +369,7 @@ private Window CreateSettingsExperience() null, [ new EarTrumpetShortcutsPageViewModel(Settings), - new EarTrumpetMouseSettingsPageViewModel(Settings), new EarTrumpetCommunitySettingsPageViewModel(Settings), - new EarTrumpetLegacySettingsPageViewModel(Settings), new EarTrumpetAboutPageViewModel(_errorReporter.DisplayDiagnosticData, Settings) ]); diff --git a/EarTrumpet/Properties/Resources.Designer.cs b/EarTrumpet/Properties/Resources.Designer.cs index c2d8007b8..fa3f3705d 100644 --- a/EarTrumpet/Properties/Resources.Designer.cs +++ b/EarTrumpet/Properties/Resources.Designer.cs @@ -1791,5 +1791,140 @@ public static string SettingsUseSliderSnap { return ResourceManager.GetString("SettingsUseSliderSnap", resourceCulture); } } + /// + /// Looks up a localized string similar to General. + /// + public static string GeneralSettingsPageText { + get { + return ResourceManager.GetString("GeneralSettingsPageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Volume. + /// + public static string SettingsSectionVolume { + get { + return ResourceManager.GetString("SettingsSectionVolume", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Mouse. + /// + public static string SettingsSectionMouse { + get { + return ResourceManager.GetString("SettingsSectionMouse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Appearance. + /// + public static string SettingsSectionAppearance { + get { + return ResourceManager.GetString("SettingsSectionAppearance", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Startup. + /// + public static string SettingsSectionStartup { + get { + return ResourceManager.GetString("SettingsSectionStartup", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Match how loudness is perceived instead of a linear 0-100 scale. + /// + public static string SettingsUseLogarithmicVolumeDescription { + get { + return ResourceManager.GetString("SettingsUseLogarithmicVolumeDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Quietest level shown on the logarithmic scale. + /// + public static string SettingsLogarithmicScaleMinimumDescription { + get { + return ResourceManager.GetString("SettingsLogarithmicScaleMinimumDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to How much the volume changes per scroll or key press. + /// + public static string SettingsVolumeStepAmountDescription { + get { + return ResourceManager.GetString("SettingsVolumeStepAmountDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scrolling lands on the step grid instead of offsetting from the current value. + /// + public static string SettingsUseRangeSnappingDescription { + get { + return ResourceManager.GetString("SettingsUseRangeSnappingDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sliders jump between steps while dragging. + /// + public static string SettingsUseSliderSnapDescription { + get { + return ResourceManager.GetString("SettingsUseSliderSnapDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scroll over the tray icon to change the default device volume. + /// + public static string SettingsUseScrollWheelInTrayDescription { + get { + return ResourceManager.GetString("SettingsUseScrollWheelInTrayDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scroll anywhere on screen to change volume while the flyout is open. + /// + public static string SettingsUseGlobalMouseWheelHookDescription { + get { + return ResourceManager.GetString("SettingsUseGlobalMouseWheelHookDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scroll over a slider in the flyout or mixer to change its volume. + /// + public static string SettingsUseScrollWheelInSlidersDescription { + get { + return ResourceManager.GetString("SettingsUseScrollWheelInSlidersDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show the original EarTrumpet tray icon. + /// + public static string SettingsUseLegacyEarTrumpetIconDescription { + get { + return ResourceManager.GetString("SettingsUseLegacyEarTrumpetIconDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open the full mixer window every time EarTrumpet starts. + /// + public static string SettingsShowFullMixerWindowOnStartupDescription { + get { + return ResourceManager.GetString("SettingsShowFullMixerWindowOnStartupDescription", resourceCulture); + } + } + } } diff --git a/EarTrumpet/Properties/Resources.resx b/EarTrumpet/Properties/Resources.resx index 19e9b8d7e..574ecea8c 100644 --- a/EarTrumpet/Properties/Resources.resx +++ b/EarTrumpet/Properties/Resources.resx @@ -697,4 +697,49 @@ Open [https://eartrumpet.app/jmp/fixstartup] to resolve this? Snap slider to volume steps when dragging + + General + + + Volume + + + Mouse + + + Appearance + + + Startup + + + Match how loudness is perceived instead of a linear 0-100 scale + + + Quietest level shown on the logarithmic scale + + + How much the volume changes per scroll or key press + + + Scrolling lands on the step grid instead of offsetting from the current value + + + Sliders jump between steps while dragging + + + Scroll over the tray icon to change the default device volume + + + Scroll anywhere on screen to change volume while the flyout is open + + + Scroll over a slider in the flyout or mixer to change its volume + + + Show the original EarTrumpet tray icon + + + Open the full mixer window every time EarTrumpet starts + \ No newline at end of file diff --git a/EarTrumpet/UI/Controls/SettingsCard.cs b/EarTrumpet/UI/Controls/SettingsCard.cs new file mode 100644 index 000000000..373d26a7a --- /dev/null +++ b/EarTrumpet/UI/Controls/SettingsCard.cs @@ -0,0 +1,25 @@ +using System.Windows; +using System.Windows.Controls; + +namespace EarTrumpet.UI.Controls; + +// Windows 11 style settings row: title + description on the left, the control (Content) on the right. +public class SettingsCard : ContentControl +{ + public static readonly DependencyProperty HeaderProperty = + DependencyProperty.Register(nameof(Header), typeof(string), typeof(SettingsCard)); + public static readonly DependencyProperty DescriptionProperty = + DependencyProperty.Register(nameof(Description), typeof(string), typeof(SettingsCard)); + + public string Header + { + get => (string)GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } +} diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs index 389771612..88953e500 100644 --- a/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs +++ b/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs @@ -74,10 +74,34 @@ public bool UseSliderSnap } } + public bool UseScrollWheelInTray + { + get => _settings.UseScrollWheelInTray; + set => _settings.UseScrollWheelInTray = value; + } + + public bool UseGlobalMouseWheelHook + { + get => _settings.UseGlobalMouseWheelHook; + set => _settings.UseGlobalMouseWheelHook = value; + } + + public bool UseScrollWheelInSliders + { + get => _settings.UseScrollWheelInSliders; + set => _settings.UseScrollWheelInSliders = value; + } + + public bool UseLegacyIcon + { + get => _settings.UseLegacyIcon; + set => _settings.UseLegacyIcon = value; + } + public EarTrumpetCommunitySettingsPageViewModel(AppSettings settings) : base(null) { _settings = settings; - Title = Properties.Resources.CommunitySettingsPageText; + Title = Properties.Resources.GeneralSettingsPageText; Glyph = "\xE902"; } } \ No newline at end of file diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetLegacySettingsPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetLegacySettingsPageViewModel.cs deleted file mode 100644 index a03b4ce6d..000000000 --- a/EarTrumpet/UI/ViewModels/EarTrumpetLegacySettingsPageViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ - - -namespace EarTrumpet.UI.ViewModels; - -public class EarTrumpetLegacySettingsPageViewModel : SettingsPageViewModel -{ - public bool UseLegacyIcon - { - get => _settings.UseLegacyIcon; - set => _settings.UseLegacyIcon = value; - } - - private readonly AppSettings _settings; - - public EarTrumpetLegacySettingsPageViewModel(AppSettings settings) : base(null) - { - _settings = settings; - Title = Properties.Resources.LegacySettingsPageText; - Glyph = "\xE825"; - } -} \ No newline at end of file diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs deleted file mode 100644 index 67a021138..000000000 --- a/EarTrumpet/UI/ViewModels/EarTrumpetMouseSettingsPageViewModel.cs +++ /dev/null @@ -1,33 +0,0 @@ - - -namespace EarTrumpet.UI.ViewModels; - -public class EarTrumpetMouseSettingsPageViewModel : SettingsPageViewModel -{ - public bool UseScrollWheelInTray - { - get => _settings.UseScrollWheelInTray; - set => _settings.UseScrollWheelInTray = value; - } - - public bool UseGlobalMouseWheelHook - { - get => _settings.UseGlobalMouseWheelHook; - set => _settings.UseGlobalMouseWheelHook = value; - } - - public bool UseScrollWheelInSliders - { - get => _settings.UseScrollWheelInSliders; - set => _settings.UseScrollWheelInSliders = value; - } - - private readonly AppSettings _settings; - - public EarTrumpetMouseSettingsPageViewModel(AppSettings settings) : base(null) - { - _settings = settings; - Title = Properties.Resources.MouseSettingsPageText; - Glyph = "\xE962"; - } -} \ No newline at end of file diff --git a/EarTrumpet/UI/Views/SettingsWindow.xaml b/EarTrumpet/UI/Views/SettingsWindow.xaml index b96c8dda1..ce2d262a5 100644 --- a/EarTrumpet/UI/Views/SettingsWindow.xaml +++ b/EarTrumpet/UI/Views/SettingsWindow.xaml @@ -30,6 +30,48 @@ + + + @@ -164,85 +206,61 @@ - - - - - - - - - - - - - - - + + + + + + + - + - - - - - - + + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + From c33b97a0de3e723dbad165a76039b7ef08ffd0c1 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Wed, 30 Sep 2026 20:04:28 -0600 Subject: [PATCH 07/15] Win11-style tray context menu; settings opens with page sidebar --- EarTrumpet/App.xaml | 43 +++++++++++-------- EarTrumpet/App.xaml.cs | 2 +- EarTrumpet/UI/ViewModels/SettingsViewModel.cs | 5 +++ 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/EarTrumpet/App.xaml b/EarTrumpet/App.xaml index fb57d8fd4..6d76e97e7 100644 --- a/EarTrumpet/App.xaml +++ b/EarTrumpet/App.xaml @@ -588,20 +588,21 @@ - + - + - + - + BorderThickness="1" + CornerRadius="8"> @@ -611,12 +612,12 @@ - + - + @@ -626,7 +627,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + From c04402b8b7ccaf68200abc7acaf4ef4cd2e6c583 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Wed, 30 Sep 2026 20:15:11 -0600 Subject: [PATCH 10/15] Developer options: drop dead ColorTool entry, add restore devices, fix mock icon path --- EarTrumpet/DebugHelpers.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/EarTrumpet/DebugHelpers.cs b/EarTrumpet/DebugHelpers.cs index 070d08a13..b3d9d602a 100644 --- a/EarTrumpet/DebugHelpers.cs +++ b/EarTrumpet/DebugHelpers.cs @@ -40,11 +40,10 @@ public IEnumerable NotificationAreaContextMenuItems Glyph = "\xE948", IsChecked = true, }, - new ContextMenuSeparator(), new() { - DisplayName = "View colors", - Command = new RelayCommand(() => ProcessHelper.StartNoThrow("EarTrumpet.ColorTool.exe")), - Glyph = "\xE790", + DisplayName = "Restore real devices", + Command = new RelayCommand(() => WindowsAudioFactory.Create(AudioDeviceKind.Playback).RefreshAllDevices()), + Glyph = "\xE72C", IsChecked = true, }, }, @@ -119,7 +118,7 @@ private static void DebugAddMockDevice() AddMockApp(mockDevice, "Chr0me", "Chr0me", - @"%ProgramFilesx86%\Google\Chrome\Application\chrome.exe"); + @"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"); var addInfo = devManager.GetType().GetMethod("Add", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); addInfo.Invoke(devManager, [mockDevice]); From 76810ffa7fc57562d388b31c7c52467909f1e0ca Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Sat, 3 Oct 2026 08:42:18 -0600 Subject: [PATCH 11/15] Tray menu: Fluent icons for non-checked items --- EarTrumpet/App.xaml | 10 ++++++++++ EarTrumpet/App.xaml.cs | 18 ++++++++++-------- EarTrumpet/DebugHelpers.cs | 1 + EarTrumpet/UI/ViewModels/ContextMenuItem.cs | 1 + 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/EarTrumpet/App.xaml b/EarTrumpet/App.xaml index af1ff334b..3988fa609 100644 --- a/EarTrumpet/App.xaml +++ b/EarTrumpet/App.xaml @@ -670,6 +670,14 @@ Text="{TemplateBinding Tag}" Visibility="Hidden" /> + diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index 823135db6..0764a4704 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -327,15 +327,17 @@ private List GetTrayContextMenuItems() new ContextMenuSeparator(), new ContextMenuItem { + Icon = "\xE7F4", DisplayName = EarTrumpet.Properties.Resources.WindowsLegacyMenuText, Children = [ - new() { DisplayName = EarTrumpet.Properties.Resources.LegacyVolumeMixerText, Command = new RelayCommand(LegacyControlPanelHelper.StartLegacyAudioMixer) }, - new() { DisplayName = EarTrumpet.Properties.Resources.PlaybackDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("playback")) }, - new() { DisplayName = EarTrumpet.Properties.Resources.RecordingDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("recording")) }, - new() { DisplayName = EarTrumpet.Properties.Resources.SoundsControlPanelText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("sounds")) }, - new() { DisplayName = EarTrumpet.Properties.Resources.OpenSoundSettingsText, Command = new RelayCommand(() => SettingsPageHelper.Open("sound")) }, + new() { Icon = "\xE767", DisplayName = EarTrumpet.Properties.Resources.LegacyVolumeMixerText, Command = new RelayCommand(LegacyControlPanelHelper.StartLegacyAudioMixer) }, + new() { Icon = "\xE7F5", DisplayName = EarTrumpet.Properties.Resources.PlaybackDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("playback")) }, + new() { Icon = "\xE720", DisplayName = EarTrumpet.Properties.Resources.RecordingDevicesText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("recording")) }, + new() { Icon = "\xE8D6", DisplayName = EarTrumpet.Properties.Resources.SoundsControlPanelText, Command = new RelayCommand(() => LegacyControlPanelHelper.Open("sounds")) }, + new() { Icon = "\xE713", DisplayName = EarTrumpet.Properties.Resources.OpenSoundSettingsText, Command = new RelayCommand(() => SettingsPageHelper.Open("sound")) }, new() { + Icon = "\xE9E9", DisplayName = Environment.OSVersion.IsAtLeast(OSVersions.Windows11) ? EarTrumpet.Properties.Resources.OpenAppsVolume_Windows11_Text : EarTrumpet.Properties.Resources.OpenAppsVolume_Windows10_Text, Command = new RelayCommand(() => SettingsPageHelper.Open("apps-volume")) }, @@ -353,9 +355,9 @@ private List GetTrayContextMenuItems() ret.AddRange( [ - new() { DisplayName = EarTrumpet.Properties.Resources.FullWindowTitleText, Command = new RelayCommand(_mixerWindow.OpenOrBringToFront) }, - new() { DisplayName = EarTrumpet.Properties.Resources.SettingsWindowText, Command = new RelayCommand(_settingsWindow.OpenOrBringToFront) }, - new() { DisplayName = EarTrumpet.Properties.Resources.ContextMenuExitTitle, Command = new RelayCommand(Shutdown) }, + new() { Icon = "\xE9E9", DisplayName = EarTrumpet.Properties.Resources.FullWindowTitleText, Command = new RelayCommand(_mixerWindow.OpenOrBringToFront) }, + new() { Icon = "\xE713", DisplayName = EarTrumpet.Properties.Resources.SettingsWindowText, Command = new RelayCommand(_settingsWindow.OpenOrBringToFront) }, + new() { Icon = "\xE7E8", DisplayName = EarTrumpet.Properties.Resources.ContextMenuExitTitle, Command = new RelayCommand(Shutdown) }, ]); return ret; } diff --git a/EarTrumpet/DebugHelpers.cs b/EarTrumpet/DebugHelpers.cs index b3d9d602a..11a94c2e4 100644 --- a/EarTrumpet/DebugHelpers.cs +++ b/EarTrumpet/DebugHelpers.cs @@ -25,6 +25,7 @@ public IEnumerable NotificationAreaContextMenuItems return new List { new() { + Icon = "\xE943", DisplayName = "Developer options", Children = new List { diff --git a/EarTrumpet/UI/ViewModels/ContextMenuItem.cs b/EarTrumpet/UI/ViewModels/ContextMenuItem.cs index 5776a9943..022a0ecf2 100644 --- a/EarTrumpet/UI/ViewModels/ContextMenuItem.cs +++ b/EarTrumpet/UI/ViewModels/ContextMenuItem.cs @@ -6,6 +6,7 @@ namespace EarTrumpet.UI.ViewModels; public class ContextMenuItem { public string Glyph { get; set; } = "\xE0E7"; // Checkmark + public string Icon { get; set; } // Fluent glyph shown in the icon column for non-checked items public string DisplayName { get; set; } public ICommand Command { get; set; } public bool IsChecked { get; set; } From 5554d3ae6b3504586a2f1525c086ce3420040233 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Sat, 3 Oct 2026 08:48:10 -0600 Subject: [PATCH 12/15] Settings: About page cards, clickable link rows, layout polish --- EarTrumpet/UI/Controls/SettingsCard.cs | 53 +++++++++- .../EarTrumpetAboutPageViewModel.cs | 2 +- EarTrumpet/UI/Views/SettingsWindow.xaml | 100 ++++++++++-------- 3 files changed, 108 insertions(+), 47 deletions(-) diff --git a/EarTrumpet/UI/Controls/SettingsCard.cs b/EarTrumpet/UI/Controls/SettingsCard.cs index 373d26a7a..6b13cb41f 100644 --- a/EarTrumpet/UI/Controls/SettingsCard.cs +++ b/EarTrumpet/UI/Controls/SettingsCard.cs @@ -1,15 +1,21 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Input; namespace EarTrumpet.UI.Controls; -// Windows 11 style settings row: title + description on the left, the control (Content) on the right. +// Windows 11 style settings row: [glyph] title + description on the left, the control (Content) on the right. +// With a Command it behaves as a link row: clickable, focusable, with a chevron. public class SettingsCard : ContentControl { public static readonly DependencyProperty HeaderProperty = DependencyProperty.Register(nameof(Header), typeof(string), typeof(SettingsCard)); public static readonly DependencyProperty DescriptionProperty = DependencyProperty.Register(nameof(Description), typeof(string), typeof(SettingsCard)); + public static readonly DependencyProperty GlyphProperty = + DependencyProperty.Register(nameof(Glyph), typeof(string), typeof(SettingsCard)); + public static readonly DependencyProperty CommandProperty = + DependencyProperty.Register(nameof(Command), typeof(ICommand), typeof(SettingsCard), new PropertyMetadata(null, OnCommandChanged)); public string Header { @@ -22,4 +28,49 @@ public string Description get => (string)GetValue(DescriptionProperty); set => SetValue(DescriptionProperty, value); } + + public string Glyph + { + get => (string)GetValue(GlyphProperty); + set => SetValue(GlyphProperty, value); + } + + public ICommand Command + { + get => (ICommand)GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var card = (SettingsCard)d; + var clickable = e.NewValue != null; + card.Cursor = clickable ? Cursors.Hand : null; + card.Focusable = clickable; + card.IsTabStop = clickable; + } + + protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) + { + base.OnMouseLeftButtonUp(e); + Invoke(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key == Key.Enter || e.Key == Key.Space) + { + Invoke(); + e.Handled = true; + } + } + + private void Invoke() + { + if (Command?.CanExecute(null) == true) + { + Command.Execute(null); + } + } } diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetAboutPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetAboutPageViewModel.cs index dca0727c2..576a202a8 100644 --- a/EarTrumpet/UI/ViewModels/EarTrumpetAboutPageViewModel.cs +++ b/EarTrumpet/UI/ViewModels/EarTrumpetAboutPageViewModel.cs @@ -29,7 +29,7 @@ public EarTrumpetAboutPageViewModel(Action openDiagnostics, AppSettings settings _openDiagnostics = openDiagnostics; Glyph = "\xE946"; Title = Properties.Resources.AboutTitle; - AboutText = $"EarTrumpet {App.PackageVersion}"; + AboutText = App.PackageVersion.ToString(); OpenAboutCommand = new RelayCommand(OpenAbout); OpenDiagnosticsCommand = new RelayCommand(OpenDiagnostics); diff --git a/EarTrumpet/UI/Views/SettingsWindow.xaml b/EarTrumpet/UI/Views/SettingsWindow.xaml index f82a361ef..fa802348b 100644 --- a/EarTrumpet/UI/Views/SettingsWindow.xaml +++ b/EarTrumpet/UI/Views/SettingsWindow.xaml @@ -53,23 +53,49 @@ CornerRadius="4"> + + - + + - - + + + + + + + + + + @@ -108,17 +134,17 @@ Description="{x:Static resx:Resources.SettingsLogarithmicScaleMinimumDescription}" Visibility="{Binding UseLogarithmicVolume, Converter={StaticResource BooleanToVisibilityConverter}}"> - - + - - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + From 1bf6e8d1717b67745fad30d00c5613a8fcb07abe Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Sat, 3 Oct 2026 08:52:45 -0600 Subject: [PATCH 13/15] Add translucent windows appearance setting, es/en strings, xunit tests --- EarTrumpet.Tests/EarTrumpet.Tests.csproj | 20 ++++++ EarTrumpet.Tests/LocalizationTests.cs | 61 +++++++++++++++++ EarTrumpet.Tests/VolumeMathTests.cs | 68 +++++++++++++++++++ EarTrumpet.sln | 20 ++++++ EarTrumpet/App.xaml.cs | 1 + EarTrumpet/AppSettings.cs | 6 ++ EarTrumpet/DataModel/SystemSettings.cs | 4 +- EarTrumpet/Properties/Resources.Designer.cs | 18 +++++ EarTrumpet/Properties/Resources.es-ES.resx | 66 ++++++++++++++++++ EarTrumpet/Properties/Resources.resx | 6 ++ EarTrumpet/UI/Themes/Manager.cs | 2 + ...arTrumpetCommunitySettingsPageViewModel.cs | 12 ++++ EarTrumpet/UI/Views/SettingsWindow.xaml | 4 ++ 13 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 EarTrumpet.Tests/EarTrumpet.Tests.csproj create mode 100644 EarTrumpet.Tests/LocalizationTests.cs create mode 100644 EarTrumpet.Tests/VolumeMathTests.cs diff --git a/EarTrumpet.Tests/EarTrumpet.Tests.csproj b/EarTrumpet.Tests/EarTrumpet.Tests.csproj new file mode 100644 index 000000000..8cb2e8ebc --- /dev/null +++ b/EarTrumpet.Tests/EarTrumpet.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0-windows10.0.19041.0 + true + false + x64 + x64 + win-x64 + false + ..\Build\Tests\ + + + + + + + + + + diff --git a/EarTrumpet.Tests/LocalizationTests.cs b/EarTrumpet.Tests/LocalizationTests.cs new file mode 100644 index 000000000..a110cc2d6 --- /dev/null +++ b/EarTrumpet.Tests/LocalizationTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Xunit; + +namespace EarTrumpet.Tests; + +// Guards the "no hardcoded text" rule for the settings UI: every resource XAML points at must exist, +// and the strings this redesign introduced must be translated for the two supported languages. +public class LocalizationTests +{ + private static readonly string s_project = FindProjectDir(); + + private static string FindProjectDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "EarTrumpet", "EarTrumpet.csproj"))) + { + dir = dir.Parent; + } + return Path.Combine(dir?.FullName ?? throw new InvalidOperationException("repo root not found"), "EarTrumpet"); + } + + private static string[] Keys(string resx) + => XDocument.Load(Path.Combine(s_project, "Properties", resx)).Root.Elements("data").Select(e => (string)e.Attribute("name")).ToArray(); + + private static string[] XamlResourceRefs() + => Directory.GetFiles(s_project, "*.xaml", SearchOption.AllDirectories) + .Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar)) + .SelectMany(f => Regex.Matches(File.ReadAllText(f), @"resx:Resources\.(\w+)").Select(m => m.Groups[1].Value)) + .Distinct().ToArray(); + + [Fact] + public void EveryXamlResourceExistsInDefaultResx() + { + var keys = Keys("Resources.resx"); + Assert.Empty(XamlResourceRefs().Except(keys)); + } + + [Fact] + public void EveryDesignerPropertyExistsInDefaultResx() + { + var designer = File.ReadAllText(Path.Combine(s_project, "Properties", "Resources.Designer.cs")); + var props = Regex.Matches(designer, @"public static string (\w+) \{").Select(m => m.Groups[1].Value); + Assert.Empty(props.Except(Keys("Resources.resx"))); + } + + [Fact] + public void SettingsRedesignStringsAreTranslatedToSpanish() + { + var es = Keys("Resources.es-ES.resx"); + var redesign = Keys("Resources.resx").Where(k => + k == "GeneralSettingsPageText" || k.StartsWith("SettingsSection") || + (k.StartsWith("Settings") && (k.EndsWith("Description") || k.Contains("Volume") || k.Contains("Snap") || + k.Contains("ScrollWheel") || k.Contains("Translucent") || k.Contains("Legacy")))).ToArray(); + Assert.NotEmpty(redesign); + Assert.Empty(redesign.Except(es)); + } +} diff --git a/EarTrumpet.Tests/VolumeMathTests.cs b/EarTrumpet.Tests/VolumeMathTests.cs new file mode 100644 index 000000000..c9c3496e5 --- /dev/null +++ b/EarTrumpet.Tests/VolumeMathTests.cs @@ -0,0 +1,68 @@ +using EarTrumpet.DataModel; +using EarTrumpet.Extensions; +using EarTrumpet.UI.Helpers; +using Xunit; + +namespace EarTrumpet.Tests; + +public class VolumeMathTests +{ + [Theory] + [InlineData(0, 5, 5)] + [InlineData(3, 5, 5)] + [InlineData(5, 5, 10)] + [InlineData(98, 5, 100)] + [InlineData(100, 5, 100)] + public void NextSnapPoint_LandsOnGridAboveAndNeverExceeds100(double current, int step, double expected) + => Assert.Equal(expected, VolumeStepper.NextSnapPoint(current, step)); + + [Theory] + [InlineData(100, 5, 95)] + [InlineData(7, 5, 5)] + [InlineData(5, 5, 0)] + [InlineData(2, 5, 0)] + [InlineData(0, 5, 0)] + public void PreviousSnapPoint_LandsOnGridBelowAndNeverGoesNegative(double current, int step, double expected) + => Assert.Equal(expected, VolumeStepper.PreviousSnapPoint(current, step)); + + [Theory] + [InlineData(12, 5, 10)] + [InlineData(13, 5, 15)] + [InlineData(99.6, 3, 100)] // 100 stays reachable when it is not on the grid + [InlineData(1, 3, 0)] + public void NearestSnapPoint_RoundsToGridButKeepsFullVolumeReachable(double current, int step, double expected) + => Assert.Equal(expected, VolumeStepper.NearestSnapPoint(current, step)); + + [Fact] + public void Bound_ClampsBothEnds() + { + Assert.Equal(0d, (-5d).Bound(0, 1)); + Assert.Equal(1d, 5d.Bound(0, 1)); + Assert.Equal(0.5d, 0.5d.Bound(0, 1)); + } + + [Theory] + [InlineData(1.0)] + [InlineData(0.5)] + [InlineData(0.01)] + public void LogarithmicConversion_RoundTrips(double linear) + => Assert.Equal(linear, linear.LinearToLog().LogToLinear(), 9); + + [Fact] + public void FullVolumeIs0Db() + => Assert.Equal(0d, 1d.LinearToLog(), 9); + + [Fact] + public void ForceOpaque_DisablesTransparencyRegardlessOfWindows() + { + try + { + SystemSettings.ForceOpaque = true; + Assert.False(SystemSettings.IsTransparencyEnabled); + } + finally + { + SystemSettings.ForceOpaque = false; + } + } +} diff --git a/EarTrumpet.sln b/EarTrumpet.sln index 873269e34..f4584c5f1 100644 --- a/EarTrumpet.sln +++ b/EarTrumpet.sln @@ -25,6 +25,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Chocolatey", "Chocolatey", EndProject Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "EarTrumpet.Package", "EarTrumpet.Package\EarTrumpet.Package.wapproj", "{EA5510ED-F014-4587-A505-64C59D5B2627}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EarTrumpet.Tests", "EarTrumpet.Tests\EarTrumpet.Tests.csproj", "{BA949B87-C0DF-4986-99CB-30458590F3FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -83,6 +85,24 @@ Global {EA5510ED-F014-4587-A505-64C59D5B2627}.VSDebug|x86.ActiveCfg = Debug|x86 {EA5510ED-F014-4587-A505-64C59D5B2627}.VSDebug|x86.Build.0 = Debug|x86 {EA5510ED-F014-4587-A505-64C59D5B2627}.VSDebug|x86.Deploy.0 = Debug|x86 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Debug|ARM64.ActiveCfg = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Debug|ARM64.Build.0 = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Debug|x64.ActiveCfg = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Debug|x64.Build.0 = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Debug|x86.ActiveCfg = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Debug|x86.Build.0 = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Release|ARM64.ActiveCfg = Release|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Release|ARM64.Build.0 = Release|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Release|x64.ActiveCfg = Release|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Release|x64.Build.0 = Release|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Release|x86.ActiveCfg = Release|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.Release|x86.Build.0 = Release|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.VSDebug|ARM64.ActiveCfg = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.VSDebug|ARM64.Build.0 = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.VSDebug|x64.ActiveCfg = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.VSDebug|x64.Build.0 = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.VSDebug|x86.ActiveCfg = Debug|x64 + {BA949B87-C0DF-4986-99CB-30458590F3FF}.VSDebug|x86.Build.0 = Debug|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index 0764a4704..645112cb2 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -78,6 +78,7 @@ private void OnAppStartup(object sender, StartupEventArgs e) PackageName = PackageHelper.GetFamilyName(HasIdentity); Settings = new AppSettings(); + DataModel.SystemSettings.ForceOpaque = !Settings.UseTranslucentWindows; _errorReporter = new ErrorReporter(Settings); if (SingleInstanceAppMutex.TakeExclusivity()) diff --git a/EarTrumpet/AppSettings.cs b/EarTrumpet/AppSettings.cs index 5d404a1db..592c484f3 100644 --- a/EarTrumpet/AppSettings.cs +++ b/EarTrumpet/AppSettings.cs @@ -152,6 +152,12 @@ public bool UseScrollWheelInSliders set => _settings.Set("UseScrollWheelInSliders", value); } + public bool UseTranslucentWindows + { + get => _settings.Get("UseTranslucentWindows", true); + set => _settings.Set("UseTranslucentWindows", value); + } + public bool HasShownFirstRun { get => _settings.HasKey("hasShownFirstRun"); diff --git a/EarTrumpet/DataModel/SystemSettings.cs b/EarTrumpet/DataModel/SystemSettings.cs index b7a6805c8..f0ee815d2 100644 --- a/EarTrumpet/DataModel/SystemSettings.cs +++ b/EarTrumpet/DataModel/SystemSettings.cs @@ -9,7 +9,9 @@ public static class SystemSettings { private static readonly string s_PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; - public static bool IsTransparencyEnabled => ReadDword(s_PersonalizeKey, "EnableTransparency"); + // App-level override (Settings > Appearance > Translucent windows); Windows' own setting still applies on top. + public static bool ForceOpaque { get; set; } + public static bool IsTransparencyEnabled => !ForceOpaque && ReadDword(s_PersonalizeKey, "EnableTransparency"); public static bool UseAccentColor => ReadDword(s_PersonalizeKey, "ColorPrevalence"); public static bool IsLightTheme => ReadDword(s_PersonalizeKey, "AppsUseLightTheme", 1); public static bool IsSystemLightTheme => LightThemeShim(ReadDword(s_PersonalizeKey, "SystemUsesLightTheme")); diff --git a/EarTrumpet/Properties/Resources.Designer.cs b/EarTrumpet/Properties/Resources.Designer.cs index fa3f3705d..650697e36 100644 --- a/EarTrumpet/Properties/Resources.Designer.cs +++ b/EarTrumpet/Properties/Resources.Designer.cs @@ -1926,5 +1926,23 @@ public static string SettingsShowFullMixerWindowOnStartupDescription { } } + /// + /// Looks up a localized string similar to Translucent windows. + /// + public static string SettingsUseTranslucentWindows { + get { + return ResourceManager.GetString("SettingsUseTranslucentWindows", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Blur the background of the flyout and settings. Turn off for solid windows. Also requires Windows transparency effects. + /// + public static string SettingsUseTranslucentWindowsDescription { + get { + return ResourceManager.GetString("SettingsUseTranslucentWindowsDescription", resourceCulture); + } + } + } } diff --git a/EarTrumpet/Properties/Resources.es-ES.resx b/EarTrumpet/Properties/Resources.es-ES.resx index 23381128c..449e09ae5 100644 --- a/EarTrumpet/Properties/Resources.es-ES.resx +++ b/EarTrumpet/Properties/Resources.es-ES.resx @@ -676,4 +676,70 @@ Deseas abrir [https://eartrumpet.app/jmp/fixfonts] ahora? Mostrar la ventana completa del mezclador al iniciar + + General + + + Volumen + + + Ratón + + + Apariencia + + + Inicio + + + Usar la rueda del ratón para cambiar el volumen sobre un control deslizante + + + Tamaño del paso de volumen + + + Ajustar la rueda a la cuadrícula del paso de volumen + + + Ajustar el control deslizante a los pasos al arrastrar + + + Mínimo de la escala logarítmica + + + Se ajusta a cómo percibimos el volumen en lugar de una escala lineal de 0 a 100 + + + Nivel más bajo que muestra la escala logarítmica + + + Cuánto cambia el volumen con cada desplazamiento o pulsación de tecla + + + La rueda salta a la cuadrícula de pasos en lugar de sumar al valor actual + + + Los controles deslizantes saltan entre pasos al arrastrar + + + Desplaza sobre el icono de la bandeja para cambiar el volumen del dispositivo predeterminado + + + Desplaza en cualquier parte de la pantalla para cambiar el volumen mientras el panel está abierto + + + Desplaza sobre un control deslizante del panel o del mezclador para cambiar su volumen + + + Muestra el icono original de EarTrumpet en la bandeja + + + Abre la ventana completa del mezclador cada vez que se inicia EarTrumpet + + + Ventanas translúcidas + + + Difumina el fondo del panel y de la configuración. Desactívalo para ventanas sólidas. También requiere los efectos de transparencia de Windows + \ No newline at end of file diff --git a/EarTrumpet/Properties/Resources.resx b/EarTrumpet/Properties/Resources.resx index 574ecea8c..7b0fefbb3 100644 --- a/EarTrumpet/Properties/Resources.resx +++ b/EarTrumpet/Properties/Resources.resx @@ -742,4 +742,10 @@ Open [https://eartrumpet.app/jmp/fixstartup] to resolve this? Open the full mixer window every time EarTrumpet starts + + Translucent windows + + + Blur the background of the flyout and settings. Turn off for solid windows. Also requires Windows transparency effects + \ No newline at end of file diff --git a/EarTrumpet/UI/Themes/Manager.cs b/EarTrumpet/UI/Themes/Manager.cs index 4846f4e65..5820e0861 100644 --- a/EarTrumpet/UI/Themes/Manager.cs +++ b/EarTrumpet/UI/Themes/Manager.cs @@ -106,6 +106,8 @@ private void WndProc(int msg, IntPtr _, IntPtr lParam) } } + public void RefreshTheme() => OnThemeColorsChanged(); + private void OnThemeColorsChanged() { if (_themeChangeTimer.IsEnabled) diff --git a/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs b/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs index 88953e500..3eed08c0b 100644 --- a/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs +++ b/EarTrumpet/UI/ViewModels/EarTrumpetCommunitySettingsPageViewModel.cs @@ -92,6 +92,18 @@ public bool UseScrollWheelInSliders set => _settings.UseScrollWheelInSliders = value; } + public bool UseTranslucentWindows + { + get => _settings.UseTranslucentWindows; + set + { + _settings.UseTranslucentWindows = value; + DataModel.SystemSettings.ForceOpaque = !value; + Themes.Manager.Current?.RefreshTheme(); + RaisePropertyChanged(nameof(UseTranslucentWindows)); + } + } + public bool UseLegacyIcon { get => _settings.UseLegacyIcon; diff --git a/EarTrumpet/UI/Views/SettingsWindow.xaml b/EarTrumpet/UI/Views/SettingsWindow.xaml index fa802348b..5e92a6eea 100644 --- a/EarTrumpet/UI/Views/SettingsWindow.xaml +++ b/EarTrumpet/UI/Views/SettingsWindow.xaml @@ -169,6 +169,10 @@ + + + From 03865a2548833e7f588c9cfc34b39307a06406d5 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Sat, 3 Oct 2026 08:55:51 -0600 Subject: [PATCH 14/15] Return focus to previous window after hotkey flyout (#1751), no blank action on open (#1647), es-* uses Spanish strings --- .../ViewModel/ActionsCategoryViewModel.cs | 5 --- EarTrumpet/App.xaml.cs | 45 +++++++++++++++++-- EarTrumpet/NativeMethods.txt | 1 + EarTrumpet/Properties/Resources.es-ES.resx | 6 +-- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/EarTrumpet/Addons/EarTrumpet.Actions/ViewModel/ActionsCategoryViewModel.cs b/EarTrumpet/Addons/EarTrumpet.Actions/ViewModel/ActionsCategoryViewModel.cs index 358b16c78..f07866a80 100644 --- a/EarTrumpet/Addons/EarTrumpet.Actions/ViewModel/ActionsCategoryViewModel.cs +++ b/EarTrumpet/Addons/EarTrumpet.Actions/ViewModel/ActionsCategoryViewModel.cs @@ -40,11 +40,6 @@ public ActionsCategoryViewModel() Glyph = "\xE948", GlyphFontSize = 15, } ]; - - if (Pages.Count == 2) - { - Toolbar[0].Command.Execute(null); - } } internal void ReloadSavedPages() diff --git a/EarTrumpet/App.xaml.cs b/EarTrumpet/App.xaml.cs index 645112cb2..3cf0e9961 100644 --- a/EarTrumpet/App.xaml.cs +++ b/EarTrumpet/App.xaml.cs @@ -69,6 +69,15 @@ public static AppSettings Settings private void OnAppStartup(object sender, StartupEventArgs e) { + // es-MX, es-AR, ... have no satellite assembly of their own: use the Spanish (Spain) strings instead of English. + var ui = System.Globalization.CultureInfo.CurrentUICulture; + if (ui.TwoLetterISOLanguageName == "es" && ui.Name != "es-ES") + { + var spanish = System.Globalization.CultureInfo.GetCultureInfo("es-ES"); + System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = spanish; + Thread.CurrentThread.CurrentUICulture = spanish; + } + RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; Exit += (_, __) => IsShuttingDown = true; @@ -114,7 +123,7 @@ private void ContinueStartup() Exit += (_, __) => _trayIcon.IsVisible = false; CollectionViewModel.TrayPropertyChanged += () => UpdateTrayTooltip(); - _flyoutViewModel = new FlyoutViewModel(CollectionViewModel, () => _trayIcon.SetFocus(), Settings); + _flyoutViewModel = new FlyoutViewModel(CollectionViewModel, ReturnFocusAfterFlyout, Settings); FlyoutWindow = new FlyoutWindow(_flyoutViewModel); // Initialize the FlyoutWindow last because its Show/Hide cycle will pump messages, causing UI frames // to be executed, breaking the assumption that startup is complete. @@ -146,7 +155,7 @@ private void CompleteStartup() _mixerWindow = new WindowHolder(CreateMixerExperience); _settingsWindow = new WindowHolder(CreateSettingsExperience); - Settings.FlyoutHotkeyTyped += () => _flyoutViewModel.OpenFlyout(InputType.Keyboard); + Settings.FlyoutHotkeyTyped += OnFlyoutHotkeyTyped; Settings.MixerHotkeyTyped += () => _mixerWindow.OpenOrClose(); Settings.SettingsHotkeyTyped += () => _settingsWindow.OpenOrBringToFront(); Settings.AbsoluteVolumeUpHotkeyTyped += AbsoluteVolumeIncrement; @@ -154,7 +163,11 @@ private void CompleteStartup() Settings.RegisterHotkeys(); Settings.UseLogarithmicVolumeChanged += (_, __) => UpdateTrayTooltip(); - _trayIcon.PrimaryInvoke += (_, type) => _flyoutViewModel.OpenFlyout(type); + _trayIcon.PrimaryInvoke += (_, type) => + { + _windowBeforeHotkey = default; + _flyoutViewModel.OpenFlyout(type); + }; _trayIcon.SecondaryInvoke += (_, args) => _trayIcon.ShowContextMenu(GetTrayContextMenuItems(), args.Point); _trayIcon.TertiaryInvoke += (_, __) => CollectionViewModel.Default?.ToggleMute.Execute(null); _trayIcon.Scrolled += TrayIconScrolled; @@ -165,6 +178,32 @@ private void CompleteStartup() ShowFullMixerWindowIfConfigured(); } + // Window that had focus when the flyout hotkey opened the flyout, so closing it can hand focus back (#1751). + private HWND _windowBeforeHotkey; + + private void OnFlyoutHotkeyTyped() + { + if (_flyoutViewModel.State == FlyoutViewState.Hidden) + { + _windowBeforeHotkey = PInvoke.GetForegroundWindow(); + } + _flyoutViewModel.OpenFlyout(InputType.Keyboard); + } + + private void ReturnFocusAfterFlyout() + { + var previous = _windowBeforeHotkey; + _windowBeforeHotkey = default; + if (previous != default && PInvoke.IsWindow(previous)) + { + PInvoke.SetForegroundWindow(previous); + } + else + { + _trayIcon.SetFocus(); + } + } + private void ShowFullMixerWindowIfConfigured() { if (Settings.ShowFullMixerWindowOnStartup) diff --git a/EarTrumpet/NativeMethods.txt b/EarTrumpet/NativeMethods.txt index 71062eb53..c2c2fcf5e 100644 --- a/EarTrumpet/NativeMethods.txt +++ b/EarTrumpet/NativeMethods.txt @@ -35,6 +35,7 @@ GetDpiForShellUIComponent GetDpiForSystem GetDpiForWindow GetForegroundWindow +IsWindow GetGuiResources GetPackageApplicationIds GetPackageId diff --git a/EarTrumpet/Properties/Resources.es-ES.resx b/EarTrumpet/Properties/Resources.es-ES.resx index 449e09ae5..6afbf8ade 100644 --- a/EarTrumpet/Properties/Resources.es-ES.resx +++ b/EarTrumpet/Properties/Resources.es-ES.resx @@ -725,10 +725,10 @@ Deseas abrir [https://eartrumpet.app/jmp/fixfonts] ahora? Desplaza sobre el icono de la bandeja para cambiar el volumen del dispositivo predeterminado - Desplaza en cualquier parte de la pantalla para cambiar el volumen mientras el panel está abierto + Desplaza en cualquier parte de la pantalla para cambiar el volumen mientras el control flotante está abierto - Desplaza sobre un control deslizante del panel o del mezclador para cambiar su volumen + Desplaza sobre un control deslizante del control flotante o del mezclador para cambiar su volumen Muestra el icono original de EarTrumpet en la bandeja @@ -740,6 +740,6 @@ Deseas abrir [https://eartrumpet.app/jmp/fixfonts] ahora? Ventanas translúcidas - Difumina el fondo del panel y de la configuración. Desactívalo para ventanas sólidas. También requiere los efectos de transparencia de Windows + Difumina el fondo del control flotante y de los ajustes. Desactívalo para ventanas sólidas. También requiere los efectos de transparencia de Windows \ No newline at end of file From 4f8c59ad759455ef3273d0a03027bbde699cde58 Mon Sep 17 00:00:00 2001 From: VictorGugug Date: Sat, 3 Oct 2026 09:04:11 -0600 Subject: [PATCH 15/15] Fix apps vanishing when persisted device is unavailable, retry session init, guard finalizer; localize Developer options --- .../Internal/AudioDeviceSession.cs | 9 +++ .../Internal/AudioDeviceSessionCollection.cs | 63 +++++++++++++++---- EarTrumpet/DebugHelpers.cs | 8 +-- EarTrumpet/Properties/Resources.Designer.cs | 36 +++++++++++ EarTrumpet/Properties/Resources.es-ES.resx | 12 ++++ EarTrumpet/Properties/Resources.resx | 12 ++++ 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs index 7cb30623a..183572714 100644 --- a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs +++ b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSession.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; @@ -359,6 +360,14 @@ private void SyncPersistedEndpoint(IAudioDevice parent) { if (parent.Id != persistedDeviceId) { + // SetDefaultEndPoint ignores devices we do not know (disabled, unplugged, or not enumerated yet at + // startup). Hiding the session anyway would leave the app listed on no device at all, so stay put. + if (!Parent.Parent.Devices.Any(d => d.Id == persistedDeviceId)) + { + Trace.WriteLine($"FORCE-MOVE skipped, persisted device unavailable: {persistedDeviceId}"); + return; + } + Trace.WriteLine($"FORCE-MOVE: {_state} {parent.Id} -> {persistedDeviceId}"); MoveToDevice(persistedDeviceId, false); diff --git a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs index 9fcf41d46..358adefaf 100644 --- a/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs +++ b/EarTrumpet/DataModel/WindowsAudio/Internal/AudioDeviceSessionCollection.cs @@ -20,18 +20,29 @@ internal class AudioDeviceSessionCollection : IAudioSessionNotification private readonly List _movedSessions = []; private IAudioSessionManager2 _sessionManager; private WeakReference _parent; + private DispatcherTimer _retryTimer; + private int _retriesLeft = 5; public AudioDeviceSessionCollection(IAudioDevice parent, IMMDevice device, Dispatcher foregroundDispatcher) { _parent = new WeakReference(parent); _dispatcher = foregroundDispatcher; + if (!TryInitialize(device)) + { + ScheduleRetry(device); + } + } + + private bool TryInitialize(IMMDevice device) + { try { - _sessionManager = device.Activate(); - _sessionManager.RegisterSessionNotification(this); - - var enumerator = _sessionManager.GetSessionEnumerator(); + var sessionManager = device.Activate(); + sessionManager.RegisterSessionNotification(this); + _sessionManager = sessionManager; + + var enumerator = sessionManager.GetSessionEnumerator(); enumerator.GetCount(out var count); for (var i = 0; i < count; i++) @@ -39,27 +50,57 @@ public AudioDeviceSessionCollection(IAudioDevice parent, IMMDevice device, Dispa enumerator.GetSession(i, out var session); CreateAndAddSession(session); } + return true; } catch (Exception ex) { device.GetId(out var deviceId); Trace.WriteLine($"AudioDeviceSessionCollection Create dev={deviceId} {ex}"); + // Registered but could not enumerate: new sessions still arrive via notifications, do not register twice. + return _sessionManager != null; } } + // When EarTrumpet starts with Windows the audio service may not be ready yet; without a retry this device would + // stay empty until the app is restarted. + private void ScheduleRetry(IMMDevice device) + { + _dispatcher.BeginInvoke((Action)(() => + { + _retryTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher) { Interval = TimeSpan.FromSeconds(2) }; + _retryTimer.Tick += (_, __) => + { + if (TryInitialize(device) || --_retriesLeft <= 0) + { + _retryTimer.Stop(); + } + }; + _retryTimer.Start(); + })); + } + ~AudioDeviceSessionCollection() { - foreach (var session in _sessions) + // An exception escaping a finalizer terminates the process, and the device may already be invalidated or + // activation may have failed (_sessionManager null). + try { - session.PropertyChanged -= Session_PropertyChanged; - } + foreach (var session in _sessions) + { + session.PropertyChanged -= Session_PropertyChanged; + } - foreach (var session in _movedSessions) + foreach (var session in _movedSessions) + { + session.PropertyChanged -= MovedSession_PropertyChanged; + } + + _sessionManager?.UnregisterSessionNotification(this); + } + catch (Exception ex) { - session.PropertyChanged -= MovedSession_PropertyChanged; + Trace.WriteLine($"AudioDeviceSessionCollection dtor Failed: {ex}"); } - - _sessionManager.UnregisterSessionNotification(this); } private void CreateAndAddSession(IAudioSessionControl session) diff --git a/EarTrumpet/DebugHelpers.cs b/EarTrumpet/DebugHelpers.cs index 11a94c2e4..f93190ba7 100644 --- a/EarTrumpet/DebugHelpers.cs +++ b/EarTrumpet/DebugHelpers.cs @@ -26,23 +26,23 @@ public IEnumerable NotificationAreaContextMenuItems { new() { Icon = "\xE943", - DisplayName = "Developer options", + DisplayName = Properties.Resources.DeveloperOptionsMenuText, Children = new List { new() { - DisplayName = "Remove all devices", + DisplayName = Properties.Resources.DeveloperRemoveAllDevicesText, Command = new RelayCommand(DebugRemoveAllDevices), Glyph = "\xE894", IsChecked = true, }, new() { - DisplayName = "Add mock device", + DisplayName = Properties.Resources.DeveloperAddMockDeviceText, Command = new RelayCommand(DebugAddMockDevice), Glyph = "\xE948", IsChecked = true, }, new() { - DisplayName = "Restore real devices", + DisplayName = Properties.Resources.DeveloperRestoreRealDevicesText, Command = new RelayCommand(() => WindowsAudioFactory.Create(AudioDeviceKind.Playback).RefreshAllDevices()), Glyph = "\xE72C", IsChecked = true, diff --git a/EarTrumpet/Properties/Resources.Designer.cs b/EarTrumpet/Properties/Resources.Designer.cs index 650697e36..3612964b6 100644 --- a/EarTrumpet/Properties/Resources.Designer.cs +++ b/EarTrumpet/Properties/Resources.Designer.cs @@ -1944,5 +1944,41 @@ public static string SettingsUseTranslucentWindowsDescription { } } + /// + /// Looks up a localized string similar to Developer options. + /// + public static string DeveloperOptionsMenuText { + get { + return ResourceManager.GetString("DeveloperOptionsMenuText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remove all devices. + /// + public static string DeveloperRemoveAllDevicesText { + get { + return ResourceManager.GetString("DeveloperRemoveAllDevicesText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add mock device. + /// + public static string DeveloperAddMockDeviceText { + get { + return ResourceManager.GetString("DeveloperAddMockDeviceText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Restore real devices. + /// + public static string DeveloperRestoreRealDevicesText { + get { + return ResourceManager.GetString("DeveloperRestoreRealDevicesText", resourceCulture); + } + } + } } diff --git a/EarTrumpet/Properties/Resources.es-ES.resx b/EarTrumpet/Properties/Resources.es-ES.resx index 6afbf8ade..c52ccc215 100644 --- a/EarTrumpet/Properties/Resources.es-ES.resx +++ b/EarTrumpet/Properties/Resources.es-ES.resx @@ -742,4 +742,16 @@ Deseas abrir [https://eartrumpet.app/jmp/fixfonts] ahora? Difumina el fondo del control flotante y de los ajustes. Desactívalo para ventanas sólidas. También requiere los efectos de transparencia de Windows + + Opciones de desarrollador + + + Quitar todos los dispositivos + + + Añadir dispositivo de prueba + + + Restaurar dispositivos reales + \ No newline at end of file diff --git a/EarTrumpet/Properties/Resources.resx b/EarTrumpet/Properties/Resources.resx index 7b0fefbb3..cfe1d1a15 100644 --- a/EarTrumpet/Properties/Resources.resx +++ b/EarTrumpet/Properties/Resources.resx @@ -748,4 +748,16 @@ Open [https://eartrumpet.app/jmp/fixstartup] to resolve this? Blur the background of the flyout and settings. Turn off for solid windows. Also requires Windows transparency effects + + Developer options + + + Remove all devices + + + Add mock device + + + Restore real devices + \ No newline at end of file