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:
Jaroslav Beneš
2026-07-25 01:48:39 +02:00
parent f886125857
commit 16be4b4afa
8 changed files with 641 additions and 2 deletions
+23
View File
@@ -0,0 +1,23 @@
"""
Browse tab - channel and playlist browsing (placeholder, implemented next)
"""
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from ..utils.ytsage_localization import _
class BrowsePage(QWidget):
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
layout = QVBoxLayout(self)
label = QLabel(_("browse.coming_soon"))
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
def open_url(self, url: str) -> None:
pass
+274
View File
@@ -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()
+20
View File
@@ -0,0 +1,20 @@
"""
Feed tab - subscriptions feed (placeholder, implemented next)
"""
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from ..utils.ytsage_localization import _
class FeedPage(QWidget):
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
layout = QVBoxLayout(self)
label = QLabel(_("feed.coming_soon"))
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
+59 -2
View File
@@ -53,6 +53,7 @@ from .ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.
from .ytsage_gui_format_table import FormatTableMixin
from .ytsage_gui_video_info import VideoInfoMixin
from .ytsage_gui_analysis import AnalysisMixin
from .ytsage_smooth_tab_widget import SmoothTabWidget
from ..utils.ytsage_constants import (
ICON_PATH,
SOUND_PATH,
@@ -381,9 +382,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.setWindowTitle(f"{_('app.title')} {_('app.version', version=self.version)}")
self.setMinimumSize(900, 750)
# Main widget and layout
# Downloads page keeps the original single-page layout; the central
# widget becomes a tab shell built at the end of init_ui
main_widget = QWidget()
self.setCentralWidget(main_widget)
self.download_page = main_widget
layout = QVBoxLayout(main_widget)
layout.setSpacing(8)
layout.setContentsMargins(20, 20, 20, 20)
@@ -626,6 +628,61 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Disable analysis-dependent controls until video is analyzed
self.toggle_analysis_dependent_controls(enabled=False)
self._setup_main_tabs()
def _setup_main_tabs(self) -> None:
"""Wrap the pages in the watch-first tab shell (SageTube)."""
from .ytsage_gui_browse import BrowsePage
from .ytsage_gui_feed import FeedPage
from .ytsage_gui_player import PlayerPanel, create_player_panel
from .ytsage_gui_router import AppRouter
from .ytsage_gui_search import SearchPage
from .ytsage_gui_watch import WatchPage
self.router = AppRouter(self)
self.watch_page = WatchPage(self.router, self)
self.search_page = SearchPage(self.router, self)
self.feed_page = FeedPage(self.router, self)
self.browse_page = BrowsePage(self.router, self)
self.main_tabs = SmoothTabWidget(self)
self.main_tabs.addTab(self.watch_page, _("main_tabs.watch"))
self.main_tabs.addTab(self.search_page, _("main_tabs.search"))
self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"))
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"))
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"))
self.setCentralWidget(self.main_tabs)
self.router.playVideo.connect(self._route_play_video)
self.router.queueVideo.connect(self.watch_page.enqueue)
self.router.downloadVideo.connect(self._route_download_video)
self.router.openChannel.connect(self._route_open_channel)
self.router.openPlaylist.connect(self._route_open_playlist)
def _tab_index_of(self, page) -> int:
for i in range(self.main_tabs.stack.count()):
if self.main_tabs.stack.widget(i) is page:
return i
return 0
def _route_play_video(self, entry: dict) -> None:
self.main_tabs.set_current_index(self._tab_index_of(self.watch_page))
self.watch_page.play_entry(entry)
def _route_download_video(self, url: str) -> None:
self.main_tabs.set_current_index(self._tab_index_of(self.download_page))
self.url_input.setText(url)
self.analyze_url()
def _route_open_channel(self, url: str) -> None:
self.main_tabs.set_current_index(self._tab_index_of(self.browse_page))
self.browse_page.open_url(url)
def _route_open_playlist(self, url: str) -> None:
self.main_tabs.set_current_index(self._tab_index_of(self.browse_page))
self.browse_page.open_url(url)
def _on_url_text_changed(self, text: str) -> None:
"""Enable or disable the Analyze button based on URL input content."""
self.analyze_button.setEnabled(bool(text.strip()))
+19
View File
@@ -0,0 +1,19 @@
"""
AppRouter - cross-tab navigation signals
========================================
A tiny QObject signal hub connecting the watch-first pages (search, feed,
browse) with the player and the downloader tab. Pages emit; the main window
routes. Entry dicts are yt-dlp flat entries or the subset
{id, url, title, channel, channel_url, duration, thumbnail}.
"""
from PySide6.QtCore import QObject, Signal
class AppRouter(QObject):
playVideo = Signal(dict) # play immediately in the Watch tab
queueVideo = Signal(dict) # append to the play queue
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
openChannel = Signal(str) # open a channel URL in the Browse tab
openPlaylist = Signal(str) # open a playlist URL in the Browse tab
+91
View File
@@ -0,0 +1,91 @@
"""
Search tab - in-app YouTube search via yt-dlp (ytsearchN:)
"""
from typing import Any, Dict, List, Optional
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
from .ytsage_gui_cards import VideoCardGrid
from ..core.ytsage_client import YtdlpWorker
from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger
PAGE_SIZE = 24
class SearchPage(QWidget):
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._worker: Optional[YtdlpWorker] = None
self._query = ""
self._offset = 0
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
bar = QHBoxLayout()
self.query_input = QLineEdit()
self.query_input.setPlaceholderText(_("search.placeholder"))
self.query_input.returnPressed.connect(self.start_search)
self.query_input.setMinimumHeight(38)
bar.addWidget(self.query_input, stretch=1)
self.search_btn = QPushButton(_("search.button"))
self.search_btn.clicked.connect(self.start_search)
self.search_btn.setMinimumHeight(38)
bar.addWidget(self.search_btn)
layout.addLayout(bar)
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #9aa0a6; padding: 2px;")
layout.addWidget(self.status_label)
self.grid = VideoCardGrid(router, self)
self.grid.loadMoreRequested.connect(self.load_more)
layout.addWidget(self.grid, stretch=1)
# ---------------------------------------------------------------- search
def start_search(self) -> None:
query = self.query_input.text().strip()
if not query or self._worker is not None:
return
self._query = query
self._offset = 0
self.grid.clear()
self._fetch(append=False)
def load_more(self) -> None:
if self._worker is None and self._query:
self._offset += PAGE_SIZE
self._fetch(append=True)
def _fetch(self, append: bool) -> None:
self.status_label.setText(_("search.searching"))
self.search_btn.setEnabled(False)
query, offset = self._query, self._offset
self._worker = YtdlpWorker(lambda c: c.search(query, n=PAGE_SIZE, offset=offset))
self._worker.result.connect(lambda entries: self._on_results(entries, append))
self._worker.error.connect(self._on_error)
self._worker.finished.connect(self._on_finished)
self._worker.start()
def _on_results(self, entries: List[Dict[str, Any]], append: bool) -> None:
show_more = len(entries) >= PAGE_SIZE
if append:
self.grid.append_entries(entries, show_load_more=show_more)
else:
self.grid.set_entries(entries, show_load_more=show_more)
self.status_label.setText(_("search.results_count", count=self.grid.card_count()))
def _on_error(self, message: str) -> None:
logger.error(f"Search failed: {message}")
self.status_label.setText(_("search.failed", error=message[:200]))
def _on_finished(self) -> None:
self.search_btn.setEnabled(True)
if self._worker is not None:
self._worker.deleteLater()
self._worker = None
+125
View File
@@ -0,0 +1,125 @@
"""
Watch tab - player plus play queue
==================================
Hosts the embedded mpv PlayerPanel (or the libmpv-missing hint) and a simple
play queue. Entries arrive via AppRouter.playVideo / queueVideo.
"""
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
from .ytsage_gui_player import PlayerPanel, create_player_panel
from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger
class WatchPage(QWidget):
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._queue: List[Dict[str, Any]] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
splitter = QSplitter(Qt.Orientation.Horizontal, self)
layout.addWidget(splitter)
self.player = create_player_panel(self)
splitter.addWidget(self.player)
queue_panel = QWidget(self)
queue_layout = QVBoxLayout(queue_panel)
queue_layout.setContentsMargins(4, 0, 0, 0)
queue_header = QHBoxLayout()
queue_label = QLabel(_("player.queue"))
queue_label.setStyleSheet("font-weight: bold;")
queue_header.addWidget(queue_label)
queue_header.addStretch()
self.clear_queue_btn = QPushButton("")
self.clear_queue_btn.setFixedWidth(28)
self.clear_queue_btn.setToolTip(_("watch.clear_queue"))
self.clear_queue_btn.clicked.connect(self.clear_queue)
queue_header.addWidget(self.clear_queue_btn)
queue_layout.addLayout(queue_header)
self.queue_list = QListWidget()
self.queue_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
self.queue_list.itemDoubleClicked.connect(self._on_queue_item_activated)
self.queue_list.model().rowsMoved.connect(self._on_rows_moved)
queue_layout.addWidget(self.queue_list)
splitter.addWidget(queue_panel)
splitter.setStretchFactor(0, 4)
splitter.setStretchFactor(1, 1)
splitter.setSizes([900, 240])
if isinstance(self.player, PlayerPanel):
self.player.playbackEnded.connect(self._on_playback_ended)
# ------------------------------------------------------------ public API
def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
if isinstance(self.player, PlayerPanel):
self.player.play(entry, resume_pos=resume_pos)
else:
logger.warning("Play requested but libmpv is unavailable")
def enqueue(self, entry: Dict[str, Any]) -> None:
self._queue.append(dict(entry))
item = QListWidgetItem(entry.get("title") or entry.get("url") or "?")
item.setData(Qt.ItemDataRole.UserRole, dict(entry))
self.queue_list.addItem(item)
# Start playing right away when nothing is on and this is the first item
if isinstance(self.player, PlayerPanel) and not self.player.current_entry() and len(self._queue) == 1:
self._play_next_from_queue()
def clear_queue(self) -> None:
self._queue.clear()
self.queue_list.clear()
def queue_entries(self) -> List[Dict[str, Any]]:
return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())]
# --------------------------------------------------------------- internal
def _play_next_from_queue(self) -> None:
if self.queue_list.count() == 0:
return
item = self.queue_list.takeItem(0)
entry = item.data(Qt.ItemDataRole.UserRole)
if entry in self._queue:
self._queue.remove(entry)
self.play_entry(entry)
def _on_playback_ended(self, reason: str) -> None:
if reason in ("eof", "") and self.queue_list.count() > 0:
self._play_next_from_queue()
def _on_queue_item_activated(self, item: QListWidgetItem) -> None:
entry = item.data(Qt.ItemDataRole.UserRole)
row = self.queue_list.row(item)
self.queue_list.takeItem(row)
if entry in self._queue:
self._queue.remove(entry)
self.play_entry(entry)
def _on_rows_moved(self, *args) -> None:
self._queue = self.queue_entries()
def shutdown(self) -> None:
if isinstance(self.player, PlayerPanel):
self.player.shutdown()
+30
View File
@@ -648,5 +648,35 @@
"queue": "Queue",
"play": "Play",
"pause": "Pause"
},
"cards": {
"play": "▶ Play",
"queue": "+ Queue",
"download": "⬇",
"load_more": "Load more",
"empty": "Nothing here yet"
},
"main_tabs": {
"watch": "Watch",
"search": "Search",
"feed": "Feed",
"browse": "Browse",
"downloads": "Downloads"
},
"search": {
"placeholder": "Search YouTube...",
"button": "Search",
"searching": "Searching...",
"results_count": "{count} results",
"failed": "Search failed: {error}"
},
"watch": {
"clear_queue": "Clear queue"
},
"browse": {
"coming_soon": "Channel & playlist browsing coming soon"
},
"feed": {
"coming_soon": "Subscriptions feed coming soon"
}
}