Skip to content

Commit ff5dd2d

Browse files
committed
Refactor: Rename player hotkey manager to GlobalMediaKeysManager and optimize speech logic
1 parent 063a9df commit ff5dd2d

File tree

4 files changed

+147
-30
lines changed

4 files changed

+147
-30
lines changed

AudioShelf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ def handle_uncaught_exception(exc_type, exc_value, exc_traceback):
9595
try:
9696
app = wx.GetApp()
9797
if app and hasattr(app, 'player_frame_instance') and app.player_frame_instance:
98-
if hasattr(app.player_frame_instance, 'hotkey_manager') and app.player_frame_instance.hotkey_manager:
99-
app.player_frame_instance.hotkey_manager.unregister_hotkeys()
98+
if hasattr(app.player_frame_instance, 'global_keys_manager') and app.player_frame_instance.global_keys_manager:
99+
app.player_frame_instance.global_keys_manager.unregister_hotkeys()
100100
except Exception as e:
101101
logging.error(f"Failed to cleanup during crash: {e}")
102102

frames/player/event_handlers.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,16 @@
88
from typing import Optional, TYPE_CHECKING
99

1010
from database import db_manager
11-
from nvda_controller import set_app_focus_status
11+
from nvda_controller import set_app_focus_status, speak, LEVEL_MINIMAL
1212

1313
if TYPE_CHECKING:
1414
from ..player_frame import PlayerFrame
1515

1616

1717
def on_engine_file_changed(frame: 'PlayerFrame', event, new_engine_index: int):
18-
"""
19-
Handles the 'on_file_changed' event from the engine.
20-
Updates the frame state, resets loops, and announces the new file.
21-
"""
2218
if new_engine_index < 0 or frame.is_exiting:
2319
return
2420

25-
# Reset Loop State
2621
if frame.loop_point_a_ms is not None:
2722
frame.loop_point_a_ms = None
2823
if frame.is_file_looping:
@@ -46,21 +41,18 @@ def on_engine_file_changed(frame: 'PlayerFrame', event, new_engine_index: int):
4641
logging.error("Critical Error: Invalid file index in book data")
4742
return
4843

49-
# Update NVDA focus label if applicable
5044
try:
51-
if frame.nvda_focus_label and frame.current_file_path:
45+
if frame.current_file_path:
5246
file_name = os.path.basename(frame.current_file_path)
53-
frame.nvda_focus_label.SetLabel(file_name)
47+
frame.update_file_display(file_name)
5448
except Exception:
5549
pass
5650

57-
# Update Duration
5851
try:
5952
frame.current_file_duration_ms = frame.book_file_durations[frame.current_file_index]
6053
except IndexError:
6154
frame.current_file_duration_ms = 0
6255

63-
# Reset start position for next file
6456
if frame.start_pos_ms > 0:
6557
frame.start_pos_ms = 0
6658

@@ -172,8 +164,8 @@ def on_escape(frame: 'PlayerFrame', event=None):
172164
frame.is_exiting = True
173165

174166
# Cleanup resources
175-
if hasattr(frame, 'hotkey_manager') and frame.hotkey_manager:
176-
frame.hotkey_manager.unregister_hotkeys()
167+
if hasattr(frame, 'global_keys_manager') and frame.global_keys_manager:
168+
frame.global_keys_manager.unregister_hotkeys()
177169

178170
frame.ui_timer.Stop()
179171

