diff --git a/dvmconsole/Controls/ChannelBox.xaml.cs b/dvmconsole/Controls/ChannelBox.xaml.cs
index cff31b0..48a10c5 100644
--- a/dvmconsole/Controls/ChannelBox.xaml.cs
+++ b/dvmconsole/Controls/ChannelBox.xaml.cs
@@ -619,6 +619,14 @@ namespace dvmconsole.Controls
selectedChannelsManager.RemoveSelectedChannel(this);
}
+ public void TriggerPTTState(bool pttState)
+ {
+ if (!IsSelected)
+ return;
+ PttState = pttState;
+ PTTButtonClicked?.Invoke(null, this);
+ }
+
///
///
///
diff --git a/dvmconsole/GlobalKeyboardHook.cs b/dvmconsole/GlobalKeyboardHook.cs
new file mode 100644
index 0000000..ba0a727
--- /dev/null
+++ b/dvmconsole/GlobalKeyboardHook.cs
@@ -0,0 +1,235 @@
+// SPDX-License-Identifier: CC-BY-SA-4.0
+/**
+* Digital Voice Modem - Desktop Dispatch Console
+* CC-BY-SA-4.0 License. Use is subject to license terms.
+* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+*
+* @package DVM / Desktop Dispatch Console
+* @license CC-BY-SA-4.0 License (https://creativecommons.org/licenses/by-sa/4.0/legalcode)
+* Code copied in its entirety from https://stackoverflow.com/a/57710850
+*
+*/
+
+
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Windows.Forms;
+
+namespace dvmconsole;
+
+public class GlobalKeyboardHookEventArgs : HandledEventArgs
+{
+ public GlobalKeyboardHook.KeyboardState KeyboardState { get; private set; }
+ public GlobalKeyboardHook.LowLevelKeyboardInputEvent KeyboardData { get; private set; }
+
+ public GlobalKeyboardHookEventArgs(
+ GlobalKeyboardHook.LowLevelKeyboardInputEvent keyboardData,
+ GlobalKeyboardHook.KeyboardState keyboardState)
+ {
+ KeyboardData = keyboardData;
+ KeyboardState = keyboardState;
+ }
+}
+
+//Based on https://gist.github.com/Stasonix
+public class GlobalKeyboardHook : IDisposable
+{
+ public event EventHandler KeyboardPressed;
+
+ // EDT: Added an optional parameter (registeredKeys) that accepts keys to restict
+ // the logging mechanism.
+ ///
+ ///
+ ///
+ /// Keys that should trigger logging. Pass null for full logging.
+ public GlobalKeyboardHook(Keys[] registeredKeys = null)
+ {
+ RegisteredKeys = registeredKeys;
+ _windowsHookHandle = IntPtr.Zero;
+ _user32LibraryHandle = IntPtr.Zero;
+ HookProcHandle = LowLevelKeyboardProc; // we must keep alive _hookProc, because GC is not aware about SetWindowsHookEx behaviour.
+
+ _user32LibraryHandle = LoadLibrary("User32");
+ if (_user32LibraryHandle == IntPtr.Zero)
+ {
+ int errorCode = Marshal.GetLastWin32Error();
+ throw new Win32Exception(errorCode, $"Failed to load library 'User32.dll'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
+ }
+
+
+
+ _windowsHookHandle = SetWindowsHookEx(WH_KEYBOARD_LL, HookProcHandle, _user32LibraryHandle, 0);
+ if (_windowsHookHandle == IntPtr.Zero)
+ {
+ int errorCode = Marshal.GetLastWin32Error();
+ throw new Win32Exception(errorCode, $"Failed to adjust keyboard hooks for '{Process.GetCurrentProcess().ProcessName}'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // because we can unhook only in the same thread, not in garbage collector thread
+ if (_windowsHookHandle != IntPtr.Zero)
+ {
+ if (!UnhookWindowsHookEx(_windowsHookHandle))
+ {
+ int errorCode = Marshal.GetLastWin32Error();
+ throw new Win32Exception(errorCode, $"Failed to remove keyboard hooks for '{Process.GetCurrentProcess().ProcessName}'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
+ }
+ _windowsHookHandle = IntPtr.Zero;
+
+ // ReSharper disable once DelegateSubtraction
+ HookProcHandle -= LowLevelKeyboardProc;
+ }
+ }
+
+ if (_user32LibraryHandle != IntPtr.Zero)
+ {
+ if (!FreeLibrary(_user32LibraryHandle)) // reduces reference to library by 1.
+ {
+ int errorCode = Marshal.GetLastWin32Error();
+ throw new Win32Exception(errorCode, $"Failed to unload library 'User32.dll'. Error {errorCode}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}.");
+ }
+ _user32LibraryHandle = IntPtr.Zero;
+ }
+ }
+
+ ~GlobalKeyboardHook()
+ {
+ Dispose(false);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ private IntPtr _windowsHookHandle;
+ private IntPtr _user32LibraryHandle;
+ public static HookProc HookProcHandle;
+
+ public delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr LoadLibrary(string lpFileName);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Auto)]
+ private static extern bool FreeLibrary(IntPtr hModule);
+
+ ///
+ /// The SetWindowsHookEx function installs an application-defined hook procedure into a hook chain.
+ /// You would install a hook procedure to monitor the system for certain types of events. These events are
+ /// associated either with a specific thread or with all threads in the same desktop as the calling thread.
+ ///
+ /// hook type
+ /// hook procedure
+ /// handle to application instance
+ /// thread identifier
+ /// If the function succeeds, the return value is the handle to the hook procedure.
+ [DllImport("USER32", SetLastError = true)]
+ static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, int dwThreadId);
+
+ ///
+ /// The UnhookWindowsHookEx function removes a hook procedure installed in a hook chain by the SetWindowsHookEx function.
+ ///
+ /// handle to hook procedure
+ /// If the function succeeds, the return value is true.
+ [DllImport("USER32", SetLastError = true)]
+ public static extern bool UnhookWindowsHookEx(IntPtr hHook);
+
+ ///
+ /// The CallNextHookEx function passes the hook information to the next hook procedure in the current hook chain.
+ /// A hook procedure can call this function either before or after processing the hook information.
+ ///
+ /// handle to current hook
+ /// hook code passed to hook procedure
+ /// value passed to hook procedure
+ /// value passed to hook procedure
+ /// If the function succeeds, the return value is true.
+ [DllImport("USER32", SetLastError = true)]
+ static extern IntPtr CallNextHookEx(IntPtr hHook, int code, IntPtr wParam, IntPtr lParam);
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct LowLevelKeyboardInputEvent
+ {
+ ///
+ /// A virtual-key code. The code must be a value in the range 1 to 254.
+ ///
+ public int VirtualCode;
+
+ // EDT: added a conversion from VirtualCode to Keys.
+ ///
+ /// The VirtualCode converted to typeof(Keys) for higher usability.
+ ///
+ public Keys Key { get { return (Keys)VirtualCode; } }
+
+ ///
+ /// A hardware scan code for the key.
+ ///
+ public int HardwareScanCode;
+
+ ///
+ /// The extended-key flag, event-injected Flags, context code, and transition-state flag. This member is specified as follows. An application can use the following values to test the keystroke Flags. Testing LLKHF_INJECTED (bit 4) will tell you whether the event was injected. If it was, then testing LLKHF_LOWER_IL_INJECTED (bit 1) will tell you whether or not the event was injected from a process running at lower integrity level.
+ ///
+ public int Flags;
+
+ ///
+ /// The time stamp stamp for this message, equivalent to what GetMessageTime would return for this message.
+ ///
+ public int TimeStamp;
+
+ ///
+ /// Additional information associated with the message.
+ ///
+ public IntPtr AdditionalInformation;
+ }
+
+ public const int WH_KEYBOARD_LL = 13;
+ //const int HC_ACTION = 0;
+
+ public enum KeyboardState
+ {
+ KeyDown = 0x0100,
+ KeyUp = 0x0101,
+ SysKeyDown = 0x0104,
+ SysKeyUp = 0x0105
+ }
+
+ // EDT: Replaced VkSnapshot(int) with RegisteredKeys(Keys[])
+ public Keys[] RegisteredKeys;
+ const int KfAltdown = 0x2000;
+ public const int LlkhfAltdown = (KfAltdown >> 8);
+ EventHandler handler;
+ public IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam)
+ {
+ bool fEatKeyStroke = false;
+
+ var wparamTyped = wParam.ToInt32();
+
+ handler = KeyboardPressed;
+ if (Enum.IsDefined(typeof(KeyboardState), wparamTyped))
+ {
+ object o = Marshal.PtrToStructure(lParam, typeof(LowLevelKeyboardInputEvent));
+ LowLevelKeyboardInputEvent p = (LowLevelKeyboardInputEvent)o;
+
+ var eventArguments = new GlobalKeyboardHookEventArgs(p, (KeyboardState)wparamTyped);
+
+ // EDT: Removed the comparison-logic from the usage-area so the user does not need to mess around with it.
+ // Either the incoming key has to be part of RegisteredKeys (see constructor on top) or RegisterdKeys
+ // has to be null for the event to get fired.
+ var key = (Keys)p.VirtualCode;
+ if (RegisteredKeys == null || RegisteredKeys.Contains(key))
+ {
+ handler?.Invoke(this, eventArguments);
+
+ fEatKeyStroke = eventArguments.Handled;
+ }
+ }
+
+ return fEatKeyStroke ? (IntPtr)1 : CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
+ }
+}
\ No newline at end of file
diff --git a/dvmconsole/KeyboardManager.cs b/dvmconsole/KeyboardManager.cs
new file mode 100644
index 0000000..a3eba72
--- /dev/null
+++ b/dvmconsole/KeyboardManager.cs
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/**
+* Digital Voice Modem - Desktop Dispatch Console
+* AGPLv3 Open Source. Use is subject to license terms.
+* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+*
+* @package DVM / Desktop Dispatch Console
+* @license AGPLv3 License (https://opensource.org/licenses/AGPL-3.0)
+*
+* Copyright (C) 2025 Steven Jennison, KD8RHO
+*
+*/
+
+using System.Collections.ObjectModel;
+using System.Windows.Forms;
+
+namespace dvmconsole;
+
+public class KeyboardManager
+{
+
+ /*
+ ** Properties
+ */
+ public bool IsListening { get; private set; } = false;
+
+ public event Action OnKeyEvent;
+
+ private GlobalKeyboardHook listenHook;
+ private GlobalKeyboardHook.HookProc hookProcHandle;
+ /*
+ ** Methods
+ */
+ public void SetListenKeys(List keys)
+ {
+ if (listenHook == null)
+ {
+ listenHook = new GlobalKeyboardHook(keys.ToArray());
+ listenHook.KeyboardPressed += ListenHookOnKeyboardEvent;
+ hookProcHandle = GlobalKeyboardHook.HookProcHandle;
+ }
+ else
+ {
+ listenHook.RegisteredKeys = keys.ToArray();
+ }
+ }
+
+ private void ListenHookOnKeyboardEvent(object sender, GlobalKeyboardHookEventArgs e)
+ {
+ OnKeyEvent?.Invoke(e.KeyboardData.Key, e.KeyboardState);
+ }
+
+
+ ///
+ /// Gets the next key pressed globally, for use with user dialogs
+ ///
+ /// The next key pressed
+ public async Task GetNextKeyPress()
+ {
+ GlobalKeyboardHook universalHook = new GlobalKeyboardHook();
+ Keys? result = null;
+
+ universalHook.KeyboardPressed += onUniversalHookOnKeyboardPressed;
+ while (result == null)
+ {
+ await Task.Delay(100);
+ }
+
+ universalHook.KeyboardPressed -= onUniversalHookOnKeyboardPressed;
+ return result.Value;
+
+ void onUniversalHookOnKeyboardPressed(object sender, GlobalKeyboardHookEventArgs args)
+ {
+ if (args.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown)
+ {
+ result = args.KeyboardData.Key;
+ // Stop listening for key presses on first key pressed
+ universalHook.KeyboardPressed -= onUniversalHookOnKeyboardPressed;
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/dvmconsole/MainWindow.xaml b/dvmconsole/MainWindow.xaml
index 114ee68..a878451 100644
--- a/dvmconsole/MainWindow.xaml
+++ b/dvmconsole/MainWindow.xaml
@@ -35,6 +35,7 @@
diff --git a/dvmconsole/MainWindow.xaml.cs b/dvmconsole/MainWindow.xaml.cs
index 0448ba0..09bb758 100644
--- a/dvmconsole/MainWindow.xaml.cs
+++ b/dvmconsole/MainWindow.xaml.cs
@@ -18,12 +18,10 @@ using System.IO;
using System.Timers;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Forms;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
-
-using Microsoft.Win32;
-
using NAudio.Wave;
using NWaves.Signals;
@@ -40,6 +38,11 @@ using fnecore.DMR;
using fnecore.P25;
using fnecore.P25.KMM;
using fnecore.P25.LC.TSBK;
+using Application = System.Windows.Application;
+using Cursors = System.Windows.Input.Cursors;
+using MessageBox = System.Windows.MessageBox;
+using MouseEventArgs = System.Windows.Input.MouseEventArgs;
+using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
using System.Net.Sockets;
namespace dvmconsole
@@ -123,6 +126,7 @@ namespace dvmconsole
private FneSystemManager fneSystemManager = new FneSystemManager();
private bool selectAll = false;
+ private KeyboardManager keyboardManager;
private CancellationTokenSource maintainenceCancelToken = new CancellationTokenSource();
private Task maintainenceTask = null;
@@ -146,13 +150,14 @@ namespace dvmconsole
public MainWindow()
{
InitializeComponent();
-
+ this.keyboardManager = new KeyboardManager();
MinWidth = Width = MIN_WIDTH;
MinHeight = Height = MIN_HEIGHT;
DisableControls();
settingsManager.LoadSettings();
+ InitializeKeyboardShortcuts();
callHistoryWindow = new CallHistoryWindow(settingsManager);
selectedChannelsManager = new SelectedChannelsManager();
@@ -1169,6 +1174,7 @@ namespace dvmconsole
menuToggleLockWidgets.IsChecked = settingsManager.LockWidgets;
menuSnapCallHistory.IsChecked = settingsManager.SnapCallHistoryToWindow;
menuTogglePTTMode.IsChecked = settingsManager.TogglePTTMode;
+ menuToggleGlobalPTTMode.IsChecked = settingsManager.GlobalPTTKeysAllChannels;
menuKeepWindowOnTop.IsChecked = settingsManager.KeepWindowOnTop;
if (!string.IsNullOrEmpty(settingsManager.LastCodeplugPath) && File.Exists(settingsManager.LastCodeplugPath))
@@ -2029,7 +2035,7 @@ namespace dvmconsole
}
///
- ///
+ /// Activates Global PTT after a click or keyboard shortcut
///
///
///
@@ -2103,7 +2109,7 @@ namespace dvmconsole
///
///
///
- private async void btnGlobalPtt_Click(object sender, RoutedEventArgs e)
+ private async void GlobalPTTActivate(object sender, RoutedEventArgs e)
{
if (globalPttState)
await Task.Delay(500);
@@ -2119,8 +2125,16 @@ namespace dvmconsole
else
btnGlobalPtt.Background = btnGlobalPttDefaultBg;
});
+
+ primaryChannel.TriggerPTTState(globalPttState);
+
+ return;
+ }
- primaryChannel.PttButton_Click(sender, e);
+
+ // Check for global PTT keys all preference, if not enabled, return early
+ if (!settingsManager.GlobalPTTKeysAllChannels)
+ {
return;
}
@@ -2193,7 +2207,7 @@ namespace dvmconsole
globalPttState = !globalPttState;
- btnGlobalPtt_Click(sender, e);
+ GlobalPTTActivate(sender, e);
}
///
@@ -2207,12 +2221,12 @@ namespace dvmconsole
if (settingsManager.TogglePTTMode)
{
globalPttState = !globalPttState;
- btnGlobalPtt_Click(sender, e);
+ GlobalPTTActivate(sender, e);
}
else
{
globalPttState = true;
- btnGlobalPtt_Click(sender, e);
+ GlobalPTTActivate(sender, e);
}
}
@@ -2228,7 +2242,7 @@ namespace dvmconsole
return;
globalPttState = false;
- btnGlobalPtt_Click(sender, e);
+ GlobalPTTActivate(sender, e);
}
///
@@ -2406,5 +2420,87 @@ namespace dvmconsole
});
}
}
+
+ ///
+ /// Sets the global PTT keybind
+ /// Hooks a listener to listen for a keypress, then saves that as the global PTT keybind
+ /// Global PTT keybind is effectively the same as pressing the Global PTT button
+ ///
+ ///
+ ///
+ ///
+ private async void SetGlobalPTTKeybind(object sender, RoutedEventArgs e)
+ {
+
+ // Create and show a MessageBox with no buttons or standard close behavior
+ Window messageBox = new Window
+ {
+ Width = 500,
+ Height = 150,
+ WindowStyle = WindowStyle.None,
+ ShowInTaskbar = false,
+ ResizeMode = ResizeMode.NoResize,
+ Topmost = true,
+ Background = System.Windows.Media.Brushes.White,
+ Content = new System.Windows.Controls.TextBlock
+ {
+ Text = "Press any key to set the Global PTT shortcut...",
+ HorizontalAlignment = System.Windows.HorizontalAlignment.Center,
+ VerticalAlignment = System.Windows.VerticalAlignment.Center,
+ FontSize = 16,
+ FontWeight = System.Windows.FontWeights.Bold,
+ }
+ };
+
+ // Center messageBox on the main window
+ messageBox.Owner = this; // Set the current window as owner
+ messageBox.WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+ // Open and close the MessageBox after 500ms
+ messageBox.Show();
+ Keys keyPress = await keyboardManager.GetNextKeyPress();
+ messageBox.Close();
+ settingsManager.GlobalPTTShortcut = keyPress;
+ InitializeKeyboardShortcuts();
+ settingsManager.SaveSettings();
+ MessageBox.Show("Global PTT shortcut set to " + keyPress.ToString(), "Success", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+
+
+ ///
+ /// Initializes global keyboard shortcut listener
+ ///
+ private void InitializeKeyboardShortcuts()
+ {
+ var listeningKeys = new List { settingsManager.GlobalPTTShortcut };
+ keyboardManager.SetListenKeys(listeningKeys);
+ // Clear event listener
+ keyboardManager.OnKeyEvent -= KeyboardManagerOnKeyEvent;
+ // Re-add listener
+ keyboardManager.OnKeyEvent += KeyboardManagerOnKeyEvent;
+ }
+
+ private void KeyboardManagerOnKeyEvent(Keys pressedKey,GlobalKeyboardHook.KeyboardState state)
+ {
+ if (pressedKey == settingsManager.GlobalPTTShortcut)
+ {
+ if(state is GlobalKeyboardHook.KeyboardState.KeyDown or GlobalKeyboardHook.KeyboardState.SysKeyDown)
+ {
+ globalPttState = true;
+ GlobalPTTActivate(null, null);
+ }
+ else
+ {
+ globalPttState = false;
+ GlobalPTTActivate(null, null);
+ }
+ }
+ }
+
+ private void ToggleGlobalPTTAllChannels_Click(object sender, RoutedEventArgs e)
+ {
+ settingsManager.GlobalPTTKeysAllChannels = !settingsManager.GlobalPTTKeysAllChannels;
+ }
} // public partial class MainWindow : Window
} // namespace dvmconsole
diff --git a/dvmconsole/SettingsManager.cs b/dvmconsole/SettingsManager.cs
index b6ac853..b565310 100644
--- a/dvmconsole/SettingsManager.cs
+++ b/dvmconsole/SettingsManager.cs
@@ -9,6 +9,7 @@
*
* Copyright (C) 2024-2025 Caleb, K4PHP
* Copyright (C) 2025 Bryan Biedenkapp, N2PLL
+* Copyright (C) 2025 Steven Jennison, KD8RHO
*
*/
@@ -16,6 +17,7 @@ using System.Diagnostics;
using System.IO;
using System.Reflection;
+using System.Windows.Forms;
using Newtonsoft.Json;
using fnecore.Utility;
@@ -135,6 +137,11 @@ namespace dvmconsole
///
public bool SaveTraceLog { get; set; }
+
+ public Keys GlobalPTTShortcut { get; set; } = Keys.None;
+
+
+ public bool GlobalPTTKeysAllChannels { get; set; }
/*
** Methods
*/
@@ -166,6 +173,7 @@ namespace dvmconsole
if (loadedSettings != null)
{
+ GlobalPTTKeysAllChannels = loadedSettings.GlobalPTTKeysAllChannels;
ShowSystemStatus = loadedSettings.ShowSystemStatus;
ShowChannels = loadedSettings.ShowChannels;
ShowAlertTones = loadedSettings.ShowAlertTones;
@@ -202,6 +210,8 @@ namespace dvmconsole
UserBackgroundImage = loadedSettings.UserBackgroundImage;
SaveTraceLog = loadedSettings.SaveTraceLog;
+ GlobalPTTShortcut = loadedSettings.GlobalPTTShortcut;
+
if (SaveTraceLog)
Log.SetupTextWriter(Environment.CurrentDirectory, "dvmconsole.log");