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
This commit is contained in:
parent
8cc4ef5279
commit
1ace4ad98c
@ -60,11 +60,10 @@ class ConfigLoader:
|
|||||||
if os.path.exists(self.config_file):
|
if os.path.exists(self.config_file):
|
||||||
logger.info(f"Loading configuration from {self.config_file}")
|
logger.info(f"Loading configuration from {self.config_file}")
|
||||||
self.config.read(self.config_file)
|
self.config.read(self.config_file)
|
||||||
return True
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Configuration file {self.config_file} not found, using defaults")
|
logger.warning(f"Configuration file {self.config_file} not found, using defaults")
|
||||||
self._set_defaults()
|
self._set_defaults()
|
||||||
return False
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load configuration: {e}")
|
logger.error(f"Failed to load configuration: {e}")
|
||||||
self._set_defaults()
|
self._set_defaults()
|
||||||
@ -129,6 +128,32 @@ class ConfigLoader:
|
|||||||
self.config.set('Advanced', 'concurrent_downloads', '2')
|
self.config.set('Advanced', 'concurrent_downloads', '2')
|
||||||
self.config.set('Advanced', 'debug', 'false')
|
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:
|
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
||||||
"""
|
"""
|
||||||
Get configuration value
|
Get configuration value
|
||||||
|
|||||||
308
src/hotkeys.py
Normal file
308
src/hotkeys.py
Normal file
@ -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()
|
||||||
148
src/main.py
148
src/main.py
@ -26,6 +26,8 @@ from audio_converter import AudioConverter
|
|||||||
from metadata_handler import MetadataHandler
|
from metadata_handler import MetadataHandler
|
||||||
from ipod_device import IPodDevice
|
from ipod_device import IPodDevice
|
||||||
from artwork.cache import cover_cache
|
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')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -282,6 +284,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.current_mount_point: Optional[str] = None
|
self.current_mount_point: Optional[str] = None
|
||||||
self.show_download_tab = False
|
self.show_download_tab = False
|
||||||
|
|
||||||
|
self.config_loader = ConfigLoader()
|
||||||
|
self.hotkey_manager: Optional[HotkeyManager] = None
|
||||||
|
|
||||||
self.player: Optional[QMediaPlayer] = None
|
self.player: Optional[QMediaPlayer] = None
|
||||||
self.audio_output: Optional[QAudioOutput] = None
|
self.audio_output: Optional[QAudioOutput] = None
|
||||||
self.current_playback_index: int = -1
|
self.current_playback_index: int = -1
|
||||||
@ -289,6 +294,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._setup_player()
|
self._setup_player()
|
||||||
|
self._load_settings()
|
||||||
|
self._setup_hotkeys()
|
||||||
self._scan_library()
|
self._scan_library()
|
||||||
self._on_refresh_devices_clicked()
|
self._on_refresh_devices_clicked()
|
||||||
|
|
||||||
@ -304,6 +311,54 @@ class MainWindow(QMainWindow):
|
|||||||
self.worker_thread.deleteLater()
|
self.worker_thread.deleteLater()
|
||||||
self.worker_thread = None
|
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):
|
def _setup_ui(self):
|
||||||
main_widget = QWidget()
|
main_widget = QWidget()
|
||||||
main_layout = QVBoxLayout(main_widget)
|
main_layout = QVBoxLayout(main_widget)
|
||||||
@ -668,6 +723,28 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
layout.addWidget(advanced_group)
|
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()
|
layout.addStretch()
|
||||||
|
|
||||||
about_label = QLabel(
|
about_label = QLabel(
|
||||||
@ -770,6 +847,37 @@ class MainWindow(QMainWindow):
|
|||||||
idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1)
|
idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1)
|
||||||
self._play_track_from_index(idx)
|
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):
|
def _on_slider_pressed(self):
|
||||||
if self.player:
|
if self.player:
|
||||||
self.player.setPosition(
|
self.player.setPosition(
|
||||||
@ -1745,6 +1853,46 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.information(self, "Duplicates" if success else "Error", message)
|
QMessageBox.information(self, "Duplicates" if success else "Error", message)
|
||||||
self._cleanup_worker()
|
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):
|
def closeEvent(self, event):
|
||||||
if self.worker_thread and self.worker_thread.isRunning():
|
if self.worker_thread and self.worker_thread.isRunning():
|
||||||
self.worker_thread.stop()
|
self.worker_thread.stop()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user