Rewrite how picking a matching audio session works

rewrite the UI layer to make it only send updates to the stream deck if needed
This commit is contained in:
dlprows 2023-08-06 21:51:04 -06:00
parent ab769bf7d2
commit a429a435bc
18 changed files with 888 additions and 448 deletions

91
src/.editorconfig Normal file
View File

@ -0,0 +1,91 @@
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = true:suggestion
[*.cs]
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case

View File

@ -7,11 +7,67 @@ using System.Threading.Tasks;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
internal class ActiveAudioSessionWrapper public class ActiveAudioSessionWrapper
{ {
public string DisplayName { get; set; } public string DisplayName { get; set; }
public string ExecutablePath { get; set; } public string ExecutablePath { get; set; }
public SimpleAudioVolume Volume { get; set; } private List<SimpleAudioVolume> Volume { get; } = new List<SimpleAudioVolume>();
} public string Icon { get; set; }
public bool Any()
{
return Volume.Any();
}
public int Count => Volume.Count;
public void AddVolume(SimpleAudioVolume volume)
{
Volume.Add(volume);
}
public void ToggleMute()
{
//when all volumes are muted, Volume.All will return true
//so we swap from muted to false (opposite of Volume.All)
//when any volumes are unmuted, Volume.All will return false
//so we set muted to true (opposite of Volume.All)
var muted = Volume.All(x => x.Mute);
Volume.ForEach(x => x.Mute = !muted);
}
public bool? GetMuted()
{
var muted = Volume.All(x => x.Mute);
var unmuted = Volume.All(x => !x.Mute);
if(muted == !unmuted)
{
return muted;
}
return null;
}
public void IncrementVolumeLevel(int step, int ticks)
{
//if you have more than one volume. they will all get set based on the first volume control
var level = Volume.FirstOrDefault()?.MasterVolume ?? 0;
level += (0.01f * step) * ticks;
level = Math.Max(level, 0);
Volume.ForEach(x => x.MasterVolume = level);
}
public int GetVolumeLevel()
{
var level = Volume.FirstOrDefault()?.MasterVolume ?? 0;
return (int)(level * 100);
}
}
} }

View File

@ -9,55 +9,113 @@ using System.Threading.Tasks;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
internal class AudioHelper public class AudioHelper
{ {
ActiveAudioSessionWrapper GetSessionForProcess(Process process) ActiveAudioSessionWrapper _current;
{ List<Process> _currentProcesses;
var deviceEnumerator = new MMDeviceEnumerator(Guid.NewGuid());
using var device = deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); public ActiveAudioSessionWrapper FindSession(List<Process> processes)
using var manager = device.AudioSessionManager2; {
var deviceEnumerator = new MMDeviceEnumerator(Guid.NewGuid());
var sessions = manager.Sessions; using var device = deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
using var manager = device.AudioSessionManager2;
foreach (var session in sessions) var sessions = manager.Sessions;
var matchingSession = new ActiveAudioSessionWrapper();
foreach (var session in sessions)
{
var audioProcess = Process.GetProcessById((int)session.ProcessID);
if (processes.Any(x => x.Id == session.ProcessID || x.ProcessName == audioProcess?.ProcessName))
{
try
{
var displayName = audioProcess.MainModule.FileVersionInfo.FileDescription;
if(string.IsNullOrEmpty(displayName))
{
displayName = audioProcess.ProcessName;
}
matchingSession.DisplayName = displayName;
}
catch
{
matchingSession.DisplayName ??= audioProcess.ProcessName;
}
matchingSession.ExecutablePath ??= audioProcess.MainModule.FileName;
//some apps like discord have multiple volume processes.
matchingSession.AddVolume(session.SimpleAudioVolume);
}
}
return matchingSession.Any() ? matchingSession : null;
}
public ActiveAudioSessionWrapper GetActiveSession()
{
var processes = GetPossibleProcesses();
if (_currentProcesses == null || !_currentProcesses.SequenceEqual(processes))
{ {
var audioProcess = Process.GetProcessById((int)session.ProcessID); _current = FindSession(processes);
if (session.ProcessID == process.Id || audioProcess?.ProcessName == process.ProcessName)
{
var displayName = audioProcess.MainModule.FileVersionInfo.FileDescription;
var path = audioProcess.MainModule.FileName;
return new ActiveAudioSessionWrapper()
{
DisplayName = displayName,
ExecutablePath = path,
Volume = session.SimpleAudioVolume
};
}
}
return null;
}
internal ActiveAudioSessionWrapper GetActiveSession()
{
const int nChars = 256;
IntPtr handle = IntPtr.Zero;
StringBuilder Buff = new StringBuilder(nChars);
handle = Native.GetForegroundWindow();
if (handle == IntPtr.Zero)
{
//todo: return system or something like that?
return null;
} }
var tid = Native.GetWindowThreadProcessId(handle, out var pid); _currentProcesses = processes;
var process = Process.GetProcessById(pid); return _current;
}
return GetSessionForProcess(process); /// <summary>
} /// Get the list of processes that might be currently selected
/// This includes getting the child window's processes
///
/// This helps to find the audo process for windows store apps whose process is "ApplicationFrameHost.exe"
///
/// The list may optionally include a parent process, because that helps thing steam to be more reliable because the steamwebhelper (ui) is a child of steam.exe
///
/// According to deej, getting the ForegroundWindow and enumerating steam windows should work, but it doesn't seem to work for me without including the parent process
/// https://github.com/omriharel/deej/blob/master/pkg/deej/util/util_windows.go#L22
///
/// but the parent process is sometimes useless (explorer, svchost, etc) so i filter some of them out because i felt like it when i wrote the code
///
/// I also experimented with grabbing the parent process and enumerating through the windows to see if that would help, but any time the parent process was an unexpected process (explorer) it could blow up. so i decided not to bother for now
/// </summary>
/// <returns></returns>
public List<Process> GetPossibleProcesses()
{
var handle = Native.GetForegroundWindow();
if (handle == IntPtr.Zero)
{
return null;
}
} var ids = Native.GetProcessesOfChildWindows(handle);
Native.GetWindowThreadProcessId(handle, out var pid);
ids.Insert(0, pid);
var processes = ids.Distinct()
.Select(x => Process.GetProcessById(x))
.ToList();
try
{
var blah = ParentProcessUtilities.GetParentProcess(pid);
if (blah != null && blah.ProcessName != "explorer" && blah.ProcessName != "svchost")
{
processes.Add(blah);
}
}
catch
{
}
return processes;
}
}
} }

