diff --git a/CHANGELOG.md b/CHANGELOG.md index a3760c1..d4bebfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, 0–9 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. diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index a48f0f9..4c9daff 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -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) diff --git a/ytsage/gui/ytsage_gui_player.py b/ytsage/gui/ytsage_gui_player.py index b9d3259..e05f7c0 100644 --- a/ytsage/gui/ytsage_gui_player.py +++ b/ytsage/gui/ytsage_gui_player.py @@ -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. diff --git a/ytsage/gui/ytsage_gui_watch.py b/ytsage/gui/ytsage_gui_watch.py index fa57b6e..8adc0b7 100644 --- a/ytsage/gui/ytsage_gui_watch.py +++ b/ytsage/gui/ytsage_gui_watch.py @@ -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() diff --git a/ytsage/gui/ytsage_player_input.py b/ytsage/gui/ytsage_player_input.py new file mode 100644 index 0000000..bcaba86 --- /dev/null +++ b/ytsage/gui/ytsage_player_input.py @@ -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 diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 386c829..2ee3149 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -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",