From fa2d31243ca28b8a2009d8881810600e84f2d53c Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 14:07:17 -0500 Subject: [PATCH] Move _ClickSeekSlider + VideoPlayer from preview.py to media/video_player.py (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the gui/app.py + gui/preview.py structural refactor. Moves the click-to-seek QSlider variant and the mpv-backed transport-control widget into their own module under media/. The new module imports _MpvGLWidget from .mpv_gl (sibling) instead of relying on the bare name in the old preview.py namespace. Address-only adjustment: the lazy `from ..core.cache import _referer_for` inside `play_file` becomes `from ...core.cache import _referer_for` because the new module sits one package level deeper. Same target module, different relative-import depth — no behavior change. preview.py grows another re-export shim line so ImagePreview (still in preview.py) and FullscreenPreview can keep constructing VideoPlayer unchanged. Shim removed in commit 14. --- booru_viewer/gui/media/video_player.py | 477 +++++++++++++++++++++++++ booru_viewer/gui/preview.py | 465 +----------------------- 2 files changed, 478 insertions(+), 464 deletions(-) create mode 100644 booru_viewer/gui/media/video_player.py diff --git a/booru_viewer/gui/media/video_player.py b/booru_viewer/gui/media/video_player.py new file mode 100644 index 0000000..f04cad3 --- /dev/null +++ b/booru_viewer/gui/media/video_player.py @@ -0,0 +1,477 @@ +"""mpv-backed video player widget with transport controls.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer, Signal, Property +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QStyle, +) + +import mpv as mpvlib + +from .mpv_gl import _MpvGLWidget + + +class _ClickSeekSlider(QSlider): + """Slider that jumps to the clicked position instead of page-stepping.""" + clicked_position = Signal(int) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + val = QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), int(event.position().x()), self.width() + ) + self.setValue(val) + self.clicked_position.emit(val) + super().mousePressEvent(event) + + +# -- Video Player (mpv backend via OpenGL render API) -- + + +class VideoPlayer(QWidget): + """Video player with transport controls, powered by mpv.""" + + play_next = Signal() # emitted when video ends in "Next" mode + media_ready = Signal() # emitted when media is loaded and duration is known + video_size = Signal(int, int) # (width, height) emitted when video dimensions are known + + # QSS-controllable letterbox / pillarbox color. mpv paints the area + # around the video frame in this color instead of the default black, + # so portrait videos in a landscape preview slot (or vice versa) blend + # into the panel theme instead of sitting in a hard black box. + # Set via `VideoPlayer { qproperty-letterboxColor: ${bg}; }` in a theme. + # The class default below is just a fallback; __init__ replaces it + # with the current palette's Window color so systems without a custom + # QSS (e.g. Windows dark/light mode driven entirely by QPalette) get + # a letterbox that automatically matches the OS background. + _letterbox_color = QColor("#000000") + + def _get_letterbox_color(self): return self._letterbox_color + def _set_letterbox_color(self, c): + self._letterbox_color = QColor(c) if isinstance(c, str) else c + self._apply_letterbox_color() + letterboxColor = Property(QColor, _get_letterbox_color, _set_letterbox_color) + + def _apply_letterbox_color(self) -> None: + """Push the current letterbox color into mpv. No-op if mpv hasn't + been initialized yet — _ensure_mpv() calls this after creating the + instance so a QSS-set property still takes effect on first use.""" + if self._mpv is None: + return + try: + self._mpv['background'] = 'color' + self._mpv['background-color'] = self._letterbox_color.name() + except Exception: + pass + + def __init__(self, parent: QWidget | None = None, embed_controls: bool = True) -> None: + """ + embed_controls: When True (default), the transport controls bar is + added to this VideoPlayer's own layout below the video — used by the + popout window which then reparents the bar to its overlay layer. + When False, the controls bar is constructed but never inserted into + any layout, leaving the embedded preview a clean video surface with + no transport controls visible. Use the popout for playback control. + """ + super().__init__(parent) + # Initialize the letterbox color from the current palette's Window + # role so dark/light mode (or any system without a custom QSS) + # gets a sensible default that matches the surrounding panel. + # The QSS qproperty-letterboxColor on the bundled themes still + # overrides this — Qt calls the setter during widget polish, + # which happens AFTER __init__ when the widget is shown. + from PySide6.QtGui import QPalette + self._letterbox_color = self.palette().color(QPalette.ColorRole.Window) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Video surface — mpv renders via OpenGL render API + self._gl_widget = _MpvGLWidget() + layout.addWidget(self._gl_widget, stretch=1) + + # mpv reference (set by _ensure_mpv) + self._mpv: mpvlib.MPV | None = None + + # Controls bar — in preview panel this sits in the layout normally; + # in slideshow mode, FullscreenPreview reparents it as a floating overlay. + self._controls_bar = QWidget(self) + controls = QHBoxLayout(self._controls_bar) + controls.setContentsMargins(4, 2, 4, 2) + + # Compact-padding override matches the top preview toolbar so the + # bottom controls bar reads as part of the same panel rather than + # as a stamped-in overlay. Bundled themes' default `padding: 5px 12px` + # is too wide for short labels in narrow button slots. + _ctrl_btn_style = "padding: 2px 6px;" + + self._play_btn = QPushButton("Play") + self._play_btn.setMaximumWidth(65) + self._play_btn.setStyleSheet(_ctrl_btn_style) + self._play_btn.clicked.connect(self._toggle_play) + controls.addWidget(self._play_btn) + + self._time_label = QLabel("0:00") + self._time_label.setMaximumWidth(45) + controls.addWidget(self._time_label) + + self._seek_slider = _ClickSeekSlider(Qt.Orientation.Horizontal) + self._seek_slider.setRange(0, 0) + self._seek_slider.sliderMoved.connect(self._seek) + self._seek_slider.clicked_position.connect(self._seek) + controls.addWidget(self._seek_slider, stretch=1) + + self._duration_label = QLabel("0:00") + self._duration_label.setMaximumWidth(45) + controls.addWidget(self._duration_label) + + self._vol_slider = QSlider(Qt.Orientation.Horizontal) + self._vol_slider.setRange(0, 100) + self._vol_slider.setValue(50) + self._vol_slider.setFixedWidth(60) + self._vol_slider.valueChanged.connect(self._set_volume) + controls.addWidget(self._vol_slider) + + self._mute_btn = QPushButton("Mute") + self._mute_btn.setMaximumWidth(80) + self._mute_btn.setStyleSheet(_ctrl_btn_style) + self._mute_btn.clicked.connect(self._toggle_mute) + controls.addWidget(self._mute_btn) + + self._autoplay = True + self._autoplay_btn = QPushButton("Auto") + self._autoplay_btn.setMaximumWidth(70) + self._autoplay_btn.setStyleSheet(_ctrl_btn_style) + self._autoplay_btn.setCheckable(True) + self._autoplay_btn.setChecked(True) + self._autoplay_btn.setToolTip("Auto-play videos when selected") + self._autoplay_btn.clicked.connect(self._toggle_autoplay) + self._autoplay_btn.hide() + controls.addWidget(self._autoplay_btn) + + self._loop_state = 0 # 0=Loop, 1=Once, 2=Next + self._loop_btn = QPushButton("Loop") + self._loop_btn.setMaximumWidth(60) + self._loop_btn.setStyleSheet(_ctrl_btn_style) + self._loop_btn.setToolTip("Loop: repeat / Once: stop at end / Next: advance") + self._loop_btn.clicked.connect(self._cycle_loop) + controls.addWidget(self._loop_btn) + + # NO styleSheet here. The popout (FullscreenPreview) re-applies its + # own `_slideshow_controls` overlay styling after reparenting the + # bar to its central widget — see FullscreenPreview.__init__ — so + # the popout still gets the floating dark-translucent look. The + # embedded preview leaves the bar unstyled so it inherits the + # panel theme and visually matches the Bookmark/Save/BL Tag bar + # at the top of the panel rather than looking like a stamped-in + # overlay box. + if embed_controls: + layout.addWidget(self._controls_bar) + + self._eof_pending = False + # Stale-eof suppression window. mpv emits `eof-reached=True` + # whenever a file ends — including via `command('stop')` — + # and the observer fires asynchronously on mpv's event thread. + # When set_media swaps to a new file, the previous file's stop + # generates an eof event that can race with `play_file`'s + # `_eof_pending = False` reset and arrive AFTER it, sticking + # the bool back to True. The next `_poll` then runs + # `_handle_eof` and emits `play_next` in Loop=Next mode → + # auto-advance past the post the user wanted → SKIP. + # + # Fix: ignore eof events for `_eof_ignore_window_secs` after + # each `play_file` call. The race is single-digit ms, so + # 250ms is comfortably wide for the suppression and narrow + # enough not to mask a real EOF on the shortest possible + # videos (booru video clips are always >= 1s). + self._eof_ignore_until: float = 0.0 + self._eof_ignore_window_secs: float = 0.25 + + # Polling timer for position/duration/pause/eof state + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(100) + self._poll_timer.timeout.connect(self._poll) + + # Pending values from mpv observers (written from mpv thread) + self._pending_duration: float | None = None + self._media_ready_fired = False + self._current_file: str | None = None + # Last reported source video size — used to dedupe video-params + # observer firings so widget-driven re-emissions don't trigger + # repeated _fit_to_content calls (which would loop forever). + self._last_video_size: tuple[int, int] | None = None + + def _ensure_mpv(self) -> mpvlib.MPV: + """Set up mpv callbacks on first use. MPV instance is pre-created.""" + if self._mpv is not None: + return self._mpv + self._mpv = self._gl_widget._mpv + self._mpv['loop-file'] = 'inf' # default to loop mode + self._mpv.volume = self._vol_slider.value() + self._mpv.observe_property('duration', self._on_duration_change) + self._mpv.observe_property('eof-reached', self._on_eof_reached) + self._mpv.observe_property('video-params', self._on_video_params) + self._pending_video_size: tuple[int, int] | None = None + # Push any QSS-set letterbox color into mpv now that the instance + # exists. The qproperty-letterboxColor setter is a no-op if mpv + # hasn't been initialized yet, so we have to (re)apply on init. + self._apply_letterbox_color() + return self._mpv + + # -- Public API (used by app.py for state sync) -- + + @property + def volume(self) -> int: + return self._vol_slider.value() + + @volume.setter + def volume(self, val: int) -> None: + self._vol_slider.setValue(val) + + @property + def is_muted(self) -> bool: + if self._mpv: + return bool(self._mpv.mute) + return False + + @is_muted.setter + def is_muted(self, val: bool) -> None: + if self._mpv: + self._mpv.mute = val + self._mute_btn.setText("Unmute" if val else "Mute") + + @property + def autoplay(self) -> bool: + return self._autoplay + + @autoplay.setter + def autoplay(self, val: bool) -> None: + self._autoplay = val + self._autoplay_btn.setChecked(val) + self._autoplay_btn.setText("Autoplay" if val else "Manual") + + @property + def loop_state(self) -> int: + return self._loop_state + + @loop_state.setter + def loop_state(self, val: int) -> None: + self._loop_state = val + labels = ["Loop", "Once", "Next"] + self._loop_btn.setText(labels[val]) + self._autoplay_btn.setVisible(val == 2) + self._apply_loop_to_mpv() + + def get_position_ms(self) -> int: + if self._mpv and self._mpv.time_pos is not None: + return int(self._mpv.time_pos * 1000) + return 0 + + def seek_to_ms(self, ms: int) -> None: + if self._mpv: + self._mpv.seek(ms / 1000.0, 'absolute+exact') + + def play_file(self, path: str, info: str = "") -> None: + """Play a file from a local path OR a remote http(s) URL. + + URL playback is the fast path for uncached videos: rather than + waiting for `download_image` to finish writing the entire file + to disk before mpv touches it, the load flow hands mpv the + remote URL and lets mpv stream + buffer + render the first + frame in parallel with the cache-populating download. mpv's + first frame typically lands in 1-2s instead of waiting for + the full multi-MB transfer. + + For URL paths we set the `referrer` per-file option from the + booru's hostname so CDNs that gate downloads on Referer don't + reject mpv's request — same logic our own httpx client uses + in `cache._referer_for`. python-mpv's `loadfile()` accepts + per-file `**options` kwargs that become `--key=value` overrides + for the duration of that file. + """ + m = self._ensure_mpv() + self._gl_widget.ensure_gl_init() + self._current_file = path + self._media_ready_fired = False + self._pending_duration = None + self._eof_pending = False + # Open the stale-eof suppression window. Any eof-reached event + # arriving from mpv's event thread within the next 250ms is + # treated as belonging to the previous file's stop and + # ignored — see the long comment at __init__'s + # `_eof_ignore_until` definition for the race trace. + import time as _time + self._eof_ignore_until = _time.monotonic() + self._eof_ignore_window_secs + self._last_video_size = None # reset dedupe so new file fires a fit + self._apply_loop_to_mpv() + if path.startswith(("http://", "https://")): + from urllib.parse import urlparse + from ...core.cache import _referer_for + referer = _referer_for(urlparse(path)) + m.loadfile(path, "replace", referrer=referer) + else: + m.loadfile(path) + if self._autoplay: + m.pause = False + else: + m.pause = True + self._play_btn.setText("Pause" if not m.pause else "Play") + self._poll_timer.start() + + def stop(self) -> None: + self._poll_timer.stop() + if self._mpv: + self._mpv.command('stop') + self._time_label.setText("0:00") + self._duration_label.setText("0:00") + self._seek_slider.setRange(0, 0) + self._play_btn.setText("Play") + + def pause(self) -> None: + if self._mpv: + self._mpv.pause = True + self._play_btn.setText("Play") + + def resume(self) -> None: + if self._mpv: + self._mpv.pause = False + self._play_btn.setText("Pause") + + # -- Internal controls -- + + def _toggle_play(self) -> None: + if not self._mpv: + return + self._mpv.pause = not self._mpv.pause + self._play_btn.setText("Play" if self._mpv.pause else "Pause") + + def _toggle_autoplay(self, checked: bool = True) -> None: + self._autoplay = self._autoplay_btn.isChecked() + self._autoplay_btn.setText("Autoplay" if self._autoplay else "Manual") + + def _cycle_loop(self) -> None: + self.loop_state = (self._loop_state + 1) % 3 + + def _apply_loop_to_mpv(self) -> None: + if not self._mpv: + return + if self._loop_state == 0: # Loop + self._mpv['loop-file'] = 'inf' + else: # Once or Next + self._mpv['loop-file'] = 'no' + + def _seek(self, pos: int) -> None: + """Seek to position in milliseconds (from slider).""" + if self._mpv: + self._mpv.seek(pos / 1000.0, 'absolute') + + def _seek_relative(self, ms: int) -> None: + if self._mpv: + self._mpv.seek(ms / 1000.0, 'relative+exact') + + def _set_volume(self, val: int) -> None: + if self._mpv: + self._mpv.volume = val + + def _toggle_mute(self) -> None: + if self._mpv: + self._mpv.mute = not self._mpv.mute + self._mute_btn.setText("Unmute" if self._mpv.mute else "Mute") + + # -- mpv callbacks (called from mpv thread) -- + + def _on_video_params(self, _name: str, value) -> None: + """Called from mpv thread when video dimensions become known.""" + if isinstance(value, dict) and value.get('w') and value.get('h'): + new_size = (value['w'], value['h']) + # mpv re-fires video-params on output-area changes too. Dedupe + # against the source dimensions we last reported so resizing the + # popout doesn't kick off a fit→resize→fit feedback loop. + if new_size != self._last_video_size: + self._last_video_size = new_size + self._pending_video_size = new_size + + def _on_eof_reached(self, _name: str, value) -> None: + """Called from mpv thread when eof-reached changes. + + Suppresses eof events that arrive within the post-play_file + ignore window — those are stale events from the previous + file's stop and would otherwise race the `_eof_pending=False` + reset and trigger a spurious play_next auto-advance. + """ + if value is True: + import time as _time + if _time.monotonic() < self._eof_ignore_until: + # Stale eof from a previous file's stop. Drop it. + return + self._eof_pending = True + + def _on_duration_change(self, _name: str, value) -> None: + if value is not None and value > 0: + self._pending_duration = value + + # -- Main-thread polling -- + + def _poll(self) -> None: + if not self._mpv: + return + # Position + pos = self._mpv.time_pos + if pos is not None: + pos_ms = int(pos * 1000) + if not self._seek_slider.isSliderDown(): + self._seek_slider.setValue(pos_ms) + self._time_label.setText(self._fmt(pos_ms)) + + # Duration (from observer) + dur = self._pending_duration + if dur is not None: + dur_ms = int(dur * 1000) + if self._seek_slider.maximum() != dur_ms: + self._seek_slider.setRange(0, dur_ms) + self._duration_label.setText(self._fmt(dur_ms)) + if not self._media_ready_fired: + self._media_ready_fired = True + self.media_ready.emit() + + # Pause state + paused = self._mpv.pause + expected_text = "Play" if paused else "Pause" + if self._play_btn.text() != expected_text: + self._play_btn.setText(expected_text) + + # Video size (set by observer on mpv thread, emitted here on main thread) + if self._pending_video_size is not None: + w, h = self._pending_video_size + self._pending_video_size = None + self.video_size.emit(w, h) + + # EOF (set by observer on mpv thread, handled here on main thread) + if self._eof_pending: + self._handle_eof() + + def _handle_eof(self) -> None: + """Handle end-of-file on the main thread.""" + if not self._eof_pending: + return + self._eof_pending = False + if self._loop_state == 1: # Once + self.pause() + elif self._loop_state == 2: # Next + self.pause() + self.play_next.emit() + + @staticmethod + def _fmt(ms: int) -> str: + s = ms // 1000 + m = s // 60 + return f"{m}:{s % 60:02d}" + + def destroy(self, *args, **kwargs) -> None: + self._poll_timer.stop() + self._gl_widget.cleanup() + self._mpv = None + super().destroy(*args, **kwargs) diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index c33fd7f..b8e1d6b 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -1075,470 +1075,6 @@ class FullscreenPreview(QMainWindow): super().closeEvent(event) -class _ClickSeekSlider(QSlider): - """Slider that jumps to the clicked position instead of page-stepping.""" - clicked_position = Signal(int) - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - val = QStyle.sliderValueFromPosition( - self.minimum(), self.maximum(), int(event.position().x()), self.width() - ) - self.setValue(val) - self.clicked_position.emit(val) - super().mousePressEvent(event) - - -# -- Video Player (mpv backend via OpenGL render API) -- - - -class VideoPlayer(QWidget): - """Video player with transport controls, powered by mpv.""" - - play_next = Signal() # emitted when video ends in "Next" mode - media_ready = Signal() # emitted when media is loaded and duration is known - video_size = Signal(int, int) # (width, height) emitted when video dimensions are known - - # QSS-controllable letterbox / pillarbox color. mpv paints the area - # around the video frame in this color instead of the default black, - # so portrait videos in a landscape preview slot (or vice versa) blend - # into the panel theme instead of sitting in a hard black box. - # Set via `VideoPlayer { qproperty-letterboxColor: ${bg}; }` in a theme. - # The class default below is just a fallback; __init__ replaces it - # with the current palette's Window color so systems without a custom - # QSS (e.g. Windows dark/light mode driven entirely by QPalette) get - # a letterbox that automatically matches the OS background. - _letterbox_color = QColor("#000000") - - def _get_letterbox_color(self): return self._letterbox_color - def _set_letterbox_color(self, c): - self._letterbox_color = QColor(c) if isinstance(c, str) else c - self._apply_letterbox_color() - letterboxColor = Property(QColor, _get_letterbox_color, _set_letterbox_color) - - def _apply_letterbox_color(self) -> None: - """Push the current letterbox color into mpv. No-op if mpv hasn't - been initialized yet — _ensure_mpv() calls this after creating the - instance so a QSS-set property still takes effect on first use.""" - if self._mpv is None: - return - try: - self._mpv['background'] = 'color' - self._mpv['background-color'] = self._letterbox_color.name() - except Exception: - pass - - def __init__(self, parent: QWidget | None = None, embed_controls: bool = True) -> None: - """ - embed_controls: When True (default), the transport controls bar is - added to this VideoPlayer's own layout below the video — used by the - popout window which then reparents the bar to its overlay layer. - When False, the controls bar is constructed but never inserted into - any layout, leaving the embedded preview a clean video surface with - no transport controls visible. Use the popout for playback control. - """ - super().__init__(parent) - # Initialize the letterbox color from the current palette's Window - # role so dark/light mode (or any system without a custom QSS) - # gets a sensible default that matches the surrounding panel. - # The QSS qproperty-letterboxColor on the bundled themes still - # overrides this — Qt calls the setter during widget polish, - # which happens AFTER __init__ when the widget is shown. - from PySide6.QtGui import QPalette - self._letterbox_color = self.palette().color(QPalette.ColorRole.Window) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Video surface — mpv renders via OpenGL render API - self._gl_widget = _MpvGLWidget() - layout.addWidget(self._gl_widget, stretch=1) - - # mpv reference (set by _ensure_mpv) - self._mpv: mpvlib.MPV | None = None - - # Controls bar — in preview panel this sits in the layout normally; - # in slideshow mode, FullscreenPreview reparents it as a floating overlay. - self._controls_bar = QWidget(self) - controls = QHBoxLayout(self._controls_bar) - controls.setContentsMargins(4, 2, 4, 2) - - # Compact-padding override matches the top preview toolbar so the - # bottom controls bar reads as part of the same panel rather than - # as a stamped-in overlay. Bundled themes' default `padding: 5px 12px` - # is too wide for short labels in narrow button slots. - _ctrl_btn_style = "padding: 2px 6px;" - - self._play_btn = QPushButton("Play") - self._play_btn.setMaximumWidth(65) - self._play_btn.setStyleSheet(_ctrl_btn_style) - self._play_btn.clicked.connect(self._toggle_play) - controls.addWidget(self._play_btn) - - self._time_label = QLabel("0:00") - self._time_label.setMaximumWidth(45) - controls.addWidget(self._time_label) - - self._seek_slider = _ClickSeekSlider(Qt.Orientation.Horizontal) - self._seek_slider.setRange(0, 0) - self._seek_slider.sliderMoved.connect(self._seek) - self._seek_slider.clicked_position.connect(self._seek) - controls.addWidget(self._seek_slider, stretch=1) - - self._duration_label = QLabel("0:00") - self._duration_label.setMaximumWidth(45) - controls.addWidget(self._duration_label) - - self._vol_slider = QSlider(Qt.Orientation.Horizontal) - self._vol_slider.setRange(0, 100) - self._vol_slider.setValue(50) - self._vol_slider.setFixedWidth(60) - self._vol_slider.valueChanged.connect(self._set_volume) - controls.addWidget(self._vol_slider) - - self._mute_btn = QPushButton("Mute") - self._mute_btn.setMaximumWidth(80) - self._mute_btn.setStyleSheet(_ctrl_btn_style) - self._mute_btn.clicked.connect(self._toggle_mute) - controls.addWidget(self._mute_btn) - - self._autoplay = True - self._autoplay_btn = QPushButton("Auto") - self._autoplay_btn.setMaximumWidth(70) - self._autoplay_btn.setStyleSheet(_ctrl_btn_style) - self._autoplay_btn.setCheckable(True) - self._autoplay_btn.setChecked(True) - self._autoplay_btn.setToolTip("Auto-play videos when selected") - self._autoplay_btn.clicked.connect(self._toggle_autoplay) - self._autoplay_btn.hide() - controls.addWidget(self._autoplay_btn) - - self._loop_state = 0 # 0=Loop, 1=Once, 2=Next - self._loop_btn = QPushButton("Loop") - self._loop_btn.setMaximumWidth(60) - self._loop_btn.setStyleSheet(_ctrl_btn_style) - self._loop_btn.setToolTip("Loop: repeat / Once: stop at end / Next: advance") - self._loop_btn.clicked.connect(self._cycle_loop) - controls.addWidget(self._loop_btn) - - # NO styleSheet here. The popout (FullscreenPreview) re-applies its - # own `_slideshow_controls` overlay styling after reparenting the - # bar to its central widget — see FullscreenPreview.__init__ — so - # the popout still gets the floating dark-translucent look. The - # embedded preview leaves the bar unstyled so it inherits the - # panel theme and visually matches the Bookmark/Save/BL Tag bar - # at the top of the panel rather than looking like a stamped-in - # overlay box. - if embed_controls: - layout.addWidget(self._controls_bar) - - self._eof_pending = False - # Stale-eof suppression window. mpv emits `eof-reached=True` - # whenever a file ends — including via `command('stop')` — - # and the observer fires asynchronously on mpv's event thread. - # When set_media swaps to a new file, the previous file's stop - # generates an eof event that can race with `play_file`'s - # `_eof_pending = False` reset and arrive AFTER it, sticking - # the bool back to True. The next `_poll` then runs - # `_handle_eof` and emits `play_next` in Loop=Next mode → - # auto-advance past the post the user wanted → SKIP. - # - # Fix: ignore eof events for `_eof_ignore_window_secs` after - # each `play_file` call. The race is single-digit ms, so - # 250ms is comfortably wide for the suppression and narrow - # enough not to mask a real EOF on the shortest possible - # videos (booru video clips are always >= 1s). - self._eof_ignore_until: float = 0.0 - self._eof_ignore_window_secs: float = 0.25 - - # Polling timer for position/duration/pause/eof state - self._poll_timer = QTimer(self) - self._poll_timer.setInterval(100) - self._poll_timer.timeout.connect(self._poll) - - # Pending values from mpv observers (written from mpv thread) - self._pending_duration: float | None = None - self._media_ready_fired = False - self._current_file: str | None = None - # Last reported source video size — used to dedupe video-params - # observer firings so widget-driven re-emissions don't trigger - # repeated _fit_to_content calls (which would loop forever). - self._last_video_size: tuple[int, int] | None = None - - def _ensure_mpv(self) -> mpvlib.MPV: - """Set up mpv callbacks on first use. MPV instance is pre-created.""" - if self._mpv is not None: - return self._mpv - self._mpv = self._gl_widget._mpv - self._mpv['loop-file'] = 'inf' # default to loop mode - self._mpv.volume = self._vol_slider.value() - self._mpv.observe_property('duration', self._on_duration_change) - self._mpv.observe_property('eof-reached', self._on_eof_reached) - self._mpv.observe_property('video-params', self._on_video_params) - self._pending_video_size: tuple[int, int] | None = None - # Push any QSS-set letterbox color into mpv now that the instance - # exists. The qproperty-letterboxColor setter is a no-op if mpv - # hasn't been initialized yet, so we have to (re)apply on init. - self._apply_letterbox_color() - return self._mpv - - # -- Public API (used by app.py for state sync) -- - - @property - def volume(self) -> int: - return self._vol_slider.value() - - @volume.setter - def volume(self, val: int) -> None: - self._vol_slider.setValue(val) - - @property - def is_muted(self) -> bool: - if self._mpv: - return bool(self._mpv.mute) - return False - - @is_muted.setter - def is_muted(self, val: bool) -> None: - if self._mpv: - self._mpv.mute = val - self._mute_btn.setText("Unmute" if val else "Mute") - - @property - def autoplay(self) -> bool: - return self._autoplay - - @autoplay.setter - def autoplay(self, val: bool) -> None: - self._autoplay = val - self._autoplay_btn.setChecked(val) - self._autoplay_btn.setText("Autoplay" if val else "Manual") - - @property - def loop_state(self) -> int: - return self._loop_state - - @loop_state.setter - def loop_state(self, val: int) -> None: - self._loop_state = val - labels = ["Loop", "Once", "Next"] - self._loop_btn.setText(labels[val]) - self._autoplay_btn.setVisible(val == 2) - self._apply_loop_to_mpv() - - def get_position_ms(self) -> int: - if self._mpv and self._mpv.time_pos is not None: - return int(self._mpv.time_pos * 1000) - return 0 - - def seek_to_ms(self, ms: int) -> None: - if self._mpv: - self._mpv.seek(ms / 1000.0, 'absolute+exact') - - def play_file(self, path: str, info: str = "") -> None: - """Play a file from a local path OR a remote http(s) URL. - - URL playback is the fast path for uncached videos: rather than - waiting for `download_image` to finish writing the entire file - to disk before mpv touches it, the load flow hands mpv the - remote URL and lets mpv stream + buffer + render the first - frame in parallel with the cache-populating download. mpv's - first frame typically lands in 1-2s instead of waiting for - the full multi-MB transfer. - - For URL paths we set the `referrer` per-file option from the - booru's hostname so CDNs that gate downloads on Referer don't - reject mpv's request — same logic our own httpx client uses - in `cache._referer_for`. python-mpv's `loadfile()` accepts - per-file `**options` kwargs that become `--key=value` overrides - for the duration of that file. - """ - m = self._ensure_mpv() - self._gl_widget.ensure_gl_init() - self._current_file = path - self._media_ready_fired = False - self._pending_duration = None - self._eof_pending = False - # Open the stale-eof suppression window. Any eof-reached event - # arriving from mpv's event thread within the next 250ms is - # treated as belonging to the previous file's stop and - # ignored — see the long comment at __init__'s - # `_eof_ignore_until` definition for the race trace. - import time as _time - self._eof_ignore_until = _time.monotonic() + self._eof_ignore_window_secs - self._last_video_size = None # reset dedupe so new file fires a fit - self._apply_loop_to_mpv() - if path.startswith(("http://", "https://")): - from urllib.parse import urlparse - from ..core.cache import _referer_for - referer = _referer_for(urlparse(path)) - m.loadfile(path, "replace", referrer=referer) - else: - m.loadfile(path) - if self._autoplay: - m.pause = False - else: - m.pause = True - self._play_btn.setText("Pause" if not m.pause else "Play") - self._poll_timer.start() - - def stop(self) -> None: - self._poll_timer.stop() - if self._mpv: - self._mpv.command('stop') - self._time_label.setText("0:00") - self._duration_label.setText("0:00") - self._seek_slider.setRange(0, 0) - self._play_btn.setText("Play") - - def pause(self) -> None: - if self._mpv: - self._mpv.pause = True - self._play_btn.setText("Play") - - def resume(self) -> None: - if self._mpv: - self._mpv.pause = False - self._play_btn.setText("Pause") - - # -- Internal controls -- - - def _toggle_play(self) -> None: - if not self._mpv: - return - self._mpv.pause = not self._mpv.pause - self._play_btn.setText("Play" if self._mpv.pause else "Pause") - - def _toggle_autoplay(self, checked: bool = True) -> None: - self._autoplay = self._autoplay_btn.isChecked() - self._autoplay_btn.setText("Autoplay" if self._autoplay else "Manual") - - def _cycle_loop(self) -> None: - self.loop_state = (self._loop_state + 1) % 3 - - def _apply_loop_to_mpv(self) -> None: - if not self._mpv: - return - if self._loop_state == 0: # Loop - self._mpv['loop-file'] = 'inf' - else: # Once or Next - self._mpv['loop-file'] = 'no' - - def _seek(self, pos: int) -> None: - """Seek to position in milliseconds (from slider).""" - if self._mpv: - self._mpv.seek(pos / 1000.0, 'absolute') - - def _seek_relative(self, ms: int) -> None: - if self._mpv: - self._mpv.seek(ms / 1000.0, 'relative+exact') - - def _set_volume(self, val: int) -> None: - if self._mpv: - self._mpv.volume = val - - def _toggle_mute(self) -> None: - if self._mpv: - self._mpv.mute = not self._mpv.mute - self._mute_btn.setText("Unmute" if self._mpv.mute else "Mute") - - # -- mpv callbacks (called from mpv thread) -- - - def _on_video_params(self, _name: str, value) -> None: - """Called from mpv thread when video dimensions become known.""" - if isinstance(value, dict) and value.get('w') and value.get('h'): - new_size = (value['w'], value['h']) - # mpv re-fires video-params on output-area changes too. Dedupe - # against the source dimensions we last reported so resizing the - # popout doesn't kick off a fit→resize→fit feedback loop. - if new_size != self._last_video_size: - self._last_video_size = new_size - self._pending_video_size = new_size - - def _on_eof_reached(self, _name: str, value) -> None: - """Called from mpv thread when eof-reached changes. - - Suppresses eof events that arrive within the post-play_file - ignore window — those are stale events from the previous - file's stop and would otherwise race the `_eof_pending=False` - reset and trigger a spurious play_next auto-advance. - """ - if value is True: - import time as _time - if _time.monotonic() < self._eof_ignore_until: - # Stale eof from a previous file's stop. Drop it. - return - self._eof_pending = True - - def _on_duration_change(self, _name: str, value) -> None: - if value is not None and value > 0: - self._pending_duration = value - - # -- Main-thread polling -- - - def _poll(self) -> None: - if not self._mpv: - return - # Position - pos = self._mpv.time_pos - if pos is not None: - pos_ms = int(pos * 1000) - if not self._seek_slider.isSliderDown(): - self._seek_slider.setValue(pos_ms) - self._time_label.setText(self._fmt(pos_ms)) - - # Duration (from observer) - dur = self._pending_duration - if dur is not None: - dur_ms = int(dur * 1000) - if self._seek_slider.maximum() != dur_ms: - self._seek_slider.setRange(0, dur_ms) - self._duration_label.setText(self._fmt(dur_ms)) - if not self._media_ready_fired: - self._media_ready_fired = True - self.media_ready.emit() - - # Pause state - paused = self._mpv.pause - expected_text = "Play" if paused else "Pause" - if self._play_btn.text() != expected_text: - self._play_btn.setText(expected_text) - - # Video size (set by observer on mpv thread, emitted here on main thread) - if self._pending_video_size is not None: - w, h = self._pending_video_size - self._pending_video_size = None - self.video_size.emit(w, h) - - # EOF (set by observer on mpv thread, handled here on main thread) - if self._eof_pending: - self._handle_eof() - - def _handle_eof(self) -> None: - """Handle end-of-file on the main thread.""" - if not self._eof_pending: - return - self._eof_pending = False - if self._loop_state == 1: # Once - self.pause() - elif self._loop_state == 2: # Next - self.pause() - self.play_next.emit() - - @staticmethod - def _fmt(ms: int) -> str: - s = ms // 1000 - m = s // 60 - return f"{m}:{s % 60:02d}" - - def destroy(self, *args, **kwargs) -> None: - self._poll_timer.stop() - self._gl_widget.cleanup() - self._mpv = None - super().destroy(*args, **kwargs) - - # -- Combined Preview (image + video) -- class ImagePreview(QWidget): @@ -1948,3 +1484,4 @@ from .media.constants import VIDEO_EXTENSIONS, _is_video # re-export for refact from .popout.viewport import Viewport, _DRIFT_TOLERANCE # re-export for refactor compat from .media.image_viewer import ImageViewer # re-export for refactor compat from .media.mpv_gl import _MpvGLWidget, _MpvOpenGLSurface # re-export for refactor compat +from .media.video_player import _ClickSeekSlider, VideoPlayer # re-export for refactor compat