View File

@ -1,6 +1,7 @@
using BarRaider.SdTools; using BarRaider.SdTools;
using BarRaider.SdTools.Payloads; using BarRaider.SdTools.Payloads;
using CoreAudio; using CoreAudio;
using FocusVolumeControl.UI;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System; using System;
@ -11,14 +12,16 @@ using System.Drawing;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.ServiceModel.Description;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Threading;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
/* /*
todo: todo:
link both discord processes link both discord processes
steam not detecting steam not detecting
@ -27,177 +30,192 @@ namespace FocusVolumeControl
option for what to do when on app without sound option for what to do when on app without sound
gitea
*/ */
[PluginActionId("com.dlprows.focusvolumecontrol.dialaction")] [PluginActionId("com.dlprows.focusvolumecontrol.dialaction")]
public class DialAction : EncoderBase public class DialAction : EncoderBase
{ {
private class PluginSettings private class PluginSettings
{ {
public static PluginSettings CreateDefaultSettings() public static PluginSettings CreateDefaultSettings()
{ {
PluginSettings instance = new PluginSettings(); PluginSettings instance = new PluginSettings();
return instance; return instance;
} }
} }
private PluginSettings settings; private PluginSettings settings;
//IntPtr _foregroundWindowChangedEvent;
//WinEventDelegate _delegate;
ActiveAudioSessionWrapper _currentAudioSession;
AudioHelper _audioHelper = new AudioHelper();
public DialAction(ISDConnection connection, InitialPayload payload) : base(connection, payload) IntPtr _foregroundWindowChangedEvent;
{ Native.WinEventDelegate _delegate;
if (payload.Settings == null || payload.Settings.Count == 0)
{
settings = PluginSettings.CreateDefaultSettings();
SaveSettings();
}
else
{
settings = payload.Settings.ToObject<PluginSettings>();
}
//_delegate = new WinEventDelegate(WinEventProc); ActiveAudioSessionWrapper _currentAudioSession;
//_foregroundWindowChangedEvent = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, _delegate, 0, 0, WINEVENT_OUTOFCONTEXT); AudioHelper _audioHelper = new AudioHelper();
}
public override async void DialDown(DialPayload payload) Thread _thread;
{ Dispatcher _dispatcher;
//dial pressed down
Logger.Instance.LogMessage(TracingLevel.INFO, "Dial Down");
if(_currentAudioSession != null)
{
_currentAudioSession.Volume.Mute = !_currentAudioSession.Volume.Mute;
var uiState = UIState.Build(_currentAudioSession);
await Connection.SetFeedbackAsync(uiState);
}
else
{
await Connection.ShowAlert();
}
}
public override async void TouchPress(TouchpadPressPayload payload) UIState _previousState;
{
Logger.Instance.LogMessage(TracingLevel.INFO, "Touch Press");
if (payload.IsLongPress)
{
//todo: iterate through all sessions setting them back to 100 except the master volume
}
else
{
public DialAction(ISDConnection connection, InitialPayload payload) : base(connection, payload)
{
if (payload.Settings == null || payload.Settings.Count == 0)
{
settings = PluginSettings.CreateDefaultSettings();
SaveSettings();
}
else
{
settings = payload.Settings.ToObject<PluginSettings>();
}
if (_currentAudioSession != null) _thread = new Thread(() =>
{ {
_currentAudioSession.Volume.Mute = !_currentAudioSession.Volume.Mute; Logger.Instance.LogMessage(TracingLevel.DEBUG, "Registering for events");
var uiState = UIState.Build(_currentAudioSession); _delegate = new Native.WinEventDelegate(WinEventProc);
await Connection.SetFeedbackAsync(uiState); _foregroundWindowChangedEvent = Native.RegisterForForegroundWindowChangedEvent(_delegate);
}
else
{
await Connection.ShowAlert();
}
}
}
public override async void DialRotate(DialRotatePayload payload) Logger.Instance.LogMessage(TracingLevel.DEBUG, "Starting Dispatcher");
{ _dispatcher = Dispatcher.CurrentDispatcher;
Logger.Instance.LogMessage(TracingLevel.INFO, "Dial Rotate"); Dispatcher.Run();
//dial rotated. ticks positive for right, negative for left Logger.Instance.LogMessage(TracingLevel.DEBUG, "Dispatcher Stopped");
if(_currentAudioSession != null) });
{ _thread.SetApartmentState(ApartmentState.STA);
_currentAudioSession.Volume.MasterVolume += (0.01f) * payload.Ticks; _thread.Start();
var uiState = UIState.Build(_currentAudioSession); }
await Connection.SetFeedbackAsync(uiState);
}
else
{
await Connection.ShowAlert();
}
}
public override void DialUp(DialPayload payload) public override async void DialDown(DialPayload payload)
{ {
//dial unpressed //dial pressed down
Logger.Instance.LogMessage(TracingLevel.INFO, "Dial Up"); Logger.Instance.LogMessage(TracingLevel.INFO, "Dial Down");
} await ToggleMuteAsync();
}
public override void Dispose() public override async void TouchPress(TouchpadPressPayload payload)
{ {
/* Logger.Instance.LogMessage(TracingLevel.INFO, "Touch Press");
if (payload.IsLongPress)
{
//todo: iterate through all sessions setting them back to 100 except the master volume
}
else
{
await ToggleMuteAsync();
}
}
async Task ToggleMuteAsync()
{
if (_currentAudioSession != null)
{
_currentAudioSession.ToggleMute();
await UpdateStateIfNeeded();
}
else
{
await Connection.ShowAlert();
}
}
public override async void DialRotate(DialRotatePayload payload)
{
Logger.Instance.LogMessage(TracingLevel.INFO, "Dial Rotate");
//dial rotated. ticks positive for right, negative for left
if (_currentAudioSession != null)
{
_currentAudioSession.IncrementVolumeLevel(1, payload.Ticks);
await UpdateStateIfNeeded();
}
else
{
await Connection.ShowAlert();
}
}
public override void DialUp(DialPayload payload)
{
//dial unpressed
Logger.Instance.LogMessage(TracingLevel.INFO, "Dial Up");
}
public override void Dispose()
{
Logger.Instance.LogMessage(TracingLevel.DEBUG, "Disposing");
if(_foregroundWindowChangedEvent != IntPtr.Zero) if(_foregroundWindowChangedEvent != IntPtr.Zero)
{ {
Native.UnhookWinEvent(_foregroundWindowChangedEvent); Native.UnhookWinEvent(_foregroundWindowChangedEvent);
} }
*/ _dispatcher.InvokeShutdown();
} }
public override async void OnTick() public override async void OnTick()
{
//called once every 1000ms and can be used for updating the title/image fo the key
var activeSession = _audioHelper.GetActiveSession();
if (activeSession == null)
{
//todo: something?
}
else
{
_currentAudioSession = activeSession;
}
await UpdateStateIfNeeded();
}
private async Task UpdateStateIfNeeded()
{
if (_currentAudioSession != null)
{
var uiState = UIState.Build(_currentAudioSession);
if ( _previousState != null && uiState != null &&
uiState.Title == _previousState.Title &&
uiState.Value.Value == _previousState.Value.Value &&
uiState.Value.Opacity == _previousState.Value.Opacity &&
uiState.Indicator.Value == _previousState.Indicator.Value &&
uiState.Indicator.Opacity == _previousState.Indicator.Opacity &&
uiState.icon.Value == _previousState.icon.Value &&
uiState.icon.Opacity == _previousState.icon.Opacity
)
{
return;
}
await Connection.SetFeedbackAsync(uiState);
_previousState = uiState;
}
}
public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload)
{
}
public override void ReceivedSettings(ReceivedSettingsPayload payload)
{
Tools.AutoPopulateSettings(settings, payload.Settings);
SaveSettings();
}
private Task SaveSettings()
{
return Connection.SetSettingsAsync(JObject.FromObject(settings));
}
public void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{ {
//called once every 1000ms and can be used for updating the title/image fo the key OnTick();
var activeSession = _audioHelper.GetActiveSession();
if (activeSession == null)
{
//todo: something?
}
else
{
_currentAudioSession = activeSession;
}
if(_currentAudioSession != null)
{
var uiState = UIState.BuildWithImage(_currentAudioSession);
await Connection.SetFeedbackAsync(uiState);
}
} }
public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) }
{
}
public override void ReceivedSettings(ReceivedSettingsPayload payload)
{
Tools.AutoPopulateSettings(settings, payload.Settings);
SaveSettings();
}
private Task SaveSettings()
{
return Connection.SetSettingsAsync(JObject.FromObject(settings));
}
/*
public async void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{
var activeSession = _audioHelper.GetActiveSession();
if(activeSession == null)
{
//todo: something?
}
else
{
_currentAudioSession = activeSession;
//populate the UI
await Connection.SetTitleAsync(_currentAudioSession.DisplayName);
}
}
*/
}
} }

