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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user