From 16be4b4afabf2e90342c4b132a484e4a2ec574ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 25 Jul 2026 01:48:39 +0200 Subject: [PATCH] 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 --- ytsage/gui/ytsage_gui_browse.py | 23 +++ ytsage/gui/ytsage_gui_cards.py | 274 ++++++++++++++++++++++++++++++++ ytsage/gui/ytsage_gui_feed.py | 20 +++ ytsage/gui/ytsage_gui_main.py | 61 ++++++- ytsage/gui/ytsage_gui_router.py | 19 +++ ytsage/gui/ytsage_gui_search.py | 91 +++++++++++ ytsage/gui/ytsage_gui_watch.py | 125 +++++++++++++++ ytsage/languages/en.json | 30 ++++ 8 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 ytsage/gui/ytsage_gui_browse.py create mode 100644 ytsage/gui/ytsage_gui_cards.py create mode 100644 ytsage/gui/ytsage_gui_feed.py create mode 100644 ytsage/gui/ytsage_gui_router.py create mode 100644 ytsage/gui/ytsage_gui_search.py create mode 100644 ytsage/gui/ytsage_gui_watch.py diff --git a/ytsage/gui/ytsage_gui_browse.py b/ytsage/gui/ytsage_gui_browse.py new file mode 100644 index 0000000..2847c71 --- /dev/null +++ b/ytsage/gui/ytsage_gui_browse.py @@ -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 diff --git a/ytsage/gui/ytsage_gui_cards.py b/ytsage/gui/ytsage_gui_cards.py new file mode 100644 index 0000000..fcbd3a6 --- /dev/null +++ b/ytsage/gui/ytsage_gui_cards.py @@ -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() diff --git a/ytsage/gui/ytsage_gui_feed.py b/ytsage/gui/ytsage_gui_feed.py new file mode 100644 index 0000000..2d24605 --- /dev/null +++ b/ytsage/gui/ytsage_gui_feed.py @@ -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) diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 1da5f0b..97857e6 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -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())) diff --git a/ytsage/gui/ytsage_gui_router.py b/ytsage/gui/ytsage_gui_router.py new file mode 100644 index 0000000..a43f38b --- /dev/null +++ b/ytsage/gui/ytsage_gui_router.py @@ -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 diff --git a/ytsage/gui/ytsage_gui_search.py b/ytsage/gui/ytsage_gui_search.py new file mode 100644 index 0000000..adda9e6 --- /dev/null +++ b/ytsage/gui/ytsage_gui_search.py @@ -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 diff --git a/ytsage/gui/ytsage_gui_watch.py b/ytsage/gui/ytsage_gui_watch.py new file mode 100644 index 0000000..e57ad55 --- /dev/null +++ b/ytsage/gui/ytsage_gui_watch.py @@ -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() diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 7bcd7bc..a60c127 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -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" } } \ No newline at end of file