View File

@ -51,6 +51,7 @@
<Reference Include="System.Data" /> <Reference Include="System.Data" />
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
<Reference Include="WindowsBase" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="ActiveAudioSessionWrapper.cs" /> <Compile Include="ActiveAudioSessionWrapper.cs" />
@ -58,10 +59,12 @@
<Compile Include="DialAction.cs" /> <Compile Include="DialAction.cs" />
<Compile Include="ISDConnectionExtensions.cs" /> <Compile Include="ISDConnectionExtensions.cs" />
<Compile Include="Native.cs" /> <Compile Include="Native.cs" />
<Compile Include="ParentProcessUtilities.cs" />
<Compile Include="PluginAction.cs" /> <Compile Include="PluginAction.cs" />
<Compile Include="Program.cs" /> <Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="UIState.cs" /> <Compile Include="UI\UIState.cs" />
<Compile Include="UI\ValueWithOpacity.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="App.config" /> <None Include="App.config" />
@ -112,12 +115,22 @@
<PackageReference Include="CoreAudio"> <PackageReference Include="CoreAudio">
<Version>1.27.0</Version> <Version>1.27.0</Version>
</PackageReference> </PackageReference>
<PackageReference Include="IsExternalInit">
<Version>1.0.3</Version>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json"> <PackageReference Include="Newtonsoft.Json">
<Version>13.0.3</Version> <Version>13.0.3</Version>
</PackageReference> </PackageReference>
<PackageReference Include="NLog"> <PackageReference Include="NLog">
<Version>5.2.3</Version> <Version>5.2.3</Version>
</PackageReference> </PackageReference>
<PackageReference Include="RequiredMemberAttribute">
<Version>1.0.0</Version>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="streamdeck-client-csharp"> <PackageReference Include="streamdeck-client-csharp">
<Version>4.3.0</Version> <Version>4.3.0</Version>
</PackageReference> </PackageReference>

