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>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user