Files
SageTube/ytsage/gui/ytsage_gui_watch.py
T
Jaroslav Beneš a8ebe89cc1 Wire watch history, resume positions and persistent queue
WatchPage now records every played video in watch_history, saves the
playback position every 5 seconds and on stop/end (completed at >=95%),
and seeks back on replay when player.resume is "auto" - resume kicks in
between 30 seconds and 95% of the duration. The play queue persists
across restarts and reorders/removals write through immediately; the
main window's closeEvent flushes position and queue and releases libmpv.

PlayerPanel gains an automatic stall-retry: YouTube's CDN intermittently
serves stalled streams to non-browser clients (reproduced ~1/3 of
attempts headless with identical code), and a reload re-resolves onto a
healthy node - two retries after 25s of no playback, then a user-facing
error.

ytsage_constants now prepends the managed-binaries dir to PATH so
yt-dlp subprocesses (including mpv's ytdl_hook) can find the managed
Deno runtime - previously nothing exported APP_BIN_DIR, so the deno
integration silently never worked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 02:42:38 +02:00

189 lines
7.0 KiB
Python

"""
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]] = []
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)
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)
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())]
# --------------------------------------------------------------- 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)
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()