View File

@ -8,11 +8,11 @@ using System.Threading.Tasks;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
internal static class ISDConnectionExtensions internal static class ISDConnectionExtensions
{ {
public static async Task SetFeedbackAsync(this ISDConnection _this, object feedbackPayload) public static async Task SetFeedbackAsync(this ISDConnection _this, object feedbackPayload)
{ {
await _this.SetFeedbackAsync(JObject.FromObject(feedbackPayload)); await _this.SetFeedbackAsync(JObject.FromObject(feedbackPayload));
} }
} }
} }

View File

@ -7,28 +7,58 @@ using System.Threading.Tasks;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
internal class Native public class Native
{ {
internal delegate void WinEventDelegate(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime); public delegate void WinEventDelegate(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
[DllImport("user32.dll")] [DllImport("user32.dll")]
static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventDelegate lpfnWinEventProc, uint idProcess, uint idThread, uint dwFlags); static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventDelegate lpfnWinEventProc, uint idProcess, uint idThread, uint dwFlags);
[DllImport("user32.dll")] [DllImport("user32.dll")]
internal static extern bool UnhookWinEvent(IntPtr hWinEventHook); public static extern bool UnhookWinEvent(IntPtr hWinEventHook);
private const uint WINEVENT_OUTOFCONTEXT = 0; private const uint WINEVENT_OUTOFCONTEXT = 0;
private const uint EVENT_SYSTEM_FOREGROUND = 3; private const uint EVENT_SYSTEM_FOREGROUND = 3;
internal static IntPtr RegisterForForegroundWindowChangedEvent(WinEventDelegate dele) => SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, dele, 0, 0, WINEVENT_OUTOFCONTEXT); public static IntPtr RegisterForForegroundWindowChangedEvent(WinEventDelegate dele) => SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, dele, 0, 0, WINEVENT_OUTOFCONTEXT);
[DllImport("user32.dll")] [DllImport("user32.dll")]
internal static extern IntPtr GetForegroundWindow(); public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId); public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);
}
private delegate bool EnumWindowProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumChildWindows(IntPtr window, EnumWindowProc callback, IntPtr lParam);
public static List<int> GetProcessesOfChildWindows(IntPtr windowHandle)
{
var ids = new List<int>();
if(windowHandle != IntPtr.Zero)
{
EnumChildWindows(windowHandle,
(hWnd, lParam) =>
{
Native.GetWindowThreadProcessId(hWnd, out var pid);
ids.Add(pid);
return true;
}, IntPtr.Zero);
}
return ids;
}
}
} }

