From 1ace4ad98cb2b9ea3df282eb987246f223f267d9 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 31 May 2026 23:26:41 +0700 Subject: [PATCH] feat: add configurable keyboard shortcut system - src/hotkeys.py: HotkeyManager with 17 default shortcuts, config load/save via config.ini, KeyCaptureDialog for reassignment UI - src/config_loader.py: [Hotkeys] section defaults, _set_defaults called on every load to backfill missing sections - src/main.py: integrate HotkeyManager, seek_forward/backward (+/-5s), context-aware select_all/delete_selected, Settings tab UI with shortcut table and Reset to Defaults button --- src/config_loader.py | 31 ++++- src/hotkeys.py | 308 +++++++++++++++++++++++++++++++++++++++++++ src/main.py | 148 +++++++++++++++++++++ 3 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 src/hotkeys.py diff --git a/src/config_loader.py b/src/config_loader.py index 96075cd..7630ec9 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -60,11 +60,10 @@ class ConfigLoader: if os.path.exists(self.config_file): logger.info(f"Loading configuration from {self.config_file}") self.config.read(self.config_file) - return True else: logger.warning(f"Configuration file {self.config_file} not found, using defaults") - self._set_defaults() - return False + self._set_defaults() + return True except Exception as e: logger.error(f"Failed to load configuration: {e}") self._set_defaults() @@ -128,6 +127,32 @@ class ConfigLoader: self.config.set('Advanced', 'temp_dir', 'temp') self.config.set('Advanced', 'concurrent_downloads', '2') self.config.set('Advanced', 'debug', 'false') + + # Hotkeys section + if not self.config.has_section('Hotkeys'): + self.config.add_section('Hotkeys') + + hotkey_defaults = { + 'play_pause': 'Space', + 'prev_track': 'Ctrl+Left', + 'next_track': 'Ctrl+Right', + 'volume_up': 'Ctrl+Up', + 'volume_down': 'Ctrl+Down', + 'seek_forward': 'Right', + 'seek_backward': 'Left', + 'tab_download': 'Ctrl+1', + 'tab_library': 'Ctrl+2', + 'tab_ipod': 'Ctrl+3', + 'tab_settings': 'Ctrl+4', + 'search_focus': 'Ctrl+F', + 'select_all': 'Ctrl+A', + 'library_refresh': 'F5', + 'ipod_refresh': 'Ctrl+R', + 'download': 'Ctrl+Enter', + 'delete_selected': 'Delete', + } + for key, value in hotkey_defaults.items(): + self.config.set('Hotkeys', key, value) def get(self, section: str, option: str, fallback: Any = None) -> Any: """ diff --git a/src/hotkeys.py b/src/hotkeys.py new file mode 100644 index 0000000..b81beb4 --- /dev/null +++ b/src/hotkeys.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Hotkey Manager for neo-pod-desktop +Provides configurable keyboard shortcuts with scope awareness. +""" + +import logging +from typing import Callable, Dict, Optional + +from PyQt6.QtCore import Qt, QObject +from PyQt6.QtGui import QKeySequence, QShortcut, QAction +from PyQt6.QtWidgets import QApplication, QWidget, QDialog, QVBoxLayout, QLabel, QPushButton + +logger = logging.getLogger(__name__) + +QSeq = QKeySequence + +# Key names that QKeySequence doesn't expose as enum values +_KEY_MAP = { + "Space": QSeq(Qt.Key.Key_Space), + "Delete": QSeq(Qt.Key.Key_Delete), + "Up": QSeq(Qt.Key.Key_Up), + "Down": QSeq(Qt.Key.Key_Down), + "Left": QSeq(Qt.Key.Key_Left), + "Right": QSeq(Qt.Key.Key_Right), + "Enter": QSeq(Qt.Key.Key_Enter), + "Return": QSeq(Qt.Key.Key_Return), + "Esc": QSeq(Qt.Key.Key_Escape), + "Escape": QSeq(Qt.Key.Key_Escape), + "Tab": QSeq(Qt.Key.Key_Tab), + "Backspace": QSeq(Qt.Key.Key_Backspace), + "Plus": QSeq(Qt.Key.Key_Plus), + "Minus": QSeq(Qt.Key.Key_Minus), + "F1": QSeq(Qt.Key.Key_F1), + "F2": QSeq(Qt.Key.Key_F2), + "F3": QSeq(Qt.Key.Key_F3), + "F4": QSeq(Qt.Key.Key_F4), + "F5": QSeq(Qt.Key.Key_F5), + "F6": QSeq(Qt.Key.Key_F6), + "F7": QSeq(Qt.Key.Key_F7), + "F8": QSeq(Qt.Key.Key_F8), + "F9": QSeq(Qt.Key.Key_F9), + "F10": QSeq(Qt.Key.Key_F10), + "F11": QSeq(Qt.Key.Key_F11), + "F12": QSeq(Qt.Key.Key_F12), +} + + +def _build_seq(text: str) -> QKeySequence: + """Build a QKeySequence from a human-readable shortcut string.""" + text = text.strip() + if not text: + return QSeq() + known = _KEY_MAP.get(text) + if known is not None: + return known + return QSeq(text) + + +def _seq_to_string(seq: QKeySequence) -> str: + """Convert a QKeySequence to a human-readable string.""" + return seq.toString() + + +def _is_editable_focused() -> bool: + """Return True if an editable widget currently has keyboard focus.""" + from PyQt6.QtWidgets import QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox + w = QApplication.focusWidget() + if w is None: + return False + return isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox)) + + +# --------------------------------------------------------------------------- +# Default shortcut definitions +# (action_name, default_key_string, display_label, scope) +# scope: "global" — always active; "tab" — handler decides based on current tab +# --------------------------------------------------------------------------- +DEFAULTS = [ + ("play_pause", "Space", "Play / Pause", "global"), + ("prev_track", "Ctrl+Left", "Previous Track", "global"), + ("next_track", "Ctrl+Right", "Next Track", "global"), + ("volume_up", "Ctrl+Up", "Volume Up", "global"), + ("volume_down", "Ctrl+Down", "Volume Down", "global"), + ("seek_forward", "Right", "Seek Forward 5s", "global"), + ("seek_backward", "Left", "Seek Backward 5s", "global"), + ("tab_download", "Ctrl+1", "Switch to Download", "global"), + ("tab_library", "Ctrl+2", "Switch to Library", "global"), + ("tab_ipod", "Ctrl+3", "Switch to iPod", "global"), + ("tab_settings", "Ctrl+4", "Switch to Settings", "global"), + ("search_focus", "Ctrl+F", "Focus Search", "global"), + ("select_all", "Ctrl+A", "Select All", "global"), + ("library_refresh", "F5", "Refresh Library", "global"), + ("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"), + ("download", "Ctrl+Enter", "Start Download", "global"), + ("delete_selected", "Delete", "Delete Selected", "global"), +] + + +class KeyCaptureDialog(QDialog): + """Modal dialog that captures a single key combination.""" + + def __init__(self, parent=None, current: str = ""): + super().__init__(parent) + self.setWindowTitle("Press New Shortcut") + self.setMinimumWidth(320) + self.setModal(True) + + self._captured_seq: QKeySequence = QSeq() + self._modifiers = Qt.KeyboardModifier.NoModifier + self._key = Qt.Key.Key_unknown + + layout = QVBoxLayout(self) + self._label = QLabel(f"Press new shortcut for this action...\n(current: {current or 'none'})") + self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._label.setWordWrap(True) + layout.addWidget(self._label) + + clear_btn = QPushButton("Clear (Disable)") + clear_btn.clicked.connect(self._on_clear) + layout.addWidget(clear_btn) + + layout.addWidget(QPushButton("Cancel", clicked=self.reject)) + + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setFocus() + + def keyPressEvent(self, event): + key = event.key() + modifiers = event.modifiers() + mod_mask = modifiers & ( + Qt.KeyboardModifier.ControlModifier + | Qt.KeyboardModifier.ShiftModifier + | Qt.KeyboardModifier.AltModifier + | Qt.KeyboardModifier.MetaModifier + ) + + # Ignore pure modifier presses + ignore = { + Qt.Key.Key_Control, Qt.Key.Key_Shift, Qt.Key.Key_Alt, Qt.Key.Key_Meta, + Qt.Key.Key_Super_L, Qt.Key.Key_Super_R, + } + if key in ignore: + return + + # Build the sequence + keys = int(mod_mask) | int(key) + self._captured_seq = QSeq(keys) + self._label.setText(f"Captured: {self._captured_seq.toString()}\nPress Close to accept") + self.accept() + + def _on_clear(self): + self._captured_seq = QSeq() + self._label.setText("Shortcut cleared (disabled)") + self.accept() + + def captured_sequence(self) -> QKeySequence: + return self._captured_seq + + +class HotkeyManager: + """Manages all keyboard shortcuts for the application. + + Loads key bindings from config, creates QShortcut instances, + and supports live reassignment at runtime. + """ + + def __init__(self, parent: QWidget, config_loader=None): + self._parent = parent + self._config_loader = config_loader + + # action_name → (QShortcut, callback, default_seq, scope) + self._shortcuts: Dict[str, QShortcut] = {} + self._callbacks: Dict[str, Callable[[], None]] = {} + self._defaults: Dict[str, QKeySequence] = {} + self._labels: Dict[str, str] = {} + self._scopes: Dict[str, str] = {} + + for name, key_str, label, scope in DEFAULTS: + self._defaults[name] = _build_seq(key_str) + self._labels[name] = label + self._scopes[name] = scope + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def register(self, action_name: str, callback: Callable[[], None]) -> None: + """Register a shortcut with its callback. + + If a saved key exists in config it is used; otherwise the default. + """ + saved_key = None + if self._config_loader: + saved_key = self._config_loader.get("Hotkeys", action_name, fallback=None) + + seq = _build_seq(saved_key) if saved_key else self._defaults.get(action_name, QSeq()) + self._callbacks[action_name] = callback + + scope = self._scopes.get(action_name, "global") + context = Qt.ShortcutContext.WindowShortcut + + sc = QShortcut(seq, self._parent, context=context) + + def _make_handler(name): + def handler(): + self._dispatch(name) + return handler + + sc.activated.connect(_make_handler(action_name)) + self._shortcuts[action_name] = sc + + def register_all(self, action_map: Dict[str, Callable[[], None]]) -> None: + """Register all known shortcuts from an action_name→callback mapping.""" + for name, callback in action_map.items(): + self.register(name, callback) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + def _dispatch(self, action_name: str): + """Check if the shortcut should fire (not blocked by editable focus), + then invoke the callback. + """ + # For seek arrows and space, don't fire when text editing + editable_blocked = {"seek_forward", "seek_backward", "play_pause"} + if action_name in editable_blocked and _is_editable_focused(): + return + + cb = self._callbacks.get(action_name) + if cb: + cb() + + # ------------------------------------------------------------------ + # Configuration persistence + # ------------------------------------------------------------------ + + def save_to_config(self) -> None: + """Write all current key bindings to the config loader.""" + if self._config_loader is None: + return + for name, sc in self._shortcuts.items(): + key_str = sc.key().toString() + self._config_loader.set("Hotkeys", name, key_str if key_str else "") + self._config_loader.save() + + def load_from_config(self) -> None: + """Re-read key bindings from config and update all shortcuts.""" + if self._config_loader is None: + return + for name, sc in self._shortcuts.items(): + saved = self._config_loader.get("Hotkeys", name, fallback=None) + if saved is not None: + seq = _build_seq(saved) if saved != "" else QSeq() + else: + seq = self._defaults.get(name, QSeq()) + sc.setKey(seq) + + def reset_to_defaults(self) -> None: + """Reset all shortcuts to their default keys and save.""" + for name, sc in self._shortcuts.items(): + default_seq = self._defaults.get(name, QSeq()) + sc.setKey(default_seq) + self.save_to_config() + + # ------------------------------------------------------------------ + # Query API (for Settings UI) + # ------------------------------------------------------------------ + + def get_label(self, action_name: str) -> str: + return self._labels.get(action_name, action_name) + + def get_current_key_string(self, action_name: str) -> str: + sc = self._shortcuts.get(action_name) + if sc is None: + return "" + return sc.key().toString() + + def get_default_key_string(self, action_name: str) -> str: + return self._defaults.get(action_name, QSeq()).toString() + + def all_action_names(self): + return list(self._shortcuts.keys()) + + # ------------------------------------------------------------------ + # Live reassignment (called from Settings UI) + # ------------------------------------------------------------------ + + def set_shortcut(self, action_name: str, key_string: str) -> None: + """Change the key binding for an action at runtime and persist.""" + sc = self._shortcuts.get(action_name) + if sc is None: + return + seq = _build_seq(key_string) + sc.setKey(seq) + if self._config_loader: + self._config_loader.set("Hotkeys", action_name, key_string) + self._config_loader.save() + + def set_shortcut_seq(self, action_name: str, seq: QKeySequence) -> None: + """Set a shortcut directly from a QKeySequence (from key capture dialog).""" + sc = self._shortcuts.get(action_name) + if sc is not None: + sc.setKey(seq) + if self._config_loader: + self._config_loader.set("Hotkeys", action_name, seq.toString()) + self._config_loader.save() diff --git a/src/main.py b/src/main.py index 468e53f..b69f582 100644 --- a/src/main.py +++ b/src/main.py @@ -26,6 +26,8 @@ from audio_converter import AudioConverter from metadata_handler import MetadataHandler from ipod_device import IPodDevice from artwork.cache import cover_cache +from config_loader import ConfigLoader +from hotkeys import HotkeyManager, KeyCaptureDialog logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -282,6 +284,9 @@ class MainWindow(QMainWindow): self.current_mount_point: Optional[str] = None self.show_download_tab = False + self.config_loader = ConfigLoader() + self.hotkey_manager: Optional[HotkeyManager] = None + self.player: Optional[QMediaPlayer] = None self.audio_output: Optional[QAudioOutput] = None self.current_playback_index: int = -1 @@ -289,6 +294,8 @@ class MainWindow(QMainWindow): self._setup_ui() self._setup_player() + self._load_settings() + self._setup_hotkeys() self._scan_library() self._on_refresh_devices_clicked() @@ -304,6 +311,54 @@ class MainWindow(QMainWindow): self.worker_thread.deleteLater() self.worker_thread = None + def _load_settings(self): + config = self.config_loader + self.output_dir_input.setText( + config.get("General", "output_dir", + fallback=os.path.join(os.path.expanduser("~"), "Music", "iPod")) + ) + self.format_combo.setCurrentText(config.get("General", "format", fallback="m4a")) + self.quality_spin.setValue(config.get_int("General", "quality", fallback=256)) + self.enable_video_check.setChecked( + config.get_boolean("Video", "enable_video", fallback=False) + ) + self.embed_artwork_check.setChecked( + config.get_boolean("Metadata", "embed_artwork", fallback=True) + ) + self.clean_temp_check.setChecked( + config.get_boolean("Advanced", "clean_temp", fallback=True) + ) + self.auto_detect_check.setChecked( + config.get_boolean("Device", "auto_detect", fallback=True) + ) + + def _setup_hotkeys(self): + self.hotkey_manager = HotkeyManager(self, self.config_loader) + self.hotkey_manager.register_all({ + "play_pause": self._on_play_pause_clicked, + "prev_track": self._on_prev_clicked, + "next_track": self._on_next_clicked, + "volume_up": lambda: self._adjust_volume(5), + "volume_down": lambda: self._adjust_volume(-5), + "seek_forward": self._seek_forward, + "seek_backward": self._seek_backward, + "tab_download": lambda: self.tab_widget.setCurrentIndex(self.download_tab_index), + "tab_library": lambda: self.tab_widget.setCurrentIndex(1), + "tab_ipod": lambda: self.tab_widget.setCurrentIndex(2), + "tab_settings": lambda: self.tab_widget.setCurrentIndex(3), + "search_focus": self._focus_search, + "select_all": self._select_all_current, + "library_refresh": self._on_refresh_library_clicked, + "ipod_refresh": self._on_refresh_devices_clicked, + "download": self._on_download_clicked, + "delete_selected": self._delete_selected_current, + }) + self._refresh_shortcuts_table() + + def _adjust_volume(self, delta: int): + new_val = max(0, min(100, self.volume_slider.value() + delta)) + self.volume_slider.setValue(new_val) + def _setup_ui(self): main_widget = QWidget() main_layout = QVBoxLayout(main_widget) @@ -668,6 +723,28 @@ class MainWindow(QMainWindow): layout.addWidget(advanced_group) + shortcuts_group = QGroupBox("Keyboard Shortcuts") + shortcuts_layout = QVBoxLayout(shortcuts_group) + + self.shortcuts_table = QTableWidget() + self.shortcuts_table.setColumnCount(2) + self.shortcuts_table.setHorizontalHeaderLabels(["Action", "Shortcut"]) + self.shortcuts_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.shortcuts_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.shortcuts_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.shortcuts_table.setAlternatingRowColors(True) + self.shortcuts_table.cellDoubleClicked.connect(self._on_shortcut_double_clicked) + shortcuts_header = self.shortcuts_table.horizontalHeader() + shortcuts_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + shortcuts_header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + shortcuts_layout.addWidget(self.shortcuts_table) + + reset_shortcuts_btn = QPushButton("Reset to Defaults") + reset_shortcuts_btn.clicked.connect(self._on_reset_shortcuts_clicked) + shortcuts_layout.addWidget(reset_shortcuts_btn) + + layout.addWidget(shortcuts_group) + layout.addStretch() about_label = QLabel( @@ -770,6 +847,37 @@ class MainWindow(QMainWindow): idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1) self._play_track_from_index(idx) + def _seek_forward(self): + if self.player is None or self.player.duration() <= 0: + return + new_pos = min(self.player.duration(), self.player.position() + 5000) + self.player.setPosition(new_pos) + + def _seek_backward(self): + if self.player is None or self.player.duration() <= 0: + return + new_pos = max(0, self.player.position() - 5000) + self.player.setPosition(new_pos) + + def _focus_search(self): + self.tab_widget.setCurrentIndex(1) + self.library_search_input.setFocus() + self.library_search_input.selectAll() + + def _select_all_current(self): + idx = self.tab_widget.currentIndex() + if idx == 1: + self.library_table.selectAll() + elif idx == 2: + self.transferred_list.selectAll() + + def _delete_selected_current(self): + idx = self.tab_widget.currentIndex() + if idx == 1: + self._on_library_remove_selected() + elif idx == 2: + self._on_remove_tracks_clicked() + def _on_slider_pressed(self): if self.player: self.player.setPosition( @@ -1745,6 +1853,46 @@ class MainWindow(QMainWindow): QMessageBox.information(self, "Duplicates" if success else "Error", message) self._cleanup_worker() + def _refresh_shortcuts_table(self): + if self.hotkey_manager is None: + return + names = self.hotkey_manager.all_action_names() + self.shortcuts_table.setRowCount(len(names)) + for row, name in enumerate(names): + label = self.hotkey_manager.get_label(name) + key = self.hotkey_manager.get_current_key_string(name) + self.shortcuts_table.setItem(row, 0, QTableWidgetItem(label)) + item = QTableWidgetItem(key if key else "\u2014") + self.shortcuts_table.setItem(row, 1, item) + + def _on_shortcut_double_clicked(self, row: int, _col: int): + if self.hotkey_manager is None: + return + names = self.hotkey_manager.all_action_names() + if row < 0 or row >= len(names): + return + action_name = names[row] + current = self.hotkey_manager.get_current_key_string(action_name) + + dialog = KeyCaptureDialog(self, current=current) + if dialog.exec() == QDialog.DialogCode.Accepted: + seq = dialog.captured_sequence() + self.hotkey_manager.set_shortcut_seq(action_name, seq) + self._refresh_shortcuts_table() + + def _on_reset_shortcuts_clicked(self): + if self.hotkey_manager is None: + return + reply = QMessageBox.question( + self, "Reset Shortcuts", + "Reset all keyboard shortcuts to their default values?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.Yes: + self.hotkey_manager.reset_to_defaults() + self._refresh_shortcuts_table() + QMessageBox.information(self, "Done", "Shortcuts reset to defaults.") + def closeEvent(self, event): if self.worker_thread and self.worker_thread.isRunning(): self.worker_thread.stop()