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);
+}