View File

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace FocusVolumeControl
{
/// <summary>
/// A utility class to determine a process parent.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct ParentProcessUtilities
{
// These members must match PROCESS_BASIC_INFORMATION
internal IntPtr Reserved1;
internal IntPtr PebBaseAddress;
internal IntPtr Reserved2_0;
internal IntPtr Reserved2_1;
internal IntPtr UniqueProcessId;
internal IntPtr InheritedFromUniqueProcessId;
[DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, ref ParentProcessUtilities processInformation, int processInformationLength, out int returnLength);
/// <summary>
/// Gets the parent process of specified process.
/// </summary>
/// <param name="id">The process id.</param>
/// <returns>An instance of the Process class.</returns>
public static Process GetParentProcess(int id)
{
var process = Process.GetProcessById(id);
return GetParentProcess(process);
}
/// <summary>
/// Gets the parent process of a specified process.
/// </summary>
/// <param name="handle">The process handle.</param>
/// <returns>An instance of the Process class.</returns>
public static Process GetParentProcess(Process process)
{
var data = new ParentProcessUtilities();
int status = NtQueryInformationProcess(process.Handle, 0, ref data, Marshal.SizeOf(data), out var returnLength);
if (status != 0)
{
return null;
}
try
{
return Process.GetProcessById(data.InheritedFromUniqueProcessId.ToInt32());
}
catch
{
return null;
}
}
}
}

View File

@ -10,74 +10,74 @@ using System.Threading.Tasks;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
[PluginActionId("FocusVolumeControl.pluginaction")] [PluginActionId("FocusVolumeControl.pluginaction")]
public class PluginAction : KeypadBase public class PluginAction : KeypadBase
{ {
private class PluginSettings private class PluginSettings
{ {
public static PluginSettings CreateDefaultSettings() public static PluginSettings CreateDefaultSettings()
{ {
PluginSettings instance = new PluginSettings(); PluginSettings instance = new PluginSettings();
instance.OutputFileName = String.Empty; instance.OutputFileName = String.Empty;
instance.InputString = String.Empty; instance.InputString = String.Empty;
return instance; return instance;
} }
[FilenameProperty] [FilenameProperty]
[JsonProperty(PropertyName = "outputFileName")] [JsonProperty(PropertyName = "outputFileName")]
public string OutputFileName { get; set; } public string OutputFileName { get; set; }
[JsonProperty(PropertyName = "inputString")] [JsonProperty(PropertyName = "inputString")]
public string InputString { get; set; } public string InputString { get; set; }
} }
#region Private Members #region Private Members
private PluginSettings settings; private PluginSettings settings;
#endregion #endregion
public PluginAction(SDConnection connection, InitialPayload payload) : base(connection, payload) public PluginAction(SDConnection connection, InitialPayload payload) : base(connection, payload)
{ {
if (payload.Settings == null || payload.Settings.Count == 0) if (payload.Settings == null || payload.Settings.Count == 0)
{ {
this.settings = PluginSettings.CreateDefaultSettings(); this.settings = PluginSettings.CreateDefaultSettings();
SaveSettings(); SaveSettings();
} }
else else
{ {
this.settings = payload.Settings.ToObject<PluginSettings>(); this.settings = payload.Settings.ToObject<PluginSettings>();
} }
} }
public override void Dispose() public override void Dispose()
{ {
Logger.Instance.LogMessage(TracingLevel.INFO, $"Destructor called"); Logger.Instance.LogMessage(TracingLevel.INFO, $"Destructor called");
} }
public override void KeyPressed(KeyPayload payload) public override void KeyPressed(KeyPayload payload)
{ {
Logger.Instance.LogMessage(TracingLevel.INFO, "Key Pressed"); Logger.Instance.LogMessage(TracingLevel.INFO, "Key Pressed");
} }
public override void KeyReleased(KeyPayload payload) { } public override void KeyReleased(KeyPayload payload) { }
public override void OnTick() { } public override void OnTick() { }
public override void ReceivedSettings(ReceivedSettingsPayload payload) public override void ReceivedSettings(ReceivedSettingsPayload payload)
{ {
Tools.AutoPopulateSettings(settings, payload.Settings); Tools.AutoPopulateSettings(settings, payload.Settings);
SaveSettings(); SaveSettings();
} }
public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) { } public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) { }
#region Private Methods #region Private Methods
private Task SaveSettings() private Task SaveSettings()
{ {
return Connection.SetSettingsAsync(JObject.FromObject(settings)); return Connection.SetSettingsAsync(JObject.FromObject(settings));
} }
#endregion #endregion
} }
} }

View File

@ -7,14 +7,14 @@ using System.Threading.Tasks;
namespace FocusVolumeControl namespace FocusVolumeControl
{ {
internal class Program internal class Program
{ {
static void Main(string[] args) static void Main(string[] args)
{ {
// Uncomment this line of code to allow for debugging // Uncomment this line of code to allow for debugging
//while (!System.Diagnostics.Debugger.IsAttached) { System.Threading.Thread.Sleep(100); } //while (!System.Diagnostics.Debugger.IsAttached) { System.Threading.Thread.Sleep(100); }
SDWrapper.Run(args); SDWrapper.Run(args);
} }
} }
} }

View File

@ -0,0 +1,65 @@
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BarRaider.SdTools;
using Newtonsoft.Json;
namespace FocusVolumeControl.UI
{
internal class UIState
{
[JsonProperty("title")]
public string Title { get; private init; }
[JsonProperty("value")]
public ValueWithOpacity<string> Value { get; private init; }
[JsonProperty("indicator")]
public ValueWithOpacity<float>Indicator { get; private init; }
[JsonProperty("icon")]
public ValueWithOpacity<string> icon { get; private init; }
public static UIState Build(ActiveAudioSessionWrapper session)
{
var volume = session.GetVolumeLevel();
var opacity = session.GetMuted() != true ? 1 : 0.5f;
var iconData = "";
if (session.Icon != null)
{
iconData = session.Icon;
}
else
{
try
{
var icon = Icon.ExtractAssociatedIcon(session.ExecutablePath);
iconData = Tools.ImageToBase64(icon.ToBitmap(), true);
}
catch
{
iconData = "Image/pluginIcon.png";
}
session.Icon = iconData;
}
return new UIState()
{
Title = session.DisplayName,
Value = new() { Value = $"{volume}%", Opacity = opacity },
Indicator = new() { Value = volume, Opacity = opacity },
icon = new() { Value = iconData, Opacity = opacity },
};
}
}
}

View File

@ -0,0 +1,20 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Data.SqlTypes;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace FocusVolumeControl.UI
{
internal class ValueWithOpacity<T>
{
[JsonProperty("value")]
public required T Value { get; init; }
[JsonProperty("opacity")]
public required float Opacity { get; init; }
}
}

View File

