From 2fbf2f64720e57385df6fb4937e45d008dbdfcea Mon Sep 17 00:00:00 2001 From: pax Date: Mon, 6 Apr 2026 13:43:46 -0500 Subject: [PATCH] 0.2.0: mpv backend, popout viewer, preview toolbar, API retry, SearchState refactor Video: - Replace Qt Multimedia with mpv via python-mpv + OpenGL render API - Hardware-accelerated decoding, frame-accurate seeking, proper EOF detection - Translucent overlay controls in both preview and popout - LC_NUMERIC=C for mpv locale compatibility Popout viewer (renamed from slideshow): - Floating toolbar + controls overlay with auto-hide (2s) - Window auto-resizes to content aspect ratio on navigation - Hyprland: hyprctl resizewindowpixel + keep_aspect_ratio prop - Window geometry persisted to DB across sessions - Smart F11 exit sizing (60% monitor, centered) Preview toolbar: - Bookmark, Save, BL Tag, BL Post, Popout buttons above preview - Save opens folder picker menu, shows Save/Unsave state - Blacklist actions have confirmation dialogs - Per-tab button visibility (Library: Save + Popout only) - Cross-tab state management with grid selection clearing Search & pagination: - SearchState dataclass replaces 8 scattered attrs + defensive getattr - Media type filter dropdown (All/Animated/Video/GIF/Audio) - API retry with backoff on 429/503/timeout - Infinite scroll dedup fix (local seen set per backfill round) - Prev/Next buttons hide at boundaries, "(end)" status indicator Grid: - Rubber band drag selection - Saved/bookmarked dots update instantly across all tabs - Library/bookmarks emit signals on file deletion for cross-tab sync Settings & misc: - Default site option - Max thumbnail cache setting (500MB default) - Source URLs clickable in info panel - Long URLs truncated to prevent splitter blowout - Bulk save no longer auto-bookmarks --- CHANGELOG.md | 87 +++ README.md | 45 +- booru-viewer.spec | 3 +- booru_viewer/core/api/base.py | 31 ++ booru_viewer/core/api/danbooru.py | 10 +- booru_viewer/core/api/e621.py | 10 +- booru_viewer/core/api/gelbooru.py | 10 +- booru_viewer/core/api/moebooru.py | 8 +- booru_viewer/core/cache.py | 20 + booru_viewer/core/db.py | 1 + booru_viewer/gui/app.py | 700 ++++++++++++++++-------- booru_viewer/gui/bookmarks.py | 21 +- booru_viewer/gui/grid.py | 49 +- booru_viewer/gui/library.py | 10 +- booru_viewer/gui/preview.py | 881 ++++++++++++++++++++++++------ booru_viewer/gui/settings.py | 22 +- installer.iss | 2 +- pyproject.toml | 3 +- themes/README.md | 61 ++- themes/catppuccin-mocha.qss | 7 + themes/everforest.qss | 7 + themes/gruvbox.qss | 7 + themes/nord.qss | 7 + themes/solarized-dark.qss | 7 + themes/tokyo-night.qss | 7 + 25 files changed, 1585 insertions(+), 431 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b1b066c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +## 0.2.0 + +### New: mpv video backend +- Replaced Qt Multimedia (QMediaPlayer/QVideoWidget) with embedded mpv via `python-mpv` +- OpenGL render API (`MpvRenderContext`) for Wayland-native compositing — no XWayland needed +- Proper hardware-accelerated decoding (`hwdec=auto`) +- Reliable aspect ratio handling — portrait videos scale correctly +- Proper end-of-file detection via `eof-reached` property observer instead of fragile position-jump heuristic +- Frame-accurate seeking with `absolute+exact` and `relative+exact` +- `keep-open=yes` holds last frame on video end instead of flashing black +- Windows: bundle `mpv-2.dll` in PyInstaller build + +### New: popout viewer (renamed from slideshow) +- Renamed "Slideshow" to "Popout" throughout UI +- Toolbar and video controls float over media with translucent background (`rgba(0,0,0,160)`) +- Auto-hide after 2 seconds of inactivity, reappear on mouse move +- Ctrl+H manual toggle +- Media fills entire window — no layout shift when UI appears/disappears +- Video controls only show for video posts, hidden for images/GIFs +- Smart F11 exit: window sizes to 60% of monitor, maintaining content aspect ratio +- Window auto-resizes to content aspect ratio on navigation (height adjusts, position stays) +- Window geometry and fullscreen state persisted to DB across sessions +- Hyprland-specific: uses `hyprctl resizewindowpixel` + `setprop keep_aspect_ratio` to lock window to content aspect ratio (works both floating and tiled) +- Default site setting in Settings > General + +### New: preview toolbar +- Action bar above the preview panel: Bookmark, Save, BL Tag, BL Post, Popout +- Appears when a post is active, hidden when preview is cleared +- Save button opens folder picker menu (Unsorted / existing folders / + New Folder) +- Save/Unsave state shown on button text +- Bookmark/Unbookmark state shown on button text +- Per-tab button visibility: Library tab only shows Save + Popout +- All actions work from any tab (Browse, Bookmarks, Library) +- Blacklist tag and blacklist post show confirmation dialogs +- "Unsave from Library" only appears in context menu when post is saved + +### New: media type filter +- Replaced "Animated" checkbox with dropdown: All / Animated / Video / GIF / Audio +- Each option appends the corresponding booru tag to the search query + +### New: thumbnail cache limits +- Added "Max thumbnail cache" setting (default 500 MB) +- Auto-evicts oldest thumbnails when limit is reached + +### Improved: state synchronization +- Saving/unsaving updates grid thumbnail dots instantly (browse, bookmarks, library) +- Unbookmarking refreshes the bookmarks tab immediately +- Saving from browse/bookmarks refreshes the library tab when async save completes +- Library items set `_current_post` on click so toolbar actions work correctly +- Preview toolbar tracks bookmark and save state across all tabs +- Tab switching clears grid selections to prevent cross-tab action conflicts +- Bookmark state updates after async bookmark completes (not before) + +### Improved: infinite scroll +- Fixed missing posts when media type filters reduce results per page +- Local dedup set (`seen`) prevents cross-page duplicates within backfill without polluting `shown_post_ids` +- Page counter only advances when results are returned, not when filtering empties them +- Backfill loop increased to 10 max pages with 300ms delay between API calls (first call instant) + +### Improved: pagination +- Status bar shows "(end)" when search returns fewer results than page size +- Prev/Next buttons hide when at page boundaries instead of just disabling +- Source URLs clickable in info panel, truncated at 60 chars for display + +### Improved: video controls +- Seek step changed from 5s to ~3s for `,` and `.` keys +- `,` and `.` seek keys now work in the main preview panel, not just popout +- Translucent overlay style on video controls in both preview and popout +- Volume slider fixed at 60px to not compete with seek slider at small sizes + +### New: API retry logic +- Single retry with backoff on HTTP 429 (rate limit) and 503 (service unavailable) +- Retries on request timeout +- Respects `Retry-After` header (capped at 5s) +- Applied to all API requests (search, get_post, autocomplete) across all four clients +- Downloads are not retried (large payloads, separate client) + +### Refactor: SearchState dataclass +- Consolidated 8 scattered search state attributes into a single `SearchState` dataclass +- Eliminated all defensive `getattr`/`hasattr` patterns (8 instances) +- State resets cleanly on new search — no stale infinite scroll data + +### Dependencies +- Added `python-mpv>=1.0` +- Removed dependency on `PySide6.QtMultimedia` and `PySide6.QtMultimediaWidgets` diff --git a/README.md b/README.md index 2f1fb03..4143867 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Supports custom styling via `custom.qss` — see [Theming](#theming). - Auto-detect site API type — just paste the URL - Tag search with autocomplete, history dropdown, and saved searches - Rating and score filtering (server-side `score:>=N`) -- **Animated filter** — checkbox to only show video/gif/animated posts +- **Media type filter** — dropdown: All / Animated / Video / GIF / Audio - Blacklisted tags and posts (client-side filtering with backfill) - Thumbnail grid with keyboard navigation - **Infinite scroll** — optional, auto-loads more posts at bottom @@ -51,17 +51,20 @@ Supports custom styling via `custom.qss` — see [Theming](#theming). - Image viewer with zoom (scroll wheel), pan (drag), and reset (middle click) - GIF animation, Pixiv ugoira auto-conversion (zip to animated GIF) - Animated PNG/WebP auto-conversion to GIF -- Video playback (MP4, WebM) with play/pause, seek, volume, mute, and seamless looping +- Video playback via mpv (MP4, WebM, MKV) with play/pause, seek, volume, mute, and seamless looping - Info panel with post details, date, clickable tags, and filetype +- **Preview toolbar** — Bookmark, Save, BL Tag, BL Post, and Popout buttons above the preview panel -### Slideshow Mode -- Right-click preview → "Slideshow Mode" for fullscreen viewing +### Popout Viewer +- Right-click preview → "Popout" or click the Popout button in the preview toolbar - Arrow keys / `h`/`j`/`k`/`l` navigate posts (including during video playback) -- `,` / `.` seek 5 seconds in videos, `Space` toggles play/pause -- Toolbar with Bookmark, Save/Unsave, Blacklist Tag, and Blacklist Post buttons +- `,` / `.` seek 3 seconds in videos, `Space` toggles play/pause +- Floating overlay UI — toolbar and video controls auto-hide after 2 seconds, reappear on mouse move - `F11` toggles fullscreen/windowed, `Ctrl+H` hides all UI, `Ctrl+P` privacy screen -- Bidirectional sync — clicking posts in the main grid updates the slideshow -- Video position and player state synced between preview and slideshow +- Window auto-sizes to content aspect ratio; state persisted across sessions +- Hyprland: `keep_aspect_ratio` prop locks window to content proportions +- Bidirectional sync — clicking posts in the main grid updates the popout +- Video position and player state synced between preview and popout ### Bookmarks & Library - Bookmark posts, organize into folders @@ -69,7 +72,7 @@ Supports custom styling via `custom.qss` — see [Theming](#theming). - Save to library (unsorted or per-folder), drag-and-drop thumbnails as files - Multi-select (Ctrl/Shift+Click, Ctrl+A) with bulk actions - Bulk context menus in both Browse and Bookmarks tabs -- Unsave from Library available in grid, preview, and slideshow +- Unsave from Library available in grid, preview, and popout (only shown when post is saved) - Import/export bookmarks as JSON ### Library @@ -91,8 +94,6 @@ Supports custom styling via `custom.qss` — see [Theming](#theming). Download `booru-viewer-setup.exe` from [Releases](https://git.pax.moe/pax/booru-viewer/releases) and run the installer. It installs to AppData with Start Menu and optional desktop shortcuts. To update, just run the new installer over the old one — your data in `%APPDATA%\booru-viewer\` is preserved. -For WebM video playback, install [VP9 Video Extensions](https://apps.microsoft.com/detail/9n4d0msmp0pt) from the Microsoft Store. - Windows 10 dark mode is automatically detected and applied. ### Linux @@ -101,17 +102,17 @@ Requires Python 3.11+ and pip. Most distros ship Python but you may need to inst **Arch / CachyOS:** ```sh -sudo pacman -S python python-pip qt6-base qt6-multimedia qt6-multimedia-ffmpeg ffmpeg +sudo pacman -S python python-pip qt6-base mpv ffmpeg ``` **Ubuntu / Debian (24.04+):** ```sh -sudo apt install python3 python3-pip python3-venv libqt6multimedia6 gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav ffmpeg +sudo apt install python3 python3-pip python3-venv mpv libmpv-dev ffmpeg ``` **Fedora:** ```sh -sudo dnf install python3 python3-pip qt6-qtbase qt6-qtmultimedia gstreamer1-plugins-good gstreamer1-plugins-bad-free gstreamer1-libav ffmpeg +sudo dnf install python3 python3-pip qt6-qtbase mpv mpv-libs-devel ffmpeg ``` Then clone and install: @@ -147,6 +148,8 @@ Categories=Graphics; - PySide6 (Qt6) - httpx - Pillow +- python-mpv +- mpv (system package on Linux, bundled DLL on Windows) ## Keybinds @@ -169,22 +172,22 @@ Categories=Graphics; | Scroll wheel | Zoom | | Middle click / `0` | Reset view | | Arrow keys / `h`/`j`/`k`/`l` | Navigate posts | -| `,` / `.` | Seek 5s back / forward (video) | +| `,` / `.` | Seek 3s back / forward (video) | | `Space` | Play / pause (video, hover to activate) | -| Right click | Context menu (bookmark, save, slideshow) | +| Right click | Context menu (bookmark, save, popout) | -### Slideshow +### Popout | Key | Action | |-----|--------| | Arrow keys / `h`/`j`/`k`/`l` | Navigate posts | -| `,` / `.` | Seek 5s (video) | +| `,` / `.` | Seek 3s (video) | | `Space` | Play / pause (video) | | Scroll wheel | Volume up / down (video) | | `F11` | Toggle fullscreen / windowed | | `Ctrl+H` | Hide / show UI | | `Ctrl+P` | Privacy screen | -| `Escape` / `Q` | Close slideshow | +| `Escape` / `Q` | Close popout | ### Global @@ -227,8 +230,8 @@ A template is also available in Settings > Theme > Create from Template. ## Settings -- **General** — page size, thumbnail size, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, slideshow monitor, file dialog platform -- **Cache** — max cache size, auto-evict, clear cache on exit (session-only mode) +- **General** — page size, thumbnail size, default site, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, popout monitor, file dialog platform +- **Cache** — max cache size, max thumbnail cache, auto-evict, clear cache on exit (session-only mode) - **Blacklist** — tag blacklist with toggle, post URL blacklist - **Paths** — data directory, cache, database, configurable library directory - **Theme** — custom.qss editor, template generator, CSS guide diff --git a/booru-viewer.spec b/booru-viewer.spec index b2ca9af..7ee0a34 100644 --- a/booru-viewer.spec +++ b/booru-viewer.spec @@ -20,12 +20,13 @@ hiddenimports = [ 'PIL.GifImagePlugin', 'PIL.WebPImagePlugin', 'PIL.BmpImagePlugin', + 'mpv', ] a = Analysis( ['booru_viewer/main_gui.py'], pathex=[], - binaries=[], + binaries=[('mpv-2.dll', '.')] if sys.platform == 'win32' else [], datas=[('icon.png', '.')], hiddenimports=hiddenimports, hookspath=[], diff --git a/booru_viewer/core/api/base.py b/booru_viewer/core/api/base.py index 8a5fa6b..6fbcee0 100644 --- a/booru_viewer/core/api/base.py +++ b/booru_viewer/core/api/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -90,6 +91,36 @@ class BooruClient(ABC): async def _log_request(request: httpx.Request) -> None: log_connection(str(request.url)) + _RETRYABLE_STATUS = frozenset({429, 503}) + + async def _request( + self, method: str, url: str, *, params: dict | None = None + ) -> httpx.Response: + """Issue an HTTP request with a single retry on 429/503/timeout.""" + for attempt in range(2): + try: + resp = await self.client.request(method, url, params=params) + if resp.status_code not in self._RETRYABLE_STATUS or attempt == 1: + return resp + wait = 1.0 + if resp.status_code == 429: + retry_after = resp.headers.get("retry-after") + if retry_after: + try: + wait = min(float(retry_after), 5.0) + except (ValueError, TypeError): + wait = 2.0 + else: + wait = 2.0 + log.info(f"Retrying {url} after {resp.status_code} (wait {wait}s)") + await asyncio.sleep(wait) + except httpx.TimeoutException: + if attempt == 1: + raise + log.info(f"Retrying {url} after timeout") + await asyncio.sleep(1.0) + return resp # unreachable in practice, satisfies type checker + async def close(self) -> None: pass # shared client stays open diff --git a/booru_viewer/core/api/danbooru.py b/booru_viewer/core/api/danbooru.py index d6e15de..890bbf8 100644 --- a/booru_viewer/core/api/danbooru.py +++ b/booru_viewer/core/api/danbooru.py @@ -24,7 +24,7 @@ class DanbooruClient(BooruClient): url = f"{self.base_url}/posts.json" log.info(f"GET {url}") log.debug(f" params: {params}") - resp = await self.client.get(url, params=params) + resp = await self._request("GET", url, params=params) log.info(f" -> {resp.status_code}") if resp.status_code != 200: log.warning(f" body: {resp.text[:500]}") @@ -66,8 +66,8 @@ class DanbooruClient(BooruClient): params["login"] = self.api_user params["api_key"] = self.api_key - resp = await self.client.get( - f"{self.base_url}/posts/{post_id}.json", params=params + resp = await self._request( + "GET", f"{self.base_url}/posts/{post_id}.json", params=params ) if resp.status_code == 404: return None @@ -91,8 +91,8 @@ class DanbooruClient(BooruClient): async def autocomplete(self, query: str, limit: int = 10) -> list[str]: try: - resp = await self.client.get( - f"{self.base_url}/autocomplete.json", + resp = await self._request( + "GET", f"{self.base_url}/autocomplete.json", params={"search[query]": query, "search[type]": "tag_query", "limit": limit}, ) resp.raise_for_status() diff --git a/booru_viewer/core/api/e621.py b/booru_viewer/core/api/e621.py index 41f8d15..230e6a4 100644 --- a/booru_viewer/core/api/e621.py +++ b/booru_viewer/core/api/e621.py @@ -44,7 +44,7 @@ class E621Client(BooruClient): url = f"{self.base_url}/posts.json" log.info(f"GET {url}") log.debug(f" params: {params}") - resp = await self.client.get(url, params=params) + resp = await self._request("GET", url, params=params) log.info(f" -> {resp.status_code}") if resp.status_code != 200: log.warning(f" body: {resp.text[:500]}") @@ -86,8 +86,8 @@ class E621Client(BooruClient): params["login"] = self.api_user params["api_key"] = self.api_key - resp = await self.client.get( - f"{self.base_url}/posts/{post_id}.json", params=params + resp = await self._request( + "GET", f"{self.base_url}/posts/{post_id}.json", params=params ) if resp.status_code == 404: return None @@ -113,8 +113,8 @@ class E621Client(BooruClient): async def autocomplete(self, query: str, limit: int = 10) -> list[str]: try: - resp = await self.client.get( - f"{self.base_url}/tags.json", + resp = await self._request( + "GET", f"{self.base_url}/tags.json", params={ "search[name_matches]": f"{query}*", "search[order]": "count", diff --git a/booru_viewer/core/api/gelbooru.py b/booru_viewer/core/api/gelbooru.py index f8eb0a9..c67f994 100644 --- a/booru_viewer/core/api/gelbooru.py +++ b/booru_viewer/core/api/gelbooru.py @@ -38,7 +38,7 @@ class GelbooruClient(BooruClient): url = f"{self.base_url}/index.php" log.info(f"GET {url}") log.debug(f" params: {params}") - resp = await self.client.get(url, params=params) + resp = await self._request("GET", url, params=params) log.info(f" -> {resp.status_code}") if resp.status_code != 200: log.warning(f" body: {resp.text[:500]}") @@ -94,7 +94,7 @@ class GelbooruClient(BooruClient): params["api_key"] = self.api_key params["user_id"] = self.api_user - resp = await self.client.get(f"{self.base_url}/index.php", params=params) + resp = await self._request("GET", f"{self.base_url}/index.php", params=params) if resp.status_code == 404: return None resp.raise_for_status() @@ -111,7 +111,7 @@ class GelbooruClient(BooruClient): id=item["id"], file_url=file_url, preview_url=item.get("preview_url"), - tags=item.get("tags", ""), + tags=self._decode_tags(item.get("tags", "")), score=item.get("score", 0), rating=item.get("rating"), source=item.get("source"), @@ -122,8 +122,8 @@ class GelbooruClient(BooruClient): async def autocomplete(self, query: str, limit: int = 10) -> list[str]: try: - resp = await self.client.get( - f"{self.base_url}/index.php", + resp = await self._request( + "GET", f"{self.base_url}/index.php", params={ "page": "dapi", "s": "tag", diff --git a/booru_viewer/core/api/moebooru.py b/booru_viewer/core/api/moebooru.py index 0e73c80..c8ef8a3 100644 --- a/booru_viewer/core/api/moebooru.py +++ b/booru_viewer/core/api/moebooru.py @@ -21,7 +21,7 @@ class MoebooruClient(BooruClient): params["login"] = self.api_user params["password_hash"] = self.api_key - resp = await self.client.get(f"{self.base_url}/post.json", params=params) + resp = await self._request("GET", f"{self.base_url}/post.json", params=params) resp.raise_for_status() try: data = resp.json() @@ -59,7 +59,7 @@ class MoebooruClient(BooruClient): params["login"] = self.api_user params["password_hash"] = self.api_key - resp = await self.client.get(f"{self.base_url}/post.json", params=params) + resp = await self._request("GET", f"{self.base_url}/post.json", params=params) if resp.status_code == 404: return None resp.raise_for_status() @@ -87,8 +87,8 @@ class MoebooruClient(BooruClient): async def autocomplete(self, query: str, limit: int = 10) -> list[str]: try: - resp = await self.client.get( - f"{self.base_url}/tag.json", + resp = await self._request( + "GET", f"{self.base_url}/tag.json", params={"name": f"*{query}*", "order": "count", "limit": limit}, ) resp.raise_for_status() diff --git a/booru_viewer/core/cache.py b/booru_viewer/core/cache.py index 3fb57c6..36c4ff3 100644 --- a/booru_viewer/core/cache.py +++ b/booru_viewer/core/cache.py @@ -310,6 +310,26 @@ def evict_oldest(max_bytes: int, protected_paths: set[str] | None = None) -> int return deleted +def evict_oldest_thumbnails(max_bytes: int) -> int: + """Delete oldest thumbnails until under max_bytes. Returns count deleted.""" + td = thumbnails_dir() + if not td.exists(): + return 0 + files = sorted(td.iterdir(), key=lambda f: f.stat().st_mtime) + deleted = 0 + current = sum(f.stat().st_size for f in td.iterdir() if f.is_file()) + for f in files: + if current <= max_bytes: + break + if not f.is_file(): + continue + size = f.stat().st_size + f.unlink() + current -= size + deleted += 1 + return deleted + + def clear_cache(clear_images: bool = True, clear_thumbnails: bool = True) -> int: """Delete all cached files. Returns count deleted.""" deleted = 0 diff --git a/booru_viewer/core/db.py b/booru_viewer/core/db.py index d28f981..6ea645c 100644 --- a/booru_viewer/core/db.py +++ b/booru_viewer/core/db.py @@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS saved_searches ( _DEFAULTS = { "max_cache_mb": "2048", + "max_thumb_cache_mb": "500", "auto_evict": "1", "thumbnail_size": "180", "page_size": "40", diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index 9dddf28..c356eef 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio import logging import os -import subprocess import sys import threading from pathlib import Path @@ -33,10 +32,12 @@ from PySide6.QtWidgets import ( QProgressBar, ) +from dataclasses import dataclass, field + from ..core.db import Database, Site from ..core.api.base import BooruClient, Post from ..core.api.detect import client_for_type -from ..core.cache import download_image, download_thumbnail, cache_size_bytes, evict_oldest +from ..core.cache import download_image, download_thumbnail, cache_size_bytes, evict_oldest, evict_oldest_thumbnails from ..core.config import MEDIA_EXTENSIONS from .grid import ThumbnailGrid @@ -50,6 +51,18 @@ from .settings import SettingsDialog log = logging.getLogger("booru") +@dataclass +class SearchState: + """Mutable state that resets on every new search.""" + shown_post_ids: set[int] = field(default_factory=set) + page_cache: dict[int, list] = field(default_factory=dict) + infinite_exhausted: bool = False + infinite_last_page: int = 0 + infinite_api_exhausted: bool = False + nav_page_turn: str | None = None + append_queue: list = field(default_factory=list) + + class LogHandler(logging.Handler, QObject): """Logging handler that emits to a QTextEdit.""" @@ -107,7 +120,8 @@ class InfoPanel(QWidget): self._details = QLabel() self._details.setWordWrap(True) - self._details.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self._details.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextBrowserInteraction) + self._details.setMaximumHeight(120) layout.addWidget(self._details) self._tags_label = QLabel("Tags:") @@ -128,13 +142,33 @@ class InfoPanel(QWidget): log.debug(f"InfoPanel: tag_categories={list(post.tag_categories.keys()) if post.tag_categories else 'empty'}") self._title.setText(f"Post #{post.id}") filetype = Path(post.file_url.split("?")[0]).suffix.lstrip(".").upper() if post.file_url else "unknown" + source = post.source or "none" + # Truncate display text but keep full URL for the link + source_full = source + if len(source) > 60: + source_display = source[:57] + "..." + else: + source_display = source + if source_full.startswith(("http://", "https://")): + source_html = f'{source_display}' + else: + source_html = source_display + from html import escape self._details.setText( f"Size: {post.width}x{post.height}\n" f"Score: {post.score}\n" f"Rating: {post.rating or 'unknown'}\n" - f"Filetype: {filetype}\n" - f"Source: {post.source or 'none'}" + f"Filetype: {filetype}" ) + self._details.setTextFormat(Qt.TextFormat.RichText) + self._details.setText( + f"Size: {post.width}x{post.height}
" + f"Score: {post.score}
" + f"Rating: {escape(post.rating or 'unknown')}
" + f"Filetype: {filetype}
" + f"Source: {source_html}" + ) + self._details.setOpenExternalLinks(True) # Clear old tags while self._tags_flow.count(): item = self._tags_flow.takeAt(0) @@ -223,6 +257,7 @@ class BooruApp(QMainWindow): self._current_rating = "all" self._min_score = 0 self._loading = False + self._search = SearchState() self._last_scroll_page = 0 self._prefetch_pause = asyncio.Event() self._prefetch_pause.set() # not paused @@ -257,7 +292,7 @@ class BooruApp(QMainWindow): s.bookmark_error.connect(self._on_bookmark_error, Q) s.autocomplete_done.connect(self._on_autocomplete_done, Q) s.batch_progress.connect(self._on_batch_progress, Q) - s.batch_done.connect(lambda m: self._status.showMessage(m), Q) + s.batch_done.connect(self._on_batch_done, Q) s.download_progress.connect(self._on_download_progress, Q) s.prefetch_progress.connect(self._on_prefetch_progress, Q) @@ -333,10 +368,11 @@ class BooruApp(QMainWindow): score_up.clicked.connect(lambda: self._score_spin.setValue(self._score_spin.value() + 1)) top.addWidget(score_up) - from PySide6.QtWidgets import QCheckBox - self._animated_only = QCheckBox("Animated") - self._animated_only.setToolTip("Only show animated/video posts") - top.addWidget(self._animated_only) + self._media_filter = QComboBox() + self._media_filter.addItems(["All", "Animated", "Video", "GIF", "Audio"]) + self._media_filter.setToolTip("Filter by media type") + self._media_filter.setFixedWidth(90) + top.addWidget(self._media_filter) page_label = QLabel("Page") top.addWidget(page_label) @@ -395,11 +431,13 @@ class BooruApp(QMainWindow): self._bookmarks_view = BookmarksView(self._db) self._bookmarks_view.bookmark_selected.connect(self._on_bookmark_selected) self._bookmarks_view.bookmark_activated.connect(self._on_bookmark_activated) + self._bookmarks_view.bookmarks_changed.connect(self._refresh_browse_saved_dots) self._stack.addWidget(self._bookmarks_view) self._library_view = LibraryView(db=self._db) self._library_view.file_selected.connect(self._on_library_selected) self._library_view.file_activated.connect(self._on_library_activated) + self._library_view.files_deleted.connect(self._on_library_files_deleted) self._stack.addWidget(self._library_view) self._splitter.addWidget(self._stack) @@ -414,6 +452,8 @@ class BooruApp(QMainWindow): self._preview.bookmark_requested.connect(self._bookmark_from_preview) self._preview.save_to_folder.connect(self._save_from_preview) self._preview.unsave_requested.connect(self._unsave_from_preview) + self._preview.blacklist_tag_requested.connect(self._blacklist_tag_from_popout) + self._preview.blacklist_post_requested.connect(self._blacklist_post_from_popout) self._preview.navigate.connect(self._navigate_preview) self._preview.fullscreen_requested.connect(self._open_fullscreen_preview) self._preview.set_folders_callback(self._db.get_folders) @@ -446,14 +486,14 @@ class BooruApp(QMainWindow): bottom_nav.addStretch() self._page_label = QLabel("Page 1") bottom_nav.addWidget(self._page_label) - bottom_prev = QPushButton("Prev") - bottom_prev.setFixedWidth(60) - bottom_prev.clicked.connect(self._prev_page) - bottom_nav.addWidget(bottom_prev) - bottom_next = QPushButton("Next") - bottom_next.setFixedWidth(60) - bottom_next.clicked.connect(self._next_page) - bottom_nav.addWidget(bottom_next) + self._prev_page_btn = QPushButton("Prev") + self._prev_page_btn.setFixedWidth(60) + self._prev_page_btn.clicked.connect(self._prev_page) + bottom_nav.addWidget(self._prev_page_btn) + self._next_page_btn = QPushButton("Next") + self._next_page_btn.setFixedWidth(60) + self._next_page_btn.clicked.connect(self._next_page) + bottom_nav.addWidget(self._next_page_btn) bottom_nav.addStretch() layout.addWidget(self._bottom_nav) @@ -544,6 +584,12 @@ class BooruApp(QMainWindow): self._site_combo.clear() for site in self._db.get_sites(): self._site_combo.addItem(site.name, site.id) + # Select default site if configured + default_id = self._db.get_setting_int("default_site_id") + if default_id: + idx = self._site_combo.findData(default_id) + if idx >= 0: + self._site_combo.setCurrentIndex(idx) def _make_client(self) -> BooruClient | None: if not self._current_site: @@ -571,6 +617,20 @@ class BooruApp(QMainWindow): self._browse_btn.setChecked(index == 0) self._bookmark_btn.setChecked(index == 1) self._library_btn.setChecked(index == 2) + # Clear grid selections and current post to prevent cross-tab action conflicts + # Preview media stays visible but actions are disabled until a new post is selected + self._grid.clear_selection() + self._bookmarks_view._grid.clear_selection() + self._library_view._grid.clear_selection() + self._preview._current_post = None + self._preview._current_site_id = None + self._preview.update_bookmark_state(False) + self._preview.update_save_state(False) + # Show/hide preview toolbar buttons per tab + is_library = index == 2 + self._preview._bookmark_btn.setVisible(not is_library) + self._preview._bl_tag_btn.setVisible(not is_library) + self._preview._bl_post_btn.setVisible(not is_library) if index == 1: self._bookmarks_view.refresh() self._bookmarks_view._grid.setFocus() @@ -590,18 +650,18 @@ class BooruApp(QMainWindow): def _on_search(self, tags: str) -> None: self._current_tags = tags self._current_page = self._page_spin.value() - self._shown_post_ids = set() - self._page_cache = {} - self._infinite_exhausted = False + self._search = SearchState() self._min_score = self._score_spin.value() self._preview.clear() + self._next_page_btn.setVisible(True) + self._prev_page_btn.setVisible(False) self._do_search() def _prev_page(self) -> None: if self._current_page > 1: self._current_page -= 1 - if self._current_page in self._page_cache: - self._signals.search_done.emit(self._page_cache[self._current_page]) + if self._current_page in self._search.page_cache: + self._signals.search_done.emit(self._search.page_cache[self._current_page]) else: self._do_search() @@ -609,26 +669,26 @@ class BooruApp(QMainWindow): if self._loading: return self._current_page += 1 - if self._current_page in getattr(self, '_page_cache', {}): - self._signals.search_done.emit(self._page_cache[self._current_page]) + if self._current_page in self._search.page_cache: + self._signals.search_done.emit(self._search.page_cache[self._current_page]) return self._do_search() def _on_nav_past_end(self) -> None: if self._infinite_scroll: return # infinite scroll handles this via reached_bottom - self._nav_page_turn = "first" + self._search.nav_page_turn = "first" self._next_page() def _on_nav_before_start(self) -> None: if self._infinite_scroll: return if self._current_page > 1: - self._nav_page_turn = "last" + self._search.nav_page_turn = "last" self._prev_page() def _on_reached_bottom(self) -> None: - if not self._infinite_scroll or self._loading or getattr(self, '_infinite_exhausted', False): + if not self._infinite_scroll or self._loading or self._search.infinite_exhausted: return self._loading = True self._current_page += 1 @@ -641,14 +701,16 @@ class BooruApp(QMainWindow): if self._db.get_setting_bool("blacklist_enabled"): bl_tags = set(self._db.get_blacklisted_tags()) bl_posts = self._db.get_blacklisted_posts() - shown_ids = getattr(self, '_shown_post_ids', set()).copy() + shown_ids = self._search.shown_post_ids.copy() + seen = shown_ids.copy() # local dedup for this backfill round def _filter(posts): if bl_tags: posts = [p for p in posts if not bl_tags.intersection(p.tag_list)] if bl_posts: posts = [p for p in posts if p.file_url not in bl_posts] - posts = [p for p in posts if p.id not in shown_ids] + posts = [p for p in posts if p.id not in seen] + seen.update(p.id for p in posts) return posts async def _search(): @@ -658,24 +720,31 @@ class BooruApp(QMainWindow): api_exhausted = False try: current_page = page - max_pages = 5 - for _ in range(max_pages): - batch = await client.search(tags=search_tags, page=current_page, limit=limit) - last_page = current_page - filtered = _filter(batch) - collected.extend(filtered) - if len(batch) < limit: - api_exhausted = True - break - if len(collected) >= limit: - break - current_page += 1 + batch = await client.search(tags=search_tags, page=current_page, limit=limit) + last_page = current_page + filtered = _filter(batch) + collected.extend(filtered) + if len(batch) < limit: + api_exhausted = True + elif len(collected) < limit: + for _ in range(9): + await asyncio.sleep(0.3) + current_page += 1 + batch = await client.search(tags=search_tags, page=current_page, limit=limit) + last_page = current_page + filtered = _filter(batch) + collected.extend(filtered) + if len(batch) < limit: + api_exhausted = True + break + if len(collected) >= limit: + break except Exception as e: log.warning(f"Infinite scroll fetch failed: {e}") finally: - self._infinite_last_page = last_page - self._infinite_api_exhausted = api_exhausted - self._signals.search_append.emit(collected[:limit]) + self._search.infinite_last_page = last_page + self._search.infinite_api_exhausted = api_exhausted + self._signals.search_append.emit(collected) await client.close() self._run_async(_search) @@ -742,9 +811,16 @@ class BooruApp(QMainWindow): if self._min_score > 0: parts.append(f"score:>={self._min_score}") - # Animated filter - if self._animated_only.isChecked(): + # Media type filter + media = self._media_filter.currentText() + if media == "Animated": parts.append("animated") + elif media == "Video": + parts.append("video") + elif media == "GIF": + parts.append("animated_gif") + elif media == "Audio": + parts.append("audio") return " ".join(parts) @@ -766,15 +842,16 @@ class BooruApp(QMainWindow): if self._db.get_setting_bool("blacklist_enabled"): bl_tags = set(self._db.get_blacklisted_tags()) bl_posts = self._db.get_blacklisted_posts() - shown_ids = getattr(self, '_shown_post_ids', set()).copy() + shown_ids = self._search.shown_post_ids.copy() + seen = shown_ids.copy() def _filter(posts): if bl_tags: posts = [p for p in posts if not bl_tags.intersection(p.tag_list)] if bl_posts: posts = [p for p in posts if p.file_url not in bl_posts] - # Skip posts already shown on previous pages - posts = [p for p in posts if p.id not in shown_ids] + posts = [p for p in posts if p.id not in seen] + seen.update(p.id for p in posts) return posts async def _search(): @@ -782,15 +859,20 @@ class BooruApp(QMainWindow): try: collected = [] current_page = page - max_pages = 5 - for _ in range(max_pages): - batch = await client.search(tags=search_tags, page=current_page, limit=limit) - filtered = _filter(batch) - collected.extend(filtered) - log.debug(f"Backfill: page={current_page} batch={len(batch)} filtered={len(filtered)} total={len(collected)}/{limit}") - if len(collected) >= limit or len(batch) < limit: - break - current_page += 1 + batch = await client.search(tags=search_tags, page=current_page, limit=limit) + filtered = _filter(batch) + collected.extend(filtered) + # Backfill only if first page didn't return enough after filtering + if len(collected) < limit and len(batch) >= limit: + for _ in range(9): + await asyncio.sleep(0.3) + current_page += 1 + batch = await client.search(tags=search_tags, page=current_page, limit=limit) + filtered = _filter(batch) + collected.extend(filtered) + log.debug(f"Backfill: page={current_page} batch={len(batch)} filtered={len(filtered)} total={len(collected)}/{limit}") + if len(collected) >= limit or len(batch) < limit: + break self._signals.search_done.emit(collected[:limit]) except Exception as e: self._signals.search_error.emit(str(e)) @@ -803,17 +885,22 @@ class BooruApp(QMainWindow): self._page_label.setText(f"Page {self._current_page}") self._posts = posts # Cache page results and track shown IDs - if not hasattr(self, '_shown_post_ids'): - self._shown_post_ids = set() - if not hasattr(self, '_page_cache'): - self._page_cache = {} - self._shown_post_ids.update(p.id for p in posts) - self._page_cache[self._current_page] = posts + ss = self._search + ss.shown_post_ids.update(p.id for p in posts) + ss.page_cache[self._current_page] = posts # Cap page cache in pagination mode (infinite scroll needs all pages) - if not self._infinite_scroll and len(self._page_cache) > 10: - oldest = min(self._page_cache.keys()) - del self._page_cache[oldest] - self._status.showMessage(f"{len(posts)} results") + if not self._infinite_scroll and len(ss.page_cache) > 10: + oldest = min(ss.page_cache.keys()) + del ss.page_cache[oldest] + limit = self._db.get_setting_int("page_size") or 40 + at_end = len(posts) < limit + if at_end: + self._status.showMessage(f"{len(posts)} results (end)") + else: + self._status.showMessage(f"{len(posts)} results") + # Update pagination buttons + self._prev_page_btn.setVisible(self._current_page > 1) + self._next_page_btn.setVisible(not at_end) thumbs = self._grid.set_posts(len(posts)) self._grid.scroll_to_top() # Clear loading after a brief delay so scroll signals don't re-trigger @@ -858,9 +945,9 @@ class BooruApp(QMainWindow): self._fetch_thumbnail(i, post.preview_url) # Auto-select first/last post if page turn was triggered by navigation - turn = getattr(self, "_nav_page_turn", None) + turn = self._search.nav_page_turn if turn and posts: - self._nav_page_turn = None + self._search.nav_page_turn = None if turn == "first": idx = 0 else: @@ -885,7 +972,7 @@ class BooruApp(QMainWindow): def _check_viewport_fill(self) -> None: """If content doesn't fill the viewport, trigger infinite scroll.""" - if not self._infinite_scroll or self._loading or getattr(self, '_infinite_exhausted', False): + if not self._infinite_scroll or self._loading or self._search.infinite_exhausted: return # Force layout update so scrollbar range is current self._grid.widget().updateGeometry() @@ -896,32 +983,33 @@ class BooruApp(QMainWindow): def _on_search_append(self, posts: list) -> None: """Queue posts and add them one at a time as thumbnails arrive.""" - # Advance page counter past pages consumed by backfill - last_page = getattr(self, '_infinite_last_page', self._current_page) - if last_page > self._current_page: - self._current_page = last_page + ss = self._search if not posts: + # Only advance page if API is exhausted — otherwise we retry + if ss.infinite_api_exhausted and ss.infinite_last_page > self._current_page: + self._current_page = ss.infinite_last_page self._loading = False # Only mark exhausted if the API itself returned a short page, # not just because blacklist/dedup filtering emptied the results - if getattr(self, '_infinite_api_exhausted', False): - self._infinite_exhausted = True + if ss.infinite_api_exhausted: + ss.infinite_exhausted = True self._status.showMessage(f"{len(self._posts)} results (end)") else: - # Viewport still not full — keep loading + # Viewport still not full ��� keep loading QTimer.singleShot(100, self._check_viewport_fill) return - self._shown_post_ids.update(p.id for p in posts) - - if not hasattr(self, '_append_queue'): - self._append_queue = [] - self._append_queue.extend(posts) + # Advance page counter past pages consumed by backfill + if ss.infinite_last_page > self._current_page: + self._current_page = ss.infinite_last_page + ss.shown_post_ids.update(p.id for p in posts) + ss.append_queue.extend(posts) self._drain_append_queue() def _drain_append_queue(self) -> None: """Add all queued posts to the grid at once, thumbnails load async.""" - if not getattr(self, '_append_queue', None) or len(self._append_queue) == 0: + ss = self._search + if not ss.append_queue: self._loading = False return @@ -933,8 +1021,8 @@ class BooruApp(QMainWindow): if _sd.exists(): _saved_ids = {int(f.stem) for f in _sd.iterdir() if f.is_file() and f.stem.isdigit()} - posts = self._append_queue[:] - self._append_queue.clear() + posts = ss.append_queue[:] + ss.append_queue.clear() start_idx = len(self._posts) self._posts.extend(posts) thumbs = self._grid.append_posts(len(posts)) @@ -1020,6 +1108,14 @@ class BooruApp(QMainWindow): if 0 <= index < len(self._posts): post = self._posts[index] log.info(f"Preview: #{post.id} -> {post.file_url}") + 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) + site_id = self._preview._current_site_id + self._preview.update_bookmark_state( + bool(site_id and self._db.is_bookmarked(site_id, post.id)) + ) + self._preview.update_save_state(self._is_post_saved(post.id)) self._status.showMessage(f"Loading #{post.id}...") self._dl_progress.show() self._dl_progress.setRange(0, 0) @@ -1136,7 +1232,7 @@ class BooruApp(QMainWindow): self._update_fullscreen_state() def _update_fullscreen_state(self) -> None: - """Update slideshow button states for the current post.""" + """Update popout button states for the current post.""" if not self._fullscreen_window: return from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS @@ -1164,22 +1260,16 @@ class BooruApp(QMainWindow): else: self._fullscreen_window.update_state(False, False) else: + post = None idx = self._grid.selected_index - if 0 <= idx < len(self._posts) and site_id: + if 0 <= idx < len(self._posts): post = self._posts[idx] - bookmarked = self._db.is_bookmarked(site_id, post.id) - saved = any( - (saved_dir() / f"{post.id}{ext}").exists() - for ext in MEDIA_EXTENSIONS - ) - if not saved: - for folder in self._db.get_folders(): - saved = any( - (saved_folder_dir(folder) / f"{post.id}{ext}").exists() - for ext in MEDIA_EXTENSIONS - ) - if saved: - break + elif self._preview._current_post: + post = self._preview._current_post + if post: + s_id = self._preview._current_site_id or site_id + bookmarked = bool(s_id and self._db.is_bookmarked(s_id, post.id)) + saved = self._is_post_saved(post.id) self._fullscreen_window.update_state(bookmarked, saved) self._fullscreen_window.set_post_tags(post.tag_categories, post.tag_list) else: @@ -1188,7 +1278,7 @@ class BooruApp(QMainWindow): def _on_image_done(self, path: str, info: str) -> None: self._dl_progress.hide() if self._fullscreen_window and self._fullscreen_window.isVisible(): - # Slideshow is open — only show there, keep preview clear + # Popout is open — only show there, keep preview clear self._preview._info_label.setText(info) self._preview._current_path = path else: @@ -1218,6 +1308,12 @@ class BooruApp(QMainWindow): evicted = evict_oldest(max_bytes, protected) if evicted: log.info(f"Auto-evicted {evicted} cached files") + # Thumbnail eviction + max_thumb_mb = self._db.get_setting_int("max_thumb_cache_mb") or 500 + max_thumb_bytes = max_thumb_mb * 1024 * 1024 + evicted_thumbs = evict_oldest_thumbnails(max_thumb_bytes) + if evicted_thumbs: + log.info(f"Auto-evicted {evicted_thumbs} thumbnails") def _set_library_info(self, path: str) -> None: """Update info panel with library metadata for the given file.""" @@ -1238,14 +1334,35 @@ class BooruApp(QMainWindow): self._status.showMessage(info) def _on_library_selected(self, path: str) -> None: - self._set_preview_media(path, Path(path).name) - self._update_fullscreen(path, Path(path).name) - self._set_library_info(path) + self._show_library_post(path) def _on_library_activated(self, path: str) -> None: + self._show_library_post(path) + + def _show_library_post(self, path: str) -> None: self._set_preview_media(path, Path(path).name) self._update_fullscreen(path, Path(path).name) self._set_library_info(path) + # Build a Post from library metadata so toolbar actions work + stem = Path(path).stem + if stem.isdigit(): + post_id = int(stem) + from ..core.api.base import Post + meta = self._db.get_library_meta(post_id) or {} + post = Post( + id=post_id, file_url=meta.get("file_url", ""), + preview_url=None, tags=meta.get("tags", ""), + score=meta.get("score", 0), rating=meta.get("rating"), + source=meta.get("source"), + tag_categories=meta.get("tag_categories", {}), + ) + self._preview._current_post = post + self._preview._current_site_id = self._site_combo.currentData() + self._preview.update_save_state(True) + self._preview.set_post_tags(post.tag_categories, post.tag_list) + else: + self._preview._current_post = None + self._preview.update_save_state(True) def _on_bookmark_selected(self, fav) -> None: self._status.showMessage(f"Bookmark #{fav.post_id}") @@ -1265,6 +1382,21 @@ class BooruApp(QMainWindow): self._on_bookmark_activated(fav) def _on_bookmark_activated(self, fav) -> None: + from ..core.api.base import Post + cats = fav.tag_categories or {} + post = Post( + id=fav.post_id, file_url=fav.file_url or "", + preview_url=fav.preview_url, tags=fav.tags or "", + score=fav.score or 0, rating=fav.rating, + source=fav.source, tag_categories=cats, + ) + self._preview._current_post = post + self._preview._current_site_id = fav.site_id + self._preview.set_post_tags(post.tag_categories, post.tag_list) + self._preview.update_bookmark_state( + bool(self._db.is_bookmarked(fav.site_id, post.id)) + ) + self._preview.update_save_state(self._is_post_saved(post.id)) info = f"Bookmark #{fav.post_id}" # Try local cache first @@ -1348,64 +1480,148 @@ class BooruApp(QMainWindow): self._grid._select(idx) self._on_post_activated(idx) elif idx >= len(self._posts) and direction > 0 and len(self._posts) > 0 and not self._infinite_scroll: - self._nav_page_turn = "first" + self._search.nav_page_turn = "first" self._next_page() elif idx < 0 and direction < 0 and self._current_page > 1 and not self._infinite_scroll: - self._nav_page_turn = "last" + self._search.nav_page_turn = "last" self._prev_page() - def _bookmark_from_preview(self) -> None: + def _is_post_saved(self, post_id: int) -> bool: + """Check if a post is saved in the library (any folder).""" + from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS + _sd = saved_dir() + if _sd.exists(): + for ext in MEDIA_EXTENSIONS: + if (_sd / f"{post_id}{ext}").exists(): + return True + for folder in self._db.get_folders(): + d = saved_folder_dir(folder) + if d.exists(): + for ext in MEDIA_EXTENSIONS: + if (d / f"{post_id}{ext}").exists(): + return True + return False + + def _get_preview_post(self): + """Get the post currently shown in the preview, from grid or stored ref.""" idx = self._grid.selected_index if 0 <= idx < len(self._posts): + return self._posts[idx], idx + if self._preview._current_post: + return self._preview._current_post, -1 + return None, -1 + + def _bookmark_from_preview(self) -> None: + post, idx = self._get_preview_post() + if not post: + return + site_id = self._preview._current_site_id or self._site_combo.currentData() + if not site_id: + return + if idx >= 0: self._toggle_bookmark(idx) - self._update_fullscreen_state() + else: + if self._db.is_bookmarked(site_id, post.id): + self._db.remove_bookmark(site_id, post.id) + else: + from ..core.cache import cached_path_for + cached = cached_path_for(post.file_url) + self._db.add_bookmark( + site_id=site_id, post_id=post.id, + file_url=post.file_url, preview_url=post.preview_url or "", + tags=post.tags, rating=post.rating, score=post.score, + source=post.source, cached_path=str(cached) if cached.exists() else None, + tag_categories=post.tag_categories, + ) + bookmarked = bool(self._db.is_bookmarked(site_id, post.id)) + self._preview.update_bookmark_state(bookmarked) + self._update_fullscreen_state() + # Refresh bookmarks tab if visible + if self._stack.currentIndex() == 1: + self._bookmarks_view.refresh() def _save_from_preview(self, folder: str) -> None: - idx = self._grid.selected_index - if 0 <= idx < len(self._posts): + post, idx = self._get_preview_post() + if post: target = folder if folder else None if folder and folder not in self._db.get_folders(): self._db.add_folder(folder) - self._save_to_library(self._posts[idx], target) - self._update_fullscreen_state() + self._save_to_library(post, target) + # State updates happen in _on_bookmark_done after async save completes def _unsave_from_preview(self) -> None: - idx = self._grid.selected_index - if 0 <= idx < len(self._posts): - post = self._posts[idx] - from ..core.cache import delete_from_library - site_id = self._site_combo.currentData() - folder = None - if site_id: - favs = self._db.get_bookmarks(site_id=site_id) - for f in favs: - if f.post_id == post.id and f.folder: - folder = f.folder - break - if delete_from_library(post.id, folder): - self._status.showMessage(f"Removed #{post.id} from library") - if 0 <= idx < len(self._grid._thumbs): - self._grid._thumbs[idx].set_saved_locally(False) - else: - self._status.showMessage(f"#{post.id} not in library") - self._update_fullscreen_state() + post, idx = self._get_preview_post() + if not post: + return + from ..core.cache import delete_from_library + # Check all folders for saved files + from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS + deleted = False + # Try unsorted + _sd = saved_dir() + for ext in MEDIA_EXTENSIONS: + p = _sd / f"{post.id}{ext}" + if p.exists(): + p.unlink() + deleted = True + # Try all folders + for folder in self._db.get_folders(): + d = saved_folder_dir(folder) + for ext in MEDIA_EXTENSIONS: + p = d / f"{post.id}{ext}" + if p.exists(): + p.unlink() + deleted = True + if deleted: + self._status.showMessage(f"Removed #{post.id} from library") + self._preview.update_save_state(False) + # Update browse grid thumbnail saved dot + for i, p in enumerate(self._posts): + if p.id == post.id and i < len(self._grid._thumbs): + self._grid._thumbs[i].set_saved_locally(False) + break + # Update bookmarks grid thumbnail + bm_grid = self._bookmarks_view._grid + for i, fav in enumerate(self._bookmarks_view._bookmarks): + if fav.post_id == post.id and i < len(bm_grid._thumbs): + bm_grid._thumbs[i].set_saved_locally(False) + break + # Refresh library tab if visible + if self._stack.currentIndex() == 2: + self._library_view.refresh() + else: + self._status.showMessage(f"#{post.id} not in library") + self._update_fullscreen_state() - def _save_toggle_from_slideshow(self) -> None: + def _save_toggle_from_popout(self) -> None: if self._fullscreen_window and self._fullscreen_window._is_saved: self._unsave_from_preview() else: self._save_from_preview("") - def _blacklist_tag_from_slideshow(self, tag: str) -> None: + def _blacklist_tag_from_popout(self, tag: str) -> None: + reply = QMessageBox.question( + self, "Blacklist Tag", + f"Blacklist tag \"{tag}\"?\nPosts with this tag will be hidden.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return self._db.add_blacklisted_tag(tag) self._db.set_setting("blacklist_enabled", "1") self._status.showMessage(f"Blacklisted: {tag}") self._remove_blacklisted_from_grid(tag=tag) - def _blacklist_post_from_slideshow(self) -> None: - idx = self._grid.selected_index - if 0 <= idx < len(self._posts): - post = self._posts[idx] + def _blacklist_post_from_popout(self) -> None: + post, idx = self._get_preview_post() + if post: + reply = QMessageBox.question( + self, "Blacklist Post", + f"Blacklist post #{post.id}?\nThis post will be hidden from results.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return self._db.add_blacklisted_post(post.file_url) self._status.showMessage(f"Post #{post.id} blacklisted") self._remove_blacklisted_from_grid(post_url=post.file_url) @@ -1418,8 +1634,8 @@ class BooruApp(QMainWindow): # Grab video position before clearing video_pos = 0 if self._preview._stack.currentIndex() == 1: - video_pos = self._preview._video_player._player.position() - # Clear the main preview — slideshow takes over + video_pos = self._preview._video_player.get_position_ms() + # Clear the main preview — popout takes over # Hide preview, expand info panel into the freed space self._info_was_visible = self._info_panel.isVisible() self._right_splitter_sizes = self._right_splitter.sizes() @@ -1433,6 +1649,20 @@ class BooruApp(QMainWindow): if 0 <= idx < len(self._posts): self._info_panel.set_post(self._posts[idx]) from .preview import FullscreenPreview + # Restore persisted window state + saved_geo = self._db.get_setting("slideshow_geometry") + saved_fs = self._db.get_setting_bool("slideshow_fullscreen") + if saved_geo: + parts = saved_geo.split(",") + if len(parts) == 4: + from PySide6.QtCore import QRect + FullscreenPreview._saved_geometry = QRect(*[int(p) for p in parts]) + FullscreenPreview._saved_fullscreen = saved_fs + else: + FullscreenPreview._saved_geometry = None + FullscreenPreview._saved_fullscreen = True + else: + FullscreenPreview._saved_fullscreen = True cols = self._grid._flow.columns show_actions = self._stack.currentIndex() != 2 monitor = self._db.get_setting("slideshow_monitor") @@ -1440,81 +1670,77 @@ class BooruApp(QMainWindow): self._fullscreen_window.navigate.connect(self._navigate_fullscreen) if show_actions: self._fullscreen_window.bookmark_requested.connect(self._bookmark_from_preview) - self._fullscreen_window.save_toggle_requested.connect(self._save_toggle_from_slideshow) - self._fullscreen_window.blacklist_tag_requested.connect(self._blacklist_tag_from_slideshow) - self._fullscreen_window.blacklist_post_requested.connect(self._blacklist_post_from_slideshow) + self._fullscreen_window.save_toggle_requested.connect(self._save_toggle_from_popout) + self._fullscreen_window.blacklist_tag_requested.connect(self._blacklist_tag_from_popout) + self._fullscreen_window.blacklist_post_requested.connect(self._blacklist_post_from_popout) self._fullscreen_window.closed.connect(self._on_fullscreen_closed) self._fullscreen_window.privacy_requested.connect(self._toggle_privacy) - # Sync video player state from preview to slideshow + # Set post tags for BL Tag menu + post = self._preview._current_post + if post: + self._fullscreen_window.set_post_tags(post.tag_categories, post.tag_list) + # Sync video player state from preview to popout pv = self._preview._video_player sv = self._fullscreen_window._video - sv._audio.setMuted(pv._audio.isMuted()) - sv._audio.setVolume(pv._audio.volume()) - sv._vol_slider.setValue(pv._vol_slider.value()) - sv._mute_btn.setText(pv._mute_btn.text()) - sv._autoplay = pv._autoplay - sv._autoplay_btn.setChecked(pv._autoplay_btn.isChecked()) - sv._autoplay_btn.setText(pv._autoplay_btn.text()) - sv._autoplay_btn.setVisible(pv._autoplay_btn.isVisible()) - sv._loop_state = pv._loop_state - sv._loop_btn.setText(pv._loop_btn.text()) + sv.volume = pv.volume + sv.is_muted = pv.is_muted + sv.autoplay = pv.autoplay + sv.loop_state = pv.loop_state + # Connect seek-after-load BEFORE set_media so we don't miss media_ready + if video_pos > 0: + def _seek_when_ready(): + sv.seek_to_ms(video_pos) + try: + sv.media_ready.disconnect(_seek_when_ready) + except RuntimeError: + pass + sv.media_ready.connect(_seek_when_ready) self._fullscreen_window.set_media(path, info) - # Seek to the position from the preview after media loads - if video_pos > 0 and self._fullscreen_window._stack.currentIndex() == 1: - def _seek_when_ready(status): - from PySide6.QtMultimedia import QMediaPlayer - if status == QMediaPlayer.MediaStatus.BufferedMedia or status == QMediaPlayer.MediaStatus.LoadedMedia: - self._fullscreen_window._video._player.setPosition(video_pos) - try: - self._fullscreen_window._video._player.mediaStatusChanged.disconnect(_seek_when_ready) - except RuntimeError: - pass - self._fullscreen_window._video._player.mediaStatusChanged.connect(_seek_when_ready) if show_actions: self._update_fullscreen_state() def _on_fullscreen_closed(self) -> None: + # Persist popout window state to DB + if self._fullscreen_window: + from .preview import FullscreenPreview + fs = FullscreenPreview._saved_fullscreen + geo = FullscreenPreview._saved_geometry + self._db.set_setting("slideshow_fullscreen", "1" if fs else "0") + if geo: + self._db.set_setting("slideshow_geometry", f"{geo.x()},{geo.y()},{geo.width()},{geo.height()}") # Restore preview and info panel visibility self._preview.show() if not getattr(self, '_info_was_visible', False): self._info_panel.hide() if hasattr(self, '_right_splitter_sizes'): self._right_splitter.setSizes(self._right_splitter_sizes) - # Sync video player state from slideshow back to preview + # Sync video player state from popout back to preview if self._fullscreen_window: sv = self._fullscreen_window._video pv = self._preview._video_player - pv._audio.setMuted(sv._audio.isMuted()) - pv._audio.setVolume(sv._audio.volume()) - pv._vol_slider.setValue(sv._vol_slider.value()) - pv._mute_btn.setText(sv._mute_btn.text()) - pv._autoplay = sv._autoplay - pv._autoplay_btn.setChecked(sv._autoplay_btn.isChecked()) - pv._autoplay_btn.setText(sv._autoplay_btn.text()) - pv._autoplay_btn.setVisible(sv._autoplay_btn.isVisible()) - pv._loop_state = sv._loop_state - pv._loop_btn.setText(sv._loop_btn.text()) + pv.volume = sv.volume + pv.is_muted = sv.is_muted + pv.autoplay = sv.autoplay + pv.loop_state = sv.loop_state # Grab video position before cleanup video_pos = 0 if self._fullscreen_window and self._fullscreen_window._stack.currentIndex() == 1: - video_pos = self._fullscreen_window._video._player.position() + video_pos = self._fullscreen_window._video.get_position_ms() # Restore preview with current media path = self._preview._current_path info = self._preview._info_label.text() self._fullscreen_window = None if path: + # Connect seek-after-load BEFORE set_media so we don't miss media_ready + if video_pos > 0: + def _seek_preview(): + self._preview._video_player.seek_to_ms(video_pos) + try: + self._preview._video_player.media_ready.disconnect(_seek_preview) + except RuntimeError: + pass + self._preview._video_player.media_ready.connect(_seek_preview) self._preview.set_media(path, info) - # Seek preview to slideshow position - if video_pos > 0 and self._preview._stack.currentIndex() == 1: - def _seek_preview(status): - from PySide6.QtMultimedia import QMediaPlayer - if status in (QMediaPlayer.MediaStatus.BufferedMedia, QMediaPlayer.MediaStatus.LoadedMedia): - self._preview._video_player._player.setPosition(video_pos) - try: - self._preview._video_player._player.mediaStatusChanged.disconnect(_seek_preview) - except RuntimeError: - pass - self._preview._video_player._player.mediaStatusChanged.connect(_seek_preview) def _navigate_fullscreen(self, direction: int) -> None: self._navigate_preview(direction) @@ -1552,7 +1778,9 @@ class BooruApp(QMainWindow): save_lib_menu.addSeparator() save_lib_new = save_lib_menu.addAction("+ New Folder...") - unsave_lib = menu.addAction("Unsave from Library") + unsave_lib = None + if self._is_post_saved(post.id): + unsave_lib = menu.addAction("Unsave from Library") copy_clipboard = menu.addAction("Copy File to Clipboard") copy_url = menu.addAction("Copy Image URL") copy_tags = menu.addAction("Copy Tags") @@ -1591,21 +1819,8 @@ class BooruApp(QMainWindow): elif id(action) in save_lib_folders: self._save_to_library(post, save_lib_folders[id(action)]) elif action == unsave_lib: - from ..core.cache import delete_from_library - site_id = self._site_combo.currentData() - folder = None - if site_id: - favs = self._db.get_bookmarks(site_id=site_id) - for f in favs: - if f.post_id == post.id and f.folder: - folder = f.folder - break - if delete_from_library(post.id, folder): - self._status.showMessage(f"Removed #{post.id} from library") - if 0 <= index < len(self._grid._thumbs): - self._grid._thumbs[index].set_saved_locally(False) - else: - self._status.showMessage(f"#{post.id} not in library") + self._preview._current_post = post + self._unsave_from_preview() elif action == copy_clipboard: self._copy_file_to_clipboard() elif action == copy_url: @@ -1804,13 +2019,13 @@ class BooruApp(QMainWindow): dest = dest_dir / f"{post.id}{ext}" if not dest.exists(): shutil.copy2(path, dest) - if site_id and not self._db.is_bookmarked(site_id, post.id): - self._db.add_bookmark( - site_id=site_id, post_id=post.id, - file_url=post.file_url, preview_url=post.preview_url, - tags=post.tags, rating=post.rating, score=post.score, - source=post.source, cached_path=str(path), folder=folder, - ) + # Store metadata for library search + self._db.save_library_meta( + post_id=post.id, tags=post.tags, + tag_categories=post.tag_categories, + score=post.score, rating=post.rating, + source=post.source, file_url=post.file_url, + ) self._signals.bookmark_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}") except Exception as e: log.warning(f"Operation failed: {e}") @@ -1885,9 +2100,9 @@ class BooruApp(QMainWindow): path = cached_path_for(post.file_url) if path.exists(): # Pause any playing video before opening externally - self._preview._video_player._player.pause() + self._preview._video_player.pause() if self._fullscreen_window and self._fullscreen_window.isVisible(): - self._fullscreen_window._video._player.pause() + self._fullscreen_window._video.pause() QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) else: self._status.showMessage("Image not cached yet — double-click to download first") @@ -2063,11 +2278,11 @@ class BooruApp(QMainWindow): self.setWindowTitle("booru-viewer") # Pause preview video if self._preview._stack.currentIndex() == 1: - self._preview._video_player._player.pause() - # Hide and pause slideshow + self._preview._video_player.pause() + # Hide and pause popout if self._fullscreen_window and self._fullscreen_window.isVisible(): if self._fullscreen_window._stack.currentIndex() == 1: - self._fullscreen_window._video._player.pause() + self._fullscreen_window._video.pause() self._fullscreen_window.hide() else: self._privacy_overlay.hide() @@ -2107,6 +2322,14 @@ class BooruApp(QMainWindow): if self._preview._stack.currentIndex() == 1 and self._preview.underMouse(): self._preview._video_player._toggle_play() return + elif key == Qt.Key.Key_Period: + if self._preview._stack.currentIndex() == 1: + self._preview._video_player._seek_relative(1800) + return + elif key == Qt.Key.Key_Comma: + if self._preview._stack.currentIndex() == 1: + self._preview._video_player._seek_relative(-1800) + return super().keyPressEvent(event) def _copy_file_to_clipboard(self, path: str | None = None) -> None: @@ -2176,13 +2399,51 @@ class BooruApp(QMainWindow): def _on_bookmark_done(self, index: int, msg: str) -> None: self._status.showMessage(f"{len(self._posts)} results — {msg}") + # Detect batch operations (e.g. "Saved 3/10 to Unsorted") — skip heavy updates + is_batch = "/" in msg and any(c.isdigit() for c in msg.split("/")[0][-2:]) thumbs = self._grid._thumbs if 0 <= index < len(thumbs): if "Saved" in msg: thumbs[index].set_saved_locally(True) if "Bookmarked" in msg: thumbs[index].set_bookmarked(True) + if not is_batch: + if "Bookmarked" in msg: + self._preview.update_bookmark_state(True) + if "Saved" in msg: + self._preview.update_save_state(True) + if self._stack.currentIndex() == 1: + bm_grid = self._bookmarks_view._grid + bm_idx = bm_grid.selected_index + if 0 <= bm_idx < len(bm_grid._thumbs): + bm_grid._thumbs[bm_idx].set_saved_locally(True) + if self._stack.currentIndex() == 2: + self._library_view.refresh() + self._update_fullscreen_state() + + def _on_library_files_deleted(self, post_ids: list) -> None: + """Library deleted files — clear saved dots on browse grid.""" + for i, p in enumerate(self._posts): + if p.id in post_ids and i < len(self._grid._thumbs): + self._grid._thumbs[i].set_saved_locally(False) + + def _refresh_browse_saved_dots(self) -> None: + """Bookmarks changed — rescan saved state for all visible browse grid posts.""" + for i, p in enumerate(self._posts): + if i < len(self._grid._thumbs): + self._grid._thumbs[i].set_saved_locally(self._is_post_saved(p.id)) + site_id = self._site_combo.currentData() + self._grid._thumbs[i].set_bookmarked( + bool(site_id and self._db.is_bookmarked(site_id, p.id)) + ) + + def _on_batch_done(self, msg: str) -> None: + self._status.showMessage(msg) self._update_fullscreen_state() + if self._stack.currentIndex() == 1: + self._bookmarks_view.refresh() + if self._stack.currentIndex() == 2: + self._library_view.refresh() def closeEvent(self, event) -> None: self._async_loop.call_soon_threadsafe(self._async_loop.stop) @@ -2280,6 +2541,11 @@ def run() -> None: app = QApplication(sys.argv) + # mpv requires LC_NUMERIC=C — Qt resets the locale in QApplication(), + # so we must restore it after Qt init but before creating any mpv instances. + import locale + locale.setlocale(locale.LC_NUMERIC, "C") + # Apply dark mode on Windows 10+ if system is set to dark if sys.platform == "win32": _apply_windows_dark_mode(app) diff --git a/booru_viewer/gui/bookmarks.py b/booru_viewer/gui/bookmarks.py index 1ac9c64..9032487 100644 --- a/booru_viewer/gui/bookmarks.py +++ b/booru_viewer/gui/bookmarks.py @@ -39,6 +39,7 @@ class BookmarksView(QWidget): bookmark_selected = Signal(object) bookmark_activated = Signal(object) + bookmarks_changed = Signal() # emitted after bookmark add/remove/unsave def __init__(self, db: Database, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -230,7 +231,21 @@ class BookmarksView(QWidget): save_lib_menu.addSeparator() save_lib_new = save_lib_menu.addAction("+ New Folder...") - unsave_lib = menu.addAction("Unsave from Library") + unsave_lib = None + # Only show unsave if the post is saved locally + from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS + _saved = False + _sd = saved_dir() + if _sd.exists(): + _saved = any((_sd / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS) + if not _saved: + for folder in self._db.get_folders(): + d = saved_folder_dir(folder) + if d.exists() and any((d / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS): + _saved = True + break + if _saved: + unsave_lib = menu.addAction("Unsave from Library") copy_file = menu.addAction("Copy File to Clipboard") copy_url = menu.addAction("Copy Image URL") copy_tags = menu.addAction("Copy Tags") @@ -282,6 +297,7 @@ class BookmarksView(QWidget): from ..core.cache import delete_from_library if delete_from_library(fav.post_id, fav.folder): self.refresh() + self.bookmarks_changed.emit() elif action == copy_file: path = fav.cached_path if path and Path(path).exists(): @@ -315,6 +331,7 @@ class BookmarksView(QWidget): elif action == remove_bookmark: self._db.remove_bookmark(fav.site_id, fav.post_id) self.refresh() + self.bookmarks_changed.emit() def _on_multi_context_menu(self, indices: list, pos) -> None: favs = [self._bookmarks[i] for i in indices if 0 <= i < len(self._bookmarks)] @@ -353,6 +370,7 @@ class BookmarksView(QWidget): for fav in favs: delete_from_library(fav.post_id, fav.folder) self.refresh() + self.bookmarks_changed.emit() elif action == move_none: for fav in favs: self._db.move_bookmark_to_folder(fav.id, None) @@ -367,3 +385,4 @@ class BookmarksView(QWidget): for fav in favs: self._db.remove_bookmark(fav.site_id, fav.post_id) self.refresh() + self.bookmarks_changed.emit() diff --git a/booru_viewer/gui/grid.py b/booru_viewer/gui/grid.py index f205d6d..85793fd 100644 --- a/booru_viewer/gui/grid.py +++ b/booru_viewer/gui/grid.py @@ -5,12 +5,13 @@ from __future__ import annotations from pathlib import Path from PySide6.QtCore import Qt, Signal, QSize, QRect, QMimeData, QUrl, QPoint, Property -from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QWheelEvent, QDrag +from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent from PySide6.QtWidgets import ( QWidget, QScrollArea, QMenu, QApplication, + QRubberBand, ) from ..core.api.base import Post @@ -304,6 +305,9 @@ class ThumbnailGrid(QScrollArea): self._last_click_index = -1 # for shift-click range self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom) + # Rubber band drag selection + self._rubber_band: QRubberBand | None = None + self._rb_origin: QPoint | None = None @property def selected_index(self) -> int: @@ -355,6 +359,13 @@ class ThumbnailGrid(QScrollArea): self._thumbs[idx].set_multi_selected(False) self._multi_selected.clear() + def clear_selection(self) -> None: + """Deselect everything.""" + self._clear_multi() + if 0 <= self._selected_index < len(self._thumbs): + self._thumbs[self._selected_index].set_selected(False) + self._selected_index = -1 + def _select(self, index: int) -> None: if index < 0 or index >= len(self._thumbs): return @@ -417,6 +428,42 @@ class ThumbnailGrid(QScrollArea): self.ensureWidgetVisible(self._thumbs[index]) self.context_requested.emit(index, pos) + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + # Only start rubber band if click is on empty grid space (not a thumbnail) + child = self.childAt(event.position().toPoint()) + if child is self.widget() or child is self.viewport(): + self._rb_origin = event.position().toPoint() + if not self._rubber_band: + self._rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self.viewport()) + self._rubber_band.setGeometry(QRect(self._rb_origin, QSize())) + self._rubber_band.show() + self._clear_multi() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if self._rb_origin and self._rubber_band: + rb_rect = QRect(self._rb_origin, event.position().toPoint()).normalized() + self._rubber_band.setGeometry(rb_rect) + # Select thumbnails that intersect the rubber band + vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0)) + self._clear_multi() + for i, thumb in enumerate(self._thumbs): + thumb_rect = thumb.geometry().translated(vp_offset) + if rb_rect.intersects(thumb_rect): + self._multi_selected.add(i) + thumb.set_multi_selected(True) + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if self._rb_origin and self._rubber_band: + self._rubber_band.hide() + self._rb_origin = None + return + super().mouseReleaseEvent(event) + def select_all(self) -> None: self._clear_multi() for i in range(len(self._thumbs)): diff --git a/booru_viewer/gui/library.py b/booru_viewer/gui/library.py index 47b2d7c..4a9003f 100644 --- a/booru_viewer/gui/library.py +++ b/booru_viewer/gui/library.py @@ -40,6 +40,7 @@ class LibraryView(QWidget): file_selected = Signal(str) file_activated = Signal(str) + files_deleted = Signal(list) # list of post IDs that were deleted def __init__(self, db=None, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -351,11 +352,13 @@ class LibraryView(QWidget): QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: + post_id = int(filepath.stem) if filepath.stem.isdigit() else None filepath.unlink(missing_ok=True) - # Also remove cached thumbnail lib_thumb = thumbnails_dir() / "library" / f"{filepath.stem}.jpg" lib_thumb.unlink(missing_ok=True) self.refresh() + if post_id is not None: + self.files_deleted.emit([post_id]) def _on_multi_context_menu(self, indices: list, pos) -> None: files = [self._files[i] for i in indices if 0 <= i < len(self._files)] @@ -375,8 +378,13 @@ class LibraryView(QWidget): QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: + deleted_ids = [] for f in files: + if f.stem.isdigit(): + deleted_ids.append(int(f.stem)) f.unlink(missing_ok=True) lib_thumb = thumbnails_dir() / "library" / f"{f.stem}.jpg" lib_thumb.unlink(missing_ok=True) self.refresh() + if deleted_ids: + self.files_deleted.emit(deleted_ids) diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index c0b6412..f832237 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -2,18 +2,19 @@ from __future__ import annotations +import logging from pathlib import Path -from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QUrl -from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QColor, QMovie +from PySide6.QtCore import Qt, QPointF, Signal, QTimer +from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QMovie from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMainWindow, QStackedWidget, QPushButton, QSlider, QMenu, QInputDialog, QStyle, ) -from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput -from PySide6.QtMultimediaWidgets import QVideoWidget -from ..core.config import MEDIA_EXTENSIONS +import mpv as mpvlib + +_log = logging.getLogger("booru") VIDEO_EXTENSIONS = (".mp4", ".webm", ".mkv", ".avi", ".mov") @@ -22,6 +23,42 @@ def _is_video(path: str) -> bool: return Path(path).suffix.lower() in VIDEO_EXTENSIONS +def _overlay_css(obj_name: str) -> str: + """Generate overlay CSS scoped to a specific object name for specificity.""" + return f""" + QWidget#{obj_name} * {{ + background: transparent; + color: white; + border: none; + }} + QWidget#{obj_name} QPushButton {{ + background: transparent; + color: white; + border: 1px solid rgba(255, 255, 255, 80); + padding: 2px 6px; + }} + QWidget#{obj_name} QPushButton:hover {{ + background: rgba(255, 255, 255, 30); + }} + QWidget#{obj_name} QSlider::groove:horizontal {{ + background: rgba(255, 255, 255, 40); + height: 4px; + }} + QWidget#{obj_name} QSlider::handle:horizontal {{ + background: white; + width: 10px; + margin: -4px 0; + }} + QWidget#{obj_name} QSlider::sub-page:horizontal {{ + background: rgba(255, 255, 255, 120); + }} + QWidget#{obj_name} QLabel {{ + background: transparent; + color: white; + }} + """ + + class FullscreenPreview(QMainWindow): """Fullscreen media viewer with navigation — images, GIFs, and video.""" @@ -35,39 +72,54 @@ class FullscreenPreview(QMainWindow): def __init__(self, grid_cols: int = 3, show_actions: bool = True, monitor: str = "", parent=None) -> None: super().__init__(parent, Qt.WindowType.Window) - self.setWindowTitle("booru-viewer — Slideshow") - self.setMinimumSize(640, 480) + self.setWindowTitle("booru-viewer — Popout") self._grid_cols = grid_cols + # Central widget — media fills the entire window central = QWidget() - main_layout = QVBoxLayout(central) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) + central.setLayout(QVBoxLayout()) + central.layout().setContentsMargins(0, 0, 0, 0) + central.layout().setSpacing(0) - # Top toolbar - self._toolbar = QWidget() + # Media stack (fills entire window) + self._stack = QStackedWidget() + central.layout().addWidget(self._stack) + + self._viewer = ImageViewer() + self._viewer.close_requested.connect(self.close) + self._stack.addWidget(self._viewer) + + self._video = VideoPlayer() + self._video.play_next.connect(lambda: self.navigate.emit(1)) + self._video.video_size.connect(self._on_video_size) + self._stack.addWidget(self._video) + + self.setCentralWidget(central) + + # Floating toolbar — overlays on top of media, translucent + self._toolbar = QWidget(central) toolbar = QHBoxLayout(self._toolbar) toolbar.setContentsMargins(8, 4, 8, 4) self._bookmark_btn = QPushButton("Bookmark") - self._bookmark_btn.setFixedWidth(80) + self._bookmark_btn.setMaximumWidth(80) self._bookmark_btn.clicked.connect(self.bookmark_requested) toolbar.addWidget(self._bookmark_btn) self._save_btn = QPushButton("Save") - self._save_btn.setFixedWidth(70) + self._save_btn.setMaximumWidth(70) self._save_btn.clicked.connect(self.save_toggle_requested) toolbar.addWidget(self._save_btn) self._is_saved = False self._bl_tag_btn = QPushButton("BL Tag") - self._bl_tag_btn.setFixedWidth(60) + self._bl_tag_btn.setMaximumWidth(60) self._bl_tag_btn.setToolTip("Blacklist a tag") self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu) toolbar.addWidget(self._bl_tag_btn) self._bl_post_btn = QPushButton("BL Post") - self._bl_post_btn.setFixedWidth(65) + self._bl_post_btn.setMaximumWidth(65) self._bl_post_btn.setToolTip("Blacklist this post") self._bl_post_btn.clicked.connect(self.blacklist_post_requested) toolbar.addWidget(self._bl_post_btn) @@ -80,24 +132,30 @@ class FullscreenPreview(QMainWindow): toolbar.addStretch() - self._info_label = QLabel() - toolbar.addWidget(self._info_label) + self._info_label = QLabel() # kept for API compat but hidden in slideshow + self._info_label.hide() - main_layout.addWidget(self._toolbar) + self._toolbar.raise_() - # Media stack - self._stack = QStackedWidget() - main_layout.addWidget(self._stack, stretch=1) + # Reparent video controls bar to central widget so it overlays properly + self._video._controls_bar.setParent(central) + self._toolbar.setStyleSheet("QWidget#_slideshow_toolbar { background: rgba(0,0,0,160); }" + _overlay_css("_slideshow_toolbar")) + self._toolbar.setObjectName("_slideshow_toolbar") + self._video._controls_bar.setStyleSheet("QWidget#_slideshow_controls { background: rgba(0,0,0,160); }" + _overlay_css("_slideshow_controls")) + self._video._controls_bar.setObjectName("_slideshow_controls") + self._video._controls_bar.raise_() + self._toolbar.raise_() - self._viewer = ImageViewer() - self._viewer.close_requested.connect(self.close) - self._stack.addWidget(self._viewer) - - self._video = VideoPlayer() - self._video.play_next.connect(lambda: self.navigate.emit(1)) - self._stack.addWidget(self._video) - - self.setCentralWidget(central) + # Auto-hide timer for overlay UI + self._ui_visible = True + self._hide_timer = QTimer(self) + self._hide_timer.setSingleShot(True) + self._hide_timer.setInterval(2000) + self._hide_timer.timeout.connect(self._hide_overlay) + self._hide_timer.start() + self.setMouseTracking(True) + central.setMouseTracking(True) + self._stack.setMouseTracking(True) from PySide6.QtWidgets import QApplication QApplication.instance().installEventFilter(self) @@ -146,7 +204,7 @@ class FullscreenPreview(QMainWindow): def update_state(self, bookmarked: bool, saved: bool) -> None: self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") - self._bookmark_btn.setFixedWidth(90 if bookmarked else 80) + self._bookmark_btn.setMaximumWidth(90 if bookmarked else 80) self._is_saved = saved self._save_btn.setText("Unsave" if saved else "Save") @@ -158,16 +216,63 @@ class FullscreenPreview(QMainWindow): self._video.stop() self._video.play_file(path, info) self._stack.setCurrentIndex(1) - elif ext == ".gif": - self._video.stop() - self._viewer.set_gif(path, info) - self._stack.setCurrentIndex(0) else: self._video.stop() - pix = QPixmap(path) - if not pix.isNull(): - self._viewer.set_image(pix, info) + self._video._controls_bar.hide() + if ext == ".gif": + self._viewer.set_gif(path, info) + else: + pix = QPixmap(path) + if not pix.isNull(): + self._viewer.set_image(pix, info) self._stack.setCurrentIndex(0) + # Adjust window to content aspect ratio + if not self.isFullScreen(): + pix = self._viewer._pixmap + if pix and not pix.isNull(): + self._adjust_to_aspect(pix.width(), pix.height()) + self._show_overlay() + + def _on_video_size(self, w: int, h: int) -> None: + if not self.isFullScreen() and w > 0 and h > 0: + self._adjust_to_aspect(w, h) + + def _is_hypr_floating(self) -> bool | None: + """Check if this window is floating in Hyprland. None if not on Hyprland.""" + win = self._hyprctl_get_window() + if win is None: + return None # not Hyprland + return bool(win.get("floating")) + + def _adjust_to_aspect(self, content_w: int, content_h: int) -> None: + """Resize windowed popout height to match content aspect ratio. Position untouched.""" + if self.isFullScreen() or content_w <= 0 or content_h <= 0: + return + floating = self._is_hypr_floating() + # On Hyprland tiled: skip resize entirely, just set the aspect lock prop + if floating is False: + self._hyprctl_resize(0, 0) # only sets keep_aspect_ratio + return + aspect = content_w / content_h + w = self.width() + new_h = int(w / aspect) + self.resize(w, new_h) + self._hyprctl_resize(w, new_h) + + def _show_overlay(self) -> None: + """Show toolbar and video controls, restart auto-hide timer.""" + if not self._ui_visible: + self._toolbar.show() + if self._stack.currentIndex() == 1: + self._video._controls_bar.show() + self._ui_visible = True + self._hide_timer.start() + + def _hide_overlay(self) -> None: + """Hide toolbar and video controls.""" + self._toolbar.hide() + self._video._controls_bar.hide() + self._ui_visible = False def eventFilter(self, obj, event): from PySide6.QtCore import QEvent @@ -185,15 +290,11 @@ class FullscreenPreview(QMainWindow): self.privacy_requested.emit() return True elif key == Qt.Key.Key_H and mods & Qt.KeyboardModifier.ControlModifier: - self._toolbar.setVisible(not self._toolbar.isVisible()) - # Also hide video controls if showing video - if self._stack.currentIndex() == 1: - for child in self._video.findChildren(QPushButton): - child.setVisible(self._toolbar.isVisible()) - for child in self._video.findChildren(QSlider): - child.setVisible(self._toolbar.isVisible()) - for child in self._video.findChildren(QLabel): - child.setVisible(self._toolbar.isVisible()) + if self._ui_visible: + self._hide_timer.stop() + self._hide_overlay() + else: + self._show_overlay() return True elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Q): self.close() @@ -212,7 +313,7 @@ class FullscreenPreview(QMainWindow): return True elif key == Qt.Key.Key_F11: if self.isFullScreen(): - self.showNormal() + self._exit_fullscreen() else: self.showFullScreen() return True @@ -220,21 +321,120 @@ class FullscreenPreview(QMainWindow): self._video._toggle_play() return True elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1: - self._video._seek_relative(5000) + self._video._seek_relative(1800) return True elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1: - self._video._seek_relative(-5000) + self._video._seek_relative(-1800) return True if event.type() == QEvent.Type.Wheel and self._stack.currentIndex() == 1 and self.isActiveWindow(): delta = event.angleDelta().y() if delta: - vol = self._video._audio.volume() - vol = max(0.0, min(1.0, vol + (0.05 if delta > 0 else -0.05))) - self._video._audio.setVolume(vol) - self._video._vol_slider.setValue(int(vol * 100)) + vol = max(0, min(100, self._video.volume + (5 if delta > 0 else -5))) + self._video.volume = vol + self._show_overlay() return True + if event.type() == QEvent.Type.MouseMove and self.isActiveWindow(): + self._show_overlay() return super().eventFilter(obj, event) + def _hyprctl_get_window(self) -> dict | None: + """Get the Hyprland window info for the popout window.""" + import os, subprocess, json + if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): + return None + try: + result = subprocess.run( + ["hyprctl", "clients", "-j"], + capture_output=True, text=True, timeout=1, + ) + for c in json.loads(result.stdout): + if c.get("title") == self.windowTitle(): + return c + except Exception: + pass + return None + + def _hyprctl_resize(self, w: int, h: int) -> None: + """Ask Hyprland to resize this window and lock aspect ratio. No-op on other WMs or tiled.""" + import os, subprocess + if not os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): + return + win = self._hyprctl_get_window() + if not win: + return + addr = win.get("address") + if not addr: + return + if not win.get("floating"): + # Tiled — don't resize (fights the layout), just set aspect lock + try: + subprocess.Popen( + ["hyprctl", "dispatch", "setprop", f"address:{addr} keep_aspect_ratio 1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + pass + return + try: + subprocess.Popen( + ["hyprctl", "--batch", + f"dispatch resizewindowpixel exact {w} {h},address:{addr}" + f" ; dispatch setprop address:{addr} keep_aspect_ratio 1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + pass + + def _exit_fullscreen(self) -> None: + """Leave fullscreen into an aspect-ratio-respecting window.""" + content_w, content_h = 0, 0 + if self._stack.currentIndex() == 1: + mpv = self._video._mpv + if mpv: + try: + vp = mpv.video_params + if vp and vp.get('w') and vp.get('h'): + content_w, content_h = vp['w'], vp['h'] + except Exception: + pass + else: + pix = self._viewer._pixmap + if pix and not pix.isNull(): + content_w, content_h = pix.width(), pix.height() + + screen = self.screen() + if content_w > 0 and content_h > 0 and screen: + avail = screen.availableGeometry() + max_w = int(avail.width() * 0.6) + max_h = int(avail.height() * 0.6) + aspect = content_w / content_h + if max_w / max_h > aspect: + win_h = max_h + win_w = int(win_h * aspect) + else: + win_w = max_w + win_h = int(win_w / aspect) + x = avail.x() + (avail.width() - win_w) // 2 + y = avail.y() + (avail.height() - win_h) // 2 + geo = self.geometry() + geo.setRect(x, y, win_w, win_h) + FullscreenPreview._saved_geometry = geo + FullscreenPreview._saved_fullscreen = False + self.showNormal() + if content_w > 0 and content_h > 0 and screen: + self.resize(win_w, win_h) + self._hyprctl_resize(win_w, win_h) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + # Position floating overlays + w = self.centralWidget().width() + h = self.centralWidget().height() + tb_h = self._toolbar.sizeHint().height() + self._toolbar.setGeometry(0, 0, w, tb_h) + ctrl_h = self._video._controls_bar.sizeHint().height() + self._video._controls_bar.setGeometry(0, h - ctrl_h, w, ctrl_h) + def closeEvent(self, event) -> None: from PySide6.QtWidgets import QApplication # Save window state for next open @@ -397,12 +597,128 @@ class _ClickSeekSlider(QSlider): super().mousePressEvent(event) -# -- Video Player -- +# -- Video Player (mpv backend via OpenGL render API) -- + + +class _MpvGLWidget(QWidget): + """OpenGL widget that hosts mpv rendering via the render API. + + Subclasses QOpenGLWidget so initializeGL/paintGL are dispatched + correctly by Qt's C++ virtual method mechanism. + Works on both X11 and Wayland. + """ + + _frame_ready = Signal() # mpv thread → main thread repaint trigger + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._gl: _MpvOpenGLSurface = _MpvOpenGLSurface(self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._gl) + self._ctx: mpvlib.MpvRenderContext | None = None + self._gl_inited = False + self._proc_addr_fn = None + self._frame_ready.connect(self._gl.update) + # Create mpv eagerly on the main thread. + self._mpv = mpvlib.MPV( + vo="libmpv", + hwdec="auto", + keep_open="yes", + input_default_bindings=False, + input_vo_keyboard=False, + osc=False, + ) + # Wire up the GL surface's callbacks to us + self._gl._owner = self + + def _init_gl(self) -> None: + if self._gl_inited or self._mpv is None: + return + from PySide6.QtGui import QOpenGLContext + ctx = QOpenGLContext.currentContext() + if not ctx: + return + + def _get_proc_address(_ctx, name): + if isinstance(name, bytes): + name_str = name + else: + name_str = name.encode('utf-8') + addr = ctx.getProcAddress(name_str) + if addr is not None: + return int(addr) + return 0 + + self._proc_addr_fn = mpvlib.MpvGlGetProcAddressFn(_get_proc_address) + self._ctx = mpvlib.MpvRenderContext( + self._mpv, 'opengl', + opengl_init_params={'get_proc_address': self._proc_addr_fn}, + ) + self._ctx.update_cb = self._on_mpv_frame + self._gl_inited = True + + def _on_mpv_frame(self) -> None: + """Called from mpv thread when a new frame is ready.""" + self._frame_ready.emit() + + def _paint_gl(self) -> None: + if self._ctx is None: + self._init_gl() + if self._ctx is None: + return + ratio = self._gl.devicePixelRatioF() + w = int(self._gl.width() * ratio) + h = int(self._gl.height() * ratio) + self._ctx.render( + opengl_fbo={'w': w, 'h': h, 'fbo': self._gl.defaultFramebufferObject()}, + flip_y=True, + ) + + def ensure_gl_init(self) -> None: + """Force GL context creation and render context setup. + + Needed when the widget is hidden (e.g. inside a QStackedWidget) + but mpv needs a render context before loadfile(). + """ + if not self._gl_inited: + self._gl.makeCurrent() + self._init_gl() + + def cleanup(self) -> None: + if self._ctx: + self._ctx.free() + self._ctx = None + if self._mpv: + self._mpv.terminate() + self._mpv = None + + +from PySide6.QtOpenGLWidgets import QOpenGLWidget as _QOpenGLWidget + + +class _MpvOpenGLSurface(_QOpenGLWidget): + """QOpenGLWidget subclass — delegates initializeGL/paintGL to _MpvGLWidget.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._owner: _MpvGLWidget | None = None + + def initializeGL(self) -> None: + if self._owner: + self._owner._init_gl() + + def paintGL(self) -> None: + if self._owner: + self._owner._paint_gl() + class VideoPlayer(QWidget): - """Video player with transport controls.""" + """Video player with transport controls, powered by mpv.""" - play_next = Signal() # emitted when video ends in "next" mode + 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 def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -410,35 +726,26 @@ class VideoPlayer(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Video surface - self._video_widget = QVideoWidget() - self._video_widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) - # Match letterbox color to theme background - from PySide6.QtGui import QPalette - vp = self._video_widget.palette() - vp.setColor(QPalette.ColorRole.Window, self.palette().color(QPalette.ColorRole.Window)) - self._video_widget.setPalette(vp) - self._video_widget.setAutoFillBackground(True) - layout.addWidget(self._video_widget, stretch=1) + # Video surface — mpv renders via OpenGL render API + self._gl_widget = _MpvGLWidget() + layout.addWidget(self._gl_widget, stretch=1) - # Player - self._player = QMediaPlayer() - self._audio = QAudioOutput() - self._player.setAudioOutput(self._audio) - self._player.setVideoOutput(self._video_widget) - self._audio.setVolume(0.5) + # mpv reference (set by _ensure_mpv) + self._mpv: mpvlib.MPV | None = None - # Controls bar - controls = QHBoxLayout() + # 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) self._play_btn = QPushButton("Play") - self._play_btn.setFixedWidth(65) + self._play_btn.setMaximumWidth(65) self._play_btn.clicked.connect(self._toggle_play) controls.addWidget(self._play_btn) self._time_label = QLabel("0:00") - self._time_label.setFixedWidth(45) + self._time_label.setMaximumWidth(45) controls.addWidget(self._time_label) self._seek_slider = _ClickSeekSlider(Qt.Orientation.Horizontal) @@ -448,24 +755,24 @@ class VideoPlayer(QWidget): controls.addWidget(self._seek_slider, stretch=1) self._duration_label = QLabel("0:00") - self._duration_label.setFixedWidth(45) + 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(80) + 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.setFixedWidth(80) + self._mute_btn.setMaximumWidth(80) self._mute_btn.clicked.connect(self._toggle_mute) controls.addWidget(self._mute_btn) self._autoplay = True self._autoplay_btn = QPushButton("Auto") - self._autoplay_btn.setFixedWidth(70) + self._autoplay_btn.setMaximumWidth(70) self._autoplay_btn.setCheckable(True) self._autoplay_btn.setChecked(True) self._autoplay_btn.setToolTip("Auto-play videos when selected") @@ -475,108 +782,240 @@ class VideoPlayer(QWidget): self._loop_state = 0 # 0=Loop, 1=Once, 2=Next self._loop_btn = QPushButton("Loop") - self._loop_btn.setFixedWidth(55) + self._loop_btn.setMaximumWidth(55) 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) - layout.addLayout(controls) + # Style controls to match the translucent overlay look + self._controls_bar.setObjectName("_preview_controls") + self._controls_bar.setStyleSheet( + "QWidget#_preview_controls { background: rgba(0,0,0,160); }" + _overlay_css("_preview_controls") + ) + # Add to layout — in preview panel this is part of the normal layout. + # FullscreenPreview reparents it to float as an overlay. + layout.addWidget(self._controls_bar) - # Signals - self._player.positionChanged.connect(self._on_position) - self._player.durationChanged.connect(self._on_duration) - self._player.playbackStateChanged.connect(self._on_state) - self._player.mediaStatusChanged.connect(self._on_media_status) - self._player.errorOccurred.connect(self._on_error) + self._eof_pending = False + + # 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 - self._error_fired = False + + 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 + 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: + m = self._ensure_mpv() + self._gl_widget.ensure_gl_init() self._current_file = path - self._error_fired = False - self._ended = False - self._last_pos = 0 - self._player.setLoops(QMediaPlayer.Loops.Infinite) - self._player.setSource(QUrl.fromLocalFile(path)) + self._media_ready_fired = False + self._pending_duration = None + self._eof_pending = False + self._apply_loop_to_mpv() + m.loadfile(path) if self._autoplay: - self._player.play() + m.pause = False else: - self._player.pause() + 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") - # If turning off autoplay mid-playback, let current video finish then stop def _cycle_loop(self) -> None: - self._loop_state = (self._loop_state + 1) % 3 - labels = ["Loop", "Once", "Next"] - self._loop_btn.setText(labels[self._loop_state]) - self._autoplay_btn.setVisible(self._loop_state == 2) + self.loop_state = (self._loop_state + 1) % 3 - @property - def _loop_mode(self): - return self._loop_state == 0 - - def stop(self) -> None: - self._player.stop() - self._player.setSource(QUrl()) - - def _toggle_play(self) -> None: - if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: - self._player.pause() - else: - self._player.play() + 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: - self._player.setPosition(pos) + """Seek to position in milliseconds (from slider).""" + if self._mpv: + self._mpv.seek(pos / 1000.0, 'absolute') def _seek_relative(self, ms: int) -> None: - pos = max(0, self._player.position() + ms) - self._player.setPosition(pos) + if self._mpv: + self._mpv.seek(ms / 1000.0, 'relative+exact') def _set_volume(self, val: int) -> None: - self._audio.setVolume(val / 100.0) + if self._mpv: + self._mpv.volume = val def _toggle_mute(self) -> None: - self._audio.setMuted(not self._audio.isMuted()) - self._mute_btn.setText("Unmute" if self._audio.isMuted() else "Mute") + if self._mpv: + self._mpv.mute = not self._mpv.mute + self._mute_btn.setText("Unmute" if self._mpv.mute else "Mute") - def _on_position(self, pos: int) -> None: - if not self._seek_slider.isSliderDown(): - self._seek_slider.setValue(pos) - self._time_label.setText(self._fmt(pos)) - # Detect loop restart: position jumps from near-end back to start - duration = self._player.duration() - if (self._last_pos > 500 and pos < 100 and not self._ended - and duration > 0 and self._last_pos > duration * 0.8): - if self._loop_state == 1: # Once - self._ended = True - self._player.pause() - elif self._loop_state == 2: # Next - self._ended = True - self._player.pause() - self.play_next.emit() - self._last_pos = pos + # -- mpv callbacks (called from mpv thread) -- - def _on_duration(self, dur: int) -> None: - self._seek_slider.setRange(0, dur) - self._duration_label.setText(self._fmt(dur)) + 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'): + self._pending_video_size = (value['w'], value['h']) - def _on_state(self, state) -> None: - if state == QMediaPlayer.PlaybackState.PlayingState: - self._play_btn.setText("Pause") - else: - self._play_btn.setText("Play") + def _on_eof_reached(self, _name: str, value) -> None: + """Called from mpv thread when eof-reached changes.""" + if value is True: + self._eof_pending = True - def _on_media_status(self, status) -> None: - pass + def _on_duration_change(self, _name: str, value) -> None: + if value is not None and value > 0: + self._pending_duration = value - def _on_error(self, error, msg: str = "") -> None: - if self._current_file and not self._error_fired: - self._error_fired = True - import logging - logging.getLogger("booru").warning(f"Video playback error: {error} {msg} ({self._current_file})") + # -- 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: @@ -584,6 +1023,12 @@ class VideoPlayer(QWidget): 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) -- @@ -596,6 +1041,8 @@ class ImagePreview(QWidget): save_to_folder = Signal(str) unsave_requested = Signal() bookmark_requested = Signal() + blacklist_tag_requested = Signal(str) + blacklist_post_requested = Signal() navigate = Signal(int) # -1 = prev, +1 = next fullscreen_requested = Signal() @@ -603,13 +1050,57 @@ class ImagePreview(QWidget): super().__init__(parent) self._folders_callback = None self._current_path: str | None = None + self._current_post = None # Post object, set by app.py + self._current_site_id = None # site_id for the current post + self._is_saved = False # tracks library save state for context menu + self._current_tags: dict[str, list[str]] = {} + self._current_tag_list: list[str] = [] layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) + # Action toolbar — above the media, in the layout + self._toolbar = QWidget() + tb = QHBoxLayout(self._toolbar) + tb.setContentsMargins(2, 1, 2, 1) + tb.setSpacing(4) + + self._bookmark_btn = QPushButton("Bookmark") + self._bookmark_btn.setFixedWidth(80) + self._bookmark_btn.clicked.connect(self.bookmark_requested) + tb.addWidget(self._bookmark_btn) + + self._save_btn = QPushButton("Save") + self._save_btn.setFixedWidth(50) + self._save_btn.clicked.connect(self._on_save_clicked) + tb.addWidget(self._save_btn) + + self._bl_tag_btn = QPushButton("BL Tag") + self._bl_tag_btn.setFixedWidth(55) + self._bl_tag_btn.setToolTip("Blacklist a tag") + self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu) + tb.addWidget(self._bl_tag_btn) + + self._bl_post_btn = QPushButton("BL Post") + self._bl_post_btn.setFixedWidth(60) + self._bl_post_btn.setToolTip("Blacklist this post") + self._bl_post_btn.clicked.connect(self.blacklist_post_requested) + tb.addWidget(self._bl_post_btn) + + tb.addStretch() + + self._popout_btn = QPushButton("Popout") + self._popout_btn.setFixedWidth(60) + self._popout_btn.setToolTip("Open in popout") + self._popout_btn.clicked.connect(self.fullscreen_requested) + tb.addWidget(self._popout_btn) + + self._toolbar.hide() # shown when a post is active + layout.addWidget(self._toolbar) + self._stack = QStackedWidget() - layout.addWidget(self._stack) + layout.addWidget(self._stack, stretch=1) # Image viewer (index 0) self._image_viewer = ImageViewer() @@ -632,6 +1123,60 @@ class ImagePreview(QWidget): self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._on_context_menu) + def set_post_tags(self, tag_categories: dict[str, list[str]], tag_list: list[str]) -> None: + self._current_tags = tag_categories + self._current_tag_list = tag_list + + def _show_bl_tag_menu(self) -> None: + menu = QMenu(self) + if self._current_tags: + for category, tags in self._current_tags.items(): + cat_menu = menu.addMenu(category) + for tag in tags[:30]: + cat_menu.addAction(tag) + else: + for tag in self._current_tag_list[:30]: + menu.addAction(tag) + action = menu.exec(self._bl_tag_btn.mapToGlobal(self._bl_tag_btn.rect().bottomLeft())) + if action: + self.blacklist_tag_requested.emit(action.text()) + + def _on_save_clicked(self) -> None: + if self._save_btn.text() == "Unsave": + self.unsave_requested.emit() + return + menu = QMenu(self) + unsorted = menu.addAction("Unsorted") + menu.addSeparator() + folder_actions = {} + if self._folders_callback: + for folder in self._folders_callback(): + a = menu.addAction(folder) + folder_actions[id(a)] = folder + menu.addSeparator() + new_action = menu.addAction("+ New Folder...") + action = menu.exec(self._save_btn.mapToGlobal(self._save_btn.rect().bottomLeft())) + if not action: + return + if action == unsorted: + self.save_to_folder.emit("") + elif action == new_action: + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + self.save_to_folder.emit(name.strip()) + elif id(action) in folder_actions: + self.save_to_folder.emit(folder_actions[id(action)]) + + def update_bookmark_state(self, bookmarked: bool) -> None: + self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") + self._bookmark_btn.setFixedWidth(90 if bookmarked else 80) + + def update_save_state(self, saved: bool) -> None: + self._is_saved = saved + self._save_btn.setText("Unsave" if saved else "Save") + + + # Keep these for compatibility with app.py accessing them @property def _pixmap(self): @@ -650,6 +1195,8 @@ class ImagePreview(QWidget): self._stack.setCurrentIndex(0) self._info_label.setText(info) self._current_path = None + self._toolbar.show() + self._toolbar.raise_() def set_media(self, path: str, info: str = "") -> None: """Auto-detect and show image or video.""" @@ -673,12 +1220,15 @@ class ImagePreview(QWidget): self._image_viewer.set_image(pix, info) self._stack.setCurrentIndex(0) self._info_label.setText(info) + self._toolbar.show() + self._toolbar.raise_() def clear(self) -> None: self._video_player.stop() self._image_viewer.clear() self._info_label.setText("") self._current_path = None + self._toolbar.hide() def _on_context_menu(self, pos) -> None: menu = QMenu(self) @@ -695,6 +1245,10 @@ class ImagePreview(QWidget): save_menu.addSeparator() save_new = save_menu.addAction("+ New Folder...") + unsave_action = None + if self._is_saved: + unsave_action = menu.addAction("Unsave from Library") + menu.addSeparator() copy_image = menu.addAction("Copy File to Clipboard") open_action = menu.addAction("Open in Default App") @@ -705,12 +1259,9 @@ class ImagePreview(QWidget): if self._stack.currentIndex() == 0: reset_action = menu.addAction("Reset View") - menu.addSeparator() - unsave_action = menu.addAction("Unsave from Library") - - slideshow_action = None + popout_action = None if self._current_path: - slideshow_action = menu.addAction("Slideshow Mode") + popout_action = menu.addAction("Popout") clear_action = menu.addAction("Clear Preview") action = menu.exec(self.mapToGlobal(pos)) @@ -745,7 +1296,7 @@ class ImagePreview(QWidget): self._image_viewer.update() elif action == unsave_action: self.unsave_requested.emit() - elif action == slideshow_action: + elif action == popout_action: self.fullscreen_requested.emit() elif action == clear_action: self.close_requested.emit() @@ -759,10 +1310,8 @@ class ImagePreview(QWidget): def wheelEvent(self, event) -> None: if self._stack.currentIndex() == 1: delta = event.angleDelta().y() - vol = self._video_player._audio.volume() - vol = max(0.0, min(1.0, vol + (0.05 if delta > 0 else -0.05))) - self._video_player._audio.setVolume(vol) - self._video_player._vol_slider.setValue(int(vol * 100)) + vol = max(0, min(100, self._video_player.volume + (5 if delta > 0 else -5))) + self._video_player.volume = vol else: super().wheelEvent(event) @@ -772,9 +1321,9 @@ class ImagePreview(QWidget): elif event.key() == Qt.Key.Key_Space: self._video_player._toggle_play() elif event.key() == Qt.Key.Key_Period: - self._video_player._seek_relative(5000) + self._video_player._seek_relative(1800) elif event.key() == Qt.Key.Key_Comma: - self._video_player._seek_relative(-5000) + self._video_player._seek_relative(-1800) elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_H): self.navigate.emit(-1) elif event.key() in (Qt.Key.Key_Right, Qt.Key.Key_L): diff --git a/booru_viewer/gui/settings.py b/booru_viewer/gui/settings.py index 6585ab3..6d598e7 100644 --- a/booru_viewer/gui/settings.py +++ b/booru_viewer/gui/settings.py @@ -99,6 +99,18 @@ class SettingsDialog(QDialog): self._default_rating.setCurrentIndex(idx) form.addRow("Default rating filter:", self._default_rating) + # Default site + self._default_site = QComboBox() + self._default_site.addItem("(none)", 0) + for site in self._db.get_sites(): + self._default_site.addItem(site.name, site.id) + default_site_id = self._db.get_setting_int("default_site_id") + if default_site_id: + idx = self._default_site.findData(default_site_id) + if idx >= 0: + self._default_site.setCurrentIndex(idx) + form.addRow("Default site:", self._default_site) + # Default min score self._default_score = QSpinBox() self._default_score.setRange(0, 99999) @@ -135,7 +147,7 @@ class SettingsDialog(QDialog): idx = self._monitor_combo.findText(current_monitor) if idx >= 0: self._monitor_combo.setCurrentIndex(idx) - form.addRow("Slideshow monitor:", self._monitor_combo) + form.addRow("Popout monitor:", self._monitor_combo) # File dialog platform (Linux only) self._file_dialog_combo = None @@ -190,6 +202,12 @@ class SettingsDialog(QDialog): self._max_cache.setValue(self._db.get_setting_int("max_cache_mb")) limits_layout.addRow("Max cache size:", self._max_cache) + self._max_thumb_cache = QSpinBox() + self._max_thumb_cache.setRange(50, 10000) + self._max_thumb_cache.setSuffix(" MB") + self._max_thumb_cache.setValue(self._db.get_setting_int("max_thumb_cache_mb") or 500) + limits_layout.addRow("Max thumbnail cache:", self._max_thumb_cache) + self._auto_evict = QCheckBox("Auto-evict oldest when limit reached") self._auto_evict.setChecked(self._db.get_setting_bool("auto_evict")) limits_layout.addRow("", self._auto_evict) @@ -683,6 +701,7 @@ class SettingsDialog(QDialog): self._db.set_setting("page_size", str(self._page_size.value())) self._db.set_setting("thumbnail_size", str(self._thumb_size.value())) self._db.set_setting("default_rating", self._default_rating.currentText()) + self._db.set_setting("default_site_id", str(self._default_site.currentData() or 0)) self._db.set_setting("default_score", str(self._default_score.value())) self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0") self._db.set_setting("prefetch_mode", self._prefetch_combo.currentText()) @@ -690,6 +709,7 @@ class SettingsDialog(QDialog): self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText()) self._db.set_setting("library_dir", self._library_dir.text().strip()) self._db.set_setting("max_cache_mb", str(self._max_cache.value())) + self._db.set_setting("max_thumb_cache_mb", str(self._max_thumb_cache.value())) self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0") self._db.set_setting("clear_cache_on_exit", "1" if self._clear_on_exit.isChecked() else "0") self._db.set_setting("blacklist_enabled", "1" if self._bl_enabled.isChecked() else "0") diff --git a/installer.iss b/installer.iss index e9e1382..682c5e1 100644 --- a/installer.iss +++ b/installer.iss @@ -2,7 +2,7 @@ [Setup] AppName=booru-viewer -AppVersion=0.1.9 +AppVersion=0.2.0 AppPublisher=pax AppPublisherURL=https://git.pax.moe/pax/booru-viewer DefaultDirName={localappdata}\booru-viewer diff --git a/pyproject.toml b/pyproject.toml index 24bf02b..a6c9865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,14 @@ build-backend = "hatchling.build" [project] name = "booru-viewer" -version = "0.1.9" +version = "0.2.0" description = "Local booru image browser with Qt6 GUI" requires-python = ">=3.11" dependencies = [ "httpx[http2]>=0.27", "Pillow>=10.0", "PySide6>=6.6", + "python-mpv>=1.0", ] [project.scripts] diff --git a/themes/README.md b/themes/README.md index b9fd965..c664dbe 100644 --- a/themes/README.md +++ b/themes/README.md @@ -185,6 +185,18 @@ QTabBar::tab:selected { ### Video Player Controls +The preview panel's video controls bar uses a translucent overlay style by default (`rgba(0,0,0,160)` background, white text). This is styled internally and **overrides QSS** for the controls bar. The seek/volume sliders and buttons inside the controls bar use a built-in dark overlay theme. + +To override the preview controls bar background in QSS: + +```css +QWidget#_preview_controls { + background: rgba(40, 40, 40, 200); /* your custom translucent bg */ +} +``` + +Standard slider styling still applies outside the controls bar: + ```css QSlider::groove:horizontal { background: #333; @@ -199,6 +211,43 @@ QSlider::handle:horizontal { } ``` +### Popout Overlay + +The popout (fullscreen preview) toolbar and video controls float over the media with a translucent background and auto-hide after 2 seconds of no mouse activity. Mouse movement or Ctrl+H toggles them. + +These overlays use internal styling that overrides QSS. To customize: + +```css +/* Popout top toolbar */ +QWidget#_slideshow_toolbar { + background: rgba(40, 40, 40, 200); +} + +/* Popout bottom video controls */ +QWidget#_slideshow_controls { + background: rgba(40, 40, 40, 200); +} +``` + +Buttons and labels inside both overlays inherit a white-on-transparent style. To override: + +```css +QWidget#_slideshow_toolbar QPushButton { + border: 1px solid rgba(255, 255, 255, 120); + color: #ccc; +} +QWidget#_slideshow_controls QPushButton { + border: 1px solid rgba(255, 255, 255, 120); + color: #ccc; +} +``` + +### Preview Toolbar + +The preview panel has an action toolbar (Bookmark, Save, BL Tag, BL Post, Popout) that appears above the media when a post is active. This toolbar uses the app's default button styling. + +The toolbar does not have a named object ID — it inherits the app's `QPushButton` styles directly. + ### Progress Bar (Download) ```css @@ -230,6 +279,17 @@ QLabel { } ``` +### Rubber Band Selection + +Click and drag on empty grid space to select multiple thumbnails. The rubber band uses the system's default `QRubberBand` style, which can be customized: + +```css +QRubberBand { + background: rgba(0, 120, 215, 40); + border: 1px solid #0078d7; +} +``` + ### Thumbnail Indicators ```css @@ -257,4 +317,3 @@ ThumbnailWidget { - Tag category colors (Artist, Character, etc.) in the info panel are set in code, not via QSS - Saved dot (green) and bookmark star (yellow) are QSS-controllable via `qproperty-savedColor` and `qproperty-bookmarkedColor` on `ThumbnailWidget` - Use `QLabel { background: transparent; }` to prevent labels from getting opaque backgrounds -- Right-click on thumbnails selects visually but does not change the preview diff --git a/themes/catppuccin-mocha.qss b/themes/catppuccin-mocha.qss index 00ae173..8764ce9 100644 --- a/themes/catppuccin-mocha.qss +++ b/themes/catppuccin-mocha.qss @@ -117,3 +117,10 @@ QTabBar::tab:selected { background: #45475a; color: #cba6f7; } + +/* Popout & preview overlay controls */ +QWidget#_preview_controls, +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +} diff --git a/themes/everforest.qss b/themes/everforest.qss index aa0aba7..50bb925 100644 --- a/themes/everforest.qss +++ b/themes/everforest.qss @@ -114,3 +114,10 @@ QTabBar::tab:selected { background: #4f585e; color: #a7c080; } + +/* Popout & preview overlay controls */ +QWidget#_preview_controls, +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +} diff --git a/themes/gruvbox.qss b/themes/gruvbox.qss index 6ad7576..f03930a 100644 --- a/themes/gruvbox.qss +++ b/themes/gruvbox.qss @@ -114,3 +114,10 @@ QTabBar::tab:selected { background: #504945; color: #fe8019; } + +/* Popout & preview overlay controls */ +QWidget#_preview_controls, +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +} diff --git a/themes/nord.qss b/themes/nord.qss index 6a9823c..c547425 100644 --- a/themes/nord.qss +++ b/themes/nord.qss @@ -117,3 +117,10 @@ QTabBar::tab:selected { color: #88c0d0; border-bottom-color: #88c0d0; } + +/* Popout & preview overlay controls */ +QWidget#_preview_controls, +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +} diff --git a/themes/solarized-dark.qss b/themes/solarized-dark.qss index 2ba246a..adc4cd1 100644 --- a/themes/solarized-dark.qss +++ b/themes/solarized-dark.qss @@ -114,3 +114,10 @@ QTabBar::tab:selected { background: #586e75; color: #fdf6e3; } + +/* Popout & preview overlay controls */ +QWidget#_preview_controls, +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +} diff --git a/themes/tokyo-night.qss b/themes/tokyo-night.qss index 5ed7858..ed46fe2 100644 --- a/themes/tokyo-night.qss +++ b/themes/tokyo-night.qss @@ -114,3 +114,10 @@ QTabBar::tab:selected { background: #3b4261; color: #7aa2f7; } + +/* Popout & preview overlay controls */ +QWidget#_preview_controls, +QWidget#_slideshow_toolbar, +QWidget#_slideshow_controls { + background: rgba(0, 0, 0, 160); +}