Files
SageTube/ytsage/gui/ytsage_gui_browse.py
T
Homer 3aca43b372 Give the app icons, tooltips and a finished theme
There was no icon system at all. Transport buttons called
QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark
monochrome glyph -- painted onto the app's saturated red buttons at a fixed
36px with no text, that is the black square. Everything else called an icon
was an emoji baked into en.json, which is tofu wherever the emoji font is
missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument
to the new helper; that is the whole fix.

The SVGs are drawn here rather than vendored, which keeps a third-party
licence out of the tree, and they live in the module rather than as asset
files, which keeps them out of package-data and safe in a frozen build.

Tooltips were the other half: three widgets in the entire application had one
and nothing set an accessible name. Filling that in at the call sites would
have meant editing well over a hundred of them, most in upstream-owned files.
Instead one application-level event filter handles QEvent.Polish, which Qt
sends to every widget once before it is shown -- so it also reaches dialogs
built by upstream code, and survives the next merge. It maps placeholder
emoji to icons, fills empty tooltips from the button text, and logs icon-only
buttons that still have none so the gaps are findable.

StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing
else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips
and the horizontal scrollbar fell through to the platform style. EXTRA_QSS
covers them, in a fork-owned module appended at the one application site.
SmoothTabWidget names its frame "tabContent" with the comment "We draw border
on content instead" -- that rule existed only inside two dialogs, and now
exists for the main window too.

Two corrections to rules that were already there: the pressed style changed
the padding, shifting every label two pixels and clipping fixed-width icon
buttons, and checkboxes were fully rounded, which reads as a radio button
rather than an on/off toggle.

Verified by screenshot on the real display: tab bar, buttons, combo carets
and checkboxes all render as intended, and all 35 icons rasterise non-empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:58:38 +02:00

264 lines
10 KiB
Python