@ -1,68 +0,0 @@
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BarRaider.SdTools;
namespace FocusVolumeControl
{
internal class UIState
{
public static Dictionary<string, object> Build(ActiveAudioSessionWrapper session)
{
var volume = (int)(session.Volume.MasterVolume * 100);
var valueThing = new Dictionary<string, string>()
{
{ "value", $"{volume}%" },
{ "opacity", "0.5" }
};
var opacity = session.Volume.Mute ? 0.5f : 1;
var payload = new Dictionary<string, object>()
{
{ "indicator", ValueWithOpacity(volume, opacity) },
{ "value", ValueWithOpacity($"{volume}%", opacity ) },
{ "title", session.DisplayName },
};
return payload;
}
public static Dictionary<string, object> ValueWithOpacity(object value, float opacity)
{
return new Dictionary<string, object>()
{
{ "value", value },
{ "opacity", opacity }
};
}
public static Dictionary<string, object> BuildWithImage(ActiveAudioSessionWrapper session)
{
var payload = Build(session);
var opacity = session.Volume.Mute ? 0.5f : 1;
var iconData = "";
try
{
var icon = Icon.ExtractAssociatedIcon(session.ExecutablePath);
iconData = Tools.ImageToBase64(icon.ToBitmap(), true);
}
catch
{
iconData = "Image/pluginIcon.png";
}
payload["icon"] = ValueWithOpacity(iconData, opacity);
return payload;
}
}
}

View File

@ -29,7 +29,7 @@
"PropertyInspectorPath": "PropertyInspector/PluginActionPI.html" "PropertyInspectorPath": "PropertyInspector/PluginActionPI.html"
} }
], ],
"Author": "Daniel Prows", "Author": "dlprows",
"Name": "FocusVolumeControl", "Name": "FocusVolumeControl",
"Description": "Control the volume of the focused application", "Description": "Control the volume of the focused application",
"URL": "https://encyclopediaofdaniel.com", "URL": "https://encyclopediaofdaniel.com",

View File

@ -5,11 +5,15 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:SoundBrowser" xmlns:local="clr-namespace:SoundBrowser"
mc:Ignorable="d" mc:Ignorable="d"
Title="MainWindow" Height="150" Width="800"> Title="MainWindow" Height="800" Width="800">
<Grid> <Grid>
<StackPanel> <Grid.RowDefinitions>
<TextBlock x:Name="_tf">blah</TextBlock> <RowDefinition Height="auto"/>
</StackPanel> <RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock x:Name="_tf" Grid.Row="0">current</TextBlock>
<TextBlock x:Name="_tf2" Grid.Row="1">list</TextBlock>
</Grid> </Grid>
</Window> </Window>

View File

