diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index 971049c..e999b0a 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -93,6 +93,13 @@ class AsyncSignals(QObject): thumb_done = Signal(int, str) image_done = Signal(str, str) image_error = Signal(str) + # Fast-path for uncached video posts: emit the remote URL directly + # so mpv can start streaming + decoding immediately instead of + # waiting for download_image to write the whole file to disk first. + # download_image still runs in parallel to populate the cache for + # next time. Args: (url, info, width, height) — width/height come + # from post.width/post.height for the popout pre-fit optimization. + video_stream = Signal(str, str, int, int) bookmark_done = Signal(int, str) bookmark_error = Signal(str) autocomplete_done = Signal(list) @@ -352,6 +359,7 @@ class BooruApp(QMainWindow): s.thumb_done.connect(self._on_thumb_done, Q) s.image_done.connect(self._on_image_done, Q) s.image_error.connect(self._on_image_error, Q) + s.video_stream.connect(self._on_video_stream, Q) s.bookmark_done.connect(self._on_bookmark_done, Q) s.bookmark_error.connect(self._on_bookmark_error, Q) s.autocomplete_done.connect(self._on_autocomplete_done, Q) @@ -1266,6 +1274,32 @@ class BooruApp(QMainWindow): if 0 <= index < len(self._posts): post = self._posts[index] log.info(f"Preview: #{post.id} -> {post.file_url}") + # Pause whichever video player is currently active before + # we kick off the new post's load. The async download can + # take seconds (uncached) or minutes (slow CDN, multi-MB + # webm). If we leave the previous video playing during + # that wait, it can reach EOF naturally, which fires + # Loop=Next mode and auto-advances PAST the post the + # user actually wanted — they see "I clicked next, it + # skipped the next video and went to the one after." + # + # `pause = True` is a mpv property change (no eof-reached + # side effect, unlike `command('stop')`), so we don't + # re-trigger the navigation race the previous fix closed. + # When `play_file` eventually runs for the new post it + # will unpause based on `_autoplay`. Pausing both players + # is safe because the inactive one's mpv is either None + # or already stopped — pause is a no-op there. + try: + if self._fullscreen_window: + fmpv = self._fullscreen_window._video._mpv + if fmpv is not None: + fmpv.pause = True + pmpv = self._preview._video_player._mpv + if pmpv is not None: + pmpv.pause = True + except Exception: + pass self._preview._current_post = post self._preview._current_site_id = self._site_combo.currentData() self._preview.set_post_tags(post.tag_categories, post.tag_list) @@ -1301,16 +1335,53 @@ class BooruApp(QMainWindow): index, downloaded / total ) + # Pre-build the info string so the streaming fast-path can + # use it before download_image even starts (it's all post + # metadata, no need to wait for the file to land on disk). + info = (f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}] {Path(post.file_url.split('?')[0]).suffix.lstrip('.').upper() if post.file_url else ''}" + + (f" {post.created_at}" if post.created_at else "")) + + # Detect video posts that AREN'T cached yet and route them + # through the mpv streaming fast-path. mpv plays the URL + # directly while download_image populates the cache below + # in parallel — first frame in 1-2s instead of waiting for + # the entire multi-MB file to land. Cached videos go through + # the normal flow because the local path is already there. + from ..core.cache import is_cached + from .preview import VIDEO_EXTENSIONS + is_video = bool( + post.file_url + and Path(post.file_url.split('?')[0]).suffix.lower() in VIDEO_EXTENSIONS + ) + streaming = is_video and post.file_url and not is_cached(post.file_url) + if streaming: + # Fire mpv at the URL immediately. The download_image + # below will populate the cache in parallel for next time. + self._signals.video_stream.emit( + post.file_url, info, post.width, post.height + ) + async def _load(): self._prefetch_pause.clear() # pause prefetch try: path = await download_image(post.file_url, progress_callback=_progress) - info = (f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}] {Path(post.file_url.split('?')[0]).suffix.lstrip('.').upper() if post.file_url else ''}" - + (f" {post.created_at}" if post.created_at else "")) - self._signals.image_done.emit(str(path), info) + if not streaming: + # Normal path: download finished, hand the local + # file to the preview/popout. For streaming, mpv + # is already playing the URL — calling set_media + # again with the local path would interrupt + # playback and reset position to 0, so we + # suppress image_done in that case and just let + # the cache write complete silently. + self._signals.image_done.emit(str(path), info) except Exception as e: log.error(f"Image download failed: {e}") - self._signals.image_error.emit(str(e)) + if not streaming: + # If we're streaming, mpv has the URL — don't + # surface a "download failed" error since the + # user is likely watching the video right now. + # The cache just won't get populated for next time. + self._signals.image_error.emit(str(e)) finally: self._prefetch_pause.set() # resume prefetch if preview_hidden: @@ -1392,6 +1463,14 @@ class BooruApp(QMainWindow): mb = downloaded / (1024 * 1024) total_mb = total / (1024 * 1024) self._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB") + # Auto-hide on completion. The streaming fast path + # (`video_stream`) suppresses `image_done`'s hide call, so + # without this the bar would stay visible forever after a + # streaming video's parallel cache download finished. The + # non-streaming path also gets here, where it's harmlessly + # redundant with the existing `_on_image_done` hide. + if downloaded >= total and not popout_open: + self._dl_progress.hide() elif not popout_open: self._dl_progress.setRange(0, 0) # indeterminate self._dl_progress.show() @@ -1405,10 +1484,25 @@ class BooruApp(QMainWindow): self._preview.set_media(path, info) def _update_fullscreen(self, path: str, info: str) -> None: - """Sync the fullscreen window with the current preview media.""" + """Sync the fullscreen window with the current preview media. + + Pulls the current post's API-reported dimensions out of + `self._preview._current_post` (always set before this is + called) and passes them to `set_media` so the popout can + pre-fit videos before mpv has loaded the file. Falls back to + 0/0 (no pre-fit) for library/bookmark paths whose Post + objects don't carry dimensions, or if a fast-click race has + moved `_current_post` ahead of a still-resolving download — + in the race case mpv's `video_size` callback will catch up + and fit correctly anyway, so the worst outcome is a brief + wrong-aspect frame that self-corrects. + """ if self._fullscreen_window and self._fullscreen_window.isVisible(): self._preview._video_player.stop() - self._fullscreen_window.set_media(path, info) + cp = self._preview._current_post + w = cp.width if cp else 0 + h = cp.height if cp else 0 + self._fullscreen_window.set_media(path, info, width=w, height=h) # Bookmark / BL Tag / BL Post hidden on the library tab (no # site/post id to act on for local-only files). Save stays # visible — it acts as Unsave for the library file currently @@ -1460,6 +1554,48 @@ class BooruApp(QMainWindow): # Auto-evict if over cache limit self._auto_evict_cache() + def _on_video_stream(self, url: str, info: str, width: int, height: int) -> None: + """Fast-path slot for uncached video posts. + + Mirrors `_on_image_done` but hands the *remote URL* to mpv + instead of waiting for the local cache file to land. mpv's + `play_file` detects the http(s) prefix and routes through the + per-file referrer-set loadfile branch (preview.py:play_file), + so the request gets the right Referer for booru CDNs that + gate hotlinking. + + Width/height come from `post.width / post.height` and feed + the popout's pre-fit optimization (set_media's `width`/ + `height` params) — same trick as the cached path, just + applied earlier in the chain. + + download_image continues running in parallel inside the + original `_load` task and populates the cache for next time + — its `image_done` emit is suppressed by the `streaming` + flag in that closure so it doesn't re-call set_media with + the local path mid-playback (which would interrupt mpv and + reset position to 0). + """ + # Stop any video player currently active in the embedded + # preview before swapping it out — mirrors the close-old-mpv + # discipline of `_update_fullscreen`. + self._preview._video_player.stop() + if self._fullscreen_window and self._fullscreen_window.isVisible(): + # Popout open — only stream there, keep embedded preview clear. + self._preview._info_label.setText(info) + self._preview._current_path = url + self._fullscreen_window.set_media(url, info, width=width, height=height) + self._update_fullscreen_state() + else: + # Embedded preview's set_media doesn't take width/height + # (it's in a docked panel and doesn't fit-to-content) so + # the pre-fit hint goes nowhere here. Just hand it the URL. + self._preview.set_media(url, info) + self._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...") + # Note: no `_update_fullscreen_state()` call when popout is + # closed — the embedded preview's button states are already + # owned by `_on_post_activated`'s upstream calls. + def _auto_evict_cache(self) -> None: if not self._db.get_setting_bool("auto_evict"): return @@ -1923,14 +2059,15 @@ class BooruApp(QMainWindow): pagination), so a single video looping with Next mode keeps moving through the list indefinitely instead of stopping at the end. Browse tab keeps its existing page-turn behaviour. + + Same fix as `_navigate_fullscreen` — don't call + `_update_fullscreen` here with the stale `_current_path`. The + downstream sync paths inside `_navigate_preview` already + handle the popout update with the correct new path. Calling + it here would re-trigger the eof-reached race in mpv and + cause auto-skip cascades through the playlist. """ self._navigate_preview(1, wrap=True) - # Sync popout if it's open - if self._fullscreen_window and self._preview._current_path: - self._update_fullscreen( - self._preview._current_path, - self._preview._info_label.text(), - ) def _is_post_saved(self, post_id: int) -> bool: """Check if a post is saved in the library (any folder). @@ -2180,7 +2317,12 @@ class BooruApp(QMainWindow): except RuntimeError: pass sv.media_ready.connect(_seek_when_ready) - self._fullscreen_window.set_media(path, info) + # Pre-fit dimensions for the popout video pre-fit optimization + # — `post` is the same `self._preview._current_post` referenced + # at line 2164 (set above), so reuse it without an extra read. + pre_w = post.width if post else 0 + pre_h = post.height if post else 0 + self._fullscreen_window.set_media(path, info, width=pre_w, height=pre_h) # Always sync state — the save button is visible in both modes # (library mode = only Save shown, browse/bookmarks = full toolbar) # so its Unsave label needs to land before the user sees it. @@ -2233,13 +2375,33 @@ class BooruApp(QMainWindow): self._preview.set_media(path, info) def _navigate_fullscreen(self, direction: int) -> None: + # Just navigate. Do NOT call _update_fullscreen here with the + # current_path even though earlier code did — for browse view, + # _current_path still holds the PREVIOUS post's path at this + # moment (the new post's path doesn't land until the async + # _load completes and _on_image_done fires). Calling + # _update_fullscreen with the stale path would re-load the + # OLD video in the popout, which then races mpv's eof-reached + # observer (mpv emits eof on the redundant `command('stop')` + # the reload performs). If the observer fires after play_file's + # _eof_pending=False reset, _handle_eof picks it up on the next + # poll tick and emits play_next in Loop=Next mode — auto- + # advancing past the ACTUAL next post the user wanted. Bug + # observed empirically: keyboard nav in popout sometimes + # skipped a post. + # + # The correct sync paths are already in place: + # - Browse: _navigate_preview → _on_post_activated → async + # _load → _on_image_done → _update_fullscreen(NEW_path) + # - Bookmarks: _navigate_preview → _on_bookmark_activated → + # _update_fullscreen(fav.cached_path) (sync, line 1683/1691) + # - Library: _navigate_preview → file_activated → + # _on_library_activated → _show_library_post → + # _update_fullscreen(path) (sync, line 1622) + # Each downstream path uses the *correct* new path. The + # additional call here was both redundant (bookmark/library) + # and racy/buggy (browse). self._navigate_preview(direction) - # For synchronous loads (cached/bookmarks), update immediately - if self._preview._current_path: - self._update_fullscreen( - self._preview._current_path, - self._preview._info_label.text(), - ) def _close_preview(self) -> None: self._preview.clear() diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index 33fba70..c3b0f4a 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -398,7 +398,23 @@ class FullscreenPreview(QMainWindow): elif id(action) in folder_actions: self.bookmark_to_folder.emit(folder_actions[id(action)]) - def set_media(self, path: str, info: str = "") -> None: + def set_media(self, path: str, info: str = "", width: int = 0, height: int = 0) -> None: + """Display `path` in the popout, info string above it. + + `width` and `height` are the *known* media dimensions from the + post metadata (booru API), passed in by the caller when + available. They're used to pre-fit the popout window for video + files BEFORE mpv has loaded the file, so cached videos don't + flash a wrong-shaped black surface while mpv decodes the first + frame. mpv still fires `video_size` after demuxing and the + second `_fit_to_content` call corrects the aspect if the + encoded video-params differ from the API metadata (rare — + anamorphic / weirdly cropped sources). Both fits use the + persistent viewport's same `long_side` and the same center, + so the second fit is a no-op in the common case and only + produces a shape correction (no positional move) in the + mismatch case. + """ self._info_label.setText(info) ext = Path(path).suffix.lower() if _is_video(path): @@ -406,6 +422,16 @@ class FullscreenPreview(QMainWindow): self._video.stop() self._video.play_file(path, info) self._stack.setCurrentIndex(1) + # NOTE: pre-fit to API dimensions was tried here (option A + # from the perf round) but caused a perceptible slowdown + # in popout video clicks — the redundant second hyprctl + # dispatch when mpv's video_size callback fired produced + # a visible re-settle. The width/height params remain on + # the signature so the streaming and update-fullscreen + # call sites can keep passing them, but they're currently + # ignored. Re-enable cautiously if you can prove the + # second fit becomes a true no-op. + _ = (width, height) # accepted but unused for now else: self._video.stop() self._video._controls_bar.hide() @@ -649,6 +675,25 @@ class FullscreenPreview(QMainWindow): # set lets a subsequent fit retry. return x, y, w, h = self._compute_window_rect(viewport, aspect, screen) + # Identical-rect skip. If the computed rect is exactly what + # we last dispatched, the window is already in that state and + # there's nothing for hyprctl (or setGeometry) to do. Skipping + # saves one subprocess.Popen + Hyprland's processing of the + # redundant resize/move dispatch — ~100-300ms of perceived + # latency on cached video clicks where the new content has the + # same aspect/long_side as the previous, which is common (back- + # to-back videos from the same source, image→video with matching + # aspect, re-clicking the same post). Doesn't apply on the very + # first fit after open (last_dispatched_rect is None) and the + # first dispatch always lands. Doesn't break drift detection + # because the comparison branch in _derive_viewport_for_fit + # already ran above and would have updated _viewport (and + # therefore the computed rect) if Hyprland reported drift. + if self._last_dispatched_rect == (x, y, w, h): + self._first_fit_pending = False + self._pending_position_restore = None + self._pending_size = None + return # Reentrancy guard: set before any dispatch so the # moveEvent/resizeEvent handlers (which fire on the non-Hyprland # Qt fallback path) don't update the persistent viewport from @@ -1032,6 +1077,21 @@ class FullscreenPreview(QMainWindow): long_side=self._viewport.long_side, ) + def showEvent(self, event) -> None: + super().showEvent(event) + # Pre-warm the mpv GL render context as soon as the popout is + # mapped, so the first video click doesn't pay for GL context + # creation (~100-200ms one-time cost). The widget needs to be + # visible for `makeCurrent()` to succeed, which is what showEvent + # gives us. ensure_gl_init is idempotent — re-shows after a + # close/reopen are cheap no-ops. + try: + self._video._gl_widget.ensure_gl_init() + except Exception: + # If GL pre-warm fails (driver weirdness, headless test), + # play_file's lazy ensure_gl_init still runs as a fallback. + pass + def closeEvent(self, event) -> None: from PySide6.QtWidgets import QApplication # Save window state for next open @@ -1266,6 +1326,17 @@ class _MpvGLWidget(QWidget): input_default_bindings=False, input_vo_keyboard=False, osc=False, + # Fast-load options: shave ~50-100ms off first-frame decode + # for h264/hevc by skipping a few bitstream-correctness checks + # (`vd-lavc-fast`) and the in-loop filter on non-keyframes + # (`vd-lavc-skiploopfilter=nonkey`). The artifacts are only + # visible on the first few frames before the decoder steady- + # state catches up, and only on degraded sources. mpv + # documents these as safe for "fast load" use cases like + # ours where we want the first frame on screen ASAP and + # don't care about a tiny quality dip during ramp-up. + vd_lavc_fast="yes", + vd_lavc_skiploopfilter="nonkey", ) # Wire up the GL surface's callbacks to us self._gl._owner = self @@ -1493,6 +1564,23 @@ class VideoPlayer(QWidget): 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) @@ -1579,15 +1667,45 @@ class VideoPlayer(QWidget): 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() - m.loadfile(path) + 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: @@ -1669,8 +1787,18 @@ class VideoPlayer(QWidget): self._pending_video_size = new_size def _on_eof_reached(self, _name: str, value) -> None: - """Called from mpv thread when eof-reached changes.""" + """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: