Files
SageTube/ytsage/gui/ytsage_gui_cards.py
T
Jaroslav Beneš 16be4b4afa Restructure main window into a watch-first tab shell
The single-page downloader layout becomes the Downloads tab of a
SmoothTabWidget with Watch / Search / Feed / Browse / Downloads pages.
The init_ui edit is deliberately small (the old central widget is now
self.download_page); all new behavior lives in new modules:

- ytsage_gui_router.py: AppRouter signal hub (playVideo, queueVideo,
  downloadVideo, openChannel, openPlaylist). A card's Download button
  deep-links into the Downloads tab with the URL prefilled and analysis
  started automatically.
- ytsage_gui_cards.py: VideoCard (thumbnail with disk cache under
  APP_THUMBNAILS_DIR, title/channel/duration, Play/Queue/Download
  actions, double-click to play) and VideoCardGrid (responsive grid,
  Load more pagination).
- ytsage_gui_watch.py: WatchPage hosting the mpv PlayerPanel and a
  drag-reorderable play queue with auto-advance on end of file.
- ytsage_gui_search.py: SearchPage running YtdlpClient.search off the
  GUI thread with Load more pagination.
- Browse and Feed pages are placeholders, implemented next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:48:39 +02:00

275 lines
9.6 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.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()