@ -1,9 +1,11 @@
using CoreAudio; using CoreAudio;
using FocusVolumeControl;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Management;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -19,98 +21,115 @@ using System.Windows.Shapes;
namespace SoundBrowser namespace SoundBrowser
{ {
/// <summary> /// <summary>
/// Interaction logic for MainWindow.xaml /// Interaction logic for MainWindow.xaml
/// </summary> /// </summary>
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
public MainWindow()
{ AudioHelper _audioHelper;
InitializeComponent(); Native.WinEventDelegate _delegate;
dele = new WinEventDelegate(WinEventProc);
IntPtr m_hhook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, dele, 0, 0, WINEVENT_OUTOFCONTEXT); public MainWindow()
} {
InitializeComponent();
_audioHelper = new AudioHelper();
//normally you can just pass a lambda, but for some reason, that seems to get garbage collected
_delegate = new Native.WinEventDelegate(WinEventProc);
Native.RegisterForForegroundWindowChangedEvent(_delegate);
}
public void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{
SetupCurrentAppFields();
SetupAllSessionFields();
}
private void SetupCurrentAppFields()
{
var handle = Native.GetForegroundWindow();
var sb = new StringBuilder();
if (handle != IntPtr.Zero)
{
//use this in debug to help there be less events
/*
Native.GetWindowThreadProcessId(handle, out var fpid);
var fp = Process.GetProcessById(fpid);
if(!fp.ProcessName.Contains("FSD"))
{
return;
}
*/
var processes = _audioHelper.GetPossibleProcesses();
var session = _audioHelper.FindSession(processes);
foreach (var p in processes)
{
sb.AppendLine($"pid: {p.Id}");
sb.AppendLine($"\tprocessName: {p.ProcessName}");
try
{
sb.AppendLine($"\tFileDescription: {p!.MainModule!.FileVersionInfo.FileDescription}");
}
catch
{
sb.AppendLine("\tFileDescription: ##ERROR##");
}
WinEventDelegate dele = null; }
sb.AppendLine();
if (session != null)
{
sb.AppendLine("picked the following best match");
sb.AppendLine($"\tsession: {session.DisplayName}");
sb.AppendLine($"\tvolume: {session.GetVolumeLevel()}");
sb.AppendLine($"\tcount: {session.Count}");
}
else
{
sb.AppendLine("No Match");
}
}
_tf.Text = sb.ToString();
}
private void SetupAllSessionFields()
{
_tf2.Text = "";
var sb = new StringBuilder();
sb.AppendLine("-------------------------------------------------------------------------------");
var deviceEnumerator = new MMDeviceEnumerator(Guid.NewGuid());
using var device = deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
using var manager = device.AudioSessionManager2;
var sessions = manager!.Sessions;
foreach (var session in sessions!)
{
var audioProcess = Process.GetProcessById((int)session.ProcessID);
var displayName = audioProcess!.MainModule!.FileVersionInfo.FileDescription;
sb.AppendLine($"pid: {audioProcess.Id}");
sb.AppendLine($"\tprocessName: {audioProcess.ProcessName}");
sb.AppendLine($"\tsession: {displayName}");
}
_tf2.Text = sb.ToString();
}
delegate void WinEventDelegate(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime); }
[DllImport("user32.dll")]
static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventDelegate lpfnWinEventProc, uint idProcess, uint idThread, uint dwFlags);
private const uint WINEVENT_OUTOFCONTEXT = 0;
private const uint EVENT_SYSTEM_FOREGROUND = 3;
[DllImport("user32.dll")]
static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);
[DllImport("user32.dll")]
static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
private static SimpleAudioVolume GetVolumeObject(Process process)
{
var deviceEnumerator = new MMDeviceEnumerator(Guid.NewGuid());
using var device = deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
using var manager = device.AudioSessionManager2;
var sessions = manager.Sessions;
foreach (var session in sessions)
{
if (session.ProcessID == process.Id)
{
return session.SimpleAudioVolume;
}
var audioProcess = Process.GetProcessById((int)session.ProcessID);
if(audioProcess?.ProcessName == process.ProcessName)
{
Console.WriteLine(process.MainModule.FileVersionInfo.FileDescription);
return session.SimpleAudioVolume;
}
}
return null;
}
SimpleAudioVolume _current;
private string GetActiveWindowTitle()
{
const int nChars = 256;
IntPtr handle = IntPtr.Zero;
StringBuilder Buff = new StringBuilder(nChars);
handle = GetForegroundWindow();
if (handle <= 0)
{
return "";
}
var tid = GetWindowThreadProcessId(handle, out var pid);
var process = Process.GetProcessById(pid);
var vol = GetVolumeObject(process);
_current = vol;
if(vol != null)
{
vol.Mute = true;
}
return $"{pid} vol:{vol?.MasterVolume ?? -1} - {process.ProcessName}";
}
public void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
{
_tf.Text = GetActiveWindowTitle();
}
}
} }

View File

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace SoundBrowser
{
/// <summary>
/// A utility class to determine a process parent.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct ParentProcessUtilities
{
// These members must match PROCESS_BASIC_INFORMATION
internal IntPtr Reserved1;
internal IntPtr PebBaseAddress;
internal IntPtr Reserved2_0;
internal IntPtr Reserved2_1;
internal IntPtr UniqueProcessId;
internal IntPtr InheritedFromUniqueProcessId;
[DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, ref ParentProcessUtilities processInformation, int processInformationLength, out int returnLength);
/// <summary>
/// Gets the parent process of specified process.
/// </summary>
/// <param name="id">The process id.</param>
/// <returns>An instance of the Process class.</returns>
public static Process? GetParentProcess(int id)
{
Process process = Process.GetProcessById(id);
return GetParentProcess(process.Handle);
}
/// <summary>
/// Gets the parent process of a specified process.
/// </summary>
/// <param name="handle">The process handle.</param>
/// <returns>An instance of the Process class.</returns>
public static Process? GetParentProcess(IntPtr handle)
{
ParentProcessUtilities pbi = new ParentProcessUtilities();
int returnLength;
int status = NtQueryInformationProcess(handle, 0, ref pbi, Marshal.SizeOf(pbi), out returnLength);
if (status != 0)
throw new Win32Exception(status);
try
{
return Process.GetProcessById(pbi.InheritedFromUniqueProcessId.ToInt32());
}
catch (ArgumentException)
{
// not found
return null;
}
}
}
}

View File

@ -11,4 +11,8 @@
<PackageReference Include="CoreAudio" Version="1.27.0" /> <PackageReference Include="CoreAudio" Version="1.27.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FocusVolumeControl\FocusVolumeControl.csproj" />
</ItemGroup>
</Project> </Project>