""" 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] = [] #: video id -> card, so merge_entries can tell new from existing self._by_key: Dict[str, "VideoCard"] = {} self._columns_in_use = 0 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._by_key[self._key(entry)] = card self._relayout() self.load_more_btn.setVisible(show_load_more) self.empty_label.setVisible(not self._cards) @staticmethod def _key(entry: Dict[str, Any]) -> str: return str(entry.get("id") or entry.get("url") or id(entry)) def merge_entries(self, entries: List[Dict[str, Any]], prune: bool = False) -> None: """ Fold entries in, keeping cards that are already here. set_entries() destroys and rebuilds every card, which during a feed refresh happened once per channel: the grid flickered, the scroll position was lost each time, and every thumbnail was re-read from disk. Existing cards are left alone here, so only genuinely new videos cost anything. """ incoming = {self._key(e): e for e in entries} scroll = self.verticalScrollBar().value() self.setUpdatesEnabled(False) try: for key, entry in incoming.items(): if key in self._by_key: continue card = VideoCard(entry, self._router, self._grid_widget) self._cards.append(card) self._by_key[key] = card if prune: for key in [k for k in self._by_key if k not in incoming]: card = self._by_key.pop(key) if card in self._cards: self._cards.remove(card) self._grid.removeWidget(card) card.deleteLater() self._relayout() self.empty_label.setVisible(not self._cards) finally: self.setUpdatesEnabled(True) self.verticalScrollBar().setValue(scroll) def clear(self) -> None: for card in self._cards: self._grid.removeWidget(card) card.deleteLater() self._cards = [] self._by_key.clear() 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() # Drain first. addWidget on a card the layout already owns adds a # second item for it, so the layout grew an extra entry per card on # every append. takeAt detaches without deleting the widget. while self._grid.count(): self._grid.takeAt(0) for i, card in enumerate(self._cards): self._grid.addWidget(card, i // cols, i % cols) self._columns_in_use = cols def resizeEvent(self, event) -> None: super().resizeEvent(event) # Compared against what was actually laid out, not columnCount(): # QGridLayout never shrinks its column count, so that comparison # stayed true forever and relaid out on every resize event. if self._cards and self._columns() != self._columns_in_use: self._relayout()