Files
SageTube/ytsage/gui/ytsage_gui_cards.py
T
Homer 3aca43b372 Give the app icons, tooltips and a finished theme
There was no icon system at all. Transport buttons called
QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark
monochrome glyph -- painted onto the app's saturated red buttons at a fixed
36px with no text, that is the black square. Everything else called an icon
was an emoji baked into en.json, which is tofu wherever the emoji font is
missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument
to the new helper; that is the whole fix.

The SVGs are drawn here rather than vendored, which keeps a third-party
licence out of the tree, and they live in the module rather than as asset
files, which keeps them out of package-data and safe in a frozen build.

Tooltips were the other half: three widgets in the entire application had one
and nothing set an accessible name. Filling that in at the call sites would
have meant editing well over a hundred of them, most in upstream-owned files.
Instead one application-level event filter handles QEvent.Polish, which Qt
sends to every widget once before it is shown -- so it also reaches dialogs
built by upstream code, and survives the next merge. It maps placeholder
emoji to icons, fills empty tooltips from the button text, and logs icon-only
buttons that still have none so the gaps are findable.

StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing
else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips
and the horizontal scrollbar fell through to the platform style. EXTRA_QSS
covers them, in a fork-owned module appended at the one application site.
SmoothTabWidget names its frame "tabContent" with the comment "We draw border
on content instead" -- that rule existed only inside two dialogs, and now
exists for the main window too.

Two corrections to rules that were already there: the pressed style changed
the padding, shifting every label two pixels and clipping fixed-width icon
buttons, and checkboxes were fully rounded, which reads as a radio button
rather than an on/off toggle.

Verified by screenshot on the real display: tab bar, buttons, combo carets
and checkboxes all render as intended, and all 35 icons rasterise non-empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:58:38 +02:00

279 lines
9.9 KiB
Python

