""" 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.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry())) actions.addWidget(self.play_btn) self.queue_btn = QPushButton(_("cards.queue")) 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.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.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()