"""
Browse tab - channel and playlist browsing
==========================================
Paste (or route) a channel/playlist URL. Channels get Videos / Shorts / Live
sub-tabs backed by {channel_url}/videos|shorts|streams flat fetches with
-I range pagination; playlists get a single grid with Play all and a
Download deep-link into the Downloads tab.
The Subscribe button emits subscribeRequested; the Feed page owns the
subscription store and completes the wiring.
"""
import re
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTabWidget,
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
CHANNEL_URL_RE = re.compile(r"(youtube\.com/(@[\w.-]+|channel/|c/|user/))", re.IGNORECASE)
PLAYLIST_URL_RE = re.compile(r"(youtube\.com/playlist\?|[?&]list=)", re.IGNORECASE)
CHANNEL_TABS = [
("browse.tab_videos", "videos"),
("browse.tab_shorts", "shorts"),
("browse.tab_live", "streams"),
]
def _normalize_channel_base(url: str) -> str:
"""Strip a trailing /videos|/shorts|/streams|/featured segment."""
return re.sub(r"/(videos|shorts|streams|featured|playlists|community|about)/?(\?.*)?$", "", url.rstrip("/"))
class _EntriesSection(QWidget):
"""One grid fed by a flat-entries URL with pagination."""
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._worker: Optional[YtdlpWorker] = None
self._url: Optional[str] = None
self._offset = 0
self._loaded_once = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 4, 0, 0)
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)
def set_url(self, url: Optional[str]) -> None:
self._url = url
self._offset = 0
self._loaded_once = False
self.grid.clear()
self.status_label.setText("")
def ensure_loaded(self) -> None:
if not self._loaded_once and self._url and self._worker is None:
self._loaded_once = True
self._fetch(append=False)
def _load_more(self) -> None:
if self._worker is None and self._url:
self._offset += PAGE_SIZE
self._fetch(append=True)
def _fetch(self, append: bool) -> None:
self.status_label.setText(_("browse.loading"))
url, start, end = self._url, self._offset + 1, self._offset + PAGE_SIZE
self._worker = YtdlpWorker(lambda c: c.fetch_flat_entries(url, start=start, end=end), parent=self)
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(_("browse.entry_count", count=self.grid.card_count()))
def _on_error(self, message: str) -> None:
logger.error(f"Browse fetch failed: {message}")
self.status_label.setText(_("browse.failed", error=message[:200]))
def _on_finished(self) -> None:
if self._worker is not None:
self._worker.deleteLater()
self._worker = None
class BrowsePage(QWidget):
subscribeRequested = Signal(dict) # {channel_id?, title?, url}
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._current_url: Optional[str] = None
self._is_channel = False
self._channel_meta: Dict[str, Any] = {}
self._meta_worker: Optional[YtdlpWorker] = None
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
bar = QHBoxLayout()
self.url_input = QLineEdit()
self.url_input.setPlaceholderText(_("browse.placeholder"))
self.url_input.returnPressed.connect(self._open_from_input)
self.url_input.setMinimumHeight(38)
bar.addWidget(self.url_input, stretch=1)
self.open_btn = QPushButton(_("browse.open"))
self.open_btn.setToolTip(_("browse.open_tooltip"))
self.open_btn.setMinimumHeight(38)
self.open_btn.clicked.connect(self._open_from_input)
bar.addWidget(self.open_btn)
layout.addLayout(bar)
header = QHBoxLayout()
self.title_label = QLabel("")
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
header.addWidget(self.title_label, stretch=1)
self.subscribe_btn = QPushButton(_("browse.subscribe"))
self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip"))
self.subscribe_btn.setVisible(False)
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
header.addWidget(self.subscribe_btn)
self.playall_btn = QPushButton(_("browse.play_all"))
self.playall_btn.setToolTip(_("browse.play_all_tooltip"))
self.playall_btn.setVisible(False)
self.playall_btn.clicked.connect(self._on_play_all)
header.addWidget(self.playall_btn)
self.download_btn = QPushButton(_("browse.download_playlist"))
self.download_btn.setToolTip(_("browse.download_playlist_tooltip"))
self.download_btn.setVisible(False)
self.download_btn.clicked.connect(self._on_download_playlist)
header.addWidget(self.download_btn)
layout.addLayout(header)
self.channel_tabs = QTabWidget()
self._sections: List[_EntriesSection] = []
for label_key, _suffix in CHANNEL_TABS:
section = _EntriesSection(router, self)
self._sections.append(section)
self.channel_tabs.addTab(section, _(label_key))
self.channel_tabs.currentChanged.connect(self._on_channel_tab_changed)
self.channel_tabs.setVisible(False)
layout.addWidget(self.channel_tabs, stretch=1)
self.playlist_section = _EntriesSection(router, self)
self.playlist_section.setVisible(False)
layout.addWidget(self.playlist_section, stretch=1)
self.hint_label = QLabel(_("browse.hint"))
self.hint_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
layout.addWidget(self.hint_label, stretch=1)
# ------------------------------------------------------------ public API
def open_url(self, url: str) -> None:
url = url.strip()
if not url:
return
self.url_input.setText(url)
self._current_url = url
self._is_channel = bool(CHANNEL_URL_RE.search(url)) and not PLAYLIST_URL_RE.search(url)
self.hint_label.setVisible(False)
self._channel_meta = {}
self.title_label.setText(url)
if self._is_channel:
base = _normalize_channel_base(url)
self._current_url = base
self.playlist_section.setVisible(False)
self.channel_tabs.setVisible(True)
self.subscribe_btn.setVisible(True)
self.playall_btn.setVisible(False)
self.download_btn.setVisible(False)
for section, (_k, suffix) in zip(self._sections, CHANNEL_TABS):
section.set_url(f"{base}/{suffix}")
self._sections[self.channel_tabs.currentIndex()].ensure_loaded()
self._fetch_channel_meta(base)
else:
self.channel_tabs.setVisible(False)
self.subscribe_btn.setVisible(False)
self.playlist_section.setVisible(True)
self.playall_btn.setVisible(True)
self.download_btn.setVisible(True)
self.playlist_section.set_url(url)
self.playlist_section.ensure_loaded()
# --------------------------------------------------------------- internal
def _open_from_input(self) -> None:
self.open_url(self.url_input.text())
def _on_channel_tab_changed(self, index: int) -> None:
if 0 <= index < len(self._sections):
self._sections[index].ensure_loaded()
def _fetch_channel_meta(self, base_url: str) -> None:
"""Fetch channel title/id from the videos tab head (cheap, 1 entry)."""
if self._meta_worker is not None:
return
self._meta_worker = YtdlpWorker(lambda c: c.fetch_flat_info(f"{base_url}/videos", items="1:1"), parent=self)
self._meta_worker.result.connect(self._on_channel_meta)
self._meta_worker.error.connect(lambda m: logger.debug(f"Channel meta fetch failed: {m}"))
self._meta_worker.finished.connect(self._on_meta_finished)
self._meta_worker.start()
def _on_channel_meta(self, info: Dict[str, Any]) -> None:
self._channel_meta = {
"channel_id": info.get("channel_id") or info.get("uploader_id"),
"title": info.get("channel") or info.get("uploader") or info.get("title"),
"url": self._current_url,
"avatar_url": None,
}
if self._channel_meta.get("title"):
self.title_label.setText(self._channel_meta["title"])
def _on_meta_finished(self) -> None:
if self._meta_worker is not None:
self._meta_worker.deleteLater()
self._meta_worker = None
def _on_subscribe_clicked(self) -> None:
meta = dict(self._channel_meta) if self._channel_meta.get("url") else {"url": self._current_url}
if not meta.get("title"):
meta["title"] = self.title_label.text()
self.subscribeRequested.emit(meta)
def _on_play_all(self) -> None:
for entry in [c.entry for c in self.playlist_section.grid._cards]:
e = dict(entry)
if not e.get("url") and e.get("id"):
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
self._router.queueVideo.emit(e)
def _on_download_playlist(self) -> None:
if self._current_url:
self._router.downloadVideo.emit(self._current_url)