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
+17
View File
@@ -12,6 +12,23 @@ records.
### Added
- **The player responds to the keyboard and the mouse.** It had a key handler
for Space, F and Escape that could never fire — nothing in the player set a
focus policy, so focus always landed on a child button or slider, which
swallowed the keys. Now: Space/K play-pause, ←/→ ∓5s, J/L ∓10s, Shift+←/→
∓1s, `,`/`.` frame step, ↑/↓ volume, M mute, `[`/`]` speed with Backspace to
reset, C subtitles, F fullscreen, Escape to leave, 09 to jump by tenths,
Home/End, N/P for next and previous. Click pauses, double-click goes
fullscreen, the wheel changes volume and Ctrl+wheel seeks, and right-click
opens a menu built from the same table as the shortcuts. The bindings are
scoped to the player, so typing in the Search box or the URL field is
unaffected.
- **Previous, next and mute buttons**, and a buffering indicator — a stalled
stream previously showed a frozen frame for 25 seconds with nothing on
screen before the automatic retry.
- In fullscreen the controls and pointer fade out after a few idle seconds and
return on any movement. In a window they never hide, so the layout does not
jump.
- **Real icons.** The app had no icon system: transport buttons used the
platform style's dark monochrome glyphs painted on saturated red, and
everything else called an icon was an emoji baked into the English strings.
+1
View File
@@ -591,6 +591,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self, self.main_tabs, self.watch_page, parent=self
)
self.watch_page.player.set_fullscreen_controller(self.fullscreen_controller)
self.fullscreen_controller.changed.connect(self.watch_page.player.on_fullscreen_changed)
self.router.playVideo.connect(self._route_play_video)
self.router.queueVideo.connect(self.watch_page.enqueue)
+345 -5
View File
@@ -18,25 +18,27 @@ widgets - they only emit queued Qt signals.
import sys
import threading
import webbrowser
from typing import Any, Dict, Optional
from PySide6.QtCore import QCoreApplication, Qt, QTimer, Signal, Slot
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtGui import QOpenGLContext
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSlider,
QStyle,
QVBoxLayout,
QWidget,
)
from . import ytsage_icons as icons
from . import ytsage_theme as theme
from .ytsage_player_input import SEEK_MEDIUM, build_context_menu, install_player_shortcuts
from ..core.ytsage_mpv import probe_player
from ..core.ytsage_yt_dlp import get_yt_dlp_path
from ..utils.ytsage_config_manager import ConfigManager
@@ -144,12 +146,32 @@ class MpvRenderWidget(QOpenGLWidget):
mpvPausedChanged = Signal(bool)
mpvEndReached = Signal(str) # end-file reason
mpvError = Signal(str)
mpvMuteChanged = Signal(bool)
mpvVolumeChanged = Signal(float)
mpvBufferingChanged = Signal(bool)
_renderUpdateRequested = Signal()
# Mouse gestures, surfaced for PlayerPanel to act on. Kept here rather
# than on the panel so the control bar below the video is unaffected.
clicked = Signal()
doubleClicked = Signal()
wheelScrolled = Signal(int, bool) # notches, ctrl_held
mouseMovedOverVideo = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(240)
# Without this the widget cannot hold focus, which is why the panel's
# key handler never ran.
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setMouseTracking(True)
self._wheel_remainder = 0
# A single click must not act until a double-click can be ruled out,
# or double-click-to-fullscreen also pauses on the way.
self._click_timer = QTimer(self)
self._click_timer.setSingleShot(True)
self._click_timer.timeout.connect(self.clicked)
self._mpv = None
self._render_ctx = None
self._gl_ctx = None
@@ -202,6 +224,13 @@ class MpvRenderWidget(QOpenGLWidget):
self._mpv.observe_property("time-pos", self._on_time_pos)
self._mpv.observe_property("duration", self._on_duration)
self._mpv.observe_property("pause", self._on_pause)
# Observed rather than assumed, so the button follows changes made
# by a key, the menu or mpv itself.
self._mpv.observe_property("mute", self._on_mute)
self._mpv.observe_property("volume", self._on_volume)
# Buffering was entirely invisible: a stall showed a frozen frame for
# 25 seconds before the retry, with nothing on screen.
self._mpv.observe_property("paused-for-cache", self._on_cache_pause)
@self._mpv.event_callback("end-file")
def _on_end_file(event): # mpv thread
@@ -224,6 +253,18 @@ class MpvRenderWidget(QOpenGLWidget):
if value is not None:
self.mpvPausedChanged.emit(bool(value))
def _on_mute(self, _name, value) -> None:
if value is not None:
self.mpvMuteChanged.emit(bool(value))
def _on_volume(self, _name, value) -> None:
if value is not None:
self.mpvVolumeChanged.emit(float(value))
def _on_cache_pause(self, _name, value) -> None:
if value is not None:
self.mpvBufferingChanged.emit(bool(value))
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
if level in ("error", "fatal"):
logger.error(f"mpv [{prefix}] {text.strip()}")
@@ -416,6 +457,35 @@ class MpvRenderWidget(QOpenGLWidget):
except Exception as e:
logger.debug(f"Error terminating mpv: {e}")
# ----------------------------------------------------------------- mouse
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.setFocus(Qt.FocusReason.MouseFocusReason)
self._click_timer.start(QApplication.doubleClickInterval())
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self._click_timer.stop() # cancel the pending single-click
self.doubleClicked.emit()
super().mouseDoubleClickEvent(event)
def wheelEvent(self, event) -> None:
# Accumulate: a high-resolution trackpad sends far less than one
# notch (120 units) per event, and dropping those makes it inert.
self._wheel_remainder += event.angleDelta().y()
notches, self._wheel_remainder = divmod(abs(self._wheel_remainder), 120)
if notches:
sign = 1 if event.angleDelta().y() > 0 else -1
ctrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
self.wheelScrolled.emit(sign * int(notches), ctrl)
event.accept()
def mouseMoveEvent(self, event) -> None:
self.mouseMovedOverVideo.emit()
super().mouseMoveEvent(event)
# ------------------------------------------------------------- controls
@property
@@ -431,6 +501,9 @@ class PlayerPanel(QWidget):
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
playerError = Signal(str)
nowPlayingChanged = Signal(dict) # entry dict of the current item
# The panel stays queue-agnostic: it asks, WatchPage decides what is next.
nextRequested = Signal()
previousRequested = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
@@ -438,6 +511,7 @@ class PlayerPanel(QWidget):
self._duration: float = 0.0
self._slider_down = False
self._fullscreen_controller = None
self._muted = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -446,15 +520,35 @@ class PlayerPanel(QWidget):
self.video = MpvRenderWidget(self)
layout.addWidget(self.video, stretch=1)
# A child of the video widget, not a sibling in the layout: it must
# float over the frame without changing the geometry underneath.
self.buffering_label = QLabel(_("player.buffering"), self.video)
self.buffering_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.buffering_label.setStyleSheet(
f"background-color: rgba(0,0,0,170); color: {theme.TEXT};"
"padding: 8px 16px; border-radius: 6px; font-size: 13px;"
)
self.buffering_label.hide()
self.title_label = QLabel("")
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
self.title_label.setWordWrap(True)
layout.addWidget(self.title_label)
controls = QHBoxLayout()
# A container, not a bare layout: fullscreen needs something it can
# hide, and hiding a layout is not a thing.
self.controls_bar = QWidget(self)
controls = QHBoxLayout(self.controls_bar)
controls.setSpacing(8)
controls.setContentsMargins(6, 0, 6, 4)
self.prev_btn = QPushButton()
self.prev_btn.setIcon(icons.icon("skip-back", theme.ICON_ON_ACCENT))
self.prev_btn.setFixedWidth(36)
self.prev_btn.setToolTip(_("player.previous"))
self.prev_btn.clicked.connect(self.request_previous)
controls.addWidget(self.prev_btn)
self.play_btn = QPushButton()
self.play_btn.setIcon(icons.icon("play", theme.ICON_ON_ACCENT))
self.play_btn.setFixedWidth(36)
@@ -462,6 +556,13 @@ class PlayerPanel(QWidget):
self.play_btn.clicked.connect(self.toggle_pause)
controls.addWidget(self.play_btn)
self.next_btn = QPushButton()
self.next_btn.setIcon(icons.icon("skip-forward", theme.ICON_ON_ACCENT))
self.next_btn.setFixedWidth(36)
self.next_btn.setToolTip(_("player.next"))
self.next_btn.clicked.connect(self.request_next)
controls.addWidget(self.next_btn)
self.time_label = QLabel("0:00 / 0:00")
controls.addWidget(self.time_label)
@@ -496,6 +597,13 @@ class PlayerPanel(QWidget):
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
self.volume_slider.setToolTip(_("player.volume_tooltip"))
self.volume_slider.valueChanged.connect(self._on_volume_changed)
self.mute_btn = QPushButton()
self.mute_btn.setIcon(icons.icon("volume-high", theme.ICON_ON_ACCENT))
self.mute_btn.setFixedWidth(36)
self.mute_btn.setToolTip(_("player.act_mute"))
self.mute_btn.clicked.connect(self.toggle_mute)
controls.addWidget(self.mute_btn)
controls.addWidget(self.volume_slider)
self.subs_btn = QPushButton(_("player.subtitles"))
@@ -511,7 +619,7 @@ class PlayerPanel(QWidget):
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
controls.addWidget(self.fullscreen_btn)
layout.addLayout(controls)
layout.addWidget(self.controls_bar)
# mpv-thread signals arrive queued on the GUI thread
self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection)
@@ -519,6 +627,9 @@ class PlayerPanel(QWidget):
self.video.mpvPausedChanged.connect(self._on_paused_changed, Qt.ConnectionType.QueuedConnection)
self.video.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
self.video.mpvError.connect(self.playerError, Qt.ConnectionType.QueuedConnection)
self.video.mpvMuteChanged.connect(self._on_mute_changed, Qt.ConnectionType.QueuedConnection)
self.video.mpvVolumeChanged.connect(self._on_mpv_volume, Qt.ConnectionType.QueuedConnection)
self.video.mpvBufferingChanged.connect(self._on_buffering, Qt.ConnectionType.QueuedConnection)
self._volume_apply_timer = QTimer(self)
self._volume_apply_timer.setSingleShot(True)
@@ -535,6 +646,36 @@ class PlayerPanel(QWidget):
self._stall_retries = 0
self._playback_started = False
# --- input ------------------------------------------------------
# The panel must be able to hold focus for its shortcuts to fire...
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# ...and the control bar must not take it, or clicking play would
# park focus on a button that then swallows Space. This is why the
# old keyPressEvent never ran.
for widget in (
self.prev_btn, self.play_btn, self.next_btn, self.mute_btn,
self.subs_btn, self.fullscreen_btn, self.seek_slider,
self.volume_slider, self.quality_combo, self.speed_combo,
):
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# Kept on the instance: an unreferenced QShortcut is collected and
# silently stops working.
self._shortcuts = install_player_shortcuts(self)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
self.video.clicked.connect(self.toggle_pause)
self.video.doubleClicked.connect(self.toggle_fullscreen)
self.video.wheelScrolled.connect(self._on_wheel)
self.video.mouseMovedOverVideo.connect(self._on_mouse_activity)
# Controls hide themselves in fullscreen only; doing it in a window
# would make the layout jump under the pointer.
self._idle_timer = QTimer(self)
self._idle_timer.setSingleShot(True)
self._idle_timer.setInterval(2500)
self._idle_timer.timeout.connect(self._hide_idle_controls)
# ------------------------------------------------------------ public API
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
@@ -593,6 +734,106 @@ class PlayerPanel(QWidget):
except Exception:
pass
# ------------------------------------------------------- bound actions
# Every method below is reachable from a key, the context menu, or both;
# see ytsage_player_input.ACTIONS. They are all no-ops when nothing is
# loaded rather than raising, because a key can be pressed at any time.
def seek_relative(self, seconds: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst.seek(seconds, reference="relative")
except Exception as e:
logger.debug(f"Relative seek failed: {e}")
def seek_absolute(self, seconds: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst.seek(max(0.0, seconds), reference="absolute")
except Exception as e:
logger.debug(f"Absolute seek failed: {e}")
def seek_percent(self, fraction: float) -> None:
if self._duration > 0:
self.seek_absolute(self._duration * max(0.0, min(1.0, fraction)))
def seek_to_end(self) -> None:
if self._duration > 0:
# Not exactly the end: mpv would treat that as EOF and advance.
self.seek_absolute(max(0.0, self._duration - 3.0))
def frame_step(self, direction: int) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst.command("frame-back-step" if direction < 0 else "frame-step")
except Exception as e:
logger.debug(f"Frame step failed: {e}")
def nudge_volume(self, delta: int) -> None:
self.volume_slider.setValue(max(0, min(100, self.volume_slider.value() + delta)))
def toggle_mute(self) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
mpv_inst["mute"] = not bool(mpv_inst["mute"])
except Exception as e:
logger.debug(f"Mute toggle failed: {e}")
def nudge_speed(self, delta: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is None:
return
try:
current = float(mpv_inst["speed"] or 1.0)
except Exception:
current = 1.0
self._apply_speed(max(0.25, min(4.0, round((current + delta) * 100) / 100)))
def reset_speed(self) -> None:
self._apply_speed(1.0)
def _apply_speed(self, speed: float) -> None:
mpv_inst = self.video.mpv
if mpv_inst is not None:
try:
mpv_inst["speed"] = speed
except Exception as e:
logger.debug(f"Speed change failed: {e}")
# Reflect it in the combo when it is one of the offered values,
# without re-triggering the handler.
index = self.speed_combo.findData(speed)
if index >= 0 and index != self.speed_combo.currentIndex():
self.speed_combo.blockSignals(True)
self.speed_combo.setCurrentIndex(index)
self.speed_combo.blockSignals(False)
def cycle_subtitles(self) -> None:
self.subs_btn.setChecked(not self.subs_btn.isChecked())
def request_next(self) -> None:
self.nextRequested.emit()
def request_previous(self) -> None:
self.previousRequested.emit()
def copy_video_url(self) -> None:
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
if url:
QApplication.clipboard().setText(str(url))
def open_in_browser(self) -> None:
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
if url:
webbrowser.open(str(url))
def current_entry(self) -> Dict[str, Any]:
return dict(self._current_entry)
@@ -631,6 +872,38 @@ class PlayerPanel(QWidget):
self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT))
self.play_btn.setToolTip(_("player.play") if paused else _("player.pause"))
@Slot(bool)
def _on_mute_changed(self, muted: bool) -> None:
self._muted = muted
self._refresh_volume_icon()
@Slot(float)
def _on_mpv_volume(self, volume: float) -> None:
# Reflect changes made by key or menu, without echoing back into mpv.
value = int(round(volume))
if value != self.volume_slider.value():
self.volume_slider.blockSignals(True)
self.volume_slider.setValue(value)
self.volume_slider.blockSignals(False)
self._volume_apply_timer.start()
self._refresh_volume_icon()
def _refresh_volume_icon(self) -> None:
if self._muted or self.volume_slider.value() == 0:
name = "volume-mute"
elif self.volume_slider.value() < 50:
name = "volume-low"
else:
name = "volume-high"
self.mute_btn.setIcon(icons.icon(name, theme.ICON_ON_ACCENT))
self.mute_btn.setToolTip(_("player.act_unmute") if self._muted else _("player.act_mute"))
@Slot(bool)
def _on_buffering(self, buffering: bool) -> None:
self.buffering_label.setVisible(buffering)
if buffering:
self.buffering_label.raise_()
@Slot(str)
def _on_end_reached(self, reason: str) -> None:
self.playbackEnded.emit(reason)
@@ -656,8 +929,10 @@ class PlayerPanel(QWidget):
if mpv_inst is None:
return
mpv_inst["ytdl-format"] = _ytdl_format_for(height)
# Reload the current item at the new quality, keeping position
if self._current_entry:
# Reload the current item at the new quality, keeping position. Only
# when something is actually loaded -- otherwise changing the default
# quality would start playing whatever was last selected.
if self._current_entry and self._playback_started:
pos = self.current_position()
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
@@ -689,6 +964,71 @@ class PlayerPanel(QWidget):
except Exception:
pass
# ---------------------------------------------------------- mouse/idle
def _on_wheel(self, notches: int, ctrl_held: bool) -> None:
if ctrl_held:
self.seek_relative(notches * SEEK_MEDIUM)
else:
self.nudge_volume(notches * 2)
def _on_mouse_activity(self) -> None:
"""Any movement over the video brings the controls back."""
if not self.controls_bar.isVisible():
self.controls_bar.show()
self.video.unsetCursor()
if self.is_fullscreen():
self._idle_timer.start()
def _hide_idle_controls(self) -> None:
# Only in fullscreen, and never while the pointer is on the bar
# itself -- hiding it out from under the cursor is hostile.
if not self.is_fullscreen() or self.controls_bar.underMouse():
return
try:
if bool(self.video.mpv["pause"]):
return # paused: leave the controls up
except Exception:
pass
self.controls_bar.hide()
self.video.setCursor(Qt.CursorShape.BlankCursor)
def on_fullscreen_changed(self, active: bool) -> None:
"""Called by FullscreenController so idle-hiding follows the state."""
self.fullscreen_btn.setIcon(
icons.icon("minimize" if active else "maximize", theme.ICON_ON_ACCENT)
)
self.fullscreen_btn.setToolTip(
_("player.act_leave_fullscreen") if active else _("player.fullscreen")
)
if active:
self.setFocus(Qt.FocusReason.OtherFocusReason)
self._idle_timer.start()
else:
self._idle_timer.stop()
self.controls_bar.show()
self.video.unsetCursor()
def contextMenuEvent(self, event) -> None:
menu = build_context_menu(self)
menu.exec(event.globalPos())
event.accept()
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
# The buffering label floats over the video, so it has no layout to
# centre it. Positioned even while hidden, so it appears in the right
# place rather than jumping there.
hint = self.buffering_label.sizeHint()
self.buffering_label.setGeometry(
(self.video.width() - hint.width()) // 2,
(self.video.height() - hint.height()) // 2,
hint.width(),
hint.height(),
)
# ------------------------------------------------------------ fullscreen
def set_fullscreen_controller(self, controller) -> None:
"""
Injected by the main window, which owns the chrome being hidden.
+44
View File
@@ -34,6 +34,9 @@ class WatchPage(QWidget):
super().__init__(parent)
self._router = router
self._queue: List[Dict[str, Any]] = []
# What has already played this session, so "previous" has somewhere
# to go. Not persisted: it is session history, not the queue.
self._played: List[Dict[str, Any]] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
@@ -81,6 +84,9 @@ class WatchPage(QWidget):
self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS)
self._position_timer.timeout.connect(self._save_position)
self.player.nowPlayingChanged.connect(self._on_now_playing)
# The panel asks; the queue lives here, so the queue answers.
self.player.nextRequested.connect(self.play_next)
self.player.previousRequested.connect(self.play_previous)
self._restore_queue()
@@ -114,6 +120,39 @@ class WatchPage(QWidget):
def queue_entries(self) -> List[Dict[str, Any]]:
return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())]
def play_next(self) -> None:
"""Next in the queue. Bound to N and the player's next button."""
self._play_next_from_queue()
def play_previous(self) -> None:
"""
Back to what was playing before.
Restarts the current item first if it is more than a few seconds in,
which is what every other player does with a "previous" press.
"""
if not isinstance(self.player, PlayerPanel):
return
if self.player.current_position() > 5.0:
self.player.seek_absolute(0.0)
return
# _played ends with what is playing now, so going back needs two.
if len(self._played) < 2:
return
current = self._played.pop()
previous = self._played[-1]
# Put the current item back at the head of the queue rather than
# dropping it on the floor.
if current:
item = QListWidgetItem(current.get("title") or current.get("url") or "?")
item.setData(Qt.ItemDataRole.UserRole, dict(current))
self.queue_list.insertItem(0, item)
self._queue.insert(0, dict(current))
self._persist_queue()
# play_entry re-pushes `previous`, so drop it here to avoid a double.
self._played.pop()
self.play_entry(previous)
# --------------------------------------------------------------- internal
def _play_next_from_queue(self) -> None:
@@ -148,6 +187,11 @@ class WatchPage(QWidget):
def _on_now_playing(self, entry: Dict[str, Any]) -> None:
LibraryManager.upsert_watch(entry)
# Record what we were on before this, so "previous" can return to it.
# The retry path replays the same entry; don't stack duplicates.
if self._played[-1:] != [entry] and entry:
self._played.append(dict(entry))
del self._played[:-50]
self._duration = 0.0
self._position_timer.start()
+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
+29 -1
View File
@@ -658,7 +658,35 @@
"quality_tooltip": "Maximum playback resolution",
"speed_tooltip": "Playback speed",
"volume_tooltip": "Volume",
"subtitles_tooltip": "Toggle subtitles (C)"
"subtitles_tooltip": "Toggle subtitles (C)",
"previous": "Previous in queue (P)",
"next": "Next in queue (N)",
"buffering": "Buffering…",
"act_play_pause": "Play / pause",
"act_seek_back_5": "Back 5 seconds",
"act_seek_fwd_5": "Forward 5 seconds",
"act_seek_back_10": "Back 10 seconds",
"act_seek_fwd_10": "Forward 10 seconds",
"act_seek_back_1": "Back 1 second",
"act_seek_fwd_1": "Forward 1 second",
"act_frame_back": "Previous frame",
"act_frame_fwd": "Next frame",
"act_vol_up": "Volume up",
"act_vol_down": "Volume down",
"act_mute": "Mute",
"act_unmute": "Unmute",
"act_speed_up": "Speed up",
"act_speed_down": "Slow down",
"act_speed_reset": "Normal speed",
"act_subtitles": "Subtitles on / off",
"act_fullscreen": "Fullscreen",
"act_leave_fullscreen": "Leave fullscreen",
"act_next": "Next in queue",
"act_previous": "Previous",
"act_start": "Back to start",
"act_end": "Jump to end",
"act_copy_url": "Copy video link",
"act_open_browser": "Open in browser"
},
"cards": {
"play": "▶ Play",