frames/player/global_media_keys.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frames/player/global_media_keys.py
2+
# Copyright (c) 2025 Mehdi Rajabi
3+
# License: GNU General Public License v3.0 (See LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
4+
5+
import wx
6+
import logging
7+
from typing import Dict, Callable
8+
9+
if wx.Platform == '__WXMSW__':
10+
try:
11+
import ctypes
12+
from ctypes import wintypes
13+
user32 = ctypes.windll.user32
14+
MOD_NONE = 0x0000
15+
16+
VK_MEDIA_PLAY_PAUSE = 0xB3
17+
VK_MEDIA_NEXT_TRACK = 0xB0
18+
VK_MEDIA_PREV_TRACK = 0xB1
19+
VK_VOLUME_UP = 0xAF
20+
VK_VOLUME_DOWN = 0xAE
21+
VK_VOLUME_MUTE = 0xAD
22+
VK_BROWSER_BACK = 0xA6
23+
VK_BROWSER_FORWARD = 0xA7
24+
except (ImportError, AttributeError):
25+
logging.error("Failed to import ctypes or load user32.dll. Global hotkeys disabled.")
26+
user32 = None
27+
VK_MEDIA_PLAY_PAUSE = VK_MEDIA_NEXT_TRACK = VK_MEDIA_PREV_TRACK = 0
28+
VK_VOLUME_UP = VK_VOLUME_DOWN = VK_VOLUME_MUTE = 0
29+
VK_BROWSER_BACK = VK_BROWSER_FORWARD = 0
30+
else:
31+
logging.info("Native global hotkeys are only implemented for Windows.")
32+
user32 = None
33+
VK_MEDIA_PLAY_PAUSE = VK_MEDIA_NEXT_TRACK = VK_MEDIA_PREV_TRACK = 0
34+
VK_VOLUME_UP = VK_VOLUME_DOWN = VK_VOLUME_MUTE = 0
35+
VK_BROWSER_BACK = VK_BROWSER_FORWARD = 0
36+
37+
38+
class GlobalMediaKeysManager:
39+
"""
40+
Manages the registration and handling of system-wide global hotkeys using
41+
native Windows API calls (user32.dll).
42+
"""
43+
44+
def __init__(self, frame: wx.Frame):
45+
self.frame = frame
46+
self.hwnd = self.frame.GetHandle()
47+
self.hotkey_map: Dict[int, Callable] = {}
48+
self.next_hotkey_id: int = 1
49+
50+
if not self.hwnd:
51+
logging.error("GlobalMediaKeysManager: Cannot register hotkeys, window handle (HWND) is None.")
52+
53+
def _register(self, vk_code: int, callback: Callable):
54+
"""Registers a single global hotkey with the OS."""
55+
if not user32 or not self.hwnd:
56+
return
57+
58+
hk_id = self.next_hotkey_id
59+
self.next_hotkey_id += 1
60+
61+
if not user32.RegisterHotKey(self.hwnd, hk_id, MOD_NONE, vk_code):
62+
error_code = ctypes.GetLastError()
63+
logging.error(f"Failed to register hotkey ID {hk_id} (VK: {vk_code}). Error: {error_code}")
64+
else:
65+
logging.info(f"Registered hotkey ID {hk_id} (VK: {vk_code})")
66+
self.hotkey_map[hk_id] = callback
67+
68+
def setup_hotkeys(self, key_function_map: Dict[int, Callable]):
69+
"""
70+
Registers multiple hotkeys based on a mapping of VK codes to callback functions.
71+
"""
72+
if not user32:
73+
return
74+
75+
logging.info("Registering native Windows global hotkeys...")
76+
for vk_code, callback in key_function_map.items():
77+
if vk_code != 0:
78+
self._register(vk_code, callback)
79+
80+
def on_hotkey_pressed(self, event: wx.KeyEvent):
81+
"""
82+
Event handler for wx.EVT_HOTKEY.
83+
"""
84+
if self.frame.is_exiting:
85+
return
86+
87+
hk_id = event.GetId()
88+
callback = self.hotkey_map.get(hk_id)
89+
90+
if callback:
91+
logging.debug(f"Global hotkey pressed, ID: {hk_id}")
92+
try:
93+
callback()
94+
except Exception as e:
95+
logging.error(f"Error executing hotkey callback: {e}")
96+
else:
97+
logging.warning(f"Unknown hotkey ID received: {hk_id}")
98+
99+
event.Skip()
100+
101+
def unregister_hotkeys(self):
102+
"""Unregisters all currently active global hotkeys."""
103+
if not user32 or not self.hwnd:
104+
return
105+
106+
logging.info("Unregistering global hotkeys...")
107+
for hk_id in list(self.hotkey_map.keys()):
108+
if not user32.UnregisterHotKey(self.hwnd, hk_id):
109+
logging.debug(f"Failed to unregister hotkey ID {hk_id}")
110+
else:
111+
logging.debug(f"Unregistered hotkey ID {hk_id}")
112+
113+
self.hotkey_map.clear()

frames/player_frame.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import List, Tuple, Optional
99
import wx.lib.newevent
1010