"""
Video cards and card grid
=========================
VideoCard renders one yt-dlp flat entry (thumbnail, title, channel, duration)
with Play / Queue / Download / Channel actions wired to the AppRouter.
VideoCardGrid lays cards out in a responsive grid inside a scroll area with
an optional "Load more" button for pagination.
Thumbnails are fetched off-thread (shared QThreadPool) and cached on disk in
APP_THUMBNAILS_DIR keyed by video id, following the history dialog's pattern.
"""
import hashlib
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import requests
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, Signal, QSize
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..utils.ytsage_constants import APP_THUMBNAILS_DIR
from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger
CARD_WIDTH = 300
THUMB_SIZE = QSize(284, 160)
_thumb_pool = QThreadPool()
_thumb_pool.setMaxThreadCount(6)
def _entry_video_id(entry: Dict[str, Any]) -> str:
vid = entry.get("id") or entry.get("url") or entry.get("title") or "unknown"
return hashlib.sha1(str(vid).encode("utf-8")).hexdigest()[:20]
def _entry_thumbnail_url(entry: Dict[str, Any]) -> Optional[str]:
if entry.get("thumbnail"):
return entry["thumbnail"]
thumbs = entry.get("thumbnails") or []
if thumbs:
# flat entries carry a list sorted small->large; prefer a medium one
mid = thumbs[len(thumbs) // 2]
return mid.get("url")
if entry.get("id") and entry.get("ie_key", "Youtube") == "Youtube":
return f"https://i.ytimg.com/vi/{entry['id']}/mqdefault.jpg"
return None
def format_duration(seconds: Optional[float]) -> str:
if not seconds:
return ""
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
class _ThumbSignals(QObject):
loaded = Signal(str, bytes) # cache_key, data
class _ThumbFetchTask(QRunnable):
"""Fetch a thumbnail (disk cache first) off the GUI thread."""
def __init__(self, cache_key: str, url: str, signals: _ThumbSignals) -> None:
super().__init__()
self._cache_key = cache_key
self._url = url
self._signals = signals
def run(self) -> None:
cache_file = APP_THUMBNAILS_DIR / f"{self._cache_key}.jpg"
try:
if cache_file.exists():
self._signals.loaded.emit(self._cache_key, cache_file.read_bytes())
return
response = requests.get(self._url, timeout=10)
if response.status_code == 200 and response.content:
try:
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
cache_file.write_bytes(response.content)
except OSError as e:
logger.debug(f"Could not cache thumbnail: {e}")
self._signals.loaded.emit(self._cache_key, response.content)
except Exception as e:
logger.debug(f"Thumbnail fetch failed for {self._url}: {e}")
class VideoCard(QFrame):
"""One video entry with hover actions."""
def __init__(self, entry: Dict[str, Any], router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.entry = entry
self._router = router
self._cache_key = _entry_video_id(entry)
self.setFixedWidth(CARD_WIDTH)
self.setObjectName("videoCard")
self.setStyleSheet(
"""
QFrame#videoCard {
background-color: #1b2021;
border-radius: 8px;
}
QFrame#videoCard:hover { background-color: #24292b; }
"""
)
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
self.thumb_label = QLabel()
self.thumb_label.setFixedSize(THUMB_SIZE)
self.thumb_label.setStyleSheet("background-color: #101314; border-radius: 4px;")
self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.thumb_label)
duration_text = format_duration(entry.get("duration"))
title_text = entry.get("title") or entry.get("url") or ""
title_label = QLabel(title_text)
title_label.setWordWrap(True)
title_label.setStyleSheet("font-weight: bold;")
title_label.setMaximumHeight(44)
layout.addWidget(title_label)
meta_parts = [p for p in [entry.get("channel") or entry.get("uploader"), duration_text] if p]
meta_label = QLabel(" • ".join(meta_parts))
meta_label.setStyleSheet("color: #9aa0a6; font-size: 11px;")
layout.addWidget(meta_label)
actions = QHBoxLayout()
actions.setSpacing(6)
self.play_btn = QPushButton(_("cards.play"))
self.play_btn.setToolTip(_("cards.play_tooltip"))
self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
actions.addWidget(self.play_btn)
self.queue_btn = QPushButton(_("cards.queue"))
self.queue_btn.setToolTip(_("cards.queue_tooltip"))
self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry()))
actions.addWidget(self.queue_btn)
self.download_btn = QPushButton(_("cards.download"))
self.download_btn.setToolTip(_("cards.download_tooltip"))
self.download_btn.clicked.connect(self._emit_download)
actions.addWidget(self.download_btn)
layout.addLayout(actions)
self._thumb_signals = _ThumbSignals()
self._thumb_signals.loaded.connect(self._on_thumb_loaded)
thumb_url = _entry_thumbnail_url(entry)
if thumb_url:
_thumb_pool.start(_ThumbFetchTask(self._cache_key, thumb_url, self._thumb_signals))
def _routed_entry(self) -> Dict[str, Any]:
e = dict(self.entry)
if not e.get("url") and e.get("id"):
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
return e
def _emit_download(self) -> None:
e = self._routed_entry()
if e.get("url"):
self._router.downloadVideo.emit(e["url"])
def _on_thumb_loaded(self, cache_key: str, data: bytes) -> None:
if cache_key != self._cache_key:
return
pixmap = QPixmap()
if pixmap.loadFromData(data):
self.thumb_label.setPixmap(
pixmap.scaled(
THUMB_SIZE,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
)
def mouseDoubleClickEvent(self, event) -> None:
self._router.playVideo.emit(self._routed_entry())
super().mouseDoubleClickEvent(event)
class VideoCardGrid(QScrollArea):
"""Responsive grid of VideoCards with optional Load more pagination."""
loadMoreRequested = Signal()
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._cards: List[VideoCard] = []
self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._container = QWidget()
self._container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
outer = QVBoxLayout(self._container)
outer.setContentsMargins(4, 4, 4, 4)
self._grid_widget = QWidget()
self._grid = QGridLayout(self._grid_widget)
self._grid.setSpacing(10)
self._grid.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
outer.addWidget(self._grid_widget)
self.load_more_btn = QPushButton(_("cards.load_more"))
self.load_more_btn.setToolTip(_("cards.load_more_tooltip"))
self.load_more_btn.clicked.connect(self.loadMoreRequested.emit)
self.load_more_btn.setVisible(False)
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
self.empty_label = QLabel(_("cards.empty"))
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
outer.addWidget(self.empty_label)
outer.addStretch()
self.setWidget(self._container)
# ---------------------------------------------------------------- data
def set_entries(self, entries: List[Dict[str, Any]], show_load_more: bool = False) -> None:
self.clear()
self.append_entries(entries, show_load_more=show_load_more)
def append_entries(self, entries: List[Dict[str, Any]], show_load_more: bool = False) -> None:
for entry in entries:
card = VideoCard(entry, self._router, self._grid_widget)
self._cards.append(card)
self._relayout()
self.load_more_btn.setVisible(show_load_more)
self.empty_label.setVisible(not self._cards)
def clear(self) -> None:
for card in self._cards:
self._grid.removeWidget(card)
card.deleteLater()
self._cards = []
self.empty_label.setVisible(True)
self.load_more_btn.setVisible(False)
def card_count(self) -> int:
return len(self._cards)
# -------------------------------------------------------------- layout
def _columns(self) -> int:
available = max(1, self.viewport().width() - 20)
return max(1, available // (CARD_WIDTH + 10))
def _relayout(self) -> None:
cols = self._columns()
for i, card in enumerate(self._cards):
self._grid.addWidget(card, i // cols, i % cols)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
if self._cards and self._columns() != self._grid.columnCount():
self._relayout()