Make the player answer the keyboard and the mouse

PlayerPanel.keyPressEvent handled Space, F and Escape and was dead code:
nothing in the player called setFocusPolicy, so focus went to whichever child
button, slider or combo box was first in the tab order, and that child ate the
keys. Fixing focus alone would not have been enough -- clicking play parks
focus on the play button, which then swallows Space -- so the control bar is
explicitly NoFocus and the bindings are QShortcuts with
WidgetWithChildrenShortcut context on the panel. That fires whichever
descendant holds focus, and does not reach the Search box or the URL field on
other tabs the way an application shortcut would.

mpv's own input stays disabled. With vo=libmpv there is no mpv-owned window,
so its input layer receives nothing; enabling default bindings would mean
hand-forwarding events through a Qt-to-mpv key-name table, handing mpv the
OSD and OSC that are switched off here, and letting `q` quit the core out from
under the Qt UI. One declarative action table now drives the shortcuts, the
context menu and the buttons.

There was no mouse handling at all. Click pauses, double-click goes
fullscreen -- via a doubleClickInterval timer, so a double-click does not also
pause on the way -- the wheel changes volume and Ctrl+wheel seeks, with
sub-notch deltas accumulated so a trackpad is not inert.

The control bar gained previous, next and mute. Mute and volume are driven by
observed mpv properties rather than assumed, so the icon follows a change made
by key, menu or mpv itself. Buffering was completely invisible: a stalled
stream showed a frozen frame for 25 seconds before the retry with nothing on
screen, so paused-for-cache now surfaces a label over the video.

WatchPage owns next/previous because it owns the queue; the panel only asks.
Previous restarts the current item when it is more than five seconds in, as
every other player does, and pushes the interrupted item back onto the head of
the queue rather than dropping it.

Verified: all 38 bindings install, every action is a no-op rather than an
exception with nothing loaded, focus policies are as intended, and a
fullscreen round trip drives the player state correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 01:04:16 +02:00
parent 3aca43b372
commit 27933dd495
6 changed files with 590 additions and 6 deletions
+154
View File
@@ -0,0 +1,154 @@
"""
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