""" Player keyboard and mouse bindings ================================== The player had one `keyPressEvent` handling Space, F and Escape -- and it never ran. Nothing in the player set a focus policy, so focus always landed on a child button, slider or combo box, which swallowed Space and the arrow keys. There were no mouse handlers at all and no `QShortcut` anywhere in the application. Why Qt handlers rather than mpv's own bindings ---------------------------------------------- `input_default_bindings` stays off. With `vo=libmpv` there is no mpv-owned window, so mpv's input layer receives nothing -- it is fed by the video output's windowing backend, which does not exist in this embedding. Turning default bindings on would only matter if key events were hand-forwarded with `keypress`, which needs a full Qt-to-mpv key-name table, hands mpv ownership of the OSD and OSC (both disabled here), and lets `q` quit the core out from under the Qt UI. Routing through Qt also means one action table drives the shortcuts, the context menu and the buttons, and the labels stay translatable. Focus, and why the shortcuts are scoped --------------------------------------- Bindings are `QShortcut`s with `WidgetWithChildrenShortcut` context on the panel. That fires when the panel *or any descendant* has focus, so it works whichever child holds it -- and it does not steal keys from the Search box or the Downloads URL field on other tabs, which an application-wide shortcut would. """ from dataclasses import dataclass, field from typing import Callable, List, Optional from PySide6.QtCore import Qt from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import QMenu, QWidget from ..utils.ytsage_localization import _ #: Seek steps, in seconds. SEEK_SMALL = 1.0 SEEK_MEDIUM = 5.0 SEEK_LARGE = 10.0 VOLUME_STEP = 5 SPEED_STEP = 0.25 @dataclass(frozen=True) class Action: """One thing the player can do, and every way to ask for it.""" ident: str keys: List[str] label_key: str method: str icon: Optional[str] = None #: Whether it belongs in the right-click menu. in_menu: bool = True args: tuple = field(default_factory=tuple) ACTIONS: List[Action] = [ Action("play_pause", ["Space", "K", "Media Play"], "player.act_play_pause", "toggle_pause", "play"), Action("seek_back_5", ["Left"], "player.act_seek_back_5", "seek_relative", None, False, (-SEEK_MEDIUM,)), Action("seek_fwd_5", ["Right"], "player.act_seek_fwd_5", "seek_relative", None, False, (SEEK_MEDIUM,)), Action("seek_back_10", ["J"], "player.act_seek_back_10", "seek_relative", None, True, (-SEEK_LARGE,)), Action("seek_fwd_10", ["L"], "player.act_seek_fwd_10", "seek_relative", None, True, (SEEK_LARGE,)), Action("seek_back_1", ["Shift+Left"], "player.act_seek_back_1", "seek_relative", None, False, (-SEEK_SMALL,)), Action("seek_fwd_1", ["Shift+Right"], "player.act_seek_fwd_1", "seek_relative", None, False, (SEEK_SMALL,)), Action("frame_back", [","], "player.act_frame_back", "frame_step", None, False, (-1,)), Action("frame_fwd", ["."], "player.act_frame_fwd", "frame_step", None, False, (1,)), Action("vol_up", ["Up"], "player.act_vol_up", "nudge_volume", None, False, (VOLUME_STEP,)), Action("vol_down", ["Down"], "player.act_vol_down", "nudge_volume", None, False, (-VOLUME_STEP,)), Action("mute", ["M"], "player.act_mute", "toggle_mute", "volume-mute"), Action("speed_up", ["]"], "player.act_speed_up", "nudge_speed", None, False, (SPEED_STEP,)), Action("speed_down", ["["], "player.act_speed_down", "nudge_speed", None, False, (-SPEED_STEP,)), Action("speed_reset", ["Backspace"], "player.act_speed_reset", "reset_speed", None, True), Action("subtitles", ["C"], "player.act_subtitles", "cycle_subtitles", "captions"), Action("fullscreen", ["F"], "player.act_fullscreen", "toggle_fullscreen", "maximize"), Action("leave_fullscreen", ["Escape"], "player.act_leave_fullscreen", "exit_fullscreen", None, False), Action("next", ["N", "Ctrl+Right", "Media Next"], "player.act_next", "request_next", "skip-forward"), Action("previous", ["P", "Ctrl+Left", "Media Previous"], "player.act_previous", "request_previous", "skip-back"), Action("start", ["Home"], "player.act_start", "seek_absolute", None, False, (0.0,)), Action("end", ["End"], "player.act_end", "seek_to_end", None, False), ] #: 0-9 jump to that tenth of the video, as YouTube does. PERCENT_KEYS = [(str(n), n / 10.0) for n in range(10)] def install_player_shortcuts(panel: QWidget) -> List[QShortcut]: """ Bind every action to the panel. Returns the shortcuts so the caller can keep them alive -- a QShortcut with no reference is collected and silently stops working. """ shortcuts: List[QShortcut] = [] def bind(sequence: str, handler: Callable) -> None: shortcut = QShortcut(QKeySequence(sequence), panel) shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) shortcut.activated.connect(handler) shortcuts.append(shortcut) for action in ACTIONS: method = getattr(panel, action.method, None) if method is None: continue for key in action.keys: bind(key, (lambda m=method, a=action.args: m(*a))) seek_percent = getattr(panel, "seek_percent", None) if seek_percent is not None: for key, fraction in PERCENT_KEYS: bind(key, (lambda f=fraction: seek_percent(f))) return shortcuts def build_context_menu(panel: QWidget) -> QMenu: """The right-click menu, from the same table as the shortcuts.""" from . import ytsage_icons as icons from . import ytsage_theme as theme menu = QMenu(panel) for action in ACTIONS: if not action.in_menu: continue method = getattr(panel, action.method, None) if method is None: continue label = _(action.label_key) if action.keys: label = f"{label}\t{action.keys[0]}" entry = menu.addAction(label) if action.icon: entry.setIcon(icons.icon(action.icon, theme.ICON)) entry.triggered.connect(lambda _checked=False, m=method, a=action.args: m(*a)) menu.addSeparator() copy_url = getattr(panel, "copy_video_url", None) if copy_url is not None: entry = menu.addAction(_("player.act_copy_url")) entry.setIcon(icons.icon("clipboard", theme.ICON)) entry.triggered.connect(lambda _checked=False: copy_url()) open_browser = getattr(panel, "open_in_browser", None) if open_browser is not None: entry = menu.addAction(_("player.act_open_browser")) entry.setIcon(icons.icon("external-link", theme.ICON)) entry.triggered.connect(lambda _checked=False: open_browser()) return menu