""" 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, QTimer from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget, ) from .ytsage_gui_player import PlayerPanel, create_player_panel from ..utils.ytsage_config_manager import ConfigManager from ..utils.ytsage_library_manager import LibraryManager from ..utils.ytsage_localization import _ from ..utils.ytsage_logger import logger POSITION_SAVE_INTERVAL_MS = 5000 class WatchPage(QWidget): def __init__(self, router, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._router = router self._queue: List[Dict[str, Any]] = [] # What has already played this session, so "previous" has somewhere # to go. Not persisted: it is session history, not the queue. self._played: 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) # Kept as an attribute: FullscreenController hides it rather than # reparenting the player, which is what used to lose the GL context. self.queue_panel = 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) self._duration = 0.0 self.player.durationChanged.connect(self._on_duration_changed) self._position_timer = QTimer(self) self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS) self._position_timer.timeout.connect(self._save_position) self.player.nowPlayingChanged.connect(self._on_now_playing) # The panel asks; the queue lives here, so the queue answers. self.player.nextRequested.connect(self.play_next) self.player.previousRequested.connect(self.play_previous) self._restore_queue() # ------------------------------------------------------------ public API def play_entry(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None: if isinstance(self.player, PlayerPanel): if resume_pos <= 0 and (ConfigManager.get("player.resume") or "auto") == "auto": video_id = entry.get("id") or entry.get("url") if video_id: resume_pos = LibraryManager.get_resume_position(str(video_id)) 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) self._persist_queue() # 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() self._persist_queue() 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())] def play_next(self) -> None: """Next in the queue. Bound to N and the player's next button.""" self._play_next_from_queue() def play_previous(self) -> None: """ Back to what was playing before. Restarts the current item first if it is more than a few seconds in, which is what every other player does with a "previous" press. """ if not isinstance(self.player, PlayerPanel): return if self.player.current_position() > 5.0: self.player.seek_absolute(0.0) return # _played ends with what is playing now, so going back needs two. if len(self._played) < 2: return current = self._played.pop() previous = self._played[-1] # Put the current item back at the head of the queue rather than # dropping it on the floor. if current: item = QListWidgetItem(current.get("title") or current.get("url") or "?") item.setData(Qt.ItemDataRole.UserRole, dict(current)) self.queue_list.insertItem(0, item) self._queue.insert(0, dict(current)) self._persist_queue() # play_entry re-pushes `previous`, so drop it here to avoid a double. self._played.pop() self.play_entry(previous) # --------------------------------------------------------------- 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._persist_queue() self.play_entry(entry) def _on_playback_ended(self, reason: str) -> None: self._save_position(final=True) 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._persist_queue() self.play_entry(entry) def _on_rows_moved(self, *args) -> None: self._queue = self.queue_entries() self._persist_queue() # ------------------------------------------------- history & persistence def _on_now_playing(self, entry: Dict[str, Any]) -> None: LibraryManager.upsert_watch(entry) # Record what we were on before this, so "previous" can return to it. # The retry path replays the same entry; don't stack duplicates. if self._played[-1:] != [entry] and entry: self._played.append(dict(entry)) del self._played[:-50] self._duration = 0.0 self._position_timer.start() def _on_duration_changed(self, duration: float) -> None: self._duration = duration def _save_position(self, final: bool = False) -> None: if not isinstance(self.player, PlayerPanel): return entry = self.player.current_entry() video_id = entry.get("id") or entry.get("url") if not video_id: return pos = self.player.current_position() if pos > 0: LibraryManager.update_position(str(video_id), pos, self._duration or entry.get("duration")) if final: self._position_timer.stop() def _persist_queue(self) -> None: try: LibraryManager.save_queue(self.queue_entries()) except Exception as e: logger.debug(f"Could not persist queue: {e}") def _restore_queue(self) -> None: try: for entry in LibraryManager.load_queue(): self._queue.append(entry) item = QListWidgetItem(entry.get("title") or entry.get("url") or "?") item.setData(Qt.ItemDataRole.UserRole, entry) self.queue_list.addItem(item) except Exception as e: logger.debug(f"Could not restore queue: {e}") def shutdown(self) -> None: if isinstance(self.player, PlayerPanel): self._save_position(final=True) self._persist_queue() self.player.shutdown()