11-
from nvda_controller import set_app_focus_status
11+
from nvda_controller import set_app_focus_status, cancel_speech
1212
from database import db_manager
1313
from i18n import _
1414
from utils import SleepTimer
@@ -19,14 +19,14 @@
1919
dialog_manager,
2020
info,
2121
event_handlers,
22-
hotkey_manager,
22+
global_media_keys,
2323
playback_logic,
2424
volume_logic,
2525
seek_logic,
2626
book_loader,
2727
equalizer_frame
2828
)
29-
from .player.hotkey_manager import (
29+
from .player.global_media_keys import (
3030
VK_MEDIA_PLAY_PAUSE, VK_MEDIA_NEXT_TRACK, VK_MEDIA_PREV_TRACK,
3131
VK_VOLUME_UP, VK_VOLUME_DOWN, VK_VOLUME_MUTE,
3232
VK_BROWSER_BACK, VK_BROWSER_FORWARD
@@ -95,7 +95,7 @@ def __init__(self,
9595
# Managers
9696
self.equalizer_frame_instance: Optional[wx.Frame] = None
9797
self.sleep_timer_manager: Optional[SleepTimer] = None
98-
self.hotkey_manager: Optional[hotkey_manager.HotkeyManager] = None
98+
self.global_keys_manager: Optional[global_media_keys.GlobalMediaKeysManager] = None
9999
self.book_loader: Optional[book_loader.BookLoader] = None
100100
self.DurationUpdateEvent = DurationUpdateEvent
101101

@@ -164,6 +164,10 @@ def _init_managers(self):
164164

165165
self.book_loader = book_loader.BookLoader(self)
166166

167+
def _do_global(self, func):
168+
cancel_speech()
169+
func()
170+
167171
def _bind_events(self):
168172
"""Binds all events (UI, Engine, Hotkeys)."""
169173
self.nvda_focus_label.Bind(wx.EVT_CHAR_HOOK, lambda event: controls.on_key_down(self, event))
@@ -178,19 +182,21 @@ def _bind_events(self):
178182
self.Bind(wx.EVT_CLOSE, lambda event: event_handlers.on_escape(self, event))
179183

180184
# Setup Global Hotkeys
181-
self.hotkey_manager = hotkey_manager.HotkeyManager(self)
185+
self.global_keys_manager = global_media_keys.GlobalMediaKeysManager(self)
186+
182187
key_function_map = {
183-
VK_MEDIA_PLAY_PAUSE: lambda: playback_logic.toggle_play_pause(self),
184-
VK_MEDIA_NEXT_TRACK: lambda: playback_logic.play_next_file(self, manual=True),
185-
VK_MEDIA_PREV_TRACK: lambda: playback_logic.play_prev_file(self),
186-
VK_VOLUME_UP: lambda: volume_logic.change_volume(self, 5),
187-
VK_VOLUME_DOWN: lambda: volume_logic.change_volume(self, -5),
188-
VK_VOLUME_MUTE: lambda: volume_logic.toggle_mute(self),
189-
VK_BROWSER_BACK: lambda: seek_logic.seek_backward_setting(self),
190-
VK_BROWSER_FORWARD: lambda: seek_logic.seek_forward_setting(self),
188+
VK_MEDIA_PLAY_PAUSE: lambda: self._do_global(lambda: playback_logic.toggle_play_pause(self)),
189+
VK_MEDIA_NEXT_TRACK: lambda: self._do_global(lambda: playback_logic.play_next_file(self, manual=True)),
190+
VK_MEDIA_PREV_TRACK: lambda: self._do_global(lambda: playback_logic.play_prev_file(self)),
191+
VK_VOLUME_UP: lambda: self._do_global(lambda: volume_logic.change_volume(self, 5)),
192+
VK_VOLUME_DOWN: lambda: self._do_global(lambda: volume_logic.change_volume(self, -5)),
193+
VK_VOLUME_MUTE: lambda: self._do_global(lambda: volume_logic.toggle_mute(self)),
194+
VK_BROWSER_BACK: lambda: self._do_global(lambda: seek_logic.seek_backward_setting(self)),
195+
VK_BROWSER_FORWARD: lambda: self._do_global(lambda: seek_logic.seek_forward_setting(self)),
191196
}
192-
self.hotkey_manager.setup_hotkeys(key_function_map)
193-
self.Bind(wx.EVT_HOTKEY, self.hotkey_manager.on_hotkey_pressed)
197+
198+
self.global_keys_manager.setup_hotkeys(key_function_map)
199+
self.Bind(wx.EVT_HOTKEY, self.global_keys_manager.on_hotkey_pressed)
194200

195201
self.ui_timer = wx.Timer(self)
196202
self.Bind(wx.EVT_TIMER, lambda event: event_handlers.on_ui_timer(self, event), self.ui_timer)
@@ -282,3 +288,9 @@ def on_eq_enabled_changed(self, new_enabled: bool):
282288
"""Toggles the Equalizer on/off."""
283289
self.is_eq_enabled = new_enabled
284290
self._update_audio_filters()
291+
292+
def update_file_display(self, filename: str):
293+
self.nvda_focus_label.SetLabel(filename)
294+
if not self.IsActive():
295+
from nvda_controller import speak, LEVEL_MINIMAL
296+
speak(filename, LEVEL_MINIMAL)

0 commit comments

Comments
 (0)