import logging import subprocess import threading from pathlib import Path from PySide6.QtCore import Signal, QObject, QTimer from PySide6.QtWidgets import ( QDialog, QLabel, QPushButton, QProgressDialog, QVBoxLayout, QFrame, QHBoxLayout, QMessageBox, QInputDialog, QScrollArea ) from constant import PROGRAM_DIR from pl_lib.messagebox import ask_yes_no_with_plain_text from utils import find_mpv_path, download_file from widgets import VideoItem from yt_lib import resolve_video_page_url logger = logging.getLogger("PlayerDialog") logger.setLevel(logging.INFO) class DownloadProgressDialog(QProgressDialog): tasks_added = Signal(int) task_finished = Signal(str, bool) producer_finished = Signal() def __init__(self, title: str, total: int, parent=None, producers: int = 0): super().__init__("Preparing downloads...", "Hide", 0, total, parent) self.completed_count = 0 self.failed_count = 0 self.failed_videos = [] self.remaining_producers = producers self.summary_shown = False self.setWindowTitle(title) self.setFixedWidth(540) self.setMinimumDuration(0) self.setAutoClose(False) self.setAutoReset(False) if total == 0: self.setRange(0, 0) else: self.setValue(0) self.tasks_added.connect(self.add_tasks) self.task_finished.connect(self.update_task) self.producer_finished.connect(self.finish_producer) status_label = self.findChild(QLabel) if status_label is not None: status_label.setWordWrap(True) status_label.setMaximumWidth(500) self.show() def add_tasks(self, count: int): if self.maximum() == 0: self.setRange(0, count) else: self.setMaximum(self.maximum() + count) self.setValue(self.completed_count) self.setLabelText(f"Downloaded {self.completed_count}/{self.maximum()} videos") def finish_future(self, result, video=None, playlist_name=None): video_title = (video or {}).get("title") or "Unknown video" video_id = (video or {}).get("id") or "unknown" display_name = f"{video_title} [{video_id}]" if playlist_name: display_name = f"{playlist_name} / {display_name}" if isinstance(result, Exception): self.task_finished.emit(f"{display_name}\n{result}", False) return try: path = result.result() except Exception as error: self.task_finished.emit(f"{display_name}\n{error}", False) return self.task_finished.emit(path.name, True) def update_task(self, message: str, succeeded: bool): self.completed_count += 1 if not succeeded: self.failed_count += 1 self.failed_videos.append(message) self.setValue(self.completed_count) status = f"Downloaded {self.completed_count}/{self.maximum()} videos" if self.failed_count: status += f" ({self.failed_count} failed)" self.setLabelText(f"{status}\n{message}") self.show_failure_summary_if_finished() def finish_producer(self): self.remaining_producers = max(0, self.remaining_producers - 1) self.show_failure_summary_if_finished() def show_failure_summary_if_finished(self): if self.summary_shown or self.remaining_producers > 0: return if self.maximum() == 0 or self.completed_count < self.maximum(): return self.summary_shown = True if not self.failed_videos: return def show_summary(): should_close = ask_yes_no_with_plain_text( self.parentWidget(), "Download failures", f"{len(self.failed_videos)} video(s) could not be downloaded. " "Close the download progress window?", "\n\n".join(self.failed_videos), ) if should_close: self.hide() QTimer.singleShot(0, show_summary) class PlayerDialog(QDialog): status_changed = Signal(str) def __init__(self, app, video: dict, /, parent): QDialog.__init__(self, parent) self.app = app self.parent = parent self.video = video self.video_url = resolve_video_page_url(video) self.process = None self.setWindowTitle(video.get("title", "Video")) self.resize(440, 190) self.status_changed.connect(self.set_status) self.layout = QVBoxLayout(self) self.layout.setContentsMargins(16, 16, 16, 16) self.layout.setSpacing(10) self.title_label = QLabel(video.get("title", "Video"), self) self.title_label.setWordWrap(True) self.status_label = QLabel("Launching mpv...", self) self.url_label = QLabel(self.video_url or "", self) self.url_label.setWordWrap(True) self.close_btn = QPushButton("Close Player", self) self.close_btn.clicked.connect(self.close) self.layout.addWidget(self.title_label) self.layout.addWidget(self.status_label) self.layout.addWidget(self.url_label) self.layout.addWidget(self.close_btn) self.setStyleSheet(""" QDialog { background-color: #202020; } QLabel { background-color: transparent; color: #f0f0f0; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } """) threading.Thread(target=self.run_mpv, daemon=True).start() def set_status(self, message: str): self.status_label.setText(message) def run_mpv(self): mpv_path = find_mpv_path(PROGRAM_DIR) if not mpv_path: self.status_changed.emit("mpv not found in bin/ or PATH.") return try: self.process = subprocess.Popen( [ mpv_path, # "--force-window=immediate", "--focus-on=open", self.video_url, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except Exception as e: logger.error("Unable to start mpv: %s", e) self.status_changed.emit(f"Unable to start mpv: {e}") return self.app.mpv_processes[self] = self.process self.status_changed.emit("Playing in mpv...") self.process.wait() self.app.mpv_processes.pop(self, None) self.status_changed.emit("Playback finished.") def closeEvent(self, event): process = self.app.mpv_processes.pop(self, None) if process and process.poll() is None: process.terminate() event.accept() class SwitchProfileDialog(QDialog): profile_changed = Signal(str) def __init__(self, app, /, parent): QDialog.__init__(self, parent) self.app = app self.setWindowTitle("Switch Profile") self.resize(420, 420) # Layout self.layout = QVBoxLayout(self) self.layout.setContentsMargins(16, 16, 16, 16) self.layout.setSpacing(12) # Items self.current_profile_label = QLabel(f"Current: {self.app.get_active_profile_name()}") self.current_profile_label.setWordWrap(True) self.current_profile_label.setObjectName("currentProfile") # Scroll are and frame (for profile item) self.scroll_area = QScrollArea(self) self.scroll_area.setWidgetResizable(True) self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.list_frame = QFrame(self.scroll_area) self.list_frame.setObjectName("profileList") self.list_layout = QVBoxLayout(self.list_frame) self.list_layout.setContentsMargins(8, 8, 8, 8) self.list_layout.setSpacing(8) self.scroll_area.setWidget(self.list_frame) self.create_profile_btn = QPushButton("Create Profile") self.create_profile_btn.setMinimumHeight(34) self.create_profile_btn.clicked.connect(self.create_profile) self.layout.addWidget(self.current_profile_label) self.layout.addWidget(self.scroll_area, 1) self.layout.addWidget(self.create_profile_btn) self.render_profiles() self.setStyleSheet(""" QDialog { background-color: #202020; } QScrollArea, #profileList { background-color: #242424; } #currentProfile { background-color: transparent; color: #f0f0f0; font-weight: 600; padding-bottom: 4px; } QFrame#profileRow { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } QLabel { background-color: transparent; color: #f0f0f0; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } QPushButton:disabled { color: #999999; background-color: #303030; } """) def clear_profiles(self): while self.list_layout.count(): item = self.list_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() class ProfileRow(QFrame): def __init__(self, parent, profile_name: str, active_profile: str, switch_profile, delete_profile): QFrame.__init__(self, parent) self.setObjectName("profileRow") self.row_layout = QHBoxLayout(self) self.row_layout.setContentsMargins(10, 8, 10, 8) self.row_layout.setSpacing(8) self.name_label = QLabel(profile_name) self.name_label.setWordWrap(True) self.switch_btn = QPushButton("Current" if profile_name == active_profile else "Switch") self.switch_btn.setMinimumWidth(88) self.switch_btn.setEnabled(profile_name != active_profile) self.switch_btn.clicked.connect(lambda _checked=False, name=profile_name: switch_profile(name)) self.delete_btn = QPushButton("Delete") self.delete_btn.setMinimumWidth(88) self.delete_btn.setDisabled(profile_name == "Default") self.delete_btn.clicked.connect(lambda _checked=False, name=profile_name: delete_profile(name)) self.row_layout.addWidget(self.name_label, 1) self.row_layout.addWidget(self.switch_btn) self.row_layout.addWidget(self.delete_btn) def render_profiles(self): self.clear_profiles() meta = self.app.load_profiles_meta() active_profile = meta["active_profile"] for profile_name in meta["profiles"]: row = self.ProfileRow(self, profile_name, active_profile, self.switch_profile, self.delete_profile) self.list_layout.addWidget(row) self.list_layout.addStretch(1) def switch_profile(self, profile_name: str): self.profile_changed.emit(profile_name) self.accept() def delete_profile(self, profile_name: str): meta = self.app.load_profiles_meta() if profile_name == meta["active_profile"]: QMessageBox.critical(self, "Error", "Cannot delete the active profile.") return if profile_name == self.app.DEFAULT_PROFILE_NAME: QMessageBox.critical(self, "Error", "Cannot delete the default profile.") return reply = QMessageBox.question( self, "Delete Profile", f"Delete profile '{profile_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return meta["profiles"] = [ name for name in meta["profiles"] if name != profile_name ] self.app.save_profiles_meta(meta) self.render_profiles() def create_profile(self): profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:") if not ok: return profile_name = profile_name.strip() if not profile_name: QMessageBox.critical(self, "Error", "Profile name cannot be empty.") return self.app.ensure_profile_exists(profile_name) self.render_profiles() class PlaylistWindow(QDialog): class LoadSignals(QObject): playlist_ready = Signal(dict) error = Signal(str) download_status = Signal(str) def __init__(self, app, playlist: dict, /, parent=None, open_player_callback=None): QDialog.__init__(self, parent) self.app = app self.playlist = playlist self.setWindowTitle(playlist.get("title", "Playlist")) self.resize(760, 640) self.signals = self.LoadSignals() self.signals.playlist_ready.connect(self.render_videos) self.signals.error.connect(self.show_error) self.open_player_callback = open_player_callback # Layout self.layout = QVBoxLayout(self) self.layout.setContentsMargins(16, 16, 16, 16) self.layout.setSpacing(12) self.header_label = QLabel(playlist.get("title", "Playlist"), self) self.header_label.setObjectName("playlistTitle") self.header_label.setWordWrap(True) self.info_label = QLabel("Loading playlist videos...", self) self.info_label.setObjectName("playlistInfo") self.signals.download_status.connect(self.info_label.setText) self.content_layout = QHBoxLayout() self.content_layout.setSpacing(12) # Scroll area and frame for video items self.scroll_area = QScrollArea(self) self.scroll_area.setWidgetResizable(True) self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) self.list_frame = QFrame(self.scroll_area) self.list_frame.setObjectName("playlistFrame") self.list_layout = QVBoxLayout(self.list_frame) self.list_layout.setContentsMargins(8, 8, 8, 8) self.list_layout.setSpacing(8) self.scroll_area.setWidget(self.list_frame) self.control_panel = QFrame(self) self.control_panel.setObjectName("playlistControls") self.control_panel.setFixedWidth(150) self.control_layout = QVBoxLayout(self.control_panel) self.control_layout.setContentsMargins(12, 12, 12, 12) self.control_layout.setSpacing(10) self.reload_playlist_btn = QPushButton("Reload") self.save_playlist_btn = QPushButton("Save Playlist") self.reload_playlist_btn.setMinimumHeight(34) self.reload_playlist_btn.clicked.connect(self.render_playlist) self.save_playlist_btn.clicked.connect(self.save_playlist) self.control_layout.addWidget(self.reload_playlist_btn) self.control_layout.addWidget(self.save_playlist_btn) self.control_layout.addStretch(1) self.content_layout.addWidget(self.scroll_area, 1) self.content_layout.addWidget(self.control_panel) self.layout.addWidget(self.header_label) self.layout.addWidget(self.info_label) self.layout.addLayout(self.content_layout, 1) self.render_playlist() self.setStyleSheet(""" QDialog { background-color: #202020; } QScrollArea, #playlistFrame { background-color: #242424; } #playlistTitle { color: #f0f0f0; font-size: 20px; font-weight: 700; } #playlistInfo { color: #a8a8a8; } #playlistControls { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } QLabel { background-color: transparent; color: #f0f0f0; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } QPushButton:disabled { color: #999999; background-color: #303030; } """) def render_playlist(self): self.reload_playlist_btn.setEnabled(False) self.info_label.setText("Loading playlist videos...") self.app.main_window.clear_layout(self.list_layout) threading.Thread(target=self.load_playlist_worker, daemon=True).start() def load_playlist_worker(self): try: playlist_data = self.app.fetch_playlist_videos(self.playlist) except Exception as e: logger.error("Unable to load playlist videos: %s", e) self.signals.error.emit(str(e)) return if playlist_data is None: playlist_data = {"entries": []} self.signals.playlist_ready.emit(playlist_data) def render_videos(self, playlist_data: dict): self.playlist_data = playlist_data self.app.main_window.clear_layout(self.list_layout) self.reload_playlist_btn.setEnabled(True) entries = [entry for entry in playlist_data.get("entries", []) if entry is not None] self.info_label.setText(f"{len(entries)} videos") if not entries: self.list_layout.addWidget(QLabel("No videos found.", self.list_frame)) self.list_layout.addStretch(1) return for index, video in enumerate(entries, start=1): thumbnail_path = self.get_video_thumbnail_path(video) item = VideoItem( self.list_frame, video, index, thumbnail_path, thumbnail_size=self.app.THUMBNAIL_SIZE, download_video_callback=lambda _checked=False, current=video: self.download_video(current), ) item.clicked.connect(self.open_player_callback) self.list_layout.addWidget(item) self.list_layout.addStretch(1) def save_playlist(self): data = getattr(self, "playlist_data", None) total = len([entry for entry in data.get("entries", []) if entry]) if data else 0 self.progress_dialog = DownloadProgressDialog( "Save Playlist", total, self.app.main_window, producers=1 ) queued_callback = None if data else self.progress_dialog.tasks_added.emit self.app.download_playlist( self.playlist, data, self.progress_dialog.finish_future, queued_callback, self.progress_dialog.producer_finished.emit, ) def download_video(self, video: dict): self.progress_dialog = DownloadProgressDialog("Download Video", 1, self.app.main_window) self.app.download_video(video, callback=self.progress_dialog.finish_future) def on_download_done(self, result, video=None, playlist_name=None): if isinstance(result, Exception): self.signals.error.emit(f"Download failed: {result}") return try: path = result.result() except Exception as error: self.signals.error.emit(f"Download failed: {error}") return self.signals.download_status.emit(f"Downloaded: {path.name}") def show_error(self, message: str): self.reload_playlist_btn.setEnabled(True) self.info_label.setText("Unable to load playlist.") QMessageBox.critical(self, "Error", f"Unable to load playlist: {message}") def get_video_thumbnail_path(self, video: dict): thumbnails = video.get("thumbnails") or [] if not thumbnails: return None thumbnail = thumbnails[0] thumbnail_url = thumbnail.get("url") if not thumbnail_url: return None thumbnail_resolution = thumbnail.get("resolution", "unknown") video_id = video.get("id") or "unknown" thumbnail_path = Path(self.app.temp_path, "thumbnails", f"thumbnail_{video_id}_{thumbnail_resolution}.jpg") if thumbnail_path.exists(): return thumbnail_path if download_file(thumbnail_url, thumbnail_path): return thumbnail_path return None